From 91b344dbcf01e1f4a00dbf38185a7daccf2519b2 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 23 Oct 2026 15:37:37 +0200 Subject: [PATCH] fix(ci): restore consumer-approved publisher identity --- .github/agent-guides/validation.md | 15 ++++--- .github/scripts/check-manifests-caller.sh | 18 +++++--- .../fixtures/manifests-caller-contract.sh | 45 +++++++++++++------ .github/workflows/cd.yaml | 15 +++---- 4 files changed, 58 insertions(+), 35 deletions(-) diff --git a/.github/agent-guides/validation.md b/.github/agent-guides/validation.md index e81ddd2..65452e1 100644 --- a/.github/agent-guides/validation.md +++ b/.github/agent-guides/validation.md @@ -27,13 +27,14 @@ required; this fixture never proves live token generation or project association **Explicit manifests recovery:** Default-off `enable-signed-recovery` requests only the original version alias, with signed promotion and caller pinning required before any legacy write. Verify the original catalogue identity and all original source/version/run claims, restore only authenticated missing versions, treat matching versions as no-ops, refuse contradictory or ambiguous reads, and always preserve `latest`. Keep recovery boundary, interruption/readback controls and native Flux/registry/cryptographic tests in required CI. The native key fixture proves signed claims and registry bytes, not production OIDC. Cross-repository publisher identity migration remains #242; complete recovery rollout remains #425. -The configuration caller guard approves the publisher workflow and both copied helpers by Git -blob and regular-file mode, while retaining the exact workflow identity, full commit reference, -publication opt-ins and artifact target. Releases with unchanged approved contents need no test-pin -edit; changed contents require review before updating the recorded blobs. Missing commits are read -through a disposable object store. The promotion suite exercises unchanged and changed releases, -helper changes, modes and input refusals. This source check does not replace the deployed verifier's -separate approved-revision gate. +The configuration caller guard separately requires the exact consumer-approved signing revision +and the publisher workflow/helper Git blobs and regular-file modes. Even an unrelated release with +unchanged publisher contents changes its certificate identity. Deploy and independently read back +consumer approval before changing the guard's recorded revision and the caller; never treat content +equivalence as that approval. Changed contents also require review before updating the recorded +blobs. Missing commits are read through a disposable object store. The promotion suite rejects an +unapproved byte-identical release and exercises an explicitly approved release, changed helpers, +modes and unsafe inputs. CI's record does not replace the deployed verifier's exact-revision gate. **Explicit application recovery:** The same default-off mode verifies both original signatures with all nine paired publication claims before reading both version aliases. Any conflict or unknown read prevents all writes; only missing aliases are restored, with digest readback and matching retries producing no writes. Keep `.github/tests/test-app-recovery.sh` and the native single-platform Buildx/Flux proof required. Never rebuild, re-sign or move `latest` during recovery. Registry writes have no compare-and-set or cross-repository transaction; the shared queue and final readback cannot fence unrelated writers. diff --git a/.github/scripts/check-manifests-caller.sh b/.github/scripts/check-manifests-caller.sh index 8ab3c6b..78c5771 100644 --- a/.github/scripts/check-manifests-caller.sh +++ b/.github/scripts/check-manifests-caller.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash -# Approve publisher contents, so an unrelated release does not require a pin edit here. +# Require independently approved signer identity AND reviewed publisher contents. set -euo pipefail -fail() { echo "FAIL: configuration caller: $*" >&2; exit 1; } +fail() { + echo "FAIL: configuration caller: $*" >&2 + exit 1 +} [[ "$#" == 1 ]] || fail 'expected one caller workflow' caller="$1" encoded_ref="$(yq -o=json -I=0 '.jobs["publish-manifests"].uses // ""' "$caller")" || @@ -18,9 +21,10 @@ yq -e ' ' "$caller" >/dev/null || fail 'expected verified-promotion inputs' # The workflow pins every external action and copies exactly these two source helpers. -# All three files are unchanged between the previously approved v10.0.3 and v12.0.1. -# A new release carrying these contents passes; a workflow/helper change requires review -# and an explicit blob update. File modes are part of the approval too. +# Byte equivalence is not signer equivalence: the revision is part of the certificate +# identity. Update this revision only AFTER consumer approval has been deployed and +# read back. Content and file-mode review remain independently required. +approved_publisher_revision='0f390329b590404c02565b27a263ee841fa02bce' reviewed_workflow_blob='ce9a9897b154486218478734a02c61e5ac3ea479' reviewed_absence_blob='3566e4067e953a1d9cb34e6a50a0f9b72a5d8bc8' reviewed_recovery_blob='48dc38f60ddebf90b0976758df9f1983bbb86bfd' @@ -51,4 +55,6 @@ $reviewed_workflow_blob .github/workflows/publish-manifests.yaml $reviewed_absence_blob .github/scripts/require-unpublished-version.sh $reviewed_recovery_blob .github/scripts/recover-manifests-version.sh EOF -echo 'ok configuration caller pins the reviewed publisher and helper contents with verified promotion' +[[ "$revision" == "$approved_publisher_revision" ]] || + fail 'signer revision is not approved by consumers; deploy and verify consumer approval before updating the recorded revision' +echo 'ok configuration caller pins an approved signer with reviewed contents and verified promotion' diff --git a/.github/tests/fixtures/manifests-caller-contract.sh b/.github/tests/fixtures/manifests-caller-contract.sh index 918793a..3fae136 100644 --- a/.github/tests/fixtures/manifests-caller-contract.sh +++ b/.github/tests/fixtures/manifests-caller-contract.sh @@ -5,7 +5,10 @@ root="$(cd "$(dirname "$0")/../../.." && pwd)" guard="$root/.github/scripts/check-manifests-caller.sh" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT -fail() { echo "FAIL: manifests caller fixture: $*" >&2; exit 1; } +fail() { + echo "FAIL: manifests caller fixture: $*" >&2 + exit 1 +} fixture_git() { git -C "$work/source" -c user.name=fixture -c user.email=fixture@example.invalid \ -c commit.gpgsign=false -c gc.auto=0 -c maintenance.auto=false "$@" @@ -47,6 +50,7 @@ fixture_git config uploadpack.allowFilter true fixture_git config uploadpack.allowAnySHA1InWant true make_guard() { sed -e "s|^reviewed_workflow_blob='.*'$|reviewed_workflow_blob='$workflow_blob'|" \ + -e "s|^approved_publisher_revision='.*'$|approved_publisher_revision='$reviewed'|" \ -e "s|^reviewed_absence_blob='.*'$|reviewed_absence_blob='$absence_blob'|" \ -e "s|^reviewed_recovery_blob='.*'$|reviewed_recovery_blob='$recovery_blob'|" \ -e "s|^source_remote='.*'$|source_remote='$1'|" "$guard" >"$2" @@ -62,23 +66,38 @@ mutate() { '.jobs["publish-manifests"].uses = ($prefix + "@" + $reviewed) | '"$1" \ "$work/baseline.json" >"$work/caller.json" } -run_guard() { (cd "$2"; bash "$1" "$work/caller.json"); } -for revision in reviewed release; do - mutate ".jobs[\"publish-manifests\"].uses = (\$prefix + \"@\" + \$$revision)" - run_guard "$work/offline.sh" "$work/source" >"$work/result" 2>&1 || { - cat "$work/result" >&2 - fail "rejected $revision content" - } -done +run_guard() { ( + cd "$2" + bash "$1" "$work/caller.json" +); } +mutate '.' +run_guard "$work/offline.sh" "$work/source" >"$work/result" 2>&1 || { + cat "$work/result" >&2 + fail 'rejected approved publisher' +} +# The two commits carry byte-identical publishers but different signer identities. +# An unrelated release must not become an approved signer implicitly. +# shellcheck disable=SC2016 # jq variables, not shell expansion. +mutate '.jobs["publish-manifests"].uses = ($prefix + "@" + $release)' +if run_guard "$work/offline.sh" "$work/source" >"$work/result" 2>&1; then + fail 'accepted unapproved signer with unchanged publisher contents' +fi +grep -qF 'signer revision is not approved by consumers' "$work/result" || { + cat "$work/result" >&2 + fail 'unchanged unapproved signer failed for the wrong reason' +} +# A separately reviewed consumer-first approval admits the exact new identity. +sed "s|^approved_publisher_revision='.*'$|approved_publisher_revision='$release'|" \ + "$work/fetching.sh" >"$work/approved-release.sh" git init --quiet "$work/consumer" # shellcheck disable=SC2016 # jq variables, not shell expansion. mutate '.jobs["publish-manifests"].uses = ($prefix + "@" + $release)' -run_guard "$work/fetching.sh" "$work/consumer" >"$work/result" 2>&1 || { +run_guard "$work/approved-release.sh" "$work/consumer" >"$work/result" 2>&1 || { cat "$work/result" >&2 - fail 'rejected fetched unchanged release' + fail 'rejected fetched explicitly approved release' } [[ ! -e "$work/consumer/.git/shallow" && ! -s "$work/consumer/.git/FETCH_HEAD" && - -z "$(git -C "$work/consumer" remote)" ]] || fail 'fetch changed consumer Git state' + -z "$(git -C "$work/consumer" remote)" ]] || fail 'fetch changed consumer Git state' while IFS=$'\t' read -r label mutation diagnostic; do mutate "$mutation" if run_guard "$work/offline.sh" "$work/source" >"$work/result" 2>&1; then @@ -110,4 +129,4 @@ if run_guard "$work/fetching.sh" "$work/consumer" >"$work/result" 2>&1; then fail 'accepted fetched changed helper' fi grep -qF 'unreviewed publisher content' "$work/result" || fail 'fetched content refusal had the wrong cause' -echo 'ok caller accepts unchanged releases and refuses unreviewed workflow/helper bytes, modes and unsafe inputs' +echo 'ok caller requires explicit signer approval and reviewed contents, modes and safe inputs' diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 6dd0a36..82cad8d 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -36,17 +36,14 @@ jobs: # the SHA must be reachable from a tag, or zizmor flags it as an impostor). # Dependabot manages this pin (.github/dependabot.yml, github-actions # ecosystem, no cooldown for devantler-tech/*). - # Keep the ref a 40-hex commit: it is part of the cosign subject the platform - # verifies, so its shape is load-bearing here. - uses: devantler-tech/.github/.github/workflows/publish-manifests.yaml@6f1dee9557a939cc24ca2c562ea775d05e01564d # v12.0.1 + # Keep the exact consumer-approved revision, not merely a 40-hex shape or + # byte-identical publisher. CI requires separate signer and content approval; + # deploy and read back consumer approval BEFORE changing this identity. + uses: devantler-tech/.github/.github/workflows/publish-manifests.yaml@0f390329b590404c02565b27a263ee841fa02bce # v10.0.3 with: oci-name: devantler-tech/github-config - # Without this, a ref the platform verifier does not accept fails nothing - # here: the artifact still publishes, signed under an identity the - # platform rejects, and the `github-config` tenant silently stops - # reconciling until the ref is put back. Enabling the guard turns that - # into a loud pre-signing failure in this workflow instead — the publish - # refuses before the artifact exists, and the error names the fix. + # The publisher refuses mutable signing refs. Consumer approval of the exact + # immutable identity is a separate CI guard, not inferred from this input. enable-caller-pin: true # Promote only after the staged digest has been signed and verified. enable-signed-promotion: true