diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59b9a73ec9b..a87ea957cd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' }} diff --git a/.github/workflows/mesh-lifecycle.yml b/.github/workflows/mesh-lifecycle.yml index b5f9660dfe3..c699439980b 100644 --- a/.github/workflows/mesh-lifecycle.yml +++ b/.github/workflows/mesh-lifecycle.yml @@ -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' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc8147515a1..187604fcf4c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/renovate.json b/renovate.json index ee07aae898d..91bd5fcc837 100644 --- a/renovate.json +++ b/renovate.json @@ -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"], diff --git a/scripts/test-rust-cache-contract-regressions.sh b/scripts/test-rust-cache-contract-regressions.sh new file mode 100755 index 00000000000..708c26e77be --- /dev/null +++ b/scripts/test-rust-cache-contract-regressions.sh @@ -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" < "$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" diff --git a/scripts/test-rust-cache-contract.sh b/scripts/test-rust-cache-contract.sh new file mode 100755 index 00000000000..30abd026597 --- /dev/null +++ b/scripts/test-rust-cache-contract.sh @@ -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"