From 3f3ac047f1c525a2753e6c0f04a2b2f994a3f8eb Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:54:38 +0200 Subject: [PATCH 01/13] fix(release): narrow the Cosign signing identity to refs/heads/main PLAN-48 widened the accepted certificate identity to admit refs/pull//merge on the premise that a merge-triggered release signs under the merge ref. This project's own signing certificates refute it: 0.1.0 (workflow_dispatch) and 0.1.1 (pull_request) both carry job_workflow_ref = .../release.yml@refs/heads/main. The merge path has never produced a merge-ref identity. Narrow all four carrier sites in three files to the single anchored form and rewrite the justification to lead with the decoded certificates, demoting GitHub's events-that-trigger-workflows reference to corroboration. The cosign verify step's flag set and the branches: [main] base filter are unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CyPQuo2bwuyPL6DWPdabZQ --- .claude/skills/release/SKILL.md | 40 +++++++++++++++++--------------- .github/workflows/release.yml | 32 +++++++++++++++---------- doc/user/container-image.adoc | 41 ++++++++++++++++++++++----------- 3 files changed, 69 insertions(+), 44 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index af889ff3..730bc872 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -648,11 +648,11 @@ run the two guarded commands above first and dispatch immediately after.) > **Why `--ref main` and not `--ref "$MAIN_SHA"`.** Dispatching at the gated SHA looks like the > tighter fix, and it is the wrong one here — for three independent reasons: -> 1. **The signature identity is bound to the ref, and the accepted set is closed.** Step 8 verifies -> the Cosign signature against a pattern accepting exactly `refs/heads/main` and -> `refs/pull//merge`. That value is the OIDC `job_workflow_ref` of the `publish-image` job, so -> it changes with the dispatch ref. A dispatch at a SHA is in neither branch of the pattern, so it -> produces a certificate this runbook's own verification would reject. +> 1. **The signature identity is bound to the ref, and the accepted set is a single value.** Step 8 +> verifies the Cosign signature against a pattern accepting exactly one ref, `refs/heads/main`. +> That value is the OIDC `job_workflow_ref` of the `publish-image` job, so it changes with the +> dispatch ref. A dispatch at a SHA does not match that pattern, so it produces a certificate this +> runbook's own verification would reject. > 2. **The release force-pushes to a branch.** `maven-release-plugin` commits the version transition > and the workflow force-pushes it to `main`; a dispatch at a detached SHA has no branch to push. > 3. **`ref` is documented as a branch or tag name.** The workflow-dispatch API documents exactly @@ -816,25 +816,29 @@ nothing. Verify the Cosign signature **against that resolved digest**, using **exactly** these two identity values. They are derived from the `publish-image` job's OIDC identity, which embeds the ref the run -was triggered from — so the accepted set is a pattern over the **two** refs a release can legitimately -come from, not a single literal: +was triggered from — and every release this project has cut embedded the **same** one, +`refs/heads/main`: ```bash cosign verify "$IMAGE@$VERSION_DIGEST" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - --certificate-identity-regexp '^https://github\.com/cuioss/API-Sheriff/\.github/workflows/release\.yml@refs/(heads/main|pull/[0-9]+/merge)$' + --certificate-identity-regexp '^https://github\.com/cuioss/API-Sheriff/\.github/workflows/release\.yml@refs/heads/main$' ``` -`refs/heads/main` is a `workflow_dispatch` release; `refs/pull//merge` is a merge-triggered one -(a `pull_request` run's `GITHUB_REF` is the merge ref, and that path is legitimate because the central -version-changed guard plus `merged == true` already decided it was a release). The pattern stays -anchored at both ends and bound to this repository and this workflow file — **do not** relax it to a -wildcard. The workflow runs this same command against every digest it signs, so a failure here is a -real supply-chain signal, not a stale expectation. - -**Three files carry this pattern** — `.github/workflows/release.yml` (the `publish-image` verify -step), `doc/user/container-image.adoc` (the operator-facing command), and this runbook. Nothing -mechanical keeps them in sync; changing one means changing all three. +**Both release paths sign under `refs/heads/main`, and that is read off the certificates rather than +inferred from the trigger config.** 0.1.0 (`github_workflow_trigger = workflow_dispatch`) and 0.1.1 +(`github_workflow_trigger = pull_request`) both carry +`job_workflow_ref = .../workflows/release.yml@refs/heads/main`. A pull request closed *by being +merged* runs against the branch it was merged into, so the merge path never produces a merge-ref +identity. The value is a *regexp* only because the identity string contains dots that must be +escaped; it stays anchored at both ends and bound to this repository and this workflow file — **do +not** relax it to a wildcard. The workflow runs this same command against every digest it signs, so a +failure here is a real supply-chain signal, not a stale expectation. + +**Four sites in three files carry this pattern** — `.github/workflows/release.yml` **twice** (the +`A CONSUMER VERIFIES WITH EXACTLY THESE TWO VALUES` comment block and the `publish-image` verify +step's `IDENTITY_REGEXP` env value), `doc/user/container-image.adoc` (the operator-facing command), +and this runbook. Nothing mechanical keeps them in sync; changing one means changing all four. > **Ignore the benchmark run this triggers.** `.github/workflows/benchmark.yml` fires on > `push: tags: ["*"]`, so the release's tag push **starts a benchmark run**. That run belongs to the diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bcb36a9b..e1b1768f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -392,10 +392,10 @@ jobs: # A CONSUMER VERIFIES WITH EXACTLY THESE TWO VALUES: # # --certificate-oidc-issuer https://token.actions.githubusercontent.com - # --certificate-identity-regexp ^https://github\.com/cuioss/API-Sheriff/\.github/workflows/release\.yml@refs/(heads/main|pull/[0-9]+/merge)$ + # --certificate-identity-regexp ^https://github\.com/cuioss/API-Sheriff/\.github/workflows/release\.yml@refs/heads/main$ # - # THE IDENTITY HAS TWO LEGITIMATE REFS, NOT ONE. Fulcio puts the OIDC `job_workflow_ref` claim - # in the certificate SAN. publish-image is a CALLER-LOCAL job — it does not `uses:` a reusable + # THE IDENTITY NAMES EXACTLY ONE REF. Fulcio puts the OIDC `job_workflow_ref` claim in the + # certificate SAN. publish-image is a CALLER-LOCAL job — it does not `uses:` a reusable # workflow — so that claim names release.yml ITSELF (never the org reusable workflow) at the ref # the run was triggered on, i.e. GITHUB_REF. This lane has two release paths and they carry # different refs: @@ -403,16 +403,24 @@ jobs: # * workflow_dispatch from main -> GITHUB_REF = refs/heads/main # * merge of a .github/project.yml PR -> GITHUB_REF = refs/pull//merge # - # The merge ref is a FIRST-CLASS release path, not an anomaly to be excluded: the org - # version-changed guard plus `merged == true` already decided that this merge is a real release - # (see the trigger comment at the top of this file), and this job only runs when - # needs.release.outputs.released-version is non-empty. Pinning the accepted identity to - # @refs/heads/main alone would therefore reject the signature of a perfectly legitimate release. + # THIS IS NOT ARGUED FROM DOCUMENTATION — IT IS READ OFF OUR OWN CERTIFICATES. Both signed + # digests this project has ever published carry the same single ref in their SAN: + # + # * 0.1.0 (sha256:937b3995...) — github_workflow_trigger = workflow_dispatch, + # job_workflow_ref = .../release.yml@refs/heads/main + # * 0.1.1 (sha256:dbd73c2e...) — github_workflow_trigger = pull_request, + # job_workflow_ref = .../release.yml@refs/heads/main + # + # 0.1.1 is the decisive one: a MERGE-triggered run that signed at refs/heads/main. No release + # this project has cut has ever carried a merge-ref identity, so an accepted set widened to + # admit one was widened for a ref that has never been used. GitHub's + # events-that-trigger-workflows reference agrees — on a close-by-merge GITHUB_REF is the base + # branch — but it is corroboration here, not the premise the identity rests on. # # The regexp is TIGHTLY ANCHORED, not a wildcard: anchored at both ends, bound to this exact - # repository and this exact workflow file, and the PR number is digits-only. It admits exactly - # the two refs above and nothing else. Do NOT relax it to a `.*` — a loose identity would accept - # a signature from any workflow in any repository and the check would prove nothing. + # repository and this exact workflow file, and it admits that one ref and nothing else. Do NOT + # relax it to a `.*` — a loose identity would accept a signature from any workflow in any + # repository and the check would prove nothing. # # doc/user/container-image.adoc publishes the same two strings to operators, and the verify step # below RUNS them — the lane proves its own signing identity rather than asserting it in prose. @@ -440,7 +448,7 @@ jobs: - name: Verify the signature under the published identity env: DIGEST: ${{ steps.digest.outputs.value }} - IDENTITY_REGEXP: '^https://github\.com/cuioss/API-Sheriff/\.github/workflows/release\.yml@refs/(heads/main|pull/[0-9]+/merge)$' + IDENTITY_REGEXP: '^https://github\.com/cuioss/API-Sheriff/\.github/workflows/release\.yml@refs/heads/main$' run: | set -euo pipefail REF="ghcr.io/cuioss/api-sheriff@${DIGEST}" diff --git a/doc/user/container-image.adoc b/doc/user/container-image.adoc index 337f7493..85d67307 100644 --- a/doc/user/container-image.adoc +++ b/doc/user/container-image.adoc @@ -189,7 +189,7 @@ DIGEST="$(docker image inspect \ [ "$(printf '%s\n' "$DIGEST" | grep -c .)" -eq 1 ] || { echo "expected exactly one digest, got: $DIGEST"; exit 1; } cosign verify \ - --certificate-identity-regexp '^https://github\.com/cuioss/API-Sheriff/\.github/workflows/release\.yml@refs/(heads/main|pull/[0-9]+/merge)$' \ + --certificate-identity-regexp '^https://github\.com/cuioss/API-Sheriff/\.github/workflows/release\.yml@refs/heads/main$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ "${DIGEST}" ---- @@ -201,8 +201,9 @@ the same string. Both flags are mandatory, and each pins a different half of the claim: `--certificate-identity-regexp`:: -*Who* signed it -- the exact workflow, in the exact repository, on one of exactly two accepted refs. -It is what binds the check to this project's release lane rather than to any workflow anywhere. +*Who* signed it -- the exact workflow, in the exact repository, on the single accepted ref +`refs/heads/main`. It is what binds the check to this project's release lane rather than to any +workflow anywhere. `--certificate-oidc-issuer`:: *Which identity provider* vouched for that identity. It is what stops the same identity string, @@ -229,17 +230,29 @@ as a tooling problem to work around. === Why the identity is a pattern, and why it is still exact -The signing identity is the release workflow's ref, and this project cuts releases from two paths: a -deliberate dispatch from `main`, and the merge of a version-changing `.github/project.yml` pull -request. GitHub stamps the certificate with the ref the run was triggered on, so the first path signs -under `@refs/heads/main` and the second under `@refs/pull//merge`. Both are legitimate -releases -- the merge path only reaches the publishing job after the central version-changed guard -has confirmed the declared version actually moved. - -*The pattern admits those two refs and nothing else.* It is anchored at both ends, so it matches the -whole identity rather than appearing somewhere inside a longer one; it names this repository and this -workflow file literally; and the pull-request number is digits-only. A signature from any other -workflow, any other repository, or any other ref fails the check. +The signing identity is the release workflow's ref, and every release this project has cut signed +under the same one -- `@refs/heads/main`. That is not inferred from the trigger configuration; it is +read off the certificates themselves. Both published digests carry an identical `job_workflow_ref` +SAN even though they were produced by *different* triggers: + +* *0.1.0*, cut by `workflow_dispatch` -- `job_workflow_ref` = + `.../workflows/release.yml@refs/heads/main`. +* *0.1.1*, cut by merging a version-changing `.github/project.yml` pull request -- + `job_workflow_ref` = `.../workflows/release.yml@refs/heads/main`. + +The 0.1.1 case is the one that settles it: a merge-triggered release signed under `@refs/heads/main`, +not under a merge ref. A pull request closed *by being merged* runs against the branch it was merged +into, so both of this project's release paths converge on the same ref. + +*So why a pattern, if the accepted set is a single value?* Because the flag is +`--certificate-identity-regexp`, and the identity string contains regexp metacharacters -- the dots +in `github.com` and in `.github/workflows/release.yml`. Escaping those and anchoring the expression +is what makes the pattern behave as the literal it is meant to be. + +*And it is still exact.* It is anchored at both ends, so it matches the whole identity rather than +appearing somewhere inside a longer one, and it names this repository and this workflow file +literally. A signature from any other workflow, any other repository, or any other ref fails the +check. Do not relax it to a wildcard. An identity pattern that accepts anything accepts a signature from any workflow in any repository, which turns the verification into a formality that always passes. From d53be5d4d89e6b8b303110b933372ad28254a9dc Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:02:36 +0200 Subject: [PATCH 02/13] fix(release): correct release.yml's account of GITHUB_REF under pull_request closed The signing comment claimed the merge path carries GITHUB_REF = refs/pull//merge. It does not: a pull request closed by being merged runs against the base branch it was merged into. State instead that only two events reach the signing job -- workflow_dispatch and a merged-close .github/project.yml PR -- and that both yield refs/heads/main, naming the merged == true gate as the reason no other close gets there. Comments only; no executable line changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CyPQuo2bwuyPL6DWPdabZQ --- .github/workflows/release.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1b1768f..cc04134e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -397,11 +397,13 @@ jobs: # THE IDENTITY NAMES EXACTLY ONE REF. Fulcio puts the OIDC `job_workflow_ref` claim in the # certificate SAN. publish-image is a CALLER-LOCAL job — it does not `uses:` a reusable # workflow — so that claim names release.yml ITSELF (never the org reusable workflow) at the ref - # the run was triggered on, i.e. GITHUB_REF. This lane has two release paths and they carry - # different refs: + # the run was triggered on, i.e. GITHUB_REF. Only two events can reach this job, and BOTH of + # them yield refs/heads/main: # - # * workflow_dispatch from main -> GITHUB_REF = refs/heads/main - # * merge of a .github/project.yml PR -> GITHUB_REF = refs/pull//merge + # * workflow_dispatch from main -> GITHUB_REF = refs/heads/main + # * a MERGED-close .github/project.yml PR -> GITHUB_REF = refs/heads/main, the base branch it + # was merged INTO. A close WITHOUT a merge never reaches here at all: the + # `merged == true` gate on the release job above refuses it before anything runs. # # THIS IS NOT ARGUED FROM DOCUMENTATION — IT IS READ OFF OUR OWN CERTIFICATES. Both signed # digests this project has ever published carry the same single ref in their SAN: From 0f21ea096d8b292bf3d4609ef4fdfdb80033f708 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:06:16 +0200 Subject: [PATCH 03/13] docs(adr): author ADR-0035 and supersede ADR-0034 release.yml:19 forward-references ADR-0035 for the decision that a release is cut by a guarded version-bump merge or a deliberate dispatch; the record did not exist. Author it, filling the 0035 gap out of sequence because that forward reference pins the number. ADR-0035 records the two triggers and their asymmetry, why the centrally-owned version-changed guard is the discrimination the 2026-07-12 trigger lacked, why it is consumed and never re-implemented locally, that the paths: filter is a prefilter and never the decision, and that ADR-0034's trigger-removal-merges-on-its-own rule and residual operator risks carry forward unchanged. ADR-0034 is flipped to Superseded with a pointer to ADR-0035; its body is left verbatim as the record of what was decided at the time. doc/README.adoc's index NOTE range widens to ADR-0020 through ADR-0035. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CyPQuo2bwuyPL6DWPdabZQ --- doc/README.adoc | 2 +- ..._event_or_an_automated_lifecycle_step.adoc | 6 +- ...n-changed_decision_is_centrally_owned.adoc | 223 ++++++++++++++++++ 3 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 doc/adr/0035-A_release_is_cut_by_a_guarded_version-bump_merge_or_a_deliberate_dispatch_and_the_version-changed_decision_is_centrally_owned.adoc diff --git a/doc/README.adoc b/doc/README.adoc index f50f4681..ddd6eb7e 100644 --- a/doc/README.adoc +++ b/doc/README.adoc @@ -286,7 +286,7 @@ ever produced (see link:configuration.adoc#_rate_limit[Configuration -- `rate_li a set literal. *(Status: proposed.)* |=== -NOTE: This index is *not exhaustive* -- ADR-0020 through ADR-0034 are recorded in +NOTE: This index is *not exhaustive* -- ADR-0020 through ADR-0035 are recorded in link:adr/[`doc/adr/`] without a row here. Treat the directory as the authoritative list and this table as a curated entry point; a missing row is index drift, never a withdrawn decision. diff --git a/doc/adr/0034-Publication_is_triggered_only_by_a_deliberate_standalone_invocation_never_by_a_repository_event_or_an_automated_lifecycle_step.adoc b/doc/adr/0034-Publication_is_triggered_only_by_a_deliberate_standalone_invocation_never_by_a_repository_event_or_an_automated_lifecycle_step.adoc index 99f79d79..2b0fa60d 100644 --- a/doc/adr/0034-Publication_is_triggered_only_by_a_deliberate_standalone_invocation_never_by_a_repository_event_or_an_automated_lifecycle_step.adoc +++ b/doc/adr/0034-Publication_is_triggered_only_by_a_deliberate_standalone_invocation_never_by_a_repository_event_or_an_automated_lifecycle_step.adoc @@ -12,7 +12,11 @@ == Status -Proposed +Superseded + +Superseded by +link:0035-A_release_is_cut_by_a_guarded_version-bump_merge_or_a_deliberate_dispatch_and_the_version-changed_decision_is_centrally_owned.adoc[ADR-0035]. +The body below is left verbatim as the record of what was decided at the time. == Context diff --git a/doc/adr/0035-A_release_is_cut_by_a_guarded_version-bump_merge_or_a_deliberate_dispatch_and_the_version-changed_decision_is_centrally_owned.adoc b/doc/adr/0035-A_release_is_cut_by_a_guarded_version-bump_merge_or_a_deliberate_dispatch_and_the_version-changed_decision_is_centrally_owned.adoc new file mode 100644 index 00000000..c1039e87 --- /dev/null +++ b/doc/adr/0035-A_release_is_cut_by_a_guarded_version-bump_merge_or_a_deliberate_dispatch_and_the_version-changed_decision_is_centrally_owned.adoc @@ -0,0 +1,223 @@ += ADR-0035: A release is cut by a guarded version-bump merge or a deliberate dispatch, and the version-changed decision is centrally owned +:toc: left +:toclevels: 2 +:sectnums: + +// adr-metadata +// summary: The release workflow carries two triggers -- a pull_request: closed event narrowed by a base filter, a paths prefilter and a merged == true gate, and an unconditional workflow_dispatch -- and the question "is this merge a release?" is answered by the pinned cuioss-organization workflow's version-changed guard, which is consumed and never re-implemented locally; the paths filter is a prefilter and never the decision, and ADR-0034's trigger-removal-merges-on-its-own rule and residual operator risks carry forward unchanged. +// tags: release, publication, irreversibility, workflow-trigger, version-changed-guard, central-ownership, prefilter, deliberate-action, runbook, supply-chain, check-then-act +// affects: api-sheriff-parent, documentation +// supersedes: 0034 +// end-adr-metadata + +== Status + +Proposed + +NOTE: This record fills a numbering gap out of sequence -- ADR-0036 and ADR-0037 already exist. The +number is not free choice: `.github/workflows/release.yml` forward-references *ADR-0035* by name for +the decision recorded here, and that reference was written before this file existed. Filling the gap +is intentional, not an error. + +== Context + +link:0034-Publication_is_triggered_only_by_a_deliberate_standalone_invocation_never_by_a_repository_event_or_an_automated_lifecycle_step.adoc[ADR-0034] +decided that an irreversible publication is fired only by a person deciding to fire it, and realised +that decision with a single mechanism: `workflow_dispatch` as the release workflow's *only* trigger. +That mechanism has since been replaced. This record states what replaced it and why the replacement +is not a reversal of ADR-0034's reasoning. + +*What ADR-0034 was actually defending against.* On 2026-07-12 a `pull_request: {types: [closed], +paths: ['.github/project.yml']}` trigger fired a real Maven Central release: merging a pull request +that touched the version-declaration file published `de.cuioss.sheriff.api:*:1.0.0`. Central +artifacts are superseded, never withdrawn, so that version was abandoned, relocation-stubbed at +1.0.1, the coordinates moved to `de.cuioss.sheriff.gateway`, and the version line restarted. The +defect was not that an *event* fired the workflow. The defect was that the event fired on +*merged-ness* -- every merge touching that file was a publication, because nothing in the lane ever +asked whether the declared version had actually changed. A path filter cannot answer that question: +`.github/project.yml` carries `name`, `description`, `maven-build`, `sonar`, `pages` and +`github-automation` today, and that list is open. Editing any of them matched the filter and cut a +release. + +*What changed.* The pinned `cuioss-organization` reusable release workflow now owns a +*version-changed guard*: it compares the declared version against the parent commit's and refuses to +release when it did not move. That is precisely the discrimination the 2026-07-12 trigger lacked, and +it lives one level up -- in a versioned, centrally-maintained workflow consumed by every project in +the organisation -- rather than in each caller's own YAML. + +*Why this reopens the trigger question rather than settling it by itself.* A guard that lives +upstream is only as good as the caller's discipline in *consuming* it. A caller that re-implements +the same comparison locally gets two answers that can disagree, and the local copy is the one that +rots. A caller that treats its `paths:` filter as the decision reproduces the 2026-07-12 defect under +a new name. And a guard that decides correctly still leaves ADR-0034's *other* residual risks +untouched: nothing rejects a change that both removes a trigger and edits the paths it watches, and +nothing enforces a merge hold while a release run is in flight. + +So the structural choice point is narrower than ADR-0034's, and different in kind: *given that the +release decision can now be made correctly and centrally, where does that decision live, and what +must the caller refrain from doing?* + +== Decision + +*A release is cut by either of two paths, and the question "is this merge a release?" is answered +centrally, never locally.* + +The mechanisms that realise this: + +* *Two triggers, deliberately asymmetric.* `.github/workflows/release.yml` declares + `workflow_dispatch` and a `pull_request` event narrowed by `types: [closed]`, `branches: [main]` + and `paths: ['.github/project.yml']`. The merge path is *conditional* -- it publishes only when the + central guard says the declared version moved. The dispatch path is *unconditional* -- it publishes + whatever the version declaration says at the dispatched ref, including a re-declaration of an + unchanged version. That asymmetry is the design, not an oversight: the merge path is the ordinary + route and is guarded; the dispatch is the operator-gated fallback for when the ordinary route + cannot or did not fire, and its lack of a guard is exactly what makes it a fallback. +* *The version-changed decision is consumed, never re-implemented.* The caller declares the trigger + and delegates the decision. There is no local version comparison, no local "did the version move" + step, and none may be added. A second implementation of a security-relevant decision is a second + answer, and the local copy is the one that drifts out of sync with the upstream it was copied from. +* *The `paths:` filter is a prefilter and never the decision.* It matches *every* edit to + `.github/project.yml`, not just a version bump, so a merge arriving at the workflow proves nothing + about the version. Its only job is to keep unrelated merges from spending a runner. Reading it as + the release condition is the 2026-07-12 defect restated, and the workflow says so in-file at the + trigger. +* *The `branches: [main]` filter bounds who may ask.* `branches:` filters on the pull request's + *base*, so only a pull request merging into the main line can reach the release job at all. Without + it, a merge into any long-lived branch that touched the declaration file would enter the workflow + and be decided by the central guard's comparison against *that* branch -- a release cut from a + non-main line. This is a base filter, not the version discriminator: it narrows what may ask, never + what is answered. +* *`merged == true` is defence in depth, not the guard's substitute.* The release job additionally + refuses any `pull_request` close that was not a merge. The central guard checks merged-ness too, so + this is not here because the guard is blind to it; its merit is not spending a runner on a + close-without-merge, and one more independent refusal on the path to an irreversible act. +* *Both reachable paths sign under the same ref.* A pull request closed *by being merged* runs + against the branch it was merged into, so `GITHUB_REF` is `refs/heads/main` on the merge path + exactly as on the dispatch path. This is read off the project's own signing certificates -- 0.1.0 + (`workflow_dispatch`) and 0.1.1 (`pull_request`) both carry + `job_workflow_ref = .../release.yml@refs/heads/main` -- not inferred from the trigger + configuration. The accepted Cosign identity is therefore a single anchored ref, and widening it to + admit a merge ref would accept a ref no release has ever used. +* *ADR-0034's carried-forward rules stand unchanged.* A change that removes or weakens an + event-driven trigger still merges on its own, ahead of any change that would fire that trigger, + because a `pull_request` workflow is evaluated from the *base* branch and a mixed change fires the + old definition on the very merge that removes it. The post-dispatch merge hold and the "exactly one + of each" verification carry forward likewise. Re-arming a trigger does not retire the rules that + make changing one safe -- it makes them load-bearing again. + +== Consequences + +=== Positive + +* *The publication decision is made where it can be made correctly.* Whether a version moved is a + fact about the declaration, and the guard that reads it is maintained once for the organisation + rather than once per repository. A defect in it is fixed in one place. +* *The ordinary release path costs no separate act.* Landing the version bump is the release. The + step that ADR-0034 deliberately kept separate is re-merged, but only because the thing that makes + the merge safe is now checked rather than assumed. +* *The 2026-07-12 failure mode is closed by mechanism rather than by prohibition.* ADR-0034 closed it + by forbidding the trigger; this closes it by answering the question the trigger could not. The + earlier defence depended on nobody re-adding an event; this one survives the event being present. +* *A fallback exists when the guarded path does not fire.* An operator whose merge cut no release has + a documented, unconditional route -- and, because the two paths differ in exactly one respect, a + short explanation of why the merge did nothing. + +=== Negative + +* *Reviewing a version bump is again reviewing a publication.* The merge of a version-changing + `.github/project.yml` pull request *is* the irrevocable act. ADR-0034 rejected this shape outright; + this record accepts it, and accepts that the safety now rests on the guard rather than on the + reviewer's framing of what they are approving. +* *The safety of the ordinary path depends on an upstream this repository does not own.* The guard is + consumed from a pinned `cuioss-organization` workflow. Pinning bounds the exposure to a deliberate + bump, but the decision itself is no longer inspectable in this repository's own YAML. +* *Two paths mean two behaviours to document and to keep in agreement.* The runbook, the process + narrative, the workflow's own comments and the operator-facing verification command all state the + posture, and nothing mechanical keeps them in sync. + +=== Risks + +Every entry below is a *residual risk*, not a closed safeguard. As in ADR-0034, they are listed +separately from the Decision precisely so the mechanisms above are never read as covering them. + +* *Nothing rejects a mixed trigger-removal change.* Unchanged from ADR-0034, and now materially more + likely to matter: an event-driven trigger exists again, so a future change that both weakens it and + edits the paths it watches will fire the *old* definition on the merge that removes it. Closing + this needs an executable check under `.github/workflows/`; until one exists the merges-on-its-own + rule is operator discipline. +* *Nothing enforces the post-dispatch merge hold.* Unchanged from ADR-0034. No branch policy, merge + freeze or workflow check prevents a merge landing between the trigger and the run's completion, so + a queue landing can race the release's force-push to the protected branch. The merge path narrows + the window rather than closing it -- the run starts at the merge rather than at a separate operator + act -- but nothing bounds the far end. +* *The `paths:` prefilter can be mistaken for the guard by a later reader.* The two are adjacent in + the same `on:` block and the prefilter is the visible one; the decision is in a workflow this + repository only references. The defence is the in-file comment at the trigger and this record. A + future change that narrows or widens the prefilter believing it is tuning the release condition + would be tuning nothing. +* *A local re-implementation of the version check would be invisible until it disagreed.* Nothing + rejects a caller-local version comparison. The prohibition is normative prose, and a second answer + is only detectable at the moment it differs from the first -- during a release. +* *Post-publication actions outside the repository still cannot be verified by the run itself.* + Unchanged from ADR-0034; registry package visibility remains the standing case. + +== Alternatives Considered + +*Keep `workflow_dispatch` as the only trigger (ADR-0034 as it stood).* The 2026-07-12 failure mode +stays closed by construction and no upstream is trusted with the release decision. Rejected because +the reason the event trigger was banned has been addressed at its root: the trigger fired on +merged-ness, and now something asks whether the version moved. Retaining the ban would preserve the +*remedy* after the *defect* was fixed, at the standing cost of a manual act on every release and of a +runbook whose pre-dispatch gate asserts a trigger shape that is no longer true. A prohibition kept +past its cause becomes folklore. + +*Re-implement the version-changed check locally, in this repository's workflow.* The release +condition becomes inspectable in the file that carries the trigger, with no dependency on an upstream +for a security-relevant decision. Rejected because it produces two implementations of one decision. +They agree on the day the copy is made and diverge silently afterwards, and the divergence surfaces +only during a release -- either as a release that should not have happened or as one that mysteriously +did not. Central ownership is the point: consuming one answer is what makes the answer improvable. + +*Trigger on the merge but require a separate confirmation before publishing.* The merge starts the +lane and an approval gate holds it short of the irrevocable act, restoring a deliberate human +decision without losing the automatic path. Rejected as the worst of both: it reintroduces the manual +act ADR-0034's critics find costly while leaving the reviewer of the version bump still unsure +whether their approval was the decision, and it adds a second place a release can stall unnoticed. An +approval that always follows a merge degenerates into a formality, which is the same substitution of +a proxy for a decision that ADR-0034 rejected. + +*Drop the `paths:` prefilter and let the central guard decide every merge to the main line.* One less +filter to reason about, and the guard is the decision anyway. Rejected because the prefilter is cheap +and its job is real -- keeping unrelated merges from spending a runner and from appearing in the +release workflow's run history, where a long list of no-op runs would make a genuine one harder to +notice. + +== Generalisation + +When a mechanism is banned because it produced a bad outcome, distinguish the *mechanism* from the +*missing discrimination* that let it misfire. A ban is the right immediate response and the wrong +permanent one: it is cheap, verifiable and total, but it fixes the symptom by removing capability, +and it survives its own justification because nothing forces a re-examination when the underlying gap +is later closed. Two questions decide whether a ban may be lifted. Has something been added that +answers the question the banned mechanism could not answer -- not merely narrowed the circumstances in +which it fires? And is that answer owned somewhere it can be maintained and corrected, rather than +copied into each consumer? Where both hold, the ban may be replaced by the guard, and the ban's +*residual* rules -- the ones that were about operating the mechanism safely rather than about +forbidding it -- must be carried forward explicitly, because lifting the ban is exactly when they +start mattering again. Where either fails, a filter that narrows *when* the mechanism fires is not a +substitute for a check on *whether it should*: narrowing reduces the frequency of the bad outcome +while leaving it reachable, and a rare irreversible failure is harder to catch than a common one. + +== References + +* link:0034-Publication_is_triggered_only_by_a_deliberate_standalone_invocation_never_by_a_repository_event_or_an_automated_lifecycle_step.adoc[ADR-0034] + -- the superseded decision. Its 2026-07-12 incident record, its base-branch trigger-removal rule + and its residual operator risks remain the reasoning this record builds on. +* link:../development/release-process.adoc[Release Process] -- the canonical process narrative: the + two release paths, the guard's role, and the surviving trigger rule. +* link:../../.github/workflows/release.yml[`.github/workflows/release.yml`] -- the two triggers, the + `merged == true` gate, the prefilter comment, and the signing identity assertion. +* link:../user/container-image.adoc[Container Image] -- the operator layer for the published image: + digest pinning and signature verification under the single accepted ref. +* The repository's standalone `release` runbook under `.claude/skills/release/` -- the two-path + procedure, the pre-cut assertions, and the "exactly one of each" verification. From 2a3c937a30019545bacf10e80600c4944a7d59a5 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:09:35 +0200 Subject: [PATCH 04/13] docs(release): reconcile the release-process narrative with the live two-path trigger The document asserted a dispatch-only posture throughout -- title, intro, the two-step cutting procedure, the artifact lead-in, the diagram alt text, the recovery procedure's dispatch-bound phrasing, the tag-provenance note, and the whole 'Dispatch is the only trigger' subsection. All of it is false against release.yml as it now stands. Replace it with the two-path account: the guarded version-bump merge is the publishing act, workflow_dispatch is the unconditional fallback, the centrally-owned version-changed guard is the decision, and the paths: filter is a prefilter that must never be read as the release condition. '=== A trigger removal merges on its own' survives byte-unchanged -- only its framing sentence moved -- and the relocation-stubs recovery paragraph is untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CyPQuo2bwuyPL6DWPdabZQ --- doc/development/release-process.adoc | 98 ++++++++++++++++++---------- 1 file changed, 62 insertions(+), 36 deletions(-) diff --git a/doc/development/release-process.adoc b/doc/development/release-process.adoc index b60ba005..b004f144 100644 --- a/doc/development/release-process.adoc +++ b/doc/development/release-process.adoc @@ -1,25 +1,34 @@ -= Release Process -- dispatch-only += Release Process :toc: :toclevels: 2 :sectnums: -How a release of API Sheriff is cut. Releases are *never* triggered by merging a pull request: -the Release workflow runs only when a maintainer deliberately dispatches it. This page states the -process and the rules that keep it deliberate. +How a release of API Sheriff is cut. A release has two entry points: merging a pull request that +changes the declared version in `.github/project.yml`, which *is* the publishing act once the +centrally-owned version-changed guard lets it through, and a deliberate `workflow_dispatch`, which +publishes unconditionally and is the operator-gated fallback. This page states the process and the +rules that keep it safe. == Cutting a release -A release is a two-part act -- declare the version, then deliberately publish it: +The ordinary path is a single act -- merge the version declaration: . *Declare the version.* Edit link:../../.github/project.yml[`.github/project.yml`], setting `current-version` to the version being released and `next-version` to the following development - version. Open a pull request with that change and merge it in the normal way. -. *Publish deliberately.* Once the version change is on `main`, dispatch the *Release* workflow by - hand (Actions -> Release -> Run workflow). This is the only way a release is cut. + version. Open a pull request with that change. +. *Merge it.* Merging into `main` cuts the release. The Release workflow runs on the merged close, + and the pinned cuioss-organization workflow's *version-changed guard* decides whether this merge + is a release: it publishes when the declared version actually moved, and refuses when it did not. -Merging the `project.yml` change publishes nothing on its own. The two steps are separate on -purpose: the merge records *what* the next version is, and the dispatch is the explicit decision to -*publish* it. +*The merge is the publishing act, so review the pull request as one.* Approving a version bump is +approving a Maven Central publication, and a published coordinate can be superseded but never +withdrawn. + +*`workflow_dispatch` is the fallback, and it is unconditional.* Actions -> Release -> Run workflow +publishes whatever `.github/project.yml` declares at the dispatched ref, including a re-declaration +of a version already published; nothing central refuses it. Reach for it when the merge path did not +fire -- and when a merge cut no release, check whether the declared version actually moved before +dispatching, because that is what the guard was answering. link:../../.github/workflows/release.yml[`.github/workflows/release.yml`] delegates to the cuioss-organization reusable Maven release workflow, pinned to a specific commit SHA. Neither the @@ -27,7 +36,7 @@ pin nor the `secrets:` block is part of the release decision -- they are build w == What a release contains -One dispatch produces one coherent set of artifacts, all carrying the same version: +One release run produces one coherent set of artifacts, all carrying the same version: * *Maven coordinates on Maven Central* -- `de.cuioss.sheriff.gateway:api-sheriff` and its reactor siblings, published by the reusable release workflow. @@ -40,7 +49,7 @@ release artifact*: it lives in the repository at `deployment/compose-sample/` an the source tree rather than pulled from a registry. Both are named here so their absence from the artifact list reads as a known boundary rather than an oversight. -image::../resources/diagrams/release-publish-flow.svg[Release publish flow -- one dispatch drives the Maven release and a single image build whose output is scanned, pushed, smoked and signed, while an independent non-gating pull-request lane scans the pinned base image and the repository filesystem, align=center] +image::../resources/diagrams/release-publish-flow.svg[Release publish flow -- one release run, entered by a guarded version-bump merge or a deliberate dispatch, drives the Maven release and a single image build whose output is scanned, pushed, smoked and signed, while an independent non-gating pull-request lane scans the pinned base image and the repository filesystem, align=center] === The image version is the Maven version @@ -70,19 +79,21 @@ a step runs "before anything is pushed", read it as *before anything is pushed t ==== If the image lane fails after the Maven release -. *Do not re-run the whole workflow.* A second dispatch would attempt another Maven release of a - version that is already published. +. *Do not re-trigger the whole workflow.* A second dispatch would attempt another Maven release of a + version that is already published -- the dispatch path is unconditional and nothing central refuses + it. The merge path is no remedy either: re-merging the same version declaration moves nothing, so + the version-changed guard refuses it and no release runs at all. . *Classify the failure first -- a re-run cannot pick up a fix.* *Re-run failed jobs* creates a new *attempt of the same run*: it reuses the original event context and the original workflow definition, and `publish-image` checks out the *release tag*, not `main`. A commit merged to `main` - after the dispatch is therefore invisible to it. "Fix it on `main`, then re-run" does not work - here and must not be attempted. + after the release run started is therefore invisible to it. "Fix it on `main`, then re-run" does + not work here and must not be attempted. + -- Transient job or runtime failure:: A registry timeout, a runner flake, a Maven Central propagation race. *Re-run the failed `publish-image` job alone* from the Actions UI. It reads its version from `.github/project.yml` at -the dispatch SHA and checks out the release tag, so it reproduces the same inputs without touching +the triggering SHA and checks out the release tag, so it reproduces the same inputs without touching Maven Central. Anything carried in the tree or the workflow:: @@ -181,8 +192,8 @@ The human-facing release reference. `` is the Maven version, verbatim. `ghcr.io/cuioss/api-sheriff:sha-`:: *Provenance, not immutability.* `` is the *full 40-character SHA of the commit the release tag points at* -- the `release:prepare` commit whose poms carry the released version -- and *not* the -`main` HEAD the release was dispatched from. That is the commit whose source produced the image; -naming the dispatch commit would point at a tree that never built it. The tag answers "which source +`main` HEAD the release ran from. That is the commit whose source produced the image; naming the +triggering commit would point at a tree that never built it. The tag answers "which source produced this?" unambiguously, but it remains an ordinary mutable registry tag and is not the image digest -- which is exactly why Cosign signs the digest rather than either tag. @@ -201,24 +212,39 @@ cryptographically verifying the published image. == Trigger rules Two rules govern how the release workflow may be triggered, and how a trigger may be changed. Both -are normative, and both are load-bearing for the dispatch-only guarantee. +are normative. + +=== The version-changed guard is the release decision + +link:../../.github/workflows/release.yml[`.github/workflows/release.yml`] declares two triggers: an +unconditional `workflow_dispatch`, and a `pull_request` narrowed by `types: [closed]`, +`branches: [main]` and `paths: ['.github/project.yml']`. On the merge path the question *"is this +merge a release?"* is answered by the pinned cuioss-organization workflow's *version-changed guard*, +which compares the declared version against the parent commit's and refuses when it did not move. + +*That guard is consumed, never re-implemented here.* A local copy of a security-relevant decision is +a second answer, and the local copy is the one that drifts out of sync. Do not add a caller-local +version comparison. -=== Dispatch is the only trigger +*The `paths:` filter is a prefilter, never the decision.* It matches *every* edit to +`.github/project.yml` -- `name`, `description`, `maven-build`, `sonar`, `pages`, `github-automation`, +and whatever key is added tomorrow -- so a merge arriving at the workflow proves nothing about the +version. Its only job is to keep unrelated merges from spending a runner. Reading it as the release +condition is precisely the 2026-07-12 defect: a trigger of exactly that shape fired on merged-ness +and published an unintended GA coordinate that had to be abandoned and relocation-stubbed. -link:../../.github/workflows/release.yml[`.github/workflows/release.yml`] declares -`on: workflow_dispatch` and nothing else. *No merge -- of any pull request, touching any path -- -can cut a release.* Releasing is always an explicit act by a maintainer. +`branches: [main]` filters on the pull request's *base*, so only a pull request merging into `main` +can reach the release job at all, and the job additionally refuses any close that was not a merge. +Both narrow *what may ask*, never *what is answered*. -The scope of that statement is *release publication*, and it is stated narrowly on purpose. A merge -to `main` is not inert: link:../../.github/workflows/maven.yml[`maven.yml`] runs on -`push: branches: [main]`, and its snapshot deployment runs on that push (it is skipped on pull -requests). So a merge can publish a *snapshot* -- what it cannot do is cut a release. The -irrevocable act, a Maven Central GA coordinate, is reachable only through the dispatch. +The scope of all this is *release publication*. A merge to `main` is not otherwise inert: +link:../../.github/workflows/maven.yml[`maven.yml`] runs on `push: branches: [main]`, and its +snapshot deployment runs on that push (it is skipped on pull requests). So a merge can publish a +*snapshot* -- and, when the guard says the declared version moved, a release. -Do not add an event-driven trigger to the release workflow. A `pull_request` trigger scoped to -`.github/project.yml` reads as a convenience -- merge the version bump, get the release -- but it -makes every merge that touches that file a publication, and a Maven Central publication is -irrevocable. Convenience is not worth an unintended GA. +The decision is recorded in +link:../adr/0035-A_release_is_cut_by_a_guarded_version-bump_merge_or_a_deliberate_dispatch_and_the_version-changed_decision_is_centrally_owned.adoc[ADR-0035], +which supersedes ADR-0034. === A trigger removal merges on its own @@ -234,8 +260,8 @@ is gone from `main` before relying on it being gone. == See also -* link:../../.github/workflows/release.yml[`.github/workflows/release.yml`] -- the dispatch-only - release workflow. +* link:../../.github/workflows/release.yml[`.github/workflows/release.yml`] -- the release workflow + and its two triggers. * link:../../.github/project.yml[`.github/project.yml`] -- where the released version is declared. * link:../user/container-image.adoc[Container Image] -- the operator layer for the published image. * link:README.adoc[Contributor Guide] -- the index for this documentation layer. From 9615f63f63a06e1d889ca56ffb05bb3741d0a091 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:12:25 +0200 Subject: [PATCH 05/13] docs(release): clear the residual runbook drift that ADR-0035 and the narrative rewrite make false The runbook claimed ADR-0035 was still owed and its reference dangling, that release-process.adoc was STALE on the trigger and not authoritative there, and it cited two section names that do not exist in that file while calling the rewrite tracked follow-up work. D3 and D4 make all of it false. Re-point the residual-risk citation at :459 from the now-superseded ADR-0034 to ADR-0035. Also corrects one claim outside the enumerated carrier list, in the same file: 'The guard has NEVER been observed' asserted that no merge-triggered release had ever run. 0.1.1's signing certificate refutes it -- github_workflow_trigger=pull_request at the #186 merge commit -- so the guard has been observed firing. What remains unobserved is the guard refusing, which is what the section now says. Step 3(i) and the frontmatter description are byte-unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CyPQuo2bwuyPL6DWPdabZQ --- .claude/skills/release/SKILL.md | 50 ++++++++++++++++----------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 730bc872..813031b3 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -75,8 +75,9 @@ trigger fired on *merged-ness* — every merge touching the file was a publicati `cuioss-organization` workflow now owns the version-changed decision, which is precisely the discrimination that trigger lacked. It is **consumed** here and **never re-implemented locally**: `publish-image` keys off `needs.release.outputs.released-version != ''`, which is the guard's own -verdict surfaced. `release.yml`'s header forward-references **ADR-0035** for the decision; that ADR is -still owed, so **the reference is dangling and this section is the current account.** +verdict surfaced. The decision is recorded in **ADR-0035** (`doc/adr/`), which `release.yml`'s header +references and which supersedes ADR-0034; read it there for the reasoning. This section remains the +narrative account of the 2026-07-12 incident. Consequently: @@ -87,15 +88,20 @@ Consequently: a block to report, never something to substitute locally. - A change that removes or weakens an event-driven trigger must merge **on its own**, before any change that would fire that trigger — for a `pull_request` event GitHub evaluates the workflow - definition **from the base branch**. `doc/development/release-process.adoc` records that rule (see - *See also* for the caveat about that document's current state); do not restate it from memory. + definition **from the base branch**. `doc/development/release-process.adoc` records that rule; do + not restate it from memory. -### The guard has NEVER been observed — read this before cutting +### The guard has fired once and has never been observed refusing — read this before cutting -**No merge-triggered release has ever run in this repository, and the central version-changed guard -has never been observed either refusing or firing.** The `0.1.0` cut went out by `workflow_dispatch`, -which is the *unconditional* path — so the guard's discrimination was never exercised even then. It -ships proven in self-test and **unproven in production**. +**The merge path has run exactly once.** `0.1.1` was cut on 2026-08-07 by merging the version-bump +PR (#186): its signing certificate carries `github_workflow_trigger = pull_request` at +`github_workflow_sha = f3b9ed6`, the merge commit. The central version-changed guard has therefore +been observed **firing** on a real version change. The `0.1.0` cut before it went out by +`workflow_dispatch`, the *unconditional* path, which exercises no discrimination at all. + +**What has never been observed is the guard REFUSING.** No merge leaving the declared version +unchanged has been watched arriving at the workflow and being turned away. That half ships proven in +self-test and **unproven here**. That is not a reason to avoid the merge path. It is a reason to **read the run** rather than assume it: after the merge, open the `Release` run and observe what the `release` and `publish-image` jobs @@ -113,9 +119,8 @@ actually did (Step 5, *Path A*), instead of inferring the outcome from the merge ## Ported reference — the facts this procedure depends on These six sections are the operative content of `doc/development/release-process.adoc`. They are -reproduced here so the runbook can be followed cold; that document remains the canonical narrative -**for everything except the trigger, where it is currently stale** — see *See also*. None of the six -sections below concerns the trigger, so all six stand. +reproduced here so the runbook can be followed cold; that document remains the canonical narrative. +None of the six sections below concerns the trigger, so all six stand on their own terms. ### The image version is the Maven version @@ -456,7 +461,7 @@ anything else open can land ahead of it and move the commit your release is cut > team, and do not merge anything yourself, from the cut until Step 7 reports the run finished. > No branch policy, merge freeze or workflow check enforces this — it is an operator obligation, > and a merge landing inside that window can still race the release force-push. Treat it as a -> residual operator risk, not a closed safeguard (ADR-0034 records it as one). +> residual operator risk, not a closed safeguard (ADR-0035 records it as one). **(iv) Confirm no tag for the release version already exists.** @@ -1068,18 +1073,13 @@ authenticated check passes. ## See also -- `doc/development/release-process.adoc` — the canonical process narrative. **It is currently STALE - on the trigger and must not be read as authoritative there:** it still describes the dispatch-only - posture (§ *Why the `pull_request` trigger was removed*, § *Why the guard had to merge before - anything else*), which PR #171 superseded. Its rewrite is tracked follow-up work. What it records - that **does** still hold is the rule that *a change which removes or weakens an event-driven - trigger merges on its own*. For everything about **what fires a release**, this file and - `.github/workflows/release.yml` are the current sources; **this file is also the surviving - narrative account of the 2026-07-12 incident** — see *How the release is wired* above. The former - *Coordinate history* note in `README.adoc` was removed once `de.cuioss.sheriff.api` had been - abandoned long enough that the history was of no use to a reader arriving at the project. - **ADR-0034 is likewise stale** — it records the dispatch-only decision and is superseded by - ADR-0035, which `release.yml`'s header already references. +- `doc/development/release-process.adoc` — the canonical process narrative, including the two release + paths, the guard's role, and the rule that *a change which removes or weakens an event-driven + trigger merges on its own*. **This file is also the surviving narrative account of the 2026-07-12 + incident** — see *How the release is wired* above. The former *Coordinate history* note in + `README.adoc` was removed once `de.cuioss.sheriff.api` had been abandoned long enough that the + history was of no use to a reader arriving at the project. **ADR-0034 is superseded by ADR-0035**, + which records the guarded-merge-or-dispatch decision and which `release.yml`'s header references. - `.github/workflows/release.yml` — the release workflow, and the authoritative statement of both triggers. Its in-file comments carry the guard rationale, the run/skip-vs-value scope split, and the composability, ordering and attestation caveats behind the steps above. From 849f93e33a8538327617844f6d24b758d18cb28f Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:14:56 +0200 Subject: [PATCH 06/13] docs(release): update the contributor-guide row and the published release-flow diagram The doc/development/README.adoc Contents row stated the retired posture four times over -- the dispatch-only link text, 'then deliberately dispatch', 'what one dispatch produces', and 'dispatch is the only trigger'. Rewritten to match the reconciled narrative. release-publish-flow.svg is rendered into the published documentation by release-process.adoc and carried the posture in four places, including a visible in-diagram note reading 'Manual dispatch is the only trigger -- no event-driven trigger exists on the release workflow', which is flatly false in operator-facing output. The authoring comment, the desc accessibility text, the visible note and the entry stage label plus its sub-label now all name both paths. viewBox, palette, style classes, theme-handling declaration and stage geometry are unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CyPQuo2bwuyPL6DWPdabZQ --- doc/development/README.adoc | 14 ++++++++------ doc/resources/diagrams/release-publish-flow.svg | 15 ++++++++------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/doc/development/README.adoc b/doc/development/README.adoc index 89a9a24b..2ce8b407 100644 --- a/doc/development/README.adoc +++ b/doc/development/README.adoc @@ -30,13 +30,15 @@ to *run* the finished gateway). merging over a red or stale-green gate, PR-new-code vs post-merge project-gate auditability, and the fix-by-default / suppress-with-rationale escape hatch. -| link:release-process.adoc[Release Process -- dispatch-only] -| How a release is cut -- declare the version in `.github/project.yml`, then deliberately dispatch - the Release workflow -- what one dispatch produces (Maven coordinates, the GHCR image, its SBOM - and its Cosign signature), why the image version is a checked fact rather than a convention, the - test-which-builds/push-that-digest inversion that makes "push exactly what was tested" true by +| link:release-process.adoc[Release Process] +| How a release is cut -- merge a version change in `.github/project.yml`, which is the publishing + act once the centrally-owned version-changed guard lets it through, with `workflow_dispatch` as the + unconditional fallback -- what one release run produces (Maven coordinates, the GHCR image, its + SBOM and its Cosign signature), why the image version is a checked fact rather than a convention, + the test-which-builds/push-that-digest inversion that makes "push exactly what was tested" true by construction, the two-tag scheme and the deliberate absence of `:latest`, and the two trigger - rules -- dispatch is the only trigger, and a trigger removal merges on its own. + rules -- the version-changed guard is the release decision, and a trigger removal merges on its + own. | link:protocol-processors.adoc[Protocol Processors -- SPI and dispatch seam] | The `ProtocolProcessor` SPI, the boot-time registry that binds a protocol to its processor, and diff --git a/doc/resources/diagrams/release-publish-flow.svg b/doc/resources/diagrams/release-publish-flow.svg index 94c50acd..0233b8c7 100644 --- a/doc/resources/diagrams/release-publish-flow.svg +++ b/doc/resources/diagrams/release-publish-flow.svg @@ -1,8 +1,9 @@ @@ -10,7 +11,7 @@ role="img" aria-labelledby="title desc" font-family="ui-sans-serif, -apple-system, system-ui, 'Segoe UI', sans-serif"> API Sheriff release publish flow - Two independent tracks. On the release lane, a manual workflow_dispatch cuts the Maven release, the publish job checks out the release tag and asserts the version, and the integration-test suite runs — that suite's docker compose build is the ONE and ONLY image build in the lane, so what is published is by construction exactly what was tested. A Syft SBOM and a gating Trivy scan at HIGH and CRITICAL then run before any registry write; a finding at either severity fails the release. Only then are the two tags pushed, the published digest pulled back and asserted identical to the tested image, and that same digest signed keylessly with Cosign. On the pull-request lane, an independent non-gating scan of the pinned base image and the repository filesystem reports on every change without ever building an image or failing the check. The two lanes never converge. + Two independent tracks. The release lane is entered by either of two triggers — merging a version change that the centrally-owned version-changed guard admits, or a deliberate workflow_dispatch. From there the Maven release is cut, the publish job checks out the release tag and asserts the version, and the integration-test suite runs — that suite's docker compose build is the ONE and ONLY image build in the lane, so what is published is by construction exactly what was tested. A Syft SBOM and a gating Trivy scan at HIGH and CRITICAL then run before any registry write; a finding at either severity fails the release. Only then are the two tags pushed, the published digest pulled back and asserted identical to the tested image, and that same digest signed keylessly with Cosign. On the pull-request lane, an independent non-gating scan of the pinned base image and the repository filesystem reports on every change without ever building an image or failing the check. The two lanes never converge. - Manual dispatch is the only trigger — no event-driven trigger exists on the release workflow + Two entry paths — a version-bump merge the central guard admits, or a deliberate dispatch Release lane @@ -51,7 +52,7 @@ - Dispatch + Trigger Maven release Check out tag IT suite @@ -61,7 +62,7 @@ Cosign sign - operator-initiated + guarded mergeor dispatch pushes the release tag assert version match THE one image build From 3635c479dd72d1b7017ebf06876e27eb4505adfd Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:27:33 +0200 Subject: [PATCH 07/13] chore(release): retire the relocation-stubs guard from the runbook The stubs branch's job finished: the relocation is complete and permanent, immortalised in the published 1.0.1 POM on Maven Central. What the branch still cost was a standing never-merge clause in the release runbook -- a permanent trap guarding a branch whose work is done. Removes both carriers in the same change: the pre-cut guard block in Step 2 (with its fetch and ancestry check) and the Critical-rules restatement. Also clears one orphaned reference to the removed guard in the Step 1 enumeration of pre-cut assertions. The forward-looking recovery knowledge is deliberately retained -- 'if a version must be abandoned, cut a patch version and publish relocation stubs' is a procedure for a future accident, not a statement about this branch -- as is the 2026-07-12 incident record. Provenance is preserved by the tag relocation-stubs-published, pushed to origin at 67c8339 and confirmed on the remote before any deletion. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CyPQuo2bwuyPL6DWPdabZQ --- .claude/skills/release/SKILL.md | 48 +++------------------------------ 1 file changed, 4 insertions(+), 44 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 813031b3..7fdf8dcb 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -309,10 +309,10 @@ have passed.' > shell would run whatever sat between them. > **THIS PR IS THE RELEASE. Do not merge it here.** Steps 2, 3 and 4 are the pre-cut safety -> assertions, and on Path A they exist to gate *this merge* — the Trivy posture, the stubs-branch -> boundary, the tag-absence check, the green-`main` gate and the quiesced queue all belong **before** -> the publishing act, not after it. Merging now would run every one of them on an irrevocable -> release that has already happened. +> assertions, and on Path A they exist to gate *this merge* — the Trivy posture, the tag-absence +> check, the green-`main` gate and the quiesced queue all belong **before** the publishing act, not +> after it. Merging now would run every one of them on an irrevocable release that has already +> happened. ### Step 2 — Confirm the tree and the pull-request queue are clean @@ -341,43 +341,6 @@ branch is **up to date with `origin/main`** (`git merge-base --is-ancestor origi commit the queue produces is the `origin/main` the rest of this step gates on plus the version bump and nothing else. -> **HARD BOUNDARY — `release/relocation-stubs`.** That branch exists on the remote and **must NEVER -> be merged**. It carries the relocation-only stubs published under the abandoned -> `de.cuioss.sheriff.api` coordinates. The release must not pick it up. Confirm it is not in the -> ancestry of what you are about to release: -> -> ```bash -> git fetch origin '+refs/heads/release/relocation-stubs:refs/remotes/origin/release/relocation-stubs' \ -> || { echo "ERROR: could not fetch the stubs branch - the check did not evaluate" >&2; exit 1; } -> git merge-base --is-ancestor origin/release/relocation-stubs origin/main; case $? in -> 0) echo "STOP: stubs branch is in main" >&2; exit 1 ;; -> 1) echo "OK: stubs branch is not in main" ;; -> *) echo "ERROR: the check did not evaluate - this is NOT a pass" >&2; exit 1 ;; -> esac -> ``` -> -> **FULLY QUALIFY THE SOURCE REF, AND FORCE IT — `+refs/heads/…`, never the bare branch name.** -> The unqualified form `git fetch origin release/relocation-stubs:refs/remotes/origin/…` **DELETES -> the remote-tracking ref instead of creating it** on any clone with `fetch.prune=true` (or -> `remote.origin.prune=true`), which is a common global setting. `git fetch` then reports -> `- [deleted] (none) -> origin/release/relocation-stubs`, the very next line dies with -> `fatal: Not a valid object name origin/release/relocation-stubs`, and the guard lands on its -> `*)` arm. Observed on the 0.1.0 cut: the branch was present on the remote the whole time. -> -> **That failure looks exactly like the branch having been deleted, and it is not.** Before -> concluding anything from an `ERROR` here, check the remote directly — `git ls-remote --heads -> origin | grep relocation` — and re-run with the qualified refspec above. Never "resolve" it by -> skipping the check or appending `|| true`; the whole point of this boundary is that it fails -> closed. -> -> **Branch on the exit code, never on `&&` / `||`.** `git merge-base --is-ancestor` exits `0` for -> ancestor, `1` for not-ancestor and `128` on error — and an absent -> `origin/release/relocation-stubs` remote-tracking ref (a single-branch or shallow clone, a clone -> predating the branch, or the prune misfire above) is exactly such an error. A -> `… && echo STOP || echo OK` idiom collapses `1` and `128` into the same branch, so a check that -> never ran prints the reassuring `OK`. **An error is not a pass**, and the explicit `git fetch` -> above is what stops the common case from reaching that arm at all. - ### Step 3 — Re-assert the pre-cut safety evidence (MANDATORY) The plan that authored this skill recorded a baseline and the method. **Items (i), (iii) and (iv) @@ -1035,9 +998,6 @@ authenticated check passes. - **Nothing merges to `main` between the cut and run completion** — the release force-pushes to `main` twice as a queue bypass actor. On Path A that window opens at **enqueue**, not at run start. This one is unenforced: it is an operator obligation, not a mechanism. -- **`release/relocation-stubs` must NEVER be merged**, and the release must not pick it up. Fetch it - with the **fully-qualified forced refspec** (`+refs/heads/…`); the bare branch name self-prunes - under `fetch.prune=true` and the guard then reports `ERROR` for a branch that is still there. - **Set the GHCR package to *public*, not *internal*, and assert the `visibility` field** rather than inferring it from a pull. Internal passes every authenticated check while anonymous pulls 401. - **Update the version-bearing examples (Step 10) after the image is public**, and delete any caveat From deeded660e16673638c9f0f922b88de2d579806b Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:21:37 +0200 Subject: [PATCH 08/13] docs(release): fix two self-review defects in the release runbook Both were introduced by this plan's own edits and caught by the pre-submission self-review gate: - Step 5 Path A pointed at "The guard has NEVER been observed", a heading this plan renamed. The dangling pointer also asserted the opposite of the section it targeted: that no cut had been read off a run. Repoint it and state the real position - exactly one merge-triggered cut (0.1.1) has been read, so a firing guard confirms that precedent while a refusing guard would still be a first observation. - The post-cut merge-hold note read "(ADR-0035 records it as one)", whose pronoun binds by nearest antecedent to "a closed safeguard" - inverting a safety-critical posture. ADR-0035 says the opposite twice. Name the referent explicitly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CyPQuo2bwuyPL6DWPdabZQ --- .claude/skills/release/SKILL.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 7fdf8dcb..242e96a6 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -424,7 +424,8 @@ anything else open can land ahead of it and move the commit your release is cut > team, and do not merge anything yourself, from the cut until Step 7 reports the run finished. > No branch policy, merge freeze or workflow check enforces this — it is an operator obligation, > and a merge landing inside that window can still race the release force-push. Treat it as a -> residual operator risk, not a closed safeguard (ADR-0035 records it as one). +> residual operator risk, not a closed safeguard — ADR-0035 records it as exactly that residual +> risk, and states plainly that nothing enforces the post-dispatch merge hold. **(iv) Confirm no tag for the release version already exists.** @@ -551,8 +552,9 @@ gh run view "$RUN_ID" --repo cuioss/API-Sheriff --json displayTitle,headBranch,h > confirm it names the version-bump PR before watching it. **Now READ THE RUN. Do not infer the outcome from the merge having happened** — see *The guard has -NEVER been observed* above; until a cut has actually been read off a run, every outcome here is a -first observation rather than a confirmation: +fired once and has never been observed refusing* above. Exactly one merge-triggered cut (`0.1.1`) has +been read off a run, so a firing guard is a confirmation of that single precedent; a *refusing* guard +would still be a first observation: | what the run shows | what decided it | published? | |---|---|---| From 4e60c3e675339832d368a15014d3ad8c5cfcd3ca Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:28:25 +0200 Subject: [PATCH 09/13] docs(release): name the referent in the See-also incident-narrative claim The See-also rewrite deleted the clause that had disambiguated its own pronoun. The bullet's subject is doc/development/release-process.adoc, so "This file is also the surviving narrative account of the 2026-07-12 incident" read as a claim about that document - which mentions the date once, in passing. The account actually lives in this runbook (line 80 claims exactly that role) and in ADR-0035. Name the referent so the two competing claims collapse to one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CyPQuo2bwuyPL6DWPdabZQ --- .claude/skills/release/SKILL.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 242e96a6..70957e15 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -1037,8 +1037,9 @@ authenticated check passes. - `doc/development/release-process.adoc` — the canonical process narrative, including the two release paths, the guard's role, and the rule that *a change which removes or weakens an event-driven - trigger merges on its own*. **This file is also the surviving narrative account of the 2026-07-12 - incident** — see *How the release is wired* above. The former *Coordinate history* note in + trigger merges on its own*. **This runbook — not `release-process.adoc` — carries the surviving + narrative account of the 2026-07-12 incident**; see *How the release is wired* above, and ADR-0035 + for the decision record. The former *Coordinate history* note in `README.adoc` was removed once `de.cuioss.sheriff.api` had been abandoned long enough that the history was of no use to a reader arriving at the project. **ADR-0034 is superseded by ADR-0035**, which records the guarded-merge-or-dispatch decision and which `release.yml`'s header references. From 4ce9142c85d0d9df7721083beecac44f90fa7a8d Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:33:57 +0200 Subject: [PATCH 10/13] chore(simplify): drop the vestigial coordinate-history tombstone from the release runbook See-also The sentence documented an absence in a different file than the bullet's stated subject, carried no rule, guard or actionable pointer, and was carried over verbatim rather than authored by this plan. The de.cuioss.sheriff.api history it gestured at is already recorded twice in live form: the incident section earlier in this runbook, and ADR-0034/0035. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CyPQuo2bwuyPL6DWPdabZQ --- .claude/skills/release/SKILL.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 70957e15..69a397f1 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -1039,10 +1039,8 @@ authenticated check passes. paths, the guard's role, and the rule that *a change which removes or weakens an event-driven trigger merges on its own*. **This runbook — not `release-process.adoc` — carries the surviving narrative account of the 2026-07-12 incident**; see *How the release is wired* above, and ADR-0035 - for the decision record. The former *Coordinate history* note in - `README.adoc` was removed once `de.cuioss.sheriff.api` had been abandoned long enough that the - history was of no use to a reader arriving at the project. **ADR-0034 is superseded by ADR-0035**, - which records the guarded-merge-or-dispatch decision and which `release.yml`'s header references. + for the decision record. **ADR-0034 is superseded by ADR-0035**, which records the + guarded-merge-or-dispatch decision and which `release.yml`'s header references. - `.github/workflows/release.yml` — the release workflow, and the authoritative statement of both triggers. Its in-file comments carry the guard rationale, the run/skip-vs-value scope split, and the composability, ordering and attestation caveats behind the steps above. From 832f4ae5cefcdab1a6ced4298ec6ae6e2088119b Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:46:46 +0200 Subject: [PATCH 11/13] fix(release): enforce the dispatch main-ref invariant and state both guard predicates Two CodeRabbit findings on PR #195, both confirmed against the live tree. The narrowed Cosign identity accepts exactly one ref, refs/heads/main, and justifies that by claiming both events able to reach the lane yield it. The merge path enforces the claim through `branches: [main]`; the dispatch path only assumed it, because `workflow_dispatch:` takes no branch filter. The `release` job's `if:` now carries a dispatch-scoped `github.ref == 'refs/heads/main'` operand, so a non-main dispatch is refused before the Maven publication rather than after it. The `pull_request` operand is unchanged, and the ref test is deliberately not extended to the merge path, which is already base-filtered. release-process.adoc described only one of the central guard's two refusal predicates. `.claude/skills/release/SKILL.md` states both, and the runbook is right: the guard refuses when the declared version did not move OR when a tag for it already exists. The document now names both, with an anchor the other sections reference. The load-bearing case is the image-lane-failure procedure, which is by definition the tag-exists scenario yet attributed a no-release outcome to the version predicate alone -- the misdiagnosis that sends an operator to the unconditional dispatch and republishes a published coordinate. Co-Authored-By: Claude --- .github/workflows/release.yml | 52 +++++++++++++++++++++------- doc/development/release-process.adoc | 37 ++++++++++++++++---- 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc04134e..b2d8625f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,14 +40,37 @@ permissions: jobs: release: - # ANDed with the central version-changed guard inside the called workflow, NEVER an alternative - # to it. Its merit is defence-in-depth plus not spending a runner on a close-without-merge — it - # is NOT here because the central guard is blind to merged-ness; that guard checks merged-ness - # too. - # Written event-first so the dispatch path still runs: on workflow_dispatch there is no - # pull_request context, so the first operand is true and the condition short-circuits. This is - # the same shape .github/workflows/benchmark.yml already ships. - if: github.event_name != 'pull_request' || github.event.pull_request.merged == true + # TWO OPERANDS DOING TWO DIFFERENT JOBS. Both are ANDed with the central version-changed guard + # inside the called workflow, NEVER an alternative to it. + # + # THE DISPATCH OPERAND — `github.event_name != 'workflow_dispatch' || github.ref == + # 'refs/heads/main'`. `workflow_dispatch:` above takes no branch filter (GitHub offers none), so + # a dispatch can be launched from any branch or tag. The signing-identity block on publish-image + # below (§ "THE IDENTITY NAMES EXACTLY ONE REF") narrows the accepted Cosign identity to the + # single ref refs/heads/main, resting that narrowing on the claim that both events able to reach + # the lane yield refs/heads/main. On the merge path `branches: [main]` above ENFORCES that claim; + # on the dispatch path nothing did — it was assumed. This operand is what makes it a checked + # fact, and it sits on the release job so a non-main dispatch is refused BEFORE the Maven + # publication rather than after it. The Cosign verify step is not the enforcement point: it + # matches a certificate against an already-produced signature, which is long after the jars are + # on Maven Central. Do not restate the certificate evidence here — it lives in that block. + # + # THE MERGE OPERAND — `github.event_name != 'pull_request' || github.event.pull_request.merged + # == true`. Its merit is defence-in-depth plus not spending a runner on a close-without-merge — + # it is NOT here because the central guard is blind to merged-ness; that guard checks + # merged-ness too. + # + # Both operands are written event-first so each path still runs: the event a given operand does + # not govern makes its first disjunct true and short-circuits the operand. This is the same shape + # .github/workflows/benchmark.yml already ships. + # + # The main-ref test is DELIBERATELY dispatch-scoped. The merge path is already base-filtered by + # `branches: [main]`, so extending the ref test to it would stake this project's primary release + # path on an assumption about github.ref under a closed-merge event, buying no guarantee that the + # base filter does not already provide. + if: >- + (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main') + && (github.event_name != 'pull_request' || github.event.pull_request.merged == true) permissions: contents: write uses: cuioss/cuioss-organization/.github/workflows/reusable-maven-release.yml@937fbcf44d34d826b3bcd66d49d7a7d32030f39a # v0.18.0 @@ -398,12 +421,17 @@ jobs: # certificate SAN. publish-image is a CALLER-LOCAL job — it does not `uses:` a reusable # workflow — so that claim names release.yml ITSELF (never the org reusable workflow) at the ref # the run was triggered on, i.e. GITHUB_REF. Only two events can reach this job, and BOTH of - # them yield refs/heads/main: + # them yield refs/heads/main — each one because the release job's `if:` or the trigger's own + # filter refuses the alternative, never because the alternative is merely unlikely: # - # * workflow_dispatch from main -> GITHUB_REF = refs/heads/main + # * workflow_dispatch -> GITHUB_REF = refs/heads/main. `workflow_dispatch:` carries no branch + # filter, so this is not a property of the trigger; the `github.ref == 'refs/heads/main'` + # operand on the release job above is what refuses a dispatch from any other ref, and it + # refuses it before the Maven release runs. # * a MERGED-close .github/project.yml PR -> GITHUB_REF = refs/heads/main, the base branch it - # was merged INTO. A close WITHOUT a merge never reaches here at all: the - # `merged == true` gate on the release job above refuses it before anything runs. + # was merged INTO, which `branches: [main]` on the trigger confines to main. A close + # WITHOUT a merge never reaches here at all: the `merged == true` gate on the release job + # above refuses it before anything runs. # # THIS IS NOT ARGUED FROM DOCUMENTATION — IT IS READ OFF OUR OWN CERTIFICATES. Both signed # digests this project has ever published carry the same single ref in their SAN: diff --git a/doc/development/release-process.adoc b/doc/development/release-process.adoc index b004f144..f631a046 100644 --- a/doc/development/release-process.adoc +++ b/doc/development/release-process.adoc @@ -18,7 +18,8 @@ The ordinary path is a single act -- merge the version declaration: version. Open a pull request with that change. . *Merge it.* Merging into `main` cuts the release. The Release workflow runs on the merged close, and the pinned cuioss-organization workflow's *version-changed guard* decides whether this merge - is a release: it publishes when the declared version actually moved, and refuses when it did not. + is a release. It publishes when the declared version actually moved *and* no tag for that version + exists yet, and refuses on either condition failing -- see <>. *The merge is the publishing act, so review the pull request as one.* Approving a version bump is approving a Maven Central publication, and a published coordinate can be superseded but never @@ -27,8 +28,10 @@ withdrawn. *`workflow_dispatch` is the fallback, and it is unconditional.* Actions -> Release -> Run workflow publishes whatever `.github/project.yml` declares at the dispatched ref, including a re-declaration of a version already published; nothing central refuses it. Reach for it when the merge path did not -fire -- and when a merge cut no release, check whether the declared version actually moved before -dispatching, because that is what the guard was answering. +fire -- and when a merge cut no release, establish *which* of the guard's two conditions refused it +before dispatching (<>). Only an unmoved version is a reason to dispatch. +An already-existing tag means that version was already released, and dispatching then republishes a +published coordinate. link:../../.github/workflows/release.yml[`.github/workflows/release.yml`] delegates to the cuioss-organization reusable Maven release workflow, pinned to a specific commit SHA. Neither the @@ -81,8 +84,18 @@ a step runs "before anything is pushed", read it as *before anything is pushed t . *Do not re-trigger the whole workflow.* A second dispatch would attempt another Maven release of a version that is already published -- the dispatch path is unconditional and nothing central refuses - it. The merge path is no remedy either: re-merging the same version declaration moves nothing, so - the version-changed guard refuses it and no release runs at all. + it. The merge path is no remedy either, and *for two reasons rather than one*: re-merging the same + version declaration moves nothing, so the guard's version condition refuses it; and an edit that + makes the released version look like it moved again still publishes nothing, because the release + job pushed that version's tag before this lane failed and tag-absence is the guard's second + condition (<>). You are in the tag-exists case here by definition. ++ +[WARNING] +==== +Do not read that silent no-release as "the version didn't move" and reach for the dispatch on the +strength of it. The dispatch refuses nothing, so acting on that diagnosis republishes an +already-published coordinate -- the failure class this whole procedure exists to prevent. +==== . *Classify the failure first -- a re-run cannot pick up a fix.* *Re-run failed jobs* creates a new *attempt of the same run*: it reuses the original event context and the original workflow definition, and `publish-image` checks out the *release tag*, not `main`. A commit merged to `main` @@ -214,13 +227,23 @@ cryptographically verifying the published image. Two rules govern how the release workflow may be triggered, and how a trigger may be changed. Both are normative. +[#guard-refusal-predicates] === The version-changed guard is the release decision link:../../.github/workflows/release.yml[`.github/workflows/release.yml`] declares two triggers: an unconditional `workflow_dispatch`, and a `pull_request` narrowed by `types: [closed]`, `branches: [main]` and `paths: ['.github/project.yml']`. On the merge path the question *"is this -merge a release?"* is answered by the pinned cuioss-organization workflow's *version-changed guard*, -which compares the declared version against the parent commit's and refuses when it did not move. +merge a release?"* is answered by the pinned cuioss-organization workflow's *version-changed guard*. + +*It refuses on either of two conditions, and a refusal announces neither of them:* + +. The declared `current-version` did not move against the parent commit. +. A tag for the declared version already exists. + +Both must be false for the merge to publish. The guard is named for the first condition because that +is what it was added for, but reading a no-release outcome as "the version didn't move" is a +one-predicate reading of a two-predicate guard -- and it is exactly the misreading that sends an +operator to the unconditional dispatch after a partial release (see <>). *That guard is consumed, never re-implemented here.* A local copy of a security-relevant decision is a second answer, and the local copy is the one that drifts out of sync. Do not add a caller-local From 6ad26507613c0e599d6737d4063aa1a4e9939913 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:49:33 +0200 Subject: [PATCH 12/13] docs(release): drop the orphaned stubs-check pointer and re-quote the release if: CodeRabbit finding e3a37b, plus one staleness this PR's own D1 introduced. The tag-absence rationale opened by pointing at "the same discipline as the stubs check in Step 2". D7 retired the relocation-stubs procedure in this PR and Step 2 is now an open-PR count check, so the pointer led nowhere. The clause is dropped rather than repointed: the paragraph IS the exact tag check's rationale, so a pointer to that check would be circular, and the remaining text already explains the discipline completely. Sweeping the rest of the file surfaced a second orphan of the same kind. Step 3(i) check 2 quotes the release job's `if:` verbatim as a four-way stop-gate, and D1 just changed that expression. An operator re-asserting the trigger at the base-branch SHA would have found a mismatch and treated a correct workflow as a stop. The check now quotes both operands and says what the new one buys. No other surviving reference to the retired stubs procedure remains; the abandoned-version remedy at the ported "image lane fails" section is the live mirror of release-process.adoc, not an orphan. Co-Authored-By: Claude --- .claude/skills/release/SKILL.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 69a397f1..11f90cc4 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -365,8 +365,13 @@ Confirm **all four**, and treat any one of them failing as a stop: 1. The `on:` block carries **exactly** `workflow_dispatch` plus the `pull_request` trigger with `types: [closed]`, `branches: [main]`, `paths: ['.github/project.yml']` — **and nothing else.** No `push`, no `schedule`, no additional path, no widened `branches:`, no extra `types:`. -2. The `release` job still carries its merged-ness gate: - `if: github.event_name != 'pull_request' || github.event.pull_request.merged == true`. +2. The `release` job's `if:` still carries **both** operands, ANDed: + `(github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')` and + `(github.event_name != 'pull_request' || github.event.pull_request.merged == true)`. The second is + the merged-ness gate. The first is what confines a dispatch to `main` — `workflow_dispatch:` + carries no branch filter of its own, and the Cosign identity Step 8 verifies accepts only + `refs/heads/main`, so losing that operand would let a dispatch from any ref publish under an + identity the project's own verification command then rejects. 3. The `publish-image` job still carries `if: needs.release.outputs.released-version != ''` — the central guard's own verdict, surfaced. Without it a guard-skipped merge would still spend 90 minutes on a native build and then re-push and re-sign an already-released tag. @@ -447,12 +452,12 @@ esac **Both must report `OK`. Anything else stops the release** — including `ERROR`, which means the check never evaluated and is therefore not a pass. -> **Match the full ref, and branch on the exit code — the same discipline as the stubs check in -> Step 2.** `git tag --list ''` and `git ls-remote … | grep …` only *print*; a procedure -> that reads their output by eye is not a guard, and a re-run that slips past it reaches the -> workflow's `force: true` tag push. `grep -w ''` is doubly wrong: `.` is a regex -> any-character, and `-w` treats `-` as a word boundary, so `-w '0.1.0'` also matches -> `refs/tags/0.1.0-rc1`. The exact `refs/tags/` forms above match one ref and nothing else. +> **Match the full ref, and branch on the exit code.** `git tag --list ''` and +> `git ls-remote … | grep …` only *print*; a procedure that reads their output by eye is not a guard, +> and a re-run that slips past it reaches the workflow's `force: true` tag push. `grep -w ''` +> is doubly wrong: `.` is a regex any-character, and `-w` treats `-` as a word boundary, so +> `-w '0.1.0'` also matches `refs/tags/0.1.0-rc1`. The exact `refs/tags/` forms above match +> one ref and nothing else. > > **Why this is checked directly:** the tag push is `force: true`, so a re-run can **MOVE** an > existing release tag rather than refusing. `release:prepare` would likely fail first, but that is From 4b31f4ed5762457b8d183fcd6b0eeea46fbe17e6 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:06:00 +0200 Subject: [PATCH 13/13] docs(release): sweep the ungated-dispatch claims the new ref gate invalidated TASK-009 added a real gate on the dispatch path (the release job's if: confines a dispatch to refs/heads/main), but six sites across two files still described that path as unconditional. Same root defect, one sweep. .claude/skills/release/SKILL.md - the READ-THIS-FIRST path table row an operator reads first - Path B's opening characterisation - the Critical rule about dispatching over an undiagnosed refusal doc/development/release-process.adoc - the intro sentence - the "workflow_dispatch is the fallback" paragraph - the normative Trigger rules section, which omitted the new operand entirely The drift ran toward under-claiming safety, so nothing risked an unsafe publication; the cost was an operator expecting a dispatch from a release branch to publish and getting a silently skipped job with no documented explanation. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CyPQuo2bwuyPL6DWPdabZQ --- .claude/skills/release/SKILL.md | 12 +++++++----- doc/development/release-process.adoc | 28 +++++++++++++++++----------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 11f90cc4..8922d745 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -43,7 +43,7 @@ on: | Path | When it fires | Role | |---|---|---| | **Merge of a version bump** — a `pull_request` closed with `merged == true`, base `main`, touching `.github/project.yml` | only when the **central guard** inside the pinned `cuioss-organization` reusable workflow sees `release.current-version` actually changed between the merge commit and its first parent, **and** no tag for that version already exists | the ordinary way a release is cut | -| **`workflow_dispatch`** | always, unconditionally — nothing gates this path but the operator, and that includes re-releasing an unchanged version | the deliberate fallback: recover a failed lane, or cut a version whose declaration already landed | +| **`workflow_dispatch`** | whenever dispatched **from `main`** — the `release` job's `if:` confines the dispatch path to `refs/heads/main`, and nothing else gates it, which includes re-releasing an unchanged version | the deliberate fallback: recover a failed lane, or cut a version whose declaration already landed | **State them as a pair.** An operator whose merge did *not* cut a release needs to know which mechanism decided that before reaching for the other path — Step 5 *Path A* carries that diagnosis. @@ -588,9 +588,11 @@ git ls-remote --refs --tags origin 'refs/tags/' #### Path B — dispatch, deliberately -The unconditional fallback: recover a lane that failed after a correct guard decision, or cut a -version whose declaration already landed on `main`. **Nothing central refuses on this path** — the -operator is the only gate, which is why Steps 2–4 are not optional here either. +The fallback: recover a lane that failed after a correct guard decision, or cut a version whose +declaration already landed on `main`. The `release` job's `if:` confines a dispatch to +`refs/heads/main` — a dispatch from any other ref is skipped, silently. Past that ref test +**nothing central refuses on this path**: the operator is the only remaining gate, which is why +Steps 2–4 are not optional here either. **Record the pre-dispatch high-water mark first.** Run ids increase monotonically, so the newest existing `Release` run id is what lets the next step tell *your* dispatch apart from one that was @@ -996,7 +998,7 @@ authenticated check passes. Never merge a version bump and then run the safety work. - **A merge that cut no release is a verdict to read, not a reason to dispatch.** Diagnose which mechanism refused — prefilter, `merged == true`, or the central guard — before reaching for Path B. - Dispatch is unconditional and will publish over an undiagnosed refusal. + A dispatch from `main` faces no central refusal and will publish over an undiagnosed one. - **Re-assert items (i), (iii) and (iv) at cut time.** They are time-varying and are never inherited from a recorded baseline. - **Dispatch only after re-reading `origin/main`.** Step 5 Path B aborts non-zero unless it still diff --git a/doc/development/release-process.adoc b/doc/development/release-process.adoc index f631a046..1d8019da 100644 --- a/doc/development/release-process.adoc +++ b/doc/development/release-process.adoc @@ -5,9 +5,9 @@ How a release of API Sheriff is cut. A release has two entry points: merging a pull request that changes the declared version in `.github/project.yml`, which *is* the publishing act once the -centrally-owned version-changed guard lets it through, and a deliberate `workflow_dispatch`, which -publishes unconditionally and is the operator-gated fallback. This page states the process and the -rules that keep it safe. +centrally-owned version-changed guard lets it through, and a deliberate `workflow_dispatch` from +`main`, which faces no central refusal and is the operator-gated fallback. This page states the +process and the rules that keep it safe. == Cutting a release @@ -25,9 +25,11 @@ The ordinary path is a single act -- merge the version declaration: approving a Maven Central publication, and a published coordinate can be superseded but never withdrawn. -*`workflow_dispatch` is the fallback, and it is unconditional.* Actions -> Release -> Run workflow -publishes whatever `.github/project.yml` declares at the dispatched ref, including a re-declaration -of a version already published; nothing central refuses it. Reach for it when the merge path did not +*`workflow_dispatch` is the fallback, and past one ref test it is unconditional.* The `release` +job's `if:` confines the dispatch path to `refs/heads/main`; a dispatch from any other ref is +skipped silently and publishes nothing. Dispatched from `main`, Actions -> Release -> Run workflow +publishes whatever `.github/project.yml` declares there, including a re-declaration of a version +already published -- nothing central refuses it. Reach for it when the merge path did not fire -- and when a merge cut no release, establish *which* of the guard's two conditions refused it before dispatching (<>). Only an unmoved version is a reason to dispatch. An already-existing tag means that version was already released, and dispatching then republishes a @@ -230,10 +232,13 @@ are normative. [#guard-refusal-predicates] === The version-changed guard is the release decision -link:../../.github/workflows/release.yml[`.github/workflows/release.yml`] declares two triggers: an -unconditional `workflow_dispatch`, and a `pull_request` narrowed by `types: [closed]`, -`branches: [main]` and `paths: ['.github/project.yml']`. On the merge path the question *"is this -merge a release?"* is answered by the pinned cuioss-organization workflow's *version-changed guard*. +link:../../.github/workflows/release.yml[`.github/workflows/release.yml`] declares two triggers: a +`workflow_dispatch` carrying no branch filter, and a `pull_request` narrowed by `types: [closed]`, +`branches: [main]` and `paths: ['.github/project.yml']`. The `release` job's `if:` then narrows each +path once more -- the dispatch path to `refs/heads/main`, the merge path to `merged == true` -- so a +dispatch from a non-`main` ref is skipped before anything is published. On the merge path the +question *"is this merge a release?"* is answered by the pinned cuioss-organization workflow's +*version-changed guard*. *It refuses on either of two conditions, and a refusal announces neither of them:* @@ -243,7 +248,8 @@ merge a release?"* is answered by the pinned cuioss-organization workflow's *ver Both must be false for the merge to publish. The guard is named for the first condition because that is what it was added for, but reading a no-release outcome as "the version didn't move" is a one-predicate reading of a two-predicate guard -- and it is exactly the misreading that sends an -operator to the unconditional dispatch after a partial release (see <>). +operator to the dispatch fallback after a partial release (see <>), where no +central guard refuses at all. *That guard is consumed, never re-implemented here.* A local copy of a security-relevant decision is a second answer, and the local copy is the one that drifts out of sync. Do not add a caller-local