Skip to content

Commit fc4ccba

Browse files
authored
Improve Rust cache reuse with revision-isolated targets (breaking build contract) (#417)
* 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 * fix: control target cache writes and prove cross-job compiler reuse * fix: address Rust cache review feedback * docs: align cache action with transferred repository
1 parent 41f4787 commit fc4ccba

19 files changed

Lines changed: 1001 additions & 96 deletions

‎.github/actions/build-app-cli/action.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ runs:
105105
EDGEZERO__PROJECT__WORKING_DIRECTORY: ${{ inputs['working-directory'] }}
106106
EDGEZERO__PROJECT__RUST_TOOLCHAIN: ${{ inputs['rust-toolchain'] }}
107107
EDGEZERO__APP__CLI__ARTIFACT: ${{ inputs['app-cli-artifact'] }}
108-
# setup-rust-build-cache publishes this through GITHUB_ENV only when its
109-
# opt-in target cache is enabled. build-app-cli confines it to RUNNER_TEMP.
108+
# setup-rust-build-cache always publishes this stable build directory,
109+
# independently of target restore/save. Confine it to RUNNER_TEMP.
110110
EDGEZERO__RUST_CACHE__TARGET_DIR: ${{ env.EDGEZERO__RUST_CACHE__TARGET_DIR }}
111111
# This step runs app-controlled code, so it emits NO outputs through the
112112
# runner's GITHUB_OUTPUT (blanked below). It collects them into this

‎.github/actions/build-app-cli/scripts/build-app-cli.sh‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
set -euo pipefail
33

44
# Compiles the CLI package the APPLICATION provides — a crate in the app's own
5-
# workspace — into an application-scoped cached CARGO_TARGET_DIR when configured,
6-
# or an action-owned scratch directory otherwise. It then packages the binary
5+
# workspace — into the shared job-local target provided by cache setup (even
6+
# without remote target caching), or fresh action-owned scratch when setup is
7+
# absent. It then packages the binary
78
# plus a self-describing app-cli-meta.json into a tar so the executable bit
89
# survives actions/upload-artifact. Never builds the EdgeZero monorepo CLI.
910
#
@@ -18,7 +19,7 @@ set -euo pipefail
1819
# RUNNER_TEMP optional action-owned scratch root (default: /tmp)
1920
# EDGEZERO__PROVIDER__ENV_CLEAR optional JSON array of provider aliases to scrub before the build (default: [])
2021
# EDGEZERO__ACTION__WORKSPACE optional per-invocation scratch root (default: mktemp under RUNNER_TEMP)
21-
# EDGEZERO__RUST_CACHE__TARGET_DIR optional shared Cargo target directory beneath RUNNER_TEMP
22+
# EDGEZERO__RUST_CACHE__TARGET_DIR optional shared job-local target beneath RUNNER_TEMP, independent of remote cache mode
2223
# EDGEZERO__ACTION__OUTPUT_FILE optional outputs handoff file, started fresh (default: GITHUB_OUTPUT)
2324
# Writes (outputs):
2425
# app-cli-package the package that was built

‎.github/actions/deploy-core/tests/run.sh‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1991,6 +1991,18 @@ test_action_output_contracts() {
19911991

19921992
script=$(awk -v want="$step_id" '$1 == want { $1 = ""; sub(/^ /, ""); print; exit }' <<<"$scripts")
19931993
if [[ -z "$script" ]]; then
1994+
# actions/cache declares cache-hit itself, not through a local script.
1995+
# Ref pinning is enforced separately; accept only this documented output.
1996+
if [[ "$out_name" == cache-hit ]]; then
1997+
if ! command -v yq >/dev/null 2>&1; then
1998+
fail "yq v4 is required to check $name_of output '$out_name'"
1999+
missing=$((missing + 1))
2000+
continue
2001+
fi
2002+
local output_action
2003+
output_action=$(STEP_ID="$step_id" yq -r '.runs.steps[] | select(.id == strenv(STEP_ID)) | .uses' "$action")
2004+
case "${output_action%@*}" in actions/cache | actions/cache/restore) continue ;; esac
2005+
fi
19942006
fail "$name_of output '$out_name' names step '$step_id', which runs no script"
19952007
missing=$((missing + 1))
19962008
continue
@@ -2292,6 +2304,17 @@ test_fastly_smoke_release_contract() {
22922304
grep -Fq './.github/actions/setup-rust-build-cache' <<<"$cache_restore"
22932305
assert_succeeds "cache restore verifies a marker created by the seed job" \
22942306
grep -Fq 'restore-marker' <<<"$cache_restore"
2307+
local cache_call missing_revisions revision_smoke
2308+
cache_call='.jobs[] | .steps[]? | select(.uses == "./.github/actions/setup-rust-build-cache")'
2309+
missing_revisions=$(yq eval -r "$cache_call | select(.with.\"cache-target\" == true and .with.\"target-cache-revision\" == null) | .name" "$workflow")
2310+
assert_equals "all target cache callers supply their source revision" "" "$missing_revisions"
2311+
revision_smoke=$(yq eval -r '.jobs."cache-new-revision-smoke"' "$workflow")
2312+
assert_succeeds "new-revision smoke checks target artifact isolation" \
2313+
grep -Fq 'target-cache-hit' <<<"$revision_smoke"
2314+
assert_succeeds "new-revision smoke checks source cache reuse" \
2315+
grep -Fq 'source-cache-hit' <<<"$revision_smoke"
2316+
assert_succeeds "new-revision smoke checks compiler reuse for both targets" \
2317+
grep -Fq 'assert-compiler-reuse.sh' <<<"$revision_smoke"
22952318
for obsolete in cache-smoke-capture.sh cache-smoke-assert.sh cache-smoke-assert-no-restore.sh; do
22962319
assert_fails "obsolete deploy-time cache helper is removed ($obsolete)" \
22972320
test -e "$ACTIONS_DIR/deploy-core/tests/$obsolete"

‎.github/actions/deploy-fastly/tests/make-smoke-fixture.sh‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,11 @@ source = "fixture.wasm"
155155
TOML
156156

157157
cargo generate-lockfile
158+
# Path dependencies live in the enclosing EdgeZero checkout, not this Git repo.
159+
git -C "$workspace" rev-parse HEAD >.edgezero-source-revision
158160
git add -A
159-
git commit -q -m fixture
161+
GIT_AUTHOR_DATE=2000-01-01T00:00:00Z GIT_COMMITTER_DATE=2000-01-01T00:00:00Z \
162+
git -c core.hooksPath=/dev/null -c commit.gpgsign=false commit -q -m fixture
160163
}
161164

162165
main() {

‎.github/actions/setup-rust-build-cache/action.yml‎

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,33 @@ inputs:
1010
required: false
1111
default: .
1212
cache-target:
13-
description: Restore and save the application's Cargo target directory in addition to compiler outputs.
13+
description: Restore the application's revision-specific Cargo target directory in addition to compiler outputs.
1414
required: false
1515
default: "false"
16+
save-target:
17+
description: Opt in to saving a missed target cache after a successful job; requires cache-target true. Prefer writes from trusted main workflows.
18+
required: false
19+
default: "false"
20+
target-cache-revision:
21+
description: Full lowercase application source Git SHA; required when cache-target is true. Never use a deployment repository SHA for different application source.
22+
required: false
23+
24+
outputs:
25+
source-cache-hit:
26+
description: Whether the dependency source cache matched its exact primary key (false also includes prefix reuse).
27+
value: ${{ steps.cargo-sources.outputs.cache-hit == 'true' }}
28+
target-cache-hit:
29+
description: Whether target artifacts matched the revision-specific key; false when target caching is disabled or misses.
30+
value: ${{ steps.cargo-target.outputs.cache-hit == 'true' || steps.cargo-target-save.outputs.cache-hit == 'true' }}
31+
source-cache-key:
32+
description: Dependency source cache primary key.
33+
value: ${{ steps.prepare.outputs['source-cache-key'] }}
34+
target-cache-key:
35+
description: Revision-specific target cache key; empty if no revision was supplied.
36+
value: ${{ steps.prepare.outputs['target-cache-key'] }}
37+
cargo-target-dir:
38+
description: Stable confined directory used by later native and WASM builds, even when target caching is disabled.
39+
value: ${{ steps.prepare.outputs['cargo-target-dir'] }}
1640

1741
runs:
1842
using: composite
@@ -23,33 +47,45 @@ runs:
2347
env:
2448
EDGEZERO__RUST_CACHE__APP_NAME: ${{ inputs['app-name'] }}
2549
EDGEZERO__RUST_CACHE__CACHE_TARGET: ${{ inputs['cache-target'] }}
50+
EDGEZERO__RUST_CACHE__SAVE_TARGET: ${{ inputs['save-target'] }}
51+
EDGEZERO__RUST_CACHE__TARGET_CACHE_REVISION: ${{ inputs['target-cache-revision'] }}
2652
EDGEZERO__RUST_CACHE__WORKING_DIRECTORY: ${{ inputs['working-directory'] }}
2753
BASH_ENV: ""
2854
ENV: ""
2955
run: exec "$GITHUB_ACTION_PATH/scripts/prepare.sh"
3056

3157
- name: Restore Cargo dependency sources
3258
id: cargo-sources
59+
if: steps.prepare.outputs.already-initialized != 'true'
3360
uses: actions/cache@v5
3461
with:
3562
path: |
3663
~/.cargo/registry/index/
3764
~/.cargo/registry/cache/
3865
~/.cargo/git/db/
39-
key: edgezero-app-${{ steps.prepare.outputs['cache-namespace'] }}-rust-sources-${{ runner.os }}-${{ runner.arch }}-${{ steps.prepare.outputs['cargo-lock-sha256'] }}
40-
restore-keys: |
41-
edgezero-app-${{ steps.prepare.outputs['cache-namespace'] }}-rust-sources-${{ runner.os }}-${{ runner.arch }}-
66+
key: ${{ steps.prepare.outputs['source-cache-key'] }}
67+
restore-keys: ${{ steps.prepare.outputs['source-cache-restore-prefix'] }}
4268

4369
- name: Restore Cargo target artifacts
44-
if: inputs['cache-target'] == 'true'
70+
id: cargo-target
71+
if: inputs['cache-target'] == 'true' && inputs['save-target'] == 'false' && steps.prepare.outputs.already-initialized != 'true'
72+
uses: actions/cache/restore@v5
73+
with:
74+
path: ${{ steps.prepare.outputs['cargo-target-dir'] }}
75+
key: ${{ steps.prepare.outputs['target-cache-key'] }}
76+
77+
# The combined action saves in its success-only post-job hook, after builds.
78+
- name: Restore Cargo targets and enable post-job save
79+
id: cargo-target-save
80+
if: inputs['cache-target'] == 'true' && inputs['save-target'] == 'true' && steps.prepare.outputs.already-initialized != 'true'
4581
uses: actions/cache@v5
4682
with:
4783
path: ${{ steps.prepare.outputs['cargo-target-dir'] }}
48-
key: edgezero-app-${{ steps.prepare.outputs['cache-namespace'] }}-rust-target-${{ runner.os }}-${{ runner.arch }}-${{ steps.prepare.outputs['cargo-lock-sha256'] }}
49-
restore-keys: |
50-
edgezero-app-${{ steps.prepare.outputs['cache-namespace'] }}-rust-target-${{ runner.os }}-${{ runner.arch }}-${{ steps.prepare.outputs['cargo-lock-sha256'] }}-
84+
key: ${{ steps.prepare.outputs['target-cache-key'] }}
5185

5286
- name: Set up Rust compiler cache
87+
id: sccache
88+
if: steps.prepare.outputs.already-initialized != 'true'
5389
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
5490
with:
5591
version: v0.16.0

‎.github/actions/setup-rust-build-cache/scripts/prepare.sh‎

Lines changed: 83 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ set -euo pipefail
44
# Validates the application and Rust workspace selected by
55
# setup-rust-build-cache, binds its cache keys to the application and Cargo.lock
66
# bytes, and enables sccache for every later Rust build in the current GitHub
7-
# Actions job. When target caching is enabled, it also publishes one confined,
8-
# application-scoped CARGO_TARGET_DIR beneath RUNNER_TEMP.
7+
# Actions job. Always publishes a stable, confined, application-scoped
8+
# CARGO_TARGET_DIR. Remote target artifacts are isolated by source revision.
99
#
1010
# This script reads no application manifest and runs no application code. The
1111
# action therefore remains independent of the package, adapter, and deployment
@@ -18,7 +18,18 @@ fail() {
1818

1919
canonical_path() {
2020
local path="$1"
21-
realpath "$path" 2>/dev/null || fail "could not resolve path '$path'"
21+
[[ ! "$path" =~ [[:cntrl:]] ]] || fail 'Rust cache paths must not contain control characters'
22+
local resolved
23+
# Preserve trailing newlines until validation, including in symlink targets.
24+
resolved=$(realpath "$path" 2>/dev/null && printf '.') || fail "could not resolve path '$path'"
25+
resolved=${resolved%.}
26+
resolved=${resolved%$'\n'}
27+
[[ ! "$resolved" =~ [[:cntrl:]] ]] || fail 'Resolved Rust cache paths must not contain control characters'
28+
printf '%s' "$resolved"
29+
}
30+
31+
append_output() {
32+
printf '%s=%s\n' "$1" "$2" >>"${GITHUB_OUTPUT:?}"
2233
}
2334

2435
is_under() {
@@ -40,10 +51,12 @@ sha256_file() {
4051

4152
main() {
4253
local workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}"
43-
local output_file="${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}"
54+
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}"
4455
local env_file="${GITHUB_ENV:?GITHUB_ENV is required}"
4556
local app_name="${EDGEZERO__RUST_CACHE__APP_NAME-}"
4657
local cache_target="${EDGEZERO__RUST_CACHE__CACHE_TARGET-false}"
58+
local save_target="${EDGEZERO__RUST_CACHE__SAVE_TARGET-false}"
59+
local revision="${EDGEZERO__RUST_CACHE__TARGET_CACHE_REVISION-}"
4760
local working_directory="${EDGEZERO__RUST_CACHE__WORKING_DIRECTORY-}"
4861

4962
[[ "$app_name" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] ||
@@ -52,6 +65,16 @@ main() {
5265
true | false) ;;
5366
*) fail "input 'cache-target' must be 'true' or 'false'" ;;
5467
esac
68+
case "$save_target" in
69+
true | false) ;;
70+
*) fail "input 'save-target' must be 'true' or 'false'" ;;
71+
esac
72+
[[ "$save_target" != true || "$cache_target" == true ]] ||
73+
fail "input 'save-target' requires cache-target true"
74+
if [[ "$cache_target" == true || -n "$revision" ]]; then
75+
[[ "$revision" =~ ^[0-9a-f]{40}$ || "$revision" =~ ^[0-9a-f]{64}$ ]] ||
76+
fail "input 'target-cache-revision' must be a full lowercase Git SHA (required when cache-target is true)"
77+
fi
5578
[[ -n "$working_directory" ]] ||
5679
fail "input 'working-directory' cannot be empty"
5780
[[ "$working_directory" != /* ]] ||
@@ -76,34 +99,67 @@ main() {
7699
[[ "$lock_sha" =~ ^[0-9a-f]{64}$ ]] ||
77100
fail "could not compute a lowercase SHA-256 for Cargo.lock"
78101

79-
{
80-
printf 'cache-namespace=%s\n' "$app_name"
81-
printf 'cargo-lock-sha256=%s\n' "$lock_sha"
82-
} >>"$output_file"
102+
local runner_temp="${RUNNER_TEMP:?RUNNER_TEMP is required}"
103+
[[ -d "$runner_temp" ]] || fail "RUNNER_TEMP does not exist or is not a directory"
104+
local runner_temp_real cache_root app_dir target_dir selection_file path
105+
runner_temp_real=$(canonical_path "$runner_temp")
106+
cache_root="$runner_temp_real/edgezero-rust-cache"
107+
app_dir="$cache_root/$app_name"
108+
target_dir="$app_dir/target"
109+
selection_file="$app_dir/selection"
110+
for path in "$cache_root" "$app_dir" "$target_dir" "$selection_file"; do
111+
[[ ! -L "$path" ]] || fail "Rust cache path must not be a symlink: '$path'"
112+
done
113+
for path in "$cache_root" "$app_dir" "$target_dir"; do
114+
[[ ! -e "$path" || -d "$path" ]] || fail "Rust cache path is not a directory: '$path'"
115+
done
116+
[[ ! -e "$selection_file" || -f "$selection_file" ]] || fail "Rust cache selection is not a regular file"
117+
118+
local ownership_revision="$revision" selection already_initialized=false
119+
if [[ -z "$ownership_revision" ]]; then
120+
ownership_revision=$(git -C "$project_real" rev-parse --verify HEAD 2>/dev/null || true)
121+
[[ "$ownership_revision" =~ ^[0-9a-f]{40}$ || "$ownership_revision" =~ ^[0-9a-f]{64}$ ]] || ownership_revision=''
122+
fi
123+
selection=$(printf '%s\n' "$project_real" "${ownership_revision:-uncached}" "$lock_sha" "$cache_target" "$save_target")
124+
if [[ -e "$selection_file" ]]; then
125+
[[ -n "$ownership_revision" ]] || fail "repeated non-Git cache setup requires target-cache-revision"
126+
[[ "$(<"$selection_file")" == "$selection" ]] ||
127+
fail "Rust target directory already belongs to a different workspace, revision, lockfile or cache settings"
128+
already_initialized=true
129+
elif [[ -d "$target_dir" ]]; then
130+
local target_contents
131+
# Quote control characters so newline-only names survive command substitution.
132+
target_contents=$(ls -Aq -- "$target_dir") || fail "could not inspect Rust target directory"
133+
[[ -z "$target_contents" ]] ||
134+
fail "Rust target directory contains unowned artifacts without a selection marker"
135+
fi
136+
137+
local runner_os="${RUNNER_OS:?RUNNER_OS is required}" runner_arch="${RUNNER_ARCH:?RUNNER_ARCH is required}"
138+
[[ "$runner_os" =~ ^[a-zA-Z0-9_-]+$ && "$runner_arch" =~ ^[a-zA-Z0-9_-]+$ ]] ||
139+
fail "runner OS and architecture must be safe cache-key components"
140+
local source_prefix source_key target_key=''
141+
source_prefix="edgezero-app-$app_name-rust-sources-$runner_os-$runner_arch-"
142+
source_key="$source_prefix$lock_sha"
143+
[[ -z "$revision" ]] || target_key="edgezero-app-$app_name-rust-target-v2-$runner_os-$runner_arch-$lock_sha-$revision-exact"
144+
145+
mkdir -p "$target_dir"
146+
target_dir=$(canonical_path "$target_dir")
147+
is_under "$runner_temp_real" "$target_dir" ||
148+
fail "Cargo target cache directory must resolve inside RUNNER_TEMP"
149+
printf '%s\n' "$selection" >"$selection_file"
150+
151+
append_output already-initialized "$already_initialized"
152+
append_output source-cache-key "$source_key"
153+
append_output source-cache-restore-prefix "$source_prefix"
154+
append_output target-cache-key "$target_key"
155+
append_output cargo-target-dir "$target_dir"
83156
{
84157
printf 'RUSTC_WRAPPER=sccache\n'
85158
printf 'SCCACHE_GHA_ENABLED=true\n'
86159
printf 'CARGO_INCREMENTAL=0\n'
160+
printf 'CARGO_TARGET_DIR=%s\n' "$target_dir"
161+
printf 'EDGEZERO__RUST_CACHE__TARGET_DIR=%s\n' "$target_dir"
87162
} >>"$env_file"
88-
89-
if [[ "$cache_target" == "true" ]]; then
90-
local runner_temp="${RUNNER_TEMP:?RUNNER_TEMP is required when cache-target is true}"
91-
[[ -d "$runner_temp" ]] || fail "RUNNER_TEMP does not exist or is not a directory"
92-
93-
local runner_temp_real target_dir
94-
runner_temp_real=$(canonical_path "$runner_temp")
95-
target_dir="$runner_temp_real/edgezero-rust-cache/$app_name/target"
96-
mkdir -p "$target_dir"
97-
target_dir=$(canonical_path "$target_dir")
98-
is_under "$runner_temp_real" "$target_dir" ||
99-
fail "Cargo target cache directory must resolve inside RUNNER_TEMP"
100-
101-
printf 'cargo-target-dir=%s\n' "$target_dir" >>"$output_file"
102-
{
103-
printf 'CARGO_TARGET_DIR=%s\n' "$target_dir"
104-
printf 'EDGEZERO__RUST_CACHE__TARGET_DIR=%s\n' "$target_dir"
105-
} >>"$env_file"
106-
fi
107163
}
108164

109165
main "$@"
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# Require native/WASM hits on the first builds of a fresh revision-specific
5+
# target directory. Only a previous job may have populated compiler outputs.
6+
fixture="${GITHUB_WORKSPACE:?}/cache-restore-fixture"
7+
target_dir="${CARGO_TARGET_DIR:?}"
8+
expected="$(realpath "${RUNNER_TEMP:?}")/edgezero-rust-cache/cache-restore-smoke-v2/target"
9+
[[ "$(realpath "$target_dir")" == "$expected" ]] || {
10+
printf '::error::compiler probe must use its confined fixture target directory\n' >&2
11+
exit 1
12+
}
13+
cd "$fixture"
14+
host=$(rustc -vV | sed -n 's/^host: //p')
15+
[[ -n "$host" ]] || exit 1
16+
for target in "$host" wasm32-wasip1; do
17+
[[ ! -e "$target_dir/$target/debug" ]] || {
18+
printf '::error::compiler probe target must be fresh before its first build\n' >&2
19+
exit 1
20+
}
21+
sccache --zero-stats
22+
cargo build --locked --lib --target "$target"
23+
stats=$(sccache --show-stats --stats-format json)
24+
printf 'Compiler reuse for %s: %s\n' "$target" "$stats"
25+
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 || {
26+
printf '::error::expected error-free Rust compiler-cache reuse on the first build for %s\n' "$target" >&2
27+
exit 1
28+
}
29+
done
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/usr/bin/env bash
2+
# Shared assertions for local cache contracts; callers own fixtures and cleanup.
3+
fail() { printf 'Rust cache test failed: %s\n' "$*" >&2; exit 1; }
4+
assert_line() { grep -Fqx -- "$2" "$1" || fail "expected '$2' in $1"; }

0 commit comments

Comments
 (0)