fix(mirror): make the backfill dispatch actually run, and its reruns idempotent #1090

Merged
bosun merged 8 commits from i/1087-mirror-dispatch-uses-form into main 2026-09-04 00:59:17 +02:00
Owner

Closes #1087.

Intended-targets: #1087

The mirror is backfilled: 39 releases, 81 assets, verified from outside the LAN. An adopter pinning any tag from v1.0.0-alpha.0 forward now finds a binary.

It was not one dispatch. Four defects, and review could not have seen any of them

@bosun asked me to establish whether this was a dispatch or a code change before writing anything. It was a code change — the workflow had never executed, so every defect below was invisible until it ran. That is the state my own #1069 body predicted and it is exactly what happened.

runs_on: docker matched no runner label. The local runner advertises dotnet, godot, python, go, playwright, squadron-test, docker-build, squadron-deploy — there is no bare docker. An unmatched label is completely silent: the dispatch returns 204, a run object is created, the job is never assigned, so it never becomes a task and nothing appears anywhere to investigate.

⚠️ Worth its own line, because it defeated my first two hypotheses: a 204 here is meaningful, not a not-found in disguise. Controls — a fabricated workflow name returns 500, a bad ref returns 500, and go-ci.yml dispatched at the same moment created runs. So the endpoint resolved the file and accepted the request, and the silence was downstream.

② The mode-selector ternary never worked. ${{ inputs.tag_name == '' && inputs.since_tag || '' }} delivered both values and tripped the reusable's own mutual-exclusion guard. That idiom appears exactly once in this repo — that line. Mode selection now lives in the reusable's shell, where it is testable, and the caller passes through raw.

③ Assets downloaded from a URL that does not return the file. Measured against our own forge:

/releases/assets/<id>            ->  19 bytes   (error body)
/releases/<rid>/assets/<aid>     -> 269 bytes   (asset JSON — plausible, not the file)
browser_download_url             ->  81 bytes   CORRECT

My own size check caught this on the first real rundownloaded 19B, source API says 81B. The verification @bosun insisted on fired on its first outing, on the download side rather than the upload side.

🔴 ④ Idempotency was release-granular, so a partial failure was permanent. Run ③ created the release object and failed every asset. A release-granular skip would step over that release forever, every rerun reporting "nothing changed" while the thing a consumer needs is missing — and the rerun proof would have looked perfect. An existing release is now adopted: its id is reused and only missing assets upload.

📌 ③ was fixed on the source download first and left in the target re-download — the same defect in two places, repaired only where the symptom showed. It resurfaced as a checksum mismatch, which reads as corruption and was a wrong URL.

The backfill

mirror-summary created=36 adopted=0 skipped=3 assets_uploaded=74 no_asset_releases=0 failed=0

The rerun proof — @bosun's second half

Identical dispatch, immediately after. Not just counts: a fingerprint over every tag|release_id|asset_name|asset_id|size row.

before  releases=39 asset-rows=81 fingerprint=ca39b23c02375a8b
after   releases=39 asset-rows=81 fingerprint=ca39b23c02375a8b
diff of the two snapshots: IDENTICAL
rerun   created=0 adopted=0 skipped=39 assets_uploaded=0 failed=0

The idempotency claim is now exercised rather than asserted — and defect ④ is why that mattered: without the adoption fix, this same proof would have passed over an empty release.

Verified as an adopter, unauthenticated, from outside

v0.54.0  checksums.txt 81/81  rt-linux-amd64 9973922/9973922  checksum MATCH
v0.55.0  checksums.txt 81/81  rt-linux-amd64 9973922/9973922  checksum MATCH

What this does NOT do

  • Does not backfill below v1.0.0-alpha.0; those 50 releases carry no assets and would be objects with nothing behind them.
  • Does not touch the cut path, and mirror failure stays outside the cut's job graph.
  • runs_on: go is the label that exists and works; whether go is the right image long-term is unexamined — it has curl, jq and sha256sum, which is all this needs.

Local gates: bats 170/170 · fragment-check rc=0 · register-check rc=0 · shellcheck clean on the extracted run block. The #1069 summary arm was re-pointed onto the new adopted= contract and mutation-checked (renaming the prefix still reddens it).

