fix(ci): prevent poisoned Rust caches (#6618)

## Summary

- roll every `Swatinem/rust-cache` use back from v2.9.2 to the last
known-good v2.9.1 digest
- give Unit Tests a new `sherpa-cache-v1` key so it cannot restore the
existing poisoned artifact
- pin Renovate to v2.9.1 and add CI contracts that reject unsafe cache
actions or a misplaced generation key

## Why

After #5441 upgraded rust-cache to v2.9.2, warm-cache `main` Unit Tests
runs began failing while linking `buzz-voice` with `could not find
native static library sherpa-onnx-c-api`. The failed run at `db5617dd1`
restored the same 1.4 KB cache generation that had already failed at
`01091c15a`; the preceding cold run at `26f4c3ed3` downloaded sherpa
1.13.4 and passed.

v2.9.2 changed target cleanup, while `sherpa-onnx-sys` treats its
prebuilt `lib/` directory as proof that the native archive exists.
Rolling back the action and invalidating the affected key removes both
sides of that failure state without disabling target caching.

## Validation

At `6da0037a0407fc498cd482fcbbb74c7a15907e9f`:

- `scripts/test-rust-cache-contract.sh`
- `scripts/test-rust-cache-contract-regressions.sh`
- negative fixtures reject a bad digest in a newly named `.yaml`
workflow and a generation key moved outside the cache action's `with`
block
- YAML parse for all workflows
- release, desktop candidate, mobile release, mobile candidate, and
mobile worktree source contracts
- `just file-size-check`
- pre-commit and pre-push hooks

The PR Unit Tests run proves the cold-cache path because pull requests
restore but do not save Rust caches. The first successful `main` run
after merge will save the new Unit Tests key; the following `main` run
will exercise the warm restore.

## Related issue

None found.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
This commit is contained in:
Wes
2026-08-24 16:21:31 -06:00
committed by GitHub
parent 02dc49f0e6
commit 69b1225923
6 changed files with 304 additions and 10 deletions

View File

@@ -87,6 +87,10 @@ jobs:
scripts/test-mobile-release-candidate-publisher.sh
- name: Mobile worktree identity contract
run: scripts/test-mobile-worktree-overrides.sh
- name: Rust cache contract
run: |
scripts/test-rust-cache-contract.sh
scripts/test-rust-cache-contract-regressions.sh
- name: File size policy
run: just file-size-check
@@ -101,7 +105,7 @@ jobs:
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
with:
save-if: ${{ github.event_name != 'pull_request' }}
- name: Format check
@@ -123,8 +127,9 @@ jobs:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
with:
key: sherpa-cache-v1
save-if: ${{ github.event_name != 'pull_request' }}
- name: Install cargo-nextest
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
@@ -147,7 +152,7 @@ jobs:
fetch-depth: 2
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
with:
workspaces: desktop/src-tauri
save-if: ${{ github.event_name != 'pull_request' }}
@@ -340,7 +345,7 @@ jobs:
key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.github/workflows/ci.yml') }}
- uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1
if: steps.relay-artifacts-cache.outputs.cache-hit != 'true'
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
if: steps.relay-artifacts-cache.outputs.cache-hit != 'true'
with:
workspaces: |
@@ -763,7 +768,7 @@ jobs:
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
with:
save-if: ${{ github.event_name != 'pull_request' }}
# Reuse the relay + git-credential-nostr built by Desktop E2E Relay
@@ -952,7 +957,7 @@ jobs:
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
with:
key: cross-${{ matrix.target }}
save-if: ${{ github.event_name != 'pull_request' }}
@@ -993,7 +998,7 @@ jobs:
# toolchain (1.95.0 + clippy via profile = default) comes from the
# repo-root rust-toolchain.toml, which the runner's preinstalled rustup
# honors on demand; the host triple already is x86_64-pc-windows-msvc.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
with:
workspaces: |
.
@@ -1070,7 +1075,7 @@ jobs:
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
with:
workspaces: desktop/src-tauri
save-if: ${{ github.event_name != 'pull_request' }}

View File

@@ -55,7 +55,7 @@ jobs:
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
with:
save-if: ${{ github.event_name != 'pull_request' }}

View File

@@ -511,7 +511,7 @@ jobs:
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1
with:
workspaces: desktop/src-tauri
lookup-only: true

View File

@@ -23,6 +23,12 @@
"separateMinorPatch": true,
"separateMultipleMinor": false
},
{
"description": "rust-cache v2.9.2 cleanup can preserve sherpa's prebuilt directory after removing its static archive, poisoning warm Unit Tests caches. Keep v2.9.1 until the action or sherpa validates the archive before reuse.",
"matchManagers": ["github-actions"],
"matchPackageNames": ["Swatinem/rust-cache"],
"allowedVersions": "<=2.9.1"
},
{
"description": "evalexpr v13 relicensed from MIT to AGPL-3.0 — pin below v13 until migrated to an MIT alternative.",
"matchPackageNames": ["evalexpr"],

View File

@@ -0,0 +1,161 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
known_good='e18b497796c12c097a38f9edb9d0641fb99eee32'
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/.github/workflows"
cp "$root"/.github/workflows/*.yml "$tmp/.github/workflows/"
for workflow in "$root"/.github/workflows/*.yaml; do
[[ -e "$workflow" ]] || continue
cp "$workflow" "$tmp/.github/workflows/"
done
cp "$root/renovate.json" "$tmp/renovate.json"
run_contract() {
BUZZ_RUST_CACHE_CONTRACT_ROOT="$tmp" "$root/scripts/test-rust-cache-contract.sh"
}
expect_failure() {
local expected=$1
local output
if output=$(run_contract 2>&1); then
echo "expected rust cache contract failure containing: $expected" >&2
exit 1
fi
if [[ "$output" != *"$expected"* ]]; then
printf 'unexpected rust cache contract error:\n%s\n' "$output" >&2
exit 1
fi
}
write_cache_workflow() {
cat > "$tmp/.github/workflows/new-cache-user.yaml"
}
run_contract >/dev/null
write_cache_workflow <<'YAML'
name: Bad digest
on: workflow_dispatch
jobs:
cache:
runs-on: ubuntu-latest
steps:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
YAML
expect_failure 'restored rust-cache v2.9.2'
write_cache_workflow <<'YAML'
name: Mutable tag
on: workflow_dispatch
jobs:
cache:
runs-on: ubuntu-latest
steps:
- uses: Swatinem/rust-cache@v2
YAML
expect_failure 'rust-cache must stay on the v2.9.1 digest'
write_cache_workflow <<'YAML'
name: Expression ref
on: workflow_dispatch
jobs:
cache:
runs-on: ubuntu-latest
steps:
- uses: Swatinem/rust-cache@${{ matrix.cache-ref }}
YAML
expect_failure 'rust-cache must stay on the v2.9.1 digest'
write_cache_workflow <<'YAML'
name: Spaced key
on: workflow_dispatch
jobs:
cache:
runs-on: ubuntu-latest
steps:
- uses : Swatinem/rust-cache@v2
YAML
expect_failure 'rust-cache must stay on the v2.9.1 digest'
write_cache_workflow <<'YAML'
name: Flow mapping
on: workflow_dispatch
jobs:
cache:
runs-on: ubuntu-latest
steps:
- { uses: Swatinem/rust-cache@v2 }
YAML
expect_failure 'rust-cache must stay on the v2.9.1 digest'
write_cache_workflow <<'YAML'
name: Quoted key
on: workflow_dispatch
jobs:
cache:
runs-on: ubuntu-latest
steps:
- "uses": Swatinem/rust-cache@v2
YAML
expect_failure 'rust-cache must stay on the v2.9.1 digest'
rm "$tmp/.github/workflows/new-cache-user.yaml"
mkdir -p "$tmp/.github/actions/cache"
cat > "$tmp/.github/actions/cache/action.yml" <<'YAML'
name: Unsafe cache wrapper
runs:
using: composite
steps:
- { uses: Swatinem/rust-cache@v2 }
YAML
write_cache_workflow <<'YAML'
name: Local cache wrapper
on: workflow_dispatch
jobs:
cache:
runs-on: ubuntu-latest
steps:
- uses: ./.github/actions/cache
YAML
expect_failure 'rust-cache must stay on the v2.9.1 digest'
cat > "$tmp/.github/actions/cache/action.yml" <<YAML
name: Safe preferred cache wrapper
runs:
using: composite
steps:
- uses: Swatinem/rust-cache@$known_good
YAML
cat > "$tmp/.github/actions/cache/action.yaml" <<'YAML'
name: Unreachable stale cache wrapper
runs:
using: composite
steps:
- uses: Swatinem/rust-cache@v2
YAML
run_contract >/dev/null
rm -rf "$tmp/.github/actions" "$tmp/.github/workflows/new-cache-user.yaml"
python3 - "$tmp/.github/workflows/ci.yml" <<'PY'
import pathlib
import sys
path = pathlib.Path(sys.argv[1])
workflow = path.read_text()
needle = " key: sherpa-cache-v1\n save-if:"
replacement = " save-if:"
if workflow.count(needle) != 1:
raise SystemExit("expected one Unit Tests cache generation key")
workflow = workflow.replace(needle, replacement)
job = " unit-tests:\n"
if workflow.count(job) != 1:
raise SystemExit("expected one Unit Tests job")
workflow = workflow.replace(job, job + " env:\n key: sherpa-cache-v1\n", 1)
path.write_text(workflow)
PY
expect_failure 'rust-cache action must keep with.key set to sherpa-cache-v1'
echo "rust cache contract regressions passed"

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env bash
set -euo pipefail
root=${BUZZ_RUST_CACHE_CONTRACT_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}
renovate="$root/renovate.json"
known_good='e18b497796c12c097a38f9edb9d0641fb99eee32'
known_bad='6323deb102c322ba6fcbdcafc7e3dddab59af2b6'
ruby - "$root" "$renovate" "$known_good" "$known_bad" <<'RUBY'
require "json"
require "pathname"
require "yaml"
root_name, renovate_name, known_good, known_bad = ARGV
root = Pathname(root_name)
workflow_paths = (root / ".github" / "workflows").children
.select { |path| path.file? && [".yml", ".yaml"].include?(path.extname) }
.sort
abort "no GitHub Actions workflows found" if workflow_paths.empty?
load_workflow = lambda do |path|
workflow = YAML.safe_load(path.binread.force_encoding("UTF-8"), aliases: true)
abort "#{path}: workflow must be a YAML mapping" unless workflow.is_a?(Hash)
workflow
rescue Psych::Exception => error
abort "#{path}: invalid workflow YAML: #{error.message}"
end
uses_values = []
walk = nil
walk = lambda do |value, path, location|
case value
when Hash
value.each do |key, child|
child_location = "#{location}.#{key}"
uses_values << [path, child_location, child] if key.to_s == "uses"
walk.call(child, path, child_location)
end
when Array
value.each_with_index do |child, index|
walk.call(child, path, "#{location}[#{index}]")
end
end
end
workflows = workflow_paths.to_h do |path|
workflow = load_workflow.call(path)
walk.call(workflow, path, "$")
[path, workflow]
end
loaded_paths = workflow_paths.to_h { |path| [path.cleanpath, true] }
uses_index = 0
while uses_index < uses_values.length
coordinate = uses_values[uses_index][2]
uses_index += 1
next unless coordinate.is_a?(String) && coordinate.start_with?("./")
clean_root = root.realpath
target = (clean_root / coordinate.delete_prefix("./")).cleanpath
resolved_target = target.exist? ? target.realpath : target
root_prefix = "#{clean_root}#{File::SEPARATOR}"
inside_root = resolved_target == clean_root || resolved_target.to_s.start_with?(root_prefix)
abort "local action path escapes contract root: #{coordinate}" unless inside_root
next unless target.directory?
manifest = [target / "action.yml", target / "action.yaml"].find(&:file?)
abort "local action manifest missing: #{coordinate}" unless manifest
clean_manifest = manifest.cleanpath
next if loaded_paths[clean_manifest]
loaded_paths[clean_manifest] = true
action = load_workflow.call(clean_manifest)
walk.call(action, clean_manifest, "$")
end
cache_uses = uses_values.each_with_object([]) do |(path, location, coordinate), found|
next unless coordinate.is_a?(String)
match = /\ASwatinem\/rust-cache@(.+)\z/i.match(coordinate)
found << [path, location, match[1]] if match
end
abort "no Swatinem/rust-cache actions found" if cache_uses.empty?
cache_uses.each do |path, location, ref|
if ref == known_bad
abort "#{path}:#{location}: restored rust-cache v2.9.2, which poisons sherpa caches"
end
unless ref == known_good
abort "#{path}:#{location}: rust-cache must stay on the v2.9.1 digest"
end
end
ci_path = root / ".github" / "workflows" / "ci.yml"
ci = workflows.fetch(ci_path) { load_workflow.call(ci_path) }
jobs = ci["jobs"]
unit_tests = jobs.is_a?(Hash) ? jobs["unit-tests"] : nil
abort "unit-tests job missing" unless unit_tests.is_a?(Hash)
steps = unit_tests["steps"]
abort "Unit Tests steps missing" unless steps.is_a?(Array)
cache_steps = steps.select do |step|
step.is_a?(Hash) && step["uses"].is_a?(String) &&
step["uses"].match?(/\ASwatinem\/rust-cache@/i)
end
abort "Unit Tests must contain exactly one rust-cache action step" unless cache_steps.length == 1
cache_with = cache_steps.first["with"]
unless cache_with.is_a?(Hash) && cache_with["key"] == "sherpa-cache-v1"
abort "Unit Tests rust-cache action must keep with.key set to sherpa-cache-v1"
end
renovate = JSON.parse(Pathname(renovate_name).binread.force_encoding("UTF-8"))
rules = renovate.fetch("packageRules", []).select do |rule|
rule["matchManagers"] == ["github-actions"] &&
rule["matchPackageNames"] == ["Swatinem/rust-cache"]
end
unless rules.length == 1 && rules.first["allowedVersions"] == "<=2.9.1"
abort "Renovate must keep Swatinem/rust-cache at v2.9.1 or older"
end
RUBY
echo "rust cache contract passed"