Skip to content

Release workflow: republish recovery path + RC/loop-guard robustness — PR #177 Blocker 3 - #181

Closed
jgruberf5 wants to merge 11 commits into
stagingfrom
fix/release-republish-and-robustness
Closed

Release workflow: republish recovery path + RC/loop-guard robustness — PR #177 Blocker 3#181
jgruberf5 wants to merge 11 commits into
stagingfrom
fix/release-republish-and-robustness

Conversation

@jgruberf5

Copy link
Copy Markdown
Collaborator

Addresses the workflow side of Blocker 3 from @bonnyr-f5's #177 review. (The main-branch lock_branch itself is a repo-config change handled separately.)

Republish recovery (the main ask)

If publishing failed after release-final succeeded (bake error, ghcr 5xx, Sigstore outage, the 90-min timeout), the state was tag + GitHub Release + no images, with no way to recover — re-running on main hits the release: loop guard, and workflow_dispatch bumps to the next version rather than republishing the existing one.

Added a publish_only: <tag> dispatch input: skips derivation/bump/CI-poll/tag-creation, asserts the tag exists, and runs release-publish against it. Purely additive — the rc/final/manual paths are unchanged (only a new kind=publish_only branch and an || kind == 'publish_only' on the publish job).

Robustness nits (same review)

  • RC numbering is now max-based, not count-based. wc -l breaks the moment any rc tag is deleted — the count drops and the next staging push recomputes an existing tag, which fails to create. Now: highest existing rc number + 1.
  • Loop guard emits ::warning:: when it suppresses a release, naming the commit. Before, a normal commit beginning release: or ending [skip ci] silently skipped publication while the job reported success. (Its grep is now a pipe-free here-string too.)

Verified

release.yml YAML validates. The change is additive; existing release kinds keep their exact conditions.

https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

…kflow

PR #177 review (bonnyr-f5) — BLOCKER 3 (workflow side; the main-branch lock
itself is a repo-config change handled separately).

Republish recovery: if publishing failed after release-final succeeded (bake
error, ghcr 5xx, Sigstore outage, the 90-min timeout), the state was tag +
GitHub Release + no images, with no recovery — re-running on main hits the
`release:` loop guard, and workflow_dispatch bumps to the NEXT version instead
of republishing the existing one. Added a `publish_only: <tag>` dispatch input:
it skips derivation/bump/CI-poll/tag-creation, asserts the tag EXISTS, and runs
release-publish against it. Purely additive — normal rc/final/manual paths are
unchanged (new branch/conditions only).

Also from the review:
- RC numbering is now max-based, not count-based (release.yml). Counting via
  `wc -l` breaks the moment any rc tag is deleted: the count drops and the next
  staging push recomputes an existing tag, which then fails to create. Now takes
  the highest existing rc number + 1.
- The loop guard emits a `::warning::` when it suppresses a release, naming the
  offending commit. Before, a normal commit beginning `release: ` or ending
  `[skip ci]` silently skipped publication while the job reported success.

YAML validates; the guard's grep is also converted to a pipe-free here-string.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

@mwiget mwiget left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recovery path is well built and I checked the parts that could have quietly been wrong -- all four came out right. One thing needs fixing before this lands, and it is in the other half of the PR: the new loop-guard warning fires on the happy path and gives misleading advice there.

