Recovering a failed release¶
The rule is: re-run, don't repair. Fix the cause, then run
release.py tag again. Do not delete or move tags by hand, and do not use
GitHub's "Re-run jobs" button on a publish job: tag works out what happened
and what is still due, including the Homebrew and Chocolatey channels made
from a published GitHub release (retry policy).
git switch main && git pull
python3 .github/rab/scripts/release.py tag --dry-run # see what it would do
python3 .github/rab/scripts/release.py tag
Every run's release record says what it published:
gh run download <run> -n release-record, then look at each package's
outcome.
What tag can prove: dead()¶
Moving a tag is safe only if nothing was published from it. tag moves a tag
only when the latest release run for it proves that. In the code this is
dead(run, commit), true when all of these hold:
- the run ran on the tagged commit (its head SHA is that commit);
- the run has completed;
- it did not succeed;
- no job whose name starts with
publishstarted (a job counts as started unless it was still queued or was skipped).
If tag cannot list the run's jobs, it assumes a publish job started. Without
the GitHub CLI (gh) it cannot see runs at all, so no run is ever proven
dead: it moves nothing and only waits.
Case 1: the run failed before publishing¶
For example, CI failed on the tagged commit. The run is dead.
Fix the cause in a PR and merge it. Then run tag on the default branch. It
finds that the record tags and the release tag are on a commit whose run
published nothing, and moves them to HEAD:
Release 0.2.0 from 7c1d... (the local checkout (HEAD))
move onto-v0.2.0: the release run at 3e9a0b1c2d4f published nothing
move onto-cli-v0.2.0: the release run at 3e9a0b1c2d4f published nothing
move release-0.2.0: its run at 3e9a0b1c2d4f ended failure before publishing
pushed release-0.2.0; waiting for the release run
The moved tags are deleted from the remote in one atomic push and pushed again at the new commit. The new tag starts a new run.
Case 2: the run published part of the release¶
For example, publish-crates uploaded onto and then failed on onto-cli
because of a missing secret. A publish job started, so the run is not dead,
and the release stays on its commit: onto 0.2.0 is published from it, and
rule 1 says that is permanent.
Fix the cause outside the tagged commit (add the secret, fix the environment,
wait out a registry outage). Then run tag. It goes back to the commit where
the release is tagged, keeps the record tags, skips what is published, and
pushes the release tag again:
Release 0.2.0 from 3e9a... (release 0.2.0 is already tagged there)
skip onto-v0.2.0: onto 0.2.0 (crates.io) is already published
keep onto-cli-v0.2.0: already tagged here
retag release-0.2.0: its run ended failure; the new run publishes only what is still due
The new run's gate selects only onto-cli.
If the fix must change files that the tagged commit ships, the release cannot move there: that would split it (rule 4). Bump the versions of the packages that are still due, merge, and release the new versions.
Partial uploads per registry¶
- crates.io, npm: each crate or package is one upload. A re-run selects the ones not yet on the registry, in order.
- PyPI: the package counts as published as soon as the version exists on
PyPI, that is, once any one of its files is up. If an upload stopped after
some wheels, a re-run does not upload the rest. Upload the missing files
from the run's build artifacts by hand (for example with
twine upload); files uploaded by hand have no attestations (only the release job's identity can sign them). Apublish-pypijob that failed before uploading anything, as every PyPI release with rab 2.0.0 did (see the changelog), leaves nothing on PyPI: install rab 2.0.1 or later and runtagagain, and the new run publishes only PyPI. - GitHub Releases: a package counts as published once its release has at
least one asset. If an upload stopped partway, a re-run does not upload the
rest. Check the release's files against the build artifacts, and upload any
missing ones (with their
.sha256) withgh release upload. - VS Code Marketplace and Open VSX: the package counts as published once
the Marketplace lists the version and, with
open-vsx: true, Open VSX serves it. A re-run uploads only to the registry still missing it.
Case 3: the run is still going, or tag stopped waiting¶
tag waits up to --timeout seconds (default 10800, three hours). If it
stops while the run is live, it says so:
error: not published within 180 minutes: onto-cli 0.2.0 (crates.io). The run is
still going (https://github.com/...); run `release.py tag` again to keep waiting:
it changes nothing while the run is live.
Run tag again. With the release tag on its commit and its run live, it
prints wait and waits; it pushes nothing. A record tag on another commit
that ships the same files, whose run may still publish it, is also waited for
and left alone.
Case 4: everything uploaded, but verify or consume failed¶
For example, crates.io took longer than verify's 15 minutes to serve a
version, or the consumer check hit a flaky network. Everything is published,
so there is nothing for tag to do. To get a green record, start a
verify-only run.
Verify-only runs¶
gh workflow run release.yml -f tag=release-0.2.0 -f verify_only=true
In a verify-only run:
- the gate (
gate --verify-only) selects every package whose record tag is on the release's commit, and refuses if any of them is not yet published ("Runrelease.py tagagain to publish them"); - the build is skipped;
- each publish job is started with
verify-only: true: it skips the preflight and the upload, and runsverify(andconsumefor crates.io, PyPI and npm).
A verify-only run never uploads, so it cannot finish a partial upload. Use
tag for that.
What each channel allows again: the retry policy¶
Channels differ in whether publishing again is safe. rab defines a retry policy per channel; it is not configured.
| Channel | Policy | Publish again? | Done when | What tag again does |
Needs a manual step |
|---|---|---|---|---|---|
| crates.io, PyPI, npm | once |
never: a version is permanent (rule 1) | the registry serves the version | skips it | a partial PyPI upload (above) |
| VS Code Marketplace, Open VSX | once |
never for the same version, on each registry | the Marketplace lists it, and with open-vsx: true Open VSX serves it too |
skips it; with open-vsx: true, a version only one registry has is due, and the new run uploads only to the other |
an Open VSX upload that failed for a package without open-vsx: true |
| GitHub Releases | finish |
only to finish a draft; a published release's files never change | the release is out of draft with its files | finishes a draft; skips a published one | missing assets on a published release (above) |
| Homebrew tap | idempotent |
yes: rendering the formula again and updating its branch and pull request is harmless | the tap's default branch has Formula/<name>.rb at the version, or a pull request from <name>-<version> is open on the tap; a prerelease is never put in the tap |
selects the package again for homebrew only |
merging the pull request (the tap's maintainers) |
| Chocolatey | until-accepted |
until the community repository has accepted or rejected the version | the API lists the version, or it is submitted (in its checks or moderation); a rejected version is terminal: never done, never due | selects the package again for chocolatey only; skips a rejected version with a note |
a rejected version: fix the package and release a new version |
release.py status prints each channel's policy and live state under its
package.
How the state is read, and why¶
- Live, not recorded in tags. The Homebrew and Chocolatey states are read
from the tap (GitHub's API) and from the community repository's API every
time
tag, the gate orstatusneeds them; nothing is written into the record tags. A record tag records what was released and is never rewritten for a run that published; a channel's state changes outside rab (a maintainer merges or closes the pull request, moderators approve or reject a package, someone copies a formula by hand), so a storedhomebrew: donecould only go stale. The live state is the truth, and costs one or two requests per channel. - Submitted counts as done. Chocolatey's moderation can take days, so
tagdoes not wait for it: a version in moderation is reported assubmittedand counts as done: pushing it again would only replace the version under review. Likewise an open tap pull request counts as done: merging it is the tap's decision. - Rejected is terminal. A version in moderation can be pushed again (the
new push replaces the one under review), but a rejected one never can:
choco pushrefuses it ("Package versions that are approved, rejected, or exempted cannot be modified"). So a rejected version is neither done nor due. The gate does not select it,tagdoes not retry or wait for it and printsnote chocolatey of <package> is rejected: publish a new version; not retried, andstatusshowsrejected: publish a new version. Fix what moderation objected to and release a new version of the package. The package's other channels (Homebrew) are still finished as usual. - A newer release supersedes an older one's channels.
taggoes back to finish an older release's channels only whileHEADnames that release (or with--release); onceHEADnames a newer one it prints anoteand releasesHEAD, since a tap or package moving back to an older version would be a downgrade. - Other channels made from a release fit the same model. Scoop (a bucket
repository) and AUR (a
PKGBUILDpushed to the AUR's git) areidempotentlike Homebrew: done when the bucket's default branch or the AUR's RPC has the version (or a bucket pull request is open). winget proposes a manifest as a pull request onmicrosoft/winget-pkgs:idempotentwhile proposing, done when the pull request is open or the manifest for the version is merged. Each needs only a state function and a policy.
Case 5: the release is out, but Homebrew or Chocolatey is not¶
For example, publish-chocolatey failed on a Chocolatey outage after
publish-github published the release. Fix the cause outside the tagged
commit (an expired API key, a tap token without pull-request access, an
outage), then run tag. It goes back to the release's commit, sees the
GitHub release is published but a channel made from it is not done, and
pushes the release tag again with only that channel due:
Release 0.2.0 from 3e9a... (release 0.2.0 is already tagged there)
skip onto-v0.2.0: onto 0.2.0 (crates.io) is already published
finish onto-cli-v0.2.0: onto-cli 0.2.0 (github) is published; chocolatey is due (until-accepted)
retag release-0.2.0: its run ended failure; the new run publishes only what is still due
tag release-0.2.0 at 3e9a0b1c2d4f (starts the release run: onto-cli (chocolatey))
The new run's gate selects onto-cli for chocolatey only (its selection
entry has "channels": ["chocolatey"]): the build plans nothing for it,
publish-github is skipped, and publish-chocolatey runs. A run that
succeeded without finishing a channel (for example, publish-homebrew without
HOMEBREW_TAP_TOKEN only attaches the formula) is pushed again the same way.
This needs the template's publish-homebrew and publish-chocolatey jobs to
run when publish-github was skipped; see the changelog.
When tag has waited for a run, it polls a channel that is not done yet a
few more times (five minutes), then names it in a note and returns rather
than waiting for hours.
Errors that need a decision¶
tag and the gate refuse, rather than guess, in these cases:
| Message starts | Meaning and fix |
|---|---|
these tags already exist for something else |
A package tag on the remote is not that package's record. Give the package its own tag: format, or bump it. See collisions. |
... has no rab.yml (nor release-kit.yml): the tag predates the kit |
A record tag is on a commit from before adoption. Bump the package, or delete the stale tag if nothing was published from it. |
... changes packages already tagged under the same version on other commits |
A split release. Release from the named commit (--commit), or bump those packages. |
release-X is on <sha>, not <sha>, and its run there may have published |
Release from that commit (--commit) or wait for its run. |
... is not on origin/main as last fetched |
Run git fetch, and release only merged commits. |
... every package of release X is already published |
(gate) Nothing to do. |
<tag> is at <sha>, not <sha>, the commit the gate checked |
(build) A record tag moved after the gate checked it, and nothing was built. Find out who moved it and why; put it back on the gated commit to run again, or tag a new release. |