From d37de722b53260afe422adf98d4dd853da2aa56e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:36:14 -0700 Subject: [PATCH 1/4] Improve Rust cache reuse with revision-isolated target artifacts Separate stable dependency source keys from source-revision target keys, keep build paths stable, and add local isolation guards and real cache/compiler probes. Closes #416 --- .github/actions/build-app-cli/action.yml | 4 +- .github/actions/deploy-core/tests/run.sh | 23 +++ .../actions/setup-rust-build-cache/action.yml | 31 +++- .../setup-rust-build-cache/scripts/prepare.sh | 78 ++++++--- .../tests/assert-compiler-reuse.sh | 27 ++++ .../tests/make-fixture.sh | 25 +++ .../setup-rust-build-cache/tests/revision.sh | 148 ++++++++++++++++++ .../setup-rust-build-cache/tests/run.sh | 23 ++- .github/workflows/deploy-action.yml | 78 +++++++-- docs/guide/deploy-action-adoption.md | 52 ++++-- ...dgezero-deploy-cache-revision-isolation.md | 80 ++++++++++ ...-deploy-cache-revision-isolation-design.md | 72 +++++++++ 12 files changed, 578 insertions(+), 63 deletions(-) create mode 100644 .github/actions/setup-rust-build-cache/tests/assert-compiler-reuse.sh create mode 100644 .github/actions/setup-rust-build-cache/tests/make-fixture.sh create mode 100644 .github/actions/setup-rust-build-cache/tests/revision.sh create mode 100644 docs/superpowers/plans/2026-10-12-edgezero-deploy-cache-revision-isolation.md create mode 100644 docs/superpowers/specs/2026-10-12-edgezero-deploy-cache-revision-isolation-design.md diff --git a/.github/actions/build-app-cli/action.yml b/.github/actions/build-app-cli/action.yml index f73d307f4..32afc84d4 100644 --- a/.github/actions/build-app-cli/action.yml +++ b/.github/actions/build-app-cli/action.yml @@ -105,8 +105,8 @@ runs: EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} EDGEZERO__PROJECT__RUST_TOOLCHAIN: ${{ inputs['rust-toolchain'] }} EDGEZERO__APP__CLI__ARTIFACT: ${{ inputs['app-cli-artifact'] }} - # setup-rust-build-cache publishes this through GITHUB_ENV only when its - # opt-in target cache is enabled. build-app-cli confines it to RUNNER_TEMP. + # setup-rust-build-cache always publishes this stable build directory, + # independently of target restore/save. Confine it to RUNNER_TEMP. EDGEZERO__RUST_CACHE__TARGET_DIR: ${{ env.EDGEZERO__RUST_CACHE__TARGET_DIR }} # This step runs app-controlled code, so it emits NO outputs through the # runner's GITHUB_OUTPUT (blanked below). It collects them into this diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh index a3bc1d0ef..6bbf74c93 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -1991,6 +1991,13 @@ test_action_output_contracts() { script=$(awk -v want="$step_id" '$1 == want { $1 = ""; sub(/^ /, ""); print; exit }' <<<"$scripts") if [[ -z "$script" ]]; then + # actions/cache declares cache-hit itself, not through a local script. + # Accept only this documented output from this concrete action ref. + if [[ "$out_name" == cache-hit ]] && command -v yq >/dev/null 2>&1; then + local output_action + output_action=$(STEP_ID="$step_id" yq -r '.runs.steps[] | select(.id == strenv(STEP_ID)) | .uses' "$action") + [[ "$output_action" != actions/cache@v5 ]] || continue + fi fail "$name_of output '$out_name' names step '$step_id', which runs no script" missing=$((missing + 1)) continue @@ -2003,6 +2010,11 @@ test_action_output_contracts() { # The named step's own script must emit it, or delegate to a shared core # script that emits it as part of the same invocation. emitted=$(grep -oE "append_output ${out_name}( |\$)" "$script" || true) + if [[ -z "$emitted" && "$name_of" == setup-rust-build-cache && "$step_id" == prepare ]]; then + # prepare.sh uses printf directly; its emissions are also checked by + # the behavioral cache contracts, including failure-before-output. + emitted=$(grep -F "printf '${out_name}=" "$script" || true) + fi if [[ -z "$emitted" ]] && grep -q 'run-app-cli\.sh' "$script"; then emitted=$(grep -oE "append_output ${out_name}( |\$)" "$CORE_SCRIPTS/run-app-cli.sh" || true) fi @@ -2292,6 +2304,17 @@ test_fastly_smoke_release_contract() { grep -Fq './.github/actions/setup-rust-build-cache' <<<"$cache_restore" assert_succeeds "cache restore verifies a marker created by the seed job" \ grep -Fq 'restore-marker' <<<"$cache_restore" + local cache_call missing_revisions revision_smoke + cache_call='.jobs[] | .steps[]? | select(.uses == "./.github/actions/setup-rust-build-cache")' + missing_revisions=$(yq eval -r "$cache_call | select(.with.\"cache-target\" == true and .with.\"target-cache-revision\" == null) | .name" "$workflow") + assert_equals "all target cache callers supply their source revision" "" "$missing_revisions" + revision_smoke=$(yq eval -r '.jobs."cache-new-revision-smoke"' "$workflow") + assert_succeeds "new-revision smoke checks target artifact isolation" \ + grep -Fq 'target-cache-hit' <<<"$revision_smoke" + assert_succeeds "new-revision smoke checks source cache reuse" \ + grep -Fq 'source-cache-hit' <<<"$revision_smoke" + assert_succeeds "new-revision smoke checks compiler reuse for both targets" \ + grep -Fq 'assert-compiler-reuse.sh' <<<"$revision_smoke" for obsolete in cache-smoke-capture.sh cache-smoke-assert.sh cache-smoke-assert-no-restore.sh; do assert_fails "obsolete deploy-time cache helper is removed ($obsolete)" \ test -e "$ACTIONS_DIR/deploy-core/tests/$obsolete" diff --git a/.github/actions/setup-rust-build-cache/action.yml b/.github/actions/setup-rust-build-cache/action.yml index b52d2e5b1..eba286e9f 100644 --- a/.github/actions/setup-rust-build-cache/action.yml +++ b/.github/actions/setup-rust-build-cache/action.yml @@ -13,6 +13,26 @@ inputs: description: Restore and save the application's Cargo target directory in addition to compiler outputs. required: false default: "false" + target-cache-revision: + description: Full lowercase application source Git SHA; required when cache-target is true. Never use a deployment repository SHA for different application source. + required: false + +outputs: + source-cache-hit: + description: Whether the dependency source cache matched its exact primary key (false also includes prefix reuse). + value: ${{ steps.cargo-sources.outputs.cache-hit == 'true' }} + target-cache-hit: + description: Whether target artifacts matched the revision-specific key; false when target caching is disabled or misses. + value: ${{ steps.cargo-target.outputs.cache-hit == 'true' }} + source-cache-key: + description: Dependency source cache primary key. + value: ${{ steps.prepare.outputs['source-cache-key'] }} + target-cache-key: + description: Revision-specific target cache key; empty if no revision was supplied. + value: ${{ steps.prepare.outputs['target-cache-key'] }} + cargo-target-dir: + description: Stable confined directory used by later native and WASM builds, even when target caching is disabled. + value: ${{ steps.prepare.outputs['cargo-target-dir'] }} runs: using: composite @@ -23,6 +43,7 @@ runs: env: EDGEZERO__RUST_CACHE__APP_NAME: ${{ inputs['app-name'] }} EDGEZERO__RUST_CACHE__CACHE_TARGET: ${{ inputs['cache-target'] }} + EDGEZERO__RUST_CACHE__TARGET_CACHE_REVISION: ${{ inputs['target-cache-revision'] }} EDGEZERO__RUST_CACHE__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} BASH_ENV: "" ENV: "" @@ -36,18 +57,16 @@ runs: ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ - key: edgezero-app-${{ steps.prepare.outputs['cache-namespace'] }}-rust-sources-${{ runner.os }}-${{ runner.arch }}-${{ steps.prepare.outputs['cargo-lock-sha256'] }} - restore-keys: | - edgezero-app-${{ steps.prepare.outputs['cache-namespace'] }}-rust-sources-${{ runner.os }}-${{ runner.arch }}- + key: ${{ steps.prepare.outputs['source-cache-key'] }} + restore-keys: ${{ steps.prepare.outputs['source-cache-restore-prefix'] }} - name: Restore Cargo target artifacts + id: cargo-target if: inputs['cache-target'] == 'true' uses: actions/cache@v5 with: path: ${{ steps.prepare.outputs['cargo-target-dir'] }} - key: edgezero-app-${{ steps.prepare.outputs['cache-namespace'] }}-rust-target-${{ runner.os }}-${{ runner.arch }}-${{ steps.prepare.outputs['cargo-lock-sha256'] }} - restore-keys: | - edgezero-app-${{ steps.prepare.outputs['cache-namespace'] }}-rust-target-${{ runner.os }}-${{ runner.arch }}-${{ steps.prepare.outputs['cargo-lock-sha256'] }}- + key: ${{ steps.prepare.outputs['target-cache-key'] }} - name: Set up Rust compiler cache uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 diff --git a/.github/actions/setup-rust-build-cache/scripts/prepare.sh b/.github/actions/setup-rust-build-cache/scripts/prepare.sh index 8554bcc69..6eb93d886 100755 --- a/.github/actions/setup-rust-build-cache/scripts/prepare.sh +++ b/.github/actions/setup-rust-build-cache/scripts/prepare.sh @@ -4,8 +4,8 @@ set -euo pipefail # Validates the application and Rust workspace selected by # setup-rust-build-cache, binds its cache keys to the application and Cargo.lock # bytes, and enables sccache for every later Rust build in the current GitHub -# Actions job. When target caching is enabled, it also publishes one confined, -# application-scoped CARGO_TARGET_DIR beneath RUNNER_TEMP. +# Actions job. Always publishes a stable, confined, application-scoped +# CARGO_TARGET_DIR. Remote target artifacts are isolated by source revision. # # This script reads no application manifest and runs no application code. The # action therefore remains independent of the package, adapter, and deployment @@ -44,6 +44,7 @@ main() { local env_file="${GITHUB_ENV:?GITHUB_ENV is required}" local app_name="${EDGEZERO__RUST_CACHE__APP_NAME-}" local cache_target="${EDGEZERO__RUST_CACHE__CACHE_TARGET-false}" + local revision="${EDGEZERO__RUST_CACHE__TARGET_CACHE_REVISION-}" local working_directory="${EDGEZERO__RUST_CACHE__WORKING_DIRECTORY-}" [[ "$app_name" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] || @@ -52,6 +53,10 @@ main() { true | false) ;; *) fail "input 'cache-target' must be 'true' or 'false'" ;; esac + if [[ "$cache_target" == true || -n "$revision" ]]; then + [[ "$revision" =~ ^[0-9a-f]{40}$ || "$revision" =~ ^[0-9a-f]{64}$ ]] || + fail "input 'target-cache-revision' must be a full lowercase Git SHA (required when cache-target is true)" + fi [[ -n "$working_directory" ]] || fail "input 'working-directory' cannot be empty" [[ "$working_directory" != /* ]] || @@ -76,34 +81,65 @@ main() { [[ "$lock_sha" =~ ^[0-9a-f]{64}$ ]] || fail "could not compute a lowercase SHA-256 for Cargo.lock" + local runner_temp="${RUNNER_TEMP:?RUNNER_TEMP is required}" + [[ -d "$runner_temp" ]] || fail "RUNNER_TEMP does not exist or is not a directory" + local runner_temp_real cache_root app_dir target_dir selection_file path + runner_temp_real=$(canonical_path "$runner_temp") + cache_root="$runner_temp_real/edgezero-rust-cache" + app_dir="$cache_root/$app_name" + target_dir="$app_dir/target" + selection_file="$app_dir/selection" + for path in "$cache_root" "$app_dir" "$target_dir" "$selection_file"; do + [[ ! -L "$path" ]] || fail "Rust cache path must not be a symlink: '$path'" + done + for path in "$cache_root" "$app_dir" "$target_dir"; do + [[ ! -e "$path" || -d "$path" ]] || fail "Rust cache path is not a directory: '$path'" + done + [[ ! -e "$selection_file" || -f "$selection_file" ]] || fail "Rust cache selection is not a regular file" + + local selection + selection=$(printf '%s\n%s\n' "$project_real" "${revision:-uncached}") + if [[ -e "$selection_file" ]]; then + [[ -n "$revision" ]] || fail "repeated cache setup requires target-cache-revision" + [[ "$(<"$selection_file")" == "$selection" ]] || + fail "Rust target directory already belongs to a different workspace or target-cache-revision" + elif [[ -d "$target_dir" ]]; then + local target_contents + # Quote control characters so newline-only names survive command substitution. + target_contents=$(ls -Aq -- "$target_dir") || fail "could not inspect Rust target directory" + [[ -z "$target_contents" ]] || + fail "Rust target directory contains unowned artifacts without a selection marker" + fi + + local runner_os="${RUNNER_OS:?RUNNER_OS is required}" runner_arch="${RUNNER_ARCH:?RUNNER_ARCH is required}" + [[ "$runner_os" =~ ^[a-zA-Z0-9_-]+$ && "$runner_arch" =~ ^[a-zA-Z0-9_-]+$ ]] || + fail "runner OS and architecture must be safe cache-key components" + local source_prefix source_key target_key='' + source_prefix="edgezero-app-$app_name-rust-sources-$runner_os-$runner_arch-" + source_key="$source_prefix$lock_sha" + [[ -z "$revision" ]] || target_key="edgezero-app-$app_name-rust-target-v2-$runner_os-$runner_arch-$lock_sha-$revision-exact" + + mkdir -p "$target_dir" + target_dir=$(canonical_path "$target_dir") + is_under "$runner_temp_real" "$target_dir" || + fail "Cargo target cache directory must resolve inside RUNNER_TEMP" + printf '%s\n' "$selection" >"$selection_file" + { printf 'cache-namespace=%s\n' "$app_name" printf 'cargo-lock-sha256=%s\n' "$lock_sha" + printf 'source-cache-key=%s\n' "$source_key" + printf 'source-cache-restore-prefix=%s\n' "$source_prefix" + printf 'target-cache-key=%s\n' "$target_key" + printf 'cargo-target-dir=%s\n' "$target_dir" } >>"$output_file" { printf 'RUSTC_WRAPPER=sccache\n' printf 'SCCACHE_GHA_ENABLED=true\n' printf 'CARGO_INCREMENTAL=0\n' + printf 'CARGO_TARGET_DIR=%s\n' "$target_dir" + printf 'EDGEZERO__RUST_CACHE__TARGET_DIR=%s\n' "$target_dir" } >>"$env_file" - - if [[ "$cache_target" == "true" ]]; then - local runner_temp="${RUNNER_TEMP:?RUNNER_TEMP is required when cache-target is true}" - [[ -d "$runner_temp" ]] || fail "RUNNER_TEMP does not exist or is not a directory" - - local runner_temp_real target_dir - runner_temp_real=$(canonical_path "$runner_temp") - target_dir="$runner_temp_real/edgezero-rust-cache/$app_name/target" - mkdir -p "$target_dir" - target_dir=$(canonical_path "$target_dir") - is_under "$runner_temp_real" "$target_dir" || - fail "Cargo target cache directory must resolve inside RUNNER_TEMP" - - printf 'cargo-target-dir=%s\n' "$target_dir" >>"$output_file" - { - printf 'CARGO_TARGET_DIR=%s\n' "$target_dir" - printf 'EDGEZERO__RUST_CACHE__TARGET_DIR=%s\n' "$target_dir" - } >>"$env_file" - fi } main "$@" diff --git a/.github/actions/setup-rust-build-cache/tests/assert-compiler-reuse.sh b/.github/actions/setup-rust-build-cache/tests/assert-compiler-reuse.sh new file mode 100644 index 000000000..8f172ed5a --- /dev/null +++ b/.github/actions/setup-rust-build-cache/tests/assert-compiler-reuse.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Force real native/WASM recompilation at a stable path, rather than accepting +# Cargo freshness as compiler-cache evidence. Clean only this fixture package. +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 probe 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 + cargo build --locked --lib --target "$target" + cargo clean --package edgezero-cache-probe --target "$target" + sccache --zero-stats + cargo build --locked --lib --target "$target" + stats=$(sccache --show-stats --stats-format json) + printf 'Compiler reuse for %s: %s\n' "$target" "$stats" + jq -e '(.stats.cache_hits.counts.Rust // 0) > 0' <<<"$stats" >/dev/null || { + printf '::error::no Rust compiler-cache hit after forced recompilation for %s\n' "$target" >&2 + exit 1 + } +done diff --git a/.github/actions/setup-rust-build-cache/tests/make-fixture.sh b/.github/actions/setup-rust-build-cache/tests/make-fixture.sh new file mode 100644 index 000000000..e98832848 --- /dev/null +++ b/.github/actions/setup-rust-build-cache/tests/make-fixture.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +# A dependency-free library; only README changes between source revisions. +mode="${1:?fixture mode is required}" +[[ "$mode" == seed || "$mode" == next ]] || exit 2 +fixture="${GITHUB_WORKSPACE:?}/cache-restore-fixture" +mkdir -p "$fixture/src" +printf '[package]\nname = "edgezero-cache-probe"\nversion = "0.1.0"\nedition = "2021"\n[workspace]\n' >"$fixture/Cargo.toml" +printf 'version = 3\n[[package]]\nname = "edgezero-cache-probe"\nversion = "0.1.0"\n' >"$fixture/Cargo.lock" +printf 'pub fn answer() -> u32 { 42 }\n' >"$fixture/src/lib.rs" +printf 'cache fixture run %s attempt %s\n' "${GITHUB_RUN_ID:-local}" "${GITHUB_RUN_ATTEMPT:-1}" >"$fixture/README.md" +git -C "$fixture" init -q +git -C "$fixture" add Cargo.toml Cargo.lock src/lib.rs README.md +GIT_AUTHOR_DATE=2000-01-01T00:00:00Z GIT_COMMITTER_DATE=2000-01-01T00:00:00Z \ + git -C "$fixture" -c core.hooksPath=/dev/null -c user.name=Test -c user.email=test@example.com \ + -c commit.gpgsign=false commit -qm seed +if [[ "$mode" == next ]]; then + printf 'new revision, identical compiler inputs\n' >>"$fixture/README.md" + git -C "$fixture" add README.md + GIT_AUTHOR_DATE=2000-01-01T00:00:01Z GIT_COMMITTER_DATE=2000-01-01T00:00:01Z \ + git -C "$fixture" -c core.hooksPath=/dev/null -c user.name=Test -c user.email=test@example.com \ + -c commit.gpgsign=false commit -qm next +fi +printf 'revision=%s\n' "$(git -C "$fixture" rev-parse HEAD)" >>"${GITHUB_OUTPUT:?}" diff --git a/.github/actions/setup-rust-build-cache/tests/revision.sh b/.github/actions/setup-rust-build-cache/tests/revision.sh new file mode 100644 index 000000000..f7be91c6b --- /dev/null +++ b/.github/actions/setup-rust-build-cache/tests/revision.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Local behavioral and structural contracts; no network or provider credentials. +ACTION_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-cache-revision-test.XXXXXX") +trap 'rm -rf -- "$WORK_DIR"' EXIT +revision_a=1111111111111111111111111111111111111111 +revision_b=2222222222222222222222222222222222222222 +mkdir -p "$WORK_DIR/workspace/app" "$WORK_DIR/workspace/other" +printf 'version = 3\n' >"$WORK_DIR/workspace/app/Cargo.lock" +cp "$WORK_DIR/workspace/app/Cargo.lock" "$WORK_DIR/workspace/other/Cargo.lock" + +fail() { printf 'cache revision test failed: %s\n' "$*" >&2; exit 1; } +assert_line() { grep -Fqx -- "$2" "$1" || fail "expected '$2' in $1"; } +value() { sed -n "s/^$1=//p" "$WORK_DIR/output"; } +prepare() { + local runner="$1" mode="$2" revision="$3" project="${4:-app}" + mkdir -p "$runner" + : >"$WORK_DIR/output" + : >"$WORK_DIR/env" + env GITHUB_WORKSPACE="$WORK_DIR/workspace" RUNNER_TEMP="$runner" \ + RUNNER_OS=Linux RUNNER_ARCH=X64 GITHUB_OUTPUT="$WORK_DIR/output" \ + GITHUB_ENV="$WORK_DIR/env" EDGEZERO__RUST_CACHE__APP_NAME=example-app \ + EDGEZERO__RUST_CACHE__CACHE_TARGET="$mode" \ + EDGEZERO__RUST_CACHE__TARGET_CACHE_REVISION="$revision" \ + EDGEZERO__RUST_CACHE__WORKING_DIRECTORY="$project" \ + "$ACTION_DIR/scripts/prepare.sh" >"$WORK_DIR/log" 2>&1 +} +reject() { + local message="$1" + shift + if prepare "$@"; then fail "accepted $message"; fi + [[ ! -s "$WORK_DIR/output" && ! -s "$WORK_DIR/env" ]] || + fail "published outputs/environment for $message" +} + +for invalid in '' main abc 111111111111111111111111111111111111111 \ + 11111111111111111111111111111111111111111A '../outside' $'abc\nCARGO_TARGET_DIR=/tmp'; do + reject "invalid revision '$invalid'" "$WORK_DIR/invalid" true "$invalid" + grep -Fq "target-cache-revision" "$WORK_DIR/log" || fail "missing revision diagnostic" +done +reject 'invalid optional revision' "$WORK_DIR/invalid" false main + +prepare "$WORK_DIR/first" true "$revision_a" +target_a=$(value cargo-target-dir) +sources_a=$(value source-cache-key) +target_key_a=$(value target-cache-key) +[[ -n "$sources_a" && -n "$target_key_a" ]] || fail 'missing cache key outputs' +[[ "$target_key_a" == *"-rust-target-v2-"*"-$revision_a-exact" ]] || fail 'target key is not revision-isolated v2' +[[ "$sources_a" != *"$revision_a"* ]] || fail 'source key contains source revision' +assert_line "$WORK_DIR/env" "CARGO_TARGET_DIR=$target_a" +assert_line "$WORK_DIR/env" "EDGEZERO__RUST_CACHE__TARGET_DIR=$target_a" +[[ "$target_a" == "$(realpath "$WORK_DIR/first")/edgezero-rust-cache/example-app/target" ]] || + fail 'target path is not stable and app-specific' +printf 'retained\n' >"$target_a/marker" +prepare "$WORK_DIR/first" true "$revision_a" +assert_line "$target_a/marker" retained +reject 'mixed revision in one target directory' "$WORK_DIR/first" true "$revision_b" +reject 'mixed application workspace' "$WORK_DIR/first" true "$revision_a" other + +prepare "$WORK_DIR/second" true "$revision_b" +[[ "$(value source-cache-key)" == "$sources_a" ]] || fail 'new SHA changed source cache key' +[[ "$(value target-cache-key)" != "$target_key_a" ]] || fail 'new SHA reused target cache key' +[[ ! -e "$(value cargo-target-dir)/marker" ]] || fail 'new runner inherited target contents' +prepare "$WORK_DIR/sha256" true 3333333333333333333333333333333333333333333333333333333333333333 +prepare "$WORK_DIR/prefix-collision" true 1111111111111111111111111111111111111111333333333333333333333333 +[[ "$(value target-cache-key)" != "$target_key_a"* ]] || fail '40-character revision can prefix-match a 64-character revision' + +prepare "$WORK_DIR/uncached" false '' +uncached_target=$(value cargo-target-dir) +assert_line "$WORK_DIR/env" "CARGO_TARGET_DIR=$uncached_target" +[[ "$uncached_target" == "$(realpath "$WORK_DIR/uncached")/edgezero-rust-cache/example-app/target" ]] || + fail 'disabled target cache uses a random directory' +reject 'unidentified repeated target reuse' "$WORK_DIR/uncached" false '' +prepare "$WORK_DIR/uncached-revision" false "$revision_a" +reject 'mixed revision with target caching disabled' "$WORK_DIR/uncached-revision" false "$revision_b" + +# Changing dependencies can reuse sources via the stable prefix, never targets. +prepare "$WORK_DIR/lock-before" true "$revision_a" +prefix_a=$(value source-cache-restore-prefix) +printf '# another dependency\n' >>"$WORK_DIR/workspace/app/Cargo.lock" +prepare "$WORK_DIR/lock-after" true "$revision_a" +[[ "$(value source-cache-key)" != "$sources_a" ]] || fail 'lockfile change did not change source key' +[[ "$(value source-cache-restore-prefix)" == "$prefix_a" ]] || fail 'lockfile change changed source restore prefix' +[[ "$(value target-cache-key)" != "$target_key_a" ]] || fail 'lockfile change did not change target key' + +for component in root app target selection; do + runner="$WORK_DIR/symlink-$component" + outside="$WORK_DIR/outside-$component" + mkdir -p "$runner" "$outside" + case "$component" in + root) ln -s "$outside" "$runner/edgezero-rust-cache" ;; + app) + mkdir -p "$runner/edgezero-rust-cache" + ln -s "$outside" "$runner/edgezero-rust-cache/example-app" ;; + target | selection) + mkdir -p "$runner/edgezero-rust-cache/example-app" + ln -s "$outside" "$runner/edgezero-rust-cache/example-app/$component" ;; + esac + reject "symlinked $component" "$runner" true "$revision_a" + [[ -z "$(ls -A "$outside")" ]] || fail "wrote outside target through $component symlink" +done +mkdir -p "$WORK_DIR/unowned/edgezero-rust-cache/example-app/target" +printf 'unowned\n' >"$WORK_DIR/unowned/edgezero-rust-cache/example-app/target/sentinel" +reject 'unowned target contents' "$WORK_DIR/unowned" true "$revision_a" +assert_line "$WORK_DIR/unowned/edgezero-rust-cache/example-app/target/sentinel" unowned + +newline_target="$WORK_DIR/newline/edgezero-rust-cache/example-app/target" +mkdir -p "$newline_target" +printf 'unowned\n' >"$newline_target/"$'\n' +reject 'newline-only filename in unowned target' "$WORK_DIR/newline" true "$revision_a" +assert_line "$newline_target/"$'\n' unowned + +unreadable_target="$WORK_DIR/unreadable/edgezero-rust-cache/example-app/target" +mkdir -p "$unreadable_target" +printf 'unowned\n' >"$unreadable_target/sentinel" +chmod 300 "$unreadable_target" +# Root and some non-POSIX filesystems bypass directory read permissions. +if ls -A "$unreadable_target" >/dev/null 2>&1; then + chmod 700 "$unreadable_target" + printf 'unreadable directory regression skipped: permissions are not enforced\n' +else + if prepare "$WORK_DIR/unreadable" true "$revision_a"; then + chmod 700 "$unreadable_target" + fail 'accepted unreadable target contents' + fi + chmod 700 "$unreadable_target" + [[ ! -s "$WORK_DIR/output" && ! -s "$WORK_DIR/env" ]] || fail 'published outputs for unreadable target' + assert_line "$unreadable_target/sentinel" unowned +fi + +command -v yq >/dev/null || fail 'mikefarah yq v4 is required' +action="$ACTION_DIR/action.yml" +[[ "$(yq -r '.inputs."target-cache-revision".description' "$action")" != null ]] || fail 'missing revision input' +assert_metadata() { + local query="$1" expected="$2" + [[ "$(yq -r "$query" "$action")" == "$expected" ]] || fail "incorrect metadata at $query" +} +# shellcheck disable=SC2016 # Literal GitHub expressions are the tested contract. +assert_metadata '.runs.steps[] | select(.id == "cargo-sources") | .with.key' '${{ steps.prepare.outputs['\''source-cache-key'\''] }}' +# shellcheck disable=SC2016 +assert_metadata '.runs.steps[] | select(.id == "cargo-target") | .with.key' '${{ steps.prepare.outputs['\''target-cache-key'\''] }}' +assert_metadata '.runs.steps[] | select(.id == "cargo-target") | .with."restore-keys"' null +for output in source-cache-hit target-cache-hit source-cache-key target-cache-key cargo-target-dir; do + [[ "$(yq -r ".outputs.\"$output\".value" "$action")" != null ]] || fail "missing $output output" +done +printf 'cache revision 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 b4f0c89d1..c0c38023f 100755 --- a/.github/actions/setup-rust-build-cache/tests/run.sh +++ b/.github/actions/setup-rust-build-cache/tests/run.sh @@ -10,6 +10,8 @@ ACTION_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-rust-cache-test.XXXXXX") trap 'rm -rf -- "$WORK_DIR"' EXIT +bash "$ACTION_DIR/tests/revision.sh" + fail() { printf 'setup-rust-build-cache test failed: %s\n' "$*" >&2 exit 1 @@ -30,10 +32,12 @@ printf '%s\n' '# generic lockfile fixture' >"$workspace/project/Cargo.lock" GITHUB_WORKSPACE="$workspace" \ RUNNER_TEMP="$runner_temp" \ + RUNNER_OS=Linux RUNNER_ARCH=X64 \ GITHUB_OUTPUT="$WORK_DIR/output" \ GITHUB_ENV="$WORK_DIR/env" \ EDGEZERO__RUST_CACHE__APP_NAME=example-app \ EDGEZERO__RUST_CACHE__CACHE_TARGET=true \ + EDGEZERO__RUST_CACHE__TARGET_CACHE_REVISION=1111111111111111111111111111111111111111 \ EDGEZERO__RUST_CACHE__WORKING_DIRECTORY=project \ "$ACTION_DIR/scripts/prepare.sh" @@ -56,19 +60,17 @@ assert_line "$WORK_DIR/env" "EDGEZERO__RUST_CACHE__TARGET_DIR=$expected_target" : >"$WORK_DIR/env" GITHUB_WORKSPACE="$workspace" \ RUNNER_TEMP="$runner_temp" \ + RUNNER_OS=Linux RUNNER_ARCH=X64 \ GITHUB_OUTPUT="$WORK_DIR/output" \ GITHUB_ENV="$WORK_DIR/env" \ EDGEZERO__RUST_CACHE__APP_NAME=example-app \ EDGEZERO__RUST_CACHE__CACHE_TARGET=false \ + EDGEZERO__RUST_CACHE__TARGET_CACHE_REVISION=1111111111111111111111111111111111111111 \ EDGEZERO__RUST_CACHE__WORKING_DIRECTORY=project \ "$ACTION_DIR/scripts/prepare.sh" assert_line "$WORK_DIR/output" 'cache-namespace=example-app' -if grep -Fq 'cargo-target-dir=' "$WORK_DIR/output"; then - fail "published a Cargo target directory while target caching was disabled" -fi -if grep -Eq '^(CARGO_TARGET_DIR|EDGEZERO__RUST_CACHE__TARGET_DIR)=' "$WORK_DIR/env"; then - fail "enabled Cargo target caching while target caching was disabled" -fi +assert_line "$WORK_DIR/output" "cargo-target-dir=$expected_target" +assert_line "$WORK_DIR/env" "CARGO_TARGET_DIR=$expected_target" for invalid in missing ../outside /; do : >"$WORK_DIR/output" @@ -135,9 +137,9 @@ tilde='~' for path in "$tilde/.cargo/registry/index/" "$tilde/.cargo/registry/cache/" "$tilde/.cargo/git/db/"; do grep -Fq "$path" "$action" || fail "Cargo source cache omits $path" done -grep -Fq "steps.prepare.outputs['cargo-lock-sha256']" "$action" || +grep -Fq "steps.prepare.outputs['source-cache-key']" "$action" || fail "Cargo source cache key is not bound to the validated Cargo.lock" -grep -Fq "steps.prepare.outputs['cache-namespace']" "$action" || +grep -Fq "steps.prepare.outputs['target-cache-key']" "$action" || fail "cache keys are not scoped to the validated application name" grep -Fq "steps.prepare.outputs['cargo-target-dir']" "$action" || fail "Cargo target cache does not use the validated target directory" @@ -145,9 +147,6 @@ grep -Fq "inputs['cache-target'] == 'true'" "$action" || fail "Cargo target cache is not gated by the cache-target input" github_sha_expression="\${{ github.sha }}" if grep -Fq "$github_sha_expression" "$action"; then - fail "Cargo target cache creates a new full cache for every source revision" + fail "cache action must use the explicit application revision, not infer github.sha" fi -grep -Eq "^[[:space:]]+key: edgezero-app-.*-rust-target-.*cargo-lock-sha256.*\}\}[[:space:]]*$" "$action" || - fail "Cargo target cache primary key must end at the validated Cargo.lock digest" - printf 'setup-rust-build-cache action tests passed\n' diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 8b9ffeb20..7477c41a8 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -111,29 +111,36 @@ jobs: npm run lint npm run build - # Exercise actions/cache itself across jobs. The fixture key is stable and - # versioned; bump it only when this restore contract changes. + # Exercise source reuse and revision-isolated target caching across real jobs. + # The fixture revision includes this run ID so the new-revision probe is cold. cache-restore-seed: runs-on: ubuntu-latest + outputs: + source-cache-key: ${{ steps.cache.outputs.source-cache-key }} + target-cache-key: ${{ steps.cache.outputs.target-cache-key }} steps: - uses: actions/checkout@v7 with: persist-credentials: false - name: Create cache restore fixture - run: | - mkdir -p cache-restore-fixture - printf '%s\n' '# edgezero target cache restore smoke v1' >cache-restore-fixture/Cargo.lock + id: fixture + run: bash .github/actions/setup-rust-build-cache/tests/make-fixture.sh seed - name: Seed the provider-neutral Rust target cache + id: cache uses: ./.github/actions/setup-rust-build-cache with: - app-name: cache-restore-smoke-v1 + app-name: cache-restore-smoke-v2 cache-target: true + target-cache-revision: ${{ steps.fixture.outputs.revision }} working-directory: cache-restore-fixture - name: Write restore marker - run: printf '%s\n' 'edgezero-cache-restore-smoke-v1' >"$CARGO_TARGET_DIR/restore-marker" + run: | + printf '%s\n' 'edgezero-cache-restore-smoke-v2' >"$CARGO_TARGET_DIR/restore-marker" + mkdir -p "$HOME/.cargo/git/db" + printf '%s\n' 'edgezero-source-cache-smoke-v2' >"$HOME/.cargo/git/db/edgezero-source-cache-smoke-v2" cache-restore-smoke: needs: cache-restore-seed @@ -144,19 +151,64 @@ jobs: persist-credentials: false - name: Recreate cache restore fixture - run: | - mkdir -p cache-restore-fixture - printf '%s\n' '# edgezero target cache restore smoke v1' >cache-restore-fixture/Cargo.lock + id: fixture + run: bash .github/actions/setup-rust-build-cache/tests/make-fixture.sh seed - name: Restore the provider-neutral Rust target cache + id: cache uses: ./.github/actions/setup-rust-build-cache with: - app-name: cache-restore-smoke-v1 + app-name: cache-restore-smoke-v2 cache-target: true + target-cache-revision: ${{ steps.fixture.outputs.revision }} working-directory: cache-restore-fixture - name: Assert target cache restored across jobs - run: grep -Fxq 'edgezero-cache-restore-smoke-v1' "$CARGO_TARGET_DIR/restore-marker" + env: + TARGET_CACHE_HIT: ${{ steps.cache.outputs.target-cache-hit }} + run: | + test "$TARGET_CACHE_HIT" = true + grep -Fxq 'edgezero-cache-restore-smoke-v2' "$CARGO_TARGET_DIR/restore-marker" + + cache-new-revision-smoke: + needs: cache-restore-seed + runs-on: ubuntu-latest + env: + RUSTUP_TOOLCHAIN: 1.95.0 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Create a different fixture source revision + id: fixture + run: bash .github/actions/setup-rust-build-cache/tests/make-fixture.sh next + - name: Set up revision-isolated target cache + id: cache + uses: ./.github/actions/setup-rust-build-cache + with: + app-name: cache-restore-smoke-v2 + cache-target: true + target-cache-revision: ${{ steps.fixture.outputs.revision }} + working-directory: cache-restore-fixture + - name: Assert sources are reused but target artifacts are isolated + env: + SOURCE_CACHE_HIT: ${{ steps.cache.outputs.source-cache-hit }} + TARGET_CACHE_HIT: ${{ steps.cache.outputs.target-cache-hit }} + SOURCE_CACHE_KEY: ${{ steps.cache.outputs.source-cache-key }} + TARGET_CACHE_KEY: ${{ steps.cache.outputs.target-cache-key }} + SEED_SOURCE_KEY: ${{ needs.cache-restore-seed.outputs.source-cache-key }} + SEED_TARGET_KEY: ${{ needs.cache-restore-seed.outputs.target-cache-key }} + run: | + test "$SOURCE_CACHE_HIT" = true + test "$TARGET_CACHE_HIT" = false + test "$SOURCE_CACHE_KEY" = "$SEED_SOURCE_KEY" + test "$TARGET_CACHE_KEY" != "$SEED_TARGET_KEY" + test ! -e "$CARGO_TARGET_DIR/restore-marker" + grep -Fxq 'edgezero-source-cache-smoke-v2' "$HOME/.cargo/git/db/edgezero-source-cache-smoke-v2" + - name: Install native and WASM compiler targets + run: rustup toolchain install 1.95.0 --profile minimal --target wasm32-wasip1 + - name: Assert native and WASM compiler cache reuse + run: bash .github/actions/setup-rust-build-cache/tests/assert-compiler-reuse.sh # Build the store-aware application once. Its production/staging matrix and # lifecycle actions consume these exact CLI, package, and manifest bytes. @@ -191,6 +243,7 @@ jobs: with: app-name: store-aware-fixture cache-target: true + target-cache-revision: ${{ steps.fixture.outputs.revision }} working-directory: fixture-app - name: Build the application-owned CLI once @@ -250,6 +303,7 @@ jobs: with: app-name: store-free-fixture cache-target: true + target-cache-revision: ${{ steps.fixture.outputs.revision }} working-directory: fixture-app - name: Build the store-free application CLI once diff --git a/docs/guide/deploy-action-adoption.md b/docs/guide/deploy-action-adoption.md index 3170e1b05..fa08db3c3 100644 --- a/docs/guide/deploy-action-adoption.md +++ b/docs/guide/deploy-action-adoption.md @@ -33,26 +33,58 @@ and before its Rust build steps: ```yaml - name: Set up Rust build cache + id: rust-cache uses: stackpop/edgezero/.github/actions/setup-rust-build-cache@ with: app-name: example-app cache-target: true + target-cache-revision: ${{ steps.source.outputs.sha }} working-directory: application ``` -`app-name` is a stable lowercase cache namespace for one application. +`app-name` is a stable lowercase cache namespace for one application. Do not +append its source SHA: that would prevent dependency-source reuse across commits. +Resolve `steps.source.outputs.sha` from the checked-out **application** source, +for example with `git -C application rev-parse HEAD`. Do not use the deployment +repository's `github.sha` when the application lives in a separate checkout. +`target-cache-revision` must be a full lowercase Git SHA and is required when +`cache-target: true`; missing or invalid values fail without a compatibility +fallback. An explicitly supplied revision is validated in either mode. + `working-directory` identifies its Cargo workspace containing the committed `Cargo.lock`. The action restores application-scoped Cargo registry and Git dependency sources, installs a pinned `sccache`, and enables compiler caching -for every later native or WASM Rust build in the job. With `cache-target: true`, -it also restores an application-scoped Cargo target directory containing Cargo -fingerprints, build-script outputs, generated code, libraries, and linking -inputs. Cargo keeps target triples, profiles, features, and compiler fingerprints -separate inside that directory. The target cache key is scoped by application, -runner OS and architecture, and the committed `Cargo.lock`. Cargo fingerprints -and `sccache` determine which source, toolchain, feature, and build-script outputs -can be reused safely. Existing build, package, release, adapter, and deployment -commands remain unchanged. +for every later native or WASM Rust build in the job. + +- Dependency source keys include app, runner OS/architecture, and `Cargo.lock`, + but not source revision. A stable restore prefix also permits reuse when the + lockfile changes; Cargo fetches missing dependencies. +- With `cache-target: true`, target artifacts are restored/saved using a new v2 + key containing the complete source revision and lock digest. There is no + cross-revision or legacy-key fallback. The first build of a new revision has + a cold target cache; repeating that revision can restore its target artifacts. +- Compiler results use sccache's compiler/input/environment checks. Unchanged + compilations can be reused across revisions, but linking and some crate types + are not cacheable. A hit ratio or deployment duration is not guaranteed. + +In both modes, the action sets one stable app-specific `CARGO_TARGET_DIR` beneath +`RUNNER_TEMP`, without a SHA or random invocation directory in the path. Native +CLI and WASM builds share it; Cargo keeps target triples/profiles separate. +`cache-target: false` disables only remote target restore/save, not that stable +directory, dependency caching, or sccache. Call setup once per application/job. +Reusing the directory for another source revision or workspace fails; repeat +setup without an explicit revision also fails. Symlinks and unowned target +contents are rejected rather than deleted. + +Outputs `source-cache-hit` and `target-cache-hit` indicate exact primary-key +matches. A false source hit can still mean prefix reuse after a lockfile change; +target hits are false when disabled or missing. `source-cache-key`, +`target-cache-key`, and `cargo-target-dir` expose the selected identities/path, +and sccache logs compiler statistics at job completion. Branch-scoped GitHub +cache access still applies; merging a feature branch may require a cold build. +When migrating, use a stable app-name and add the application SHA input together; +the new target-key namespace starts cold. Existing CLI build invocation, package, +release verification, credential isolation, and deployment commands are unchanged. Build and download the configurable application CLI artifact before assembling the release: diff --git a/docs/superpowers/plans/2026-10-12-edgezero-deploy-cache-revision-isolation.md b/docs/superpowers/plans/2026-10-12-edgezero-deploy-cache-revision-isolation.md new file mode 100644 index 000000000..37e693e38 --- /dev/null +++ b/docs/superpowers/plans/2026-10-12-edgezero-deploy-cache-revision-isolation.md @@ -0,0 +1,80 @@ +# Revision-isolated Rust Build Cache Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans to implement this plan +> inline, with independent document and code reviews. Steps use checkboxes. + +**Goal:** Share Rust dependency/compiler caches across commits while isolating +writable target artifacts by application source revision. + +**Architecture:** Keep source keys stable and introduce a revision-specific v2 +target key. Always publish an app-specific job-local target directory. Bind its +local reuse to a validated selection marker and preserve build/deploy boundaries. + +**Tech Stack:** Bash, composite GitHub Actions, actions/cache v5, sccache v0.16.0, +mikefarah yq v4, and existing Cargo workspace verification. + +## Task 1: Contract tests (red) + +**File:** `.github/actions/setup-rust-build-cache/tests/run.sh` + +- [ ] Add a reusable prepare invocation helper and isolated runner fixtures. +- [ ] Test required full lowercase revision with target caching enabled; invalid + inputs leave GITHUB_OUTPUT/GITHUB_ENV empty. +- [ ] Test stable target paths in both modes, same-revision repeat setup, + mixed-revision/workspace rejection, and unknown existing target contents. +- [ ] Test root/app/target/marker symlinks and confinement before directory writes. +- [ ] Parse action YAML with yq: source key contains no revision; target key has + a version and full revision, with no restore-keys; expose hit/key/path outputs. +- [ ] Run `.github/actions/setup-rust-build-cache/tests/run.sh`; confirm failures + are the missing revision contract and stable-directory behavior. + +## Task 2: Minimal cache implementation (green) + +**Files:** `.github/actions/setup-rust-build-cache/action.yml`, +`.github/actions/setup-rust-build-cache/scripts/prepare.sh`, +`.github/actions/build-app-cli/action.yml` + +- [ ] Add optional metadata input `target-cache-revision`, require it in Bash + only for cache-target=true, and validate with `^[0-9a-f]{40}$|^[0-9a-f]{64}$`. +- [ ] Validate all paths and selection state before emitting outputs/environment. +- [ ] Create confined nonsymlink cache/app/target directories and write an + outside-target marker binding workspace and revision; reject unsafe reuse. +- [ ] Always emit CARGO_TARGET_DIR and EDGEZERO__RUST_CACHE__TARGET_DIR. +- [ ] Emit source/target key prefixes; target ends with the full source revision + and `-exact`, preventing 40/64-character SHA prefix collisions. Test that + collision explicitly. Use the new v2 target key and omit target restore-keys. +- [ ] Expose exact-hit outputs, cache keys, and directory. Keep sccache pins. +- [ ] Correct build-app-cli's cache-directory comment; do not change its build + invocation, credential scrubbing, or packaging implementation. +- [ ] Run cache contracts and scoped Cargo tests; require zero failures. + +## Task 3: Consumers, CI smoke tests, and documentation + +**Files:** `.github/workflows/deploy-action.yml`, +`.github/actions/deploy-core/tests/run.sh`, +`docs/guide/deploy-action-adoption.md` + +- [ ] Pass source SHA to every in-repository cache-target=true caller. Fixture + release jobs use their own fixture revision, not github.sha. +- [ ] Update the existing cross-job target restore probe to the v2 contract and + add a new-revision probe: no marker/target hit, source cache remains reusable. +- [ ] Use a tiny native/WASM library fixture to demonstrate compiler-cache reuse; + log sccache statistics and fail if unchanged-library hits are absent. +- [ ] Extend structural workflow contracts for all revision inputs and probes. +- [ ] Document the new input, stable directory, cache layers, exact-hit semantics, + migration, first-run cold cache, and honest performance limitations. +- [ ] Run all Bash action contracts and actionlint/ShellCheck/pin/security checks. + +## Task 4: Verification and delivery + +- [ ] Run `cargo test --workspace --all-targets`. +- [ ] Run `cargo fmt --all -- --check`. +- [ ] Run `cargo clippy --workspace --all-targets --all-features -- -D warnings`. +- [ ] Run `cargo check --workspace --all-targets --features 'fastly cloudflare spin'`. +- [ ] Run `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`. +- [ ] Run docs format/lint/build and `git diff --check`. +- [ ] Obtain independent code review and fix substantive findings. +- [ ] Create one linked Task issue with acceptance criteria, commit and push the + branch, and open a PR against main using the repository template. +- [ ] Report actual verification results and leave the worktree available for + follow-up; do not merge or release. diff --git a/docs/superpowers/specs/2026-10-12-edgezero-deploy-cache-revision-isolation-design.md b/docs/superpowers/specs/2026-10-12-edgezero-deploy-cache-revision-isolation-design.md new file mode 100644 index 000000000..daec32fdb --- /dev/null +++ b/docs/superpowers/specs/2026-10-12-edgezero-deploy-cache-revision-isolation-design.md @@ -0,0 +1,72 @@ +# Revision-isolated Rust build caches + +## Goal + +Reuse dependency sources and valid compiler results across application commits, +without restoring writable Cargo target artifacts from another source revision. +This implements the cache requirements approved in the conversation. Deployment +behavior, credential isolation, and release verification are unchanged. + +## Action contract + +`setup-rust-build-cache` keeps a stable `app-name` and adds +`target-cache-revision`. With `cache-target: true`, the revision is required and +must be a full lowercase Git SHA (40 or 64 hexadecimal characters). Callers must +resolve it from the application source, not the deployment repository. An +explicit revision is validated even with target caching disabled. Missing or +invalid required revisions fail before publishing outputs or changing runner +environment files. There is no legacy target-key fallback. + +The action always publishes a stable app-specific `CARGO_TARGET_DIR` under +`RUNNER_TEMP`, including when `cache-target: false`. The latter controls remote +target restore/save only; source caching and sccache remain enabled. The SHA is +in the cache key, never the directory path. Native CLI and subsequent WASM +builds share this directory. Existing CLI native-target selection and Cargo +invocation remain mandatory. + +## Cache layers + +- Dependency source keys contain app, OS, architecture, and Cargo.lock digest. + Their restore prefix omits the lock digest and source revision. +- A versioned target key contains app, OS, architecture, lock digest, and source + revision followed by `-exact`. It has no broad or legacy restore prefix. The + terminator prevents a 40-character SHA from prefix-matching a 64-character SHA. +- sccache manages compiler/input/environment-dependent results. Keeping build + paths stable removes one avoidable cache-key change; not all crate types or + compilations are cacheable and no particular speedup is promised. + +Expose source and target `cache-hit` outputs (exact-match booleans), their keys, +and the target directory. Distinguish an exact source hit from prefix reuse in +documentation. Compiler statistics remain visible through sccache's job logs. + +## Local isolation + +Before creating any cache directory, reject symlinked cache-root, app, target, +or selection-marker components. Check canonical confinement below RUNNER_TEMP. +Reject pre-existing nonempty target directories without a selection marker. +Keep the selection marker outside the cached target directory. It binds the +directory to the canonical application workspace and selected source revision. +Reject subsequent setup for a different workspace/revision. Without an explicit +revision, setup is allowed only once for that app; callers needing another +setup must supply the revision. No cleanup of unrelated or unowned files occurs. + +## Scope and migration + +Update all in-repository action callers and the adoption guide. New releases +require external consumers to use a stable app-name and explicitly pass their +resolved source SHA when enabling target caching. Existing SHA-suffixed app names +must be replaced by the stable app name. First use of the new target namespace +is cold. Do not update trusted-server-deployer or publish a release in this PR. + +## Verification + +Local shell contracts cover valid/invalid revisions, outputs on failure, stable +paths with caching enabled/disabled, mixed-revision rejection, unknown target +contents, symlinks, traversal, and structured cache-key isolation. Existing CLI +contracts prove Cargo builds the host target instead of packaging a stale cached +root-level executable. GitHub smoke jobs restore an exact-revision marker and +prove another revision cannot restore it while dependency sources can be reused. +Log compiler cache statistics and require repeatable unchanged-library compiler +hits using a credential-free native/WASM fixture. Measure real-world CLI/WASM +timings after adoption separately; local tests cannot exercise GitHub's cache +service. From ff18102510d4359dedb5422d61a1c0663709d5e2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:27:55 -0700 Subject: [PATCH 2/4] fix: control target cache writes and prove cross-job compiler reuse --- .../build-app-cli/scripts/build-app-cli.sh | 7 +- .github/actions/deploy-core/tests/run.sh | 9 +- .../deploy-fastly/tests/make-smoke-fixture.sh | 5 +- .../actions/setup-rust-build-cache/action.yml | 23 +++- .../setup-rust-build-cache/scripts/prepare.sh | 48 ++++++--- .../tests/assert-compiler-reuse.sh | 14 +-- .../setup-rust-build-cache/tests/common.sh | 4 + .../setup-rust-build-cache/tests/fixture.sh | 54 ++++++++++ .../tests/make-fixture.sh | 6 +- .../setup-rust-build-cache/tests/review.sh | 68 ++++++++++++ .../setup-rust-build-cache/tests/revision.sh | 12 ++- .../setup-rust-build-cache/tests/run.sh | 25 ++--- .../tests/setup-toolchain.sh | 8 ++ .github/workflows/deploy-action.yml | 30 +++++- docs/guide/deploy-action-adoption.md | 101 ++++++++++++++---- ...dgezero-deploy-cache-review-remediation.md | 80 ++++++++++++++ ...-deploy-cache-revision-isolation-design.md | 51 ++++++++- 17 files changed, 461 insertions(+), 84 deletions(-) mode change 100644 => 100755 .github/actions/setup-rust-build-cache/tests/assert-compiler-reuse.sh create mode 100644 .github/actions/setup-rust-build-cache/tests/common.sh create mode 100755 .github/actions/setup-rust-build-cache/tests/fixture.sh mode change 100644 => 100755 .github/actions/setup-rust-build-cache/tests/make-fixture.sh create mode 100755 .github/actions/setup-rust-build-cache/tests/review.sh mode change 100644 => 100755 .github/actions/setup-rust-build-cache/tests/revision.sh create mode 100755 .github/actions/setup-rust-build-cache/tests/setup-toolchain.sh create mode 100644 docs/superpowers/plans/2026-10-13-edgezero-deploy-cache-review-remediation.md diff --git a/.github/actions/build-app-cli/scripts/build-app-cli.sh b/.github/actions/build-app-cli/scripts/build-app-cli.sh index 6a40508d6..84d3c1c4a 100755 --- a/.github/actions/build-app-cli/scripts/build-app-cli.sh +++ b/.github/actions/build-app-cli/scripts/build-app-cli.sh @@ -2,8 +2,9 @@ set -euo pipefail # Compiles the CLI package the APPLICATION provides — a crate in the app's own -# workspace — into an application-scoped cached CARGO_TARGET_DIR when configured, -# or an action-owned scratch directory otherwise. It then packages the binary +# workspace — into the shared job-local target provided by cache setup (even +# without remote target caching), or fresh action-owned scratch when setup is +# absent. It then packages the binary # plus a self-describing app-cli-meta.json into a tar so the executable bit # survives actions/upload-artifact. Never builds the EdgeZero monorepo CLI. # @@ -18,7 +19,7 @@ set -euo pipefail # RUNNER_TEMP optional action-owned scratch root (default: /tmp) # EDGEZERO__PROVIDER__ENV_CLEAR optional JSON array of provider aliases to scrub before the build (default: []) # EDGEZERO__ACTION__WORKSPACE optional per-invocation scratch root (default: mktemp under RUNNER_TEMP) -# EDGEZERO__RUST_CACHE__TARGET_DIR optional shared Cargo target directory beneath RUNNER_TEMP +# EDGEZERO__RUST_CACHE__TARGET_DIR optional shared job-local target beneath RUNNER_TEMP, independent of remote cache mode # EDGEZERO__ACTION__OUTPUT_FILE optional outputs handoff file, started fresh (default: GITHUB_OUTPUT) # Writes (outputs): # app-cli-package the package that was built diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh index 6bbf74c93..f0a0e7925 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -1992,11 +1992,11 @@ test_action_output_contracts() { script=$(awk -v want="$step_id" '$1 == want { $1 = ""; sub(/^ /, ""); print; exit }' <<<"$scripts") if [[ -z "$script" ]]; then # actions/cache declares cache-hit itself, not through a local script. - # Accept only this documented output from this concrete action ref. + # Ref pinning is enforced separately; accept only this documented output. if [[ "$out_name" == cache-hit ]] && command -v yq >/dev/null 2>&1; then local output_action output_action=$(STEP_ID="$step_id" yq -r '.runs.steps[] | select(.id == strenv(STEP_ID)) | .uses' "$action") - [[ "$output_action" != actions/cache@v5 ]] || continue + case "${output_action%@*}" in actions/cache | actions/cache/restore) continue ;; esac fi fail "$name_of output '$out_name' names step '$step_id', which runs no script" missing=$((missing + 1)) @@ -2010,11 +2010,6 @@ test_action_output_contracts() { # The named step's own script must emit it, or delegate to a shared core # script that emits it as part of the same invocation. emitted=$(grep -oE "append_output ${out_name}( |\$)" "$script" || true) - if [[ -z "$emitted" && "$name_of" == setup-rust-build-cache && "$step_id" == prepare ]]; then - # prepare.sh uses printf directly; its emissions are also checked by - # the behavioral cache contracts, including failure-before-output. - emitted=$(grep -F "printf '${out_name}=" "$script" || true) - fi if [[ -z "$emitted" ]] && grep -q 'run-app-cli\.sh' "$script"; then emitted=$(grep -oE "append_output ${out_name}( |\$)" "$CORE_SCRIPTS/run-app-cli.sh" || true) fi diff --git a/.github/actions/deploy-fastly/tests/make-smoke-fixture.sh b/.github/actions/deploy-fastly/tests/make-smoke-fixture.sh index 29826a18a..fe2eabd1a 100755 --- a/.github/actions/deploy-fastly/tests/make-smoke-fixture.sh +++ b/.github/actions/deploy-fastly/tests/make-smoke-fixture.sh @@ -155,8 +155,11 @@ source = "fixture.wasm" TOML cargo generate-lockfile + # Path dependencies live in the enclosing EdgeZero checkout, not this Git repo. + git -C "$workspace" rev-parse HEAD >.edgezero-source-revision git add -A - git commit -q -m fixture + GIT_AUTHOR_DATE=2000-01-01T00:00:00Z GIT_COMMITTER_DATE=2000-01-01T00:00:00Z \ + git -c core.hooksPath=/dev/null -c commit.gpgsign=false commit -q -m fixture } main() { diff --git a/.github/actions/setup-rust-build-cache/action.yml b/.github/actions/setup-rust-build-cache/action.yml index eba286e9f..7d3c15f41 100644 --- a/.github/actions/setup-rust-build-cache/action.yml +++ b/.github/actions/setup-rust-build-cache/action.yml @@ -10,7 +10,11 @@ inputs: required: false default: . cache-target: - description: Restore and save the application's Cargo target directory in addition to compiler outputs. + description: Restore the application's revision-specific Cargo target directory in addition to compiler outputs. + required: false + default: "false" + save-target: + description: Opt in to saving a missed target cache after a successful job; requires cache-target true. Prefer writes from trusted main workflows. required: false default: "false" target-cache-revision: @@ -23,7 +27,7 @@ outputs: value: ${{ steps.cargo-sources.outputs.cache-hit == 'true' }} target-cache-hit: description: Whether target artifacts matched the revision-specific key; false when target caching is disabled or misses. - value: ${{ steps.cargo-target.outputs.cache-hit == 'true' }} + value: ${{ steps.cargo-target.outputs.cache-hit == 'true' || steps.cargo-target-save.outputs.cache-hit == 'true' }} source-cache-key: description: Dependency source cache primary key. value: ${{ steps.prepare.outputs['source-cache-key'] }} @@ -43,6 +47,7 @@ runs: env: EDGEZERO__RUST_CACHE__APP_NAME: ${{ inputs['app-name'] }} EDGEZERO__RUST_CACHE__CACHE_TARGET: ${{ inputs['cache-target'] }} + EDGEZERO__RUST_CACHE__SAVE_TARGET: ${{ inputs['save-target'] }} EDGEZERO__RUST_CACHE__TARGET_CACHE_REVISION: ${{ inputs['target-cache-revision'] }} EDGEZERO__RUST_CACHE__WORKING_DIRECTORY: ${{ inputs['working-directory'] }} BASH_ENV: "" @@ -51,6 +56,7 @@ runs: - name: Restore Cargo dependency sources id: cargo-sources + if: steps.prepare.outputs.already-initialized != 'true' uses: actions/cache@v5 with: path: | @@ -62,13 +68,24 @@ runs: - name: Restore Cargo target artifacts id: cargo-target - if: inputs['cache-target'] == 'true' + if: inputs['cache-target'] == 'true' && inputs['save-target'] == 'false' && steps.prepare.outputs.already-initialized != 'true' + uses: actions/cache/restore@v5 + with: + path: ${{ steps.prepare.outputs['cargo-target-dir'] }} + key: ${{ steps.prepare.outputs['target-cache-key'] }} + + # The combined action saves in its success-only post-job hook, after builds. + - name: Restore Cargo targets and enable post-job save + id: cargo-target-save + if: inputs['cache-target'] == 'true' && inputs['save-target'] == 'true' && steps.prepare.outputs.already-initialized != 'true' uses: actions/cache@v5 with: path: ${{ steps.prepare.outputs['cargo-target-dir'] }} key: ${{ steps.prepare.outputs['target-cache-key'] }} - name: Set up Rust compiler cache + id: sccache + if: steps.prepare.outputs.already-initialized != 'true' uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 with: version: v0.16.0 diff --git a/.github/actions/setup-rust-build-cache/scripts/prepare.sh b/.github/actions/setup-rust-build-cache/scripts/prepare.sh index 6eb93d886..02b55da86 100755 --- a/.github/actions/setup-rust-build-cache/scripts/prepare.sh +++ b/.github/actions/setup-rust-build-cache/scripts/prepare.sh @@ -18,7 +18,18 @@ fail() { canonical_path() { local path="$1" - realpath "$path" 2>/dev/null || fail "could not resolve path '$path'" + [[ ! "$path" =~ [[:cntrl:]] ]] || fail 'Rust cache paths must not contain control characters' + local resolved + # Preserve trailing newlines until validation, including in symlink targets. + resolved=$(realpath "$path" 2>/dev/null && printf '.') || fail "could not resolve path '$path'" + resolved=${resolved%.} + resolved=${resolved%$'\n'} + [[ ! "$resolved" =~ [[:cntrl:]] ]] || fail 'Resolved Rust cache paths must not contain control characters' + printf '%s' "$resolved" +} + +append_output() { + printf '%s=%s\n' "$1" "$2" >>"${GITHUB_OUTPUT:?}" } is_under() { @@ -40,10 +51,11 @@ sha256_file() { main() { local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}" - local output_file="${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + : "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" local env_file="${GITHUB_ENV:?GITHUB_ENV is required}" local app_name="${EDGEZERO__RUST_CACHE__APP_NAME-}" local cache_target="${EDGEZERO__RUST_CACHE__CACHE_TARGET-false}" + local save_target="${EDGEZERO__RUST_CACHE__SAVE_TARGET-false}" local revision="${EDGEZERO__RUST_CACHE__TARGET_CACHE_REVISION-}" local working_directory="${EDGEZERO__RUST_CACHE__WORKING_DIRECTORY-}" @@ -53,6 +65,12 @@ main() { true | false) ;; *) fail "input 'cache-target' must be 'true' or 'false'" ;; esac + case "$save_target" in + true | false) ;; + *) fail "input 'save-target' must be 'true' or 'false'" ;; + esac + [[ "$save_target" != true || "$cache_target" == true ]] || + fail "input 'save-target' requires cache-target true" if [[ "$cache_target" == true || -n "$revision" ]]; then [[ "$revision" =~ ^[0-9a-f]{40}$ || "$revision" =~ ^[0-9a-f]{64}$ ]] || fail "input 'target-cache-revision' must be a full lowercase Git SHA (required when cache-target is true)" @@ -97,12 +115,17 @@ main() { done [[ ! -e "$selection_file" || -f "$selection_file" ]] || fail "Rust cache selection is not a regular file" - local selection - selection=$(printf '%s\n%s\n' "$project_real" "${revision:-uncached}") + local ownership_revision="$revision" selection already_initialized=false + if [[ -z "$ownership_revision" ]]; then + ownership_revision=$(git -C "$project_real" rev-parse --verify HEAD 2>/dev/null || true) + [[ "$ownership_revision" =~ ^[0-9a-f]{40}$ || "$ownership_revision" =~ ^[0-9a-f]{64}$ ]] || ownership_revision='' + fi + selection=$(printf '%s\n' "$project_real" "${ownership_revision:-uncached}" "$lock_sha" "$cache_target" "$save_target") if [[ -e "$selection_file" ]]; then - [[ -n "$revision" ]] || fail "repeated cache setup requires target-cache-revision" + [[ -n "$ownership_revision" ]] || fail "repeated non-Git cache setup requires target-cache-revision" [[ "$(<"$selection_file")" == "$selection" ]] || - fail "Rust target directory already belongs to a different workspace or target-cache-revision" + fail "Rust target directory already belongs to a different workspace, revision, lockfile or cache settings" + already_initialized=true elif [[ -d "$target_dir" ]]; then local target_contents # Quote control characters so newline-only names survive command substitution. @@ -125,14 +148,11 @@ main() { fail "Cargo target cache directory must resolve inside RUNNER_TEMP" printf '%s\n' "$selection" >"$selection_file" - { - printf 'cache-namespace=%s\n' "$app_name" - printf 'cargo-lock-sha256=%s\n' "$lock_sha" - printf 'source-cache-key=%s\n' "$source_key" - printf 'source-cache-restore-prefix=%s\n' "$source_prefix" - printf 'target-cache-key=%s\n' "$target_key" - printf 'cargo-target-dir=%s\n' "$target_dir" - } >>"$output_file" + append_output already-initialized "$already_initialized" + append_output source-cache-key "$source_key" + append_output source-cache-restore-prefix "$source_prefix" + append_output target-cache-key "$target_key" + append_output cargo-target-dir "$target_dir" { printf 'RUSTC_WRAPPER=sccache\n' printf 'SCCACHE_GHA_ENABLED=true\n' diff --git a/.github/actions/setup-rust-build-cache/tests/assert-compiler-reuse.sh b/.github/actions/setup-rust-build-cache/tests/assert-compiler-reuse.sh old mode 100644 new mode 100755 index 8f172ed5a..abd4dba3c --- a/.github/actions/setup-rust-build-cache/tests/assert-compiler-reuse.sh +++ b/.github/actions/setup-rust-build-cache/tests/assert-compiler-reuse.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -# Force real native/WASM recompilation at a stable path, rather than accepting -# Cargo freshness as compiler-cache evidence. Clean only this fixture package. +# Require native/WASM hits on the first builds of a fresh revision-specific +# target directory. Only a previous job may have populated compiler outputs. fixture="${GITHUB_WORKSPACE:?}/cache-restore-fixture" target_dir="${CARGO_TARGET_DIR:?}" expected="$(realpath "${RUNNER_TEMP:?}")/edgezero-rust-cache/cache-restore-smoke-v2/target" @@ -14,14 +14,16 @@ cd "$fixture" host=$(rustc -vV | sed -n 's/^host: //p') [[ -n "$host" ]] || exit 1 for target in "$host" wasm32-wasip1; do - cargo build --locked --lib --target "$target" - cargo clean --package edgezero-cache-probe --target "$target" + [[ ! -e "$target_dir/$target/debug" ]] || { + printf '::error::compiler probe target must be fresh before its first build\n' >&2 + exit 1 + } sccache --zero-stats cargo build --locked --lib --target "$target" stats=$(sccache --show-stats --stats-format json) printf 'Compiler reuse for %s: %s\n' "$target" "$stats" - jq -e '(.stats.cache_hits.counts.Rust // 0) > 0' <<<"$stats" >/dev/null || { - printf '::error::no Rust compiler-cache hit after forced recompilation for %s\n' "$target" >&2 + jq -e '(.stats.cache_hits.counts.Rust // 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 || { + printf '::error::expected error-free Rust compiler-cache reuse on the first build for %s\n' "$target" >&2 exit 1 } done diff --git a/.github/actions/setup-rust-build-cache/tests/common.sh b/.github/actions/setup-rust-build-cache/tests/common.sh new file mode 100644 index 000000000..5c39c7b1e --- /dev/null +++ b/.github/actions/setup-rust-build-cache/tests/common.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Shared assertions for local cache contracts; callers own fixtures and cleanup. +fail() { printf 'Rust cache test failed: %s\n' "$*" >&2; exit 1; } +assert_line() { grep -Fqx -- "$2" "$1" || fail "expected '$2' in $1"; } diff --git a/.github/actions/setup-rust-build-cache/tests/fixture.sh b/.github/actions/setup-rust-build-cache/tests/fixture.sh new file mode 100755 index 000000000..637d08d4b --- /dev/null +++ b/.github/actions/setup-rust-build-cache/tests/fixture.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-cache-fixture-test.XXXXXX") +trap 'rm -rf -- "$WORK_DIR"' EXIT +# shellcheck source=common.sh +source "$ACTION_DIR/tests/common.sh" +fixture() { + local directory="$1" attempt="$2" mode="$3" identity="$4" + mkdir -p "$directory" + GITHUB_WORKSPACE="$directory" GITHUB_OUTPUT="$directory/output" \ + GITHUB_RUN_ID=417 GITHUB_RUN_ATTEMPT="$attempt" CACHE_FIXTURE_ID="$identity" \ + bash "$ACTION_DIR/tests/make-fixture.sh" "$mode" +} +fixture "$WORK_DIR/seed" 1 seed 417-1 +fixture "$WORK_DIR/retry" 2 seed 417-1 +seed_sha=$(git -C "$WORK_DIR/seed/cache-restore-fixture" rev-parse HEAD) +retry_sha=$(git -C "$WORK_DIR/retry/cache-restore-fixture" rev-parse HEAD) +[[ "$seed_sha" == "$retry_sha" ]] || fail 'failed-jobs-only retry changes the seed revision' +fixture "$WORK_DIR/next" 2 next 417-1 +[[ "$seed_sha" != "$(git -C "$WORK_DIR/next/cache-restore-fixture" rev-parse HEAD)" ]] || fail 'next revision is unchanged' +cmp "$WORK_DIR/seed/cache-restore-fixture/src/lib.rs" "$WORK_DIR/next/cache-restore-fixture/src/lib.rs" || fail 'next revision changes compiler inputs' +fixture "$WORK_DIR/other" 1 seed 418-1 +if cmp -s "$WORK_DIR/seed/cache-restore-fixture/src/lib.rs" "$WORK_DIR/other/cache-restore-fixture/src/lib.rs"; then + fail 'library is not unique to its seed run' +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" +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' +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 +# shellcheck disable=SC2016 # GitHub expressions are literal metadata contracts. +expected_identity='${{ needs.cache-restore-seed.outputs.fixture-id }}' +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' +# shellcheck disable=SC2016 +expected_save='${{ github.ref == '\''refs/heads/main'\'' }}' +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."save-target"' "$workflow") + [[ "$actual" == "$expected_save" ]] || fail "$job writes full target caches outside main" +done +fixture_script="$ACTION_DIR/../deploy-fastly/tests/make-smoke-fixture.sh" +grep -Fq 'GIT_AUTHOR_DATE=2000-01-01T00:00:00Z GIT_COMMITTER_DATE=2000-01-01T00:00:00Z' "$fixture_script" || fail 'application fixture dates are not deterministic' +grep -Fq '.edgezero-source-revision' "$fixture_script" || fail 'application fixture omits its path dependency revision' +printf 'cache fixture retry and first-build contracts passed\n' diff --git a/.github/actions/setup-rust-build-cache/tests/make-fixture.sh b/.github/actions/setup-rust-build-cache/tests/make-fixture.sh old mode 100644 new mode 100755 index e98832848..0a5b477e9 --- a/.github/actions/setup-rust-build-cache/tests/make-fixture.sh +++ b/.github/actions/setup-rust-build-cache/tests/make-fixture.sh @@ -5,11 +5,14 @@ set -euo pipefail mode="${1:?fixture mode is required}" [[ "$mode" == seed || "$mode" == next ]] || exit 2 fixture="${GITHUB_WORKSPACE:?}/cache-restore-fixture" +fixture_id="${CACHE_FIXTURE_ID:-${GITHUB_RUN_ID:-0}-${GITHUB_RUN_ATTEMPT:-1}}" +[[ "$fixture_id" =~ ^[0-9]+-[0-9]+$ ]] || { printf 'invalid cache fixture ID\n' >&2; exit 2; } mkdir -p "$fixture/src" printf '[package]\nname = "edgezero-cache-probe"\nversion = "0.1.0"\nedition = "2021"\n[workspace]\n' >"$fixture/Cargo.toml" printf 'version = 3\n[[package]]\nname = "edgezero-cache-probe"\nversion = "0.1.0"\n' >"$fixture/Cargo.lock" printf 'pub fn answer() -> u32 { 42 }\n' >"$fixture/src/lib.rs" -printf 'cache fixture run %s attempt %s\n' "${GITHUB_RUN_ID:-local}" "${GITHUB_RUN_ATTEMPT:-1}" >"$fixture/README.md" +printf 'pub const CACHE_FIXTURE_ID: &str = "%s";\n' "$fixture_id" >>"$fixture/src/lib.rs" +printf 'cache fixture %s\n' "$fixture_id" >"$fixture/README.md" git -C "$fixture" init -q git -C "$fixture" add Cargo.toml Cargo.lock src/lib.rs README.md GIT_AUTHOR_DATE=2000-01-01T00:00:00Z GIT_COMMITTER_DATE=2000-01-01T00:00:00Z \ @@ -23,3 +26,4 @@ if [[ "$mode" == next ]]; then -c commit.gpgsign=false commit -qm next fi printf 'revision=%s\n' "$(git -C "$fixture" rev-parse HEAD)" >>"${GITHUB_OUTPUT:?}" +printf 'fixture-id=%s\n' "$fixture_id" >>"$GITHUB_OUTPUT" diff --git a/.github/actions/setup-rust-build-cache/tests/review.sh b/.github/actions/setup-rust-build-cache/tests/review.sh new file mode 100755 index 000000000..6a6b4c7c6 --- /dev/null +++ b/.github/actions/setup-rust-build-cache/tests/review.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Review regressions: real local ownership behavior plus structural action gates. +ACTION_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-cache-review-test.XXXXXX") +trap 'rm -rf -- "$WORK_DIR"' EXIT +# shellcheck source=common.sh +source "$ACTION_DIR/tests/common.sh" +workspace="$WORK_DIR/workspace" +mkdir -p "$workspace/app" "$WORK_DIR/runner" +printf 'version = 3\n' >"$workspace/app/Cargo.lock" +git -C "$workspace/app" init -q +git -C "$workspace/app" add Cargo.lock +git -C "$workspace/app" -c core.hooksPath=/dev/null -c user.name=Test \ + -c user.email=test@example.com -c commit.gpgsign=false commit -qm seed +revision=$(git -C "$workspace/app" rev-parse HEAD) +prepare() { + mkdir -p "${4:-$WORK_DIR/runner}" + : >"$WORK_DIR/output" + : >"$WORK_DIR/env" + env GITHUB_WORKSPACE="$workspace" RUNNER_TEMP="${4:-$WORK_DIR/runner}" \ + RUNNER_OS=Linux RUNNER_ARCH=X64 GITHUB_OUTPUT="$WORK_DIR/output" GITHUB_ENV="$WORK_DIR/env" \ + EDGEZERO__RUST_CACHE__APP_NAME=review-app EDGEZERO__RUST_CACHE__WORKING_DIRECTORY=app \ + EDGEZERO__RUST_CACHE__CACHE_TARGET="$1" EDGEZERO__RUST_CACHE__SAVE_TARGET="$2" \ + EDGEZERO__RUST_CACHE__TARGET_CACHE_REVISION="$3" \ + "$ACTION_DIR/scripts/prepare.sh" >"$WORK_DIR/log" 2>&1 +} +reject() { + if prepare "$@"; then fail "accepted invalid setup: $*"; fi + [[ ! -s "$WORK_DIR/output" && ! -s "$WORK_DIR/env" ]] || fail 'invalid setup published file commands' +} +prepare false false '' +assert_line "$WORK_DIR/output" 'already-initialized=false' +target=$(sed -n 's/^cargo-target-dir=//p' "$WORK_DIR/output") +printf 'newer local output\n' >"$target/marker" +prepare false false '' +assert_line "$WORK_DIR/output" 'already-initialized=true' +assert_line "$target/marker" 'newer local output' +printf 'next\n' >"$workspace/app/README" +git -C "$workspace/app" add README +git -C "$workspace/app" -c core.hooksPath=/dev/null -c user.name=Test \ + -c user.email=test@example.com -c commit.gpgsign=false commit -qm next +reject false false '' +reject false true "$revision" "$WORK_DIR/invalid-save" +reject true yes "$revision" "$WORK_DIR/invalid-save" +prepare true true "$revision" "$WORK_DIR/writer" +assert_line "$WORK_DIR/output" 'already-initialized=false' +prepare true true "$revision" "$WORK_DIR/writer" +assert_line "$WORK_DIR/output" 'already-initialized=true' +reject true false "$revision" "$WORK_DIR/writer" +newline_runner="$WORK_DIR/"$'runner\nCARGO_TARGET_DIR=/tmp' +mkdir -p "$newline_runner" +reject false false '' "$newline_runner" + +command -v yq >/dev/null || fail 'mikefarah yq v4 is required for metadata contracts' +action="$ACTION_DIR/action.yml" +[[ "$(yq -r '.inputs."save-target".default' "$action")" == false ]] || fail 'target saves are not opt-in' +[[ "$(yq -r '.runs.steps[] | select(.id == "cargo-target") | .uses' "$action")" == actions/cache/restore@v5 ]] || fail 'default target step is not restore-only' +[[ "$(yq -r '.runs.steps[] | select(.id == "cargo-target-save") | .uses' "$action")" == actions/cache@v5 ]] || fail 'writer does not use the post-job save hook' +for step in cargo-target cargo-target-save; do + STEP_ID="$step" yq -e '.runs.steps[] | select(.id == strenv(STEP_ID)) | .with."restore-keys" == null' "$action" >/dev/null || fail 'target fallback weakens revision isolation' +done +for step in cargo-sources cargo-target cargo-target-save sccache; do + condition=$(STEP_ID="$step" yq -r '.runs.steps[] | select(.id == strenv(STEP_ID)) | .if' "$action") + [[ "$condition" == *"already-initialized"* ]] || fail "repeat setup reruns $step" +done +printf 'cache review regressions passed\n' diff --git a/.github/actions/setup-rust-build-cache/tests/revision.sh b/.github/actions/setup-rust-build-cache/tests/revision.sh old mode 100644 new mode 100755 index f7be91c6b..58384c064 --- a/.github/actions/setup-rust-build-cache/tests/revision.sh +++ b/.github/actions/setup-rust-build-cache/tests/revision.sh @@ -4,15 +4,21 @@ set -euo pipefail # Local behavioral and structural contracts; no network or provider credentials. ACTION_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-cache-revision-test.XXXXXX") -trap 'rm -rf -- "$WORK_DIR"' EXIT +cleanup() { + [[ -z "${unreadable_target:-}" ]] || chmod 700 "$unreadable_target" + rm -rf -- "$WORK_DIR" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM revision_a=1111111111111111111111111111111111111111 revision_b=2222222222222222222222222222222222222222 mkdir -p "$WORK_DIR/workspace/app" "$WORK_DIR/workspace/other" printf 'version = 3\n' >"$WORK_DIR/workspace/app/Cargo.lock" cp "$WORK_DIR/workspace/app/Cargo.lock" "$WORK_DIR/workspace/other/Cargo.lock" -fail() { printf 'cache revision test failed: %s\n' "$*" >&2; exit 1; } -assert_line() { grep -Fqx -- "$2" "$1" || fail "expected '$2' in $1"; } +# shellcheck source=common.sh +source "$ACTION_DIR/tests/common.sh" value() { sed -n "s/^$1=//p" "$WORK_DIR/output"; } prepare() { local runner="$1" mode="$2" revision="$3" project="${4:-app}" diff --git a/.github/actions/setup-rust-build-cache/tests/run.sh b/.github/actions/setup-rust-build-cache/tests/run.sh index c0c38023f..ba2af9ebf 100755 --- a/.github/actions/setup-rust-build-cache/tests/run.sh +++ b/.github/actions/setup-rust-build-cache/tests/run.sh @@ -10,18 +10,8 @@ ACTION_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-rust-cache-test.XXXXXX") trap 'rm -rf -- "$WORK_DIR"' EXIT -bash "$ACTION_DIR/tests/revision.sh" - -fail() { - printf 'setup-rust-build-cache test failed: %s\n' "$*" >&2 - exit 1 -} - -assert_line() { - local file="$1" expected="$2" - grep -Fqx -- "$expected" "$file" || - fail "expected '$expected' in $file" -} +# shellcheck source=common.sh +source "$ACTION_DIR/tests/common.sh" workspace="$WORK_DIR/workspace" runner_temp="$WORK_DIR/runner" @@ -46,8 +36,7 @@ if command -v sha256sum >/dev/null 2>&1; then else expected_sha=$(shasum -a 256 "$workspace/project/Cargo.lock" | awk '{ print $1 }') fi -assert_line "$WORK_DIR/output" "cargo-lock-sha256=$expected_sha" -assert_line "$WORK_DIR/output" 'cache-namespace=example-app' +assert_line "$WORK_DIR/output" "source-cache-key=edgezero-app-example-app-rust-sources-Linux-X64-$expected_sha" expected_target=$(realpath "$runner_temp/edgezero-rust-cache/example-app/target") assert_line "$WORK_DIR/output" "cargo-target-dir=$expected_target" assert_line "$WORK_DIR/env" 'RUSTC_WRAPPER=sccache' @@ -58,8 +47,9 @@ assert_line "$WORK_DIR/env" "EDGEZERO__RUST_CACHE__TARGET_DIR=$expected_target" : >"$WORK_DIR/output" : >"$WORK_DIR/env" +mkdir -p "$runner_temp/disabled" GITHUB_WORKSPACE="$workspace" \ - RUNNER_TEMP="$runner_temp" \ + RUNNER_TEMP="$runner_temp/disabled" \ RUNNER_OS=Linux RUNNER_ARCH=X64 \ GITHUB_OUTPUT="$WORK_DIR/output" \ GITHUB_ENV="$WORK_DIR/env" \ @@ -68,7 +58,7 @@ GITHUB_WORKSPACE="$workspace" \ EDGEZERO__RUST_CACHE__TARGET_CACHE_REVISION=1111111111111111111111111111111111111111 \ EDGEZERO__RUST_CACHE__WORKING_DIRECTORY=project \ "$ACTION_DIR/scripts/prepare.sh" -assert_line "$WORK_DIR/output" 'cache-namespace=example-app' +expected_target=$(realpath "$runner_temp/disabled/edgezero-rust-cache/example-app/target") assert_line "$WORK_DIR/output" "cargo-target-dir=$expected_target" assert_line "$WORK_DIR/env" "CARGO_TARGET_DIR=$expected_target" @@ -150,3 +140,6 @@ if grep -Fq "$github_sha_expression" "$action"; then fail "cache action must use the explicit application revision, not infer github.sha" fi 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" diff --git a/.github/actions/setup-rust-build-cache/tests/setup-toolchain.sh b/.github/actions/setup-rust-build-cache/tests/setup-toolchain.sh new file mode 100755 index 000000000..880c3166d --- /dev/null +++ b/.github/actions/setup-rust-build-cache/tests/setup-toolchain.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +# All compiler smoke jobs must use identical rustc binaries and target libraries. +version=$(awk '$1 == "rust" { print $2 }' "${GITHUB_WORKSPACE:?}/.tool-versions") +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { printf 'invalid Rust toolchain version\n' >&2; exit 1; } +rustup toolchain install "$version" --profile minimal --target wasm32-wasip1 +printf 'RUSTUP_TOOLCHAIN=%s\n' "$version" >>"${GITHUB_ENV:?}" diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 7477c41a8..00c00242e 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -112,10 +112,11 @@ jobs: npm run build # Exercise source reuse and revision-isolated target caching across real jobs. - # The fixture revision includes this run ID so the new-revision probe is cold. + # Run-specific compiler inputs prevent accidental hits from older smoke runs. cache-restore-seed: runs-on: ubuntu-latest outputs: + fixture-id: ${{ steps.fixture.outputs.fixture-id }} source-cache-key: ${{ steps.cache.outputs.source-cache-key }} target-cache-key: ${{ steps.cache.outputs.target-cache-key }} steps: @@ -127,12 +128,16 @@ jobs: id: fixture run: bash .github/actions/setup-rust-build-cache/tests/make-fixture.sh seed + - name: Install native and WASM compiler targets + run: bash .github/actions/setup-rust-build-cache/tests/setup-toolchain.sh + - name: Seed the provider-neutral Rust target cache id: cache uses: ./.github/actions/setup-rust-build-cache with: app-name: cache-restore-smoke-v2 cache-target: true + save-target: true target-cache-revision: ${{ steps.fixture.outputs.revision }} working-directory: cache-restore-fixture @@ -142,6 +147,14 @@ jobs: mkdir -p "$HOME/.cargo/git/db" 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 + cache-restore-smoke: needs: cache-restore-seed runs-on: ubuntu-latest @@ -152,8 +165,13 @@ jobs: - name: Recreate cache restore fixture id: fixture + env: + CACHE_FIXTURE_ID: ${{ needs.cache-restore-seed.outputs.fixture-id }} run: bash .github/actions/setup-rust-build-cache/tests/make-fixture.sh seed + - name: Install native and WASM compiler targets + run: bash .github/actions/setup-rust-build-cache/tests/setup-toolchain.sh + - name: Restore the provider-neutral Rust target cache id: cache uses: ./.github/actions/setup-rust-build-cache @@ -173,15 +191,17 @@ jobs: cache-new-revision-smoke: needs: cache-restore-seed runs-on: ubuntu-latest - env: - RUSTUP_TOOLCHAIN: 1.95.0 steps: - uses: actions/checkout@v7 with: persist-credentials: false - name: Create a different fixture source revision id: fixture + env: + CACHE_FIXTURE_ID: ${{ needs.cache-restore-seed.outputs.fixture-id }} run: bash .github/actions/setup-rust-build-cache/tests/make-fixture.sh next + - name: Install native and WASM compiler targets + run: bash .github/actions/setup-rust-build-cache/tests/setup-toolchain.sh - name: Set up revision-isolated target cache id: cache uses: ./.github/actions/setup-rust-build-cache @@ -205,8 +225,6 @@ jobs: test "$TARGET_CACHE_KEY" != "$SEED_TARGET_KEY" test ! -e "$CARGO_TARGET_DIR/restore-marker" grep -Fxq 'edgezero-source-cache-smoke-v2' "$HOME/.cargo/git/db/edgezero-source-cache-smoke-v2" - - name: Install native and WASM compiler targets - run: rustup toolchain install 1.95.0 --profile minimal --target wasm32-wasip1 - name: Assert native and WASM compiler cache reuse run: bash .github/actions/setup-rust-build-cache/tests/assert-compiler-reuse.sh @@ -243,6 +261,7 @@ jobs: with: app-name: store-aware-fixture cache-target: true + save-target: ${{ github.ref == 'refs/heads/main' }} target-cache-revision: ${{ steps.fixture.outputs.revision }} working-directory: fixture-app @@ -303,6 +322,7 @@ jobs: with: app-name: store-free-fixture cache-target: true + save-target: ${{ github.ref == 'refs/heads/main' }} target-cache-revision: ${{ steps.fixture.outputs.revision }} working-directory: fixture-app diff --git a/docs/guide/deploy-action-adoption.md b/docs/guide/deploy-action-adoption.md index fa08db3c3..3439e7253 100644 --- a/docs/guide/deploy-action-adoption.md +++ b/docs/guide/deploy-action-adoption.md @@ -28,18 +28,45 @@ GitHub Release asset, when the required rollback window exceeds 14 days. ### Optional Rust build caching -Call the provider-neutral cache action once after checking out the application -and before its Rust build steps: +Call the provider-neutral cache action after checking out the application and +before its Rust build steps. Replace the repository/ref placeholders and pin +EdgeZero actions to one reviewed full commit SHA: ```yaml +- name: Check out application source + uses: actions/checkout@v7 + with: + repository: your-org/example-app + ref: + path: application + persist-credentials: false + +- name: Capture immutable application revision + id: source + shell: bash + run: | + set -euo pipefail + sha=$(git -C application rev-parse HEAD) + [[ "$sha" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] || exit 1 + printf 'sha=%s\n' "$sha" >>"$GITHUB_OUTPUT" + - name: Set up Rust build cache id: rust-cache uses: stackpop/edgezero/.github/actions/setup-rust-build-cache@ with: app-name: example-app cache-target: true + save-target: ${{ github.ref == 'refs/heads/main' }} target-cache-revision: ${{ steps.source.outputs.sha }} working-directory: application + +- name: Report cache selection + shell: bash + env: + SOURCE_HIT: ${{ steps.rust-cache.outputs.source-cache-hit }} + TARGET_HIT: ${{ steps.rust-cache.outputs.target-cache-hit }} + TARGET_DIR: ${{ steps.rust-cache.outputs.cargo-target-dir }} + run: printf 'Source exact hit=%s; target exact hit=%s; target directory=%s\n' "$SOURCE_HIT" "$TARGET_HIT" "$TARGET_DIR" ``` `app-name` is a stable lowercase cache namespace for one application. Do not @@ -59,32 +86,66 @@ for every later native or WASM Rust build in the job. - Dependency source keys include app, runner OS/architecture, and `Cargo.lock`, but not source revision. A stable restore prefix also permits reuse when the lockfile changes; Cargo fetches missing dependencies. -- With `cache-target: true`, target artifacts are restored/saved using a new v2 - key containing the complete source revision and lock digest. There is no +- With `cache-target: true`, target artifacts are restored using a new v2 key + containing the complete source revision and lock digest. There is no cross-revision or legacy-key fallback. The first build of a new revision has - a cold target cache; repeating that revision can restore its target artifacts. + a cold target cache; repeating that revision can restore previously saved artifacts. + Saving is separately opt-in: `save-target: true` enables a success-only + post-job save after builds complete. Its default is false (restore-only), and + enabling it without `cache-target: true` fails. Prefer writes from trusted main + workflow runs; this controls the workflow ref, not which application branch + can be built. Do not save a full target on every disposable fixture/PR run. - Compiler results use sccache's compiler/input/environment checks. Unchanged - compilations can be reused across revisions, but linking and some crate types - are not cacheable. A hit ratio or deployment duration is not guaranteed. + compilations can be reused across revisions. Crates that invoke the linker + (`bin`, `dylib`, `cdylib`, and `proc-macro`) are not cacheable. A cold target + still requires those compilations, build-script execution, and linking; it + does not make a new application revision a fully warm build. A hit ratio or + deployment duration is not guaranteed. In both modes, the action sets one stable app-specific `CARGO_TARGET_DIR` beneath `RUNNER_TEMP`, without a SHA or random invocation directory in the path. Native CLI and WASM builds share it; Cargo keeps target triples/profiles separate. -`cache-target: false` disables only remote target restore/save, not that stable -directory, dependency caching, or sccache. Call setup once per application/job. -Reusing the directory for another source revision or workspace fails; repeat -setup without an explicit revision also fails. Symlinks and unowned target -contents are rejected rather than deleted. +`cache-target: false` disables remote target operations, not that stable +directory, dependency caching, or sccache. Prefer one setup per application per +job. An identical repeat skips remote restores and compiler-cache setup, so it +cannot overwrite newer local build outputs. Changing workspace, source revision, +lockfile or cache settings within that directory fails. With remote targets +disabled, Git HEAD can identify local ownership when no revision was supplied; +a non-Git workspace requires an explicit revision for repeat setup. Symlinks, +control characters in paths and unowned target contents are rejected rather +than deleted. Outputs `source-cache-hit` and `target-cache-hit` indicate exact primary-key matches. A false source hit can still mean prefix reuse after a lockfile change; -target hits are false when disabled or missing. `source-cache-key`, +target hits are false when disabled or missing. Both hit outputs are false on +an identical repeat because that call performs no restore. `source-cache-key`, `target-cache-key`, and `cargo-target-dir` expose the selected identities/path, and sccache logs compiler statistics at job completion. Branch-scoped GitHub -cache access still applies; merging a feature branch may require a cold build. -When migrating, use a stable app-name and add the application SHA input together; -the new target-key namespace starts cold. Existing CLI build invocation, package, -release verification, credential isolation, and deployment commands are unchanged. +cache access still applies: PR merge-ref caches cannot seed main or unrelated +PRs. Trusted main builds warm the source/compiler caches accessible to later +branch builds; merging may require a cold target build. + +#### Breaking cache/build-path migration + +This changes the former lockfile-only target policy: only dependency sources and +valid compiler results may cross source revisions, never writable target archives. +It also changes build paths when remote target caching is disabled. + +1. Use one stable `app-name` per application, without a source SHA suffix. +2. Resolve the application's full SHA and supply `target-cache-revision` when + enabling target restores; missing revisions have no compatibility fallback. +3. Opt into target saves explicitly where repeated builds justify archive cost. + Restore-only callers do not automatically populate new target keys. +4. Remove hard-coded `/target/...` paths. Use `CARGO_TARGET_DIR` or + `cargo-target-dir`; CLI and adapter builds now share this job-local directory + even with `cache-target: false`, instead of fresh per-invocation CLI scratch. +5. Prefer one setup per application per job; repeats must identify the same + source and use identical workspace, lockfile and cache settings. +6. Expect a cold first target build after migration and for each new source SHA. + Measure compiler hits and build duration instead of assuming all work is cached. + +CLI build invocation, packaging, release verification, credential isolation, and +production/staging deployment semantics are unchanged. Build and download the configurable application CLI artifact before assembling the release: @@ -95,7 +156,7 @@ the release: with: app-cli-package: example-app-cli app-cli-bin: example-app-cli - app-cli-artifact: example-app-cli-${{ github.sha }} + app-cli-artifact: example-app-cli-${{ steps.source.outputs.sha }} working-directory: application - name: Download the application CLI archive @@ -116,8 +177,8 @@ built in the credential-free producer job: fastly-package: application/pkg/app.tar.gz application-manifest: application/edgezero.toml adapter-manifest: application/adapters/fastly/fastly.toml - source-revision: ${{ github.sha }} - artifact-name: application-release-${{ github.sha }} + source-revision: ${{ steps.source.outputs.sha }} + artifact-name: application-release-${{ steps.source.outputs.sha }} ``` The action verifies the application CLI's lifecycle command surface, writes diff --git a/docs/superpowers/plans/2026-10-13-edgezero-deploy-cache-review-remediation.md b/docs/superpowers/plans/2026-10-13-edgezero-deploy-cache-review-remediation.md new file mode 100644 index 000000000..1214e90e6 --- /dev/null +++ b/docs/superpowers/plans/2026-10-13-edgezero-deploy-cache-review-remediation.md @@ -0,0 +1,80 @@ +# Rust Cache Review Remediation Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans for inline test-first +> execution, with independent document and code review. + +**Goal:** Address PR #417's verified review findings without weakening revision isolation. + +**Architecture:** Add opt-in post-job target writes and restore-only defaults. +Bind repeated setup to source identity and skip duplicate restores. Prove first-build +compiler hits across jobs and retries; align deployer PR #36 with the new immutable pin. + +**Tech Stack:** Bash, composite GitHub Actions, actions/cache v5, sccache v0.16.0, +yq v4, Cargo, existing deployer policy checks. + +## Task 1: Cache action behavior and regressions + +Files: `.github/actions/setup-rust-build-cache/action.yml`, `scripts/prepare.sh`, +`tests/run.sh`, `tests/revision.sh`, new `tests/review.sh`, and +`.github/actions/deploy-core/tests/run.sh`. + +- [ ] Add failing tests for default `save-target: false`, restore-only metadata, + opt-in writer metadata, invalid save modes, repeat skip flags, inferred local + Git identity, mixed revision/settings rejection, and newline paths. +- [ ] Run `bash .github/actions/setup-rust-build-cache/tests/review.sh`; verify red. +- [ ] Implement `save-target` validation and two mutually exclusive target steps: + restore-only when false; combined cache with post-job save when true. Gate all + remote setup steps on `already-initialized != 'true'`; keep no restore prefixes. +- [ ] Infer local ownership SHA with `git -C "$project_real" rev-parse --verify HEAD` + only when no revision was supplied. Compare workspace/SHA/lock/cache settings. + Keep first non-Git setup allowed and reject unidentified repeats. +- [ ] Add `append_output`, remove unused internal outputs, reject path control + characters, normalize cache-action identity in output contracts while retaining + the separate pin gate, and fix cleanup of permission-restricted test fixtures. +- [ ] Run both old and new cache suites, full deployment contracts and ShellCheck. + +## Task 2: Cross-job and retry-safe probes + +Files: `.github/workflows/deploy-action.yml`, cache `tests/make-fixture.sh`, +`tests/assert-compiler-reuse.sh`, new `tests/fixture.sh`, +`.github/actions/deploy-fastly/tests/make-smoke-fixture.sh`. + +- [ ] Add failing retry fixture tests: seed attempt 1 and consumer attempt 2 share + an explicitly passed fixture ID and SHA; different run IDs change library bytes; + next revision preserves compiler inputs. Add structural first-build assertions. +- [ ] Export seed fixture ID, use it in both consumers, and embed it in lib.rs. + Seed builds native and wasm32-wasip1; all three jobs resolve, install and select + the identical Rust toolchain from .tool-versions using one shared helper. +- [ ] Remove build/clean/rebuild from the reuse probe: reset stats, build once, + assert Rust hits and zero cache read/write errors for each target. +- [ ] Set fixed fixture Git dates and record upstream revision in generated app + source identity. Gate full fixture target writes on main, seed writes explicitly, + and leave consumers restore-only. Extend existing workflow structural contracts. +- [ ] Run fixture regressions and a local two-revision, two-sccache-server probe + with fresh target directories; require native/WASM first-build hits. + +## Task 3: Documentation and aligned deployer + +Files: `docs/guide/deploy-action-adoption.md`, CLI builder header comments, +deployer `.github/workflows/prepare-release.yml`, policy tests, README, CLAUDE, +and active cache specs/plans. + +- [ ] Add complete checkout, validated SHA output, cache inputs, and diagnostic + output example. Add migration checklist, explicit limitations and branch scope. +- [ ] Update deployer policy tests first to require five exact cache inputs and + `save-target: ${{ github.ref == 'refs/heads/main' }}`; watch red before workflow edit. +- [ ] Preserve source selection, defaults, cache-target=true, protected deployment, + CLI packaging, config-only, recovery, artifact retention, and no-Python policy. +- [ ] After upstream commit verification, advance all action/contract/checker + pins together and validate 49 consumed input pairs against that exact checkout. + +## Task 4: Verification and delivery + +- [ ] Run full EdgeZero Cargo test/fmt/clippy/features/Spin gates, Bash contracts, + actionlint, ShellCheck, pin gate, offline security scan and docs lint/build. +- [ ] Run all six deployer suites, YAML parsing, actionlint, full ShellCheck, + live upstream contract checker and git diff --check in both worktrees. +- [ ] Obtain independent code review; fix substantive issues and reverify. +- [ ] Commit and push the existing PR #417 and #36 branches. Update PR descriptions + with breaking build/cache migration and actual verification evidence; no merge, + deployment, release or cache cleanup. Observe PR CI for the final heads. diff --git a/docs/superpowers/specs/2026-10-12-edgezero-deploy-cache-revision-isolation-design.md b/docs/superpowers/specs/2026-10-12-edgezero-deploy-cache-revision-isolation-design.md index daec32fdb..d055088ff 100644 --- a/docs/superpowers/specs/2026-10-12-edgezero-deploy-cache-revision-isolation-design.md +++ b/docs/superpowers/specs/2026-10-12-edgezero-deploy-cache-revision-isolation-design.md @@ -4,7 +4,7 @@ Reuse dependency sources and valid compiler results across application commits, without restoring writable Cargo target artifacts from another source revision. -This implements the cache requirements approved in the conversation. Deployment +This implements [issue #416](https://github.com/stackpop/edgezero/issues/416). Deployment behavior, credential isolation, and release verification are unchanged. ## Action contract @@ -46,9 +46,10 @@ or selection-marker components. Check canonical confinement below RUNNER_TEMP. Reject pre-existing nonempty target directories without a selection marker. Keep the selection marker outside the cached target directory. It binds the directory to the canonical application workspace and selected source revision. -Reject subsequent setup for a different workspace/revision. Without an explicit -revision, setup is allowed only once for that app; callers needing another -setup must supply the revision. No cleanup of unrelated or unowned files occurs. +Reject subsequent setup for a different workspace, revision, lockfile or cache +settings. Identical repeats skip remote setup. Without an explicit revision, +local ownership can use Git HEAD; non-Git workspaces require an explicit revision +for repeat setup. No cleanup of unrelated or unowned files occurs. ## Scope and migration @@ -56,7 +57,8 @@ Update all in-repository action callers and the adoption guide. New releases require external consumers to use a stable app-name and explicitly pass their resolved source SHA when enabling target caching. Existing SHA-suffixed app names must be replaced by the stable app name. First use of the new target namespace -is cold. Do not update trusted-server-deployer or publish a release in this PR. +is cold. Deployer adoption is delivered separately in its own PR. Do not publish +a release in this PR. ## Verification @@ -70,3 +72,42 @@ Log compiler cache statistics and require repeatable unchanged-library compiler hits using a credential-free native/WASM fixture. Measure real-world CLI/WASM timings after adoption separately; local tests cannot exercise GitHub's cache service. + +## Review remediation (2026-10-13) + +Target caching deliberately replaces #381's lockfile-only target policy: source +downloads and compiler outputs can cross revisions, but writable target archives +cannot. This costs a cold target on each new source SHA; it is not a promise of a +fully warm application build. Do not introduce a lockfile-only target fallback. + +Add `save-target`, default false, requiring `cache-target: true` when enabled. +Read-only consumers use `actions/cache/restore`; opted-in writers use the combined +cache action, whose post-job hook saves only after successful builds. Both use +the same exact revision key and neither has target restore prefixes. CI fixtures +save full targets only from main; the small cross-job seed explicitly saves. +The deployer adopts the new contract and permits target writes only when its +workflow ref is main, independently of the selected application source branch. + +Bind local ownership to source identity, workspace, lock digest, and cache +settings. When remote targets are disabled and no revision is supplied, use the +workspace's Git HEAD for local ownership if available; a non-Git workspace still +requires an explicit revision for repeat setup. An identical repeat publishes +the same path and keys but skips remote restores and sccache initialization. +Reject different selections, symlinks, unowned contents, and control characters +in paths before writing file-command channels. Keep the marker outside targets. + +The cross-job seed compiles a run-specific library for native and WASM targets. +It exports its fixture identity so retrying only downstream jobs reconstructs +the same seed source, even when GITHUB_RUN_ATTEMPT changes. The next revision +changes only README and requires compiler hits on its first native/WASM builds, +using a fresh target and the same .tool-versions toolchain as the seed. All three +jobs install and select that shared toolchain. Existing +application fixtures use fixed commit dates and record the upstream EdgeZero +revision so unchanged inputs have stable SHAs without hiding dependency changes. + +This is a breaking build-path/cache contract for callers that assumed an +application-local target directory or implicit target saves. Provide a complete +adoption example, migration checklist, safe repeat behavior, main-cache scope, +and explicit sccache limitations (bin/dylib/cdylib/proc-macro, build-script +execution, and linking). Deployment semantics and credential boundaries stay +unchanged. Do not clean caches, deploy, merge, or release during remediation. From 6cbeec0e29a8cafeae542b7a6c5738d8c0718a79 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:35:30 -0700 Subject: [PATCH 3/4] fix: address Rust cache review feedback --- .github/actions/deploy-core/tests/run.sh | 7 ++++++- .../setup-rust-build-cache/tests/fixture.sh | 8 ++++---- .github/workflows/deploy-action.yml | 9 +++------ docs/guide/deploy-action-adoption.md | 20 +++++++++++-------- ...dgezero-deploy-cache-revision-isolation.md | 3 +++ ...dgezero-deploy-cache-review-remediation.md | 3 +++ ...-deploy-cache-revision-isolation-design.md | 5 +++-- 7 files changed, 34 insertions(+), 21 deletions(-) diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh index 2aa495db3..b6eeb660a 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -1993,7 +1993,12 @@ test_action_output_contracts() { if [[ -z "$script" ]]; then # actions/cache declares cache-hit itself, not through a local script. # Ref pinning is enforced separately; accept only this documented output. - if [[ "$out_name" == cache-hit ]] && command -v yq >/dev/null 2>&1; then + if [[ "$out_name" == cache-hit ]]; then + if ! command -v yq >/dev/null 2>&1; then + fail "yq v4 is required to check $name_of output '$out_name'" + missing=$((missing + 1)) + continue + fi local output_action output_action=$(STEP_ID="$step_id" yq -r '.runs.steps[] | select(.id == strenv(STEP_ID)) | .uses' "$action") case "${output_action%@*}" in actions/cache | actions/cache/restore) continue ;; esac diff --git a/.github/actions/setup-rust-build-cache/tests/fixture.sh b/.github/actions/setup-rust-build-cache/tests/fixture.sh index 637d08d4b..019740f5f 100755 --- a/.github/actions/setup-rust-build-cache/tests/fixture.sh +++ b/.github/actions/setup-rust-build-cache/tests/fixture.sh @@ -42,11 +42,11 @@ 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")" == true ]] || fail 'seed does not opt into saving' -# shellcheck disable=SC2016 -expected_save='${{ github.ref == '\''refs/heads/main'\'' }}' 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."save-target"' "$workflow") - [[ "$actual" == "$expected_save" ]] || fail "$job writes full target caches outside main" + 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" + actual=$(JOB_ID="$job" yq -r '.jobs[strenv(JOB_ID)].steps[] | select(.uses == "./.github/actions/setup-rust-build-cache") | (.with."save-target" // false)' "$workflow") + [[ "$actual" == false ]] || fail "$job opts into saving full target caches" done fixture_script="$ACTION_DIR/../deploy-fastly/tests/make-smoke-fixture.sh" grep -Fq 'GIT_AUTHOR_DATE=2000-01-01T00:00:00Z GIT_COMMITTER_DATE=2000-01-01T00:00:00Z' "$fixture_script" || fail 'application fixture dates are not deterministic' diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index 00c00242e..15e797cef 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -113,6 +113,7 @@ jobs: # Exercise source reuse and revision-isolated target caching across real jobs. # Run-specific compiler inputs prevent accidental hits from older smoke runs. + # The tiny per-run target archive lets downstream jobs verify exact restores. cache-restore-seed: runs-on: ubuntu-latest outputs: @@ -260,9 +261,7 @@ jobs: uses: ./.github/actions/setup-rust-build-cache with: app-name: store-aware-fixture - cache-target: true - save-target: ${{ github.ref == 'refs/heads/main' }} - target-cache-revision: ${{ steps.fixture.outputs.revision }} + cache-target: false working-directory: fixture-app - name: Build the application-owned CLI once @@ -321,9 +320,7 @@ jobs: uses: ./.github/actions/setup-rust-build-cache with: app-name: store-free-fixture - cache-target: true - save-target: ${{ github.ref == 'refs/heads/main' }} - target-cache-revision: ${{ steps.fixture.outputs.revision }} + cache-target: false working-directory: fixture-app - name: Build the store-free application CLI once diff --git a/docs/guide/deploy-action-adoption.md b/docs/guide/deploy-action-adoption.md index 3439e7253..bdc9030bb 100644 --- a/docs/guide/deploy-action-adoption.md +++ b/docs/guide/deploy-action-adoption.md @@ -106,10 +106,13 @@ In both modes, the action sets one stable app-specific `CARGO_TARGET_DIR` beneat `RUNNER_TEMP`, without a SHA or random invocation directory in the path. Native CLI and WASM builds share it; Cargo keeps target triples/profiles separate. `cache-target: false` disables remote target operations, not that stable -directory, dependency caching, or sccache. Prefer one setup per application per -job. An identical repeat skips remote restores and compiler-cache setup, so it -cannot overwrite newer local build outputs. Changing workspace, source revision, -lockfile or cache settings within that directory fails. With remote targets +directory, dependency caching, or sccache. Use **one application per job**: setup +sets job-wide target-directory variables, so setting up another application +redirects subsequent builds into its directory. Put different applications in +separate jobs. Prefer one setup call; an identical repeat skips remote restores +and compiler-cache setup, preserving newer local build outputs. Changing +workspace, source revision, lockfile or cache settings within that directory +fails. With remote targets disabled, Git HEAD can identify local ownership when no revision was supplied; a non-Git workspace requires an explicit revision for repeat setup. Symlinks, control characters in paths and unowned target contents are rejected rather @@ -118,8 +121,9 @@ than deleted. Outputs `source-cache-hit` and `target-cache-hit` indicate exact primary-key matches. A false source hit can still mean prefix reuse after a lockfile change; target hits are false when disabled or missing. Both hit outputs are false on -an identical repeat because that call performs no restore. `source-cache-key`, -`target-cache-key`, and `cargo-target-dir` expose the selected identities/path, +an identical repeat because that call performs no restore. Read hit status from +the first setup call. `source-cache-key`, `target-cache-key`, and +`cargo-target-dir` expose the selected identities/path, and sccache logs compiler statistics at job completion. Branch-scoped GitHub cache access still applies: PR merge-ref caches cannot seed main or unrelated PRs. Trusted main builds warm the source/compiler caches accessible to later @@ -139,8 +143,8 @@ It also changes build paths when remote target caching is disabled. 4. Remove hard-coded `/target/...` paths. Use `CARGO_TARGET_DIR` or `cargo-target-dir`; CLI and adapter builds now share this job-local directory even with `cache-target: false`, instead of fresh per-invocation CLI scratch. -5. Prefer one setup per application per job; repeats must identify the same - source and use identical workspace, lockfile and cache settings. +5. Use one application per job and prefer one setup call. Repeats must identify + the same source and use identical workspace, lockfile and cache settings. 6. Expect a cold first target build after migration and for each new source SHA. Measure compiler hits and build duration instead of assuming all work is cached. diff --git a/docs/superpowers/plans/2026-10-12-edgezero-deploy-cache-revision-isolation.md b/docs/superpowers/plans/2026-10-12-edgezero-deploy-cache-revision-isolation.md index 37e693e38..a0cc4acb8 100644 --- a/docs/superpowers/plans/2026-10-12-edgezero-deploy-cache-revision-isolation.md +++ b/docs/superpowers/plans/2026-10-12-edgezero-deploy-cache-revision-isolation.md @@ -1,5 +1,8 @@ # Revision-isolated Rust Build Cache Implementation Plan +**Status:** Complete. Implementation and verification are recorded in PR #417. +The checklists below preserve the original implementation sequence. + > **For agentic workers:** Use superpowers:executing-plans to implement this plan > inline, with independent document and code reviews. Steps use checkboxes. diff --git a/docs/superpowers/plans/2026-10-13-edgezero-deploy-cache-review-remediation.md b/docs/superpowers/plans/2026-10-13-edgezero-deploy-cache-review-remediation.md index 1214e90e6..518a6b5ef 100644 --- a/docs/superpowers/plans/2026-10-13-edgezero-deploy-cache-review-remediation.md +++ b/docs/superpowers/plans/2026-10-13-edgezero-deploy-cache-review-remediation.md @@ -1,5 +1,8 @@ # Rust Cache Review Remediation Implementation Plan +**Status:** Complete. Implementation and verification are recorded in PR #417. +The checklists below preserve the original implementation sequence. + > **For agentic workers:** Use superpowers:executing-plans for inline test-first > execution, with independent document and code review. diff --git a/docs/superpowers/specs/2026-10-12-edgezero-deploy-cache-revision-isolation-design.md b/docs/superpowers/specs/2026-10-12-edgezero-deploy-cache-revision-isolation-design.md index d055088ff..9797370c6 100644 --- a/docs/superpowers/specs/2026-10-12-edgezero-deploy-cache-revision-isolation-design.md +++ b/docs/superpowers/specs/2026-10-12-edgezero-deploy-cache-revision-isolation-design.md @@ -83,8 +83,9 @@ fully warm application build. Do not introduce a lockfile-only target fallback. Add `save-target`, default false, requiring `cache-target: true` when enabled. Read-only consumers use `actions/cache/restore`; opted-in writers use the combined cache action, whose post-job hook saves only after successful builds. Both use -the same exact revision key and neither has target restore prefixes. CI fixtures -save full targets only from main; the small cross-job seed explicitly saves. +the same exact revision key and neither has target restore prefixes. Release +fixtures reuse dependency sources and compiler outputs with target caching +disabled; the small cross-job seed explicitly saves to verify exact restores. The deployer adopts the new contract and permits target writes only when its workflow ref is main, independently of the selected application source branch. From a25f0235ee058df13e5c4776f8aafb3d1bbc7fb3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:37:57 -0700 Subject: [PATCH 4/4] docs: align cache action with transferred repository --- docs/guide/deploy-action-adoption.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/deploy-action-adoption.md b/docs/guide/deploy-action-adoption.md index bdc9030bb..6eb858505 100644 --- a/docs/guide/deploy-action-adoption.md +++ b/docs/guide/deploy-action-adoption.md @@ -52,7 +52,7 @@ EdgeZero actions to one reviewed full commit SHA: - name: Set up Rust build cache id: rust-cache - uses: stackpop/edgezero/.github/actions/setup-rust-build-cache@ + uses: IABTechLab/edgezero/.github/actions/setup-rust-build-cache@ with: app-name: example-app cache-target: true