How to cut, soak, and publish a release, and what to do when one of those goes wrong. Maintainer-facing; for using a release, see the documentation site.
Releases come from two places. main cuts minors and majors (vX.Y.0); a
release-X.Y branch cuts patches (vX.Y.Z) against a series that has already
shipped. See The tag and branch model.
Three workflows, in order. In the normal flow only the first is started by a
human; release-upgrade is also dispatchable by hand for backfills, rollbacks
and the break-glass paths.
| Phase | Workflow | Trigger | Result |
|---|---|---|---|
| 1. Prepare | release-prepare.yaml |
manual dispatch | pushes a vX.Y.Z tag, nothing else |
| 2. Build | release.yaml |
the tag push | builds, signs, attests, creates a draft release |
| 3. Soak and publish | release-upgrade.yaml |
automatic, on phase 2 completing | deploys to unbounded-stable, smokes it, flips the draft to published |
The split exists so phase 2 runs in the tag's context. Keyless cosign signatures
carry the identity release.yaml@refs/tags/vX.Y.Z, and phase 3 verifies exactly
that. A build dispatched from a branch would sign as @refs/heads/main and fail
verification, which is why release.yaml deliberately has no manual
trigger.
The tag is pushed with an SSH deploy key rather than GITHUB_TOKEN, because
GitHub suppresses workflow triggers for tags pushed with the default token.
One consequence is worth knowing before it confuses someone: GitHub attributes a
deploy-key push to whoever registered the key, so the actor shown on every
release.yaml run is the same person regardless of who cut the release. It is
not evidence that they did anything. relctl watch <tag> reports the real cutter
on a Cut by: line, and relctl status in a BY column.
Every procedure below is given twice: with relctl, and with gh directly.
gh is authoritative. The workflows are the interface and gh dispatches
them; relctl is a convenience wrapper that also answers questions gh cannot.
make relctl-build # bin/relctlThe full account, including the one exception where relctl is authoritative,
is in relctl and gh.
Land the fix on main first, always. Where it ships from depends on who needs
it and what else is sitting on main.
| Situation | What to do |
|---|---|
main is clean |
Cut from main. It is a minor. Nothing special. |
main carries work you are not ready to ship |
A release from main takes all of it, and there is no way to exclude it. Read the list first, then: cut a candidate the moment your fix lands and promote that, which holds that exact tree; or revert the unready work, cut, and re-land it after; or ship it anyway, having read the list. |
| Someone is on an older series | Cut a patch from that series' branch. This is the case release branches exist for, and the only way to ship the fix without everything merged since. |
| The fix is needed on both | Land on main, then cherry-pick down. Never the reverse: the flow is one-way, so nothing needs forward-porting. |
| A patch on the newest series | Allowed immediately. release-0.5 can be opened the moment v0.5.0 ships, without waiting for main to move, because main will never take v0.5.1. This is the property the whole rule buys. |
| Two branches at once | Independent. Their numbers cannot collide. |
| An incompatible change | A major, from main. It is never derived; you ask for it. |
The common case: everything merged since the last release. One dispatch, then waiting - nothing between the tag and the published release needs a human.
main has to be releasable: the nightly green and green recently, CI green on
the commit you intend to release, and no candidate train already in flight.
relctl preflightThe same checks with gh
# The nightly deploys main to a real cluster and smoke-tests it. This is the
# strongest signal we have. It should be green, and green recently.
gh run list --repo Azure/unbounded --workflow nightly.yaml --limit 5
# CI on the commit you intend to release.
gh run list --repo Azure/unbounded --workflow ci.yaml --branch main --limit 5A red nightly is a release blocker until it is understood. See
Is main releasable? for what each check means and what
"green recently" is judged as.
# 1. What version, and what would ship?
relctl next
git log --oneline "$(relctl next -o json | jq -r .latestFinal)..origin/main"
# 2. Cut it. Shows the version and every workflow input, then asks.
# Add --dry-run to see that without dispatching.
relctl cut
# 3. Follow it through build, soak and publish. Exits non-zero if it does not
# publish, so this can be the last line of a script.
relctl watch v0.5.0The same thing with gh only
# 1. What would ship? Everything on main since the last final tag.
git fetch --tags origin
git log --oneline \
"$(git describe --tags --abbrev=0 --match 'v[0-9]*' --exclude '*-*' origin/main)..origin/main"
# 2. Confirm the version without cutting anything. The run log prints the tag
# it would create and the commit it would tag.
gh workflow run release-prepare.yaml --repo Azure/unbounded \
-f mode=release -f dry_run=true
# 3. Cut it. Same command, without dry_run.
gh workflow run release-prepare.yaml --repo Azure/unbounded -f mode=release
# 4. Watch it through. The tag push starts the build, which drafts the release;
# finishing that starts the soak, which publishes it.
gh run list --repo Azure/unbounded --workflow release.yaml --limit 3
gh run list --repo Azure/unbounded --workflow release-upgrade.yaml --limit 3
# 5. Confirm. isDraft false means it shipped.
gh release view v0.5.0 --repo Azure/unbounded --json tagName,isDraft,urlThe dry_run=true step exists because there was no other way to see the version
before minting it. relctl next answers the same question locally and instantly,
so the relctl path skips it - but the dispatch remains the only way to prove the
WORKFLOW agrees, which is what makes it worth keeping before a first release from
a branch nobody has cut from.
There is no bump input. main cuts a minor unless you ask for a major, and a
major is the one deliberate choice in the scheme:
relctl cut --majorWith gh
gh workflow run release-prepare.yaml --repo Azure/unbounded \
-f mode=release -f major=trueAsk for it when the release contains incompatible changes. While the project is
pre-1.0 the compatibility signal lives entirely in the major, so v1.0.0 is the
release that says compatibility now matters. See
Choosing a version.
See Recovery. If the soak cluster is the problem rather than the release, see Break glass.
For when someone is on v0.3.x and needs a fix without everything merged to
main since. Open the branch if it does not exist, cherry-pick the fix, cut a
patch.
Three things differ from a release cut from main, and all three bite before
you start:
-
It will not soak.
unbounded-stablesoaksmainonly. The patch skips deploy, Orca and smoke entirely, and then publishes - verified, but deployed nowhere, and it does not sit as a draft waiting for anyone. Why -
It may not be marked Latest. A patch on an older series leaves the install command pointing at the newer release, so shipping
v0.3.1afterv0.5.0changes nothing for new users. The rules -
A branch cut from an old tag runs no CI at all, because the trigger lists live in the branch's own copy of
.github/workflows/, and therelease-*ruleset requires those checks - so every pull request to it would be unmergeable. Branches cut fromv0.4.0or later are fine. Fromv0.3.xor earlier, addrelease-*toci.yaml'spushandpull_requestbranch filters as the branch's first commit.create-release-branchchecks this itself and warns, but only once dispatched: it is the workflow that resolves the branch point, so theghform with-f dry_run=truebelow is what reports it without creating anything.relctl branch create --dry-runprints what it would send and dispatches nothing, so it cannot tell you.
Release candidates are also pointless here: a candidate exists to soak, and one
cut from a release branch will not. Cut candidates on main.
The nightly only ever runs on the default branch, so it says nothing about a
release branch. relctl preflight --branch release-0.3 reports that rather than
showing a tick that refers somewhere else.
Check whether the branch already exists:
relctl status # "Release branches:" lists themOnly if it does not exist. The branch point is derived rather than given: the newest release in the series.
relctl branch create 0.3 # --dry-run prints the dispatch without sending itWith gh
gh workflow run create-release-branch.yaml --repo Azure/unbounded \
-f series=0.3 -f dry_run=true # resolve the branch point only
gh workflow run create-release-branch.yaml --repo Azure/unbounded -f series=0.3# 1. Cherry-pick the fix onto the branch through a pull request. Fixes land on
# main first and are cherry-picked down; never the other way round.
# 2. Check what it will cut. Run this ON the branch, or pass --branch: the
# version is resolved against whatever is checked out.
git checkout release-0.3 && git fetch --tags
relctl next
# 3. Cut the patch. --branch defaults to the checkout, so on the branch this
# is just:
relctl cut
# 4. Confirm it will not be marked Latest if main has moved past it. classify
# answers relative to the trunk, so it needs main checked out and refuses
# from anywhere else.
git checkout main && git fetch --tags
relctl classify v0.3.1With gh
gh workflow run release-prepare.yaml --repo Azure/unbounded \
-f mode=release -f branch=release-0.3 -f dry_run=true
gh workflow run release-prepare.yaml --repo Azure/unbounded \
-f mode=release -f branch=release-0.3Step 2 is worth doing rather than skipping: relctl next resolves against the
CHECKOUT, so running it on main while passing --branch release-0.3 applies
that branch's policy to main's history. It warns that the two disagree, and for
that particular pairing it then refuses outright, because the version it
computes falls outside the series the branch is allowed to cut.
The branch cuts v0.3.1, then v0.3.2, and can never mint a number main
owns.
See Recovery.
Worth it for anything you would not want to publish blind. Each candidate is
built, signed and soaked on unbounded-stable exactly like a final release, and
published as a prerelease, so it never becomes "Latest".
# 1. Start the train. Cut candidates on main: --branch release-X.Y is accepted
# but the candidate will not soak, which is the only reason to cut one.
relctl rc
# 2. Iterate. Same command: the train in flight is detected and the next rc
# taken automatically.
relctl rc
# 3. See where the train is at any point.
relctl status # live and stale trains
relctl next --mode prerelease # the rc that would be cut next
# 4. Promote when it is good.
relctl promoteThe same thing with gh only
gh workflow run release-prepare.yaml --repo Azure/unbounded -f mode=prerelease
gh workflow run release-prepare.yaml --repo Azure/unbounded -f mode=prerelease
gh workflow run release-prepare.yaml --repo Azure/unbounded -f mode=promoteThere is no gh equivalent of step 3: which trains are live is computed from
tags rather than stored anywhere.
relctl status is the reason to prefer it here: which trains are live has no
gh equivalent, because it is computed from tags rather than stored anywhere.
A train that was abandoned shows as stale, which is the state that orphaned
v0.1.24 at rc.18.
promote tags the last candidate's commit, not main HEAD, so anything
merged after that candidate is not in the release. That is a feature as much as
a constraint: cut a candidate as soon as your change lands and you hold that
exact tree, whatever merges afterwards. See
How trains work for the detail.
The rest of this document is what the guides above are built on: what each phase of the pipeline does, what to do when one of them goes wrong, and the two sanctioned overrides.
Everything is release-prepare.yaml. It only computes a version and pushes a
tag; it builds nothing, so a mistake here is cheap to undo. The commands are in
the guides above - from main or
from a release branch; what
follows is what they mean.
-f dry_run=true works with any mode and prints the version and commit it would
cut without pushing anything.
-f branch= selects what to cut from: main, or a release-X.Y branch.
Anything else is refused, so a tag can never come from a feature branch. The
bump follows from that choice and is not an input.
The release tooling itself always comes from main, even when cutting from a
release branch. The two supply different things: the branch supplies the history
the version is computed against, which is what scopes resolution to its series,
while main supplies the tool that does the computing. An old branch would
otherwise cut releases with whatever tooling existed when it was created.
A consequence is that a change to the resolver takes effect only once merged to
main. It cannot be rehearsed by dispatching this workflow from a branch,
dry_run included; the Go tests under hack/cmd/relctl are how you test it
before that.
release.yaml needs no input. It validates the tag shape, then builds the
binaries via GoReleaser, the frontend, every container image, the offline agent
artifacts and the manifests tarball. Everything is signed
with keyless cosign, images carry SPDX SBOM attestations, and a digest-pinned
release BOM records exactly what shipped.
It always creates the release as a draft. Prerelease is inferred from a -
in the tag.
release-upgrade.yaml starts automatically when the build finishes. A release
cut from a release branch skips straight past
the cluster work and publishes. Otherwise it:
- downloads the draft release's artifacts,
- verifies their signatures and the BOM against the tag's cosign identity,
- deploys to the
unbounded-stablecluster, - waits for the gated component workloads to roll out,
- deploys Orca, the origin cache, as an integration workload,
- runs the smoke tests in
hack/release/smoke/, taken from the default branch so an old tag still gets today's checks, - and only then flips the draft to published.
Step 4 gates on unbounded-operator, unbounded-net-controller,
unbounded-net-node, machina-controller, gantry, and on
metalman-controller-<site> for every Site that enables metalman. Metalman is a
per-Site component, so those targets are discovered from the cluster rather than
assumed: on unbounded-stable the cluster's own Site is stable while metalman
runs for a remote site. A cluster where no Site enables it fails the deploy,
because this one is expected to run it.
A clean deploy, a clean Orca deploy and green smoke are the soak gate.
Publishing is not a manual step. If you find yourself running
gh release edit --draft=false by hand, use the
break-glass path instead so the bypass is recorded.
Smoke tests cannot be skipped. Discovery fails if hack/release/smoke/ is empty
on the default branch, and publishing requires both discovery and every task to
succeed, so a release that ran no smoke tests stays a draft. Shipping one anyway
is the break-glass path, where it is recorded.
This is also why promote tags the last candidate's commit:
the soak is evidence about one specific tree, and it is only worth anything if
that is the tree that ships.
Prereleases are published too, but stay flagged as prereleases and never become "Latest".
A DaemonSet counts every node toward its desired count, including nodes the cluster has lost contact with, so one dead node would otherwise block every release forever. The gate tolerates a shortfall when it is caused only by NotReady nodes and everything on a reachable node is healthy.
MAX_NOTREADY_NODES caps how much is excusable: 2, for both the release
deploy and the nightly. Beyond the cap the shortfall is not excused and the
rollout fails, with the offending nodes named and grouped by site.
Tolerance is not a way to skip the upgrade. A shortfall is only excusable on a workload that already carries the release's image tag, so a rollout cannot be declared done while the operator is still reconciling the previous version.
relctl preflightIt checks the nightly, CI on the branch, and whether a train is already in flight, and says RELEASABLE or NOT RELEASABLE with the reason.
The same checks with gh
There is no single dashboard. Check these before cutting:
# The nightly deploys main to a real cluster and smoke-tests it. This is the
# strongest signal we have. It should be green, and green recently.
gh run list --repo Azure/unbounded --workflow nightly.yaml --limit 5
# CI on the commit you intend to release.
gh run list --repo Azure/unbounded --workflow ci.yaml --branch main --limit 5A red nightly is a release blocker until it is understood. It deploys the same component images the release will, to the same shape of cluster, so a nightly failure is a release failure you have not had yet - unless it is only unreachable nodes, which the rollout gate tolerates. See Degraded clusters.
"Green recently" is judged as within 48 hours, which allows for a weekend gap in scheduling while still refusing a week-old pass - that describes a tree nobody has released since.
The nightly only ever runs on the default branch, so it says nothing about a
release branch. relctl preflight --branch reports that rather than showing a
tick that refers somewhere else; see
Cut a patch.
You do not choose. The version is determined by where you are cutting from:
| Number | Cut from | Means |
|---|---|---|
major (v0.4.0 to v1.0.0) |
main, with -f major=true |
incompatible changes |
minor (v0.3.0 to v0.4.0) |
main |
every other release from main |
patch (v0.3.0 to v0.3.1) |
release-0.3 |
cherry-picked fixes to a shipped series |
main never cuts a patch, and a release branch never cuts anything else. That
is what lets the two coexist: every series' patch space belongs to exactly one
branch, so main and release-0.3 can never compute the same number. Without
it they would compete, and pre-1.0 they would compete for months, because a
series here runs twenty-odd releases before the minor turns over.
A major is the one deliberate choice, and it is never derived.
This follows semver 2.0.0 with one declared deviation.
While the project is pre-1.0 it is not even a deviation: clause 4 gives 0.y.z
total latitude, and the specification's own FAQ recommends exactly this scheme,
to "start your initial development release at 0.1.0 and then increment the
minor version for each subsequent release".
After 1.0, clause 6 says a release containing only backward compatible bug fixes
MUST increment the patch. A fixes-only release cut from main takes a minor
here instead.
The clearest way to hold this is that main's minor is a release counter, and
the compatibility signal lives entirely in the major. That is structural rather
than an oversight: patch cannot also be a counter on main without colliding
with the branch that owns it. Every other case is covered by clause 7, which
permits a minor for new functionality and says it may include patch level
changes, so an ordinary mixed release from main is correctly a minor. Where it
matters, the compliant route is also the better one: cherry-pick the fixes to a
release branch and cut a patch, which is what the branch is for.
What main's numbering no longer expresses is "this release contains no
behavior change", because minor absorbs patch. That signal was never reliable:
a release from main takes everything merged since, with no way to exclude it.
Patches now come only from a release branch carrying a deliberate set of
cherry-picks, so the patch signal means more than it used to, not less.
The prerelease suffix is rc.N, and release-prepare rejects anything else.
alpha and beta were previously used interchangeably with no defined
meaning.
Iterate a train by re-running prepare in prerelease mode with no input
changes; the next rc is chosen for you. Promote when it is good.
The operator resolves each component image as
<registry>/<repository>:<operator version>, where the version is the one
compiled into the operator binary. So machina, gantry, metalman
and the two unbounded-net-* images are all
deployed at the release tag. There is no per-component versioning, and a
component's image must be built by the release pipeline or its workload will not
start. internal/operator/imagecoverage_test.go enforces that.
A train is in flight when its core version has prerelease tags, no final tag, and is newer than the latest final tag.
prereleasecontinues the train in flight and takes the nextrc.N. Which bump the branch would otherwise take is only consulted when there is no train to continue, so it cannot fork a train halfway through.preis almost never needed. Leave it blank. An explicit value must berc.Nand must be ahead of the current highest.promoteresolves on its own when exactly one train is in flight. With several it refuses and asks which, rather than guessing.
promote creates the tag on the last candidate's commit, not on main
HEAD. The point of a candidate is that it was built, deployed to
unbounded-stable and smoke-tested; tagging HEAD would ship a different tree,
including everything merged since, under a version whose only claim to being
trustworthy is that soak. Anything merged after the last rc is therefore
not in the release. dry_run prints the commit and how many commits are
being left out.
If you want those commits, cut another candidate first and promote that one.
Starting a second train while one is in flight requires
-f allow_concurrent_trains=true, and once two exist, promote requires an
explicit version.
That guard exists because of a real incident: the v0.1.24 train reached
rc.18 and was silently orphaned when a v0.2.0 train started beside it.
Promote picked the newer train, v0.1.24 was never cut, and its twelve
candidate tags are still in the repository. Those older candidates are now
classified as stale rather than in flight, so they are reported for cleanup
and never offered as something to continue or promote.
main owns .0; a release branch owns .1 upward. Nothing else releases at
all. Because those spaces are disjoint, two branches can never compute the same
number, and the collision is impossible rather than merely detected.
A release branch is release-X.Y, created on demand. A maintenance
release is a patch cut from one. main is never a release branch, even though
releases are cut from it.
A worked sequence:
| Event | Branch | Tag | Soaked? |
|---|---|---|---|
| Normal release | main |
v0.4.0 |
yes |
| Start a candidate train | main |
v0.5.0-rc.1 |
yes, as a prerelease |
| Promote | main |
v0.5.0 |
yes |
A v0.4.0 user needs a fix |
release-0.4 created from v0.4.0 |
- | - |
| Cherry-pick, then cut | release-0.4 |
v0.4.1 |
no; publishes unsoaked |
| Work continues | main |
v0.6.0 |
yes |
| Second fix on the branch | release-0.4 |
v0.4.2 |
no; publishes unsoaked |
| The big one | main |
v1.0.0 |
yes |
unbounded-stable soaks main, and only main. A release cut from a release
branch skips deploy, Orca and smoke entirely, and then publishes - it does
not sit as a draft waiting for someone.
That is a decision about provenance, not about version numbers. The soak cluster
runs whatever main last put there, including a candidate, so "is this version
newer" is the wrong question: a release from a branch has no business on that
cluster whatever its number. Maintenance branches will get their own soak
clusters; until they do, these releases ship unsoaked and that is deliberate.
Verification is not skipped. Signatures and the release BOM are checked before publishing, by the same script the soaked path uses. Skipping a soak is a decision someone made; shipping something unverified is not.
Candidates on a release branch are a special case worth knowing. A candidate
exists to soak, so one cut from a branch cannot do its job: it will skip the
soak like everything else from that branch. Cut candidates on main.
"Latest" is what releases/latest/download resolves to, which is the install
command in the README and every guide, so it is set explicitly on every publish
rather than left to GitHub's default of "whatever was published most recently".
A release is marked Latest when no higher release exists on main's trunk or in
its own series, and it is not a prerelease. So:
- a release from
mainis Latest, as you would expect; - the first patch on a branch is Latest while
mainhas not moved on, because it genuinely is the newest release; - a patch from an older series is not, so shipping
v0.3.1afterv0.5.0leaves the install command pointing atv0.5.0; - republishing an older patch does not steal the marker back from a newer one.
Not published anywhere: the branch list is the answer, which is why branches are
created on demand rather than at every release. Security fixes land on main
and reach a maintained branch as cherry-picks; see SECURITY.md.
Note that a branch cut from a release predating release-* CI coverage will not
run any workflow, because the trigger lists live in the branch's own copy of
.github/workflows/. create-release-branch detects that and warns;
Cut a patch covers what to do
about it.
gh is authoritative, as Driving this says at the
top: the workflows are the interface, gh dispatches them, and where the two
disagree gh is right and relctl has a bug. What relctl adds is the
questions gh cannot answer, like which trains are live, which is computed from
tags rather than stored anywhere.
The one exception, where relctl is authoritative, is version resolution.
release-prepare calls relctl next internally, so relctl next and the
workflow give the same answer by construction rather than by agreement.
See hack/cmd/relctl/README.md for what each command needs; some work with no GitHub credential at all.
Three sanctioned overrides. All are manual-dispatch only: the automatic path carries no inputs and cannot be relaxed.
They are not equally dangerous, and relctl asks for a different confirmation
for each rather than treating them alike:
| Override | What it bypasses | Command | Confirmation |
|---|---|---|---|
| Tolerate more unreachable nodes | how many NotReady nodes are excusable | relctl soak <tag> --max-notready-nodes N |
y/N, with a warning |
| Re-initialize the cluster | site init instead of upgrade-apply |
relctl soak <tag> --force-init |
typed phrase |
| Publish without a soak | deploy, Orca and smoke entirely | relctl publish <tag> --reason ... |
typed phrase, no --yes |
A typed phrase cannot be satisfied by --yes, so the last two are not reachable
by reflex or by a script that passes --yes everywhere. Raising the NotReady
ceiling takes an ordinary prompt because it is a bounded relaxation that still
fails on anything unhealthy, not a way past the gate.
Re-running a soak with no override at all is an ordinary retry:
relctl soak v0.4.0When a known outage takes out more nodes than the cap allows:
relctl soak v0.4.0 --max-notready-nodes 7With gh
gh workflow run release-upgrade.yaml --repo Azure/unbounded \
-f tag=v0.4.0 -f max_notready_nodes=7This raises the ceiling only. A shortfall must still be entirely explained by NotReady nodes, and anything unhealthy on a reachable node still fails. State the number deliberately; it is a claim someone can review.
site init instead of upgrade-apply, for a first-ever bootstrap:
relctl soak v0.4.0 --force-initWith gh
gh workflow run release-upgrade.yaml --repo Azure/unbounded \
-f tag=v0.4.0 -f force_init=trueThis is not a recovery tool. On a cluster that is already initialized, site init creates a fresh Site rather than migrating the existing one, which is why
the workflow otherwise selects init mode only when it detects the pre-redesign
layout, and why relctl asks for a typed phrase here and an ordinary prompt for
a plain retry.
If the soak failed and the cluster is intact, re-run it without this.
For when the soak cluster itself is the problem and the release must ship:
relctl publish v0.4.0 --reason "unbounded-stable unreachable during DC maintenance"There is no --yes. The confirmation phrase must be typed in full.
With gh
gh workflow run release-upgrade.yaml --repo Azure/unbounded \
-f tag=v0.4.0 -f force_publish=true \
-f reason="unbounded-stable unreachable during DC maintenance"- The reason is required and is written into the release body, where it stays visible long after the CI logs expire.
- Artifact signatures and the release BOM are still verified. Forcing means "we accept an unsoaked release", never "we accept an unverified one".
- The deploy and smoke jobs still run, so their diagnostics are preserved.
- The forced path uses the
unbounded-reviewersenvironment, so it can be gated on reviewer approval independently of normal releases.
If you use this, file a follow-up to fix whatever made it necessary.
A tag was pushed but no build started. Usually a deploy-key problem. Delete
the tag and re-run prepare. There is no manual trigger on release.yaml by
design: a dispatched build would sign with the wrong identity and fail
verification downstream.
git push origin :refs/tags/v0.4.0The build failed partway. Re-run it. release.yaml is safe to re-run: the
draft release is opened once, up front, and every job that writes to it
replaces rather than appends.
gh run rerun <run-id> --failed --repo Azure/unbounded--failed re-runs only what failed and leaves successful jobs alone, which is
what you want for a transient cause: a registry blip, a runner, a permission
that has since been granted. Fix the cause first, because a re-run re-runs the
same tree.
Two limits on this:
- It has a seven-day shelf life.
finalizedownloads the GoReleaser dist and the manifests tarball by name, and those artifacts are kept for seven days. Past that,--failedwill not help: the jobs that produced them are still green, so it will not rebuild them, andfinalizefails on the missing artifact. Use a fullgh run rerun <run-id>instead, which reruns everything. - The release must still be a draft. If the soak already published it, the
build refuses to run rather than rewriting a shipped release's artifacts.
Re-draft it first with
gh release edit <tag> --draft=true.
A re-run cannot fix it. Only then delete the tag and cut it again. This is the expensive path: it burns a version number.
git push origin :refs/tags/v0.4.0Deleting the tag leaves the draft release behind. Deleting the draft too is
tidier but no longer required: the next cut of the same version adopts it and
overwrites its assets. If the tag came from promote, note that re-promoting
resolves the same candidate commit again, so a fix that is a code change needs
a new rc cut first - only a fix outside the tagged tree (a secret, a runner, a
registry) can be retried by re-promoting.
Where did it get to? relctl watch <tag> --once reports the build, every
soak attempt including manual retries, and whether the release published. It
correlates runs across all three workflows, which gh run list cannot: only the
build names the tag.
The soak failed. Read the failure before reaching for the override. The
deploy job's Diagnose failed rollout step dumps node readiness, Sites,
workloads, applied images and operator logs. A missing image is named
explicitly; so is a rollout blocked by unreachable nodes.
A release was published by mistake. Re-draft it:
gh release edit v0.4.0 --repo Azure/unbounded --draft=trueStale drafts. Abandoned candidates linger as drafts and should be deleted
once their train is promoted, otherwise the release list becomes misleading.
relctl status lists them with the date of the commit each points at, and
relctl status --all shows every one rather than the last 30 days. At the time
of writing there are 24, going back to v0.1.17 (2026-06-19).
./hack/test-release-local.sh rehearses the build phase on a workstation. See
CONTRIBUTING.md.