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/2] 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/2] 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.