Advanced examples

Patterns for fitting rational-release around real projects: mirrored changelogs, attached artefacts, gated pre-tasks, JSR / npm publish triggers, and using the CLI directly outside the workflows.

On this page
  1. Pre-tasks: gating the release PR
  2. Mirroring the changelog into a docs site
  3. Adding generated files to the release commit
  4. Attaching artefacts to the GitHub Release
  5. Triggering JSR / npm publish from cut-release
  6. Non-default manifest paths
  7. Using the CLI standalone
  8. Pinning to an exact version

Pre-tasks: gating the release PR

pre-tasks is a newline-separated shell script that runs before the release PR is generated. If any line exits non-zero, the workflow fails and the release PR is not opened or updated. Use it as your release gate.

jobs:
  prepare:
    uses: sigmadigitalza/rational-release/.github/workflows/prepare-release.yml@v1
    with:
      pre-tasks: |
        deno fmt --check
        deno lint
        deno check src/mod.ts
        deno test --allow-read --allow-net

If you have a heavier integration suite, run it here too — the worst case is the release PR doesn’t open until the suite is green.

Mirroring the changelog into a docs site

Many projects keep a docs site that wants the changelog rendered alongside other pages. mirror-paths takes src:dst pairs and copies after changelog generation, so the doc page tracks the source of truth on every release-PR update.

with:
  mirror-paths: |
    CHANGELOG.md:docs/changelog.md
    CHANGELOG.md:website/src/pages/changelog.md

Adding generated files to the release commit

If your pre-tasks regenerates a search index, an OpenAPI spec, or a bundled CSS file, those files need to be part of the prepare commit so they ship with the release. List the paths in commit-paths:

with:
  pre-tasks: |
    deno task build      # writes ui/search-index.json and dist/openapi.json
  commit-paths: |
    ui/search-index.json
    dist/openapi.json

The version bump in the manifest, the changelog, and any mirrored paths are always staged automatically. commit-paths is purely additive.

Attaching artefacts to the GitHub Release

cut-release can run a build step after tagging and attach the result as release-asset downloads. Useful for binary distributions, source tarballs, or anything you’d normally upload by hand.

jobs:
  cut:
    uses: sigmadigitalza/rational-release/.github/workflows/cut-release.yml@v1
    with:
      artefact-task: deno task package
      artefact-paths: dist/*.tar.gz dist/checksums.txt

artefact-paths is a glob list. Anything matched is uploaded to the GitHub Release alongside the auto-generated source archives.

Triggering JSR / npm publish from cut-release

Tags pushed by cut-release use the default GITHUB_TOKEN. GitHub intentionally does not fire downstream push workflows from tokens-pushed events — loop prevention. So a publish workflow listening on push: tags: [v*] will never run.

The fix is workflow_run — allowed to fire from the same token-pushed event. workflow_run jobs run in a privileged context (they have repo secrets and, for JSR, id-token: write), so we don’t blindly check out the triggering workflow’s head_sha — CodeQL flags that as actions/untrusted-checkout. Instead, derive the version from the triggering branch (release/vX.Y.Z), check out the freshly-pushed tag, and verify the tag matches the manifest before publishing:

name: Publish to JSR
on:
  workflow_run:
    workflows: ["Cut Release"]    # must match name: in cut-release.yml exactly
    types: [completed]
permissions:
  contents: read
  id-token: write
jobs:
  publish:
    if: >-
      github.event.workflow_run.conclusion == 'success' &&
      startsWith(github.event.workflow_run.head_branch, 'release/v')
    runs-on: ubuntu-latest
    steps:
      - id: ver
        env: { HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} }
        run: |
          set -euo pipefail
          version="${HEAD_BRANCH#release/v}"
          [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::bad branch ${HEAD_BRANCH}"; exit 1; }
          echo "tag=v${version}" >> "$GITHUB_OUTPUT"
      - uses: actions/checkout@v4
        with: { ref: refs/tags/${{ steps.ver.outputs.tag }} }
      - uses: denoland/setup-deno@v2
        with: { deno-version: v2.x }
      - env: { TAG: ${{ steps.ver.outputs.tag }} }
        run: |
          set -euo pipefail
          v=$(deno eval --quiet --allow-read=deno.json 'console.log(JSON.parse(Deno.readTextFileSync("deno.json")).version)')
          [ "v${v}" = "$TAG" ] || { echo "::error::tag $TAG != manifest v${v}"; exit 1; }
      - run: deno publish

For npm, swap the last three steps for:

      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          registry-url: "https://registry.npmjs.org"
      - env: { TAG: ${{ steps.ver.outputs.tag }} }
        run: |
          v=$(node -p "require('./package.json').version")
          [ "v${v}" = "$TAG" ] || { echo "::error::tag $TAG != manifest v${v}"; exit 1; }
      - run: npm publish --provenance --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
JSR setup. Before the first publish, create the package on jsr.io, link it to the GitHub repo (so OIDC trust is set up), and confirm the version in your manifest matches the tag you’re cutting. The first run is the only one where you’ll touch JSR’s UI.

Non-default manifest paths

Default is deno.json with $.version. Override either:

with:
  manifest-path: package.json
  manifest-jsonpath: $.version     # default; shown for clarity

manifest-jsonpath takes a dot-path: $.version, $.workspaces.api.version, etc. Array indexing and filters are not supported — the version field for any single package is always at a dotted-path location in the wild.

Using the CLI standalone

The CLI underneath the workflows ships on JSR and uses only node:* standard-library imports, so it runs on Deno, Node, and Bun unchanged. Useful for one-off tasks — computing the next version locally, extracting a single changelog section, or scripting outside GitHub Actions.

# Compute the next version from current commits
git log v1.2.3..HEAD --pretty=%s | \
  deno run -A jsr:@sigmadigitalza/rational-release \
    next-version deno.json

# Extract a single release’s notes
deno run -A jsr:@sigmadigitalza/rational-release \
  extract-section CHANGELOG.md 1.2.3

The full subcommand list:

SubcommandPurpose
next-version Read the current version from the manifest, walk commit subjects from stdin (or --commits-file), print the next semver.
read-version Print the current version from the manifest.
set-version Update the version field in the manifest, preserving indentation and trailing newline.
changelog-generate Rewrite the [Unreleased] section from a JSON list of merged PRs. Pass --bootstrap to create a Keep-a-Changelog skeleton if missing.
changelog-finalise Promote [Unreleased] to [X.Y.Z] - date and prepend a fresh empty block.
extract-section Print the body of a single [X.Y.Z] section to stdout. Useful for release-notes bodies.

Cloud agents and JSR

If you run releases from a cloud agent that operates behind a network allowlist (GitHub-hosted Copilot agents, sandboxed CI runners), make sure the agent can reach both of these hosts:

For Copilot specifically, add both URLs in Settings → Copilot → Coding agent → Custom allowlist on the consumer repository (admins only). Without them, Copilot's review/PR-edit jobs will fail with a firewall block on dl.deno.land and never reach the CLI. On unrestricted GitHub-hosted Actions runners this is already the default, so no change is needed there.

Pinning to an exact version

Workflows reference @v1 by default — a moving tag that re-points on additive changes within the v1 input shape. If you want immutability:

uses: sigmadigitalza/rational-release/.github/workflows/prepare-release.yml@v1.0.0

Pinning loses you the implicit security patches that ride with @v1, but you trade that for total reproducibility. Either is reasonable; pick the trade consciously.