bug(release-assets): the POST-condition expects after==before, so a clean first publish fails — v0.50.0 has assets but no baked digest #970

Closed
opened 2026-08-26 18:54:38 +02:00 by bosun · 3 comments
Owner

The POST-condition is #962 one function later — and #963's fix is PROVEN by the same run

v0.50.0 published at 18:50:26 WITH ASSETS. Then the job failed anyway.

release published    checksums.txt (81 B) · rt-linux-amd64 (9,932,962 B)   ← #963's fix WORKS
::error::release-assets: asset count changed unexpectedly:
         before=0  duplicate-extra=0  after=2  expected=0
task 29267 "build + publish rt asset"   FAILURE 18:50:56
the bake task                           NEVER RAN — it needs: goreleaser
action.yml @ v0.50.0                    sha256:0000…  (unbaked)
verify-image-pull                       did not run

The defect — scripts/release-assets.sh:194-202

before_exists="${RELEASE_ASSET_BEFORE_EXISTS:-false}"
if [ "$before_exists" = true ]; then
    expected_count=$((before_count - before_duplicate_extra))
    [ "$asset_count" = "$expected_count" ] || die "asset count changed unexpectedly: …"
fi

🔑 expected_count is computed ENTIRELY from the BEFORE snapshot and never accounts for the
assets GoReleaser is about to upload.
On a normal push cut the release object exists and is empty,
so before_count=0expected_count=0 → and the two assets that were just published correctly
are read as an unexpected change.

🔴 before_exists is TRUE for exactly the same reason #962 fired: rt release creates the
release object before assets are uploaded.
Same premise, same population, same script — one
function later.

🔑 This is a fix that moved the violation next door, and the run proves BOTH halves

#963 PROVED   the pre-condition no longer blocks a clean push cut — the assets uploaded
THIS DEFECT   the post-condition then refuses the correct result

⚠️ #962's AC4 is therefore HALF discharged and must not be ticked: assets != 0 is
observed ; the non-zero baked digest and a verify-image-pull task are not — both are
downstream of a job that exits 1.

Fix

expected_count must account for the assets this run publishes. The named set is already in scope
as expected_names, and :188-191 already asserts exactly one copy of each — so the post-condition
above is checking a quantity the loop above it has already constrained more precisely.

Candidate: expected_count=$(( before_count - before_duplicate_extra )) is correct only for a
re-run that replaces. For a first publish the expected count is `before_count - before_duplicate_extra

  • ${#expected_names[@]}` — but prefer deriving it from what the loop verified rather than adding a
    second arithmetic path.

Acceptance criteria

  • A push cut against an empty release publishes and the post-condition PASSES — arm asserts the
    job exits 0 and that the assets are present, not merely that the guard is silent
  • A re-run that replaces existing assets still refuses on a genuine unexpected count — the
    guard's purpose survives, mutation-verified
  • The duplicate-name check at :188-191 still fires on a genuine duplicate
  • Verified by a REAL CUT, and specifically by the three observables #962 AC4 names:
    assets != 0, a non-zero baked digest at the tag, and a verify-image-pull task
  • #962 / #963 — the pre-condition. Proven working by this same run.
  • #947 — introduced both guards.

Anchor

Found by @bosun 2026-08-26 18:52 by reading task 29267's log after v0.50.0 published with assets but
an unbaked digest. The release is live and correct; the job that would have baked the digest never
ran.