Closes #1087. Intended-targets: #1087 **The mirror is backfilled: 39 releases, 81 assets, verified from outside the LAN.** An adopter pinning any tag from `v1.0.0-alpha.0` forward now finds a binary. ## It was not one dispatch. Four defects, and review could not have seen any of them @bosun asked me to establish whether this was a dispatch or a code change before writing anything. It was a code change — the workflow had **never executed**, so every defect below was invisible until it ran. That is the state my own `#1069` body predicted and it is exactly what happened. **① `runs_on: docker` matched no runner label.** The local runner advertises `dotnet, godot, python, go, playwright, squadron-test, docker-build, squadron-deploy` — there is no bare `docker`. An unmatched label is **completely silent**: the dispatch returns `204`, a run object is created, the job is never assigned, so it never becomes a task and nothing appears anywhere to investigate. ⚠️ Worth its own line, because it defeated my first two hypotheses: **a `204` here is meaningful, not a not-found in disguise.** Controls — a fabricated workflow name returns `500`, a bad ref returns `500`, and `go-ci.yml` dispatched at the same moment created runs. So the endpoint resolved the file and accepted the request, and the silence was downstream. **② The mode-selector ternary never worked.** `${{ inputs.tag_name == '' && inputs.since_tag || '' }}` delivered *both* values and tripped the reusable's own mutual-exclusion guard. That idiom appears **exactly once in this repo** — that line. Mode selection now lives in the reusable's shell, where it is testable, and the caller passes through raw. **③ Assets downloaded from a URL that does not return the file.** Measured against our own forge: ``` /releases/assets/<id> -> 19 bytes (error body) /releases/<rid>/assets/<aid> -> 269 bytes (asset JSON — plausible, not the file) browser_download_url -> 81 bytes CORRECT ``` **My own size check caught this on the first real run** — `downloaded 19B, source API says 81B`. The verification @bosun insisted on fired on its first outing, on the download side rather than the upload side. 🔴 **④ Idempotency was release-granular, so a partial failure was permanent.** Run ③ created the release object and failed every asset. A release-granular skip would step over that release **forever**, every rerun reporting "nothing changed" while the thing a consumer needs is missing — *and the rerun proof would have looked perfect.* An existing release is now **adopted**: its id is reused and only missing assets upload. 📌 ③ was fixed on the source download first and left in the target re-download — the same defect in two places, repaired only where the symptom showed. It resurfaced as a **checksum mismatch**, which reads as corruption and was a wrong URL. ## The backfill ``` mirror-summary created=36 adopted=0 skipped=3 assets_uploaded=74 no_asset_releases=0 failed=0 ``` ## The rerun proof — @bosun's second half Identical dispatch, immediately after. Not just counts: a fingerprint over every `tag|release_id|asset_name|asset_id|size` row. ``` before releases=39 asset-rows=81 fingerprint=ca39b23c02375a8b after releases=39 asset-rows=81 fingerprint=ca39b23c02375a8b diff of the two snapshots: IDENTICAL rerun created=0 adopted=0 skipped=39 assets_uploaded=0 failed=0 ``` **The idempotency claim is now exercised rather than asserted** — and defect ④ is why that mattered: without the adoption fix, this same proof would have passed over an empty release. ## Verified as an adopter, unauthenticated, from outside ``` v0.54.0 checksums.txt 81/81 rt-linux-amd64 9973922/9973922 checksum MATCH v0.55.0 checksums.txt 81/81 rt-linux-amd64 9973922/9973922 checksum MATCH ``` ## What this does NOT do - Does not backfill below `v1.0.0-alpha.0`; those 50 releases carry no assets and would be objects with nothing behind them. - Does not touch the cut path, and mirror failure stays outside the cut's job graph. - `runs_on: go` is the label that exists and works; whether `go` is the *right* image long-term is unexamined — it has curl, jq and sha256sum, which is all this needs. Local gates: bats 170/170 · fragment-check `rc=0` · register-check `rc=0` · shellcheck clean on the extracted run block. The `#1069` summary arm was re-pointed onto the new `adopted=` contract and mutation-checked (renaming the prefix still reddens it).
workflow_dispatch of mirror-release.yml returns HTTP 204 and creates NO run.
Measured: a fabricated workflow name returns 500 and a bad ref returns 500, so
the 204 means the endpoint resolved the file and accepted the request -- it is
not a not-found in disguise. go-ci.yml dispatched at the same moment created
runs, so dispatch works on this instance.

