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.
What one tag sets off
Section titled “What one tag sets off”| 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.
Before you tag
Section titled “Before you tag”Everything here is read only. Run it all.
1. CI is green on the exact commit you are about to tag.
SHA=$(git rev-parse origin/main)gh run list --commit "$SHA" --workflow ci.ymlgh 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:
just coverage-profile # about an hour, needs Docker and a Postgresjust coverageNothing 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.
gh run list --workflow cd.yml --limit 3curl -sS https://app.dev.antifailure.dev/readyzThe 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.
just ldcheckjust relnotesjust tagsyncjust reproducibleldcheck 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:
head -n 1 .changes/*.md | grep -v '^==>' | sort | uniq -ccat .changes/*.md5. 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:
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.
git tag -a v0.1.2 -m "v0.1.2"git push origin v0.1.2Annotated 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.
Watching release.yml
Section titled “Watching release.yml”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:
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.
-
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." -
Find which asset is missing and read the log of the stage that produces it. A missing
.sigstore.jsonmeans the signing stage; a missingsbom.spdx.jsonmeans syft orsbomcheck; 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. -
Do not re-run the publish job against the same tag.
softprops/action-gh-releasewould 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.
Watching cd.yml
Section titled “Watching cd.yml”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.
What the production job does, in order
Section titled “What the production job does, in order”- Asks Azure whether
afcpprod-appexists inaf-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. - Runs
tools/azguardagainst the resource group, offline, failing closed. - Runs
deploy/cd/deploy.sh, which reads what is serving now, applies migrations first in theafcpprod-bootstrapjob, 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. - After both health checks pass, points
afcpprod-maintenanceat 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.
git diff --name-only v0.1.1..v0.1.2 -- web/packages/db/migrationsThis 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/loginunder theproductionenvironment needs a federated credential forrepo: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/azguardagainstaf-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:
curl -sS https://app.antifailure.dev/readyzgit rev-list --count f66d6af..origin/mainAt 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:
git diff --name-only f66d6af..origin/main -- web/packages/db/migrationsAll 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
usersand four onsessions. On PostgreSQL 11 and later these rewrite nothing and touch only the catalog, whatever the table holds. 0018backfillsnetwork_rules, in the same transaction as its own schema change, and buildsnetwork_rules_pending_idxwithoutCONCURRENTLY. That takes a SHARE lock and blocks writes tonetwork_rulesfor the length of the build. It is a small table.sessions.impersonated_bycarries a foreign key tousers, so adding it locksusersas well assessions. Nothing on this path sets alock_timeout, which is the same exposure0018already 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, becauseresolveSessionreads it on every request.ADD CONSTRAINT sessions_impersonation_is_completetakes ACCESS EXCLUSIVE and validates every existing row, andsessions_impersonated_idxis 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:
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.
0018adds three nullable columns tonetwork_rulesand backfillsapproved_atfromcreated_at. After it ran, zero rules were left pending and every existing one carriedapproved_at = created_atwith no approver, which is the true statement: nobody approved them because there was nothing to approve with. No live egress rule stops enforcing.0019createsruntimes. 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 ofantifailure_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.
After it is green
Section titled “After it is green”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.
Prove the thing a stranger gets
Section titled “Prove the thing a stranger gets”From outside, with nothing of yours in the path.
curl -fsSL https://antifailure.dev/install.sh | AF_PREFIX=$(mktemp -d) shThen check that the binary knows what it is:
af versionversion, 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:
curl -sS https://app.antifailure.dev/readyzWhen a stage fails
Section titled “When a stage fails”| 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.
If a release goes out wrong
Section titled “If a release goes out wrong”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.
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.
git log -1 --format=%cI v0.1.2 # the bad release's commit dategit log -1 --format=%cI v0.1.3 # must be later than the line aboveThe hosted control plane is a separate decision from the published binary. If the binary is wrong and production is fine, leave production alone.