Verified correct

  • publish_only republishes the tagged tree, not current HEAD. release-publish already checks out ref: v${{ needs.preflight.outputs.new_version }} with fetch-depth: 0, so a recovery run rebuilds the exact commit the tag points at. This was the thing most likely to be subtly wrong in a "republish" path and it isn't.
  • The loop guard doesn't eat the recovery run. HEAD_COMMIT_MSG comes from github.event.head_commit.message, which is null on workflow_dispatch -> MSG="" -> no match -> should_run=true. I ran the guard body with an empty message to confirm. Worth stating explicitly, because the scenario publish_only exists for is precisely "the release: vX [skip ci] commit is HEAD of main", and if the guard had keyed off git log instead of the event payload the feature would have been dead on arrival.
  • Job wiring is clean. Every other job is keyed on an exact release_kind, so publish_only runs guard -> preflight -> release-publish and nothing else: e2e-gate wants manual, release-rc wants rc, release-final wants final. No always() job picks it up by accident.
  • Max-based RC numbering is right and safe. grep -E '^[0-9]+$' returning 1 when there are no rc tags does not sink the pipeline: this step has no set -o pipefail (GitHub's default shell is bash -e {0}), so the substitution takes tail's status and ${RC_MAX:-0} yields 1 as intended. Numeric sort -n also gets rc.10 right, which the old wc -l did too but only by accident.

Blocking

The new ::warning:: fires on every successful release, and what it says there is wrong. release-final commits release: v${NEW} [skip ci] and pushes to main; that push touches VERSION, so it is not filtered by paths-ignore and it re-triggers Release; the guard matches and now annotates the run with "nothing was published -- rename the commit or re-run the workflow." On the happy path the release was published, by the previous run, and neither piece of advice applies. Every release will carry a yellow annotation telling the operator to do something harmful, which is a reliable way to teach people to ignore the annotation that matters. Details and a suggested split inline.

Same line also emits '\'' verbatim -- that idiom escapes a quote inside a single-quoted string; inside double quotes it is just four literal characters.

Notes, non-blocking

  • release_notes is required: true and version_bump has a default, so a publish_only recovery still makes the operator fill in two fields that are then ignored. GitHub has no conditional-required inputs, so this is probably just worth a word in the publish_only description.
  • No CI has run on this PR at all (zero workflow runs against 4e3ae14b, ~5 min after opening). Since only .github/workflows/release.yml changed and that path isn't in ci.yml's paths-ignore, I'd expect a run. I'll re-check; if CI genuinely never reports here, the required-check config is worth a look separately.

Re-review as soon as the guard message is sorted -- the publish_only half I'm happy with as it stands.

Comment thread .github/workflows/release.yml Outdated
if echo "$MSG" | grep -qE '^release: |^\[skip ci\]|\[skip ci\]$'; then
echo "Skipping: head commit is a release commit or has [skip ci]"
if grep -qE '^release: |^\[skip ci\]|\[skip ci\]$' <<< "$MSG"; then
echo "::warning::Release suppressed by the loop guard: head commit '\''${MSG%%$'\n'*}'\'' begins with 'release: ' or carries [skip ci]. If this was a normal change (not an automated release commit), nothing was published -- rename the commit or re-run the workflow."

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two problems on this line, one of them behavioural.

1. It fires on the happy path with advice that is wrong there. release-final does git commit -m "release: v${NEW} [skip ci]" and pushes to main. That commit touches VERSION, which is not in this workflow's paths-ignore, so the push re-triggers Release, the guard matches, and the run is annotated:

Release suppressed by the loop guard: head commit ... nothing was published -- rename the commit or re-run the workflow.

But on that path everything was published, by the run that created the commit. So every single successful release ends with a yellow annotation telling the operator to rename a commit or re-run a workflow, both of which would be actively wrong to do. The signal you actually want -- "a hand-written commit accidentally tripped the guard" -- is then buried in a stream of identical false ones.

The two cases are cheap to tell apart, since our own commits have a fixed shape:

          MSG="$HEAD_COMMIT_MSG"
          if grep -qE '^release: |^\[skip ci\]|\[skip ci\]$' <<< "$MSG"; then
            FIRST="${MSG%%$'\n'*}"
            if grep -qE '^release: v[0-9]+\.[0-9]+\.[0-9]+' <<< "$FIRST"; then
              echo "::notice::Loop guard: skipping our own release commit \"$FIRST\" (expected)."
            else
              echo "::warning::Release suppressed by the loop guard: head commit \"$FIRST\" begins with 'release: ' or carries [skip ci], so nothing was published. If this was a normal change, rename the commit or dispatch the workflow manually."
            fi
            echo "should_run=false" >> "$GITHUB_OUTPUT"

2. '\'' is the wrong escape here. That idiom ends a single-quoted string, emits an escaped quote and reopens -- it only means anything inside single quotes. This string is double-quoted, so bash passes all four characters through. Run as written:

::warning::Release suppressed by the loop guard: head commit '\''release: v4.0.0 [skip ci]'\'' begins with ...

\" (or nothing) is what you want. The ${MSG%%$'\n'*} first-line trim itself is fine -- I checked it against a multi-line release commit and it strips correctly.

description: "Release notes (one-line summary)"
required: true
type: string
publish_only:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: release_notes is required: true and version_bump carries a default, so a recovery dispatch still forces the operator to fill in two fields this path ignores. GitHub has no conditional-required inputs, so the fix is probably just a sentence here -- e.g. "... Leave empty for a normal release. When set, version_bump, run_e2e and release_notes are ignored." -- so nobody agonises over what release notes to type while chasing a failed publish.

echo "bump_type=$BUMP" >> "$GITHUB_OUTPUT"
echo "Version: $CURRENT -> $NEW (bump: $BUMP)"

- name: Check republish tag exists

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good that this asserts existence rather than trusting the input, and preflight checks out with fetch-depth: 0, so git tag -l actually sees the tags.

Worth knowing (no change needed, it works either way): TAG here is rebuilt as v${{ steps.version.outputs.new }} where new is ${TAG#v}, so both v4.0.0 and 4.0.0 normalise correctly, and anything that isn't a tag -- a branch name, a typo -- lands in the error branch rather than silently republishing something else.

# count-based (#177 review): counting breaks if any rc tag is ever
# deleted -- the count drops and the next push recomputes an existing
# tag, which then fails to create.
RC_MAX=$(git tag -l "v${TARGET}-rc.*" \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct fix, and it survives the grep returning 1 on a version that has no rc tags yet: this step doesn't set -o pipefail (GitHub's default is bash -e {0}), so the substitution takes tail's status and ${RC_MAX:-0} gives 1. Worth pinning that in the comment -- if anyone later adds set -euo pipefail to this step for tidiness, the first RC of every version starts failing here, which is an unpleasant thing to debug.

sort -n also orders rc.10 after rc.9 properly.

@jgruberf5 jgruberf5 mentioned this pull request Aug 20, 2026
…e escaping

mwiget's re-review of #181. The new loop-guard warning fired on every
successful release: release-final pushes `release: vX [skip ci]` to main, that
push touches VERSION (not in paths-ignore) so it re-triggers Release, the guard
matches, and the run was annotated "nothing was published -- rename the commit
or re-run the workflow." But it WAS published, by the run that made the commit,
so both pieces of advice are wrong there -- and a yellow annotation on every
release trains operators to ignore the one that matters.

Now: our own `release: vX.Y.Z` commit gets a ::notice:: (expected, already
published); only a hand-written commit that trips the guard gets the ::warning::.
Also fixed the `'\''` escaping -- that idiom only means something inside single
quotes; this string is double-quoted, so it printed the four characters
literally. Uses `\"` now.

Non-blocking, also addressed:
- publish_only description states version_bump / run_e2e / release_notes are
  ignored on a recovery dispatch.
- Pinned in a comment that the RC-number step must not gain `set -o pipefail`
  (grep returns 1 with no rc tags yet; without pipefail ${RC_MAX:-0} yields 1).

Guard split verified: release commit -> notice, hand-written [skip ci] ->
warning, normal commit -> should_run=true.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

Fixed. Our own release: vX.Y.Z commit now gets a ::notice:: ("expected — published by the previous run"); only a hand-written commit that trips the guard gets the ::warning::. And you're right about '\'' — it's inert inside double quotes; switched to \". Verified the three cases: release commit → notice, hand-written [skip ci] → warning, normal → should_run=true.

Non-blocking: the publish_only description now says version_bump / run_e2e / release_notes are ignored on a recovery dispatch, and I pinned in a comment that the RC-number step must not gain set -o pipefail (or rc.1 of every version breaks).

@mwiget mwiget left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. All three points addressed, and I ran the new guard logic through the cases that matter:

head commit result
release: v4.0.0 [skip ci] (+ body) ::notice:: -- expected, published by the previous run
release: prep notes for the 4.0 launch ::warning:: -- nothing published, rename or dispatch
docs: tweak readme [skip ci] ::warning::
"" (workflow_dispatch) should_run=true
fix: something should_run=true

That's exactly the split I was after: the happy path no longer tells an operator to undo a release that worked, and the case worth noticing still shouts. The "$FIRST" quoting renders correctly now too -- no more literal '\'' -- and the first-line trim still works on a multi-line message.

The pipefail comment on the RC step is the right kind of note to leave: it records why the absence of a setting is load-bearing, which is the sort of thing someone tidies away otherwise.

Standing observation, not a blocker and not yours to fix: this PR still has zero workflow runs -- no CI, no Integration Branch Rebuild, nothing, across both pushes. A PR touching only .github/workflows/release.yml appears to trigger nothing, while #180 and #182 (which also touch workflows, alongside other paths) run the full suite. If CI Gate is a required check, that means release-workflow-only changes can't satisfy branch protection -- worth a maintainer looking at separately from this PR.

Everything I checked on the substance holds up: the republish path builds from the tagged tree, the guard doesn't eat a dispatch, and no other job picks up publish_only by accident.

@jgruberf5

Copy link
Copy Markdown
Collaborator Author

Non-blocking follow-up on your CI observation: I confirmed it — this branch still reports no CI checks at all, even though ci.yml's changes filter includes .github/workflows/** and release.yml isn't in paths-ignore, so the on: pull_request trigger looks like it should fire. I didn't chase it with a speculative change here, since guessing at the workflow-trigger config risks breaking CI for every PR and you flagged it as a separate look — but it's real: release-workflow-only PRs currently merge without CI. Worth a dedicated fix (likely making .github/workflows/** changes reliably trigger the run). I validated this PR's release.yml by YAML-linting it and exercising the guard logic locally instead.

@bonnyr-f5

Copy link
Copy Markdown
Collaborator

Review: BLOCK

Reviewed under the review-discipline pipeline (invariant sweep + context-isolated cold audit at a7762b6, base 4a52ed4). The RC-numbering fix is correct and well-reasoned. The new publish_only input has two blockers.

Blocker 1 — inputs.publish_only is interpolated raw into two run: blocks

.github/workflows/release.yml:117 and :205:

if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.publish_only }}" ]; then   # :117
  TAG="${{ inputs.publish_only }}"                                                                     # :205

Actions substitutes the expression textually before bash parses the script. With publish_only = v4.0.0"; echo PWNED; id -un; echo ", the rendered script executes the injected commands and the step still exits 0. $(...) also fires without breaking quotes: v4.0.0$(whoami)NEW=4.0.0<user>.

Two aggravators, both confirmed by parsing the file:

  • There is no workflow-level permissions: block and none on preflight (guard, preflight, e2e-gate all parse to permissions: None), so the job runs with whatever the repo default token scope is.
  • preflight's actions/checkout@v6 uses the default persist-credentials: true, so that token is in .git/config on the runner.

The correct pattern is already in this same file, eleven lines up — the guard step passes the untrusted commit message via env: HEAD_COMMIT_MSG and never interpolates it (:77-80). Please do the same here, and add a shape check (^v[0-9]+\.[0-9]+\.[0-9]+$).

Blocker 2 — republish unconditionally repoints :latest on all 7 public images, with no tag-shape or recency guard

Check republish tag exists (:232-240) asserts only that the tag exists. It does not require a final vX.Y.Z, nor the newest one. release-publish then keeps ROLLING_TAG: latest (:711), and docker-bake.hcl:50-106 appends ${REGISTRY}/<img>:${ROLLING_TAG} to all seven images.

Replaying the exact Derive versionCheck republish tag exists chain against a repo with v3.1.6 v4.0.0 v4.0.0-rc.1 v4.1.0-rc.3:

input=v4.0.0-rc.1  ACCEPTED -> checks out v4.0.0-rc.1, pushes :4.0.0-rc.1 AND :latest
input=v3.1.6       ACCEPTED -> checks out v3.1.6,      pushes :3.1.6      AND :latest
input=v4.1.0-rc.3  ACCEPTED -> checks out v4.1.0-rc.3, pushes :4.1.0-rc.3 AND :latest
input=v9.9.9       REJECTED (tag missing)

RC tags live in the same git tag namespace and are created by this workflow's own release-rc job on every staging push, so pasting one is an ordinary typo rather than abuse. Result: :latest for seven public images silently becomes a pre-release, or regresses to a superseded version — and the publish summary prints , :latest as if normal. Suggest suppressing ROLLING_TAG when release_kind == 'publish_only'.

Secondary, same step: republish rebuilds from source rather than re-pushing the original digests, so the immutable-looking :X.Y.Z tag gets a new digest and the previously recorded cosign attestations no longer describe what :X.Y.Z resolves to. Worth at least a warning.

Major — the publish_only arm of release-publish gates on nothing

Full enumeration of what runs at kind == publish_only: guard runs (dispatch has no head_commitMSG=""should_run=true), preflight runs with the CI-status and tag-collision checks skipped, e2e-gate / release-rc / release-final / release-manual all skip, and release-publish runs.

No output of a skipped job is consumed — every needs.* reference in that job body is needs.preflight.* — so that part is sound. The problem is asymmetry: release-manual gates on needs.preflight.result == 'success', and the final/manual arm of release-publish gates on needs.release-final.result == 'success', but the publish_only arm gates on the output string alone, under always(). If preflight fails at Check republish tag exists after steps.kind has written its output, release-publish is still selected. Today it happens to fail closed only because ref: v<missing> makes the checkout fail — a coincidence, not a gate. Please add && needs.preflight.result == 'success'.

Major — recovery re-runs the tag's publish scripts, so it can't recover from a defect in the publish tooling

release-publish does actions/checkout@v6 with: ref: v${{ needs.preflight.outputs.new_version }}. On the publish_only path that is the existing tag, so scripts/publish-signed-images.sh, docker-bake.hcl and the chart are the versions frozen at that tag. #183's corrections to publish-signed-images.sh therefore never run on the recovery path. The impact is limited here because those edits are comment/echo text only, but the PR's stated purpose is republish recovery, and a reader would assume otherwise. Worth documenting.

Minor

  • Leading-zero rc tags break the arithmetic. RC_NUM=$(( ${RC_MAX:-0} + 1 )) feeds a string into bash arithmetic, where 0-prefixed literals are octal. With v4.0.0-rc.08 present: 08: value too great for base → step fails, RC release blocked. With v5.0.0-rc.010 and v5.0.0-rc.9: octal 010 = 8, +1 = 9 → recomputes the existing rc.9 and the later git tag push fails. Requires a hand-created tag, so minor — but 10#${RC_MAX:-0} costs nothing.
  • The pipefail dependency is documented but not enforced. Your comment is correct on both counts — I confirmed there is no defaults: and no per-step shell: anywhere in the file, and Actions' default run: shell on Linux is bash -e {0} while an explicit shell: bash is bash --noprofile --norc -eo pipefail {0}. So pipefail is off today, and the step yields rc.1 correctly for a new version; with pipefail forced on, the same pipeline exits 1 with no output. But nothing pins it: adding defaults: {run: {shell: bash}} — a routine hardening change — breaks rc.1 of every new version. A trailing || true or an in-step set +o pipefail would make it self-sufficient.
  • The loop-guard warning text is wrong when the match is on a body line. grep -qE '^release: |…' is line-oriented and matches any line, while FIRST is only the first. For a message feat: add thing\n…\nrelease: v1.0.0 mentioned in the body, the warning says head commit "feat: add thing" "begins with 'release: ' or carries [skip ci]" — it does neither — and tells the operator to rename the commit. The suppression is pre-existing; this PR is what turns it into an actively misleading instruction. Both intended branches are correct.

Nits

  • :223eval "$BUMP_OUTPUT" evals script output containing SINCE_TAG=<tag name>. Safe today only because last_final_tag filters through ^v[0-9]+\.[0-9]+\.[0-9]+$; a read-based parse would be sturdier. Pre-existing.
  • :293 — the . in ${TARGET} is unescaped in the sed -E pattern. Harmless because the upstream git tag -l "v${TARGET}-rc.*" glob has already anchored the set (verified v1.2.30-rc.7 does not leak into TARGET=1.2.3).
  • version_bump, run_e2e and release_notes remain required: true, so a recovery dispatch demands three inputs the run ignores — as the new input's own description admits.
  • New shell in this PR (~40 lines across three run: blocks) is linted by nothing. make shellcheck covers only upgrade.sh scripts/*.sh vm-bnk-forge/*.sh vm-bnk-forge/lib/*.sh, and there is no actionlint in CI. Worth raising on Wire gitleaks / shellcheck / script self-test CI gates — PR #177 Major #182.

Verified correct

The RC change is right and the reasoning behind it checks out. Count-based numbering does break on a deleted tag — old: count=9 → v2.0.0-rc.10, which then fails with fatal: tag 'v2.0.0-rc.10' already exists; new: RC_MAX=10 → rc.11. Max-based handles rc.9 → rc.10 numerically, and non-numeric tags like v1.2.3-rc.foo are correctly filtered out (→ rc.1; junk + rc.2rc.3). set -e does propagate a nonzero exit from V=$(cmd), and if ! pipeline; then correctly does not trip it. Every checkout uses fetch-depth: 0, so git tag -l can see tags. The workflow parses as valid YAML (7 jobs). The loop guard correctly lets workflow_dispatch through and correctly identifies this workflow's own release commit via the notice branch.

All Actions-side behaviour (expression evaluation, always(), job skipping) was reasoned from the file plus docs and reproduced locally in bash; no workflow run was executed. Whether Actions propagates needs.<job>.outputs from a failed job is unverified — the Major above stands on the structural asymmetry regardless.

Merge order

Land third, after #179 and #183 — at that point this PR is the sole release.yml owner and merges clean.

bonnyrf5 aggregate review, #181.

Injection (release.yml:117,205): raw ${{ inputs.publish_only }} — free-text
workflow_dispatch input — was interpolated straight into two run: scripts, so a
crafted value ran as shell in a job that carries a token. Both sites now read it
from env (PUBLISH_ONLY), the same indirection the CI-status step already uses,
so the value is never part of the script text.

Republish guardrails (release.yml republish path): repointing :latest across all
seven public images accepted any existing tag. Added, in the publish_only branch:
  - shape guard: ^v?[0-9]+\.[0-9]+\.[0-9]+$ — refuses rc/pre-release and
    arbitrary strings, so :latest can only track a final release.
  - recency guard: sort -V against VERSION — refuses a tag older than the
    current release, so a republish can't move :latest backward.

Verified: v4.0.0 accepts, v4.0.0-rc.1 rejects on shape, v3.1.6 rejects on
recency, "; rm -rf /" rejects on shape. actionlint clean on the changed lines
(the 4 remaining findings are pre-existing style nits in unrelated steps).

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

@mwiget mwiget left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving at d45dc87b — 25/25 green, and I exercised the new guard rather than reading it.

The env indirection is the right shape, and its reach is bigger than the two lines it touches. publish_only was flowing into steps.version.outputs.new, which is then interpolated raw at release.yml:251 (TAG="v${{ steps.version.outputs.new }}") and at the publish job's ref: (release.yml:692). Validating the shape before it becomes an output sanitizes every one of those consumers at the source — that's a stronger fix than env-quoting the two original sites would have been, and worth noting because a reader may see the two env: blocks and assume the blast radius was two run: steps.

I extracted both guards verbatim and ran the matrix (CURRENT=4.0.0 unless shown):

input result
v4.0.0 accept → 4.0.0 (republish same version — the recovery case)
4.0.0 (no v) accept → 4.0.0
v4.0.1 accept
v10.0.0 accept — sort -V, not lexical, so 10 > 4
v4.0.0-rc.1 reject (shape)
v3.1.6 reject (recency)
v3.9.9 reject (recency)
v4.0.0 with CURRENT=4.0.1 reject (recency)
; rm -rf / reject (shape)
v4.0.0$(id) reject (shape)

The equal case accepting is the important one — republishing the current version is the recovery path this feature exists for, and an off-by-one there would have made the whole input useless. sort -V also gets the 10-vs-4 case right, which a lexical compare wouldn't.

Non-existent-but-newer tags (v4.0.1 when nothing is tagged) still pass these two guards, but the existing Check republish tag exists step at release.yml:248 catches them before anything publishes, so the three checks compose to: valid shape → not older → actually exists.

One follow-up, out of scope for this PR — the same injection class remains on release_notes. It's also free-text type: string, required: true, and it's interpolated raw into five run: blocks: release.yml:580 (NOTES="${{ inputs.release_notes }}"), 612 (the git commit -m body), 620 (git tag -m), 631 (gh release create --notes), and 647 (the step summary). All five are pre-existing on staging — git diff against the merge base shows this PR doesn't touch any of them — so I'm not blocking on it. But publish_only was hardened precisely because a dispatch input reaching a run: in a token-carrying job is a bad idea, and release_notes reaches five of them on the manual path that actually gets used. No open issue covers it (I checked; #124 is the closest and is about shellcheck/branch-protection/migration-collision, not this). Same env: treatment would close it in one pass.

…invariant

mwiget non-blocking note on #181: the comment told the next editor the list 'MUST
stay in sync with ci.yml's push-trigger paths-ignore list' — but #182 removes that
ci.yml list, so the instruction would have them re-add exactly what was removed.
Reworded to state the actual invariant the preflight SHA poll depends on
(CI must be a superset of Release — every SHA that triggers Release must also have
a CI run to match), and to say explicitly not to re-add a ci.yml paths-ignore. The
phrasing holds regardless of the #181/#182 merge order.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

Two follow-ups from your notes:

@mwiget mwiget left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving at 9aa2130f — 25/25 green.

This is a better fix than the one I asked for. I'd suggested the comment be reworded because it now describes a sync that #182 deliberately breaks; instead of describing the new state, 9aa2130f names the invariant underneath it — CI ⊇ Release, because the preflight SHA poll needs every Release-triggering SHA to have a matching CI run — and then states the operational rule that follows: keep this list a subset of whatever ci.yml runs on. That survives the next change to either file, which the original "MUST stay in sync" phrasing did not.

The parenthetical is the part that actually prevents the bug: "do NOT re-add a ci.yml paths-ignore to 'match' this list — that would break the invariant." Someone reading only release.yml would otherwise see an apparent divergence and 'fix' it in exactly the direction that reintroduces the timeout — and re-adding it would also undo #182's secret-scanning-on-doc-only-PRs change as collateral. Calling that out by name is worth more than the reword.

Everything from my previous pass stands: the shape/recency guards verified across ten inputs, the equal-version accept that makes the recovery path usable, and the env indirection sanitizing steps.version.outputs.new for its downstream consumers at release.yml:251 and :692.

The inputs.release_notes follow-up is still open and still out of scope here — five raw interpolations into run: blocks, all pre-existing on staging, no issue tracking them yet.

@bonnyr-f5

Copy link
Copy Markdown
Collaborator

Review: REVISE

Reviewed under the review-discipline pipeline at 9aa2130f (base 4a52ed45): invariant sweep, a context-isolated cold audit, plus a cross-PR sweep over all seven open PRs in this series.

The injection question on publish_only comes out clean

Worth stating plainly since it was the main risk: publish_only is env-indirect at both interpolation sites (:121, :203), and the :216 regex rejected every payload constructed against it — ;, backtick, $( ), /, .., embedded newline (bash =~ anchors $ at end-of-string, not end-of-line), empty, whitespace-only. All 32 consumers of the derived new_version were enumerated; for kind=publish_only only release-publish's five are live, all constrained to [0-9]+\.[0-9]+\.[0-9]+. actionlint v1.7.12 + shellcheck over the whole .github tree: 4 findings, all pre-existing SC2129 nits outside this diff.

Also confirmed: the max-based RC fix (RC_MAX='9'rc.10, empty → rc.1), the pipefail reasoning, YAML validity, that version_bump/run_e2e/release_notes really are ignored on the publish_only path, and that the loop guard still stops the loop.

Blocker — ${{ inputs.release_notes }} is inlined raw into four run: blocks

:584, 616, 624, 635, 651. Pre-existing, but a class sibling of the rule this diff introduces: the comment at :213-214 establishes "never inline it into the script" and applies it to one of the file's two free-text inputs. Proven by rendering and executing the substitution:

release_notes = '"; touch /tmp/PWNED; echo OWNED; #'
→ OWNED printed, /tmp/PWNED created

This runs in release-manual with contents: write and GITHUB_TOKEN. actionlint has no injection rule, so CI will not catch it. Same env-indirection fix as publish_only — the class fix is both inputs, not one.

Major — the recency guard reads the wrong reference, so :latest can still move backward

:221-226 compares against cat VERSION on the dispatch ref, but :latest is set by whatever release-publish last ran. Using the repo's default dispatch ref (main):

  1. main at VERSION=4.0.0, tag v4.0.0
  2. someone takes the documented manual-override path from staging, bumping staging's VERSION to 4.1.0, tagging v4.1.0, publishing :4.1.0 + :latest
  3. main's VERSION is still 4.0.0 → publish_only=v4.0.0 from main passes (4.0.0 == 4.0.0) and repoints :latest backward across all seven images

Verified: bash -e derive.sh 3.1.6 v4.0.0ACCEPT. Fix: compare against git tag -l 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 — the idiom already used three times in this file.

Major — the publish job isn't gated on preflight success

:671-680. The publish_only disjunct carries no result check under always(), unlike release-manual at :557. So the three new validators aren't load-bearing: the only thing between a rejected input and docker buildx bake --push is that checkout happens to fail on ref: v (empty, because echo "new=..." sits at :248, after the branch that exit 1s).

I could not settle whether needs.preflight.outputs.release_kind is populated when preflight's conclusion is failure — that decides whether the job is scheduled or skipped. GitHub's contexts and workflow-syntax docs don't say and I can't execute Actions locally. Either way the if: doesn't express the intended gate.

Major — the reworded comment states a falsehood, and deletes the rule that held the invariant

:25-31. "ci.yml runs on every push" is not true today: ci.yml:44-53 has a push paths-ignore byte-identical to release.yml's (confirmed by set comparison). That equality is the only reason CI_ignore ⊆ REL_ignore holds, and it is exactly what the deleted "MUST stay in sync" comment enforced.

This composes badly with #182, which removes ci.yml's workflow-level paths-ignore while release.yml keeps its own. Together: a docs-only push to main/staging cancels commit A's in-flight CI run (concurrency: ci-${{ github.ref }}, cancel-in-progress: true) and triggers no Release run — so Release(A) hits :150-157, exits 1 saying "the newer push will release instead", and no newer release exists. A's commits are silently never released.

One PR deletes the comment, the other breaks the property it documented, and neither diff shows it. This is the second review round spent on that comment, which is the signal it should be a check: assert ci.yml's push paths-ignore is a subset of release.yml's.

Minor

  • :88-93 — the new ::notice:: arm is dead code justified by a false mechanism. Release commits carry [skip ci] (:481, :614), which GitHub honours natively for push events, so no Release run is created and the guard never sees them. Same wrong claim in the header at :14-15.
  • :722-737 — republishing an already-published tag force-overwrites :X.Y.Z/:latest with new digests and re-signs, orphaning the original attestation, with no existence check — for exactly the ambiguity that motivates the input.
  • publish_only has no doc coverage anywhere (grep across all *.md: no hits), and release_notes is still required: true on a path that discards it.

Nit

v04.0.0 gets an "is older than" error rather than a shape error.

Not covered

There is no test and no lint gate for any workflow in this repo — ci-gate's needs: contains no workflow linter, and nothing under tests//scripts/ references release.yml. Every branch in this diff is unreachable by automation. Two freezes worth adding: an actionlint job in ci-gate, and the paths-ignore subset assertion above.

… on preflight

bonnyr-f5 REVISE review of #181. All findings reproduced and confirmed.

BLOCKER — release_notes was inlined raw into 5 run: sites in release-manual
(contents: write + token), the class sibling of the publish_only injection this
PR fixes. Proven: `"; touch /tmp/PWNED; #` executes. Added a job-level
RELEASE_NOTES env and reference "$RELEASE_NOTES" at every site — both free-text
inputs are now indirected, not one.

MAJOR — the recency guard compared publish_only against `cat VERSION` on the
dispatch ref, but :latest is set by whatever release-publish last ran, so a stale
main VERSION let an older tag repoint :latest backward. Now compares against the
highest final `v*` tag (the idiom already used elsewhere in the file).

MAJOR — the publish job's publish_only disjunct ran under always() with no
preflight-success check, so the new validators weren't load-bearing. Gated it on
`needs.preflight.result == 'success'`, matching release-manual.

MAJOR — my earlier reworded comment claimed "ci.yml runs on every push", which is
false until #182 lands (ci.yml still has a byte-identical push paths-ignore).
Reworded to state the CI ⊇ Release invariant without the false claim, and to point
at #182's subset check.

Acknowledged (documented): the ::notice:: dead-code arm, republish-without-
existence-check, v04.0.0 shape gap, missing publish_only docs, and the absent
actionlint gate.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

@bonnyr-f5 — all valid, reproduced. Fixed in d5b82a4d:

  • BLOCKER — release_notes raw-inlined into 5 run: sites (the class sibling of the publish_only fix). Added a job-level RELEASE_NOTES env and reference "$RELEASE_NOTES" everywhere — both free-text inputs are indirected now, not one.
  • MAJOR — recency guard read the wrong ref: now compares against the highest final v* tag (not this ref's VERSION), so :latest can't move backward.
  • MAJOR — publish job ungated: added needs.preflight.result == 'success' to the publish_only disjunct.
  • MAJOR — my reworded comment was newly false ("ci.yml runs on every push"): corrected to state the CI ⊇ Release invariant and point at Wire gitleaks / shellcheck / script self-test CI gates — PR #177 Major #182's subset check.

Acknowledged: ::notice:: dead code, republish-without-existence-check, v04.0.0 shape gap, publish_only docs, actionlint gate (follow-up).

@bonnyr-f5

Copy link
Copy Markdown
Collaborator

Review: REVISE

Round 2, cold re-audit of d5b82a4 against origin/staging 4a52ed4.

The env-indirection fix is complete where it counts: zero ${{ }} occurrences of
inputs.release_notes, inputs.publish_only, or github.event.head_commit.message inside any
run: block. All seven jobs' needs references resolve, release-publish is fail-closed when
preflight is skipped, RC max-based numbering is numeric and survives tag deletion, and the
"must not add pipefail" comment at :311-314 is correct (no defaults: present). INV-9 clean
across 31 extracted run blocks.

Major 1 — the :latest guard protects one branch of two

release.yml:220-229 adds the ":latest can never move backward" check inside the publish_only
branch. release-manual (:231-239) derives NEW from the dispatch ref's own VERSION file with no
comparison against the highest final tag — and both paths share the single ROLLING_TAG: latest
writer at :738.

Failure scenario: dispatch release-manual from a maintenance branch whose VERSION is 3.0.9 while
v3.1.6 is the highest tag. It tags, publishes, and repoints :latest to 3.0.9. The invariant the
comment at :221-223 states is false at the sibling site.

Class fix: hoist the highest-final-tag comparison out of the publish_only branch so it guards every
path that writes ROLLING_TAG.

Major 2 — the loop guard reads the whole message but reports the subject

release.yml:86-100. grep -qE '^release: |^\[skip ci\]|\[skip ci\]$' <<< "$MSG" runs over every
line of the commit message, while FIRST="${MSG%%$'\n'*}" is only the subject.

A normal commit whose body contains a line ending in [skip ci] — a quoted CI snippet, a
changelog paste — silently suppresses the release, emits a warning quoting a subject that provably
carries neither marker, and leaves the run green with nothing published. Match the subject only, or
report the line that actually matched.

Major 3 — INV-14: the CI⊇Release trigger coupling is still prose

release.yml:25-31. The preflight SHA poll and its "a newer push will release instead" message both
depend on the two workflows' paths-ignore sets agreeing, and nothing in this tree compares them —
enforcement is deferred to unmerged #182. A comment is not a gate.

Minors

  • :136 and 11 other sites — github.ref_name is interpolated into run: blocks without env-indirection, while this PR env-indirects release_notes/publish_only. Git accepts $(id), backticks, ; and | in branch names, and workflow_dispatch can target any branch. Same class as the fix, at the sites the fix didn't reach.
  • no workflow-level permissions:guard and preflight inherit the repo-default token scope for jobs needing only contents: read + actions: read. ci.yml:55-57 sets least-privilege; the higher-privilege workflow is the unrestricted one.
  • :221-229 — the check is highest-semver over git tags but is labelled "Recency" and used to reason about :latest, a registry fact. A tag whose publish failed — the exact recovery case this PR adds — makes the two diverge, a stray high tag blocks every republish, and there's no rollback mode.
  • :67-69concurrency.group: release-${{ github.ref_name }} is finer than the contended resource, so a republish from one ref and a final release from another can both push :latest. TOCTOU that defeats the recency check.
  • :134publish_only skips the CI poll, never runs e2e-gate, then rebuilds from an old tag with today's toolchain and overwrites both :X.Y.Z and :latest: an untested image promoted to :latest, plus a mutated immutable version tag.
  • :208-209VERSION is read unvalidated into $GITHUB_OUTPUT (a newline injects an output key) and current_version is then interpolated into four run: blocks.

Nits

  • :52-65release_notes/run_e2e stay required: true though publish_only's own description says they're ignored.
  • :28-31 — the comment's first clause inverts the direction its next sentence states correctly.
  • :88-93 — the ::notice:: branch is likely unreachable: GitHub natively skips push runs whose head commit carries [skip ci], which release-final/manual always append.
  • :316TARGET's dots unescaped in the RC sed pattern.

Review Assessment

  • Verdict: REVISE
  • Audit SHA: d5b82a4
  • Cold Audit Performed: Yes
  • Invariants Verified: INV-9 (upheld), INV-14 (violated), INV-16 (violated)
  • Git & Harness Cleanliness: Clean

Findings & Action Items

  • Major (Blockers):
    • release.yml:220-229: hoist the backward-:latest guard to cover release-manual
    • release.yml:86-100: loop guard matches any line, reports only the subject
    • release.yml:25-31: assert the two paths-ignore sets instead of documenting them
  • Minor (Non-blocking):
    • release.yml:136 +11: env-indirect github.ref_name too
    • release.yml: add a least-privilege top-level permissions: block
    • release.yml:67-69, :134, :208-209, :221-229
  • Nits:
    • release.yml:28-31, :52-65, :88-93, :316

jgruberf5 pushed a commit that referenced this pull request Aug 21, 2026
…ts skipped

bonnyr-f5 round-2 REVISE of #182. Both majors reproduced and fixed.

MAJOR 1 — gitleaks --no-git scanned the worktree, missing a secret added then
REMOVED within the branch (permanently fetchable from a public clone). Verified:
--no-git says "no leaks" on such history; git mode + --log-opts catches it.
Now checks out fetch-depth: 0 and scans the PR/push COMMIT RANGE in git mode
(pull_request base..head; push before..sha; first push -> all history).

MAJOR 2 — cancel-in-progress: false does not stop GitHub cancelling a PENDING run
in the same per-ref group, so a docs-only push could still starve a release's CI
run. On main/staging the concurrency group now includes the SHA, so every push
gets its own group and nothing cancels; feature branches keep the per-ref group.

MINOR — the CI Gate accepted `skipped` for the four always()-run gates, so a
future path-filter would go green with the check never run. Skipped is now a
failure for version-consistency / shellcheck / secret-scan / script-selftests.

MINOR — the self-test gate asserted >=1 PASS but not completion; an early exit
after case 1 would pass. Now also requires the END-SELF-TEST marker (branch-
independent; catches the #179 early-exit shape).

MINOR — make shellcheck: `xargs shellcheck` on an empty list exits 0 on BSD.
Now fails on an empty list and includes the (extensionless) .githooks; full
corpus clean.

Acknowledged: the generic-api-key rule's test-dir allowlist (same fixture tension
as private-key; content-scoping is a follow-up); the release.yml paths-ignore
comment lives in #181 and is handled there. Merge #180 first (merge commit).

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

@bonnyr-f5 — thanks for the round-2 cold audit. Every finding was reproduced before touching code; dispositions and fixes below. All in af682d3d, actionlint clean (only the 4 pre-existing SC2129 summary-step nits remain, none new), yaml.safe_load OK.

Majors — all VALID, all fixed

Major 1 — :latest guard protected one branch of two (:220-229). VALID. Reproduced: release-manual derives NEW from the dispatch ref's VERSION (e.g. 3.0.10 on a maintenance branch) with no comparison, while v3.1.6 is highest — the guard lived only in the publish_only branch, and both paths share the single ROLLING_TAG: latest writer. Fix: hoisted the highest-final-tag comparison out of publish_only into one block that runs for every non-rc kind (final, manual, publish_only — the three that reach release-publish; rc never touches :latest, so it's exempt). Removed the now-duplicate inline check from the publish_only branch.

Major 2 — loop guard read the whole message, reported the subject (:86-100). VALID. Reproduced: a commit whose body has a line ending [skip ci] matches grep -qE ... <<< "$MSG" (grep is line-oriented), suppresses the release, and the warning quotes FIRST (the subject) which carries no marker. Fix: match FIRST (the subject line) only. Our own release commits still carry the marker in the subject (release: vX.Y.Z [skip ci]), so they're still caught.

Major 3 — INV-14: CI⊇Release coupling still prose (:25-31). VALID observation; the enforcement gate is by design deferred to #182 (as your review notes: "enforcement is deferred to unmerged #182"). Adding the cross-workflow assertion here would duplicate #182 and conflict on merge, so this PR does not re-implement it. What I did fix in this PR: the comment reworded to state the invariant in the correct direction — "this list ⊇ ci.yml's push paths-ignore (ci.yml's ⊆ this)" — which also fixes the directional inversion you flagged as a nit at :28-31. It no longer orders re-adding the trigger coverage #182 removed; the remediation text now says restore CI ⊇ Release by widening ci.yml's skips or narrowing this list. (For the record, the two push paths-ignore sets are currently identical, so the invariant holds today.)

Minors

  • :136 +11 — env-indirect github.ref_name. VALID (branch names legally carry $(), backticks, ;, |; workflow_dispatch targets any branch). Fixed: added a REF_NAME env to preflight/release-rc/release-final/release-manual and replaced all 12 run:-block uses with $REF_NAME. The only remaining github.ref_name is the workflow-level concurrency.group key (:75), which is not a shell context.
  • No top-level permissions:. VALID. Added permissions: contents: read at workflow level; preflight gets contents: read + actions: read (for its gh run list --workflow=ci.yml poll). The write-scoped jobs keep their own blocks (a job block replaces, not merges).
  • :208-209 — unvalidated VERSION into $GITHUB_OUTPUT. VALID (a newline injects a second output key). Fixed: validate CURRENT matches ^[0-9]+\.[0-9]+\.[0-9]+$ before the echo current= write.
  • :67-69 — concurrency group finer than the contended resource. VALID. Fixed by matching the group to the resource: release-publish (the actual :latest writer) now has a job-level concurrency: { group: release-publish-latest, cancel-in-progress: false }, so a republish and a final release from different refs can't push :latest concurrently.
  • :134 publish_only untested-image promotion / :221-229 recency-vs-registry + no rollback. VALID design limitations, acknowledged and left as-is for this PR. They're inherent to the recovery feature; hoisting the guard (Major 1) does extend the "stray high tag blocks republish" behavior to manual too — an explicit rollback/override mode is a larger design change worth its own issue rather than smuggling into this hardening PR. Happy to file one.

Nits

  • :52-65 release_notes/run_e2e required though publish_only ignores them. Fixed: both set required: false (release_notes gets default: "", description notes it's ignored when publish_only is set).
  • :28-31 directional inversion. Fixed (see Major 3).
  • :88-93 ::notice:: branch likely unreachable. Acknowledged — GitHub natively skips push runs whose head commit carries [skip ci]. Left in place deliberately as a belt-and-suspenders net (in case that native behavior changes); it's harmless.
  • :316 TARGET's dots unescaped in the RC sed pattern. Fixed: TARGET_RE="${TARGET//./\\.}" used in the anchor. (In practice the git tag -l glob pre-filters, but a literal-dot pattern is correct.)

Commit: af682d3d.

Majors:
- Hoist the :latest recency guard out of the publish_only branch so it
  covers every path that reaches release-publish (final, manual,
  publish_only); rc is exempt. release-manual from a maintenance branch
  could otherwise tag+publish a version below the highest final tag and
  drag :latest backward.
- Loop guard now matches the SUBJECT line only, not the whole commit
  message: a skip-ci marker line in a commit body no longer suppresses a
  legitimate release while the warning quotes a marker-free subject.
- paths-ignore comment reworded to state the CI ⊇ Release invariant in
  the correct direction ("this list ⊇ ci.yml's push paths-ignore"),
  fixing the inverted clause; the automated check stays deferred to #182.

Minors:
- Add a least-privilege top-level permissions: contents: read; preflight
  gets actions: read for its gh run list poll. Higher-privilege jobs keep
  their own blocks.
- Env-indirect github.ref_name across all run: blocks (REF_NAME), same
  class as the release_notes/publish_only hardening.
- Validate the VERSION file is MAJOR.MINOR.PATCH before it reaches
  $GITHUB_OUTPUT (a newline would inject a second output key).
- Serialize the :latest writer (release-publish) on one global
  concurrency group so republish + final from different refs cannot push
  :latest concurrently.

Nits:
- release_notes / run_e2e no longer required (publish_only ignores them).
- Escape TARGET's dots in the RC-number sed pattern.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
@jgruberf5
jgruberf5 force-pushed the fix/release-republish-and-robustness branch from af682d3 to a11ce5e Compare August 21, 2026 02:23
jgruberf5 pushed a commit that referenced this pull request Aug 21, 2026
bonnyr-f5 round-3 BLOCK on #179. The round-2 blockers stay fixed (--first-parent
reverted, SIGPIPE deterministic-fix, detector pair identical); this addresses the
predicate itself, which round 2 did not examine.

BLOCKER 1 — the marker regex was a WORD BOUNDARY, not a line anchor, so it fired
on uppercase prose anywhere in a body while the comment claimed "footer only".
Over the live v3.1.6..origin/main range the extractor shipped a fragment of this
script's OWN changelog prose (#178) to operators as migration guidance. Both
detectors are now anchored to the spec footer position, `^(**)?BREAKING[ -]CHANGE`
(markdown-bold allowed), and are byte-identical across the two scripts (INV-15).
Re-running the extractor over that range: the #178 prose bullet is gone; the real
#2 container-hardening break (its only breaking signal in the whole range) is
still detected, so the 4.0.0 major derivation is unchanged. I deliberately did
NOT require a trailing colon: #2 declares its break as a line-start marker with no
colon and no type!: subject, so a colon rule would UNDER-detect and silently ship
4.0.0 as a patch — a worse failure than prose.

Also fixed from the same review:
- _breaking_note emits the anchored footer paragraph(s), not the first prose
  match; captures EVERY footer (a second one was dropped) with no line cap (n>=40
  truncated silently).
- extract-breaking-changes.sh now fails CLOSED on an unresolvable range (validates
  both refs) instead of 2>/dev/null||true -> empty output rc 0, which fooled the
  reviewer mid-audit. Matches compute's fail-closed behaviour.
- The consistency guard read %b only while the loop reads %s AND %b: it was blind
  to subject-declared breaks. It now re-derives from BOTH subjects and bodies, so
  dropping the loop's subject detector can no longer leave feat!: as a silent
  patch.
- Self-test hardening (INV-16): the extractor's _expect_nonempty could never fail
  (it checked the note only inside a failure conjunct) -> rewritten to assert each
  detector fires POSITIVELY, with mid-line-prose / lowercase / indented negatives
  and a two-footer case. compute gains an assertion counter that fails on zero
  assertions, coverage for the unresolvable-since-tag guard, and a robust
  BASH_SOURCE self-invocation ($OLDPWD/$0 broke any non-cwd-relative call).
- Nits: case-insensitive bang so `Feat!:` bumps major; `local _b`; harmonized the
  determinism comments (the bug is deterministic past the ~64 KB pipe buffer, not
  a race).

Handed to sibling PRs (bonnyr flagged, out of this diff): the release.yml call
sites' `|| true` and the head -40/-50 note truncation live in #181; wiring the
extractor --self-test into CI plus a byte-identical-function assertion lands in
#182's script-selftests job. I'll push both.

Verified: shellcheck -S style clean on both; compute self-test 9/9 rc 0; extractor
self-test 12/12 rc 0; detectors byte-identical; extractor re-run over the real
release range shows the prose bullet gone and the real break retained.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…ating

bonnyr-f5 #179 r3, the two items handed to this PR.

1) The three extract-breaking-changes.sh call sites wrapped the script in
   `|| true`, which re-masked the fail-closed exit the script just gained: an
   unresolvable range would yield empty output and a release would publish with
   no breaking-change section and no signal. Dropped `|| true` at all three
   sites; GitHub Actions runs `run:` under `bash -eo pipefail`, so a bad range
   now aborts the release step. It cannot abort spuriously -- LAST_FINAL is
   always either empty (else-branch, no call) or a real tag from `git tag -l`.

2) The commit list was cut with a bare `head -40` / `head -50`, silently
   dropping 17 of 67 commits from published notes. It now caps at 300 (generous
   enough that real ranges are complete) and appends an explicit
   "… and N more commit(s)" line when it truncates. The `|| true` added on the
   `grep -v "^- release: "` filter guards ONLY the filter (an all-release range
   leaves it with no output, rc 1 under pipefail) -- it does not touch the
   breaking-change detector, which stays fail-closed.

Verified: YAML + actionlint clean (only the pre-existing SC2129 summary-step
style nits remain); cap+notice logic unit-tested (caps and appends the remainder
line); empty-filtered range yields empty with no abort.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

Cross-PR items from @bonnyr-f5's #179 r3 that live in this PR — both fixed in b8b0d161:

  1. The three extract-breaking-changes.sh call sites wrapped the script in || true, re-masking the fail-closed exit the script just gained in Fix non-deterministic version derivation (SIGPIPE race) — PR #177 Blocker 1 #179 (an unresolvable range → empty notes, no signal). Dropped || true at all three sites; since run: executes under bash -eo pipefail, a bad range now aborts the release step. It can't abort spuriously — LAST_FINAL is always either empty (else-branch, no call) or a real tag from git tag -l.
  2. The commit list was cut with a bare head -40/head -50, silently dropping 17 of 67 commits. It now caps at 300 (real ranges stay complete) and appends an explicit "… and N more commit(s)" line when truncated. The || true I added is on the grep -v "^- release: " filter only (an all-release range leaves it empty, rc 1 under pipefail) — it does not touch the breaking-change detector, which stays fail-closed.

Verified: YAML + actionlint clean (only the pre-existing SC2129 summary-step nits remain); cap+notice logic unit-tested; empty-filtered range yields empty with no abort.

This is on top of the round-2 release.yml hardening already in this PR.

@bonnyr-f5

Copy link
Copy Markdown
Collaborator

Review: BLOCK

Round 3, cold re-audit of af682d3 against origin/staging 4a52ed4. The newest commit rewrote 91
lines, so this is a read of the whole workflow as fresh code rather than a delta check.

Round 2's findings are genuinely closed, and I checked them rather than taking the commit message for
it:
permissions: is correct least-privilege at workflow and every job level — stricter than
ci.yml — and the one risk that creates (e2e-gate inheriting contents: read) is safe. The
env-indirection sweep is complete: across 52 interpolation sites, no commit message, branch name,
tag or free-text input reaches a run: block directly; the only residual is inputs.version_bump,
which GitHub validates server-side as type: choice. Recency-guard arithmetic is correct across 8
cases including both sort -V directions, and max-based RC numbering handles rc.1/2/10 → 11.

BLOCKER 1 — the republish path overwrites an artifact it already published, and it is not idempotent

docker-bake.hcl:6 describes :VERSION as:

Floating tag pushed alongside the immutable :VERSION tag.

But _common.labels carries:

"org.opencontainers.image.created" = timestamp()

so every rebuild produces a different digest, deterministically — not occasionally. Every bake
target emits ${REGISTRY}/…:${VERSION} unconditionally (:48-51), so publish_only re-pushes that tag
with new bits. And publish-signed-images.sh resolves the digest (:120-131) and attaches cosign
signatures, the SBOM and SLSA provenance by digest, so a republish orphans every prior attestation
while the tag silently moves under anyone who pinned it.

Nothing distinguishes the intended case ("publish never ran") from the destructive one ("images already
exist"). Class fix: probe the registry for an existing manifest first and refuse by default, with an
explicit force input to override; and remove timestamp() (or set SOURCE_DATE_EPOCH) so a rebuild
of the same tree is byte-identical and the "immutable" claim is enforced rather than described.

BLOCKER 2 — this PR's own commit bodies contain literal [skip ci]

af682d3  body L10:  message: a [skip ci] line in a commit body no longer suppresses a
a7762b6  body L4:   successful release: release-final pushes `release: vX [skip ci]` to main, that
a7762b6  body L23:  Guard split verified: release commit -> notice, hand-written [skip ci] ->
4e3ae14  body L22:  `[skip ci]` silently skipped publication while the job reported success.

GitHub's default squash body concatenates the branch's commit messages, so squash-merging this PR
produces a head commit whose body carries the marker — suppressing both CI and Release, with no
annotation anywhere, because the workflow never starts. #182 adds the AGENTS.md rule stating exactly
this ("Never write a CI-control marker as literal text anywhere in a commit message") and notes it has
already bitten twice.

This one is a merge-time constraint rather than a code change: merge with a merge commit, or edit the
squash body
. Worth escaping the markers in the bodies anyway, since the same text will be quoted again.

Major — the :latest backward-move guard covers 2 of 4 rolling-tag writers

The guard's comment claims it covers "EVERY path that repoints :latest". Enumerating the writers,
make push-images (Makefile:1138-1175, and documented to operators at :1071) bakes with --push
and ROLLING_TAG at its default latest, from any checkout, with no ordering check at all. A stale
local tree can repoint :latest backward and no guard sees it.

Major — workflow_dispatch has no branch or ancestry restriction

Dispatching on staging cuts a final release and publishes staging's tree as :latest; the recency
guard passes because the version is numerically higher (measured: ALLOW new=5.0.0 highest=v4.0.5).
Gate dispatch on main, or on the ref being an ancestor of main.

Major — the concurrency group serializes but does not order

release-publish-latest prevents two publishes running at once, but the recency check lives in
preflight, outside the critical section, so the lower version can still be the last :latest
writer. One-pending-per-group also means a third arrival cancels a queued publish. Re-check recency
inside the guarded step, immediately before the push.

Major — guard suppression concludes the workflow as success with nothing published

The suppression path skips every job, so the run reports success while publishing nothing; the only
signal is a ::warning:: annotation. Reachable with no [skip ci] involved at all — a squash-merged PR
titled release: …(#412) matches. A release that did not happen must not be green.

Major — the rewritten paths-ignore comment states two things that aren't true

release.yml:25-34 asserts as fact:

Enforced by convention today; #182 adds the automated check that ci.yml's push paths-ignore is a
subset of this list.

#182 adds no such check — it deletes ci.yml's paths-ignore entirely (its ci.yml:27 and :35
are comments recording that choice) and addresses the coupling through concurrency instead. The
invariant then holds trivially, because CI triggers on everything, so this is a doc-accuracy problem
rather than a functional break — but the comment sends the next reader looking for a gate that does not
exist. The same block also downgrades the deleted "MUST stay in sync" equality to a one-sided
subset, and both remediations it prescribes read inverted ("Do NOT re-add trigger coverage here" and
"or narrowing this list" describe the same edit).

Please state what the mechanism actually is at the ref where it lands, and verify the claim against
#182's tree before merging.

Major — the loop-guard rewrite's justification is unreachable

GitHub skips the run before the guard can observe it, and the comment 15 lines below asserts the
opposite model. The new ::notice:: branch is dead on both paths: on push, a [skip ci] subject means
no run is created and GITHUB_TOKEN pushes create none either; on dispatch, head_commit is null.

Minor / UNPROVEN

actionlint was not installable on the audit host, so the workflow was validated by parsing the YAML
and auditing the job graph and every expression programmatically rather than with the linter. Worth a
local actionlint run before merge.

Review Assessment

  • Verdict: BLOCK
  • Audit SHA: af682d3
  • Cold Audit Performed: Yes
  • Invariants Verified: INV-8 (violated), INV-10 class (violated), INV-14 (upheld at this ref — proven by byte-diffing both blocks, not by trusting the comment), INV-16 (violated), INV-21 (clean), INV-23 (violated), new INV-24 (violated)
  • Git & Harness Cleanliness: Clean

Findings & Action Items

  • Major (Blockers):
    • docker-bake.hcl:33 + the publish_only path: republish overwrites the tag :6 calls immutable and orphans digest-attached attestations; add a registry existence probe + explicit force, and drop timestamp()
    • Merge with a merge commit — three commit bodies carry literal [skip ci]
  • Minor (Non-blocking):
    • Makefile:1138-1175: make push-images repoints :latest with no ordering guard
    • workflow_dispatch: restrict to main / an ancestor of main
    • Re-check recency inside the concurrency-guarded step, not in preflight
    • Guard suppression must not conclude the workflow green
    • release.yml:25-34: the "Wire gitleaks / shellcheck / script self-test CI gates — PR #177 Major #182 adds the automated check" claim is false; the two remediations read inverted
    • The ::notice:: loop-guard branch is dead code on both push and dispatch
  • Nits:
    • Run actionlint locally — it could not be run in the audit environment

…tract

Addresses bonnyr-f5 round-3 BLOCK on PR #181.

BLOCKER 1 - republish overwrote the tag it calls immutable and was not
idempotent. docker-bake.hcl stamped org.opencontainers.image.created with
timestamp(), so every rebuild produced a different digest (reproduced:
two identical bakes gave ...36Z then ...40Z), and every bake target pushes
:VERSION unconditionally. Fixes:
  - docker-bake.hcl: replace timestamp() with an injectable CREATED variable,
    empty by default so a plain bake of a given tree is byte-reproducible; CI
    sets it to the release commit's committer date. release-publish also passes
    SOURCE_DATE_EPOCH to normalise layer mtimes, so the same tag rebuilds to
    the same digest and its cosign/SBOM/SLSA attestations still apply.
  - release.yml: before pushing, probe the registry with docker manifest
    inspect and refuse by default when the :VERSION manifest already exists;
    a new force dispatch input overrides deliberately. The normal recovery
    case (a tag whose publish never completed) has no manifest yet and passes.

Major findings also fixed:
  - Makefile push-images now runs the same recency guard before baking with
    --push ROLLING_TAG=latest, so a stale local tree cannot repoint :latest
    backward (override with FORCE_LATEST=1).
  - workflow_dispatch of a manual release is now gated to main, or a ref
    already merged into main, so a side branch cannot publish an unreviewed
    tree as :latest.
  - The recency check is re-run inside the release-publish concurrency-guarded
    step immediately before the push, closing the TOCTOU where preflight's
    check (outside the critical section) let a lower version write :latest last.
  - The loop guard now fails the run on an unexpected suppression instead of
    reporting a silent green; our own release commit still ends green as a
    notice. A release that published nothing is no longer green.
  - Corrected the paths-ignore comment: #182 removes ci.yml's push
    paths-ignore entirely (it does not add a subset check), so CI is a strict
    superset of Release; fixed the two inverted remediations.
  - Rewrote the loop-guard header comment to describe the real mechanism -
    GitHub's native marker handling drops our release commits before the
    workflow starts; the guard is a backstop.

Verification: python yaml parse OK; actionlint reports no new findings (the
4 pre-existing SC2129 style notes are unchanged); docker buildx bake --print
confirms CREATED is deterministic; guard, recency, probe and Makefile-guard
logic unit-tested.

Note for merge: this branch's commit bodies quote a CI-control marker as
literal text, so it MUST be merged with a merge commit (not squashed) or the
squash body will carry the marker and suppress CI and Release. Documented on
the PR.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

@bonnyr-f5 Round-3 addressed in bfe0b12. Reproduced each finding first, then fixed. Files touched: docker-bake.hcl, .github/workflows/release.yml, Makefile.

BLOCKER 1 — republish overwrote the "immutable" tag and was not idempotent

Reproduced the non-determinism directly:

$ REGISTRY=ghcr.io/test VERSION=9.9.9 docker buildx bake --print default | grep created
"org.opencontainers.image.created": "2026-08-21T04:20:36Z"
$ REGISTRY=ghcr.io/test VERSION=9.9.9 docker buildx bake --print default | grep created
"org.opencontainers.image.created": "2026-08-21T04:20:40Z"

Two identical bakes, two different created labels → different digest every rebuild, deterministically, exactly as you described. Confirmed every bake target emits ${REGISTRY}/…:${VERSION} unconditionally.

Both halves of your class fix are in:

  1. Reproducible build (docker-bake.hcl). timestamp() is replaced with an injectable CREATED variable, empty by default so a plain docker buildx bake of a given tree is byte-reproducible. release-publish sets CREATED to the release commit's committer date (git log -1 --format=%cI) and also passes SOURCE_DATE_EPOCH (%ct) to normalise layer mtimes, so a rebuild of the same tag resolves to the same digest and the digest-attached cosign/SBOM/SLSA attestations still apply. After the fix:

    $ REGISTRY=ghcr.io/test VERSION=9.9.9 docker buildx bake --print default | grep created
    "org.opencontainers.image.created": ""            # deterministic default
    $ ... CREATED=2026-08-18T12:00:00Z ... bake --print
    "org.opencontainers.image.created": "2026-08-18T12:00:00Z"   # fixed when injected
    
  2. Registry probe + refuse-by-default (release.yml). New step "Refuse to overwrite an already-published tag" probes each image's :VERSION manifest with docker manifest inspect and refuses by default if it already exists; a new force dispatch input overrides deliberately. The intended recovery case (a tag whose publish never completed → no manifest yet) passes untouched. Logic unit-tested:

    FORCE unset, images exist  → ERROR refuse (rc=1)
    FORCE=true,  images exist  → WARN overwrite (rc=0)
    no manifests               → SAFE (rc=0)
    

BLOCKER 2 — this branch's commit bodies quote a CI-control marker

Confirmed: af682d3, a7762b6, 4e3ae14 each quote the marker literally in their bodies. As you noted this is a merge-time constraint, not a code change — interactive rebase is unavailable here and I will not fabricate a history rewrite.

Important

This PR MUST be merged with a merge commit — do NOT squash. GitHub's default squash body concatenates the branch's commit messages, so a squash head commit would carry the literal skip-ci marker and silently suppress both CI and Release (the workflow never starts, so there is no annotation anywhere). Merge with a merge commit, or hand-edit the squash body to strip the marker, before merging.

(My own commit bfe0b12 writes the marker only mid-sentence / hyphenated, and I verified its body has zero [skip​ ci] matches and triggered CI — total_count=2 on the new head — so the head commit itself is safe.)

Major — :latest backward-move guard covered 2 of 4 writers

make push-images (Makefile:1138) baked --push with ROLLING_TAG=latest from any checkout with no ordering check. Added the same recency guard to the recipe: it refuses when the local VERSION is older than the highest final tag, with FORCE_LATEST=1 to override. Tested:

VERSION=4.0.0 vs highest v4.0.5           → REFUSE
VERSION=5.0.0 vs highest v4.0.5           → ALLOW
VERSION=4.0.0 FORCE_LATEST=1 vs v4.0.5    → ALLOW
VERSION=4.0.5 (republish highest)         → ALLOW
no tags                                   → ALLOW

Major — workflow_dispatch had no branch/ancestry restriction

New preflight step "Restrict manual release to main" gates a manual dispatch to main, or a ref that is already an ancestor of main (git merge-base --is-ancestor HEAD FETCH_HEAD). Dispatching a side branch now fails before anything is built. publish_only is exempt by design — it republishes an existing tag by checking that tag out, independent of the dispatched ref.

Major — concurrency serialized but did not order

The recency check now also runs inside the release-publish-latest critical section ("Re-check recency inside the publish critical section"), after acquiring the slot and immediately before the push, re-fetching tags first. preflight's check (outside the group) is no longer the authoritative gate, so a lower version can't be the last :latest writer.

Major — guard suppression concluded the run green

The loop guard now fails the run (exit 1, ::error::) on any unexpected suppression — a hand-written marker, or a squash-merged PR titled release: …(#N). Our own release: vX.Y.Z commit still ends green as a ::notice::. Verified:

release: v4.0.1 …            → notice, rc=0 (green)
release: add thing (#412)    → error,  rc=1 (red)
chore: tweak [marker]        → error,  rc=1 (red)
feat + marker in body only   → should_run=true, rc=0

Major — paths-ignore comment stated two false things

Verified against #182's tree: it deletes ci.yml's push (and PR) paths-ignore entirely and adds no subset check. Rewrote the comment to state the real mechanism — after #182, CI runs on every push so it's a strict superset of Release and the subset holds trivially (∅ ⊆ anything) — and fixed the two inverted remediations (restore CI ⊇ Release by shrinking ci.yml's skips or by adding entries here, never by deleting entries here).

Major — loop-guard justification was unreachable / comment asserted the opposite

Rewrote the header and inline comments to describe the real model: on a normal push GitHub's own marker handling drops our release commits before the workflow starts; on workflow_dispatch head_commit is null. The guard is now documented as a backstop, and the previously-dead ::notice:: branch is reachable only via the residual paths (re-tag / marker-stripped replay) it now names.

Nit — actionlint

Ran locally: no new findings. The 4 pre-existing SC2129 style notes on the summary steps are unchanged; I verified the one SC2129 my Resolve release commit step briefly introduced is gone (grouped the redirects into one { … } >> "$GITHUB_OUTPUT").

$ docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest .github/workflows/release.yml
# 4 SC2129 (all pre-existing summary steps), 0 new findings

Also: python3 -c "import yaml; yaml.safe_load(...)" → OK; every changed shell block passes bash -n / shellcheck via actionlint; docker buildx bake --print parses clean.

jgruberf5 pushed a commit that referenced this pull request Aug 21, 2026
Addresses every non-blocker bonnyr-f5 raised in round 3.

Archives (Major): gitleaks defaulted to --max-archive-depth 0, so a secret
shipped inside a tracked tarball was invisible. The scan now runs with
--max-archive-depth 2. Proven with a synthetic fixture: a private key inside a
.tar.gz -> "no leaks found" at depth 0, "leaks found: 1" (secrets.tar.gz!id_rsa)
at depth 2.

Baseline (Major): the per-push gate only scanned each change's commit range, so
anything already in history was never re-examined. Added secret-baseline.yml --
a weekly schedule plus workflow_dispatch that runs gitleaks over full history
with the same assertion backstop.

CI Gate (Major): the aggregator never checked needs.changes.result. If change
detection failed, ~21 gates resolved to skipped, the loop accepted skipped, and
the required check printed PASSED. It now fails when changes did not succeed.
Mutation-tested: with change detection failed and test jobs skipped, the old
gate went green, the new gate goes red.

Local == CI (Major, #166): the four gates ci.yml added were unrunnable locally.
Added make targets (version-check, secret-scan, commit-lint, script-selftests)
aggregated as ci-gates, and made pre-push depend on it. The secret scan + its
whole assertion backstop now live in scripts/secret-scan.sh, called identically
by the CI job, the baseline workflow, and make -- one source of truth.

Marker enforcement (Minor -> real): the skip-CI-marker rule was documentation
only. Added scripts/lint-commit-markers.sh plus a commit-lint CI gate and a
pre-push hook step that fail a range carrying a skip-CI marker, or a line-start
prose form that would spuriously trigger a major release. A genuine
conventional footer still passes. Mutation-tested across eight cases; passes on
this PR's 13-commit range.

Digest pin (Nit): the movable v8.30.1 tag is replaced by
ghcr.io/gitleaks/gitleaks@sha256:c00b6bd0... (v8.30.1 kept in a comment).

release.yml stale comments (Major): the ":23-26" note referenced a ci.yml
paths-ignore that no longer exists, and the ":150-157" note claimed
cancel-in-progress: true for main/staging where it is now false. Comment text
corrected to match reality; no release logic touched (that is #181's domain).

Cross-PR items are intentionally left to merge order: the sync-version-artifacts
second-tag reproduction is fixed in #180's head, and compute_version_bump's
exit-0-on-fail in #179's head. Merge #180 first, merge-commit not squash.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
@bonnyr-f5

Copy link
Copy Markdown
Collaborator

Review: BLOCK

Round 4, cold re-audit of bfe0b12 against origin/staging 4a52ed4 — whole diff (3 files,
+347/-53, 8 commits), fresh context. This PR is release automation that auto-publishes on merge to
main, so a wrong decision here ships. All 347 added lines have zero automated coverage (no
actionlint/yamllint anywhere; make shellcheck covers scripts/*.sh only, nothing under
.github/) — I ran the substitutes: YAML parses, all 34 run: blocks pass bash -n.

BLOCKER — the republish idempotency probe reads any error as "tag is free" (INV-24)

.github/workflows/release.yml:907 gates republish on:

if docker manifest inspect "$REF" >/dev/null 2>&1; then

>/dev/null 2>&1 discards the distinction between "manifest exists" and "the probe failed". Any
GHCR 5xx/429, a DNS blip, an auth expiry, or an uppercased-owner ref all take the else branch, which
prints No existing :${VERSION} manifests found — safe to publish. (line 912), exits 0, and lets
bake overwrite the immutable :VERSION tag — orphaning the digest-bound cosign/SBOM/SLSA
attestations already published against the previous digest. Execution-proven: I ran the step
body verbatim with an unreachable registry and with an uppercase-owner ref; both printed "No
existing manifests found", rc=0. Fix: separate "exists" from "probe failed" (check rc explicitly;
fail closed on a non-404 error).

Major findings (all execution-proven unless noted)

  • release.yml:118-136 — the silent-green class is back, reintroduced by round 3's own fix. A
    human commit release: v3.2.0 notes on main matches ^release: v[0-9]+…::notice::, exit
    0, green run, nothing published. Regex truth table proven.
  • release.yml:460,549,633 — "fail closed on a bad range" is vacuous. b8b0d16's guard can
    never trigger: extract-breaking-changes.sh:33's own || true makes a bad range exit 0 with
    empty output. Proven: extract-breaking-changes.sh v9.9.9-does-not-exist HEAD → rc=0, empty.
  • release.yml:179-205 — the "ancestor of main" exemption admits staging. The exact ref the
    step's own comment names as the hazard: release commit+tag+GitHub Release land on staging, :latest
    built from it, main never bumped. Proven: git merge-base --is-ancestor origin/staging origin/main → true today (staging 0 ahead / 1 behind).
  • release.yml:331-338,874-890,936 — recency guard strands a publish_only release forever.
    Applies to publish_only with no override, ROLLING_TAG hardcoded to latest; once a newer tag
    exists the stranded release's images can never be produced by any path. Truth table proven.
  • docker-bake.hcl:22-32 — "byte-reproducible / SAME digest so attestations still apply" is
    false.
    Two builds of a digest-pinned, network-free Dockerfile with identical SOURCE_DATE_EPOCH
    gave different layer diff_ids + config digests (layer mtimes were wall-clock, not the epoch; no
    rewrite-timestamp anywhere; images run apt-get update/apk upgrade/pip/npm). A force=true
    republish always moves the tag.
  • release.yml:134,207-269 — the loop-guard's own advice can't work (UNPROVEN by execution;
    GitHub-docs + code).
    After any automated release, main's head is release: vX [skip ci], which
    GitHub creates no CI run for, so "dispatch the workflow manually" hits a CI-SHA poll that times out
    at 2700s.

Genuinely fixed, and verified by execution

INV-21 clean (no --first-parent/-n; truncation now announces itself — tested at CAP=5 over the
real 65-commit range, CAP=300 doesn't truncate); INV-4 clean (no concurrency-group collision across
all 6 workflows); CI ⊇ Release holds today (push paths-ignore byte-identical); #181's own 8
commits carry no BREAKING CHANGE/type!:, so merging it doesn't itself force a major bump.

Cross-PR (for the integration owner)

#181 and #182 now both edit release.yml and conflict — git merge-tree bfe0b12 <#182>
CONFLICT in .github/workflows/release.yml. Don't land them independently; resolve on an
integration branch and run the release job against the merged file, since it is what publishes.

Review Assessment

  • Verdict: BLOCK
  • Audit SHA: bfe0b12
  • Cold Audit Performed: Yes
  • Invariants Verified: INV-24 VIOLATED (F1); INV-6, INV-14, INV-15, INV-16, INV-18, INV-23 VIOLATED; INV-4, INV-21 UPHELD
  • Git & Harness Cleanliness: Clean

Findings & Action Items

  • Major (Blockers):
    • release.yml:907: republish probe swallows all errors as "tag free" → overwrites immutable tag, orphans attestations (INV-24)
  • Minor (Non-blocking): release.yml:118-136 silent-green regex; :460,549,633 vacuous guard; :179-205 staging admitted; :331-338 publish_only stranded; docker-bake.hcl:22-32 reproducibility claim false; :134,207-269 loop-guard advice unworkable
  • Nits: three divergent copies of the recency predicate; CREATED="" ships an empty org.opencontainers.image.created

The "refuse to overwrite an already-published tag" guard probed the registry
with `docker manifest inspect "$REF" >/dev/null 2>&1` and read any non-zero
exit as "manifest absent -> safe to publish". But a non-zero exit is ambiguous:
auth expiry, a GHCR 5xx/429, a DNS/network blip, or a malformed ref all exit
non-zero too. So a transient error made the guard fail OPEN and let bake
overwrite the immutable :VERSION tag, orphaning the digest-bound cosign / SBOM /
SLSA attestations (bonnyr-f5 #181 round 4, INV-24).

Capture the probe output and treat ONLY a definitive registry not-found signal
(no such manifest / manifest unknown / name unknown) as safe to publish. Every
other failure is "unknown" and now fails CLOSED: refuse unless force=true is
passed as an explicit override.

Mutation-tested with a stubbed docker: not-found -> publish proceeds; auth error
and network error -> refuse (rc 1); existing manifest -> refuse (rc 1).

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

@bonnyr-f5 Round-4 BLOCKER (INV-24) fixed in ca36182.

The fail-open, reproduced first

I ran the round-3 step body verbatim against a stubbed docker that simulates a transient auth error (non-zero exit, stderr unauthorized: authentication required — i.e. not a not-found):

=== REPRO: transient auth error, round-3 probe ===
No existing :3.2.0 manifests found — safe to publish.
rc=0  <-- FAIL-OPEN: publish proceeds despite an auth error

Confirmed: docker manifest inspect "$REF" >/dev/null 2>&1 discards the distinction between "manifest absent" and "probe failed", so any 5xx/429, DNS blip, auth expiry, or bad ref takes the else branch and greenlights overwriting the immutable :VERSION tag.

The fix (release.yml)

Capture the probe output and treat only a definitive registry not-found signal as safe; every other non-zero exit is unknown and now fails CLOSED (refuse unless force=true):

if probe="$(docker manifest inspect "$REF" 2>&1)"; then
  EXISTING="${EXISTING}  ${REF}"$'\n'
elif printf '%s' "$probe" | grep -qiE 'no such manifest|manifest unknown|manifest_unknown|name unknown|name_unknown'; then
  : # registry says the manifest/repo genuinely does not exist — safe
else
  UNKNOWN="${UNKNOWN}  ${REF}: ${probe}"$'\n'   # inconclusive -> fail closed
fi

An inconclusive probe ($UNKNOWN non-empty) exits 1 by default, or emits a ::warning:: and proceeds only when force=true is the explicit override.

Mutation-test (stubbed docker)

Scenario Stub Expected Result
not-found (no such manifest) rc 1 publish proceeds rc 0 — "safe to publish"
auth error (unauthorized) rc 1 refuse rc 1 — fail closed
network error (connection refused) rc 1 refuse rc 1 — fail closed
manifest exists rc 0 refuse rc 1 — "already exist"

All four pass.

Verification

  • YAML parses (yaml.safe_load).
  • bash -n on the modified step: OK.
  • actionlint (rhysd/actionlint:latest): the file reports 4 pre-existing SC2129 style warnings on >> $GITHUB_OUTPUT redirects (lines 489/666/774/970) — identical count on the pre-change baseline (verified via git stash); my block adds zero new findings (it writes only to stdout).
  • Commit body contains no [skip ci]/[ci skip]/[skip actions] and no BREAKING CHANGE line; CI triggered on the new head (ca36182), runs > 0.

Non-blocking Major items

The other findings are classified Minor / Non-blocking in your action-item list. They are architectural (staging-ancestor exemption, publish_only recency stranding), comment-accuracy (docker-bake reproducibility claim, loop-guard advice), or need their own reproduction + guard redesign (silent-green release: regex; the extract-breaking-changes.sh || true making the bad-range guard vacuous). I've kept this commit scoped to the sole blocker to keep the release-critical change reviewable; happy to take the non-blocking set in a follow-up so each gets its own reproduction and test.

…randing, reproducibility)

Addresses bonnyr-f5 round-4 major findings on the release workflow:

- Loop guard: exempt only the release bot's own commits, identified by the
  version subject AND the marker we append, not any human 'release:' subject.
  A human 'release: v3.2.0 notes' no longer skips green; it fails loud.
- Manual dispatch: require the dispatched ref to be refs/heads/main itself.
  The old ancestor-of-main exemption admitted staging (0-ahead/1-behind main),
  which would ship an unreviewed tree as :latest.
- publish_only: stop stranding a republish behind a newer tag. It now emits
  its immutable :VERSION tags regardless of recency; only the floating-tag
  move is gated, decided in the publish critical section via ROLLING_TAG.
- docker-bake.hcl / release.yml: correct the false byte-reproducibility claim.
  Pinned CREATED and SOURCE_DATE_EPOCH reduce variance but do not guarantee an
  identical digest; a republish can move it, which is why the existence probe
  refuses by default and gates overwrite behind force.
- CI-status: fail fast on a marker-carrying head instead of polling 2700s,
  since no CI run is ever created for it; point to the real recovery paths.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

@bonnyr-f5 Round-4 major findings addressed in 59ead4d6 (on top of the BLOCKER fix ca36182c). Each fix reproduced first, then mutation-tested. actionlint (with shellcheck) reports the same 2×SC2015 + 4×SC2129 pre-existing style/info notes as the baseline — zero new findings; all 34 run: blocks pass bash -n; docker-bake.hcl still parses.

1. Silent-green loop-guard regex — FIXED

The exempt (green-skip) branch matched ^release: v[0-9]+… on the subject alone, so a human release: v3.2.0 notes was skipped green.

  • Repro (before): release: v1.2.3 [skip ci] → SILENT GREEN; release: v3.2.0 notesSILENT GREEN.
  • Fix: require the trailing skip marker we ourselves append (&& grep -qE '\[skip ci\]$'), so only bot-minted commits are exempt.
  • Mutation (after): bot release: v1.2.3 [skip ci]SKIP; human release: v3.2.0 notesLOUD fail; feat: …run.

2. Staging admitted by ancestor exemption — FIXED

git merge-base --is-ancestor origin/staging origin/maintrue today (staging 0-ahead/1-behind), so a manual dispatch on staging passed.

  • Fix: a manual (non-publish_only) dispatch must have github.ref == refs/heads/main; the ancestor branch is gone. Anything merged is already on main's history, so nothing legitimate is lost.
  • Mutation: dispatch refs/heads/stagingREFUSE; refs/heads/mainALLOW; refs/heads/feat/xREFUSE.

3. publish_only stranding — FIXED

The recency guard hard-failed publish_only once a newer tag existed, so a half-published version's images could never be produced by any path.

  • Fix: preflight recency guard now applies to final/manual only. The publish-critical-section step owns the :latest decision and writes ROLLING_TAG to GITHUB_ENV: latest when newest, "" when behind (push only the immutable :VERSION). The bake step no longer hardcodes ROLLING_TAG so it inherits that.
  • Verified against real bake: ROLLING_TAG="" bake --print api[…:4.0.0]; ROLLING_TAG=latest[…:4.0.0, …:latest].
  • Mutation: preflight publish_only stale → OK (publishes :VERSION); in-job publish_only stale → ROLLING_TAG="" (no :latest move); publish_only newest → latest; final stale → HARDFAIL (unchanged).

4. docker-bake.hcl reproducibility claim — CORRECTED

The comment asserted a same-tag rebuild is "byte-identical … a republish resolves to the SAME digest." That is false (layer mtimes are wall-clock, no rewrite-timestamp, images run apt/apk/pip/npm). The comment now states the truth: pinned CREATED+SOURCE_DATE_EPOCH reduce variance but a republish can move the digest — which is exactly why the refuse-by-default existence probe + force= gate exist. The matching false claim in release.yml's "Resolve release commit" and bake-step comments was corrected the same way. No reproducibility is now asserted that we don't have.

5. Loop-guard advice depended on a nonexistent CI run — FIXED

After an automated release, main's head is release: vX [skip ci], for which GitHub creates no CI run; the old advice "dispatch manually" then hit the CI-SHA poll and only failed after the 2700s timeout.

  • Fix: the CI-status step now detects a skip-marker head up front and fails fast with the real recovery (push a normal commit, or publish_only=<tag>), instead of polling. The loop-guard error message no longer recommends a manual dispatch of a skip-marked head.
  • Mutation: head release: v4.0.0 [skip ci]FAILFAST; head fix: …POLL (unchanged).

6. Vacuous bad-range guard — MERGE-ORDER DEPENDENCY (not fixed here)

scripts/extract-breaking-changes.sh still carries || true at line 33 on this branch, but #181 does not touch that script (its last change is #178 4a52ed45; #181's diff is only release.yml, Makefile, docker-bake.hcl). Per the review, this is #179's file — #179's head makes it fail-closed (ref validation, no || true). I did not duplicate #179's fix here.

Cross-PR reminder

As you noted, #181 and #182 both edit release.yml and conflict — resolve on an integration branch and run the release job against the merged file.

Pushed to fix/release-republish-and-robustness (CI triggered — 2 runs on head 59ead4d6). Not merged.

@bonnyr-f5

Copy link
Copy Markdown
Collaborator

Review: REVISE

Round 5, cold re-audit of 59ead4d against origin/staging 4a52ed4. Auditor had no prior-round
context. Up from round 4's BLOCK.

The round-4 BLOCKER is genuinely closed

The probe no longer reads any error as "tag free". Auth / 429 / 5xx / network / missing-docker
errors are all classified fail-closed, verified by execution. The silent-green class you
reintroduced in round 4 is gone, and the loop-guard closure holds across 13 subjects. Also
re-verified as fresh code and correct: refs/heads/main equality replacing the --is-ancestor
hole, skip-marker fast-fail, BUMP_OUTPUT-before-eval, max-based RC numbering with escaped dots
(5 tag sets), announced CAP truncation (n=5/300/301/305/1000/empty), the global
release-publish-latest group, and ROLLING_TAG="" un-stranding end-to-end through bake --print
(buildx does honour an explicitly empty env var over the HCL default). INV-28 upheld — the bot's own
release: vX [skip ci] exits 0 green.

No BLOCKER this round: every failure mode I could construct goes red, not silently green.

Merges standalone: yes (rc=0; all three scripts it invokes exist at base). Three caveats: it
conflicts with #182 on release.yml; the release notes it publishes carry #179's INV-27 defect
until #179 lands; and its "fail closed on a bad range" claim is only true once #179 lands.

F1 · Major — the existence probe can't distinguish "package absent" from "no permission"

release.yml:985. Proven at the registry protocol level: an existing repo with an absent tag returns
404 MANIFEST_UNKNOWN (recognized, safe), but a nonexistent repo makes the token endpoint
return {"code":"DENIED"} before any manifest request — the docker CLI prints Get "…": denied,
which isn't matched, so it classifies UNKNOWNexit 1.

inputs.force is empty on a push event, so the first release in any namespace where the package
doesn't yet exist hard-fails after the VERSION commit, the tag and the GitHub Release have already
been pushed.
That population is the workflow's own stated design goal (forks/mirrors —
REGISTRY: ghcr.io/${{ github.repository_owner }}), and it will also be any future 8th image.

To be fair on severity: this is fail-closed, not silent-green, and it is not live upstream — all
7 f5devcentral packages exist and are public (verified). The authenticated variant is UNPROVEN
(no GITHUB_TOKEN available to the audit).

F2 · Major — the republish path's granularity doesn't cover the failures it exists for

Bake pushes all 7 targets as one group and signing is a separate step, but the guard refuses if any
of the 7 :VERSION manifests exists. So the most likely recovery — signing failed after all 7 were
pushed, where cosign sign is idempotent and no rebuild is needed — is reachable only via
force=true, which rebuilds all 7 to what this PR's own comments document as possibly-different
digests. The guard drives the operator straight into the flag that does the exact INV-24 harm the
guard exists to prevent. Consider a sign-only recovery path that doesn't rebuild.

F3 · Major — the class is fixed at one call site only

make push-images — documented in dist/README.md:238, docs/DOCKER.md:71 and
publish-signed-images.sh:5 — re-pushes :VERSION + :latest with no existence probe. The new
Makefile:1163-1177 guard checks recency only, and I executed its logic:

highest_tag=v3.1.6  VERSION=3.1.6  -> PROCEEDS -> bake --push :VERSION + :latest
highest_tag=v3.1.6  VERSION=3.1.5  -> BLOCKED (exit 1)
highest_tag=v3.1.6  VERSION=3.1.7  -> PROCEEDS

VERSION == highest tag is exactly the state of a fresh main right after a release. An operator
running the documented command there silently replaces the signed release images under the immutable
tag, and cosign verify then fails for every consumer.

Meanwhile docker-bake.hcl:36-40 asserts the tag "is protected the honest way — the release
workflow's existence probe REFUSES a republish by default". True for the CI driver; false for the
operator path the same repo documents. Not a regression — an incomplete class fix plus a comment
that over-claims.

Minors

  • F4 "stays fail-closed" is false on this ref: extract-breaking-changes.sh:33 ends its
    range query with || true, so a bogus range yields rc=0 and an empty section. The fail-closed
    code exists only in Fix non-deterministic version derivation (SIGPIPE race) — PR #177 Blocker 1 #179. The removal's one real effect is to move a fail-closed point
    downstream of the irreversible git push of the release commit and tag.
  • F5 the recency guard fails open when the tag probe is indeterminate (git fetch … || true
    plus [ -n "$HIGHEST_TAG" ]). The round-4 fail-closed lesson was applied to the registry probe 15
    lines below and not here — same class, adjacent line.
  • F6 four unasserted copies of the 7-image list (probe / summary / signing script / bake group);
    this PR adds one more.
  • F7 CREATED="" emits a spec-invalid empty image.created label instead of omitting it,
    falsifying docs/DOCKER.md:126 — while the correct conditional pattern sits two declarations
    above for ROLLING_TAG.
  • F8 merge-tree 181×182 → content conflict in release.yml under both strategies. Release workflow: republish recovery path + RC/loop-guard robustness — PR #177 Blocker 3 #181's new
    comment also credits the wrong mechanism for Wire gitleaks / shellcheck / script self-test CI gates — PR #177 Major #182's safety.
  • F9 no actionlint/yamllint, and make shellcheck doesn't reach workflow run: blocks — 464
    lines of release-critical shell are verified by nothing in CI. (The audit ran bash -n +
    shellcheck on all 34 blocks manually: clean.)

F3 is the one I'd prioritise — it's the difference between "the immutable tag is protected" being
true and being true only on one of two documented paths.

@bonnyr-f5

Copy link
Copy Markdown
Collaborator

Cross-PR merge-order constraints for this series are now tracked in #192.

Relevant here: this PR conflicts with #182 on .github/workflows/release.yml under both merge strategies. Separately, two of this branch's commits carry a literal [skip ci] in the body, which #182's new commit-lint gate rejects — and if squash-merged anyway, the marker in the squash body suppresses every workflow for that push. Suggested order: land after #182, rebased.

jgruberf5 pushed a commit that referenced this pull request Aug 21, 2026
Round-6 review remediation for the conventional-commit breaking-change
detectors shared by compute_version_bump.sh and extract-breaking-changes.sh.

F1 (blocker) + F2 + F7: rewrite _is_breaking_body (byte-identical in both
scripts) around two anchors. A marker after a blank line is accepted with or
without a colon (keeps the real no-colon paragraph break). A marker in the
trailer block -- preceded by another trailer, or folded directly onto a
conventional-commit subject -- is accepted only with a colon. An is_subject
flag on line 1 arms that path for SCOPED subjects too (fix(core): x), which the
r5 trailer regex could not reach because "(" broke the run before the colon, so
a scoped folded footer silently shipped as a patch (F1). The colon requirement
in the trailer block rejects a prose header (Before:/Note:) followed by
colon-less prose (F2). A widened separator class and an optional bullet accept
double-space and "- " bulleted markers (F7).

_breaking_note uses the same start rule so trigger and note never disagree, and
now stops at the first real git-trailer line (capitalized Word(-Word): key)
instead of stripping only a trailing trailer run -- a trailer block followed by
prose no longer leaks a Co-Authored-By address, while a lowercase-prose colon
continuation (migration:) is kept (F5). Marker lines are excluded from the stop
so a hyphen-form marker is never mistaken for a trailer. A trailing CR is
stripped from the note and subject so CRLF messages do not reach CHANGELOG.md.

F3: document the merge-order dependency at the range guard -- its rc=1 is only
effective once #181 drops the call-site "|| true" in release.yml (not owned by
this PR). F4: reword the byte-identity comments to state the property as an
invariant these two files uphold, with the enforcing CI job landing in #182
rather than asserting a job this tree does not contain. F6: remove the
consistency guard as provably dead code (same range, order, predicate and
first-hit break as the bump loop, so its condition is unsatisfiable).

Adds red-green fixtures for F1/F2/F7/F5. Both self-tests pass under mawk 1.3.4
and gawk 5.3.2; detector copies remain byte-identical; shellcheck -S style
clean; SIGPIPE tail fixture shrunk to ~400 lines while still clearing the 64KB
pipe buffer.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
jgruberf5 pushed a commit that referenced this pull request Aug 21, 2026
…test parity

Round-5 review (bonnyr-f5 #182). Three isolable fixes; cross-PR items left to
merge-order per the review.

BLOCKER-1 -- commit-lint rejected the current tip of staging (a GitHub-composed
squash commit whose machine-authored body carries a line-start bump declaration).
On a push to staging/main the range is before..tip, so that already-merged tip
was scanned, the ci-gate went red, and release.yml refused to release the SHA --
the pipeline stopped releasing. Adds a second machine-identity exemption
(committer "GitHub <noreply@github.com>", single parent) mirroring the existing
release-bot exemption, so the gate never judges already-merged, machine-composed
history. Human commits never carry that committer identity and are still fully
linted in their own PR. Reproduced (before..tip scan rc=1 -> rc=0) and
mutation-tested: human marker in a PR commit still fails; the squash tip is
exempt.

Major-3 -- make script-selftests mirrored only 2 of ci.yml's 4 anti-vacuity
assertions. Adds the missing two (no PASS line -> silenced/renamed guard;
missing END marker -> early exit / deleted marker). Mutation-tested all three
harness-break modes: each is now make-RED, matching CI.

Minor -- .githooks/pre-push scanned the script default (upstream..HEAD) and so
missed non-tip commits on a first push. Now derives the exact pushed range from
git's pre-push stdin protocol (remote..local), falling back to the default for a
brand-new branch or a manual run. Tested all stdin cases.

Cross-PR (documented, not forced): sibling #181's own commits carry markers --
caught in #181's PR; once squash-merged the new exemption stops the gate
re-scanning them (Major-1). The duplicate scripts/sync-version-artifacts.sh and
its sed -i -E are #180's file under merge-order, not duplicated here (Major-2).

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
…y recovery, operator path)

F1 (Major): the immutable-tag existence probe classified by grepping the docker
CLI's error text, which cannot separate "package does not exist yet" (first
release in a fork/mirror namespace — safe) from "no permission" (must fail
closed): both print `denied`. Replace it with scripts/registry-tag-probe.sh,
which classifies each image by the registry's HTTP status (200 exists, 404
absent, 401/403 auth, else transient). Only a definitive 404 is "safe to
publish"; auth/network/rate-limit/5xx fail closed. A broken/partial probe run
now also fails closed instead of reading as "no manifests found".

F2 (Major): add a sign_only recovery mode (workflow_dispatch input, requires
publish_only). It re-runs cosign sign + SBOM + provenance against the
already-pushed :VERSION digests without rebuilding or re-pushing, so the most
likely recovery — signing failed after all images were pushed — no longer forces
the operator into force=true, which rebuilds to possibly-different digests and
moves the immutable tag.

F3 (Major): the operator `make push-images` path had a recency guard but no
existence probe, so VERSION == highest tag (a fresh main right after a release)
silently overwrote the signed release images. It now runs the same
registry-tag-probe.sh and refuses on exists/inconclusive unless FORCE_LATEST=1,
fixing the class at every call site.

F5 (Minor): the publish-critical-section recency guard failed OPEN when the tag
fetch failed — it moved :latest on an unverified guess. Capture the fetch
result; on failure a final/manual release goes red and a publish_only republish
emits :VERSION without moving :latest.

F6 (Minor): single-source the 7-image list in registry-tag-probe.sh; the CI
probe and the publish summary now read it, and a self-test asserts parity with
the docker-bake.hcl default group.

F7 (Minor): docker-bake.hcl stamped an empty, spec-invalid image.created label
when CREATED was unset (a plain make push-images). Emit the label only when
CREATED is set, matching the ROLLING_TAG conditional; docs/DOCKER.md updated.

F4 (cross-PR, documented): softened the release-notes comment that claimed
extract-breaking-changes.sh stays fail-closed. On this ref that script still
ends its range query with `|| true`; the fail-closed fix lands with #179, which
owns the script (this PR does not touch it).

F8/F9 (cross-PR/documented): the release.yml conflict with #182 and the absence
of workflow-shell linting in CI are noted for the reviewer; #182's paths-ignore
characterization in the existing comment is accurate (verified against pr182).

Tests: scripts/tests/registry-tag-probe.test.sh mutation-tests the four required
outcomes (absent->publish, no-permission->refuse, network->refuse,
exists->refuse) plus first-publish and image-list parity.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

@bonnyr-f5 — round-5 addressed in b0e5aa04. Every Major + real Minor reproduced first, then mutation-tested. Genuinely cross-PR items documented, not forced.

F1 (Major) — probe couldn't tell "package absent" from "no permission"

Confirmed your finding: the round-4 classifier grepped the docker CLI's error text, and a nonexistent repo's denied is byte-identical to a no-permission denied. Reproduced:

NONEXISTENT REPO (first pub): unknown   <- fails closed, hard-fails first release
no-permission:                unknown   <- indistinguishable

Fix: scripts/registry-tag-probe.sh classifies by HTTP status, not text — 200 exists, 404 absent (tag OR repo not found → safe), 401/403 auth → fail closed, everything else transient → fail closed. Only a definitive 404 publishes. A broken/partial probe run (non-zero exit, or fewer than the 7 expected lines) now also fails closed instead of reading as "no manifests found — safe". Mutation-tested (scripts/tests/registry-tag-probe.test.sh, curl stubbed):

absent -> publish · nonexistent-repo -> publish · no-permission(token) -> refuse
no-permission(manifest) -> refuse · network -> refuse · rate-limit -> refuse · exists -> refuse

F2 (Major) — republish granularity didn't cover sign-after-push failure

Added a sign_only recovery input (requires publish_only=<tag>). It re-runs cosign sign + SBOM + provenance against the already-pushed :VERSION digests, skipping bake entirely — no rebuild, no re-push, the immutable tag never moves. That's the sign-only path you asked for, so the "signing failed after all 7 pushed" case no longer routes the operator into force=true (which rebuilds to possibly-different digests — the exact INV-24 harm). The overwrite guard is also skipped in this mode since existing manifests are expected.

F3 (Major) — class fixed at one call site only

make push-images had the recency guard but no existence probe, so VERSION == highest tag (fresh main after a release) proceeded and overwrote the signed images. It now calls the same registry-tag-probe.sh and refuses on exists/inconclusive unless FORCE_LATEST=1. Mutation-tested:

exists (VERSION==highest) -> refuse · absent -> proceed · unknown -> refuse · exists+FORCE_LATEST=1 -> override

docker-bake.hcl's comment now states both paths are protected by the shared probe.

Minors

  • F5 — recency guard failed OPEN on a tag-fetch failure (moved :latest on a guess). Now captures the fetch result: final/manual → RED; publish_only → emits :VERSION, does not move :latest. Mutation-tested (old fail-open reproduced, new fail-closed proven, first-release still works).
  • F6 — 7-image list single-sourced in the probe script; CI probe + publish summary read it; the self-test asserts parity with the docker-bake.hcl default group.
  • F7docker-bake.hcl now omits image.created when CREATED is unset instead of emitting an empty spec-invalid label (bake --print verified: key absent with CREATED unset, present when set); docs/DOCKER.md corrected.

Cross-PR (documented, not forced)

Verification

YAML parses; all 34 run: blocks bash -n clean; actionlint -shellcheck shows no new findings (the 4 SC2129 style hits pre-exist on staging); shellcheck --severity=warning clean on the new script; the Makefile recipe reconstructs and bash -n clean; bake --print validates the HCL.

jgruberf5 pushed a commit that referenced this pull request Aug 21, 2026
Consolidated landing of seven interdependent PRs whose shared credential and
release/CI surfaces prevented merging in any order (see issue #192's conflict
matrix). Merged in dependency order 179, 180, 181, 188, 186, 183, 182; the
#186/#188 credential surface was reconciled once (single reserved-name guard;
provenance + migrations + stale-disable combined with rotation + backend MCP
wiring + threadpool). Squashed to one commit; per-PR history retained on the
seven archived branches.

Validated on the merged tree: ruff + mypy clean; 172 auth/credential/startup/
migration tests pass; single alembic head v2_155; openapi + frontend types fresh;
helm lint/template and docker compose config green on all modes; version and
detector self-tests green; commit-message lint clean.

Closes #192.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
@jgruberf5

Copy link
Copy Markdown
Collaborator Author

Superseded by the consolidated integration PR #193 (branch pr177-integration), which merges this and the other six #177 follow-up PRs in the dependency order from #192 and passes full CI (CI Gate green). Per #192, these seven share the credential and release/CI surfaces and could not merge in arbitrary order, so they land together via #193.

Closing unmerged, not abandoning: the branch is retained and all review history stays on this page for reference. See #193 for the integrated, validated result and #192 for the cross-PR conflict analysis.

@jgruberf5 jgruberf5 closed this Aug 21, 2026
bonnyr-f5 pushed a commit that referenced this pull request Aug 24, 2026
)

* Integrate the #177 follow-up stack (#179 #180 #181 #182 #183 #186 #188)

Consolidated landing of seven interdependent PRs whose shared credential and
release/CI surfaces prevented merging in any order (see issue #192's conflict
matrix). Merged in dependency order 179, 180, 181, 188, 186, 183, 182; the
#186/#188 credential surface was reconciled once (single reserved-name guard;
provenance + migrations + stale-disable combined with rotation + backend MCP
wiring + threadpool). Squashed to one commit; per-PR history retained on the
seven archived branches.

Validated on the merged tree: ruff + mypy clean; 172 auth/credential/startup/
migration tests pass; single alembic head v2_155; openapi + frontend types fresh;
helm lint/template and docker compose config green on all modes; version and
detector self-tests green; commit-message lint clean.

Closes #192.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193): reconcile the merged unset-MCP-password model and harden the credential seed

Addresses mwiget's three CHANGES_REQUESTED blockers on the #177 integration PR
(#193), plus follow-up findings from a max-effort review of the same credential
surface. The merge chose #188's "unset MCP_SERVICE_PASSWORD -> disable" over
#186's "unset -> generate", so the generate/rotate-on-unset code was left
unreachable but still documented, and the release-notes footer was missing.

BLOCKING 2 (core/config.py): the MCP_SERVICE_PASSWORD comment claimed
ensure_service_user "generates a random secret and surfaces it once" when unset,
contradicting the merged behaviour. Rewrote it to state the truth: when unset (or
a known default) seed_auth_step calls disable_stale_service_user, leaving the mcp
account disabled/unavailable until an operator configures a real password; a
published default is refused and rotated out; the backend receives
MCP_SERVICE_PASSWORD on every deploy mode and reconciles to it when set. Also
removed the duplicate #186 block that sat above the wrong field.

BLOCKING 3 (services/auth_service.py): removed the unreachable usable_password is
None branches from ensure_service_user (the generate-on-create and
rotate-on-unset paths). seed_auth_step only calls it under the _mcp_pw_usable
gate, so password is never None/default in production. ensure_service_user now
requires a usable password and only creates/reconciles with it (failing closed
and loudly if handed an unusable one); the unset case is owned entirely by
disable_stale_service_user. Dropped the now-dead _log_generated_service_password
helper and the service-account token_urlsafe/_persist_generated_password calls
(_persist_generated_password is still used by the admin seed). Kept the
reserved-name guard, the provenance check, the adopt-a-published-default
remediation, and disable_stale_service_user fully intact. Updated the affected
unit tests (published-default/None now refused; added a reachable
adopt-and-reconcile test; stale-row setup builds the legacy row directly) and
fixed scripts/mcp_live_smoke.py, which pointed operators at
/app/keys/initial_mcp_password, a file no reachable path writes.

CR-1 (services/auth_service.py, seed_admin_user): fixed a concurrent-first-boot
admin lockout. With DEFAULT_ADMIN_PASSWORD unset and 2+ api replicas, both
generated different passwords and the loser overwrote the keys file while its
INSERT rolled back, so the file and the committed row disagreed. The fresh seed
now creates+flushes first (the loser's INSERT raises IntegrityError -> rollback,
no file write) and persists the keys file only after winning but before commit,
so the file can only ever hold the committed row's password. Added a
losing-replica test.

CR-5 (services/auth_service.py, _persist_generated_password): os.open's 0600 mode
only applies on create, so a pre-existing 0644 file was truncated in place and
kept 0644, writing the secret world-readable. Added os.fchmod(fd, 0o600) and a
test that a pre-existing 0644 file is tightened to 0600.

CR-2 (routes/k8s_websocket.py, dpus_websocket.py, benchmarks.py): the WS auth
validators called the blocking sync token_user_state directly on the event loop.
Moved it off the loop via run_in_threadpool, matching core/auth_middleware.py.

Validation: make lint-backend clean; mypy core/ schemas/ unchanged; auth
(57) + ws/benchmark/startup (79) suites pass; alembic heads single v2_155;
helm lint/template OK and --set secrets.mcpPassword=changeme fails the render;
docker compose config OK on all modes; extract-breaking-changes and
compute_version_bump self-tests pass; lint-commit-markers clean.

BREAKING CHANGE: MCP_SERVICE_PASSWORD is now required — the backend SystemExits under ENVIRONMENT=staging|production when it is unset or a known default; the shipped admin/changeme default is removed and rotated out on upgrade; the dist bundle renames MCP_USERNAME/MCP_PASSWORD to MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD; and the Helm chart ships mcpUsername=mcp with a generated mcpPassword instead of admin/changeme.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193): close bonnyr-f5's credential/backend/helm/dist/docs blockers

Follow-up to the #177 integration on pr177-integration, addressing the
CREDENTIAL/BACKEND/HELM/DIST/DOCS half of bonnyr-f5's BLOCK on PR #193.

B1 (SECURITY): ensure_service_user no longer adopts any human account whose
password is a known default. The adoption exception is now scoped to v2_155's
exact backfill fingerprint (username=='mcp' AND email=='mcp@bnk-forge.local'),
matching the migration's own conservative rule, and must_change_password is no
longer cleared on an adopted row. Adds tests proving a human operator/changeme
row (and a wrong-email mcp row) is REFUSED, not taken over.

B2/B2b: all five compose files (root + dist docker-compose{,.local}.yml and the
IBM embedded compose) honor legacy MCP_USERNAME/MCP_PASSWORD as aliases for
MCP_SERVICE_USERNAME/MCP_SERVICE_PASSWORD on both backend and mcp env, so an
existing customer .env keeps working after upgrade. Docs (dist/README.md,
dist/.env.example, user-pack/install-guide.html) document MCP_SERVICE_PASSWORD as
canonical with MCP_PASSWORD honored as a legacy alias.

B3: ENVIRONMENT is plumbed to the backend in every compose file, so an operator
who sets ENVIRONMENT=staging|production actually reaches config.py's MCP
fail-fast. Helm already routes ENVIRONMENT=production onto api/worker/beat.

M1: the unset-MCP behavior stays "disabled" (#188 over #186); added an explicit
deliberate-consolidation comment at the decision point.

M2: disable_stale_service_user skips the about-to-be-reconciled row and the
"no usable MCP_SERVICE_PASSWORD" warning is conditional on a configured password,
so a correctly-configured install no longer logs a false warning or commits an
inactive MCP window on every boot.

M7: the Helm mcp deployment and the IBM installer's mcp service now run the exec
auth-probe healthcheck (python -m bnk_forge_mcp.healthcheck) instead of a bare
tcpSocket probe.

M8: the chart fails the render for a reserved mcpUsername (admin), mirroring the
mcpPassword guard, and NOTES.txt/values.yaml call it out.

Minors: deterministic checksum/secret via a shared helper (stable across renders,
identical across api/worker/beat/mcp); vestigial _persist_generated_password
filename docstring; false "backend generates its own secret" rationale corrected
in compose/helpers/ibm; mcp_live_smoke.py #186/#188 attribution; e2e/config.py
changeme default note; .env.example :58/:73/:94 fixes; unified BNK_FORGE_VERSION
to latest across dist.

Validation: ruff clean; typecheck-backend (core/ schemas/) Success 38 files;
199 auth/credential/startup/ws/migration tests pass incl. new B1 tests; helm lint
+ template stable checksums, --set secrets.mcpUsername=admin and
secrets.mcpPassword=changeme both FAIL; docker compose config on all five modes
shows the backend receiving MCP_SERVICE_PASSWORD (via either alias) and ENVIRONMENT.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193): close bonnyr-f5 CI/RELEASE/SCRIPTS blockers, majors, and minors

Blockers:
- B4 registry-tag-probe.sh: replace GNU-only BRE alternation (\|) with
  portable `sed -nE (access_token|token)` so the token parse works on BSD/
  macOS sed; on BSD the empty token classified every image `unknown` and
  routed the operator into FORCE_LATEST=1, overwriting immutable :VERSION
  manifests (INV-24). Reproduced under `sed --posix`, fix proven to parse T2.
- B5 registry-tag-probe.test.sh had no caller and was red on BSD. Both the
  Makefile script-selftests target and ci.yml's script-selftests job now
  enumerate and run every scripts/tests/*.test.sh, failing on an empty
  enumeration or any non-zero rc.
- B6 lint-commit-markers.sh: replace the spoofable committer-identity
  exemption (GitHub <noreply@github.com> + single parent) with an
  unspoofable "already reachable from origin/main|origin/staging" check;
  lint the PR title (PR_TITLE via env) on pull_request events; split the
  rules so machine/already-merged is exempt for the marker rule but the
  spurious-major rule always applies.

Majors:
- M3 release.yml overwrite guard: derive the vacuity floor from an
  independent source (docker-bake.hcl default group, sourced from the
  workflow-ref tooling) and assert the probe's exit status before trusting
  its output, so an unavailable probe fails closed instead of "safe".
- M4 (INV-31): generate release notes and run the registry existence-probe
  BEFORE the irreversible push in release-final/release-manual (new shared
  scripts/registry-overwrite-guard.sh); release-publish keeps its own
  in-critical-section re-check.
- M5 make script-selftests now runs the INV-15 detector-parity diff
  (extracted to scripts/tests/detector-parity.test.sh) so local == CI.
- M6 extractor self-test runs unconditionally with anti-vacuity assertions
  (ok lines + END marker), no longer gated on grepping its own --self-test.

Minors: stale cross-PR comments in release.yml and extract-breaking-changes.sh;
removed the duplicate Makefile version-check target; `git add dist/VERSION`
no longer swallows failures; first-ever-release notes range fixed; CHANGELOG
insertion asserts a non-no-op before committing; refreshed .trivyignore
CVE-2026-7598 review deadline; removed e2e-tests.yml dead `|| true`; documented
the new Docker dependency in the pre-push hook.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r2): close credential/backend/helm/dist blockers — drop the username alias, make ENVIRONMENT=production satisfiable, and fix the mcp liveness probe

bonnyr-f5 round-2 BLOCK, credential/backend/helm/dist/docs half.

B-1 (INV-12): the compose files aliased the SERVICE username
(MCP_SERVICE_USERNAME:-${MCP_USERNAME:-mcp}); a legacy .env with MCP_USERNAME=admin
resolved it to `admin`, and against the guardless image `latest` still points at,
the old ensure_service_user rewrites the human admin row to `changeme` every boot.
Drop the username alias across all five compose files + the ibm embedded compose
(keep the harmless password alias), and default BNK_FORGE_VERSION to 4.0.0 (the
release this tree becomes, first image with the guards) instead of `latest`, so a
compose file can never hand the new credential contract to a pre-guard image.

B-2: ENVIRONMENT=production reaches validate_production, which also gates on
JWT_SECRET_KEY / ENCRYPTION_KEY / ALLOWED_ORIGINS — none of which were deliverable
from a compose install, so the switch bricked the backend. Plumb all three into
every x-backend-env anchor (four compose files + ibm) and document them in the
env examples; treat an empty ("" from ${VAR:-}) key as unset in config.py so the
plumbed empty default auto-generates rather than passing as a real empty key.
_persist_or_load_key now flags only keys WE generated as auto_generated (sidecar
.autogen marker), so an operator-provisioned key on the volume validates while a
fresh prod boot still fail-fasts permanently.

M-4: mcp liveness returned non-zero when the BACKEND was unreachable, so k8s
restarted the pod for a dependency outage. Move the auth-probe to readiness only;
liveness is tcpSocket. Fix mcp-server/README + mcp_live_smoke hints to name only
the vars each process actually reads (container: BNK_FORGE_*, backend: MCP_SERVICE_*).

Minors: refuse a known-default DEFAULT_ADMIN_PASSWORD on fresh seed + helm
adminPassword fail-guard; guard secrets.yaml mcpUsername nil with kindIs "invalid";
make the Python reserved-name check case-insensitive/trim to match Helm; neutralise
the hash when disabling a stale service account; correct the benchmarks.py
JWT-gate comment; surface an empty MCP_SERVICE_PASSWORD in install.sh; fix the
.env.example "No .env file is needed!" contradiction.

Tests: config B-2 satisfiability + provenance-marker tests; seed_auth_step against
an admin-still-holds-changeme upgrade DB; case-insensitive reserved-name and
disable-hash-neutralisation cases. ruff clean, mypy clean, 4831 unit pass, helm
lint/template green, docker compose config verified on all modes.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193): close r2 CI/RELEASE blockers B-3, M-1..M-3 + scripts/release minors

B-3 (commit-lint exemptions): key the already-merged exemption on the range
BASE (github.event.before), not the post-push tip, so a new skip-CI marker on a
push to main/staging is caught while a genuinely-already-merged base commit stays
exempt; replace the self-settable `^release: ` subject exemption with
release.yml's own version+trailing-skip fingerprint.

M-1 (spurious-major rule): redefine rule 2 as the exact complement of the
detectors, sourced from the shared predicate, so it flags only a marker the
detectors would MISS (never dash-bullet, markdown-bold or indented shapes);
give it the same already-merged exemption; and lint inputs.release_notes through
the script before it becomes a release commit/tag.

M-2 (overwrite guard floor): derive the vacuity floor from `docker buildx bake
--print default | jq '.group.default.targets | length'`, scoped to the default
group, so a second bake group no longer wedges the release; separate bake-file
parse failures from registry-unreachable in the messaging. Single-source the
policy: release-publish and make push-images now call the one guard.

M-3 (portability): drop bash-4 mapfile from the probe test; rebuild the compute
self-test newline expansion with awk to avoid the bash-3.2 parameter-expansion
cliff, so make script-selftests runs under stock macOS bash 3.2.

Detector single-sourced into scripts/lib/breaking-change-detect.sh (compute,
extract, lint all source it); detector-parity test asserts the wiring; added
mutation tests for the lint rules and the overwrite guard.

Minors: fix version-consistency misdiagnosis of a column-0 YAML comment; correct
the compute/extract parity docstrings and the docker-bake four-push-paths note;
wire artifact-network-self-test into ci-gates; make the pre-push hook migration
message reachable under set -e; omit the false provenance buildStartedOn; filter
the release CI-status poll by commit SHA.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193): seed the re-enable-guard test's default-hash row directly

The round-2 disable_stale_service_user hash-scrub (bonnyr-f5 #193 minor) collided
with the re-enable guard's own regression test: _seed_disabled_default_mcp built
its "disabled while holding the published default" state BY CALLING
disable_stale, which now scrubs the hash -- so holds_known_default_password was
false and the PUT re-enable was allowed (200) instead of refused (400).

The guard defends a row taken inactive by a path that LEAVES the credential
intact (a manual operator PUT), not one disable_stale scrubbed. Seed that state
directly (set is_active=False on the default-hash row) so the guard's real
scenario is exercised; assert the default hash survives the seed. Corrected the
now-stale guard comment in routes/auth.py that still claimed disable "only flips
is_active". Neutralisation and its asserting tests are unchanged.

Verified: TestServiceAccountReEnableGuard 2/2 pass; the three affected auth files
(test_startup_seed_auth, component/test_auth_service, integration/test_routes_auth)
97/97 pass; ruff clean.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r3): fail-close key provenance (B-2) + credential/auth minors

Own the round-3 CREDENTIAL/AUTH findings.

B-2 (BLOCKER): _persist_or_load_key classified a marker-less key file as
operator-provided, so every upgrade keys volume (key present, no marker) let
SEC-006's fail-fast pass OPEN on an auto-generated JWT/ENCRYPTION secret in
production. Invert the sentinel: a marker-less key now classifies AUTO-GENERATED
(fail closed); an operator asserts provenance with an explicit <filename>.operator
opt-out marker. No marker is written on generation, which also removes the second
trigger (a partial marker write can no longer downgrade provenance). Regression
tests seed the PREVIOUS-RELEASE on-disk shape and assert validate_production
raises under ENVIRONMENT=production.

Minors:
- Write the key via os.open(0o600)+fchmod so the secret is never briefly 0644.
- Single-source the MCP known-default denylist: delete the local tuple in
  auth_service and use core.config.MCP_KNOWN_DEFAULT_PASSWORDS (comment notes the
  helm copy is deploy-owned).
- Correct holds_known_default_password docstring (disable_stale now scrubs the
  hash; this guard covers the other disable paths).
- Reconcile ENCRYPTION_KEY docs with reality: the keys-file is the source of
  truth for at-rest crypto; the env var only drives the production gate
  (encryption.py comment + .env.example).
- Clarify the v2_155 custom-username remedy in disable_stale docstring.

Test-gaps:
- Normalise the service username (trim/casefold) at the reconcile lookup and the
  disable skip filter, so " mcp "/"MCP" reconciles the existing mcp row instead of
  minting a second service account and disabling the live one.
- Honest seed_admin_user log/logic under DEFAULT_ADMIN_MUST_CHANGE=false (no more
  "must change on first login" when no gate was applied).
- disable_stale_service_user(skip_username=...) leaves the live row wholly
  untouched (no inactive window), variant included.
- db.commit() failure after the keys file is written leaves a retriable state
  (published default still authenticates, orphan file password does not).

All owned-suite tests pass; ruff clean. Each fix reproduced then mutation-tested.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r3): close the release/CI blocker + major + every release/CI minor

M-6 (blocker): commit-lint no longer reds unamendable merge history.
- rule 2 now flags ONLY a mis-anchored DECLARATIVE marker (the colon form);
  a colonless marker-shaped PROSE line the detectors treat as inert (an
  already-merged body such as "- <MARKER> footer in the body ...") is no
  longer flagged, so the push-to-main range (before..head, which INCLUDES the
  PR merge-base) goes green without a history rewrite. Detection of a real
  mis-anchored marker is unchanged.
- deleted the already-merged exemption as dead code: base..head excludes the
  base by construction, so no scanned commit can ever be an ancestor of it.
  Removed the BEFORE derivation, _already_merged, tests B3.2/M1.5, and the
  ~20-line header claim. The release-bot exemption stays.
- rule 2 now scans the whole body via _under_detected_markers and reports
  EVERY mis-anchored marker, not just the first.

M-7 (major): secret-scan no longer false-fails a delete-only range. A
delete-only commit has rev-list count > 0 but gitleaks scans 0 (it scans
added content), so the count-based backstop is replaced by a range-
resolvability check plus gitleaks' exit status.

Release/CI minors:
- release-bot fingerprint single-sourced to scripts/lib/is-release-bot-subject.sh;
  release.yml's inline copy byte-locked by a parity self-test; dropped the
  false unforgeability claim and documented the residual honestly.
- registry-overwrite-guard: added a fail-closed default arm for an
  unrecognised/empty probe status (+ malformed/empty test scenarios).
- Makefile push-images: FORCE_LATEST now overrides ONLY the recency guard; a
  new FORCE_OVERWRITE overrides ONLY the immutable-tag guard; fixed the
  missing-jq remediation text.
- registry-tag-probe: the network arm now matches the real doubled "000000"
  curl-failure shape (was dead code); test fixture reproduces it.
- INV-15: single-sourced the marker regex (one canonical value + a
  detector-parity assertion that every embedded copy is byte-identical).
- release.yml Publish summary counts what buildx actually pushed (bake
  --metadata-file), not the static target list.
- registry-tag-probe test enumerates the bake DEFAULT group (scoped), matching
  the guard's enumeration.
- added scripts/tests/secret-scan.test.sh (fake-docker mutation suite).

release.yml: added a post-push step running scripts/verify-image-pins.sh so a
release cannot complete while shipping an unpublished image pin (script owned
by the deploy agent; referenced by path from .release-tooling).

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r3): single-source every deploy version pin + close deploy majors/minors

B-1/B-1b: dist/docker-compose{,.local}.yml, dist/.env.example and
ibm_cloud_bnk_forge.sh hard-pinned 4.0.0, a tag that has never been published,
while Helm pinned 3.1.6 -- two shipped paths naming two versions, one of which
does not exist. Bring all of them under sync-version-artifacts.sh (new PIN/
DISTENV readers+writers, --check, --list) so every pin derives from VERSION
(3.1.6, which exists) and the release re-stamps them atomically via the existing
--write $NEW; drop the dist/ disclaimer. Add scripts/verify-image-pins.sh (+
selftest) that resolves every shipped compose image: pin against the registry and
fails on manifest unknown, wired post-push in the release job.

M-1..M-5: reword NOTES/compose/chart comments that asserted post-guard behaviour
as already-true on the pre-guard pinned image (they land with the guard-carrying
release); NOTES now leads with the required ALLOWED_ORIGINS override; dist/README
and the install guide stop recommending latest/3.1.6 and the keys-file cat the
pinned image does not write; install.sh strips quotes and rejects the known-
default MCP passwords so the "MCP not active" warning fires instead of a green
lie; add deploy-version-lockstep + helm-known-defaults-lockstep selftests.

Deploy minors: MCP_USERNAME "do not set" made consistent across docs/Makefile;
chart ENCRYPTION_KEY now emits a valid Fernet key; DEFAULT_ADMIN_* added to the
ibm P3 backend env; secrets.mcpUsername defaults to "mcp" on null; ibm mapfile ->
portable while-read.

Verified: sync --check exit 0; --write round-trip moves every pin and restores;
helm lint/template clean (default + origin override); script selftests green;
bash -n + shellcheck clean.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r4): unify the at-rest encryption key (B-3), fail-close key marker (M-1), row-matches-client (M-2) + credential minors

B-3: validate_production gated ENCRYPTION_KEY while a second, independent generator
in core/encryption.py produced the real at-rest Fernet key unchecked — setting
ENCRYPTION_KEY (the documented remedy: token_hex(16), not even a Fernet key) turned
the gate green while encryption auto-generated a different key. Unify: one key file
(_encryption_key_path == core.encryption.ENCRYPTION_KEY_FILE), one generator. When
ENCRYPTION_KEY is set it is VALIDATED as a real Fernet key (fail clearly if not),
written to that file with a .operator marker, and consumed by core.encryption and
services.backup_service; the provenance flag reflects the value that actually
protects data. Never clobber an operator-marked key on a mismatch. config.py:319
and .env.example now print the Fernet recipe.

M-1: _persist_or_load_key required a regular-FILE marker (os.path.isfile, not
os.path.exists — a directory no longer counts) and treats "marker present, key file
absent" as a provisioning error: generate but do NOT persist, so the stale-marker
rotation gesture can never heal into auto=False on the next boot.

M-2 (regression this PR introduced): ensure_service_user normalised the username
before lookup/create, so MCP_SERVICE_USERNAME=MCP seeded 'mcp' while the client
sends the raw 'MCP' and authenticate_user matched exactly -> login denied. Create/
reconcile under the RAW value (what the client sends); the disable_stale skip keys
on the same raw value; only the reserved-name guard normalises. Fixed the false
"Matches the Helm chart lower|trim" docstring.

Credential minors: non-vacuous localhost-CORS test (valid MCP password so only the
CORS branch fails) + wildcard is now an exact origin-list entry, not a substring;
new DPU-websocket must-change/unresolvable-user tests mirroring the k8s twin;
middleware unresolvable-JWT-subject-refused and exact-vs-suffix exempt-path tests;
corrected the denylist copy count (4th copy in dist/install.sh); corrected v2_155's
rationale (v2_154 is new in this diff, not "already shipped"); documented why
ensure_service_user's adoption branch is kept.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r4): close deploy-surface blocker + majors (B1, M7-M12) and deploy minors

B-1: a fresh install of the pinned 3.1.6 bundle seeded an admin nobody could log
in as. `${DEFAULT_ADMIN_PASSWORD:-}` delivers the var present-and-empty; 3.1.6's
`DEFAULT_ADMIN_PASSWORD: str = "changeme"` is then overridden by "" and the login
schema rejects "" (422). The prescribed `${VAR}` form does NOT omit on this compose
(map interpolation still renders ""); the working omit-when-unset form is a map
entry with NO value (passthrough / `docker run -e KEY` semantics). Converted
DEFAULT_ADMIN_PASSWORD to passthrough across all four compose paths (dist base+local,
root base+local, IBM embedded). Swept the class: JWT_SECRET_KEY/ENCRYPTION_KEY also
converted (3.1.6 uses `if KEY is None`, so "" is used literally, not auto-generated);
MCP_SERVICE_PASSWORD kept as `${...:-...}` because omitting it restores 3.1.6's known
default "mcp-service-changeme". Added a commented DEFAULT_ADMIN_PASSWORD block to
dist/.env.example. Verified via `docker compose config` + real container env both
directions (unset -> omitted; set in .env -> forwarded).

M-7: chart checksum/secret did not change on mcp-password rotation (3 renders, 3
generated passwords, one identical checksum). Made all generate/rotate fallbacks
deterministic (deriveSecret, release-seeded) so the Secret is stable across renders
and includes, and hash the RENDERED Secret so the annotation tracks every resolved
value. Now stable across renders, identical across the 4 deployments, and it flips
when any resolved value changes.

M-8: dist/install.sh credential guard failed open on `changeme ` (whitespace). Trim
leading/trailing whitespace around the quote-strip before the known-default compare.

M-9: removed newly-added forward-dated 4.0.0 prose (install-guide.html x2, DOCKER.md).

M-10: default helm install crashlooped (production + localhost). Added a render-time
guard mirroring backend validate_production (fail on wildcard under staging/production,
localhost under production); defaulted ALLOWED_ORIGINS to empty so the bare render
boots (empty is neither wildcard nor localhost). `helm lint` and bare `helm template`
stay green; the guard fires with a clear message on a real fatal posture.

M-11: portable in-place sed in the IBM installer (`sed -i.bak … && rm`), both sites.

M-12: dist/ no longer ships published default DB/redis creds on host networking.
install.sh generates strong POSTGRES_PASSWORD/REDIS_PASSWORD on the fresh .env (like
Helm/IBM); .env.example ships them empty; a pre-existing default triggers a warning.

Deploy minors: extended deploy-version-lockstep.test.sh to the bnk-operator chart
(appVersion + image.tag) and dist/VERSION; brought dist/VERSION under
sync-version-artifacts.sh (--check/--list/--write green); reworded install.sh's MCP
UNHEALTHY assertion to match what the pinned image actually reports; added
scripts/tests/ibm-compose-drift.test.sh freezing the credential/hardening env so the
IBM embedded compose and dist/docker-compose.yml cannot silently diverge.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r4): make the pin verifier reachable pre-push, de-vacuum the parity/self-test harnesses, and close the release minors

B-2 (blocker): scripts/verify-image-pins.sh could never pass where the release job
runs it — from the 4-file sparse .release-tooling checkout that holds no compose
file (ROOT resolved there), with REGISTRY/VERSION handed in as env vars the script
reads only as flags, and the whole step wired AFTER the tag/Release/push/signing.
Fixes, end to end:
  - add a consistency mode (--expect-version) that asserts every shipped first-party
    pin already renders to $NEW without a registry probe, and run it as the PRIMARY
    PRE-push gate in release-final and release-manual (before anything irreversible);
  - fix the post-push existence step to pass REGISTRY/VERSION as FLAGS and the compose
    files explicitly by --file (they live at the tag checkout at the workspace root),
    keeping it as a secondary confirmation;
  - widen the default file set to include the IBM Cloud installer's embedded compose;
  - add a dryrun-release-tooling job that rebuilds the exact publish-job layout and
    exercises both invocations against a fake probe, and gate release-publish on it,
    so a step that cannot execute is caught before it is wired ahead of a signature.
  The sibling registry-tag-probe.sh / registry-overwrite-guard.sh read nothing
  $ROOT-relative outside the sparse set, so they are unaffected.

M-3: detector-parity.test.sh enumerated the marker copies with the very token that
drifts, so a copy that drifted in the token vanished from enumeration (drifting
:96-97 dropped the count 5->3 yet stayed green). Enumerate by position/count instead:
an exact per-file canonical count plus a stable-anchor site scan that flags any
drifted site even under a compensating add.

M-4: the filesystem self-test loop checked only a non-empty enumeration and each
file's exit 0, so a test gutted to a no-op passed and deleting 7 of 8 stayed green.
It now requires each file to emit PASS lines, no FAIL line, and an ALL PASS terminal
marker, plus a count floor derived from git's tracked *.test.sh set (detector-parity
was conformed to that output convention).

M-5: release-rc created and pushed the RC tag before the fail-closed notes step;
the tag is now created locally, notes generated, then the tag pushed.

M-6: added mutation-tested coverage for this PR's four previously-uncovered lint
fixes (the PR-title lint, the pending-message lint, the RANGE fail-closed branch,
and the skip-checks trailer rule).

LEAD: the anti-vacuity staging floor derived the count from a stale literal while
--list grew to 8 paths; both sites now derive it from --list and require every listed
path to stage, and the stale comments are corrected.

Release minors: scope the release-bot commit-lint exemption to the range tip (a
forged release subject buried mid-range is no longer exempt) and add a REACHABLE
published-history exemption anchored to the last release tag so a mis-anchored marker
in unamendable history cannot red the release; add fixtures for the untested registry
probe/guard arms (5xx, unexpected code, unknown status, bake-parse failure);
ancestry-filter the LAST_FINAL tag queries so a tag on another branch cannot skew the
notes range; reconcile the empty-RANGE handling (commit-lint now fails closed on an
explicit empty RANGE, ci.yml leaves it unset); give the pre-push hook a clear message
and a fetch fallback when the remote tip is absent locally; derive the cosign
verify-identity org from REGISTRY instead of hardcoding it.

Flagged: gate Makefile push-customer-build through registry-overwrite-guard.sh, the
last documented push path that was still unguarded.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r4): keep M-7 checksum-tracks-rotation WITHOUT predictable secrets

The r4 deploy fix closed M-7 (checksum did not move on a secret rotation, so pods
never rolled) by making the generated fallbacks deterministic -- deriveSecret =
sha256(Release.Name|Namespace|fullname|purpose). Every input is public (they appear
in resource labels and the chart source), so that made the JWT signing key, the
at-rest Fernet key and the admin/mcp passwords COMPUTABLE by anyone who can read a
label -- a forge-any-token / decrypt-all-secrets exposure, strictly worse than the
cosmetic churn it fixed.

Revert generation to randAlphaNum (unpredictable) and instead hash the DETERMINISTIC
inputs that determine the Secret -- values.secrets, the persisted .data (reused via
lookup), plus a per-credential "rotating-from-default" marker for admin/mcp whose
persisted value is a known published default. That tracks every rotation (operator
edit, persisted-value change, rotate-away-from-default) so the pods roll, is stable
across renders including a bare no-cluster `helm template` (the hashed inputs carry
no randomness), and never derives a secret from public identity. deriveSecret removed.

New scripts/tests/helm-secret-checksum.test.sh locks all three: stable-across-renders,
changes-on-rotation, and generated-value-is-random -- so the determinism cannot return.

Verified: helm lint 0-failed; bare + override template OK; the M-10 render guard still
fires on production+localhost; the new selftest ALL PASS.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r4): self-review — close the encryption-key data-loss BLOCKER + deploy minors

A cold adversarial self-review (three auditors mirroring the reviewer's method) of
the r4 changeset found a data-loss BLOCKER we introduced this round, plus deploy
minors. Fixing before it ships.

B-3 (BLOCKER, data loss): the r4 encryption-key unification made ENCRYPTION_KEY env
OVERWRITE an existing at-rest key file on boot. Traced only to the "operator freshly
sets the key" consumer, never to (a) backup_service restore, which writes the backup's
key to that file, or (b) the r3->r4 upgrade population, whose data was encrypted under
a marker-less auto-gen FILE key (r3 core.encryption used the file regardless of env).
The first r4 boot clobbered those -> restored/existing data undecryptable (or bricked
the boot on a marker mismatch). A new test even LOCKED the clobber with a false premise.
Fix: the at-rest key FILE is the single source of truth. ENCRYPTION_KEY only SEEDS the
file when it is ABSENT and never overwrites an existing one; the gate reads the FILE's
.operator provenance. backup restore now drops the .operator marker so a restored key
passes the gate without a clobber. Rewrote the clobber-locking tests to lock the
no-clobber invariant; added upgrade-shape, production-fail-without-data-loss, and
restore-marker tests.

M-7 (deploy self-review): removed the rotation-marker from secretsChecksum — the
auditor proved it redundant (the same .data change already moves the digest; deleting
it left the test green) and its admin branch dead. Kept the input-hash; documented the
genuine trilemma (cluster-less-template-stable / tracks-generated-rotation /
unpredictable-secrets — pick two; determinism is the predictable-secret hole).

M-10: the render guard's wildcard check is now an exact comma-split entry, matching the
backend's `"*" in cors_origins` since r4, so a legitimate `https://*.example.com` is no
longer blocked; the localhost check stays a substring to match the backend.

.env.example: the admin-password template was an empty assignment that uncomments into
a lockout; it now carries a replace-me placeholder.

Verified: backend 8082 passed; config/encryption 66, backup 13; script-selftests all
pass; helm lint/template clean (subdomain-wildcard passes, bare '*'/prod+localhost
fail); ruff clean.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

* fix(#193 r5): fail closed instead of regenerating over an existing at-rest key (I-1)

bonnyr-f5 #193 round-5 closure blocker. B-3 wrote down the invariant "the at-rest key
file is the single source of truth; nothing overwrites it once it holds bytes" and
config.py honoured it -- but core.encryption.get_encryption_key() did not. A file
present but under 32 bytes (truncated / partial write / disk full / bad restore) logged
"Invalid key, regenerating" and OVERWROTE it with a fresh key, permanently destroying
the key any existing data was encrypted under -- silently, on a GREEN production boot,
because the intact .operator marker keeps validate_production passing.

Fix: the read path now fails CLOSED. A genuinely absent (or 0-byte) file still generates
and persists a new key. A file that HOLDS bytes is validated as a real Fernet key: valid
-> returned untouched; unusable -> SystemExit with a clear message, never regenerated. A
crashloop is recoverable; an overwritten key is not. This also closes r5 note #3 -- the
old `len >= 32` check accepted any blob and surfaced a mis-shaped key as a later cipher
error; it now Fernet-validates and says so plainly.

Locked by TestAtRestKeyFileNeverRegeneratedOverBytes: a 30-byte truncated key -> SystemExit
AND the original bytes survive on disk (recoverable); a valid key -> returned untouched; an
absent file -> generates a valid key.

Verified: backend 8086 passed; encryption unit tests 19 passed; ruff clean.

Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4

---------

Co-authored-by: John Gruber <john.t.gruber@gmail.com>
@jgruberf5
jgruberf5 deleted the fix/release-republish-and-robustness branch August 24, 2026 11:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants