Release/deploy facts measured during the 2026-08-06 stuck cut — for the next clean release #36

Closed
opened 2026-08-06 11:25:00 +02:00 by engineer · 4 comments
Owner

Recording what was measured during the 2026-08-06 stuck-cut morning, so the next release doesn't
rediscover it. The operator picked B for that incident; this is for whoever does the clean cut later.

purser publishes immediately — it does not stop at a draft

release.yml:47        publish_mode: ${{ inputs.publish_mode || 'immediate' }}
release-toolkit.yml   no publish_mode key (layer 2 absent, so layer 3 wins)
push-triggered run    inputs empty => immediate

The toolkit default is draft (ADR-0003 Gate-3). purser overrides it. So a successful cut here
publishes the release, which fires deploy.yml. There is no human gate between cut and deploy on the
current config.

If a gate is wanted, that's a one-line change to release.yml or a publish_mode: key in
release-toolkit.yml — not a workflow rewrite.

The v0.1.0 precedent does not mean publishing is safe

v0.1.0 was published and nothing deployed. The reason is that deploy.yml was not in the tagged
tree — it landed on main at 22:28 and the tag predates it by ten minutes.

ref        deploy.yml   control go-ci.yml
v0.1.0     404          200        => genuinely absent, ref resolves
9f732257   200          200
fe2699fc   200          200

Both current candidates contain it. Anyone reasoning "publishing was fine last time" would fire the
deploy on a precedent that only held because the workflow didn't exist yet. Measured by
Quartermaster; control by Surveyor.

Tag and publish are separable

All five workflows have zero tag triggers:

deploy.yml          release:[published] · workflow_dispatch
release.yml         push:branches[main] (paths-ignore manifest) · workflow_dispatch
go-ci.yml           pull_request · push:branches[main] · workflow_dispatch
fragment-check.yml  pull_request
manifest-check.yml  pull_request

So git push origin <tag> emits no release event and cannot deploy. Creating and publishing the
release is the step that deploys. If a future recovery needs the repo consistent without shipping,
that split is available — but state it as "stopping deliberately short of what a cut would do", not
as "reproducing what a cut would do", because with publish_mode: immediate those differ.

That wording matters: a later reader who checks the default, finds immediate, and concludes the
hold rested on a mistake would publish and fire the deploy. The false rationale is what removes the
safeguard.

Tag target has a downstream consequence

Measured by Quartermaster with rt v0.35.0 decide --dry-run, control arm first:

tag at 9f732257 (prep commit)  orphan clears, mode=UPDATE, next version 0.2.1
                               => primes a v0.2.1 cut on the next push to main
tag at fe2699fc (main tip)     orphan clears, mode=NOOP => quiescent

Tagging the prep commit leaves later commits reading as new work. The next person to merge anything
gets an unexpected release.

Do not hand-write a manifest

purser has none and runs on release-decide.sh's bootstrap-from-tags path. A hand-written manifest
missing "schema": 1 converts a working bootstrap into a hard FATAL (release-decide.sh:136-143).

Separately: purser is a path-gamma consumer, gamma delivers the manifest by PR, and no manifest PR
has ever existed here. Filed as release-toolkit#661 — an observation, since the v0.1.0 run log
hasn't been read.

Not verified

Nobody has watched a cut complete on this repo end to end. Every finding above is a source read or a
--dry-run, which exercises decide's git-side logic and skips the API-dependent layers.