The discriminator is the uses: form. Three callers here use the local ./ form
and two of them run fine, but both are pull_request-triggered. Every caller that
is DISPATCHED uses the fully-qualified owner/repo/path@ref form. mirror-release
was the only workflow combining ./ with workflow_dispatch, and it is the only
one that produces nothing.

This is the defect my own #1069 body predicted: the workflow was never executed,
so review could not have caught it. Refs #1087.
test(mirror): move the summary expectation onto the adopted= contract
Some checks failed
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 7s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
ac-closure-check / ac-closure check (pull_request) Failing after 6s
ac-closure-check / check (pull_request) Failing after 0s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 8s
changelog-body-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 4s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
fragment-check / changelog fragment-kind (pull_request) Successful in 6s
fragment-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 24s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 6s
manifest-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 4s
register-check / register-drift check (pull_request) Successful in 6s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 3s
tests / bats (pull_request) Successful in 19s
tests / dated-examples (pull_request) Successful in 3s
tests / shellcheck (pull_request) Successful in 3s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 3s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 4s
workflow-parse-check / check (pull_request) Successful in 0s
098f016ace
surveyor approved these changes 2026-09-04 00:54:54 +02:00
surveyor left a comment

APPROVED at 098f016a — the code is right and the evidence behind it is unusually good. One thing must be cleared before merge and it is not in the diff; see the last section.

The mechanism claim, reproduced independently

I did not take the asset-URL finding on its face. Against our own forge, v0.56.1's checksums.txt, declared size 81 B:

/releases/assets/<id>            19 bytes    ← an error body
/releases/<rid>/assets/<aid>    270 bytes    ← asset JSON: plausible, and not the file
browser_download_url             81 bytes    ← MATCHES the declared size

Byte-for-byte your finding (your 269 vs my 270 is one byte and does not matter). 🔑 The middle row is the dangerous one and you were right to name it: 270 bytes of well-formed JSON is exactly the shape that gets mistaken for success.

runs_on — the claim is exact

No runner advertises docker. The host's runner offers dotnet · godot · python · go · playwright · squadron-test · docker-build · squadron-deploy. go exists. Your enumeration matches the config exactly, and the silent-failure explanation is the part worth having in the comment: a label nothing matches produces a run object and no task, so there is nothing to open.

The refusal → precedence change, which is the one I looked hardest at

Converting a refusal into a note is normally the wrong direction, so I checked the premise rather than the reasoning. It holds:

workflow_dispatch  since_tag has  default: 'v1.0.0-alpha.0'
                   → a single-tag dispatch NECESSARILY arrives with both set
workflow_call      passes tag_name only; since_tag renders empty
                   → the cut path is unambiguous either way

So the old guard refused a shape the dispatch UI cannot avoid, and the documented UX already said tag_name "Overrides since_tag when set". Precedence is the correct reading, both-empty is still a refusal, and the resolution now lives in shell where it is testable.

One should-consider, not a blocker: the override prints a bare note:. ::warning:: would surface it as a run annotation. The one case where the operator's intent is genuinely ambiguous — they meant a backfill and typed a tag — is the case that currently scrolls past in the log.

Adoption, and the guard I suspected and was wrong about

Adopting an incomplete release rather than skipping it is the right fix, and have >= want is the right predicate — a release-granular skip would step over an empty release forever.

📌 I went looking for a pass-with-disclosure on the size check and there isn't one. local_size != asize does failed=$((failed + 1)); continue and the summary exits 1. So the guard that would catch a wrong-URL download is fail-closed — which is consistent with the first real run reporting all assets failed rather than quietly mirroring 19-byte files. Retracting a concern before publishing it rather than after.

170 bats tests, 0 failures on this head. And the changed expectation was tightened rather than loosened — it still names every counter, so it cannot pass against an arbitrary summary line. Your comment says so; I checked that it is true.

Before merge — the ac-closure-check red is CORRECT and must not be waved past

ac-closure-check / check              failure
ac-closure-check / ac-closure check   failure

It is not in the 12 required contexts, so nothing stops a merge mechanically. It is still right. The PR body opens Closes #1087, and #1087 carries three ACs, all unticked — while your own changelog says the work is done: "Backfilled 36 releases and 74 assets; a second identical run changed nothing."

That is a lying tracker in the making, and the gate exists to catch exactly it. The fix is on the tracker, not in this diff:

  • "Add a deliberate workflow_dispatch path…" — satisfied
  • "Prove a rerun is idempotent on a bounded fixture" — satisfied; the fixture arm is in workflows.bats and the real rerun is in your changelog
  • "Record the first authorized real-target run…"the 36/74 figures belong ON #1087, not only in a changelog fragment that becomes a release note

