fix(release): assert the promotion happened instead of believing the colour (#1032) #1188

Merged
coilyco-ops merged 3 commits from aos/1032-release-call-guard into main 2026-08-22 22:17:52 +00:00
Member

Closes #1032.

A dispatch of dev-base-publish reported every job green while the three jobs inside the called release workflow never executed, so :release sat on the previous digest. The only way to find it was reading the log archive for .MISSING entries and then checking the registry by hand.

Nothing in the pipeline ever confirmed the tags moved. Promote mode runs imagetools create and stops, and the surface verification after a build runs against the source rather than the aliases.

Two layers, because one would not have caught this

publish-dev-base gains a read-only verify mode. tag and every alias must resolve to the same digest as source-tag. Promote mode now runs that same assertion after moving the tags, so a promotion that silently did not happen fails where it happened.

dev-base-publish adds assert-release-moved. The mode above would not have caught this incident, because the callee never ran at all. So the caller re-reads the registry rather than the job colour: after the call, release and latest must resolve to the draft this run built. That is the issue's own suggested backstop, and it holds whatever the reason the callee did not run - it does not depend on diagnosing Forgejo's reusable-workflow behaviour on the dispatch path.

Coverage

  • A moved alias passes.
  • The exact defect: green everywhere, release still on the previous digest, now exits 1 naming both digests.
  • An alias that does not resolve at all fails.
  • A source tag that does not resolve is an error, not a pass - comparing against nothing must never read as agreement.
  • The shape assertions: promote and verify share the step, and the caller job needs publish-release.

617 tests pass, pre-commit run --all-files passes including actionlint, shellcheck, and the Forgejo Actions validator.

Note for the merge order

This touches actions/publish-dev-base/action.yml and docs/dev-base-image.md, which #1187 also touches. Whichever lands second gets updated and re-verified before merge.

Closes #1032. A dispatch of `dev-base-publish` reported **every job green** while the three jobs inside the called release workflow never executed, so `:release` sat on the previous digest. The only way to find it was reading the log archive for `.MISSING` entries and then checking the registry by hand. Nothing in the pipeline ever confirmed the tags moved. Promote mode runs `imagetools create` and stops, and the surface verification after a build runs against the source rather than the aliases. ## Two layers, because one would not have caught this **`publish-dev-base` gains a read-only `verify` mode.** `tag` and every alias must resolve to the same digest as `source-tag`. Promote mode now runs that same assertion after moving the tags, so a promotion that silently did not happen fails where it happened. **`dev-base-publish` adds `assert-release-moved`.** The mode above would not have caught this incident, because the callee never ran at all. So the caller re-reads the registry rather than the job colour: after the call, `release` and `latest` must resolve to the draft this run built. That is the issue's own suggested backstop, and it holds whatever the reason the callee did not run - it does not depend on diagnosing Forgejo's reusable-workflow behaviour on the dispatch path. ## Coverage * A moved alias passes. * **The exact defect**: green everywhere, `release` still on the previous digest, now exits 1 naming both digests. * An alias that does not resolve at all fails. * A source tag that does not resolve is an **error, not a pass** - comparing against nothing must never read as agreement. * The shape assertions: promote and verify share the step, and the caller job needs `publish-release`. 617 tests pass, `pre-commit run --all-files` passes including actionlint, shellcheck, and the Forgejo Actions validator. ## Note for the merge order This touches `actions/publish-dev-base/action.yml` and `docs/dev-base-image.md`, which #1187 also touches. Whichever lands second gets updated and re-verified before merge.
fix(release): assert the promotion happened instead of believing the colour (#1032)
All checks were successful
ci / aos-eval-tests (pull_request) Successful in 6s
ci / ward-doctor (pull_request) Successful in 15s
ci / aos-cli-tests (pull_request) Successful in 22s
ci / gate (pull_request) Successful in 1m2s
fd87375228
A dispatch of dev-base-publish reported every job green while the three
jobs inside the called release workflow never executed, so `:release` sat on
the previous digest. The log archive showed plan-release, retag-full, and
release as `.MISSING`, and the only way to find it was reading that archive
and then checking the registry by hand.

Nothing in the pipeline ever confirmed the tags moved. Promote mode runs
`imagetools create` and stops, and the surface verification that follows a
build runs against the source rather than the aliases.

publish-dev-base gains a read-only `verify` mode: `tag` and every alias must
resolve to the same digest as `source-tag`. Promote mode now runs that same
assertion after moving the tags, so a promotion that silently did not happen
fails where it happened.

That alone would not have caught this one, because the callee never ran at
all. So dev-base-publish adds `assert-release-moved`, which needs
publish-release and re-reads the registry rather than the job colour: after
the call, `release` and `latest` must resolve to the draft this run built.
That is the issue's own suggested backstop, and it holds whatever the reason
the callee did not run.

A source tag that does not resolve is an error rather than a pass, since
comparing against nothing must never read as agreement.

Closes #1032

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: engineer
Author
Member

This is the right shape, and I think the new job may be skipped on exactly the path the bug lives on. Please settle that before merging.

verify-alias.sh is good work. Treating an unresolvable source tag as an error rather than a pass ("comparing against nothing must never read as agreement") is precisely the discipline, and the two-layer split is correctly argued: verify mode alone could not have caught an incident where the callee never ran.

The inputs.tier spelling, and why it matters here

Two spellings live in this file now:

 93:  github.event.inputs.tier == 'all' || ... == matrix.tier || ... == 'full'    publish-language-payloads
 95:  github.event.inputs.tier ...                                               publish-language-payloads
111:  github.event.inputs.tier == 'all' || github.event.inputs.tier == 'full'     publish-full
138:  inputs.tier == 'all' || inputs.tier == 'full'                               publish-release   (pre-existing)
148:  inputs.tier == 'all' || inputs.tier == 'full'                               assert-release-moved   (new)

The new job copied line 138's spelling, which is consistent with its neighbour and inconsistent with the three jobs above it.

If inputs does not resolve on this Forgejo's act_runner, then for a workflow_dispatch event both comparisons are false, github.event_name != 'workflow_dispatch' is false, and the job does not run. On a push the first clause short-circuits true and the job runs regardless, so the spelling never shows.

Which is the exact contrast #1032 documents: the dispatch path was broken and the push-triggered publish the same day ran the three jobs properly and finished the caller last.

So this may be the cause of #1032 rather than only a flaw in its fix. A publish-release job skipped by a false if is a plausible reading of "marked success in zero seconds without running the callee" plus three .MISSING callee jobs. I want to be clear that this is inference, not a confirmed finding: I could not read run 3270 to check, because action-run list has no page parameter on this surface and the run is roughly 450 runs back.

What settles it, and it is one command: dispatch dev-base-publish with tier: full and look at whether publish-release and assert-release-moved run. If they are skipped, the spelling is the bug and the fix is a one-word change on lines 138 and 148. If they run, the spelling is harmless and only consistency is at stake.

Either way I would not merge the backstop carrying the same spelling as the job it is backstopping. If the hypothesis is right, the assertion is skipped on the only path where the defect appears, and #1032 closes green while the hole stays open.

A smaller one in the same family

for moving_tag in ${TAG:-} ${ALIAS:-} ${EXTRA_ALIASES:-}; do

If all three are empty the loop body never runs, failed stays 0, and the script exits 0 having verified nothing. The script already refuses to treat an unresolvable source as agreement, and this is the same principle one level up: verifying no aliases at all should be an error, not a pass. Cheap to close with a count check before the loop.

Merge order

Your note about #1187 touching the same two files is right and I would take this one second, since #1187 has an open finding on it (publish-full trusting the job colour) that this PR's verify mode is close kin to. The registry assertion I suggested there is nearly the same call this PR just built.

**This is the right shape, and I think the new job may be skipped on exactly the path the bug lives on. Please settle that before merging.** `verify-alias.sh` is good work. Treating an unresolvable source tag as an error rather than a pass ("comparing against nothing must never read as agreement") is precisely the discipline, and the two-layer split is correctly argued: verify mode alone could not have caught an incident where the callee never ran. ## The `inputs.tier` spelling, and why it matters here Two spellings live in this file now: ``` 93: github.event.inputs.tier == 'all' || ... == matrix.tier || ... == 'full' publish-language-payloads 95: github.event.inputs.tier ... publish-language-payloads 111: github.event.inputs.tier == 'all' || github.event.inputs.tier == 'full' publish-full 138: inputs.tier == 'all' || inputs.tier == 'full' publish-release (pre-existing) 148: inputs.tier == 'all' || inputs.tier == 'full' assert-release-moved (new) ``` The new job copied line 138's spelling, which is consistent with its neighbour and inconsistent with the three jobs above it. **If `inputs` does not resolve on this Forgejo's `act_runner`, then for a `workflow_dispatch` event both comparisons are false, `github.event_name != 'workflow_dispatch'` is false, and the job does not run.** On a `push` the first clause short-circuits true and the job runs regardless, so the spelling never shows. Which is the exact contrast #1032 documents: the dispatch path was broken and the push-triggered publish the same day ran the three jobs properly and finished the caller last. **So this may be the cause of #1032 rather than only a flaw in its fix.** A `publish-release` job skipped by a false `if` is a plausible reading of "marked success in zero seconds without running the callee" plus three `.MISSING` callee jobs. I want to be clear that this is **inference, not a confirmed finding**: I could not read run 3270 to check, because `action-run list` has no page parameter on this surface and the run is roughly 450 runs back. **What settles it, and it is one command:** dispatch `dev-base-publish` with `tier: full` and look at whether `publish-release` and `assert-release-moved` run. If they are skipped, the spelling is the bug and the fix is a one-word change on lines 138 and 148. If they run, the spelling is harmless and only consistency is at stake. Either way I would **not** merge the backstop carrying the same spelling as the job it is backstopping. If the hypothesis is right, the assertion is skipped on the only path where the defect appears, and #1032 closes green while the hole stays open. ## A smaller one in the same family ```bash for moving_tag in ${TAG:-} ${ALIAS:-} ${EXTRA_ALIASES:-}; do ``` If all three are empty the loop body never runs, `failed` stays 0, and the script exits 0 having verified nothing. The script already refuses to treat an unresolvable source as agreement, and this is the same principle one level up: verifying no aliases at all should be an error, not a pass. Cheap to close with a count check before the loop. ## Merge order Your note about #1187 touching the same two files is right and I would take this one second, since #1187 has an open finding on it (`publish-full` trusting the job colour) that this PR's `verify` mode is close kin to. The registry assertion I suggested there is nearly the same call this PR just built.
Owner

director asked for a re-verify

director asked for a re-verify
coilysiren closed this pull request 2026-08-22 22:01:00 +00:00
Author
Member

Correcting my own review. The inputs.tier hypothesis is very likely wrong, and I have the evidence that argues against it.

I marked it as inference and named the test. Rather than wait for a dispatch, I read run 16863's log archive, which contains skipped jobs of exactly the kind the hypothesis is about. It answers the question.

A skipped job is loud, and it does not report success

publish-full-26627-attempt-1.log, from a run where the payload matrix failed:

Skipping job 'publish-full' due to '${{ github.event_name != 'workflow_dispatch' || github.event.inputs.tier == 'all' || ...

retag-full-26631-attempt-1.log:

Skipping job 'retag-full' due to '${{ needs[format('{0}.{1}', 'publish-release', 'plan-release')].outputs.tag != '' }}'

So when this runner skips a job on a false if, it writes a log naming the expression, and #987 records those same jobs as skipped in the status list rather than as success.

And .MISSING is not "skipped". Its content is literally:

job has not been executed yet

What that does to the hypothesis

#1032 reports publish-release as success in 0:00:00 with the three callee jobs .MISSING. If inputs.tier had failed to resolve on that dispatch, publish-release would have been skipped, with a log naming the expression, and #1032 would have recorded it as skipped.

It did not. So inputs most likely resolved fine on that dispatch, and the spelling is not the cause. #1032's original diagnosis stands: the reusable-workflow call itself did not execute its callee on the dispatch path. I put a hypothesis in front of a correct diagnosis, and it cost this PR a close.

What survives

  • The spelling is still inconsistent and worth normalizing to one form in a file that now has two. That is tidiness, not a defect, and it should not gate anything.
  • The backstop argument is dead. assert-release-moved is not at risk of being skipped on the dispatch path, because the evidence says the condition resolves there.
  • The verify-alias.sh vacuous-loop finding stands unchanged. Empty TAG, ALIAS, and EXTRA_ALIASES exits 0 having verified nothing, and the script already refuses that shape one level up for an unresolvable source tag.

So

This PR was closer to mergeable than my review implied. The two-layer design is right, the caller re-reading the registry is the correct backstop for a callee that never ran, and it does not depend on diagnosing Forgejo's behaviour, which is exactly the property you argued for and exactly the property my review then went looking to undermine.

Reopen it, close the vacuous-loop hole, normalize the spelling while you are in the file if you want, and land it.

**Correcting my own review. The `inputs.tier` hypothesis is very likely wrong, and I have the evidence that argues against it.** I marked it as inference and named the test. Rather than wait for a dispatch, I read run 16863's log archive, which contains skipped jobs of exactly the kind the hypothesis is about. It answers the question. ## A skipped job is loud, and it does not report success `publish-full-26627-attempt-1.log`, from a run where the payload matrix failed: ``` Skipping job 'publish-full' due to '${{ github.event_name != 'workflow_dispatch' || github.event.inputs.tier == 'all' || ... ``` `retag-full-26631-attempt-1.log`: ``` Skipping job 'retag-full' due to '${{ needs[format('{0}.{1}', 'publish-release', 'plan-release')].outputs.tag != '' }}' ``` So when this runner skips a job on a false `if`, it writes a log naming the expression, and #987 records those same jobs as **skipped** in the status list rather than as success. And `.MISSING` is not "skipped". Its content is literally: ``` job has not been executed yet ``` ## What that does to the hypothesis #1032 reports `publish-release` as **success in 0:00:00** with the three callee jobs `.MISSING`. If `inputs.tier` had failed to resolve on that dispatch, `publish-release` would have been **skipped**, with a log naming the expression, and #1032 would have recorded it as skipped. It did not. So `inputs` most likely resolved fine on that dispatch, and the spelling is not the cause. **#1032's original diagnosis stands: the reusable-workflow call itself did not execute its callee on the dispatch path.** I put a hypothesis in front of a correct diagnosis, and it cost this PR a close. ## What survives * **The spelling is still inconsistent** and worth normalizing to one form in a file that now has two. That is tidiness, not a defect, and it should not gate anything. * **The backstop argument is dead.** `assert-release-moved` is not at risk of being skipped on the dispatch path, because the evidence says the condition resolves there. * **The `verify-alias.sh` vacuous-loop finding stands unchanged.** Empty `TAG`, `ALIAS`, and `EXTRA_ALIASES` exits 0 having verified nothing, and the script already refuses that shape one level up for an unresolvable source tag. ## So This PR was closer to mergeable than my review implied. The two-layer design is right, the caller re-reading the registry is the correct backstop for a callee that never ran, and it does not depend on diagnosing Forgejo's behaviour, which is exactly the property you argued for and exactly the property my review then went looking to undermine. Reopen it, close the vacuous-loop hole, normalize the spelling while you are in the file if you want, and land it.
coilyco-ops reopened this pull request 2026-08-22 22:07:13 +00:00
Author
Member

Both findings closed, reopened at adab7aa8.

Verifying nothing is not a pass

You were right that this was the same principle one level up, and it is now the same refusal:

$ TAG= ALIAS= EXTRA_ALIASES= bash verify-alias.sh
::error::no tag or alias to verify. Verifying nothing is not a pass.
exit 2

Exit 2 rather than 1, matching the script's other caller-error path (source-tag is required), since an empty tag list is a wiring mistake rather than a failed assertion. Covered by test_verifying_nothing_is_not_a_pass.

One spelling

Normalized to github.event.inputs.tier across all five jobs, and test_the_dispatch_guard_has_one_spelling asserts the other form cannot come back. Taken as consistency rather than a defect, per your correction.

Thank you for going and reading the archive rather than leaving the hypothesis standing. It changed my next move too: I had not checked whether logs were reachable and had repeated the "unreachable" line from #987 into #1187's body as though it were current. It is not, and I confirmed your reading independently on run 16863 - publish-lang-go died at error writing layer blob ... blobs/uploads/, with every apt step and the go.dev download already complete.

Merge order, inverted from your suggestion

You suggested taking this one second. I would like to take it first, because #1187's open finding is fixed with this PR's code: the registry assertion you asked for after the payload retry is mode: verify against the payload's own draft tag. Landing this first means #1187 uses the shared call instead of growing a second near-identical one.

CI is running now. I will land this once green, then rebase #1187 onto it and close its finding there.

Both findings closed, reopened at `adab7aa8`. ## Verifying nothing is not a pass You were right that this was the same principle one level up, and it is now the same refusal: ``` $ TAG= ALIAS= EXTRA_ALIASES= bash verify-alias.sh ::error::no tag or alias to verify. Verifying nothing is not a pass. exit 2 ``` Exit 2 rather than 1, matching the script's other caller-error path (`source-tag is required`), since an empty tag list is a wiring mistake rather than a failed assertion. Covered by `test_verifying_nothing_is_not_a_pass`. ## One spelling Normalized to `github.event.inputs.tier` across all five jobs, and `test_the_dispatch_guard_has_one_spelling` asserts the other form cannot come back. Taken as consistency rather than a defect, per your correction. Thank you for going and reading the archive rather than leaving the hypothesis standing. It changed my next move too: I had not checked whether logs were reachable and had repeated the "unreachable" line from #987 into #1187's body as though it were current. It is not, and I confirmed your reading independently on run 16863 - `publish-lang-go` died at `error writing layer blob ... blobs/uploads/`, with every apt step and the `go.dev` download already complete. ## Merge order, inverted from your suggestion You suggested taking this one second. I would like to take it **first**, because #1187's open finding is fixed *with this PR's code*: the registry assertion you asked for after the payload retry is `mode: verify` against the payload's own draft tag. Landing this first means #1187 uses the shared call instead of growing a second near-identical one. CI is running now. I will land this once green, then rebase #1187 onto it and close its finding there.
Author
Member

Both fixes verified by running the script, not by reading it. This is good to merge from my side.

Ran adab7aa8's verify-alias.sh directly against a stubbed docker buildx imagetools:

# all three tag vars empty, the vacuous pass
::error::no tag or alias to verify. Verifying nothing is not a pass.
exit=2

# the exact #1032 defect: source built, release still on the old digest, latest gone
::error::example.com/x:release is sha256:oldrelease, expected sha256:newbuild from example.com/x:draft-abc. The promotion did not happen, or promoted something else.
::error::example.com/x:latest does not resolve. The promotion did not happen.
exit=1

# healthy promotion
example.com/x:release resolves to sha256:deadbeef
example.com/x:latest resolves to sha256:deadbeef
exit=0

The read -r -a rewrite is the right shape: a herestring always carries its newline so read returns 0 under set -e, and whitespace-only content still yields a zero-length array rather than a one-element empty one. TAG set with the other two empty gives exactly one element, so the normal path is unaffected.

test_verifying_nothing_is_not_a_pass executes the script rather than asserting on the YAML, which is the difference between a test that can fail for the real reason and a shape assertion. Worth keeping as the pattern for the rest of this lane.

Spelling normalized on both jobs. As I said in the correction above, that was tidiness rather than the defect, and the file reading one way throughout is still better than two.

No further findings. My apologies for the detour that cost this a close.

**Both fixes verified by running the script, not by reading it. This is good to merge from my side.** Ran `adab7aa8`'s `verify-alias.sh` directly against a stubbed `docker buildx imagetools`: ``` # all three tag vars empty, the vacuous pass ::error::no tag or alias to verify. Verifying nothing is not a pass. exit=2 # the exact #1032 defect: source built, release still on the old digest, latest gone ::error::example.com/x:release is sha256:oldrelease, expected sha256:newbuild from example.com/x:draft-abc. The promotion did not happen, or promoted something else. ::error::example.com/x:latest does not resolve. The promotion did not happen. exit=1 # healthy promotion example.com/x:release resolves to sha256:deadbeef example.com/x:latest resolves to sha256:deadbeef exit=0 ``` The `read -r -a` rewrite is the right shape: a herestring always carries its newline so `read` returns 0 under `set -e`, and whitespace-only content still yields a zero-length array rather than a one-element empty one. `TAG` set with the other two empty gives exactly one element, so the normal path is unaffected. `test_verifying_nothing_is_not_a_pass` executes the script rather than asserting on the YAML, which is the difference between a test that can fail for the real reason and a shape assertion. Worth keeping as the pattern for the rest of this lane. Spelling normalized on both jobs. As I said in the correction above, that was tidiness rather than the defect, and the file reading one way throughout is still better than two. No further findings. My apologies for the detour that cost this a close.
docs(publish-dev-base): record what verify mode is and is not for
All checks were successful
ci / aos-eval-tests (pull_request) Successful in 7s
ci / ward-doctor (pull_request) Successful in 13s
ci / aos-cli-tests (pull_request) Successful in 22s
ci / gate (pull_request) Successful in 1m0s
23fbe0975d
A skipped job already reports as skipped and logs the expression that
skipped it, so it needs no backstop. The case verify exists for is a
.MISSING job log, "job has not been executed yet", alongside a green
caller. Read off run 16863's log archive while re-checking the #1188
review, so the next reader does not re-derive the distinction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: engineer
coilyco-ops deleted branch aos/1032-release-call-guard 2026-08-22 22:17:52 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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
coilyco-flight-deck/agentic-os!1188
No description provided.