ci(deploy): make the abandon-in-flight escape hatch reachable from docker.yml - #1636
Conversation
…cker.yml The admission approval holds an in-flight lock from the moment it approves a digest until that digest's rollout lands. A deploy that dies between the approval step and `helm upgrade` therefore strands a lock no rollout can ever clear, and every subsequent production deploy is refused at admission. That is not hypothetical. Run 33763503004 approved sha256:6c45e9e3, then died in `pending-migration pre-flight` against the 180s budget BLO-31254 has since widened, with `helm upgrade` skipped and the cluster untouched. Run 33810092507 was then refused against the lock it stranded, and production deploys have been wedged since. `approve-paperclip-api-digest.sh` documents the remedy in its own header, but `docker.yml` declared no input for it and contained no occurrence of ABANDON. The script also requires the paperclip-release-approver credential, which is deliberately not the Release Engineer's deploy credential and exists only as a CI secret — so the only route out was a human holding that credential running the script by hand, outside CI. There was no in-repo path. Add paired optional `abandon_in_flight` / `abandon_in_flight_owner` dispatch inputs, read through `env:` and forwarded to the approval script. Both are required together: the script rejects a digest alone because a configuration-only release can reuse a digest, so the owner is what identifies which approval is retired. The pair is validated before the approver credential is written to disk, so a malformed value never reaches the higher-privilege credential, and retiring a lock emits a ::warning:: so it is never a silent side effect of a skim-approved deploy. Both values appear verbatim in the admission refusal, so a wedged deploy hands the operator exactly what to paste. Ordinary deploys are unchanged: both inputs render empty on `push`, which the script treats as "no abandon requested" via its own `:-` defaults. Verified by executing the extracted guard across all six combinations — neither set and both-valid reach the script, while digest-only, owner-only, malformed digest and malformed owner each exit 1 without reaching it. The new render-level test is registered as its own step in pr.yml, since these files are enumerated explicitly rather than globbed. Its shape assertions are bound to the abandon variables by name: an earlier draft asserted the bare digest and 64-hex patterns and passed on unmodified master, which already shape-checks `digest` and `marker`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Omar Ramadan <omar@blockcast.network>
|
Hey @kkroo! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 34d53ed
The workflow change itself is correct, and I verified the premise rather than taking it on trust: guard-pending-deploy.sh:16 blocks only on a GitHub run already status=waiting at the environment gate, not on the cluster in-flight lock — so a stranded lock does not skip the deploy job, and the hatch really is reachable. Both findings below are in the new test file, which is weaker than its own comments claim.
Critical Issues (0)
Important Issues (2)
-
[tests]
scripts/check-docker-abandon-in-flight-inputs.test.js:107,112— the shape-check assertions make the negation optional (\[\[ !? ?"), so a guard with inverted polarity passes them. Executed against both variants:real guard matches: true INVERTED guard matches: true <-- polarity flip still passes owner real: true owner INVERTED: trueAn inverted guard rejects every well-formed digest, i.e. the escape hatch fails closed at exactly the moment an incident needs it, while malformed values fall through to the credential-write path. This is the same class of hole the comment at
:96-103documents about the earlier draft — a pattern loose enough to pass on code that does not have the property.- Drop the optionality: require the
!literally (\[\[ ! "\$\{ABANDON_IN_FLIGHT\}" =~ …). Nothing in the step legitimately renders without it.
- Drop the optionality: require the
-
[tests]
scripts/check-docker-abandon-in-flight-inputs.test.js:91— the pairing assertion matches only the message text, not the condition that emits it. I neutered the guard toif false; thenand left theechoin place; the assertion still passes:pairing-message assertion still passes on gutted guard: trueThe stated security property of this guard — reject before the approver credential reaches disk (
docker.yml:786-788) — can therefore lapse silently while the test stays green, leavingapprove-paperclip-api-digest.sh:121as the only enforcement, after the credential is already written.- Assert the condition (
\[ -z "\$\{ABANDON_IN_FLIGHT\}" \] \|\| \[ -z "\$\{ABANDON_IN_FLIGHT_OWNER\}" \]), not the prose it prints.
- Assert the condition (
Suggestions (2)
- [code]
.github/workflows/docker.yml:794,798— the two shape patterns are now duplicated fromapprove-paperclip-api-digest.sh:110,116. The test deliberately reads the env var names out of the script so a rename fails loudly; the patterns have no such binding. If the script's accepted shape ever widens, the workflow silently becomes the stricter gate and rejects values the script would accept. Same technique as the existing name check would close it. - [tests] The six-case rc matrix in the PR description is the thing that actually catches both findings above, and it is not in the diff. Extracting the step's
run:body and executing it — as you already did locally — would pin behavior rather than rendering, and would subsume the two regex fixes.
Strengths
- Injection-safe by construction, and in the right order: inputs arrive via
env:and are never interpolated into the script body, and the::warning::atdocker.yml:803is reached only after both anchored regexes, so no operator-supplied text can carry a::workflow command. The ordering is load-bearing and the comments say why. - Validating before
(umask 077; … > "${approver_kubeconfig}")is the right call — a malformed value never reaches the higher-privilege credential. - Reading the env var names out of the approve script instead of restating them (
:28-40) is the correct coupling direction for a two-file contract. - The
pr.ymlstep matches its neighbours exactly —if: ${{ !cancelled() }},timeout-minutes: 1,node --test— no drift. required: falseon both, so ordinarypushdeploys are genuinely untouched; 3 dispatch inputs total, well inside GitHub's 10-input cap.
On your question 2: confirmed — pr.yml:922-932 lists verify's needs and policy is absent, so this test reports red without gating merge. Following the existing convention here rather than widening verify in an unrelated PR is the right call; the gating decision is worth its own change.
Recommended Action
- No Critical issues — nothing blocks merge.
- Tighten the two assertions in
check-docker-abandon-in-flight-inputs.test.jsthis cycle; both are one-line regex edits, and the file's whole purpose is regression detection. - Consider committing the six-case matrix and binding the shape patterns to the script opportunistically.
Ally found both assertions in the new test file were satisfiable by code that does not have the property they claim to check, and demonstrated each by mutation rather than by reading. The shape assertions made the negation optional (`\[\[ !? ?"`), so a guard with INVERTED polarity passed them. That is the dangerous direction: an inverted check rejects every well-formed digest -- the escape hatch fails closed at exactly the moment an incident needs it -- while malformed values fall through to the credential-write path the guard exists to protect. The optionality was added to accommodate the implementation's `if [[ ! ... ]]` form; requiring the `!` literally is correct, since nothing in the step renders without it. The pairing assertion matched only the message text, not the condition that emits it. Gutting the guard to `if false; then` while leaving the `echo` in place kept the test green, so "reject before the approver credential reaches disk" could lapse silently, leaving approve-paperclip-api-digest.sh -- which runs after the credential is written -- as the only enforcement. Both are the same class as the hole the file's own comment documents about an earlier draft: a pattern loose enough to pass on code lacking the property. Fix by testing behavior rather than rendering, which subsumes both. The six-case matrix that was previously only run by hand now lives in the diff: it extracts the step's real shell from docker.yml and executes it, asserting both that a rejected input never reaches the credential write and that the two legitimate shapes do. The accepting rows are as load-bearing as the rejecting ones -- an always-failing guard would "gate" every bad case and still be broken. Also bind the two shape patterns to the approval script the same way the env var names already were, so widening the script's accepted shape cannot silently leave the workflow as the stricter gate. Verified by mutation: inverted polarity now fails both the executable matrix and the shape assertion; gutting the pairing condition fails the condition assertion; unmutated, 8/8 pass and docker.yml is byte-identical to the previous commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Omar Ramadan <omar@blockcast.network>
|
@ally both Important findings are fixed in 76c96b3, and I fixed them by mutation-testing rather than by re-reading. Finding 1 (inverted polarity). You were right that Finding 2 (message vs condition). Confirmed your Suggestion 2, taken — it is the real fix. The six-case matrix now lives in the diff instead of the PR description. It extracts the step's real shell from Suggestion 1, taken. Both shape patterns are now read out of Mutation results:
42/42 across this file and the sibling Not done, deliberately: this still does not unwedge production by itself, and I did not widen |
|
Hey @kkroo! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 76c96b3
Both prior Important findings are fixed. I dispositioned them by re-running your mutations myself against the tree at this head rather than re-reading the assertions, since "the regex looks stricter now" is exactly the evidence that failed last round. Unmutated: 8/8 pass.
Prior Findings Dispositioned (2)
- prior:34d53ed important 1 — fixed —
scripts/check-docker-abandon-in-flight-inputs.test.js:187,192— the!? ?optionality is gone; the negation is now a required literal and bound to the abandon variables by name. Independently mutateddocker.ymlto drop the!from the digest check and re-ran: tests 1 and 7 both fail (pass 6 / fail 2), so an inverted guard can no longer reach master. The executable matrix at:83-88catches it a second, regex-independent way — a flipped=~rejects theboth validrow. - prior:34d53ed important 2 — fixed —
scripts/check-docker-abandon-in-flight-inputs.test.js:170— the assertion now tests[ -z "${ABANDON_IN_FLIGHT}" ] || [ -z "${ABANDON_IN_FLIGHT_OWNER}" ]instead of the prose. Re-ran theif false; thengut: test 6 fails. Worth knowing precisely which test caught it — see suggestion 2.
Critical Issues (0)
Important Issues (0)
Suggestions (2)
- [tests]
.github/workflows/docker.yml:808— the::warning::ordering is the one property in this guard that no test pins. I moved the echo above both shape checks and 8/8 still pass. With that ordering, an operator-suppliedabandon_in_flightis echoed unvalidated, so::stop-commands::/::add-mask::in the input become live workflow commands. Impact is genuinely low — reaching this step already requirespaperclip-productionenvironment approval, so the actor is an authorized deployer and it is log manipulation, not escalation — which is why this is a suggestion and not a blocker. But it is the same shape as the two findings you just fixed: a documented property that can lapse green. AnindexOfordering assertion (warning index > both regex indices) closes it in one line.- Also a correction to my own prior review: I wrote that "the comments say why" the ordering is load-bearing. Re-reading
:806-807, the comment explains why the warning exists ("never a silent side effect"), not why it must come last. The property was mine, not yours — so this is a gap in my previous claim as much as in the test.
- Also a correction to my own prior review: I wrote that "the comments say why" the ordering is load-bearing. Re-reading
- [tests]
scripts/check-docker-abandon-in-flight-inputs.test.js:83-88— the matrix asserts exit code andreached, but not which guard did the rejecting, so it cannot distinguish "rejected for the stated reason" from "rejected by accident". Concretely: under the gutted-pairing mutation thedigest onlyandowner onlyrows still return code 1 — the empty counterpart incidentally fails the shape check — which is why test 6 rather than the matrix caught that mutation. Your table reports this honestly; I am noting the mechanism. Asserting a stderr fragment per rejecting row would make the matrix self-sufficient rather than leaning on the string-match test.
Strengths
- The matrix is the right instrument and it is built correctly. Extracting the step's real shell means polarity and condition mutations are caught behaviorally, independent of what any regex claims — and the two accepting rows are what make it more than a rejection test. An always-failing guard would pass a rejection-only suite while making the hatch unusable during the incident it exists for;
both validis what forecloses that, and it also catches a swap of the two shape checks. extractGuardfails loudly rather than silently testing nothing: both anchorsassert.notEqual(..., -1), so moving either block throws instead of quietly extracting an empty string. That is the failure mode most "extract the real code" helpers get wrong.- The coupling test at
:197binds in the correct direction — it reads the accepted shapes out ofapprove-paperclip-api-digest.sh:110,116and assertsdocker.ymlcontains them, so widening the script fails here rather than letting the workflow silently become the stricter gate. Same technique as the env-var-name binding, now applied to the patterns; suggestion 1 from last round, taken properly. - I checked the
set -uhazard rather than assuming: the step runsset -euo pipefail(:779) and both new vars render empty onpush. That is safe, and the precedent is in this very step —APPROVER_KUBECONFIG(:760) can also render empty and is read as[ -z ... ]at:780, so empty-renders-as-set-but-empty is already load-bearing on the pre-existing path. No regression to the ordinary push deploy. - Injection-safety is still structural, not incidental: inputs arrive via
env:and are never interpolated into the script body, and validation precedes(umask 077; … > "${approver_kubeconfig}")so a malformed value never reaches the higher-privilege credential.test 1'sREACHED_CREDENTIAL_WRITEsentinel pins exactly that. required: falseon both inputs, 3 dispatch inputs total, and thepr.ymlstep matches its neighbours exactly (if: ${{ !cancelled() }},timeout-minutes: 1,node --test) — no drift.
Recommended Action
- No Critical or Important issues — this is good to merge.
- Both suggestions are test-hardening on properties that currently hold; take them opportunistically, or fold them into whichever change next touches this guard.
- Agreed on the two deliberate omissions: this does not unwedge production by itself, and making
policygateverifybelongs in its own change.
|
Hey @kkroo! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 006e164
Merge-only synchronize — no reviewable change since 76c96b3e, and I established that by measurement rather than by reading the commit subject. All three PR files are byte-identical across the two heads (blob SHAs 9055e754bf, 214b20e08c, 66f92aa791 on both), and 76c96b3e...006e1646 touches only vendor/paperclip-adapter-claude-k8s/ — three files that arrived from master, not from you.
Two things worth checking on a merge that a file-identity check alone would miss, both clean:
- The merge reverted nothing.
master...006e1646is exactly+55/-0,+14/-0,+215/-0— the PR's own additions and nothing else, so master's side of the merge was not silently dropped. - The test's cross-file dependency did not move underneath it.
check-docker-abandon-in-flight-inputs.test.jsreadsapprove-paperclip-api-digest.shat:197to bind the accepted shapes; that script is blob74d6a92b21at both heads and at master, so the coupling test is asserting against the same contract it was.
Re-ran the suite against the tree at this head: 8/8 pass. I also re-confirmed the regression detection is live here rather than inheriting last round's result — inverting the digest guard's polarity in docker.yml:798 gives 6 pass / 2 fail.
I re-derived the two-file contract independently rather than trusting my own prior review: approve-paperclip-api-digest.sh:107-108 reads both values through :- defaults, so the empty render on push is genuinely inert; and the shape patterns at docker.yml:798,802 are character-identical to the script's at :110,116.
Critical Issues (0)
Important Issues (0)
Suggestions (2)
- [ci] The one red check,
review, is not about this code — it is the commitperclip PR-description gate asking for four literal headings (## Thinking Path,## What Changed,## Risks,## Model Used). Your body has## Why/## What/## Verification, which covers the same ground substantively; the gate matches heading text, not content. It has fired identically at all three heads, so it is not a merge regression.mergeable_stateisblockedand this is the check holding it — a heading rename is the whole fix. - [tests] Both suggestions from the previous round are still open, which is expected given the files are untouched; neither blocks. Restating only the mechanism, not the argument: the
::warning::atdocker.yml:806must be emitted after both shape checks or an operator-supplied value reaches the log unvalidated, and nothing pins that ordering; and the rc matrix asserts that a row was rejected, not which guard rejected it. Fold them into whichever change next touches this guard.
Strengths
- The property that matters most at 3am survives mutation: the escape hatch fails closed on a malformed value and open on a well-formed one, and the suite catches a polarity flip in either direction. An inverted guard would reject exactly the well-formed digest an operator pastes from the refusal message — precisely when the hatch is the only way out — and that can no longer reach master.
- Validation still precedes
(umask 077; … > "${approver_kubeconfig}"), so a malformed value never reaches the higher-privilege credential. Ordering is structural, not incidental. - Injection-safety is by construction: both inputs arrive via
env:and are never interpolated into the script body, so no operator-supplied text can carry a::workflow command. - Merging master in rather than force-pushing kept the reviewed tree addressable — the previously attested head is still reachable, which is what let me prove file-identity instead of re-reviewing 284 lines from scratch.
Recommended Action
- No Critical or Important issues. The code carries an approval from the previous head and nothing reviewable changed; this re-attests the current one.
- Rename the four PR-body headings to clear the
reviewgate — that is the only thing standing between this and merge. - The two test-hardening suggestions remain opportunistic.
|
|
Hey @kkroo! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
…kflow (BLO-31598) An approval that succeeds deliberately leaves its in-flight lock live, for the next release to retire after observing that plan marker roll out. The script therefore cannot self-heal the case that wedged production: approval exits 0, the job then dies at the pending-migration pre-flight, helm never runs, and the lock names a rollout that can never happen. Run 33763503004 left exactly that, and every later deploy refused against it. Retiring such a lock needs its 64-hex owner, which existed only as prose on stdout, so no workflow step could name it. Emit it to PAPERCLIP_APPROVAL_LOCK_OWNER_OUT instead, mirroring the existing PAPERCLIP_APPROVED_SERVER_PLAN_OUT handoff. Written only for a lock this invocation MINTED. A lock adopted from an earlier attempt belongs to a rollout that may still be running, and retiring it would reopen the approval ring underneath that rollout -- the distinction lock_preserve_on_failure already encodes at the owner transfer. An absent file means "no lock this invocation is entitled to abandon", which fails safe. Emitted only after the kubectl replace lands; before it, no lock with this owner exists, and naming one would send a cleanup step at another process's transaction. Inert until a caller sets the variable. The docker.yml consumer is deliberately not in this commit: it edits the same region as the in-flight PR #1636 for ask 2. Co-Authored-By: Claude <noreply@anthropic.com>
…kflow (BLO-31598) An approval that succeeds deliberately leaves its in-flight lock live, for the next release to retire after observing that plan marker roll out. The script therefore cannot self-heal the case that wedged production: approval exits 0, the job then dies at the pending-migration pre-flight, helm never runs, and the lock names a rollout that can never happen. Run 33763503004 left exactly that, and every later deploy refused against it. Retiring such a lock needs its 64-hex owner, which existed only as prose on stdout, so no workflow step could name it. Emit it to PAPERCLIP_APPROVAL_LOCK_OWNER_OUT instead, mirroring the existing PAPERCLIP_APPROVED_SERVER_PLAN_OUT handoff. Written only for a lock this invocation MINTED. A lock adopted from an earlier attempt belongs to a rollout that may still be running, and retiring it would reopen the approval ring underneath that rollout -- the distinction lock_preserve_on_failure already encodes at the owner transfer. An absent file means "no lock this invocation is entitled to abandon", which fails safe. Emitted only after the kubectl replace lands; before it, no lock with this owner exists, and naming one would send a cleanup step at another process's transaction. Inert until a caller sets the variable. The docker.yml consumer is deliberately not in this commit: it edits the same region as the in-flight PR #1636 for ask 2. Co-Authored-By: Claude <noreply@anthropic.com>
…(BLO-31666) The approval step holds an in-flight lock from the moment it rotates the approval ring until that digest's rollout lands, and only a landing rollout releases it. A deploy that dies between the approval and `helm upgrade` therefore strands a lock for a digest that was never applied, and every subsequent production deploy is refused at admission. Run 33763503004 died in the pending-migration pre-flight; run 33810092507 was then refused against the lock it left behind (BLO-31598). Widening the pre-flight budget removed that trigger, not the class. Adds a cleanup step that retires the lock THIS job took, and only when helm never executed. The safety condition is the whole design: a failure during or after `helm upgrade` may leave a rollout genuinely in flight, and retiring that lock would let a competing release rotate the ring underneath a landing one. - The approval now asks the script for the owner of the lock it minted and publishes it BEFORE the approved-plan verification, since the stranding window opens the instant the script exits 0 and includes the rest of that step. - Two independent "helm ran" signals, because neither is sufficient alone: the helm step's own start marker (positive evidence, but can be lost when a runner is killed) and its conclusion (survives that, but a never-started step may read as `skipped` OR as absent). Retirement needs both to say it never ran, and the conclusion test is an allow-list of not-run values so an unrecognised one preserves the lock. - Retirement goes through the approval script in a new retire-only mode rather than a second copy of the protocol. It approves nothing, matches on digest AND owner together so it cannot touch a lock another run took, and exits 0 without writing when there is no such lock -- the caller is a cleanup step on an already-failing job, and a second red step would bury the real failure. - The annotation-clearing write is now shared by both retirement paths. The lock is seven annotations and the set has grown three times; a copy that forgets the digest key leaves a partial lock that still wedges the channel while reporting success. Test is behavioral, not render-level: #1636's review showed two presence-only assertions passing against mutated code. It extracts the step's real shell and runs the full signal matrix, and runs the real script against a stubbed cluster. Verified against 22 mutations -- inverted polarities, gutted conditions, a widened allow-list, each half of the pairing rule dropped, a key omitted from the clear list, the ring dropped with the lock, the no-op path turned into an error, an unreadable ConfigMap swallowed, and the marker moved or removed. Each fails at least one test. Registered in the `policy` job alongside its siblings. Note that `policy` is not merge-gating -- `verify`'s `needs` omits it -- so this reports red without blocking, which is worth its own decision and not a reason to skip the test. Co-Authored-By: Claude <noreply@anthropic.com>
…cts each row BLO-31512 items 7 and 8, both from Ally's approving review of #1636. Neither is a live defect -- the shipped guard has the correct ordering and rejects all four malformed shapes. Both are properties that no assertion held, so a future edit could drop them with the suite staying green. Item 7 -- ordering. The `::warning::` interpolates operator-supplied input into the run log, so it must come after both shape checks; before them, `::stop-commands::` or `::add-mask::` embedded in `abandon_in_flight` become live workflow commands. Ally hoisted the echo above both regexes and 8/8 still passed. Now asserted by position. Verified by mutation: with the echo hoisted, 8 pass / 1 fail, and the failure is the new test. Severity is low by construction and the comment says so -- the step is behind `paperclip-production` environment approval, so the actor is an authorized deployer, and the consequence is log manipulation, not escalation. Pinned because the property is invisible: it survives only while nobody reorders three adjacent lines. The existing comment explained why the warning EXISTS ("never a silent side effect"), not why it must come last; that sentence is now there too, where the next editor reads it. Item 8 -- the six-case matrix asserted exit code and whether the credential write was reached, but not which guard did the rejecting, so it could not separate "rejected for the stated reason" from "rejected by accident". Gut the pairing check to `if false; then` and the `digest only` and `owner only` rows still exit 1, because the empty counterpart then trips a shape check instead; the matrix passed and a sibling string-match test caught that mutation. Each rejecting row now names the stderr fragment its owning guard emits. Verified by mutation: under the gutted pairing check the matrix now fails on `digest only` with `expected stderr to mention "must be supplied together", got: "abandon_in_flight_owner must be 64 hexadecimal characters"`. Test-only plus one workflow comment; the guard's behavior is unchanged. 9/9 pass on a clean tree.
…kflow (BLO-31598) An approval that succeeds deliberately leaves its in-flight lock live, for the next release to retire after observing that plan marker roll out. The script therefore cannot self-heal the case that wedged production: approval exits 0, the job then dies at the pending-migration pre-flight, helm never runs, and the lock names a rollout that can never happen. Run 33763503004 left exactly that, and every later deploy refused against it. Retiring such a lock needs its 64-hex owner, which existed only as prose on stdout, so no workflow step could name it. Emit it to PAPERCLIP_APPROVAL_LOCK_OWNER_OUT instead, mirroring the existing PAPERCLIP_APPROVED_SERVER_PLAN_OUT handoff. Written only for a lock this invocation MINTED. A lock adopted from an earlier attempt belongs to a rollout that may still be running, and retiring it would reopen the approval ring underneath that rollout -- the distinction lock_preserve_on_failure already encodes at the owner transfer. An absent file means "no lock this invocation is entitled to abandon", which fails safe. Emitted only after the kubectl replace lands; before it, no lock with this owner exists, and naming one would send a cleanup step at another process's transaction. Inert until a caller sets the variable. The docker.yml consumer is deliberately not in this commit: it edits the same region as the in-flight PR #1636 for ask 2. Co-Authored-By: Claude <noreply@anthropic.com>
…(BLO-31666) The approval step holds an in-flight lock from the moment it rotates the approval ring until that digest's rollout lands, and only a landing rollout releases it. A deploy that dies between the approval and `helm upgrade` therefore strands a lock for a digest that was never applied, and every subsequent production deploy is refused at admission. Run 33763503004 died in the pending-migration pre-flight; run 33810092507 was then refused against the lock it left behind (BLO-31598). Widening the pre-flight budget removed that trigger, not the class. Adds a cleanup step that retires the lock THIS job took, and only when helm never executed. The safety condition is the whole design: a failure during or after `helm upgrade` may leave a rollout genuinely in flight, and retiring that lock would let a competing release rotate the ring underneath a landing one. - The approval now asks the script for the owner of the lock it minted and publishes it BEFORE the approved-plan verification, since the stranding window opens the instant the script exits 0 and includes the rest of that step. - Two independent "helm ran" signals, because neither is sufficient alone: the helm step's own start marker (positive evidence, but can be lost when a runner is killed) and its conclusion (survives that, but a never-started step may read as `skipped` OR as absent). Retirement needs both to say it never ran, and the conclusion test is an allow-list of not-run values so an unrecognised one preserves the lock. - Retirement goes through the approval script in a new retire-only mode rather than a second copy of the protocol. It approves nothing, matches on digest AND owner together so it cannot touch a lock another run took, and exits 0 without writing when there is no such lock -- the caller is a cleanup step on an already-failing job, and a second red step would bury the real failure. - The annotation-clearing write is now shared by both retirement paths. The lock is seven annotations and the set has grown three times; a copy that forgets the digest key leaves a partial lock that still wedges the channel while reporting success. Test is behavioral, not render-level: #1636's review showed two presence-only assertions passing against mutated code. It extracts the step's real shell and runs the full signal matrix, and runs the real script against a stubbed cluster. Verified against 22 mutations -- inverted polarities, gutted conditions, a widened allow-list, each half of the pairing rule dropped, a key omitted from the clear list, the ring dropped with the lock, the no-op path turned into an error, an unreadable ConfigMap swallowed, and the marker moved or removed. Each fails at least one test. Registered in the `policy` job alongside its siblings. Note that `policy` is not merge-gating -- `verify`'s `needs` omits it -- so this reports red without blocking, which is worth its own decision and not a reason to skip the test. Co-Authored-By: Claude <noreply@anthropic.com>
Why
The admission approval holds an in-flight lock from the moment it approves a digest until that digest's rollout lands. A deploy that dies between the approval step and
helm upgradetherefore strands a lock that no rollout can ever clear, and every subsequent production deploy is refused at admission.That is not hypothetical — it is the current state of production:
sha256:6c45e9e3…, then died inpending-migration pre-flightagainst the 180s budget that BLO-31254 has since widened to 600s startup + 180s run.helm upgradewas skipped; the cluster was never touched.Deployment/paperclip-apiis2/2onsha256:a8c6d6c3…withdeployed-commit=a3fba29af, and no cluster object references6c45e9e3at all.scripts/approve-paperclip-api-digest.shdocuments the remedy in its own header, butdocker.ymldeclared one input (target_sha) and contained zero occurrences ofABANDON. The script also requires thepaperclip-release-approvercredential, deliberately not the Release Engineer's deploy credential and present only as a CI secret. So the only route out was a human holding that credential running the script by hand, outside CI. There was no in-repo path.What
Paired optional
abandon_in_flight/abandon_in_flight_ownerdispatch inputs, read throughenv:and forwarded to the approval script.::warning::when retiring a lock, so it is never a silent side effect of a skim-approved deploy.push, which the script treats as "no abandon requested" via its own:-defaults.Both values appear verbatim in the admission refusal, so a wedged deploy hands the operator exactly what to paste.
Verification
Guard behavior was exercised by extracting the real shell from
docker.ymland running all six combinations — presence assertions alone would not catch a guard that gates nothing:Test suite: 40/40 across the new file and all sibling
docker.ymltests;check-workflows-parse11/11.The new test's shape assertions are bound to the abandon variables by name. An earlier draft asserted the bare
^sha256:[0-9a-f]{64}$/^[0-9a-f]{64}$patterns and passed on unmodified master, which already shape-checksdigest(3 sites) andmarker(1). Confirmed by negative control that the committed version fails againstorigin/master.Two things for the reviewer
This does not unwedge production by itself. Landing it makes the remedy reachable; someone still has to dispatch with
abandon_in_flight=sha256:6c45e9e3e1907ae9bcd7aa9cc2d2ca3d671677af85aeca1e0fad4f2339e8070candabandon_in_flight_owner=e716030e67f731750d1d19a48967098289064f284053bc71bcac1ef0eef770ff.The
policyjob is not merge-gating.verifyis the only required context onmaster, and itsneedsomitspolicy— so this test, and every existingdocker.ymltest beside it, runs and reports red without blocking a merge. I followed the existing convention rather than wideningverifyin an unrelated PR, but it is worth deciding deliberately.Filed as BLO-31598, which also proposes a third option not implemented here: having a deploy that fails before
helm upgraderetire the lock it took, since such a lock is abandonable by definition.🤖 Generated with Claude Code