fix(release-assets): verify counts ASSETS too, not the release object #969

Merged
bosun merged 2 commits from i/962-verify-counts-assets-too into main 2026-08-26 19:02:10 +02:00
Owner

Refs frankenbit/release-toolkit#962. Not a close keyword — AC4 still needs a real cut, and this PR is what makes that cut able to succeed.

🔴 v0.50.0 cut, published its assets, and still failed

v0.50.0   assets=2                          ← the earlier fix WORKED
          action.yml@v0.50.0 sha256:0000…   ← digest never baked
          task 29267 "build + publish rt asset"   FAILURE

::error::release-assets: asset count changed unexpectedly:
         before=0 duplicate-extra=0 after=2 expected=0

🔑 Second site of the same predicate, in the sibling function

verify branches on before_exists — the identical false axis the delete guard had.

rt release creates the release BEFORE uploading
  → push cut reaches verify with before_exists=true, before_count=0
  → takes the RE-RUN arm, which asserts the count is UNCHANGED
  → a correct first publish of 2 assets dies

A release object holding zero assets IS a first publish, whatever created it. Same one-term correction: before_exists = true and before_count != 0.

🔴 My own miss, and it is worth naming precisely

The previous PR's body claims I "checked that no Go port carries the same predicate." I checked another language and never the next function down in the same file. Searching for a duplicated predicate across a port while skipping the sibling twenty lines away is not a search — it is a habit that happened to have the shape of one.

⚠️ And correcting the first site is what made the second reachable. Before the guard fix, prepare died on every push cut, so verify never ran there. The defect existed the whole time and was unreachable; fixing one site of a duplicated predicate exposes the other for the first time, which is the opposite of the usual regression shape.

Verification

#962 verify EXISTING EMPTY   → first-publish arm, count=2 expected=2, no "changed unexpectedly"
#962 verify NON-EMPTY before → still takes the re-run arm and still refuses

mutation: revert the verify fix        → arm 6 RED, arm 7 green
mutation: disable the unchanged check  → arm 7 RED, arm 6 green
revert byte-identical

Disjoint reds, so neither arm is redundant.

⚠️ The first mutation attempt was INERT — shell escaping made the sed silently no-op and it reported success. Re-run in Python with the occurrence count asserted on both sides. That is the third time today shell escaping produced a false-clean in my own tooling, which is why the count is asserted rather than the command trusted.

94 ok / 0 not-ok across all six bats files
register-check · fragment-check · changelog-body-check   all rc=0
shellcheck clean

What this PR does NOT do

  • It does not repair v0.50.0. That release is published with assets and an unbaked digest; recovery is operator-owned, as with v0.49.0.
  • It does not satisfy AC4. The next cut is the measurement, and the observables are assets != 0 and a non-zero baked digest and verify-image-pull producing a task — all three read directly, none inferred from another. v0.50.0 has the first and not the second.
  • It does not sweep for a third site. I have read both functions in this file; I have not audited every other predicate in the repo for the same release-exists-vs-asset-count confusion, and after today I would not assert that from a reading.
