Skip to content

Type to search pages.

View .md

Cutting a release

The same tag deploys the hosted control plane, applies migrations to production before any traffic moves, and then waits on a human approval. That sentence is why this page exists. A tag here is not a bookkeeping act.

It also publishes the binary that curl -fsSL https://antifailure.dev/install.sh | sh hands to a stranger, and the installer follows releases/latest, so the download changes the moment the release is created. Two workflows fire on the same tag, they run in parallel, and neither knows the other exists.

This page is the order to do it in and the thing to look at after each step. Releases and how to verify one is the companion page, written for the person downloading a release rather than the person cutting one.

Workflow Triggered by What it does
.github/workflows/release.yml push of a tag matching v* Waits for CI, builds four platforms, packages, signs, and creates the GitHub release
.github/workflows/cd.yml push to main and push of a tag matching v* Waits for CI, builds the control plane image, deploys staging, then waits for a human to approve production

Two things follow from that table and both have bitten somebody somewhere.

release.yml has a gate of its own, and until recently it did not. A gate job runs before the build, waits for CI’s conclusion on the commit the tag names, and refuses anything but success. So a tag on a red commit now publishes nothing. It waits for about 38 minutes before giving up, and giving up is a refusal too. The judgement is tools/cigate.

That gate refuses a run GitHub reports as cancelled, and this is the case worth knowing about before you tag. GitHub uses that one word for three unrelated things: a job that hit its own time limit, a run somebody stopped by hand, and a run that a newer push superseded. None of them is a verdict, so none of them publishes.

ci.yml no longer cancels a superseded run on main or on a tag, which is why this is now rare rather than routine. Six merges once landed inside one run’s length and each cancelled the one before it, and main went hours with no completed run. If you do meet a cancelled run on the commit you want to tag, re-run CI on it, wait for green, then re-run the release from the Actions page.

Checking before you tag is still the cheaper order. The gate turns a mistake into a refused release rather than a published one, which is not the same as turning it into no mistake.

cd.yml runs a second time on the tag, on the same commit it already ran on when that commit merged to main. Its concurrency group is keyed on the ref, so the tag run and the main run are in different groups and do not queue behind each other. Wait for the main run to finish before you push the tag.

Everything here is read only. Run it all.

1. CI is green on the exact commit you are about to tag.

Terminal window
SHA=$(git rev-parse origin/main)
gh run list --commit "$SHA" --workflow ci.yml
gh run list --commit "$SHA" --json workflowName,conclusion,status \
--jq '.[] | "\(.workflowName)\t\(.status)\t\(.conclusion)"'

Read the second command’s output rather than counting checks. Do not assert a number. The count has been wrong every time somebody has quoted one: it was “seven” in a briefing while ci.yml alone had nine jobs, and splitting the credential scan into its own job took that to ten. Enumerate what actually ran on that sha and require every entry to be success.

A cancelled entry is resolved by WORKFLOW, not by trigger. A scheduled run can cancel a push-triggered run of the same workflow on the same commit, which leaves a cancelled row that is not a failure. Look at which workflow it belongs to and whether another run of that same workflow succeeded on that sha.

cd.yml’s first job polls for that same CI conclusion, as release.yml’s now does, and both give up after about 38 minutes. If CI has not finished when you tag, the tag’s deploy fails on a timeout rather than on anything real.

1a. just gate is not the bar, and cannot be met as written.

The bar is CI green on the sha, above. just gate is the local approximation of it and is deliberately a superset: coverage is in gate and CI does not run it at all. coverage reads a profile that coverage-profile writes, and coverage-profile is NOT in gate because producing it needs the whole engine suite against a Docker daemon and a Postgres and takes the better part of an hour. tools/gatecheck exempts it by name with that reason recorded.

So a clean checkout runs just gate and gets one red, coverage, over a profile nobody made. That is the documented exception and not a defect. Either run it first, or read the gate’s other lines and ignore that one:

Terminal window
just coverage-profile # about an hour, needs Docker and a Postgres
just coverage

Nothing else in gate is excused. A criterion nobody can meet is one people learn to skip, which is why this paragraph exists rather than a rule saying “all gates green” that is false on a fresh clone.

1b. Every branch that landed reached CI before it landed.

Pushing a w-* or prep-* branch to this repository runs NOTHING. ci.yml triggers on push to main and on pull_request, and no other branch triggers any workflow. A branch that was merged without a pull request has therefore never been through CI, and the tag’s commit is the first run of it. Open a draft pull request per branch before landing, so that its first CI run is not on main.

