Commands¶
release.py is one script with subcommands. Run it from anywhere inside the
repository (it works from the repository root, found with
git rev-parse --show-toplevel):
python3 .github/rab/scripts/release.py <command> [options]
It needs Python 3.11 or newer, git and PyYAML. It exits 0 on success and 1 on
any refusal, printing error: <reason> to standard error.
release.py --version prints the kit's version (rab 2.0.0).
It reads the release configuration from rab.yml. Until rab 3.0.0, a
repository still on release-kit 1.x's name is read from release-kit.yml,
with a warning naming rab.yml; when both files exist, every command refuses
until one is deleted. Likewise RELEASE_KIT_OFFLINE is read when
RAB_OFFLINE is unset, with a warning.
People run the person commands. The release workflow runs the CI commands; you can run them by hand to see what a run would do.
Person commands¶
check¶
release.py check [--offline] [--publish-dry-run]
Checks rab.yml against the repository's manifests: versions,
names, tag formats, dependency order and artifacts. The full list is in the
reference. On success it prints
rab.yml matches the repository (release 0.1.0).; otherwise it prints
one problem per line and exits 1. Run it in CI.
For crates.io packages, it then checks what cargo publish would refuse:
manifests, dependencies, who owns the name, and what each package carries
(see Publishing to crates.io). It
also warns, on lines starting warning:, about library crates in the
workspace that rab.yml does not publish. Warnings do not fail it.
| Option | Meaning |
|---|---|
--offline |
Skip the checks that ask crates.io. RAB_OFFLINE=1 does the same. |
--publish-dry-run |
Also run cargo publish --dry-run --locked over every crates.io package. This builds them, so use it in CI. |
sync¶
release.py sync
After you bump versions in the manifests, rewrites each package's version:
line in rab.yml to match, quoted, keeping comments and layout. It
prints one line per change (onto: 0.1.0 -> 0.2.0) or Nothing to change.
status¶
release.py status
Prints the release, its tag and where the tag is on the remote, then one line
per package: the package, its record tag, the commit that tag is on (or
untagged), and published or due. Under a github package with
homebrew or chocolatey, one line per such channel gives its
retry policy
and its state, read live once the release is out: merged, proposed,
prerelease or due for Homebrew; listed, submitted, due, or
rejected: publish a new version (terminal: not retried) for Chocolatey.
Release 0.2.0 (tag release-0.2.0: not tagged)
onto 0.2.0 (crates.io) onto-v0.2.0 untagged due
onto-cli 0.2.0 (crates.io) onto-cli-v0.2.0 untagged due
It asks each registry over HTTPS, so it needs network access.
tag¶
release.py tag [--commit SHA] [--release X.Y.Z] [--remote NAME] [--timeout SECONDS] [--dry-run]
Tags the release and waits for its run to publish it.
| Option | Default | Meaning |
|---|---|---|
--commit SHA |
Release this commit. | |
--release X.Y.Z |
Release (or finish) this release name. | |
--remote NAME |
origin |
The remote to read tags from and push them to. |
--timeout SECONDS |
10800 (3 hours) |
How long to wait for the run. Run tag again to keep waiting. |
--dry-run |
off | Print what would be tagged, moved or waited for; push nothing and wait for nothing. |
Which commit. In order:
--commit, if given.- Otherwise, a release that is already tagged and not finished: the one named
by
--release, or else the newest tagged release (by version) if it still has a due package, or a publishedgithubpackage whose Homebrew or Chocolatey channel is not done (only whileHEADnames that release; otherwise it prints anote).taggoes back to that commit. A tag whose run provably published nothing does not hold its commit. - Otherwise, with
--release, the last commit on<remote>/<branch>(first-parent history) whoserab.ymlnames that release. - Otherwise
HEAD.
The commit must be on <remote>/<branch> as last fetched (tag does not
fetch the branch): run git pull or git fetch first.
What it checks. It refuses when a package tag on the remote is not that
package's own record (collisions),
when rab.yml at the commit fails check, and when the release would
be split (rule 4).
What it does, one line per package:
| Line | Meaning |
|---|---|
skip <tag> |
the package is already published, and so is every channel made from it |
finish <tag> |
the package is published, but a channel made from it (homebrew, chocolatey) is not done; it is due again, alone |
keep <tag> |
the record tag is already on this commit |
tag <tag> |
a new record tag on this commit |
move <tag> |
the record tag is on another commit whose run published nothing; it is moved here |
wait <tag> |
the record tag is on another commit that ships the same files, and that commit's run may still publish it; tag waits for that run |
Then the release tag:
| Line | Meaning |
|---|---|
tag release-X |
a new release tag, which starts the run |
move release-X |
it was on another commit whose run ended before publishing; it is moved here |
retag release-X |
it is on this commit but its run failed (or succeeded with only channels left to finish); it is pushed again, starting a new run that publishes only what is still due |
wait release-X |
it is on this commit and its run is live (or succeeded); tag only waits |
Tags are annotated. A record tag's message is:
onto-cli 0.2.0 (crates.io)
Release: 0.2.0
Commit: 9f1c2e7d4b5a6c3e8f0a1b2c3d4e5f6a7b8c9d0e
The release tag's message starts Release 0.2.0, lists the packages it
publishes, and ends with the same Release: and Commit: lines.
tag deletes moved tags in one atomic push, pushes all record tags in a second
atomic push, then pushes the release tag on its own (GitHub starts no workflow
for a push of more than three tags).
Waiting. tag then polls every 30 seconds until the run has succeeded and
every due package is on its registry. Then it polls the due homebrew and
chocolatey channels up to ten more times, and names any not done in a
note instead of waiting longer. If the run fails, it stops and says how
many packages were published. If --timeout passes while the run is still
going, it says so and names the run; run tag again to keep waiting. Running
tag while a run is live changes nothing: it prints wait and waits.
tag follows runs with the GitHub CLI (gh run list, gh api). Without gh
it cannot see runs, so it never proves that a run published nothing: it does
not move or retag tags, and it waits on the registries alone. See
Recovering a failed release.
install¶
python3 ../release-asset-bundle/scripts/release.py install [--into DIR]
Run from a checkout's script (a clone of
buchochelliq-labs/release-asset-bundle), in the repository to install into
(or name it with --into). It refuses to run from an installed copy, and to
install into the kit itself. It replaces .github/rab/, writes
.github/workflows/rab-build.yml, writes a starter
.github/workflows/release.yml only if there is none, and prints every path
written. The version recorded is git describe --tags --always --dirty of
the checkout. See Getting started.
In a repository with release-kit 1.x's layout, install first migrates it,
and prints each change:
release-kit.ymlbecomesrab.yml(withgit mvwhen git tracks it);.github/release-kit/and.github/workflows/release-kit-build.ymlare removed (the new copy is written at.github/rab/and.github/workflows/rab-build.yml);- in every workflow under
.github/workflows/(yourrelease.yml, and a CI workflow that runscheckorvendored),.github/release-kit/becomes.github/rab/andrelease-kit-build.ymlbecomesrab-build.yml.
It refuses, changing nothing, when both rab.yml and release-kit.yml
exist. See Migrating from release-kit 1.x.
vendored¶
release.py vendored
Checks the installed copy against .github/rab/manifest.json. It
fails if the manifest is missing, if a listed file is missing or edited, or if
a file not in the manifest was added under .github/rab/
(__pycache__ is ignored). On success it prints
rab v2.0.0: the installed copy is unmodified. Run it in CI.
A copy still at release-kit 1.x's .github/release-kit/ (with no
.github/rab/) is checked there, with a warning to install again. A
.github/release-kit/ left next to .github/rab/ is a problem.
CI commands¶
These take the gate's selection: a JSON list with one object per selected
package, in rab.yml order:
[{"name": "onto", "registry": "crates.io", "version": "0.2.0",
"manifest": "crates/onto/Cargo.toml", "tag": "onto-v0.2.0", "slug": "onto",
"channels": ["crates.io"]}]
channels lists what is due for the package: its registry, then for a
github package the homebrew and chocolatey channels it has. A published
github package selected only to finish a channel lists just that channel:
plan, preflight, verify and consume leave it out of its registry, and
publish-github skips it. A selection without channels (from an older
gate) means every channel.
Commands that write outputs append key=value lines to the file named by
GITHUB_OUTPUT.
gate¶
release.py gate TAG [--remote NAME] [--verify-only]
| Option | Default | Meaning |
|---|---|---|
TAG |
the release tag, which must be checked out with full history | |
--remote NAME |
origin |
|
--verify-only |
off | select the release's published packages, to verify them again |
It refuses unless:
TAGis an annotated tag;rab.ymlat the tagged commit passescheck;TAGis that commit's release tag;- the commit is on
<remote>/<branch>(fetched first); - the tag's message records
Release: <name>andCommit: <sha>; - no unpublished package's tag on the remote belongs to something else;
- the release is not split;
- at least one package is due, or a channel made from a published
githubpackage whose record tag is on the commit (see the retry policy); - every due package's record tag is on the commit, and annotated.
It prints selected, selected for <channel> (<state>) or not selected
for each package.
With --verify-only, steps 7–9 are replaced: it selects every package whose
record tag is on the commit, and refuses if none is, or if any of them is not
yet published (Run release.py tag again to publish them).
Outputs:
| Output | Value |
|---|---|
sha |
the tagged commit |
release |
the release's name |
selection |
the selection (JSON) |
verify-only |
true or false |
github, crates-io, pypi, npm, vscode |
true when the selection has a package of that registry due on it (not one selected only for its channels) |
homebrew, chocolatey |
true when a selected package has that channel due |
plan¶
release.py plan --selection JSON
The build matrix: one job per artifact of each selected package, and per
target for rust-binary and maturin. It prints build <id> on <runner>
per job and writes two outputs: jobs (a JSON list for a matrix's include)
and count.
Each job carries id, kind, os, target, package, slug, version,
registry, manifest, tag (the package's record tag, which the build
job checks out) and test, plus what its kind needs (bin, crate,
include; cargo; dir; run, outputs). Job ids:
| Kind | Job id |
|---|---|
rust-binary |
<slug>-rust-binary-<target> |
maturin |
<slug>-maturin-<target>, plus <slug>-sdist |
python, vsix, command |
<slug>-<kind> for a package's first artifact; <slug>-<kind>-<n> for the artifact at 0-based position n after that |
Two artifacts that plan the same job id are refused.
build¶
release.py build --job JSON [--out DIR]
Builds one planned job into --out (default dist), as described per kind
in the reference. Then it runs the artifact's
test, if any, and for a github package writes a .sha256 beside every
file. It fails if the job produced no files. For maturin jobs the build
action has already run maturin; this step adds the test and checksums.
preflight¶
release.py preflight --registry REGISTRY --selection JSON
Just before uploading: refuses if any selected package of REGISTRY is
already published (already on crates.io: ...; a published version is
permanent). Another run may have published it since the gate.
For crates.io it first runs check's
crates.io checks on the selected
crates, and refuses (not ready for crates.io: ...) before writing any
output. Then it asks crates.io which selected crates do not exist yet,
and writes three outputs:
| Output | Value |
|---|---|
new_crates |
JSON list of the selected crates with no version on crates.io |
new_crate |
true if that list is not empty |
existing_crate |
true if at least one selected crate already exists |
For vscode it asks each registry separately and writes uploads: a JSON
list of {name, version, marketplace, open-vsx}, true where that registry
still lacks the version. A package with open-vsx: true that the Marketplace
already lists is not refused while Open VSX lacks it: only the missing upload
is made. With an Open VSX upload due and no OVSX_PAT in the environment, it
refuses before anything is uploaded.
publish-crates¶
RELEASE_TRUSTED_TOKEN=... RELEASE_NEW_CRATE_TOKEN=... \
release.py publish-crates --selection JSON [--new-crates JSON]
| Option | Default | Meaning |
|---|---|---|
--selection |
the gate's selection | |
--new-crates |
[] |
preflight's new_crates output |
Tokens come from the environment, never the command line:
RELEASE_TRUSTED_TOKEN (the Trusted Publishing token) and
RELEASE_NEW_CRATE_TOKEN (the token for first versions).
It splits the selected crates, in order, into runs that share a credential:
existing crates use the trusted token, new crates the new-crate token. If a
run's token is missing it refuses before uploading anything. Then it runs one
cargo publish --dry-run --locked -p A -p B ... for all of them, and one
cargo publish --locked -p ... per run. For example, with onto new and
onto-cli existing, it publishes onto with the new-crate token, then
onto-cli with the trusted one. Publishing several crates in one cargo
publish needs Cargo 1.90 or later.
pypi-upload¶
release.py pypi-upload --dir DIR [--attestations true|false]
Uploads every .whl and .tar.gz in --dir to PyPI with Trusted
Publishing; the publish-pypi action runs it
with the Python of a virtualenv holding twine and pypi-attestations. In
order:
twine checkon every file;- a short-lived API token:
GET https://pypi.org/_/oidc/audience, GitHub's OIDC token for that audience (ACTIONS_ID_TOKEN_REQUEST_URL, which needsid-token: write), andPOST https://pypi.org/_/oidc/mint-token. The token is masked (::add-mask::) before anything else runs. When PyPI refuses the exchange, the error shows PyPI's reasons and the token's claims (repository, workflow, environment) to compare with the trusted publisher; - with
--attestations true(the default),pypi-attestations sign, which writes a PEP 740 attestation beside each file, signed with the job's identity through Sigstore; - each file's sha256, md5 and blake2_256;
twine upload --verbose --attestationswith the token.
There is no --skip-existing: preflight has already refused a version
PyPI has.
verify¶
release.py verify --registry REGISTRY --selection JSON [--timeout SECONDS]
Waits until every selected package of REGISTRY is served, checking every 20
seconds, for up to --timeout seconds (default 900). Prints
on <registry>: ..., or fails with not on <registry> after N minutes.
What "served" means per registry:
| Registry | Check |
|---|---|
github |
the release on the package's tag exists and has at least one asset |
crates.io |
crates.io/api/v1/crates/<name>/<version> answers 200 |
pypi |
pypi.org/pypi/<name>/<version>/json answers 200 |
npm |
registry.npmjs.org/<name>/<version> answers 200 |
vscode |
the Marketplace's extension query lists the version; with open-vsx: true, also open-vsx.org/api/<publisher>/<name>/<version> answers 200 |
record¶
release.py record --tag TAG [--sha SHA] [--selection JSON] [--verify-only true|false]
[--needs JSON] [--dist DIR] [--out FILE]
release.py record --schema
| Option | Default | Meaning |
|---|---|---|
--tag |
the release tag the run was for | |
--sha |
the tag's commit | the gate's sha output (empty when the gate failed) |
--selection |
none | the gate's selection output |
--verify-only |
false |
the gate's verify-only output |
--needs |
{} |
the job's toJSON(needs): each job's result |
--dist |
dist |
where the release--* build artifacts were downloaded |
--out |
release-record.json |
the file to write |
--schema |
print the record's JSON Schema and stop |
Writes the run's release record: every package of
rab.yml at the commit, whether the gate selected it, its publish
job's result, its outcome, its registry URL and the SHA-256 of the files built
for it. A selected package whose publish job did not succeed is looked up on
its registry, as verify does. It checks the record against its schema,
prints one line per package (also to the job summary), and refuses if a
selected package's publish job is not in --needs, or a built file does not
match its .sha256.
consume¶
release.py consume --registry REGISTRY --selection JSON
Uses each selected package as a stranger would: in a fresh temporary
directory, with CARGO_REGISTRY_TOKEN, NODE_AUTH_TOKEN and
PYPI_API_TOKEN emptied.
| Registry | What it does |
|---|---|
crates.io, a crate with a binary |
cargo install <name> --version =<version> --locked into a temporary root |
crates.io, a library |
cargo new --lib consumer, cargo add <name>@=<version>, cargo check |
pypi |
a new virtualenv, then pip install --no-cache-dir <name>==<version> |
npm |
npm view <name>@<version> version |
github, vscode |
nothing (verify has already seen the files) |
A crate "has a binary" when its Cargo.toml has a [[bin]] table or there
is a src/main.rs beside it. A registry's index can lag behind its API, so
the install, add or view step is tried up to 10 times, 30 seconds apart.
Prints used <package> as a consumer would per package.