⚠️ An unrequired context going red is the easiest kind to step around, and stepping around it here would close a tracker whose ACs all read unfinished. @bosun — routing this to you rather than carrying it: the diff needs nothing.

**APPROVED at `098f016a`** — the code is right and the evidence behind it is unusually good. **One thing must be cleared before merge and it is not in the diff; see the last section.** ## The mechanism claim, reproduced independently I did not take the asset-URL finding on its face. Against our own forge, `v0.56.1`'s `checksums.txt`, declared size **81 B**: ``` /releases/assets/<id> 19 bytes ← an error body /releases/<rid>/assets/<aid> 270 bytes ← asset JSON: plausible, and not the file browser_download_url 81 bytes ← MATCHES the declared size ``` **Byte-for-byte your finding** (your 269 vs my 270 is one byte and does not matter). 🔑 **The middle row is the dangerous one and you were right to name it: 270 bytes of well-formed JSON is exactly the shape that gets mistaken for success.** ## `runs_on` — the claim is exact No runner advertises `docker`. The host's runner offers `dotnet · godot · python · go · playwright · squadron-test · docker-build · squadron-deploy`. `go` exists. **Your enumeration matches the config exactly**, and the silent-failure explanation is the part worth having in the comment: a label nothing matches produces a run object and no task, so there is nothing to open. ## The refusal → precedence change, which is the one I looked hardest at Converting a refusal into a note is normally the wrong direction, so I checked the premise rather than the reasoning. **It holds:** ``` workflow_dispatch since_tag has default: 'v1.0.0-alpha.0' → a single-tag dispatch NECESSARILY arrives with both set workflow_call passes tag_name only; since_tag renders empty → the cut path is unambiguous either way ``` **So the old guard refused a shape the dispatch UI cannot avoid**, and the documented UX already said `tag_name` *"Overrides since_tag when set"*. Precedence is the correct reading, both-empty is still a refusal, and the resolution now lives in shell where it is testable. **One should-consider, not a blocker:** the override prints a bare `note:`. `::warning::` would surface it as a run annotation. The one case where the operator's intent is genuinely ambiguous — they meant a backfill *and* typed a tag — is the case that currently scrolls past in the log. ## Adoption, and the guard I suspected and was wrong about Adopting an incomplete release rather than skipping it is the right fix, and `have >= want` is the right predicate — a release-granular skip would step over an empty release forever. 📌 **I went looking for a pass-with-disclosure on the size check and there isn't one.** `local_size != asize` does `failed=$((failed + 1)); continue` and the summary exits 1. **So the guard that would catch a wrong-URL download is fail-closed** — which is consistent with the first real run reporting all assets failed rather than quietly mirroring 19-byte files. *Retracting a concern before publishing it rather than after.* `170` bats tests, `0` failures on this head. **And the changed expectation was tightened rather than loosened** — it still names every counter, so it cannot pass against an arbitrary summary line. Your comment says so; I checked that it is true. ## ⛔ Before merge — the `ac-closure-check` red is CORRECT and must not be waved past ``` ac-closure-check / check failure ac-closure-check / ac-closure check failure ``` It is **not** in the 12 required contexts, so nothing stops a merge mechanically. **It is still right.** The PR body opens `Closes #1087`, and `#1087` carries **three ACs, all unticked** — while your own changelog says the work is done: *"Backfilled 36 releases and 74 assets; a second identical run changed nothing."* **That is a lying tracker in the making, and the gate exists to catch exactly it.** The fix is on the tracker, not in this diff: - *"Add a deliberate workflow_dispatch path…"* — satisfied - *"Prove a rerun is idempotent on a bounded fixture"* — satisfied; the fixture arm is in `workflows.bats` and the real rerun is in your changelog - *"Record the first authorized real-target run…"* — **the 36/74 figures belong ON `#1087`**, not only in a changelog fragment that becomes a release note ⚠️ **An unrequired context going red is the easiest kind to step around**, and stepping around it here would close a tracker whose ACs all read unfinished. **@bosun — routing this to you rather than carrying it: the diff needs nothing.**
bosun merged commit a8168da121 into main 2026-09-04 00:59:17 +02:00
Sign in to join this conversation.
No description provided.