2. The main deploy of that commit has finished.

Terminal window
gh run list --workflow cd.yml --limit 3
curl -sS https://app.dev.antifailure.dev/readyz

The commit field in that answer should already be the commit you are tagging. Staging is then serving the build production is about to serve.

3. The release build works on this commit.

Terminal window
just ldcheck
just relnotes
just tagsync
just reproducible

ldcheck reads the -X flags out of tools/release/build.sh and proves each one names a variable that exists. The linker accepts a -X for a symbol it cannot find and says nothing, which is how v0.1.0 shipped four platforms that all reported themselves as dev.

relnotes and tagsync are the two that decide whether the tag can publish at all, and both are cheap here and expensive later. relnotes refuses a CHANGELOG.md section that is a heading with nothing under it; at tag time the same check runs inside release.yml, where the only remedy is deleting a tag people may already have fetched. tagsync refuses a version pin naming a tag nobody published, and holds the four version literals in verifying a release to the version at the top of the changelog.

4. The release notes are written before the tag, not after it.

release.yml passes generate_release_notes: false and a body_path that tools/relnotes writes, so the notes are the ## vX.Y.Z section of CHANGELOG.md with the verification instructions prepended. Write that section first: a tag whose section is missing or empty fails the release job, and by then the tag is pushed.

Read the section you are about to publish for figures. Anything counted out of the tree, commits, landings, pages, days, is counted against a tree that was still moving when it was written, and just figurecheck does not read this file. The v1.0.0 section carried a commit count that had drifted by 40 percent before anybody looked. Either re-count it against the commit you are tagging or take it out.

Nothing reads the fragments under .changes/, so they are the raw material and not the notes. Gather them into the changelog section by hand:

Terminal window
head -n 1 .changes/*.md | grep -v '^==>' | sort | uniq -c
cat .changes/*.md

5. Nothing in the release path has moved since it was last exercised.

Everything on this page was checked against a tree, not against the idea of a tree. Nothing in the mechanism depends on any particular branch having landed, so a release can be cut at any point. What does depend on the tree is whether the checks behind this page still describe what is about to run. Ask, rather than assume:

Terminal window
git diff --stat 8389faf..origin/main -- \
.github/workflows/release.yml .github/workflows/cd.yml \
tools/release/ tools/sbomcheck/ tools/ldcheck/ tools/relnotes/ \
tools/tagsync/ deploy/cd/ install.sh \
web/packages/db/migrations/

Empty output means this page still holds. Anything outside migrations/ means the pipeline changed and the rehearsal behind this page no longer covers it. A new file under migrations/ means production is being asked to apply a migration nobody on this page has read, and that one is worth stopping for: a migration is the only part of a deploy that cannot be rolled back.

It is not empty today, and here is what has been done about each half. install.sh and tools/release/build.sh have both moved since that revision, so the release build path was re-run rather than assumed: just build-release v1.0.0 on this tree, the archive unpacked and the binary inside it run out of the unpacked directory, af version reporting the version passed to the script with the real commit and that commit’s own date, the checksum file verified, just reproducible building twice with a cold cache and getting the same archive, and just ldcheck, just relnotes, just tagsync and just releasecheck green. That re-run is what found build.sh packaging an archive with no af in it, so the drift here was carrying a real defect and not only a stale sentence.

The migrations/ half is not resolved and is read below rather than here.

Terminal window
git tag -a v0.1.2 -m "v0.1.2"
git push origin v0.1.2

Annotated and unsigned, and pushed on its own. The signing in this pipeline is cosign over checksums.txt and the bill of materials, done by the publish job, and it does not depend on the tag carrying a signature. Setting up signed tags is optional and the steps are on the releases page. Do not push the tag in the same command as a branch: a tag that arrives before its commit is on main has no CI run for cd.yml to wait for.

Terminal window
gh run watch "$(gh run list --workflow release.yml --limit 1 --json databaseId --jq '.[0].databaseId')"

Five jobs. Four of them build one platform each and only compile; the fifth is the only one in the repository that holds contents: write.

Stage Green looks like Red means
build darwin-arm64 and its three siblings Each uploads one .tar.gz and one .sha256 A compile failure, or a -X flag naming a symbol that no longer exists. just ldcheck locally is the same question
Third party notices THIRD_PARTY_NOTICES.md regenerated from what is linked A dependency whose licence the generator does not know
Checksums Four lines in checksums.txt Fewer than four archives arrived, so a build job silently produced nothing
Unpack Four paths printed, one per platform Two archives unpacked over each other, which would leave the bill of materials describing three of four binaries
Software bill of materials An SPDX document written to dist/sbom.spdx.json syft failed. The document is not published unless the next stage passes
The bill of materials describes this release sbomcheck: <n> packages, 4 binaries, every one described, where n is in the hundreds The count is the load bearing number and the floor is 50. A document listing one package is what syft produces when it is pointed at archives instead of binaries, and it is valid SPDX, so only this stage can tell you
Sign the checksums and the bill of materials Two .sigstore.json bundles written Sigstore was unreachable, or the job lost id-token: write
The signature verifies, and a changed byte does not Verified OK twice, then a tampered checksums.txt was rejected, as it must be Either half failing stops the release. The second half failing means cosign accepted a file that does not match its signature, and every verification instruction the project publishes is worthless until that is understood
The release notes tools/relnotes prints the notes it wrote, opening with the verification instructions and then this version’s changelog section CHANGELOG.md has no ## vX.Y.Z section for this tag, or the section is empty. just relnotes before tagging is the same question, and the only remedy here is deleting a tag people may already have fetched
Release The tag appears under Releases with nine assets The publish itself failed. A files: pattern matching nothing is one of the ways, because fail_on_unmatched_files is set, which turns the silent version of this into a red stage. Nothing was signed with a key, so there is nothing to revoke

The two stages nobody has watched, and the two checks only a person can do

Section titled “The two stages nobody has watched, and the two checks only a person can do”

The signing and the bill of materials have never run in a real release. v0.1.1 predates both, and its assets are four archives, checksums.txt and THIRD_PARTY_NOTICES.md and nothing else. So the first real run of both is the release you are cutting. That is correct-looking code that has never executed, which is the category this project keeps getting caught by, and the answer is that somebody watches it rather than assuming it.

Every step of both has been rehearsed locally against the real artifacts: four platforms built, unpacked, catalogued by the exact syft version anchore/sbom-action pins rather than whatever was on the machine, and tools/sbomcheck watched passing on the good document and failing on the old broken shape. cosign sign-blob --bundle and verify-blob were exercised the same way, including the one byte change being rejected, with a local key pair.

Two things that rehearsal could not reach, so they are checked by hand, on the run, and neither has a tick that means anything on its own.

Did the release itself publish what it should have? Nine assets, not eight and not four:

Terminal window
gh release view v0.1.2 --json assets --jq '[.assets[].name]'

Four archives, checksums.txt and its bundle, sbom.spdx.json and its bundle, and THIRD_PARTY_NOTICES.md. Four assets is the shape of a release that published before the signing stage existed.

If it is not nine, do this. The release notes tell people to fetch a file that is not there, so the release is wrong even though every stage was green.

  1. Mark it immediately, before anything else. The installer is already serving it and every minute counts more than the diagnosis does:

    Terminal window
    gh release edit v0.1.2 --notes "Incomplete assets. Superseded shortly. Do not use."
  2. Find which asset is missing and read the log of the stage that produces it. A missing .sigstore.json means the signing stage; a missing sbom.spdx.json means syft or sbomcheck; a missing archive means one of the four build jobs. The stage cannot have failed, because a failure stops the release, so what you are looking for is a stage that succeeded while producing less than it should have. That is the same defect shape as the empty bill of materials, one layer up.

  3. Do not re-run the publish job against the same tag. softprops/action-gh-release would upload onto the existing release, so the tag would quietly come to mean something different from what people already downloaded. Fix the cause and cut the next patch, following If a release goes out wrong.

Did the Fulcio identity binding work? Open the log of the stage named The signature verifies, and a changed byte does not. It runs cosign verify-blob with --certificate-identity bound to this workflow, in this repository, at this tag. That is the only thing in the pipeline that proves the certificate says who signed rather than merely that somebody did, and it cannot be exercised anywhere but on GitHub, because the certificate is issued against the job’s own OIDC token. It must print Verified OK twice and then a tampered checksums.txt was rejected, as it must be. A green tick on that stage without those three lines in its log is not the same thing.

The tag’s cd run is a second run, distinct from the one main already had.

Stage Green looks like Red means
gate CI is green on <sha> in the step summary CI is not green on this commit, or it never ran on it. Nothing has deployed. Fix main, then tag again with a new patch version
build A digest printed, then bootstrap refuses and names the variable The image does not build, or it built without the entrypoints the deploy needs
staging DEPLOYED: https://app.dev.antifailure.dev is serving <sha> See the failure table below. Production does not start
production Waits for a reviewer, then the same line for https://app.antifailure.dev See below

Production does not begin until somebody named on the production environment approves it. That approval is a deployment protection rule rather than an if: in the workflow, so it cannot be edited in the same pull request that deploys.

  1. Asks Azure whether afcpprod-app exists in af-cp-prod-centralus, and refuses by asking rather than by asserting. It stops being a refusal the moment the apply has happened, with no workflow edit.
  2. Runs tools/azguard against the resource group, offline, failing closed.
  3. Runs deploy/cd/deploy.sh, which reads what is serving now, applies migrations first in the afcpprod-bootstrap job, creates the new revision at zero traffic, health checks it on its own address, shifts traffic, health checks the public origin, and rolls traffic back if that last check fails.
  4. After both health checks pass, points afcpprod-maintenance at the exact image digest staging tested and reads the job back. A failed candidate cannot change the scheduled process that runs DDL later.

The migration runs before any traffic moves. If it fails, nothing has changed and the previous revision is still serving. That ordering is the reason a failed release is usually a non event.

Migrations are not rolled back. deploy.sh can put traffic back on the old revision and cannot un-apply a schema change, so the old code has to tolerate the new schema. Read every migration in the tag that production has not seen before you approve, and satisfy yourself that each one is additive.

Terminal window
git diff --name-only v0.1.1..v0.1.2 -- web/packages/db/migrations

This is the first time production will deploy itself

Section titled “This is the first time production will deploy itself”

Every production cd run so far has been skipped. The script inside it has run against production once, by hand: afcpprod-app carries a revision named afcpprod-app--cf66d6af2-164545, which is deploy.sh’s own naming, and afcpprod-bootstrap has exactly one execution, Succeeded, a minute before it. So the script is not the untested part. The job around it is.

What has no prior run behind it:

  • azure/login under the production environment needs a federated credential for repo:antifailure/antifailure:environment:production. Staging’s proves the pattern and not this subject. This is the step to read first if the job fails early with nothing else to go on.
  • tools/azguard against af-cp-prod-centralus. It is offline and fails closed, and it has only ever been pointed at staging’s group.
  • The approval itself.

The app is in Multiple revision mode with one revision at 100 percent, so there is a revision to roll back onto. The case where there is not is the one deploy.sh reports plainly rather than pretending a rollback happened.

This is an unusually large deploy, and one of its migrations wants a window

Section titled “This is an unusually large deploy, and one of its migrations wants a window”

Production is serving f66d6af. Ask how far ahead the tag is rather than carrying a number that goes stale between two merges:

Terminal window
curl -sS https://app.antifailure.dev/readyz
git rev-list --count f66d6af..origin/main

At the time of writing that was 178, so the first tag is not a normal increment. It is every change since, arriving at once.

Ask which migrations rather than reading a count off this page, because the count has already gone stale once:

Terminal window
git diff --name-only f66d6af..origin/main -- web/packages/db/migrations

All of them have been checked, and the checks are recorded here so nobody repeats them nervously at tag time. Every migration from 0001 to 0023 applies cleanly to a real PostgreSQL 17 from an empty database, and 0023 was applied a second time to a database built to 0022 and then seeded, so that it met existing rows rather than an empty table. It validated its constraint and left every seeded session in place.

Correctness is settled. Duration is not, and that is the one thing to decide before you approve production. The seeded table held 500 rows and production does not, so what has been proved is that these migrations do the right thing, not that they do it quickly enough to run while the site is serving.

Only three tables that exist at 0017 are touched at all. Everything else in 0018 to 0023 creates a new table, which locks nothing and cannot block a running request. The three are network_rules, users and sessions, and this is every operation against them:

  • Nine nullable column adds with no default, five on users and four on sessions. On PostgreSQL 11 and later these rewrite nothing and touch only the catalog, whatever the table holds.
  • 0018 backfills network_rules, in the same transaction as its own schema change, and builds network_rules_pending_idx without CONCURRENTLY. That takes a SHARE lock and blocks writes to network_rules for the length of the build. It is a small table.
  • sessions.impersonated_by carries a foreign key to users, so adding it locks users as well as sessions. Nothing on this path sets a lock_timeout, which is the same exposure 0018 already has and which is written out under the three migration budgets below.
  • Two full scans of sessions, which is the hottest table in the product, because resolveSession reads it on every request. ADD CONSTRAINT sessions_impersonation_is_complete takes ACCESS EXCLUSIVE and validates every existing row, and sessions_impersonated_idx is partial but still reads the whole table to evaluate its predicate. For as long as the first of those runs, every authenticated request waits.

So measure before you approve, rather than assuming the table is small:

Terminal window
psql "$PROD_URL" -c "SELECT count(*) FROM sessions"
psql "$PROD_URL" -c "SELECT count(*) FROM network_rules"

sessions holds live sessions rather than history, so it is bounded by how many people are signed in and is very likely small enough that none of this matters. If it is not, this deploy needs a quiet window. The standard way out is ADD CONSTRAINT ... NOT VALID followed by VALIDATE CONSTRAINT as a separate statement, which holds ACCESS EXCLUSIVE only for an instant and validates under a lock that lets writes through. That is deliberately not being done to 0023: a migration’s digest is frozen the moment it is applied anywhere, staging has already applied this one, and migrate refuses a file whose digest has changed. If the split is ever wanted it belongs in a later migration, not in a rewrite of this one.

The two records below are from the earlier rehearsal and are kept because they say what was observed rather than what was expected. 0001 through 0017 were applied to a real PostgreSQL 17, seeded with two organizations and three network_rules rows, and then 0018 and 0019 were applied on top.

  • 0018 adds three nullable columns to network_rules and backfills approved_at from created_at. After it ran, zero rules were left pending and every existing one carried approved_at = created_at with no approver, which is the true statement: nobody approved them because there was nothing to approve with. No live egress rule stops enforcing.
  • 0019 creates runtimes. Row level security is enabled and forced, proved not by reading the catalog but by connecting as a real unprivileged role that is a member of antifailure_app: the other tenant’s runtime is invisible, a query with no organization set returns zero rows, and an insert aimed at another tenant is refused by the policy.

Every one of 0018 to 0023 is additive, which is what makes a rollback safe: deploy.sh can put traffic back on the old revision and cannot un-apply a schema change, so the old code has to tolerate the new schema. Nothing in the range drops a column, drops a table, renames anything, or adds a NOT NULL to a column that already exists, which is the property that lets the currently deployed revision keep running against the new schema.

There is no window between publishing and shipping

Section titled “There is no window between publishing and shipping”

The installer resolves latest from the GitHub releases API. That is good news with a sharp edge: a new tag is picked up with no further step and nothing to publish by hand, and it is picked up the moment the release is created. The next person to run the install command gets it, whether or not anybody has looked at it yet.

So the checks below are not a gate. By the time you run them the download is already live, and what they decide is whether to announce it and whether to cut the next patch immediately. If you want a version people cannot reach yet, the release has to be a GitHub prerelease, which releases/latest skips by definition, and release.yml does not currently create one.

That same API has one more property, and it decides whether a recovery works rather than whether a release does, so it is written out under If a release goes out wrong where you will need it: latest follows the newest tagged commit, not the newest publish.

From outside, with nothing of yours in the path.

Terminal window
curl -fsSL https://antifailure.dev/install.sh | AF_PREFIX=$(mktemp -d) sh

Then check that the binary knows what it is:

Terminal window
af version

version, commit and built are stamped by the linker from the tag, the commit and that commit’s own date. A binary reporting dev, none and unknown means the -X flags missed, which is a release to replace rather than to explain.

Then check production is serving the tag:

Terminal window
curl -sS https://app.antifailure.dev/readyz
Symptom What it means What to do
gate times out No CI conclusion for this commit inside twenty minutes Nothing deployed. Wait for CI, then re run the cd run
sbomcheck reports a low package count The bill of materials describes the directory rather than the binaries Nothing published. Read the unpack stage above it: it printed the binaries it found
The tampered file was accepted cosign is not rejecting a file that does not match its signature Nothing published, and this is the loudest thing in the pipeline. Do not retry it
MIGRATION FAILED The bootstrap job returned Failed or Degraded No traffic moved. Read the job’s logs before retrying. A partly applied schema is not something the script papers over
MIGRATION DID NOT FINISH within the budget The job was still running when deploy.sh stopped watching No traffic moved and nothing was killed. The shorter budget belongs to the watcher, not to anything that can terminate a replica. Let the job finish, confirm the execution succeeded, then re run the deploy, which will find the schema already up to date. See the note below
NEW REVISION FAILED TO START The revision never reached Running Traffic never moved. The new revision is deactivated
healthy but wrong build The origin answers, on the previous commit The rollout did not happen. This is the check that exists to catch exactly that, and it is doing its job
ROLLED BACK The deploy failed and the damage was contained The previous revision is serving again. The job still fails, which is correct: a successful rollback is not a successful deploy
ROLLBACK DID NOT RESTORE HEALTH Both builds are unhealthy This needs a person. Start at Operations

The three migration budgets, and which one can kill something

Section titled “The three migration budgets, and which one can kill something”

Three numbers govern the migration step and only one of them can terminate anything. Written down because working out which is which under pressure is exactly what a runbook is for.

Budget Value What happens when it runs out
The migration’s own work measured at about a sixth of a second Nothing. 0018 and 0019 were timed against 2000 network_rules rows, far more than production carries
deploy.sh’s poll 60 attempts five seconds apart, so five to seven minutes of wall clock It stops watching and refuses to move traffic. It kills nothing. The job carries on and usually succeeds a moment later
The job’s replica_timeout_in_seconds 600, with replica_retry_limit = 2 The replica is terminated. This is the only budget that can kill a migration, and it is the longest of the three

So the mismatch is the harmless way round: the shorter budget belongs to the observer. The failure it produces is a deploy that did not happen while the schema moved forward, which is recoverable by re running the deploy.

The one path to 600 seconds is not work, it is waiting. 0018 takes an ACCESS EXCLUSIVE lock on network_rules and a SHARE ROW EXCLUSIVE lock on users for its foreign keys, and nothing sets lock_timeout anywhere on this path, so it waits for as long as another transaction holds what it needs. The old revision is still serving while this happens, so a long transaction over users is what would do it.

That is checked against the running server rather than inferred from the repository. az postgres flexible-server parameter show -g af-cp-prod-centralus -s afcpprod-pg -n lock_timeout returns 0 from system-default, and nothing in the migration path sets one per session either.

This product’s own migration linter agrees, and says it better than this page can. Run against 0018 on a database at 0017, its no_lock_timeout rule fires and names the mechanism exactly: “A lock request that is not granted immediately queues, and every query that arrives after it queues behind the request rather than behind the table, so a statement that would have taken milliseconds stops all traffic on network_rules for as long as whatever it is waiting for runs.”

It has never seen these migrations, because insights.Discover looks for a SQL migration directory at the repository root and the control plane’s live at web/packages/db/migrations. That is a dogfooding gap rather than a broken check, and it does not change the risk here: the fix the rule asks for cannot go into 0018 or 0019 now, because staging has already applied both and migrate refuses a file whose digest has changed. If a lock_timeout is wanted, it belongs on the migration role or in bootstrap.mjs before migrate() runs, which covers every migration without editing any of them.

Even then nothing half applies. Each migration file is one transaction and is recorded in the same transaction that ran it, so a terminated replica drops the connection, PostgreSQL rolls the file back, and it is not written down as applied. The retry takes the advisory lock and runs it again from the start.

Do not delete the tag and push it again. A tag that changes meaning breaks everybody who already fetched it, and it breaks the signature’s identity binding, which names the tag. Cut the next patch version and mark the bad release as such on GitHub.

Terminal window
gh release edit v0.1.2 --notes "Superseded by v0.1.3. Do not use."

The replacement has to be tagged on a newer commit, and this is the part that will catch somebody. GitHub decides which release is latest as “the most recent non-prerelease, non-draft release, sorted by the created_at attribute”, where created_at “is the date of the commit used for the release, and not the date when the release was drafted or published”. The installer follows that, so it follows the newest tagged commit, not the newest publish.

A hotfix cut from an older commit therefore publishes perfectly, reports nothing wrong, and never reaches a single installer: latest stays on the bad release. There is no error anywhere, and it strikes at precisely the moment somebody is trying to pull a bad release back.

A patch branched off main is always newer, so the ordinary path is safe. The case to refuse is reverting to an earlier good commit and tagging that. If the bad release has to be undone rather than moved past, revert the commits on main and tag the revert, so the tagged commit is still the newest one.

Terminal window
git log -1 --format=%cI v0.1.2 # the bad release's commit date
git log -1 --format=%cI v0.1.3 # must be later than the line above

The hosted control plane is a separate decision from the published binary. If the binary is wrong and production is fine, leave production alone.