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.

Re-triggering required checks on the release PR

When prepare-release force-pushes the release branch using GITHUB_TOKEN, GitHub deliberately suppresses the downstream push and pull_request:synchronize events that would normally fire on PR head updates. This is the anti-loop protection that stops a workflow from recursively triggering itself.

Side effect: any workflow that gates the release PR via pull_request: — CI, linters, conventional-commit validators — never gets a chance to run. If those checks are required in branch protection or a ruleset, the release PR can never satisfy them and is permanently blocked.

workflow_dispatch and repository_dispatch are the only triggers explicitly excluded from the suppression. Use the post-push-workflows input to have prepare-release call gh workflow run against the release branch after force-push. The dispatched run’s status check attaches to the branch HEAD commit (which is also the PR head commit), satisfying required-status-checks.

with:
  post-push-workflows: |
    CI
    Lint
    # one workflow name per line; each must declare a `workflow_dispatch:` trigger

Each named workflow must declare workflow_dispatch: in its on: block. Workflows that read github.event.pull_request.* need to handle the empty PR context on dispatch invocations (or stay on pull_request: only). The canonical case: validating that the PR title is a conventional-commit subject doesn’t make sense for bot-authored release PRs (whose title is intentionally Release vX.Y.Z), so leave that workflow off the dispatch list.

If gh workflow run fails for a named workflow (it doesn’t exist, has no workflow_dispatch: trigger, etc.), the step emits a warning and continues — the release PR is opened either way; you just have to dispatch the missing check manually.

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.