## The POST-condition is `#962` one function later — and `#963`'s fix is PROVEN by the same run **v0.50.0 published at 18:50:26 WITH ASSETS. Then the job failed anyway.** ``` release published checksums.txt (81 B) · rt-linux-amd64 (9,932,962 B) ← #963's fix WORKS ::error::release-assets: asset count changed unexpectedly: before=0 duplicate-extra=0 after=2 expected=0 task 29267 "build + publish rt asset" FAILURE 18:50:56 the bake task NEVER RAN — it needs: goreleaser action.yml @ v0.50.0 sha256:0000… (unbaked) verify-image-pull did not run ``` ## The defect — `scripts/release-assets.sh:194-202` ```bash before_exists="${RELEASE_ASSET_BEFORE_EXISTS:-false}" if [ "$before_exists" = true ]; then expected_count=$((before_count - before_duplicate_extra)) [ "$asset_count" = "$expected_count" ] || die "asset count changed unexpectedly: …" fi ``` 🔑 **`expected_count` is computed ENTIRELY from the BEFORE snapshot and never accounts for the assets GoReleaser is about to upload.** On a normal push cut the release object exists and is empty, so `before_count=0` → `expected_count=0` → and the two assets that were just published correctly are read as an unexpected change. 🔴 **`before_exists` is TRUE for exactly the same reason `#962` fired: `rt release` creates the release object before assets are uploaded.** Same premise, same population, same script — one function later. ## 🔑 This is a fix that moved the violation next door, and the run proves BOTH halves ``` #963 PROVED the pre-condition no longer blocks a clean push cut — the assets uploaded THIS DEFECT the post-condition then refuses the correct result ``` ⚠️ **`#962`'s AC4 is therefore HALF discharged and must not be ticked**: `assets != 0` is **observed** ✅; the non-zero baked digest and a `verify-image-pull` task are **not** ❌ — both are downstream of a job that exits 1. ## Fix `expected_count` must account for the assets this run publishes. The named set is already in scope as `expected_names`, and `:188-191` already asserts exactly one copy of each — so the post-condition above is checking a quantity the loop above it has already constrained more precisely. **Candidate:** `expected_count=$(( before_count - before_duplicate_extra ))` is correct only for a re-run that replaces. For a first publish the expected count is `before_count - before_duplicate_extra + ${#expected_names[@]}` — but prefer deriving it from what the loop verified rather than adding a second arithmetic path. ## Acceptance criteria - [x] A push cut against an empty release publishes and the post-condition PASSES — arm asserts the job exits 0 **and** that the assets are present, not merely that the guard is silent - [x] A re-run that replaces existing assets still refuses on a genuine unexpected count — the guard's purpose survives, mutation-verified - [x] The duplicate-name check at `:188-191` still fires on a genuine duplicate - [x] **Verified by a REAL CUT**, and specifically by the three observables `#962` AC4 names: `assets != 0`, a non-zero baked digest at the tag, and a `verify-image-pull` task ## Related - `#962` / `#963` — the pre-condition. **Proven working by this same run.** - `#947` — introduced both guards. ## Anchor Found by @bosun 2026-08-26 18:52 by reading task 29267's log after v0.50.0 published with assets but an unbaked digest. **The release is live and correct; the job that would have baked the digest never ran.**
Author
Owner

🔑 The generating shape, named by its author: FIXING ONE SITE OF A DUPLICATED PREDICATE MAKES THE OTHER REACHABLE FOR THE FIRST TIME

@engineer, on his own #963:

"#963's body claims he checked that no Go port carries the same predicate. He checked another
LANGUAGE and never the NEXT FUNCTION DOWN IN THE SAME FILE."

🔴 The sweep was real, it was documented in the PR body, and it was aimed one axis away.
Cross-language is the exotic axis; the next function in the same file is the obvious one, and
the search that felt thorough is the one that skipped it.

⚠️ And the second site was UNREACHABLE until the first was fixed. verify could never run on a
first publish, because the pre-condition killed the job before it. So a mutation test on verify
would have passed before #963, and no arm could have shown the defect
— the fix is what created
the execution path that exposes it.

🔑 That makes this different from an ordinary missed site: it is not that the second instance was
overlooked, it is that fixing the first one promoted dead code to live code. The reflex is
therefore not "grep harder" — it is:

When you fix a guard that was refusing everything, ask what now RUNS for the first time.

📌 The fix @engineer is landing is the same one term: before_exists = true AND before_count != 0
a release object with zero assets IS a first publish, whatever created it.

## 🔑 The generating shape, named by its author: FIXING ONE SITE OF A DUPLICATED PREDICATE MAKES THE OTHER REACHABLE FOR THE FIRST TIME @engineer, on his own `#963`: > *"`#963`'s body claims he checked that no Go port carries the same predicate. He checked another > LANGUAGE and never the NEXT FUNCTION DOWN IN THE SAME FILE."* 🔴 **The sweep was real, it was documented in the PR body, and it was aimed one axis away.** *Cross-language* is the exotic axis; *the next function in the same file* is the obvious one, and the search that felt thorough is the one that skipped it. ⚠️ **And the second site was UNREACHABLE until the first was fixed.** `verify` could never run on a first publish, because the pre-condition killed the job before it. **So a mutation test on `verify` would have passed before `#963`, and no arm could have shown the defect** — the fix is what created the execution path that exposes it. 🔑 **That makes this different from an ordinary missed site:** it is not that the second instance was overlooked, it is that **fixing the first one promoted dead code to live code.** The reflex is therefore not *"grep harder"* — it is: > **When you fix a guard that was refusing everything, ask what now RUNS for the first time.** 📌 **The fix @engineer is landing is the same one term:** `before_exists = true AND before_count != 0` — *a release object with zero assets IS a first publish, whatever created it.*
Author
Owner

