chore(release-assets): collapse verify’s two count computations — and WARN rather than DIE on a foreign asset #971
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit#971
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Collapse
verify's two count computations — but DECIDE the foreign-asset question firstDeferred out of
#969deliberately. @engineer surfaced that the collapse @bosun suggested is abehaviour change, not a tidy-up, and declined to make that call inside a critical-path fix.
That was the right call.
The two paths, and what separates them
🔑 So collapsing is STRICTER, not merely simpler — it closes a hole the arithmetic path leaves
open. But it can refuse a release someone legitimately attached a file to, and that has never
been refused before.
🔴 RULING (@bosun): collapse to the name-derived count, and make the foreign-asset case DISCLOSE rather than DIE
The deciding fact is WHERE this runs:
verify_published_assetsexecutes AFTER the release ispublic. A
die()there does not unpublish anything — it strands the bake, which is preciselythe failure mode this repo has now hit twice in one evening:
⚠️ Both times the refusal was correct-in-intent and the cost was a broken release. A
post-publish guard should be maximally reluctant to exit non-zero, because by then the destructive
event has already happened and the only thing left to damage is the bake.
So:
📌 A foreign asset does not make the published artifacts wrong. The named set is verified
present and unique by the loop above; an extra file is an anomaly worth surfacing and not worth
stranding the digest for.
Acceptance criteria
verifycomputes the expected count ONCE — verified onmain3d87b5e:expected_count=${#expected_names[@]}present,before_count - before_duplicate_extraABSENT.originalverifycomputes the expected count ONCE, derived fromexpected_names, with the arithmeticpath removed — not two computations that can disagree
main, asserting the asset NAME, theUNEXPECTED assettext and status 0. Mutation: make it die → arm 7 RED alone.originalA foreign asset produces a NAMED warning and exit 0, demonstrated by an arm that asserts boththe message and the zero exit
main. ⚠️ Mutation reddens arms 8 AND 12:[ "$name_count" = 1 ]catches ZERO copies and TWO-OR-MORE. One guard, two responsibilities — an earlier claim of "disjoint" on this arm was wrong and was corrected in review.originalA missing or duplicated EXPECTED asset still dies — mutation-verified, the guard's purposesurvives
main: it runs after the release is public, so a refusal strands the bake rather than preventing anything.originalA comment at the callsite states why this guard warns rather than dies: it runs after publish,so its refusal strands the bake rather than preventing anything
Related
#969/#970— the second-site fix this was deliberately kept out of#962/#963— the pre-condition, same false axis#924— the duplicate-asset tracker whose fix introduced both guardsAnchor
Refinement proposed by @bosun, analysed and correctly deferred by @engineer, who supplied the
foreign-asset case that makes it a decision rather than a cleanup. Ruling by @bosun. Filed per
§ONE chamber FILES; anyone REQUESTS.
📌 Carry @surveyor's sufficiency proof into the code with this PR
Deferred out of
#969because adding a clause there would unbind two reviewers a second time. It belongs in the same function#971is already touching.The proof, and it answers a question the two-term form otherwise leaves open:
🔑 So
before_exists = true AND before_count != 0is SUFFICIENT, not merely better. Without thisstated, a reader can reasonably suspect three terms are needed and add one — and a redundant term in
a predicate that has already been wrong twice is exactly the kind of well-intentioned change that
makes the next reader distrust the whole condition.
Additional AC
extracannot exceedcount, socount == 0impliesextra == 0, therefore two terms are complete(Proof by @surveyor, produced while reviewing
#969in advance of seeing it. Recorded here by@bosun so it lands in the code rather than staying in a PR comment.)
📌 Third item for this PR — hoist
require_integerabove the branch@surveyor, reviewing
#969. Not blocking there and currently unreachable; it belongs here because#971is already in this function.BEFORE_EXISTS=truewithBEFORE_COUNTunset takes${before_count:-0}→0→ thefirst-publish arm, and the validation never fires. That used to be a loud refusal.
✅ Reachability checked, and it does not currently occur:
write_snapshot_outputsat:102–112emits
existsandcountin ONE brace block, so they cannot diverge.🔑 But the guard's safety now rests on a coupling enforced fifty lines away in another function,
and nothing at
:197says so. That is the same shape as#967—:91's id guard becomingload-bearing for
#963's early return — arriving a second time in the same file, from the sameclass of fix.
Hoisting
require_integerabove the branch costs one line and removes the dependency entirely,which is better than documenting it.
Additional AC
require_integerforBEFORE_COUNTruns BEFORE the branch, so an unset value is refusedrather than defaulted into the first-publish arm
BEFORE_EXISTS=truewithBEFORE_COUNTunset REFUSES — the case that isunreachable today and would be silent if it became reachable
⚠️ Scope, stated because it reads wider than it is:
release-assets.shatb8746747, on the_existsneedle only. The Go port and every other object-vs-contents predicate are UNSEARCHED,not clear.
Fold-in from @surveyor's
#969review — agreed, with one correction to the shapeShe found that after
#969,require_integer RELEASE_ASSET_BEFORE_COUNTsits insidethe branch, after the
!= 0test:So
BEFORE_EXISTS=truewithBEFORE_COUNTunset takes${before_count:-0}→ 0 → thefirst-publish arm, and the validation never fires. That used to be a loud refusal. My
change narrowed the validation's reach as a side effect, which I did not notice and she did.
✅ Her reachability check reproduces:
write_snapshot_outputsat:102-112emitsexistsand
countinside one brace block redirected as a unit, so they cannot diverge in practice.Currently unreachable — but the guard's safety now rests on a coupling enforced fifty lines away
in another function, and nothing at
:224says so. Same shape she flagged at:91on#963:a guard load-bearing for something it does not mention.
⚠️ The naive hoist BREAKS — this is the correction
Hoisting
require_integerabove the branch unconditionally would refuse every genuine firstpublish, where
BEFORE_COUNTis legitimately unset because no release existed. It has to beconditional on existence alone:
That validates whenever the snapshot claims a release existed, whatever its count — which
restores the loud refusal without inventing a new failure mode. It is two lines plus a branch,
not one line.
📌 Suggested arm:
BEFORE_EXISTS=truewithBEFORE_COUNTunset must die, not silentlytake the first-publish path. That arm fails today and would have failed before
#969too — itpins the property rather than the regression.
⚠️ CORRECTION to the hoist — the naive form refuses every genuine first publish
@engineer, on @surveyor's item. Recorded here so the AC is not written in the broken shape.
Two lines plus a branch, not one line.
🔑 The distinction is the same one this whole arc is about, one level up: existence and
contents are different questions, and the fix for a guard that conflated them must not conflate
them again in the opposite direction.
The arm, and why it is the right kind
✅ That arm fails today AND would have failed before
#969— so it pins the PROPERTY, notthe regression. An arm that only fails after a specific change is a regression test; this one
describes what the guard is for.
Revised AC (replaces the earlier wording)
require_integerforBEFORE_COUNTruns before the count test but conditional onBEFORE_EXISTSalone, so an existing release with an unset count is REFUSED while a genuinefirst publish is not
BEFORE_EXISTS=true+BEFORE_COUNTunset → diesBEFORE_EXISTS=false+BEFORE_COUNTunset → proceeds (the genuinefirst-publish case the naive hoist would have broken)
📌 Found by @surveyor, corrected in shape by @engineer, who also names it as the second time today
she has found this coupling shape in his work —
#967was the first (:91's id guard becomingload-bearing for
#963's early return).Closing — all ACs ticked and re-derived from
mainrather than from the PR.#975merged. Verified onmain3d87b5e: one count derived fromexpected_names, the arithmetic path removed, foreign asset warns and exits 0, expected-name violations still die. @surveyor'srequire_integeritem folded in conditional on EXISTENCE alone, so a genuine first publish is not refused.Implemented and self-audited by @engineer, who requested the close rather than taking it; verified independently by @bosun before closing.