Recording what was measured during the 2026-08-06 stuck-cut morning, so the next release doesn't rediscover it. The operator picked B for that incident; this is for whoever does the clean cut later. ## purser publishes immediately — it does not stop at a draft ``` release.yml:47 publish_mode: ${{ inputs.publish_mode || 'immediate' }} release-toolkit.yml no publish_mode key (layer 2 absent, so layer 3 wins) push-triggered run inputs empty => immediate ``` The toolkit default is `draft` (ADR-0003 Gate-3). purser overrides it. So a successful cut here publishes the release, which fires deploy.yml. There is no human gate between cut and deploy on the current config. If a gate is wanted, that's a one-line change to `release.yml` or a `publish_mode:` key in `release-toolkit.yml` — not a workflow rewrite. ## The v0.1.0 precedent does not mean publishing is safe v0.1.0 was published and nothing deployed. The reason is that `deploy.yml` was not in the tagged tree — it landed on main at 22:28 and the tag predates it by ten minutes. ``` ref deploy.yml control go-ci.yml v0.1.0 404 200 => genuinely absent, ref resolves 9f732257 200 200 fe2699fc 200 200 ``` Both current candidates contain it. Anyone reasoning "publishing was fine last time" would fire the deploy on a precedent that only held because the workflow didn't exist yet. Measured by Quartermaster; control by Surveyor. ## Tag and publish are separable All five workflows have zero tag triggers: ``` deploy.yml release:[published] · workflow_dispatch release.yml push:branches[main] (paths-ignore manifest) · workflow_dispatch go-ci.yml pull_request · push:branches[main] · workflow_dispatch fragment-check.yml pull_request manifest-check.yml pull_request ``` So `git push origin <tag>` emits no release event and cannot deploy. Creating and publishing the release is the step that deploys. If a future recovery needs the repo consistent without shipping, that split is available — but state it as "stopping deliberately short of what a cut would do", not as "reproducing what a cut would do", because with `publish_mode: immediate` those differ. That wording matters: a later reader who checks the default, finds `immediate`, and concludes the hold rested on a mistake would publish and fire the deploy. The false rationale is what removes the safeguard. ## Tag target has a downstream consequence Measured by Quartermaster with `rt v0.35.0 decide --dry-run`, control arm first: ``` tag at 9f732257 (prep commit) orphan clears, mode=UPDATE, next version 0.2.1 => primes a v0.2.1 cut on the next push to main tag at fe2699fc (main tip) orphan clears, mode=NOOP => quiescent ``` Tagging the prep commit leaves later commits reading as new work. The next person to merge anything gets an unexpected release. ## Do not hand-write a manifest purser has none and runs on `release-decide.sh`'s bootstrap-from-tags path. A hand-written manifest missing `"schema": 1` converts a working bootstrap into a hard FATAL (`release-decide.sh:136-143`). Separately: purser is a path-gamma consumer, gamma delivers the manifest by PR, and no manifest PR has ever existed here. Filed as release-toolkit#661 — an observation, since the v0.1.0 run log hasn't been read. ## Not verified Nobody has watched a cut complete on this repo end to end. Every finding above is a source read or a `--dry-run`, which exercises decide's git-side logic and skips the API-dependent layers.
Author
Owner

What actually gates a cut here — added after #35 settled

The unwind is done (#35). Three facts from settling it belong with the rest, because they will
otherwise be re-derived at the real v0.2.0.

The prepare commit is still armed, and the CHANGELOG has nothing to do with it

9f73225 chore(release): prepare v0.2.0 remains in v0.1.0..HEAD. History is append-only, so
neither renaming the section nor reverting #25 removes it — Layer 1's range-scan still matches it
(buried-prepare, #259). Whenever the real v0.2.0 goes out, that buried commit is still the thing
authorising the version.

Layer 2 is what gates the cut, not the CHANGELOG state

release-decide.sh gates the whole cut on LAYER2_RESULT != "fail". Layer 2 compares the merged
PR's head.label to ROLLING_BRANCH_NAME, which defaults to release-prep/rolling and is never
exported by the reusable.

merge of a release-prep/rolling PR   PASS  -> cut         (this is what happened at 10:12)
merge of any other PR                FAIL  -> mode=update
direct push to main                  no PR -> lookup empty -> treated as fail -> mode=update

So an ordinary merge to main cannot cut. Only merging the rolling PR does. Layer 1 finding the
prepare commit is necessary and not sufficient, which is the part all three of us initially reasoned
past.

Practical consequence: the buried prepare commit is inert until someone merges a
release-prep/rolling PR — and at that moment publish_mode: immediate (above) means it publishes
and deploys in one step.

Why B2 rather than B1, measured

B1 (rename the heading, leave fragments deleted) duplicates. changelog_dedup_cc_by_fragment_refs()
takes only fragments_dir and commits_file, so the [Unreleased] prose is never in scope; with no
fragments the ref set is empty and every commit bullet passes through. Control run by @bosun on the
two candidate trees:

B1  26 bullets   three deploy bullets verbatim twice in one Added block, plus
                 "profile: pin the key baseline to measured RSA-3072" (commit subject)
                 alongside "purser: the profile gate's key baseline is pinned to the
                 measured RSA-3072" (#34's prose) — one change, two entries, different
                 strings, nothing textual would catch it
B2  16 bullets   what shipped

Fragments carry the dedup keys. That is the reason to restore them, and it is unrelated to deferral.

Caveat carried forward

No cut has been watched end to end on this repo. Layer 2's fall-through is established from the
source plus head.label reads, not from a live post-merge run.

## What actually gates a cut here — added after #35 settled The unwind is done (#35). Three facts from settling it belong with the rest, because they will otherwise be re-derived at the real v0.2.0. ### The prepare commit is still armed, and the CHANGELOG has nothing to do with it `9f73225 chore(release): prepare v0.2.0` remains in `v0.1.0..HEAD`. History is append-only, so neither renaming the section nor reverting #25 removes it — Layer 1's range-scan still matches it (`buried-prepare`, #259). Whenever the real v0.2.0 goes out, that buried commit is still the thing authorising the version. ### Layer 2 is what gates the cut, not the CHANGELOG state `release-decide.sh` gates the whole cut on `LAYER2_RESULT != "fail"`. Layer 2 compares the merged PR's `head.label` to `ROLLING_BRANCH_NAME`, which defaults to `release-prep/rolling` and is never exported by the reusable. ``` merge of a release-prep/rolling PR PASS -> cut (this is what happened at 10:12) merge of any other PR FAIL -> mode=update direct push to main no PR -> lookup empty -> treated as fail -> mode=update ``` So an ordinary merge to main cannot cut. Only merging the rolling PR does. Layer 1 finding the prepare commit is necessary and not sufficient, which is the part all three of us initially reasoned past. Practical consequence: the buried prepare commit is inert until someone merges a `release-prep/rolling` PR — and at that moment `publish_mode: immediate` (above) means it publishes and deploys in one step. ### Why B2 rather than B1, measured B1 (rename the heading, leave fragments deleted) duplicates. `changelog_dedup_cc_by_fragment_refs()` takes only `fragments_dir` and `commits_file`, so the `[Unreleased]` prose is never in scope; with no fragments the ref set is empty and every commit bullet passes through. Control run by @bosun on the two candidate trees: ``` B1 26 bullets three deploy bullets verbatim twice in one Added block, plus "profile: pin the key baseline to measured RSA-3072" (commit subject) alongside "purser: the profile gate's key baseline is pinned to the measured RSA-3072" (#34's prose) — one change, two entries, different strings, nothing textual would catch it B2 16 bullets what shipped ``` Fragments carry the dedup keys. That is the reason to restore them, and it is unrelated to deferral. ### Caveat carried forward No cut has been watched end to end on this repo. Layer 2's fall-through is established from the source plus `head.label` reads, not from a live post-merge run.
Author
Owner

Caveat update — NARROWED, not lifted

Corrected in place. An earlier revision of this comment said the Layer 2 half was "measured,
not read" and listed only the end-to-end cut as outstanding. That overstated it, and @bosun
— who ran the measurement — flagged the gap against his own result. The honest scope is below.

@bosun drove the real Layer-2 code path without a live merge, using the seam the toolkit's own
FAIL-path tests use (LAYER2_BYPASS_DRY_RUN=1 + FORGEJO_TEST_PR_LOOKUP_FILE), two arms against the
post-merge tree:

head.label = release-prep/rolling       -> Layer 2 PASS -> mode=cut     (control: still cuts)
head.label = chore/unwind-v0.2.0-prep   -> Layer 2 FAIL -> mode=update

The control arm is what makes it evidence rather than a harness that answers update to everything,
and it reproduces the live 10:12 outcome — #25's label was release-prep/rolling.

Exactly what is and is not established

MEASURED    Layer 2's branch-name comparison, including a control arm returning the
            opposite answer
MOCKED      the lookup feeding it. FORGEJO_TEST_PR_LOOKUP_FILE supplies the response, so
            forgejo_find_pr_by_merge_sha never ran. head.label was taken from the live API,
            but "this lookup returns this PR for this merge SHA" is an assumption, not an
            observation.
UNTOUCHED   the act phase, the release job, everything downstream of decide
UNTOUCHED   a full cut end to end — tag, release, manifest, deploy — has never been watched
            on this repo

@surveyor separately enumerated that all three lookup outcomes (match, empty, empty head.label)
land on fail, which is why the mocked leg is reasonable rather than load-bearing. It is still an
assumption and should be named as one.

Why this correction is here rather than dropped

The person who ran the measurement is the one who narrowed it, unprompted and against his own result.
A caveat that gets removed because a partial measurement landed is worse than one that was never
written — the next reader inherits "this was checked" with no way to see which layer.

## Caveat update — NARROWED, not lifted > **Corrected in place.** An earlier revision of this comment said the Layer 2 half was "measured, > not read" and listed only the end-to-end cut as outstanding. **That overstated it**, and @bosun > — who ran the measurement — flagged the gap against his own result. The honest scope is below. @bosun drove the real Layer-2 code path without a live merge, using the seam the toolkit's own FAIL-path tests use (`LAYER2_BYPASS_DRY_RUN=1` + `FORGEJO_TEST_PR_LOOKUP_FILE`), two arms against the post-merge tree: ``` head.label = release-prep/rolling -> Layer 2 PASS -> mode=cut (control: still cuts) head.label = chore/unwind-v0.2.0-prep -> Layer 2 FAIL -> mode=update ``` The control arm is what makes it evidence rather than a harness that answers `update` to everything, and it reproduces the live 10:12 outcome — `#25`'s label was `release-prep/rolling`. ### Exactly what is and is not established ``` MEASURED Layer 2's branch-name comparison, including a control arm returning the opposite answer MOCKED the lookup feeding it. FORGEJO_TEST_PR_LOOKUP_FILE supplies the response, so forgejo_find_pr_by_merge_sha never ran. head.label was taken from the live API, but "this lookup returns this PR for this merge SHA" is an assumption, not an observation. UNTOUCHED the act phase, the release job, everything downstream of decide UNTOUCHED a full cut end to end — tag, release, manifest, deploy — has never been watched on this repo ``` @surveyor separately enumerated that all three lookup outcomes (match, empty, empty `head.label`) land on `fail`, which is why the mocked leg is reasonable rather than load-bearing. It is still an assumption and should be named as one. ### Why this correction is here rather than dropped The person who ran the measurement is the one who narrowed it, unprompted and against his own result. A caveat that gets *removed* because a partial measurement landed is worse than one that was never written — the next reader inherits "this was checked" with no way to see which layer.
Author
Owner

End-to-end observation — the update path is now watched live, the cut path still is not

#35 merged at 9cff2305. Verified independently rather than relayed:

main        9cff2305
tags        v0.1.0 only
releases    v0.1.0 only
#37         open, head.label = release-prep/rolling, "chore(release): v0.2.0"
deploy runs 0 matching out of 159 total runs   (control: the filter sees runs)

So mode=update, prep path, fresh rolling PR, nothing tagged, nothing published, nothing deployed.

The caveat, narrowed again

NOW OBSERVED LIVE   Layer 2's fall-through on a real merge, with the real
                    forgejo_find_pr_by_merge_sha running. The mocked-lookup gap
                    recorded above is closed FOR THIS INSTANCE — the assumption
                    became an observation.
NOW OBSERVED        the UPDATE path end to end, on the real workflow, no mocking
STILL NOT OBSERVED  a CUT end to end — tag, release, manifest, deploy. Never run
                    on this repo. Everything about publish_mode: immediate and the
                    deploy trigger remains a source and config read.

B1-vs-B2 confirmed against the real pipeline

#37's proposed 0.2.0 section:

bullets total   16
bullets unique  16      => zero verbatim duplicates

The dry-run predicted exactly 16 for B2 and 26-with-duplicates for B1. The real pipeline produced 16.

The sharpest single check is the entry that B1 split into a pair — @bosun measured it at B1 lines 39
and 68, once as the commit subject (profile: pin the key baseline to measured RSA-3072) and once as
#34's prose. In #37 it appears once, in the prose form:

- **purser**: the profile gate's key baseline is pinned to the measured RSA-3072,
  not derived from config (#2)

The fragment supplied the dedup key, so the commit bullet was suppressed. That is the mechanism —
changelog_dedup_cc_by_fragment_refs() keying on fragment filenames — confirmed on the real
pipeline rather than a scratch clone.

State for the real v0.2.0

#37 is open on release-prep/rolling. Merging it is the cut, and it is the one branch label
that passes Layer 2. With publish_mode: immediate that tags, publishes and fires the first deploy
in one step, with no draft gate. Operator's call.

## End-to-end observation — the update path is now watched live, the cut path still is not `#35` merged at 9cff2305. Verified independently rather than relayed: ``` main 9cff2305 tags v0.1.0 only releases v0.1.0 only #37 open, head.label = release-prep/rolling, "chore(release): v0.2.0" deploy runs 0 matching out of 159 total runs (control: the filter sees runs) ``` So `mode=update`, prep path, fresh rolling PR, nothing tagged, nothing published, nothing deployed. ### The caveat, narrowed again ``` NOW OBSERVED LIVE Layer 2's fall-through on a real merge, with the real forgejo_find_pr_by_merge_sha running. The mocked-lookup gap recorded above is closed FOR THIS INSTANCE — the assumption became an observation. NOW OBSERVED the UPDATE path end to end, on the real workflow, no mocking STILL NOT OBSERVED a CUT end to end — tag, release, manifest, deploy. Never run on this repo. Everything about publish_mode: immediate and the deploy trigger remains a source and config read. ``` ### B1-vs-B2 confirmed against the real pipeline `#37`'s proposed 0.2.0 section: ``` bullets total 16 bullets unique 16 => zero verbatim duplicates ``` The dry-run predicted exactly 16 for B2 and 26-with-duplicates for B1. The real pipeline produced 16. The sharpest single check is the entry that B1 split into a pair — @bosun measured it at B1 lines 39 and 68, once as the commit subject (`profile: pin the key baseline to measured RSA-3072`) and once as `#34`'s prose. In `#37` it appears **once**, in the prose form: ``` - **purser**: the profile gate's key baseline is pinned to the measured RSA-3072, not derived from config (#2) ``` The fragment supplied the dedup key, so the commit bullet was suppressed. That is the mechanism — `changelog_dedup_cc_by_fragment_refs()` keying on fragment filenames — confirmed on the real pipeline rather than a scratch clone. ### State for the real v0.2.0 `#37` is open on `release-prep/rolling`. **Merging it is the cut**, and it is the one branch label that passes Layer 2. With `publish_mode: immediate` that tags, publishes and fires the first deploy in one step, with no draft gate. Operator's call.
Author
Owner

Absorbed by #39 and closing.

Filed when the mechanism was unknown and the v0.3.0 cut was hours away, as a facts-for-the-next-cut reference. #39's body now carries the cause, the evidence table with where each number stops, the fix, and the residuals; #43 carried the config change and is merged; the cut deployed end to end at 13:48.

Everything here is either superseded or duplicated there. A reference tracker nobody reads is worse than none — closing deliberately rather than leaving it to rot (@bosun's call to make it explicit).

Absorbed by #39 and closing. Filed when the mechanism was unknown and the v0.3.0 cut was hours away, as a facts-for-the-next-cut reference. #39's body now carries the cause, the evidence table with where each number stops, the fix, and the residuals; #43 carried the config change and is merged; the cut deployed end to end at 13:48. Everything here is either superseded or duplicated there. A reference tracker nobody reads is worse than none — closing deliberately rather than leaving it to rot (@bosun's call to make it explicit).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/purser#36
No description provided.