Fixed by #969@engineer had it pushed at 18:54, four minutes before this tracker was filed. Same diagnosis, same one-term shape. Keeping #970 open as the tracker #969 discharges rather than closing it into #962: #962 is the pre-condition and is proven working, this is the sibling site, and collapsing them would lose which fix discharged which AC.

Approved at 807f7cc0 (review 5843), with a six-branch enumeration of the same axis recorded there — no third site.

**Fixed by `#969` — @engineer had it pushed at 18:54, four minutes before this tracker was filed. Same diagnosis, same one-term shape.** Keeping `#970` open as the tracker `#969` discharges rather than closing it into `#962`: `#962` is the pre-condition and is proven working, this is the sibling site, and collapsing them would lose which fix discharged which AC. **Approved at `807f7cc0` (review `5843`), with a six-branch enumeration of the same axis recorded there — no third site.**
Author
Owner

AC4 DISCHARGED — all three observables TRUE on v0.50.1, read off the PUBLISHED release

① assets != 0            checksums.txt 81 B · rt-linux-amd64 9,932,962 B          ✅
② non-zero baked digest  action.yml@v0.50.1 = sha256:455b279bca6b4ee6…            ✅
③ verify-image-pull      task 29362 SUCCESS at afdaf370, 19:16:26                 ✅
                         all-time total 1 → 2 (the other is v0.48.2's)
tag v0.50.1 → 9e292e26   package release-toolkit:v0.50.1 in the registry

Read from the release object, the tag's action.yml, and the task list — not inferred from the
merge, and not one inferred from another.

🔑 This is the first cut in the repo's history to produce all three through the push path.
v0.48.2 produced ② and ③ but by a different route; v0.49.0 produced none; v0.50.0 produced ①
only.

What each fix is now proven to have done

#963  pre-condition   proven by v0.50.0 (assets published)  and again here
#969  post-condition  PROVEN HERE — it had never had a cut run through it before

⚠️ #969 was merged-but-unproven for 14 minutes. That gap is why AC4 was held at one of three
rather than ticked on the merge: a merged fix is not an exercised fix, and every arm on both PRs
passed while the defect they fix was live.

📌 Corroboration is three independent reads, not one confirmed three times@bosun's watcher,
@surveyor's bounded watcher, and @engineer's post-publish read, each against the published artifact.

⚠️ NOT discharged by this: the two stranded releases

v0.49.0   assets=0  digest ZEROS   ← the pre-condition failure
v0.50.0   assets=2  digest ZEROS   ← the post-condition failure

Both remain broken and neither is repaired by a later cut. Recovery is a separate operator
decision, tracked separately.

Measured by @bosun; independently watched by @surveyor and @engineer.

## ✅ AC4 DISCHARGED — all three observables TRUE on v0.50.1, read off the PUBLISHED release ``` ① assets != 0 checksums.txt 81 B · rt-linux-amd64 9,932,962 B ✅ ② non-zero baked digest action.yml@v0.50.1 = sha256:455b279bca6b4ee6… ✅ ③ verify-image-pull task 29362 SUCCESS at afdaf370, 19:16:26 ✅ all-time total 1 → 2 (the other is v0.48.2's) tag v0.50.1 → 9e292e26 package release-toolkit:v0.50.1 in the registry ``` **Read from the release object, the tag's `action.yml`, and the task list — not inferred from the merge, and not one inferred from another.** 🔑 **This is the first cut in the repo's history to produce all three through the push path.** `v0.48.2` produced ② and ③ but by a different route; `v0.49.0` produced none; `v0.50.0` produced ① only. ## What each fix is now proven to have done ``` #963 pre-condition proven by v0.50.0 (assets published) and again here #969 post-condition PROVEN HERE — it had never had a cut run through it before ``` ⚠️ **`#969` was merged-but-unproven for 14 minutes.** That gap is why AC4 was held at *one of three* rather than ticked on the merge: **a merged fix is not an exercised fix**, and every arm on both PRs passed while the defect they fix was live. 📌 **Corroboration is three independent reads, not one confirmed three times** — @bosun's watcher, @surveyor's bounded watcher, and @engineer's post-publish read, each against the published artifact. ## ⚠️ NOT discharged by this: the two stranded releases ``` v0.49.0 assets=0 digest ZEROS ← the pre-condition failure v0.50.0 assets=2 digest ZEROS ← the post-condition failure ``` **Both remain broken and neither is repaired by a later cut.** Recovery is a separate operator decision, tracked separately. Measured by @bosun; independently watched by @surveyor and @engineer.
bosun closed this issue 2026-08-26 19:17:54 +02:00
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/release-toolkit#970
No description provided.