diff --git a/.github/agent-guides/validation.md b/.github/agent-guides/validation.md index 933194e..e81ddd2 100644 --- a/.github/agent-guides/validation.md +++ b/.github/agent-guides/validation.md @@ -27,6 +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. + **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. **Prebuilt WebApp publication:** Default-off `enable-prebuilt-webapp` requires diff --git a/.github/scripts/check-manifests-caller.sh b/.github/scripts/check-manifests-caller.sh new file mode 100644 index 0000000..8ab3c6b --- /dev/null +++ b/.github/scripts/check-manifests-caller.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Approve publisher contents, so an unrelated release does not require a pin edit here. +set -euo pipefail +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")" || + fail 'cannot read publisher reference' +ref_pattern='^"devantler-tech/\.github/\.github/workflows/publish-manifests\.yaml@([0-9a-f]{40})"$' +[[ "$encoded_ref" =~ $ref_pattern ]] || fail 'expected a full immutable publisher reference' +revision="${BASH_REMATCH[1]}" +[[ "$revision" != 0000000000000000000000000000000000000000 ]] || + fail 'expected a full immutable publisher reference' +yq -e ' + (.jobs["publish-manifests"].with["enable-caller-pin"] == true) and + (.jobs["publish-manifests"].with["enable-signed-promotion"] == true) and + (.jobs["publish-manifests"].with["oci-name"] == "devantler-tech/github-config") + ' "$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. +reviewed_workflow_blob='ce9a9897b154486218478734a02c61e5ac3ea479' +reviewed_absence_blob='3566e4067e953a1d9cb34e6a50a0f9b72a5d8bc8' +reviewed_recovery_blob='48dc38f60ddebf90b0976758df9f1983bbb86bfd' +source_remote='https://github.com/devantler-tech/.github.git' +object_store=() +read_object() { git --no-replace-objects ${object_store[@]+"${object_store[@]}"} "$@"; } +if ! read_object cat-file -e "$revision" 2>/dev/null; then + scratch="$(mktemp -d)" + trap 'rm -rf "$scratch"' EXIT + git init --quiet --bare "$scratch/source.git" + object_store=(--git-dir "$scratch/source.git") + bounded=() + if command -v timeout >/dev/null 2>&1; then bounded=(timeout 2m); fi + ${bounded[@]+"${bounded[@]}"} git --no-replace-objects "${object_store[@]}" \ + -c http.lowSpeedLimit=1000 -c http.lowSpeedTime=60 \ + fetch --quiet --no-tags --depth=1 --filter=blob:none "$source_remote" "$revision" 2>/dev/null || + fail 'publisher reference stays unverified: cannot read source commit' +fi +[[ "$(read_object cat-file -t "$revision" 2>/dev/null)" == commit ]] || + fail 'publisher reference stays unverified: not a commit' +while IFS=' ' read -r expected path; do + actual="$(read_object ls-tree "$revision" -- "$path")" || + fail "publisher reference stays unverified: cannot read $path" + [[ "$actual" == "100644 blob $expected"$'\t'"$path" ]] || + fail "unreviewed publisher content at $revision: $path; review its contents and mode before updating the approved blob" +done <&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 "$@" +} +git init --quiet "$work/source" +mkdir -p "$work/source/.github/workflows" "$work/source/.github/scripts" +workflow='.github/workflows/publish-manifests.yaml' +absence='.github/scripts/require-unpublished-version.sh' +recovery='.github/scripts/recover-manifests-version.sh' +for path in "$workflow" "$absence" "$recovery"; do + printf 'independently reviewed fixture: %s\n' "$path" >"$work/source/$path" + fixture_git add -- "$path" +done +fixture_git commit --quiet -m reviewed +reviewed="$(fixture_git rev-parse HEAD)" +workflow_blob="$(fixture_git rev-parse "HEAD:$workflow")" +absence_blob="$(fixture_git rev-parse "HEAD:$absence")" +recovery_blob="$(fixture_git rev-parse "HEAD:$recovery")" +printf 'unrelated release\n' >"$work/source/RELEASE" +fixture_git add -- RELEASE +fixture_git commit --quiet -m release +release="$(fixture_git rev-parse HEAD)" +for path in "$workflow" "$absence" "$recovery"; do + printf 'unreviewed change\n' >>"$work/source/$path" + fixture_git add -- "$path" + fixture_git commit --quiet -m changed + case "$path" in + "$workflow") changed_workflow="$(fixture_git rev-parse HEAD)" ;; + "$absence") changed_absence="$(fixture_git rev-parse HEAD)" ;; + "$recovery") changed_recovery="$(fixture_git rev-parse HEAD)" ;; + esac + fixture_git checkout --quiet "$release" -- "$path" + fixture_git commit --quiet -m restore +done +fixture_git update-index --chmod=+x "$workflow" +fixture_git commit --quiet -m mode +changed_mode="$(fixture_git rev-parse HEAD)" +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|^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" +} +make_guard "$work/unavailable" "$work/offline.sh" +make_guard "file://$work/source" "$work/fetching.sh" +yq -o=json '.' "$root/.github/workflows/cd.yaml" >"$work/baseline.json" +prefix='devantler-tech/.github/.github/workflows/publish-manifests.yaml' +mutate() { + jq --arg prefix "$prefix" --arg reviewed "$reviewed" --arg release "$release" \ + --arg workflow "$changed_workflow" --arg absence "$changed_absence" \ + --arg recovery "$changed_recovery" --arg mode "$changed_mode" \ + '.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 +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 || { + cat "$work/result" >&2 + fail 'rejected fetched unchanged release' +} +[[ ! -e "$work/consumer/.git/shallow" && ! -s "$work/consumer/.git/FETCH_HEAD" && + -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 + fail "accepted $label" + fi + grep -qF -- "$diagnostic" "$work/result" || { + cat "$work/result" >&2 + fail "$label failed for the wrong reason" + } +done <<'CASES' +mutable ref .jobs["publish-manifests"].uses=$prefix+"@main" full immutable publisher reference +short ref .jobs["publish-manifests"].uses=$prefix+"@"+$reviewed[0:12] full immutable publisher reference +zero ref .jobs["publish-manifests"].uses=$prefix+"@0000000000000000000000000000000000000000" full immutable publisher reference +trailing newline .jobs["publish-manifests"].uses=$prefix+"@"+$reviewed+"\n" full immutable publisher reference +lookalike owner .jobs["publish-manifests"].uses="outside/.github/.github/workflows/publish-manifests.yaml@"+$reviewed full immutable publisher reference +lookalike path .jobs["publish-manifests"].uses="devantler-tech/.github/.github/workflows/publish-manifestsXyaml@"+$reviewed full immutable publisher reference +missing pin verification del(.jobs["publish-manifests"].with["enable-caller-pin"]) verified-promotion inputs +disabled promotion .jobs["publish-manifests"].with["enable-signed-promotion"]=false verified-promotion inputs +wrong artifact .jobs["publish-manifests"].with["oci-name"]="devantler-tech/other" verified-promotion inputs +changed workflow .jobs["publish-manifests"].uses=$prefix+"@"+$workflow unreviewed publisher content +changed absence helper .jobs["publish-manifests"].uses=$prefix+"@"+$absence unreviewed publisher content +changed recovery helper .jobs["publish-manifests"].uses=$prefix+"@"+$recovery unreviewed publisher content +changed file mode .jobs["publish-manifests"].uses=$prefix+"@"+$mode unreviewed publisher content +unreadable revision .jobs["publish-manifests"].uses=$prefix+"@0123456789abcdef0123456789abcdef01234567" reference stays unverified +CASES +# shellcheck disable=SC2016 # jq variables, not shell expansion. +mutate '.jobs["publish-manifests"].uses=$prefix+"@"+$absence' +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' diff --git a/.github/tests/test-publish-manifests-promotion.sh b/.github/tests/test-publish-manifests-promotion.sh index eb8bf05..45d7ce3 100644 --- a/.github/tests/test-publish-manifests-promotion.sh +++ b/.github/tests/test-publish-manifests-promotion.sh @@ -4,18 +4,13 @@ set -euo pipefail # The live configuration caller must opt in only through a SHA-pinned publisher. validate_caller() { - yq -e ' - (.jobs["publish-manifests"].uses == - "devantler-tech/.github/.github/workflows/publish-manifests.yaml@0f390329b590404c02565b27a263ee841fa02bce") and - (.jobs["publish-manifests"].with["enable-caller-pin"] == true) and - (.jobs["publish-manifests"].with["enable-signed-promotion"] == true) and - (.jobs["publish-manifests"].with["oci-name"] == "devantler-tech/github-config") - ' "$1" >/dev/null + bash .github/scripts/check-manifests-caller.sh "$1" } validate_caller .github/workflows/cd.yaml || { echo 'FAIL: configuration publication requires a SHA-pinned, verified-promotion caller' >&2 exit 1 } +bash .github/tests/fixtures/manifests-caller-contract.sh scratch="$(mktemp -d)" trap 'rm -rf "$scratch"' EXIT mkdir -p "$scratch/bin" diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index cfb54fb..6dd0a36 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -38,7 +38,7 @@ jobs: # 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@0f390329b590404c02565b27a263ee841fa02bce # v10.0.3 + uses: devantler-tech/.github/.github/workflows/publish-manifests.yaml@6f1dee9557a939cc24ca2c562ea775d05e01564d # v12.0.1 with: oci-name: devantler-tech/github-config # Without this, a ref the platform verifier does not accept fails nothing