The workflow¶
A release is one run of .github/workflows/release.yml. install writes it
from templates/release.yml
when the repository has none. After that it is the repository's own file.
gate ──▶ ci ──▶ build ──┬──▶ publish-github
├──▶ publish-pypi
├──▶ publish-vscode
├──▶ publish-crates
└──▶ publish-npm
...all of them ──▶ record (always)
Triggers¶
on:
push:
tags: ["release-*"]
workflow_dispatch:
inputs:
tag: # an existing release tag to run again (required)
verify_only: # boolean, default false
- A pushed release tag starts a normal run.
release.py tagpushes it. - A manual dispatch runs an existing release tag again. With
verify_onlyset, it only verifies (see Verify-only runs).
gh workflow run release.yml -f tag=release-0.2.0
gh workflow run release.yml -f tag=release-0.2.0 -f verify_only=true
Permissions, environments, concurrency¶
The workflow's default token permission is contents: read. Only the publish
jobs get more:
| Job | Permissions | Environment |
|---|---|---|
gate, ci |
contents: read |
none |
build |
contents: read, actions: read (only a ci-artifact job uses its token) |
none |
publish-github |
contents: write |
none |
publish-crates |
contents: read, id-token: write |
crates-io |
publish-pypi |
contents: read, id-token: write |
pypi |
publish-npm |
contents: read, id-token: write |
npm |
publish-vscode |
contents: read (default) |
vscode-marketplace |
record |
contents: read (default) |
none |
id-token: write lets a job request an OIDC token for Trusted Publishing.
Environments hold the secrets and are what the registries' trusted publishers
name. In each environment's settings, you can limit deployments to the
main branch and release-* tags (a dispatched run's ref is the branch it was
dispatched from), and add required reviewers if you want a person to approve
each upload.
concurrency:
group: release
cancel-in-progress: false
One release runs at a time. A second run waits; a run that may be half published is never cancelled by another.
Every checkout uses persist-credentials: false, so later steps cannot push
with the job's token.
Jobs¶
gate¶
Checks out refs/tags/<tag> with full history (fetch-depth: 0) and runs
the gate action. The tag is inputs.tag on a dispatch,
else the pushed tag. Its outputs are the job's outputs: sha, release,
selection, verify-only, one flag per registry (github, crates-io,
pypi, npm, vscode), and one per channel made from a GitHub release
(homebrew, chocolatey).
If the gate refuses, nothing else runs.
ci¶
ci:
needs: gate
uses: ./.github/workflows/ci.yml
with:
ref: ${{ needs.gate.outputs.sha }}
Your own CI, called on the tagged commit. ci.yml needs on: workflow_call
with a ref input that it checks out. If your file or input has another name,
change this job. Every later job needs ci to succeed.
build¶
build:
needs: [gate, ci]
if: needs.gate.outputs.verify-only != 'true'
uses: ./.github/workflows/rab-build.yml
with:
sha: ${{ needs.gate.outputs.sha }}
selection: ${{ needs.gate.outputs.selection }}
Calls the build workflow (below). Skipped in a verify-only run.
publish-github, publish-pypi, publish-vscode¶
These publish built files, so they wait for the build:
needs: [gate, ci, build]
if: >-
!cancelled() && needs.gate.outputs.github == 'true' && needs.ci.result == 'success'
&& (needs.build.result == 'success'
|| (needs.build.result == 'skipped' && needs.gate.outputs.verify-only == 'true'))
That is: the gate selected a package of this registry, CI passed, and the
build succeeded, or was skipped because this is a verify-only run. Each job
checks out the tagged commit, installs Python 3.12 and PyYAML, downloads every
release--* artifact into dist/ (not in a verify-only run), and runs its
publish action.
publish-vscode passes the VSCE_PAT and OVSX_PAT secrets (OVSX_PAT is
needed for packages with open-vsx: true).
publish-crates, publish-npm¶
These publish from source, but still wait for the build, with the same condition as the other publish jobs: a release whose artifacts fail to build or fail their tests publishes nothing anywhere.
needs: [gate, ci, build]
if: >-
!cancelled() && needs.gate.outputs.crates-io == 'true' && needs.ci.result == 'success'
&& (needs.build.result == 'success'
|| (needs.build.result == 'skipped' && needs.gate.outputs.verify-only == 'true'))
publish-crates installs stable Rust, runs
preflight, requests a Trusted Publishing token only when an existing crate
is selected, and passes CARGO_REGISTRY_TOKEN only when a new one is; see
crates.io. publish-npm installs Node 24 with the
npm registry, runs npm ci at the repository root (not in a verify-only run),
and runs its action.
record¶
record:
needs: [gate, ci, build, publish-github, publish-homebrew, publish-chocolatey, publish-crates,
publish-pypi, publish-npm, publish-vscode]
if: always()
Runs last, whatever happened before it, even in a cancelled run. It checks
out the release tag and, as the build workflow does (below), stops unless
the tag still names the gate's sha (when the gate failed there is no
sha, and the record says so); it writes no cache. It downloads the build artifacts (unless the build was
skipped), runs record with the gate's outputs and
toJSON(needs), and uploads release-record.json as the artifact
release-record. See The release record.
Adding your own jobs¶
Keep repository-specific checks (for example cargo-semver-checks) as jobs
in release.yml that the publish jobs need. If you remove a publish job
for a registry you do not use, remove it from record's needs too; a job
of yours that publishes belongs in them.
Name publishing jobs publish…
release.py tag decides whether a failed run published anything by
looking for jobs whose names start with publish that started. A job that
uploads anywhere must have a name starting with publish, or tag may
move its tags after it uploaded.
The build workflow¶
.github/workflows/rab-build.yml is part of the installed copy: do
not edit it. It is a reusable workflow with two inputs, sha and selection,
and contents: read. It holds no credentials.
planchecks out the first selected package's record tag, checks it (below), and runs theplanaction: outputsjobsandcount.buildruns only whencountis not0. It is a matrix overjobs(fail-fast: false), namedbuild <id>, on each job'sos. Each job checks out its package's record tag, checks it, runs thebuildaction, and uploadsdist/*as the artifactrelease--<slug>--<id>(if-no-files-found: error).
The record tags are the ones the gate found on sha. Each job checks out
refs/tags/<record tag> and, before anything from the checkout runs, stops
unless the tag still names sha: what is built is exactly the commit the
gate checked (on the default branch, recorded by its tags), and a tag moved
or re-pushed during the run builds nothing.
The build action sets up what the kind needs: Python 3.12 always; stable Rust
with the target for rust-binary; Node 24 for vsix; PyO3/maturin-action
for maturin (wheels, --release, manylinux: auto) and for the sdist.
No caches¶
Nothing in a release run restores or saves an Actions cache: no
actions/cache, no cache: on a setup action, package-manager-cache: false
on every actions/setup-node (it caches npm by itself when package.json
names npm as its packageManager), and sccache: "false" on
PyO3/maturin-action. A run dispatched from main shares the default
branch's cache scope, which every branch's workflows can restore from; a
release run has nothing to gain from a cache and must not add to that one.
Keep it so in jobs you add to release.yml.
GitHub's CodeQL (actions, query actions/cache-poisoning/poisonable-step)
reports a job that checks out a commit named by an input such as sha, in a
workflow workflow_dispatch can start, and then runs code from it. Its
model takes any checkout ref ending in sha, head or commit for an
unvalidated pull request head, and it accepts no check for
workflow_dispatch. The gate is that check here: the commit must be on the
default branch and carry the release's annotated tags. The build workflow
checks out the gate's record tags and verifies them against sha (above),
and CodeQL reports nothing in the installed copy. A release.yml job of your own that checks out
needs.gate.outputs.sha and runs code is reported the same way unless the
job writes (the publish jobs do); if you add one, check out a record tag
and verify it as the build workflow does, or dismiss the alert as a false
positive: "the commit is the release gate's output, verified to be on the
default branch; the job writes no cache".
Artifact naming¶
Artifacts are named release--<slug>--<job id>, for example:
release--onto-cli-binaries--onto-cli-binaries-rust-binary-x86_64-unknown-linux-gnu
release--rs-rich--rs-rich-maturin-aarch64-apple-darwin
release--rs-rich--rs-rich-sdist
The publish jobs download them with pattern: release--* into dist/, one
directory per artifact (not merged), and each publish action takes a
package's files from dist/release--<slug>--*/.
Pinned actions¶
Every third-party action in the kit and the template is pinned to a full commit SHA, with its version in a comment:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
A test in rab refuses any uses: that is not local (./…) or pinned
this way, and Dependabot opens PRs that move a SHA and its comment together.
Repositories get new pins by installing a newer rab. Pin any actions
you add to release.yml the same way.