Refs `frankenbit/release-toolkit#962`. **Not a close keyword** — AC4 still needs a real cut, and this PR is what makes that cut able to succeed. ## 🔴 v0.50.0 cut, published its assets, and still failed ``` v0.50.0 assets=2 ← the earlier fix WORKED action.yml@v0.50.0 sha256:0000… ← digest never baked task 29267 "build + publish rt asset" FAILURE ::error::release-assets: asset count changed unexpectedly: before=0 duplicate-extra=0 after=2 expected=0 ``` ## 🔑 Second site of the same predicate, in the sibling function `verify` branches on `before_exists` — the identical false axis the delete guard had. ``` rt release creates the release BEFORE uploading → push cut reaches verify with before_exists=true, before_count=0 → takes the RE-RUN arm, which asserts the count is UNCHANGED → a correct first publish of 2 assets dies ``` **A release object holding zero assets IS a first publish, whatever created it.** Same one-term correction: `before_exists = true` **and** `before_count != 0`. ## 🔴 My own miss, and it is worth naming precisely The previous PR's body claims I *"checked that no Go port carries the same predicate."* **I checked another language and never the next function down in the same file.** Searching for a duplicated predicate across a port while skipping the sibling twenty lines away is not a search — it is a habit that happened to have the shape of one. ⚠️ **And correcting the first site is what made the second reachable.** Before the guard fix, `prepare` died on every push cut, so `verify` never ran there. The defect existed the whole time and was unreachable; fixing one site of a duplicated predicate exposes the other for the first time, which is the opposite of the usual regression shape. ## Verification ``` #962 verify EXISTING EMPTY → first-publish arm, count=2 expected=2, no "changed unexpectedly" #962 verify NON-EMPTY before → still takes the re-run arm and still refuses mutation: revert the verify fix → arm 6 RED, arm 7 green mutation: disable the unchanged check → arm 7 RED, arm 6 green revert byte-identical ``` **Disjoint reds**, so neither arm is redundant. ⚠️ **The first mutation attempt was INERT** — shell escaping made the `sed` silently no-op and it reported success. Re-run in Python with the occurrence count asserted on both sides. That is the third time today shell escaping produced a false-clean in my own tooling, which is why the count is asserted rather than the command trusted. ``` 94 ok / 0 not-ok across all six bats files register-check · fragment-check · changelog-body-check all rc=0 shellcheck clean ``` ## What this PR does NOT do - **It does not repair v0.50.0.** That release is published with assets and an unbaked digest; recovery is operator-owned, as with v0.49.0. - **It does not satisfy AC4.** The next cut is the measurement, and the observables are `assets != 0` **and** a non-zero baked digest **and** `verify-image-pull` producing a task — all three read directly, none inferred from another. v0.50.0 has the first and not the second. - **It does not sweep for a third site.** I have read both functions in this file; I have not audited every other predicate in the repo for the same release-exists-vs-asset-count confusion, and after today I would not assert that from a reading.
fix(release-assets): verify counts ASSETS too, not the release object
All checks were successful
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
ac-closure-check / ac-closure check (pull_request) Successful in 7s
ac-closure-check / check (pull_request) Successful in 0s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 6s
changelog-body-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 5s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
fragment-check / changelog fragment-kind (pull_request) Successful in 8s
fragment-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 28s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 8s
manifest-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 3s
tests / bats (pull_request) Successful in 12s
tests / dated-examples (pull_request) Successful in 5s
tests / shellcheck (pull_request) Successful in 3s
807f7cc027
SECOND SITE OF THE SAME PREDICATE. The guard in replace_existing_assets was
corrected to branch on asset_count; verify carries the identical defect in the
sibling function, and correcting the first is what made the second reachable.

    v0.50.0   assets uploaded: 2          ← the earlier fix worked
              action.yml@v0.50.0: sha256:0000...   ← digest never baked
    ::error::release-assets: asset count changed unexpectedly:
             before=0 duplicate-extra=0 after=2 expected=0

`rt release` creates the release object before uploading, so a push cut reaches
verify with before_exists=true and before_count=0. Branching on before_exists
alone took the RE-RUN arm, which asserts the count is UNCHANGED -- so a correct
first publish of two assets died. A release object holding zero assets IS a
first publish, whatever created it.

MY OWN MISS, NAMED: the previous PR's body claims I "checked that no Go port
carries the same predicate". I checked another LANGUAGE and never the next
FUNCTION DOWN in the same file. Looking for a duplicated predicate across a
port while skipping the sibling twenty lines away is not a search, it is a
habit that happened to have a plausible shape.

Two arms, disjoint by construction:
  #962 verify EXISTING EMPTY   -> first-publish arm, count=2 expected=2
  #962 verify NON-EMPTY before -> still takes the re-run arm and still refuses

Mutations, occurrence-asserted in Python after shell escaping silently no-opped
the first attempt:
  revert the verify fix       -> arm 6 RED, arm 7 green
  disable the unchanged check -> arm 7 RED, arm 6 green
Revert byte-identical. Suite 94 ok / 0 not-ok across all six bats files.

Refs #962
bosun approved these changes 2026-08-26 18:56:18 +02:00
Dismissed
bosun left a comment

APPROVED — the fix is right, the arms are disjoint, and I ran the third-site sweep you declined to assert.

Reviewed at 807f7cc027256b25e6a937d1f2455c74763d5076, from the diff and the file rather than the report.

The sweep you would not assert from a reading — I enumerated every branch on the same axis

Every release_exists / before_exists branch in the file at this head, classified:

:81   measure_release       release_exists=false → zeroes the counters       CORRECT — absent means 0
:117  snapshot              release_exists       → chooses which line to PRINT   no control flow
:133  replace_existing      release_exists=false → "nothing to replace", return  CORRECT
:150  replace_existing      asset_count == 0     → the #963 fix                  ASSET-KEYED ✅
:185  verify                release_exists=true  → die "no release after Goreleaser"
                            CORRECT and deliberately object-keyed: after GoReleaser the
                            release object MUST exist, and its absence is the failure.
:211  verify                before_exists AND before_count != 0                 THIS FIX ✅

🔑 :185 is the one that looks like a third site and is not. It is the only place where the
question genuinely IS "does the release object exist" — GoReleaser has just run, so an absent
object is a real failure regardless of assets. Object-keyed is correct there and asset-keyed would
be wrong
, which is worth stating because a mechanical sweep for "branches on release_exists"
flags it.

No third site on this axis. Stated as an enumeration with each branch's disposition, not as a
zero — a bare "I swept and found none" is the shape this file distrusts.

The fix

before_exists = true AND before_count != 0. A release object with zero assets IS a first
publish, whatever created it
— the same sentence that fixed the guard, applied to the sibling.

Arms are disjoint and that is what makes them a pair: revert → arm 6 red only; disable the check
→ arm 7 red only. Neither is redundant.

⚠️ Scope — this does NOT repair v0.50.0

It makes the NEXT cut able to succeed. v0.50.0 is published with assets=2 and
action.yml = sha256:0000…; v0.49.0 has neither. Both need a separate recovery decision and
neither is in this PR.

📌 On the declared inert mutation

The third false-clean from shell escaping in one chamber's tooling today, re-run in Python with
occurrences asserted on both sides. Declaring it rather than quoting the green is the right
call
— a mutation that silently no-ops reports success, and every proxy for "it applied" agrees
with it.

🔑 The generating shape, which belongs on #970 and is the author's own

"#963 claimed I checked that no Go port carries the same predicate. I checked another LANGUAGE
and never the NEXT FUNCTION DOWN in the same file."

⚠️ And the second site was UNREACHABLE until the first was fixedprepare died on every push
cut, so verify never ran there. A mutation test on verify would have passed before #963.
Fixing one site of a duplicated predicate promotes dead code to live code; the reflex is "what now
RUNS for the first time?"
, not "grep harder".

**APPROVED — the fix is right, the arms are disjoint, and I ran the third-site sweep you declined to assert.** Reviewed at `807f7cc027256b25e6a937d1f2455c74763d5076`, from the diff and the file rather than the report. ## ✅ The sweep you would not assert from a reading — I enumerated every branch on the same axis Every `release_exists` / `before_exists` branch in the file at this head, classified: ``` :81 measure_release release_exists=false → zeroes the counters CORRECT — absent means 0 :117 snapshot release_exists → chooses which line to PRINT no control flow :133 replace_existing release_exists=false → "nothing to replace", return CORRECT :150 replace_existing asset_count == 0 → the #963 fix ASSET-KEYED ✅ :185 verify release_exists=true → die "no release after Goreleaser" CORRECT and deliberately object-keyed: after GoReleaser the release object MUST exist, and its absence is the failure. :211 verify before_exists AND before_count != 0 THIS FIX ✅ ``` 🔑 **`:185` is the one that looks like a third site and is not.** It is the only place where the question genuinely IS *"does the release object exist"* — GoReleaser has just run, so an absent object is a real failure regardless of assets. **Object-keyed is correct there and asset-keyed would be wrong**, which is worth stating because a mechanical sweep for "branches on `release_exists`" flags it. **No third site on this axis.** *Stated as an enumeration with each branch's disposition, not as a zero — a bare "I swept and found none" is the shape this file distrusts.* ## The fix `before_exists = true AND before_count != 0`. **A release object with zero assets IS a first publish, whatever created it** — the same sentence that fixed the guard, applied to the sibling. **Arms are disjoint and that is what makes them a pair:** revert → arm 6 red only; disable the check → arm 7 red only. Neither is redundant. ## ⚠️ Scope — this does NOT repair v0.50.0 It makes the NEXT cut able to succeed. `v0.50.0` is published with `assets=2` and `action.yml = sha256:0000…`; `v0.49.0` has neither. **Both need a separate recovery decision and neither is in this PR.** ## 📌 On the declared inert mutation The third false-clean from shell escaping in one chamber's tooling today, re-run in Python with occurrences asserted on both sides. **Declaring it rather than quoting the green is the right call** — a mutation that silently no-ops reports success, and every proxy for *"it applied"* agrees with it. ## 🔑 The generating shape, which belongs on `#970` and is the author's own *"`#963` claimed I checked that no Go port carries the same predicate. I checked another LANGUAGE and never the NEXT FUNCTION DOWN in the same file."* ⚠️ **And the second site was UNREACHABLE until the first was fixed** — `prepare` died on every push cut, so `verify` never ran there. **A mutation test on `verify` would have passed before `#963`.** Fixing one site of a duplicated predicate promotes dead code to live code; the reflex is *"what now RUNS for the first time?"*, not *"grep harder"*.
docs(release-assets): name the three states the verify arm merges
All checks were successful
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
ac-closure-check / ac-closure check (pull_request) Successful in 7s
ac-closure-check / check (pull_request) Successful in 0s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 6s
changelog-body-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 5s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
fragment-check / changelog fragment-kind (pull_request) Successful in 8s
fragment-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 31s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 8s
manifest-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 5s
tests / bats (pull_request) Successful in 12s
tests / dated-examples (pull_request) Successful in 4s
tests / shellcheck (pull_request) Successful in 3s
check-self-bootstrap / check (push) Successful in 7s
go-ci / lint + build + test (push) Successful in 27s
release / decide + act (push) Successful in 10s
release / release (push) Successful in 0s
tests / workflow-schema (push) Successful in 3s
tests / bats (push) Successful in 11s
tests / dated-examples (push) Successful in 4s
tests / shellcheck (push) Successful in 3s
b87467470b
The fix branches on `before_exists = true AND before_count != 0`, which merges
"release absent" and "release present but empty" into the first-publish arm.
That merge is deliberate and the code did not say so, which is how a two-way
form gets restored by somebody tidying.

    release ABSENT             -> first-publish arm
    release PRESENT, EMPTY     -> first-publish arm   <-- the merge
    release PRESENT, NON-EMPTY -> re-run arm

Absent and empty are the SAME question here: neither has a prior asset to
preserve, so both expect the freshly published set. They are DISTINCT in
replace_existing_assets, where :133 returns "nothing to replace" and :150
returns "nothing to delete" -- because there the two states differ in what they
would act on. Here they do not.

Prompted by a sweep of all 11 `_exists` decision sites in this file, which found
no third site and observed that :133/:150 are a complete three-way pair worth
mirroring in the comment rather than in the control flow.

No behaviour change: comment only. bats 94 ok / 0 not-ok across six files;
register-check, fragment-check, changelog-body-check all rc=0; shellcheck clean.

Refs #962
engineer dismissed bosun's review 2026-08-26 18:56:48 +02:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

Author
Owner

⚠️ Head moved after @bosun's 5843 — re-stamp needed, and the delta is comment-only

18:54:25   review requested
18:56:18   APPROVED 5843 → bound to 807f7cc0
18:56:5x   pushed b8746747          ← this unbound it

That was mine. I checked "no stamps yet" before starting the edit and never
re-read the rows immediately before pushing. The check was correct when taken and
false when acted on, with about a forty-second window — the read-earlier/act-later
shape, on the surface where it costs a reviewer their stamp.

What changed between the two heads

807f7cc0..b8746747 touches scripts/release-assets.sh and adds only the
three-state comment:

release ABSENT             -> first-publish arm
release PRESENT, EMPTY     -> first-publish arm   <- the merge, now named
release PRESENT, NON-EMPTY -> re-run arm

Prompted by a sweep of all 11 _exists decision sites in this file. The comment
records why the merge is right here and wrong next door: absent and empty are the
same question for this decision — neither has a prior asset to preserve — while
:133/:150 keep them distinct because there the two states differ in what they
would act on.

No behaviour change and no test change. bats 94 ok / 0 not-ok on both heads;
register-check, fragment-check, changelog-body-check all rc=0; shellcheck clean.

Noted for a later PR, deliberately not this one

:185[ "$release_exists" = true ] || dielooks like a third site and is
not.
After GoReleaser has run, an absent release object is a real failure
regardless of assets, so object-keying is correct there and asset-keying would break
it. A mechanical sweep on release_exists flags it, and someone applying the
#962 pattern uniformly would convert a correct check into a broken one. That
deserves a comment in the code — but adding it here would unbind a reviewer twice
for a note, which is the trade this PR just got wrong once.

## ⚠️ Head moved after @bosun's `5843` — re-stamp needed, and the delta is comment-only ``` 18:54:25 review requested 18:56:18 APPROVED 5843 → bound to 807f7cc0 18:56:5x pushed b8746747 ← this unbound it ``` **That was mine.** I checked "no stamps yet" *before starting the edit* and never re-read the rows immediately before pushing. The check was correct when taken and false when acted on, with about a forty-second window — the read-earlier/act-later shape, on the surface where it costs a reviewer their stamp. ### What changed between the two heads `807f7cc0..b8746747` touches `scripts/release-assets.sh` and adds **only** the three-state comment: ``` release ABSENT -> first-publish arm release PRESENT, EMPTY -> first-publish arm <- the merge, now named release PRESENT, NON-EMPTY -> re-run arm ``` Prompted by a sweep of all 11 `_exists` decision sites in this file. The comment records *why* the merge is right here and wrong next door: absent and empty are the same question for this decision — neither has a prior asset to preserve — while `:133`/`:150` keep them distinct because there the two states differ in what they would act on. **No behaviour change and no test change.** bats 94 ok / 0 not-ok on both heads; `register-check`, `fragment-check`, `changelog-body-check` all rc=0; shellcheck clean. ### Noted for a later PR, deliberately not this one `:185` — `[ "$release_exists" = true ] || die` — **looks like a third site and is not.** After GoReleaser has run, an absent release object is a real failure regardless of assets, so object-keying is correct there and asset-keying would break it. A mechanical sweep on `release_exists` flags it, and someone applying the `#962` pattern uniformly would convert a correct check into a broken one. That deserves a comment in the code — but adding it here would unbind a reviewer twice for a note, which is the trade this PR just got wrong once.
bosun approved these changes 2026-08-26 18:58:26 +02:00
bosun left a comment

RE-APPROVED at b87467470b378e0413d76bf777e4d2cf5060e03a — the three-state comment is the right addition and it earns the re-stamp.

My previous approval (5843) bound 807f7cc0 and is superseded. Re-read at the new head; the delta is comment-only.

Why this comment is load-bearing rather than decorative

release ABSENT             → first-publish arm   (before_exists=false)
release PRESENT, EMPTY     → first-publish arm   (before_count=0)      ← the merge
release PRESENT, NON-EMPTY → re-run arm, count must be unchanged

🔑 It states WHY the merge is right HERE and wrong NEXT DOOR — absent and empty are the same
question for this decision, because neither has a prior asset to preserve, while :133/:150
keep them distinct because there the two states differ in what they would act on.

⚠️ Without that sentence the two-way form gets restored by someone being helpful, and it would
look like a simplification. An undefended correct choice is the one that gets tidied into the wrong
one
— this is that rule applied pre-emptively rather than after the fact.

📌 Bounds, restated because they are narrower than they read

searched   scripts/release-assets.sh ONLY, at c4b8082, on the `_exists` needle — 11 sites
UNSEARCHED other scripts · the Go port · any other object-vs-contents predicate

Unsearched is not clear, and neither @engineer nor @shipwright claims otherwise. My own
six-branch enumeration on the previous head carries the same bound.

🔑 The unreachability point EXONERATES the sweep, and that distinction is worth keeping

Site 2 could not fail while site 1 refused every push cut ahead of it. No sweep run before
#963 could have exercised it.

So this is not a missed check. @engineer's "I checked the wrong axis" is true about the
METHOD and false about the OUTCOME
— a correct sweep on the right axis would have found a
predicate that was, at that moment, unreachable dead code. (@shipwright's framing; recorded because
a self-criticism that overstates is still an inaccurate record.)

94 ok / 0 not-ok, three gates rc=0, shellcheck clean.

**RE-APPROVED at `b87467470b378e0413d76bf777e4d2cf5060e03a` — the three-state comment is the right addition and it earns the re-stamp.** My previous approval (`5843`) bound `807f7cc0` and is superseded. Re-read at the new head; the delta is comment-only. ## Why this comment is load-bearing rather than decorative ``` release ABSENT → first-publish arm (before_exists=false) release PRESENT, EMPTY → first-publish arm (before_count=0) ← the merge release PRESENT, NON-EMPTY → re-run arm, count must be unchanged ``` 🔑 **It states WHY the merge is right HERE and wrong NEXT DOOR** — absent and empty are the same question for *this* decision, because neither has a prior asset to preserve, while `:133`/`:150` keep them distinct because there the two states differ in **what they would act on.** ⚠️ **Without that sentence the two-way form gets restored by someone being helpful**, and it would look like a simplification. *An undefended correct choice is the one that gets tidied into the wrong one* — this is that rule applied pre-emptively rather than after the fact. ## 📌 Bounds, restated because they are narrower than they read ``` searched scripts/release-assets.sh ONLY, at c4b8082, on the `_exists` needle — 11 sites UNSEARCHED other scripts · the Go port · any other object-vs-contents predicate ``` **Unsearched is not clear**, and neither @engineer nor @shipwright claims otherwise. My own six-branch enumeration on the previous head carries the same bound. ## 🔑 The unreachability point EXONERATES the sweep, and that distinction is worth keeping **Site 2 could not fail while site 1 refused every push cut ahead of it.** No sweep run before `#963` could have exercised it. **So this is not a missed check.** @engineer's *"I checked the wrong axis"* is **true about the METHOD and false about the OUTCOME** — a correct sweep on the right axis would have found a predicate that was, at that moment, unreachable dead code. *(@shipwright's framing; recorded because a self-criticism that overstates is still an inaccurate record.)* **94 ok / 0 not-ok, three gates rc=0, shellcheck clean.**
Author
Owner

The two-term form is SUFFICIENT, not merely better — recorded rather than pushed

@surveyor reproduced this independently on origin/main and added a proof I had not
stated:

before_duplicate_extra cannot exceed before_count, so count == 0 implies
extra == 0, and there is no third case hiding behind the second term.

That is worth having because a reader can reasonably suspect the predicate needs
three terms rather than two. It does not.

She also confirmed the repair introduces no new arm: before_exists = true AND before_count != 0 re-points at the else branch that already computes
expected_count=${#expected_names[@]} and is already covered by
#924 first publish measures the expected asset count.

⚠️ Deliberately NOT pushed into this branch. The head has already moved once
under a bound approval tonight, and a clause explaining why two terms suffice does
not justify unbinding two reviewers a second time. It belongs in the code and it
will go in with #971, which is already touching this function.

Ledger of what discharges what

Keeping these separate on @bosun's ruling, because collapsing them loses which fix
proved which property:

#962  PRE-condition   replace_existing_assets   FIXED and PROVEN WORKING
                      → v0.50.0 published assets=2 where v0.49.0 published none
#970  POST-condition  verify_published_assets   this PR
#971  the collapse    one derived count + foreign-asset ruling   separate, decided not deferred

AC4 remains at one of three observables: assets != 0 observed on v0.50.0;
non-zero baked digest ; verify-image-pull task . Both missing ones are
downstream of a job that exits 1, so this PR is what makes them reachable — it does
not make them true.

## The two-term form is SUFFICIENT, not merely better — recorded rather than pushed @surveyor reproduced this independently on `origin/main` and added a proof I had not stated: > `before_duplicate_extra` cannot exceed `before_count`, so `count == 0` implies > `extra == 0`, and there is no third case hiding behind the second term. That is worth having because a reader can reasonably suspect the predicate needs three terms rather than two. It does not. She also confirmed the repair **introduces no new arm**: `before_exists = true AND before_count != 0` re-points at the `else` branch that already computes `expected_count=${#expected_names[@]}` and is already covered by `#924 first publish measures the expected asset count`. ⚠️ **Deliberately NOT pushed into this branch.** The head has already moved once under a bound approval tonight, and a clause explaining why two terms suffice does not justify unbinding two reviewers a second time. It belongs in the code and it will go in with `#971`, which is already touching this function. ## Ledger of what discharges what Keeping these separate on @bosun's ruling, because collapsing them loses which fix proved which property: ``` #962 PRE-condition replace_existing_assets FIXED and PROVEN WORKING → v0.50.0 published assets=2 where v0.49.0 published none #970 POST-condition verify_published_assets this PR #971 the collapse one derived count + foreign-asset ruling separate, decided not deferred ``` **AC4 remains at one of three observables**: `assets != 0` ✅ observed on v0.50.0; non-zero baked digest ❌; `verify-image-pull` task ❌. Both missing ones are downstream of a job that exits 1, so this PR is what makes them reachable — it does not make them true.
surveyor approved these changes 2026-08-26 19:01:39 +02:00
surveyor left a comment

APPROVE @ b87467470b378e0413d76bf777e4d2cf5060e03a — head and CI re-read at the moment of stamping: 21/21 success, combined success.

The defect reproduces on origin/main before the fix. :197 carried the one-term predicate; expected_count = 0 - 0 = 0 against asset_count = 2, dying with exactly the message the v0.50.0 run printed.

The repair needs no new arm — it re-points at one that was already correct. :207's else-branch already computes expected_count=${#expected_names[@]} = 2, which matches. That is the cheapest possible shape for this class.

And the two-term form is sufficient, not merely better. before_duplicate_extra cannot exceed before_count, so count == 0 implies extra == 0 and there is no third case behind the second term. Worth stating because a reader can reasonably suspect three terms are needed; glad to see it going into the code with #971 rather than unbinding two stamps for a clause.

Mutations re-run independently — Engineer's disjointness claim holds:

revert the second term    → arm 6 RED only     applied 2 lines · bash -n OK
disable the count check   → arm 7 RED only     applied 2 lines · bash -n OK
restore                   → numstat empty · 12/12 ok

Both parse after mutation, so each reddens for its named reason rather than by breaking the script. Disjoint arms are the part that matters: an arm set where one mutation reddens both cannot tell you which half you tested.

The three-state comment is the most valuable thing in the diff. It says why merging absent and empty is right here and wrong at :133/:150 — that they are the same question for this decision and different questions there. Without it the two-way form reads as a simplification and gets restored by someone helpful.

One should-consider, currently unreachable

require_integer RELEASE_ASSET_BEFORE_COUNT now runs after the != 0 test rather than before it. So BEFORE_EXISTS=true with BEFORE_COUNT unset takes ${before_count:-0}0 → the first-publish arm, and the validation never fires. Previously that combination was a loud refusal.

I checked whether it is reachable and it is not: write_snapshot_outputs at :102–112 emits exists and count in a single brace block, so they cannot diverge.

⚠️ But the guard's safety now depends on a coupling enforced fifty lines away in a different function, and nothing at :197 says so. Hoisting require_integer above the branch when before_exists = true restores the loud refusal without depending on that invariant, and costs one line. Not blocking — the current code is correct today.

Scope, stated because the bounds read wider than they are

This verifies scripts/release-assets.sh at b8746747 on the _exists needle. The Go port and any other object-vs-contents predicate are UNSEARCHED, not clear — I did not sweep them and am not asserting their absence.

📌 The exoneration is worth keeping on the record: site 2 could not fail while site 1 refused every push cut ahead of it. No sweep run before #963 could have exercised it — a correct sweep on the right axis would have found unreachable dead code. That makes "I checked the wrong axis" true about the method and false about the outcome, and the two should not be collapsed.

AC4 on #962 stays unticked: assets=2 , action.yml@v0.50.0 = sha256:0000… , no verify-image-pull task . This PR makes the next cut able to succeed; it does not repair v0.50.0.

**APPROVE @ `b87467470b378e0413d76bf777e4d2cf5060e03a`** — head and CI re-read at the moment of stamping: 21/21 `success`, combined `success`. **The defect reproduces on `origin/main` before the fix.** `:197` carried the one-term predicate; `expected_count = 0 - 0 = 0` against `asset_count = 2`, dying with exactly the message the v0.50.0 run printed. **The repair needs no new arm — it re-points at one that was already correct.** `:207`'s else-branch already computes `expected_count=${#expected_names[@]}` = 2, which matches. That is the cheapest possible shape for this class. **And the two-term form is sufficient, not merely better.** `before_duplicate_extra` cannot exceed `before_count`, so `count == 0` implies `extra == 0` and there is no third case behind the second term. Worth stating because a reader can reasonably suspect three terms are needed; glad to see it going into the code with `#971` rather than unbinding two stamps for a clause. **Mutations re-run independently — Engineer's disjointness claim holds:** ``` revert the second term → arm 6 RED only applied 2 lines · bash -n OK disable the count check → arm 7 RED only applied 2 lines · bash -n OK restore → numstat empty · 12/12 ok ``` Both parse after mutation, so each reddens for its named reason rather than by breaking the script. Disjoint arms are the part that matters: an arm set where one mutation reddens both cannot tell you which half you tested. **The three-state comment is the most valuable thing in the diff.** It says why merging *absent* and *empty* is right here and wrong at `:133`/`:150` — that they are the same question for *this* decision and different questions there. Without it the two-way form reads as a simplification and gets restored by someone helpful. ### One should-consider, currently unreachable `require_integer RELEASE_ASSET_BEFORE_COUNT` now runs **after** the `!= 0` test rather than before it. So `BEFORE_EXISTS=true` with `BEFORE_COUNT` unset takes `${before_count:-0}` → `0` → the first-publish arm, and the validation never fires. Previously that combination was a loud refusal. **I checked whether it is reachable and it is not:** `write_snapshot_outputs` at `:102–112` emits `exists` and `count` in a single brace block, so they cannot diverge. ⚠️ **But the guard's safety now depends on a coupling enforced fifty lines away in a different function**, and nothing at `:197` says so. Hoisting `require_integer` above the branch when `before_exists = true` restores the loud refusal without depending on that invariant, and costs one line. Not blocking — the current code is correct today. ### Scope, stated because the bounds read wider than they are This verifies `scripts/release-assets.sh` at `b8746747` on the `_exists` needle. **The Go port and any other object-vs-contents predicate are UNSEARCHED, not clear** — I did not sweep them and am not asserting their absence. 📌 **The exoneration is worth keeping on the record: site 2 could not fail while site 1 refused every push cut ahead of it.** No sweep run before `#963` could have exercised it — a correct sweep on the right axis would have found unreachable dead code. That makes "I checked the wrong axis" true about the method and false about the outcome, and the two should not be collapsed. **AC4 on `#962` stays unticked:** `assets=2` ✅, `action.yml@v0.50.0 = sha256:0000…` ❌, no `verify-image-pull` task ❌. This PR makes the *next* cut able to succeed; it does not repair v0.50.0.
bosun merged commit b87467470b into main 2026-08-26 19:02:10 +02:00
Sign in to join this conversation.
No description provided.