From e3bfd1015f0facf167c4e5f6e085797982cd359e Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 13:33:19 +0530 Subject: [PATCH 1/3] Verify cache seed publication without gating deploy smokes --- .../setup-rust-build-cache/tests/fixture.sh | 14 +++- .../setup-rust-build-cache/tests/run.sh | 1 + .../tests/seed-compiler-cache.sh | 34 +++++++++ .../setup-rust-build-cache/tests/seed.sh | 71 +++++++++++++++++++ .github/workflows/deploy-action.yml | 44 ++++++++++-- 5 files changed, 154 insertions(+), 10 deletions(-) create mode 100755 .github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh create mode 100755 .github/actions/setup-rust-build-cache/tests/seed.sh diff --git a/.github/actions/setup-rust-build-cache/tests/fixture.sh b/.github/actions/setup-rust-build-cache/tests/fixture.sh index 019740f5..38cbc455 100755 --- a/.github/actions/setup-rust-build-cache/tests/fixture.sh +++ b/.github/actions/setup-rust-build-cache/tests/fixture.sh @@ -30,8 +30,7 @@ grep -Fxq 'fixture-id=417-1' "$WORK_DIR/seed/output" || fail 'seed does not expo if grep -q 'cargo clean' "$ACTION_DIR/tests/assert-compiler-reuse.sh"; then fail 'reuse probe accepts same-job rebuilds'; fi workflow="$ACTION_DIR/../../workflows/deploy-action.yml" seed=$(yq -r '.jobs."cache-restore-seed".steps[] | select(.name == "Seed native and WASM compiler outputs") | .run' "$workflow") -# shellcheck disable=SC2016 # Assert the literal shell command in workflow metadata. -[[ "$seed" == *'cargo build --locked --lib --target "$host"'* && "$seed" == *'cargo build --locked --lib --target wasm32-wasip1'* ]] || fail 'seed job does not compile both targets' +[[ "$seed" == 'bash .github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh' ]] || fail 'seed does not verify compiler publication' for job in cache-restore-seed cache-restore-smoke cache-new-revision-smoke; do JOB_ID="$job" yq -e '.jobs[strenv(JOB_ID)].steps[] | select(.name == "Install native and WASM compiler targets") | .run == "bash .github/actions/setup-rust-build-cache/tests/setup-toolchain.sh"' "$workflow" >/dev/null || fail "$job does not share its Rust toolchain" done @@ -41,7 +40,16 @@ for job in cache-restore-smoke cache-new-revision-smoke; do actual=$(JOB_ID="$job" yq -r '.jobs[strenv(JOB_ID)].steps[] | select(.id == "fixture") | .env.CACHE_FIXTURE_ID' "$workflow") [[ "$actual" == "$expected_identity" ]] || fail "$job does not reuse the seed fixture identity" done -[[ "$(yq -r '.jobs."cache-restore-seed".steps[] | select(.id == "cache") | .with."save-target"' "$workflow")" == true ]] || fail 'seed does not opt into saving' +[[ "$(yq -r '.jobs."cache-restore-seed".steps[] | select(.id == "cache") | .with."save-target"' "$workflow")" == false ]] || fail 'seed relies on unverified post-job saving' +for job in fixture-release store-free-release; do + actual=$(JOB_ID="$job" yq -r '.jobs[strenv(JOB_ID)].needs // [] | length' "$workflow") + [[ "$actual" == 0 ]] || fail "$job depends on cache probe availability" +done +for attempt in 1 2; do + STEP_ID="target-save-$attempt" yq -e '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .uses == "actions/cache/save@v5"' "$workflow" >/dev/null || fail 'missing explicit target save' + STEP_ID="target-verify-$attempt" yq -e '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .with."lookup-only" == true' "$workflow" >/dev/null || fail 'target publication is not verified' +done +yq -e '.jobs."cache-restore-seed".steps[] | select(.id == "target-verify-2") | .with."fail-on-cache-miss" == true' "$workflow" >/dev/null || fail 'exhausted target publication retries stay green' for job in fixture-release store-free-release; do actual=$(JOB_ID="$job" yq -r '.jobs[strenv(JOB_ID)].steps[] | select(.uses == "./.github/actions/setup-rust-build-cache") | .with."cache-target"' "$workflow") [[ "$actual" == false ]] || fail "$job enables full target caching for a disposable fixture" diff --git a/.github/actions/setup-rust-build-cache/tests/run.sh b/.github/actions/setup-rust-build-cache/tests/run.sh index ba2af9eb..ea488cd5 100755 --- a/.github/actions/setup-rust-build-cache/tests/run.sh +++ b/.github/actions/setup-rust-build-cache/tests/run.sh @@ -143,3 +143,4 @@ printf 'setup-rust-build-cache action tests passed\n' bash "$ACTION_DIR/tests/revision.sh" bash "$ACTION_DIR/tests/review.sh" bash "$ACTION_DIR/tests/fixture.sh" +bash "$ACTION_DIR/tests/seed.sh" diff --git a/.github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh b/.github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh new file mode 100755 index 00000000..7788c273 --- /dev/null +++ b/.github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Publish each target before downstream jobs test first-build compiler reuse. +fixture="${GITHUB_WORKSPACE:?}/cache-restore-fixture" +target_dir="${CARGO_TARGET_DIR:?}" +expected="$(realpath "${RUNNER_TEMP:?}")/edgezero-rust-cache/cache-restore-smoke-v2/target" +[[ "$(realpath "$target_dir")" == "$expected" ]] || { + printf '::error::compiler seed must use its confined fixture target directory\n' >&2 + exit 1 +} +cd "$fixture" +host=$(rustc -vV | sed -n 's/^host: //p') +[[ -n "$host" ]] || exit 1 +for target in "$host" wasm32-wasip1; do + for attempt in 1 2 3; do + # Cargo must call sccache again after a failed publication, rather than + # reporting locally fresh outputs as a successful cache seed. + rm -rf -- "$target_dir/$target/debug" + sccache --zero-stats + cargo build --locked --lib --target "$target" + stats=$(sccache --show-stats --stats-format json) + printf 'Compiler seed for %s (attempt %s): %s\n' "$target" "$attempt" "$stats" + if jq -e '((.stats.cache_hits.counts.Rust // 0) + (.stats.cache_writes // 0)) > 0 and (.stats.cache_errors.counts.Rust // 0) == 0 and (.stats.cache_read_errors // 0) == 0 and (.stats.cache_write_errors // 0) == 0' <<<"$stats" >/dev/null; then + break + fi + if [[ "$attempt" == 3 ]]; then + printf '::error::failed to publish compiler cache for %s after three attempts\n' "$target" >&2 + exit 1 + fi + printf '::warning::compiler cache seed failed; retrying after backoff\n' + sleep "$((attempt * 60))" + done +done diff --git a/.github/actions/setup-rust-build-cache/tests/seed.sh b/.github/actions/setup-rust-build-cache/tests/seed.sh new file mode 100755 index 00000000..8a8720b7 --- /dev/null +++ b/.github/actions/setup-rust-build-cache/tests/seed.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail +ACTION_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-cache-seed-test.XXXXXX") +trap 'rm -rf -- "$WORK_DIR"' EXIT +# shellcheck source=common.sh +source "$ACTION_DIR/tests/common.sh" +mkdir -p "$WORK_DIR/bin" "$WORK_DIR/workspace/cache-restore-fixture" "$WORK_DIR/runner" +export GITHUB_WORKSPACE="$WORK_DIR/workspace" RUNNER_TEMP="$WORK_DIR/runner" +export CARGO_TARGET_DIR="$RUNNER_TEMP/edgezero-rust-cache/cache-restore-smoke-v2/target" +export PROBE_LOG="$WORK_DIR/calls" PROBE_COUNT="$WORK_DIR/count" +cat >"$WORK_DIR/bin/rustc" <<'MOCK' +#!/usr/bin/env bash +printf 'host: test-host\n' +MOCK +cat >"$WORK_DIR/bin/sleep" <<'MOCK' +#!/usr/bin/env bash +printf 'sleep %s\n' "$*" >>"$PROBE_LOG" +MOCK +cat >"$WORK_DIR/bin/cargo" <<'MOCK' +#!/usr/bin/env bash +printf 'build %s\n' "$*" >>"$PROBE_LOG" +[[ "${PROBE_MODE:-}" != compile-failure ]] || exit 42 +[[ ! -d "$CARGO_TARGET_DIR/${*: -1}/debug" ]] || exit 43 +mkdir -p "$CARGO_TARGET_DIR/${*: -1}/debug" +MOCK +cat >"$WORK_DIR/bin/sccache" <<'MOCK' +#!/usr/bin/env bash +[[ "$1" == --show-stats ]] || exit 0 +count=$(cat "$PROBE_COUNT") +printf '%s\n' "$((count + 1))" >"$PROBE_COUNT" +if [[ "$PROBE_MODE" == permanent || ( "$PROBE_MODE" == transient && "$count" == 0 ) ]]; then + printf '{"stats":{"cache_write_errors":1,"cache_writes":0}}\n' +elif [[ "$PROBE_MODE" == empty ]]; then + printf '{"stats":{}}\n' +elif [[ "$PROBE_MODE" == hit ]]; then + printf '{"stats":{"cache_hits":{"counts":{"Rust":1}}}}\n' +else + printf '{"stats":{"cache_writes":1}}\n' +fi +MOCK +chmod +x "$WORK_DIR/bin/"* +export PATH="$WORK_DIR/bin:$PATH" +run_seed() { + rm -rf "$CARGO_TARGET_DIR" + mkdir -p "$CARGO_TARGET_DIR" + printf '0\n' >"$PROBE_COUNT" + : >"$PROBE_LOG" + PROBE_MODE="$1" bash "$ACTION_DIR/tests/seed-compiler-cache.sh" >"$WORK_DIR/log" 2>&1 +} +run_seed transient || fail 'compiler seed does not recover from a transient write failure' +[[ "$(grep -c '^build ' "$PROBE_LOG")" == 3 ]] || fail 'retry does not rebuild fresh outputs' +[[ "$(grep -c '^sleep ' "$PROBE_LOG")" == 1 ]] || fail 'transient error does not back off' +run_seed hit || fail 'existing compiler hit is not accepted as published' +for mode in permanent empty compile-failure; do + if run_seed "$mode"; then fail "accepted unsuccessful compiler seeding: $mode"; fi + if [[ "$mode" == compile-failure ]]; then + [[ "$(grep -c '^build ' "$PROBE_LOG")" == 1 ]] || fail 'compiler errors are retried' + else + [[ "$(grep -c '^build ' "$PROBE_LOG")" == 3 ]] || fail 'cache retries are not bounded to three attempts' + fi +done +# A mistaken directory must never be removed by retry cleanup. +export CARGO_TARGET_DIR="$WORK_DIR/outside" +mkdir -p "$CARGO_TARGET_DIR" +printf keep >"$CARGO_TARGET_DIR/marker" +if PROBE_MODE=hit bash "$ACTION_DIR/tests/seed-compiler-cache.sh" >"$WORK_DIR/log" 2>&1; then + fail 'accepted unconfined compiler seed directory' +fi +[[ -f "$CARGO_TARGET_DIR/marker" ]] || fail 'removed unconfined output' +printf 'compiler seed backoff contracts passed\n' diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 15e797ce..1c20bd07 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -138,7 +138,7 @@ jobs: with: app-name: cache-restore-smoke-v2 cache-target: true - save-target: true + save-target: false target-cache-revision: ${{ steps.fixture.outputs.revision }} working-directory: cache-restore-fixture @@ -149,12 +149,42 @@ jobs: printf '%s\n' 'edgezero-source-cache-smoke-v2' >"$HOME/.cargo/git/db/edgezero-source-cache-smoke-v2" - name: Seed native and WASM compiler outputs - working-directory: cache-restore-fixture - run: | - host=$(rustc -vV | sed -n 's/^host: //p') - test -n "$host" - cargo build --locked --lib --target "$host" - cargo build --locked --lib --target wasm32-wasip1 + run: bash .github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh + + # Cache save warnings do not fail the action. Verify publication explicitly + # so a throttled seed cannot report success and fail both consumer jobs. + - name: Save seed target cache + id: target-save-1 + uses: actions/cache/save@v5 + with: + path: ${{ steps.cache.outputs.cargo-target-dir }} + key: ${{ steps.cache.outputs.target-cache-key }} + - name: Verify seed target publication + id: target-verify-1 + uses: actions/cache/restore@v5 + with: + path: ${{ steps.cache.outputs.cargo-target-dir }} + key: ${{ steps.cache.outputs.target-cache-key }} + lookup-only: true + - name: Back off before retrying target publication + if: steps.target-verify-1.outputs.cache-hit != 'true' + run: sleep 60 + - name: Retry seed target cache save + id: target-save-2 + if: steps.target-verify-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: ${{ steps.cache.outputs.cargo-target-dir }} + key: ${{ steps.cache.outputs.target-cache-key }} + - name: Require seed target publication + id: target-verify-2 + if: steps.target-verify-1.outputs.cache-hit != 'true' + uses: actions/cache/restore@v5 + with: + path: ${{ steps.cache.outputs.cargo-target-dir }} + key: ${{ steps.cache.outputs.target-cache-key }} + lookup-only: true + fail-on-cache-miss: true cache-restore-smoke: needs: cache-restore-seed From 523c3e1edd63cf727679414980fcac7d6ccde780 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 14:03:37 +0530 Subject: [PATCH 2/3] Require exact cache publication and confine seed cleanup --- .../setup-rust-build-cache/tests/fixture.sh | 44 ++++++++++- .../tests/publication.sh | 60 +++++++++++++++ .../setup-rust-build-cache/tests/run.sh | 1 + .../tests/seed-compiler-cache.sh | 16 +++- .../setup-rust-build-cache/tests/seed.sh | 74 ++++++++++++++++--- .github/workflows/deploy-action.yml | 12 +++ 6 files changed, 193 insertions(+), 14 deletions(-) create mode 100755 .github/actions/setup-rust-build-cache/tests/publication.sh diff --git a/.github/actions/setup-rust-build-cache/tests/fixture.sh b/.github/actions/setup-rust-build-cache/tests/fixture.sh index 38cbc455..643b1940 100755 --- a/.github/actions/setup-rust-build-cache/tests/fixture.sh +++ b/.github/actions/setup-rust-build-cache/tests/fixture.sh @@ -28,7 +28,7 @@ fi grep -Fxq 'fixture-id=417-1' "$WORK_DIR/seed/output" || fail 'seed does not export fixture identity' [[ "$(grep -c 'cargo build' "$ACTION_DIR/tests/assert-compiler-reuse.sh")" == 1 ]] || fail 'reuse probe primes its own cache' if grep -q 'cargo clean' "$ACTION_DIR/tests/assert-compiler-reuse.sh"; then fail 'reuse probe accepts same-job rebuilds'; fi -workflow="$ACTION_DIR/../../workflows/deploy-action.yml" +workflow="${CACHE_WORKFLOW_FILE:-$ACTION_DIR/../../workflows/deploy-action.yml}" seed=$(yq -r '.jobs."cache-restore-seed".steps[] | select(.name == "Seed native and WASM compiler outputs") | .run' "$workflow") [[ "$seed" == 'bash .github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh' ]] || fail 'seed does not verify compiler publication' for job in cache-restore-seed cache-restore-smoke cache-new-revision-smoke; do @@ -41,6 +41,23 @@ for job in cache-restore-smoke cache-new-revision-smoke; do [[ "$actual" == "$expected_identity" ]] || fail "$job does not reuse the seed fixture identity" done [[ "$(yq -r '.jobs."cache-restore-seed".steps[] | select(.id == "cache") | .with."save-target"' "$workflow")" == false ]] || fail 'seed relies on unverified post-job saving' +# shellcheck disable=SC2016 # Validate the actual inputs to both action attempts. +expected_path='${{ steps.cache.outputs.cargo-target-dir }}' +# shellcheck disable=SC2016 +expected_key='${{ steps.cache.outputs.target-cache-key }}' +retry_condition="steps.target-verify-1.outputs.cache-hit != 'true'" +for step in target-save-1 target-verify-1 target-save-2 target-verify-2; do + actual=$(STEP_ID="$step" yq -r '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .with.path' "$workflow") + [[ "$actual" == "$expected_path" ]] || fail "$step uses a different cache path" + actual=$(STEP_ID="$step" yq -r '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .with.key' "$workflow") + [[ "$actual" == "$expected_key" ]] || fail "$step uses a different cache key" + actual=$(STEP_ID="$step" yq -r '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .if // ""' "$workflow") + if [[ "$step" == *-2 ]]; then + [[ "$actual" == "$retry_condition" ]] || fail "$step retries without a first-attempt miss" + else + [[ -z "$actual" ]] || fail "$step skips the initial publication attempt" + fi +done for job in fixture-release store-free-release; do actual=$(JOB_ID="$job" yq -r '.jobs[strenv(JOB_ID)].needs // [] | length' "$workflow") [[ "$actual" == 0 ]] || fail "$job depends on cache probe availability" @@ -50,6 +67,31 @@ for attempt in 1 2; do STEP_ID="target-verify-$attempt" yq -e '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .with."lookup-only" == true' "$workflow" >/dev/null || fail 'target publication is not verified' done yq -e '.jobs."cache-restore-seed".steps[] | select(.id == "target-verify-2") | .with."fail-on-cache-miss" == true' "$workflow" >/dev/null || fail 'exhausted target publication retries stay green' +# The action can return successfully before honoring fail-on-cache-miss when +# the cache feature is unavailable. Require actual exact-hit output as well. +publication_step='.jobs."cache-restore-seed".steps[] | select(.name == "Require exact target seed publication")' +publication_assertion=$(yq -r "$publication_step | .run" "$workflow") +[[ -n "$publication_assertion" && "$publication_assertion" != null ]] || fail 'missing exact publication assertion' +# shellcheck disable=SC2016 # GitHub outputs must feed the assertion literally. +first_hit='${{ steps.target-verify-1.outputs.cache-hit }}' +# shellcheck disable=SC2016 +second_hit='${{ steps.target-verify-2.outputs.cache-hit }}' +[[ "$(yq -r "$publication_step | .env.TARGET_SEED_FIRST_HIT" "$workflow")" == "$first_hit" ]] || fail 'publication assertion ignores first lookup output' +[[ "$(yq -r "$publication_step | .env.TARGET_SEED_SECOND_HIT" "$workflow")" == "$second_hit" ]] || fail 'publication assertion ignores second lookup output' +[[ "$(yq -r "$publication_step | .if // \"\"" "$workflow")" == '' ]] || fail 'publication assertion can be skipped after successful lookup steps' +[[ "$(yq -r "$publication_step | .\"continue-on-error\" // false" "$workflow")" == false ]] || fail 'publication assertion failure is ignored' +for first in '' false true partial; do + for second in '' false true partial; do + status=0 + TARGET_SEED_FIRST_HIT="$first" TARGET_SEED_SECOND_HIT="$second" \ + bash -e -o pipefail -c "$publication_assertion" >"$WORK_DIR/publication.log" 2>&1 || status=$? + if [[ "$first" == true || "$second" == true ]]; then + [[ "$status" == 0 ]] || fail 'publication assertion rejects a verified exact hit' + else + [[ "$status" != 0 ]] || fail "publication assertion accepts unverified outputs '$first'/'$second'" + fi + done +done for job in fixture-release store-free-release; do actual=$(JOB_ID="$job" yq -r '.jobs[strenv(JOB_ID)].steps[] | select(.uses == "./.github/actions/setup-rust-build-cache") | .with."cache-target"' "$workflow") [[ "$actual" == false ]] || fail "$job enables full target caching for a disposable fixture" diff --git a/.github/actions/setup-rust-build-cache/tests/publication.sh b/.github/actions/setup-rust-build-cache/tests/publication.sh new file mode 100755 index 00000000..a0ae6792 --- /dev/null +++ b/.github/actions/setup-rust-build-cache/tests/publication.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Mutate the workflow itself so contract checks cannot merely test a detached if. +set -euo pipefail +ACTION_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-cache-publication-test.XXXXXX") +trap 'rm -rf -- "$WORK_DIR"' EXIT +# shellcheck source=common.sh +source "$ACTION_DIR/tests/common.sh" +workflow="$ACTION_DIR/../../workflows/deploy-action.yml" + +reject_mutation() { + local name="$1" expression="$2" diagnostic="$3" status=0 + yq "$expression" "$workflow" >"$WORK_DIR/workflow.yml" + CACHE_WORKFLOW_FILE="$WORK_DIR/workflow.yml" bash "$ACTION_DIR/tests/fixture.sh" >"$WORK_DIR/log" 2>&1 || status=$? + [[ "$status" == 1 ]] || fail "workflow guard accepted mutation: $name (exit $status)" + grep -Fq "$diagnostic" "$WORK_DIR/log" || { + cat "$WORK_DIR/log" >&2 + fail "mutation failed for an unrelated reason: $name" + } +} + +publication='.jobs."cache-restore-seed".steps[] | select(.name == "Require exact target seed publication")' +reject_mutation 'missing final gate' \ + '.jobs."cache-restore-seed".steps |= map(select(.name != "Require exact target seed publication"))' \ + 'missing exact publication assertion' +reject_mutation 'hardcoded first hit' \ + "($publication | .env.TARGET_SEED_FIRST_HIT) = \"true\"" \ + 'publication assertion ignores first lookup output' +reject_mutation 'hardcoded second hit' \ + "($publication | .env.TARGET_SEED_SECOND_HIT) = \"true\"" \ + 'publication assertion ignores second lookup output' +reject_mutation 'skipped final gate' \ + "($publication | .if) = \"false\"" \ + 'publication assertion can be skipped' +reject_mutation 'ignored final failure' \ + "($publication | .\"continue-on-error\") = true" \ + 'publication assertion failure is ignored' +reject_mutation 'gate always succeeds' \ + "($publication | .run) = \"true\"" \ + 'publication assertion accepts unverified outputs' +reject_mutation 'gate always fails' \ + "($publication | .run) = \"false\"" \ + 'publication assertion rejects a verified exact hit' +for step in target-save-1 target-verify-1 target-save-2 target-verify-2; do + for input in path key; do + reject_mutation "$step wrong $input" \ + "(.jobs.\"cache-restore-seed\".steps[] | select(.id == \"$step\") | .with.$input) = \"wrong\"" \ + "$step uses a different cache $input" + done + if [[ "$step" == *-2 ]]; then + reject_mutation "$step wrong retry condition" \ + "(.jobs.\"cache-restore-seed\".steps[] | select(.id == \"$step\") | .if) = \"false\"" \ + "$step retries without a first-attempt miss" + else + reject_mutation "$step skipped initial attempt" \ + "(.jobs.\"cache-restore-seed\".steps[] | select(.id == \"$step\") | .if) = \"false\"" \ + "$step skips the initial publication attempt" + fi +done +printf 'target publication workflow mutation contracts passed\n' diff --git a/.github/actions/setup-rust-build-cache/tests/run.sh b/.github/actions/setup-rust-build-cache/tests/run.sh index ea488cd5..9f8fbcbf 100755 --- a/.github/actions/setup-rust-build-cache/tests/run.sh +++ b/.github/actions/setup-rust-build-cache/tests/run.sh @@ -143,4 +143,5 @@ printf 'setup-rust-build-cache action tests passed\n' bash "$ACTION_DIR/tests/revision.sh" bash "$ACTION_DIR/tests/review.sh" bash "$ACTION_DIR/tests/fixture.sh" +bash "$ACTION_DIR/tests/publication.sh" bash "$ACTION_DIR/tests/seed.sh" diff --git a/.github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh b/.github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh index 7788c273..487383d4 100755 --- a/.github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh +++ b/.github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh @@ -3,20 +3,28 @@ set -euo pipefail # Publish each target before downstream jobs test first-build compiler reuse. fixture="${GITHUB_WORKSPACE:?}/cache-restore-fixture" -target_dir="${CARGO_TARGET_DIR:?}" +target_dir=$(realpath "${CARGO_TARGET_DIR:?}") expected="$(realpath "${RUNNER_TEMP:?}")/edgezero-rust-cache/cache-restore-smoke-v2/target" -[[ "$(realpath "$target_dir")" == "$expected" ]] || { +[[ "$target_dir" == "$expected" ]] || { printf '::error::compiler seed must use its confined fixture target directory\n' >&2 exit 1 } cd "$fixture" host=$(rustc -vV | sed -n 's/^host: //p') -[[ -n "$host" ]] || exit 1 +[[ "$host" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]] || { + printf '::error::rustc must report a safe host target component\n' >&2 + exit 1 +} for target in "$host" wasm32-wasip1; do for attempt in 1 2 3; do # Cargo must call sccache again after a failed publication, rather than # reporting locally fresh outputs as a successful cache seed. - rm -rf -- "$target_dir/$target/debug" + debug_dir="$target_dir/$target/debug" + [[ ! -L "$target_dir/$target" && ! -L "$debug_dir" ]] || { + printf '::error::compiler seed cleanup must not follow target symlinks\n' >&2 + exit 1 + } + rm -rf -- "$debug_dir" sccache --zero-stats cargo build --locked --lib --target "$target" stats=$(sccache --show-stats --stats-format json) diff --git a/.github/actions/setup-rust-build-cache/tests/seed.sh b/.github/actions/setup-rust-build-cache/tests/seed.sh index 8a8720b7..c939af2b 100755 --- a/.github/actions/setup-rust-build-cache/tests/seed.sh +++ b/.github/actions/setup-rust-build-cache/tests/seed.sh @@ -11,7 +11,7 @@ export CARGO_TARGET_DIR="$RUNNER_TEMP/edgezero-rust-cache/cache-restore-smoke-v2 export PROBE_LOG="$WORK_DIR/calls" PROBE_COUNT="$WORK_DIR/count" cat >"$WORK_DIR/bin/rustc" <<'MOCK' #!/usr/bin/env bash -printf 'host: test-host\n' +printf 'host: %s\n' "${PROBE_HOST:-test-host}" MOCK cat >"$WORK_DIR/bin/sleep" <<'MOCK' #!/usr/bin/env bash @@ -21,16 +21,31 @@ cat >"$WORK_DIR/bin/cargo" <<'MOCK' #!/usr/bin/env bash printf 'build %s\n' "$*" >>"$PROBE_LOG" [[ "${PROBE_MODE:-}" != compile-failure ]] || exit 42 +[[ "$PROBE_MODE" != compile-wasm-failure || "${*: -1}" != wasm32-wasip1 ]] || exit 42 [[ ! -d "$CARGO_TARGET_DIR/${*: -1}/debug" ]] || exit 43 mkdir -p "$CARGO_TARGET_DIR/${*: -1}/debug" MOCK cat >"$WORK_DIR/bin/sccache" <<'MOCK' #!/usr/bin/env bash -[[ "$1" == --show-stats ]] || exit 0 +if [[ "$1" == --zero-stats ]]; then + printf 'zero\n' >>"$PROBE_LOG" + exit 0 +fi +[[ "$1" == --show-stats ]] || exit 2 count=$(cat "$PROBE_COUNT") printf '%s\n' "$((count + 1))" >"$PROBE_COUNT" -if [[ "$PROBE_MODE" == permanent || ( "$PROBE_MODE" == transient && "$count" == 0 ) ]]; then +if [[ "$PROBE_MODE" == permanent || + ( "$PROBE_MODE" == transient && "$count" == 0 ) || + ( "$PROBE_MODE" == wasm-transient && "$count" == 1 ) || + ( "$PROBE_MODE" == both-transient && ( "$count" == 0 || "$count" == 2 ) ) || + ( "$PROBE_MODE" == wasm-permanent && "$count" -ge 1 ) ]]; then printf '{"stats":{"cache_write_errors":1,"cache_writes":0}}\n' +elif [[ "$PROBE_MODE" == read-error ]]; then + printf '{"stats":{"cache_writes":1,"cache_read_errors":1}}\n' +elif [[ "$PROBE_MODE" == compile-cache-error ]]; then + printf '{"stats":{"cache_hits":{"counts":{"Rust":1}},"cache_errors":{"counts":{"Rust":1}}}}\n' +elif [[ "$PROBE_MODE" == malformed ]]; then + printf 'not-json\n' elif [[ "$PROBE_MODE" == empty ]]; then printf '{"stats":{}}\n' elif [[ "$PROBE_MODE" == hit ]]; then @@ -52,14 +67,55 @@ run_seed transient || fail 'compiler seed does not recover from a transient writ [[ "$(grep -c '^build ' "$PROBE_LOG")" == 3 ]] || fail 'retry does not rebuild fresh outputs' [[ "$(grep -c '^sleep ' "$PROBE_LOG")" == 1 ]] || fail 'transient error does not back off' run_seed hit || fail 'existing compiler hit is not accepted as published' -for mode in permanent empty compile-failure; do +[[ "$(grep -c '^build ' "$PROBE_LOG")" == 2 ]] || fail 'successful seed does not build both targets' +grep -q -- '--target test-host$' "$PROBE_LOG" || fail 'successful seed omits native target' +grep -q -- '--target wasm32-wasip1$' "$PROBE_LOG" || fail 'successful seed omits WASM target' +[[ "$(grep -c '^zero$' "$PROBE_LOG")" == 2 ]] || fail 'successful seed does not reset stats for each target' +for mode in wasm-transient both-transient; do + run_seed "$mode" || fail "$mode does not recover" + expected=3 + if [[ "$mode" == both-transient ]]; then expected=4; fi + [[ "$(grep -c '^build ' "$PROBE_LOG")" == "$expected" ]] || fail "$mode does not rebuild every attempt" + [[ "$(grep -c '^zero$' "$PROBE_LOG")" == "$expected" ]] || fail "$mode does not reset every attempt" +done +for mode in permanent empty compile-failure compile-wasm-failure wasm-permanent read-error compile-cache-error malformed; do if run_seed "$mode"; then fail "accepted unsuccessful compiler seeding: $mode"; fi - if [[ "$mode" == compile-failure ]]; then - [[ "$(grep -c '^build ' "$PROBE_LOG")" == 1 ]] || fail 'compiler errors are retried' - else - [[ "$(grep -c '^build ' "$PROBE_LOG")" == 3 ]] || fail 'cache retries are not bounded to three attempts' - fi + case "$mode" in + compile-failure) expected=1 ;; + compile-wasm-failure) expected=2 ;; + wasm-permanent) expected=4 ;; + *) expected=3 ;; + esac + [[ "$(grep -c '^build ' "$PROBE_LOG")" == "$expected" ]] || fail "$mode violates per-target retry limits" + [[ "$(grep -c '^zero$' "$PROBE_LOG")" == "$expected" ]] || fail "$mode uses stale stats" done +# Cleanup must never follow restored symlinks outside the fixture root. +for target in test-host wasm32-wasip1; do + for component in namespace debug; do + rm -rf -- "$CARGO_TARGET_DIR" "$WORK_DIR/outside" + mkdir -p "$CARGO_TARGET_DIR" "$WORK_DIR/outside/debug" + printf keep >"$WORK_DIR/outside/debug/marker" + if [[ "$component" == namespace ]]; then + ln -s "$WORK_DIR/outside" "$CARGO_TARGET_DIR/$target" + else + mkdir -p "$CARGO_TARGET_DIR/$target" + ln -s "$WORK_DIR/outside/debug" "$CARGO_TARGET_DIR/$target/debug" + fi + status=0 + PROBE_MODE=hit bash "$ACTION_DIR/tests/seed-compiler-cache.sh" >"$WORK_DIR/log" 2>&1 || status=$? + [[ -f "$WORK_DIR/outside/debug/marker" ]] || fail "cleanup escaped through $target $component symlink" + [[ "$status" != 0 ]] || fail "accepted $target $component symlink" + grep -Fq 'compiler seed cleanup must not follow target symlinks' "$WORK_DIR/log" || fail 'symlink case failed for an unrelated reason' + done +done +rm -rf -- "$CARGO_TARGET_DIR" +mkdir -p "$CARGO_TARGET_DIR" "$WORK_DIR/runner/edgezero-rust-cache/debug" +printf keep >"$WORK_DIR/runner/edgezero-rust-cache/debug/marker" +if PROBE_HOST=../.. PROBE_MODE=hit bash "$ACTION_DIR/tests/seed-compiler-cache.sh" >"$WORK_DIR/log" 2>&1; then + fail 'accepted traversal in compiler host' +fi +[[ -f "$WORK_DIR/runner/edgezero-rust-cache/debug/marker" ]] || fail 'removed output through host traversal' +grep -Fq 'rustc must report a safe host target component' "$WORK_DIR/log" || fail 'traversal case failed for an unrelated reason' # A mistaken directory must never be removed by retry cleanup. export CARGO_TARGET_DIR="$WORK_DIR/outside" mkdir -p "$CARGO_TARGET_DIR" diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 1c20bd07..37afa346 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -186,6 +186,18 @@ jobs: lookup-only: true fail-on-cache-miss: true + # Cache availability checks can return successfully before applying + # fail-on-cache-miss. Only an exact lookup hit establishes publication. + - name: Require exact target seed publication + env: + TARGET_SEED_FIRST_HIT: ${{ steps.target-verify-1.outputs.cache-hit }} + TARGET_SEED_SECOND_HIT: ${{ steps.target-verify-2.outputs.cache-hit }} + run: | + if [[ "$TARGET_SEED_FIRST_HIT" != true && "$TARGET_SEED_SECOND_HIT" != true ]]; then + echo '::error::target seed was not verified with an exact cache hit after two attempts' + exit 1 + fi + cache-restore-smoke: needs: cache-restore-seed runs-on: ubuntu-latest From 3373552e8684fb9655dbb462c8d7ed0453d7abe8 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Thu, 22 Oct 2026 14:11:40 +0530 Subject: [PATCH 3/3] Verify source cache publication before restore probes --- .../setup-rust-build-cache/tests/fixture.sh | 90 ++++++++++--------- .../tests/publication.sh | 76 ++++++++-------- .github/workflows/deploy-action.yml | 66 +++++++++++++- 3 files changed, 149 insertions(+), 83 deletions(-) diff --git a/.github/actions/setup-rust-build-cache/tests/fixture.sh b/.github/actions/setup-rust-build-cache/tests/fixture.sh index 643b1940..ecaf7d05 100755 --- a/.github/actions/setup-rust-build-cache/tests/fixture.sh +++ b/.github/actions/setup-rust-build-cache/tests/fixture.sh @@ -41,56 +41,60 @@ for job in cache-restore-smoke cache-new-revision-smoke; do [[ "$actual" == "$expected_identity" ]] || fail "$job does not reuse the seed fixture identity" done [[ "$(yq -r '.jobs."cache-restore-seed".steps[] | select(.id == "cache") | .with."save-target"' "$workflow")" == false ]] || fail 'seed relies on unverified post-job saving' -# shellcheck disable=SC2016 # Validate the actual inputs to both action attempts. -expected_path='${{ steps.cache.outputs.cargo-target-dir }}' -# shellcheck disable=SC2016 -expected_key='${{ steps.cache.outputs.target-cache-key }}' -retry_condition="steps.target-verify-1.outputs.cache-hit != 'true'" -for step in target-save-1 target-verify-1 target-save-2 target-verify-2; do - actual=$(STEP_ID="$step" yq -r '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .with.path' "$workflow") - [[ "$actual" == "$expected_path" ]] || fail "$step uses a different cache path" - actual=$(STEP_ID="$step" yq -r '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .with.key' "$workflow") - [[ "$actual" == "$expected_key" ]] || fail "$step uses a different cache key" - actual=$(STEP_ID="$step" yq -r '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .if // ""' "$workflow") - if [[ "$step" == *-2 ]]; then - [[ "$actual" == "$retry_condition" ]] || fail "$step retries without a first-attempt miss" - else - [[ -z "$actual" ]] || fail "$step skips the initial publication attempt" - fi -done for job in fixture-release store-free-release; do actual=$(JOB_ID="$job" yq -r '.jobs[strenv(JOB_ID)].needs // [] | length' "$workflow") [[ "$actual" == 0 ]] || fail "$job depends on cache probe availability" done -for attempt in 1 2; do - STEP_ID="target-save-$attempt" yq -e '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .uses == "actions/cache/save@v5"' "$workflow" >/dev/null || fail 'missing explicit target save' - STEP_ID="target-verify-$attempt" yq -e '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .with."lookup-only" == true' "$workflow" >/dev/null || fail 'target publication is not verified' -done -yq -e '.jobs."cache-restore-seed".steps[] | select(.id == "target-verify-2") | .with."fail-on-cache-miss" == true' "$workflow" >/dev/null || fail 'exhausted target publication retries stay green' -# The action can return successfully before honoring fail-on-cache-miss when -# the cache feature is unavailable. Require actual exact-hit output as well. -publication_step='.jobs."cache-restore-seed".steps[] | select(.name == "Require exact target seed publication")' -publication_assertion=$(yq -r "$publication_step | .run" "$workflow") -[[ -n "$publication_assertion" && "$publication_assertion" != null ]] || fail 'missing exact publication assertion' -# shellcheck disable=SC2016 # GitHub outputs must feed the assertion literally. -first_hit='${{ steps.target-verify-1.outputs.cache-hit }}' -# shellcheck disable=SC2016 -second_hit='${{ steps.target-verify-2.outputs.cache-hit }}' -[[ "$(yq -r "$publication_step | .env.TARGET_SEED_FIRST_HIT" "$workflow")" == "$first_hit" ]] || fail 'publication assertion ignores first lookup output' -[[ "$(yq -r "$publication_step | .env.TARGET_SEED_SECOND_HIT" "$workflow")" == "$second_hit" ]] || fail 'publication assertion ignores second lookup output' -[[ "$(yq -r "$publication_step | .if // \"\"" "$workflow")" == '' ]] || fail 'publication assertion can be skipped after successful lookup steps' -[[ "$(yq -r "$publication_step | .\"continue-on-error\" // false" "$workflow")" == false ]] || fail 'publication assertion failure is ignored' -for first in '' false true partial; do - for second in '' false true partial; do - status=0 - TARGET_SEED_FIRST_HIT="$first" TARGET_SEED_SECOND_HIT="$second" \ - bash -e -o pipefail -c "$publication_assertion" >"$WORK_DIR/publication.log" 2>&1 || status=$? - if [[ "$first" == true || "$second" == true ]]; then - [[ "$status" == 0 ]] || fail 'publication assertion rejects a verified exact hit' +# Exercise both publication surfaces against their actual workflow wiring. +for kind in source target; do + if [[ "$kind" == source ]]; then + expected_path=$(yq -r '.runs.steps[] | select(.id == "cargo-sources") | .with.path' "$ACTION_DIR/action.yml") + else + # shellcheck disable=SC2016 # Literal GitHub output reference. + expected_path='${{ steps.cache.outputs.cargo-target-dir }}' + fi + expected_key="\${{ steps.cache.outputs.$kind-cache-key }}" + retry_condition="steps.$kind-verify-1.outputs.cache-hit != 'true'" + for step in "$kind-save-1" "$kind-verify-1" "$kind-save-2" "$kind-verify-2"; do + actual=$(STEP_ID="$step" yq -r '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .with.path' "$workflow") + [[ "$actual" == "$expected_path" ]] || fail "$step uses a different cache path" + actual=$(STEP_ID="$step" yq -r '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .with.key' "$workflow") + [[ "$actual" == "$expected_key" ]] || fail "$step uses a different cache key" + actual=$(STEP_ID="$step" yq -r '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .if // ""' "$workflow") + if [[ "$step" == *-2 ]]; then + [[ "$actual" == "$retry_condition" ]] || fail "$step retries without a first-attempt miss" else - [[ "$status" != 0 ]] || fail "publication assertion accepts unverified outputs '$first'/'$second'" + [[ -z "$actual" ]] || fail "$step skips the initial publication attempt" fi done + for attempt in 1 2; do + STEP_ID="$kind-save-$attempt" yq -e '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .uses == "actions/cache/save@v5"' "$workflow" >/dev/null || fail 'missing explicit cache save' + STEP_ID="$kind-verify-$attempt" yq -e '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .with."lookup-only" == true' "$workflow" >/dev/null || fail 'cache publication is not verified' + done + STEP_ID="$kind-verify-2" yq -e '.jobs."cache-restore-seed".steps[] | select(.id == strenv(STEP_ID)) | .with."fail-on-cache-miss" == true' "$workflow" >/dev/null || fail 'exhausted publication retries stay green' + # The action can return successfully before honoring fail-on-cache-miss. + # Require actual exact-hit output, including when availability checks fail. + publication_step=".jobs.\"cache-restore-seed\".steps[] | select(.name == \"Require exact $kind seed publication\")" + publication_assertion=$(yq -r "$publication_step | .run" "$workflow") + [[ -n "$publication_assertion" && "$publication_assertion" != null ]] || fail 'missing exact publication assertion' + first_hit="\${{ steps.$kind-verify-1.outputs.cache-hit }}" + second_hit="\${{ steps.$kind-verify-2.outputs.cache-hit }}" + [[ "$(yq -r "$publication_step | .env.CACHE_SEED_FIRST_HIT" "$workflow")" == "$first_hit" ]] || fail 'publication assertion ignores first lookup output' + [[ "$(yq -r "$publication_step | .env.CACHE_SEED_SECOND_HIT" "$workflow")" == "$second_hit" ]] || fail 'publication assertion ignores second lookup output' + [[ "$(yq -r "$publication_step | .if // \"\"" "$workflow")" == '' ]] || fail 'publication assertion can be skipped after successful lookup steps' + [[ "$(yq -r "$publication_step | .\"continue-on-error\" // false" "$workflow")" == false ]] || fail 'publication assertion failure is ignored' + for first in '' false true partial; do + for second in '' false true partial; do + status=0 + CACHE_SEED_FIRST_HIT="$first" CACHE_SEED_SECOND_HIT="$second" \ + bash -e -o pipefail -c "$publication_assertion" >"$WORK_DIR/publication.log" 2>&1 || status=$? + if [[ "$first" == true || "$second" == true ]]; then + [[ "$status" == 0 ]] || fail 'publication assertion rejects a verified exact hit' + else + [[ "$status" != 0 ]] || fail "publication assertion accepts unverified outputs '$first'/'$second'" + fi + done + done done for job in fixture-release store-free-release; do actual=$(JOB_ID="$job" yq -r '.jobs[strenv(JOB_ID)].steps[] | select(.uses == "./.github/actions/setup-rust-build-cache") | .with."cache-target"' "$workflow") diff --git a/.github/actions/setup-rust-build-cache/tests/publication.sh b/.github/actions/setup-rust-build-cache/tests/publication.sh index a0ae6792..efee1326 100755 --- a/.github/actions/setup-rust-build-cache/tests/publication.sh +++ b/.github/actions/setup-rust-build-cache/tests/publication.sh @@ -19,42 +19,44 @@ reject_mutation() { } } -publication='.jobs."cache-restore-seed".steps[] | select(.name == "Require exact target seed publication")' -reject_mutation 'missing final gate' \ - '.jobs."cache-restore-seed".steps |= map(select(.name != "Require exact target seed publication"))' \ - 'missing exact publication assertion' -reject_mutation 'hardcoded first hit' \ - "($publication | .env.TARGET_SEED_FIRST_HIT) = \"true\"" \ - 'publication assertion ignores first lookup output' -reject_mutation 'hardcoded second hit' \ - "($publication | .env.TARGET_SEED_SECOND_HIT) = \"true\"" \ - 'publication assertion ignores second lookup output' -reject_mutation 'skipped final gate' \ - "($publication | .if) = \"false\"" \ - 'publication assertion can be skipped' -reject_mutation 'ignored final failure' \ - "($publication | .\"continue-on-error\") = true" \ - 'publication assertion failure is ignored' -reject_mutation 'gate always succeeds' \ - "($publication | .run) = \"true\"" \ - 'publication assertion accepts unverified outputs' -reject_mutation 'gate always fails' \ - "($publication | .run) = \"false\"" \ - 'publication assertion rejects a verified exact hit' -for step in target-save-1 target-verify-1 target-save-2 target-verify-2; do - for input in path key; do - reject_mutation "$step wrong $input" \ - "(.jobs.\"cache-restore-seed\".steps[] | select(.id == \"$step\") | .with.$input) = \"wrong\"" \ - "$step uses a different cache $input" +for kind in source target; do + publication=".jobs.\"cache-restore-seed\".steps[] | select(.name == \"Require exact $kind seed publication\")" + reject_mutation 'missing final gate' \ + ".jobs.\"cache-restore-seed\".steps |= map(select(.name != \"Require exact $kind seed publication\"))" \ + 'missing exact publication assertion' + reject_mutation 'hardcoded first hit' \ + "($publication | .env.CACHE_SEED_FIRST_HIT) = \"true\"" \ + 'publication assertion ignores first lookup output' + reject_mutation 'hardcoded second hit' \ + "($publication | .env.CACHE_SEED_SECOND_HIT) = \"true\"" \ + 'publication assertion ignores second lookup output' + reject_mutation 'skipped final gate' \ + "($publication | .if) = \"false\"" \ + 'publication assertion can be skipped' + reject_mutation 'ignored final failure' \ + "($publication | .\"continue-on-error\") = true" \ + 'publication assertion failure is ignored' + reject_mutation 'gate always succeeds' \ + "($publication | .run) = \"true\"" \ + 'publication assertion accepts unverified outputs' + reject_mutation 'gate always fails' \ + "($publication | .run) = \"false\"" \ + 'publication assertion rejects a verified exact hit' + for step in "$kind-save-1" "$kind-verify-1" "$kind-save-2" "$kind-verify-2"; do + for input in path key; do + reject_mutation "$step wrong $input" \ + "(.jobs.\"cache-restore-seed\".steps[] | select(.id == \"$step\") | .with.$input) = \"wrong\"" \ + "$step uses a different cache $input" + done + if [[ "$step" == *-2 ]]; then + reject_mutation "$step wrong retry condition" \ + "(.jobs.\"cache-restore-seed\".steps[] | select(.id == \"$step\") | .if) = \"false\"" \ + "$step retries without a first-attempt miss" + else + reject_mutation "$step skipped initial attempt" \ + "(.jobs.\"cache-restore-seed\".steps[] | select(.id == \"$step\") | .if) = \"false\"" \ + "$step skips the initial publication attempt" + fi done - if [[ "$step" == *-2 ]]; then - reject_mutation "$step wrong retry condition" \ - "(.jobs.\"cache-restore-seed\".steps[] | select(.id == \"$step\") | .if) = \"false\"" \ - "$step retries without a first-attempt miss" - else - reject_mutation "$step skipped initial attempt" \ - "(.jobs.\"cache-restore-seed\".steps[] | select(.id == \"$step\") | .if) = \"false\"" \ - "$step skips the initial publication attempt" - fi done -printf 'target publication workflow mutation contracts passed\n' +printf 'source and target publication workflow mutation contracts passed\n' diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 37afa346..bfb18d8d 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -151,6 +151,66 @@ jobs: - name: Seed native and WASM compiler outputs run: bash .github/actions/setup-rust-build-cache/tests/seed-compiler-cache.sh + # Sources otherwise save only in a warning-only post-job hook. Verify + # them before allowing the source-restore consumer to start. The combined + # action may later attempt an immutable duplicate save on an initial miss. + - name: Save seed source cache + id: source-save-1 + uses: actions/cache/save@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: ${{ steps.cache.outputs.source-cache-key }} + - name: Verify seed source publication + id: source-verify-1 + uses: actions/cache/restore@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: ${{ steps.cache.outputs.source-cache-key }} + lookup-only: true + - name: Back off before retrying source publication + if: steps.source-verify-1.outputs.cache-hit != 'true' + run: sleep 60 + - name: Retry seed source cache save + id: source-save-2 + if: steps.source-verify-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: ${{ steps.cache.outputs.source-cache-key }} + - name: Require seed source publication + id: source-verify-2 + if: steps.source-verify-1.outputs.cache-hit != 'true' + uses: actions/cache/restore@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: ${{ steps.cache.outputs.source-cache-key }} + lookup-only: true + fail-on-cache-miss: true + + # Cache availability checks can return successfully before applying + # fail-on-cache-miss. Only an exact lookup hit establishes publication. + - name: Require exact source seed publication + env: + CACHE_SEED_FIRST_HIT: ${{ steps.source-verify-1.outputs.cache-hit }} + CACHE_SEED_SECOND_HIT: ${{ steps.source-verify-2.outputs.cache-hit }} + run: | + if [[ "$CACHE_SEED_FIRST_HIT" != true && "$CACHE_SEED_SECOND_HIT" != true ]]; then + echo '::error::source seed was not verified with an exact cache hit after two attempts' + exit 1 + fi + # Cache save warnings do not fail the action. Verify publication explicitly # so a throttled seed cannot report success and fail both consumer jobs. - name: Save seed target cache @@ -190,10 +250,10 @@ jobs: # fail-on-cache-miss. Only an exact lookup hit establishes publication. - name: Require exact target seed publication env: - TARGET_SEED_FIRST_HIT: ${{ steps.target-verify-1.outputs.cache-hit }} - TARGET_SEED_SECOND_HIT: ${{ steps.target-verify-2.outputs.cache-hit }} + CACHE_SEED_FIRST_HIT: ${{ steps.target-verify-1.outputs.cache-hit }} + CACHE_SEED_SECOND_HIT: ${{ steps.target-verify-2.outputs.cache-hit }} run: | - if [[ "$TARGET_SEED_FIRST_HIT" != true && "$TARGET_SEED_SECOND_HIT" != true ]]; then + if [[ "$CACHE_SEED_FIRST_HIT" != true && "$CACHE_SEED_SECOND_HIT" != true ]]; then echo '::error::target seed was not verified with an exact cache hit after two attempts' exit 1 fi