Repository navigation
Improve Rust cache reuse with revision-isolated targets (breaking build contract) #417
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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,12 @@ 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-}" | ||
|
|
||
| [[ "$app_name" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] || | ||
|
|
@@ -52,6 +65,16 @@ 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)" | ||
| fi | ||
| [[ -n "$working_directory" ]] || | ||
| fail "input 'working-directory' cannot be empty" | ||
| [[ "$working_directory" != /* ]] || | ||
|
|
@@ -76,34 +99,67 @@ main() { | |
| [[ "$lock_sha" =~ ^[0-9a-f]{64}$ ]] || | ||
| fail "could not compute a lowercase SHA-256 for Cargo.lock" | ||
|
|
||
| { | ||
| printf 'cache-namespace=%s\n' "$app_name" | ||
| printf 'cargo-lock-sha256=%s\n' "$lock_sha" | ||
| } >>"$output_file" | ||
| 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 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 "$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, 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. | ||
| 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" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 😃 Thorough validation
Each of these has a negative test in |
||
|
|
||
| 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" | ||
|
|
||
| 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' | ||
| printf 'CARGO_INCREMENTAL=0\n' | ||
| printf 'CARGO_TARGET_DIR=%s\n' "$target_dir" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤔 Several apps set up in one job overwrite each other's Every setup writes The guide's wording "Prefer one setup per application per job" ( Fix: either document "one application per job" as a hard rule, or make |
||
| 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 "$@" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
|
|
||
| # 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" | ||
| [[ "$(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 | ||
| [[ ! -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 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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"; } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
⛏ Missing
yqgives a misleading errorWithout
yq, a legitimatecache-hitoutput falls through to "names step '…', which runs no script", which hides the real cause. Suggest failing clearly instead: