diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index d27fd39f..9cdd2897 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -2,19 +2,37 @@ name: Shared Setup description: Install mise, Rust toolchain, and development dependencies inputs: cache-save: - description: Whether this job is the designated cache saver (others are restore-only) + description: Whether this job is a designated cache saver (others are restore-only) default: "false" cache-shared-key: - description: rust-cache shared-key so jobs share one dependency cache; omit to key by job + description: Namespace so related jobs share one R2 Rust cache; omit for the default namespace default: "" + rust-cache: + description: Restore the R2 Rust cache; set false for jobs that do not compile + default: "true" runs: using: "composite" steps: - uses: jdx/mise-action@v4 - - uses: Swatinem/rust-cache@v2 - with: - shared-key: ${{ inputs.cache-shared-key }} - save-if: ${{ inputs.cache-save == 'true' && github.ref_type != 'tag' }} + - name: Configure Rust cache + if: inputs.rust-cache == 'true' + shell: bash + run: |- + echo "POD_RUST_CACHE_SHARED_KEY=${{ inputs.cache-shared-key }}" >> "$GITHUB_ENV" + # Only main / *-dev / tags may write shared keys, so a bad PR branch (or a + # fork, which has no secrets) can never poison the cache — restore-only. + if [[ "${{ inputs.cache-save }}" == "true" \ + && ( "$GITHUB_REF" == "refs/heads/main" \ + || "$GITHUB_REF_NAME" == *-dev \ + || "$GITHUB_REF_TYPE" == "tag" ) ]]; then + echo "POD_RUST_CACHE_SAVE=1" >> "$GITHUB_ENV" + fi + - name: Restore Rust cache (R2) + if: inputs.rust-cache == 'true' + shell: bash + env: + POD_RUST_CACHE_SHARED_KEY: ${{ inputs.cache-shared-key }} + run: mise run ci:rust-cache restore - if: runner.os == 'Linux' shell: bash run: |- diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4b73820d..e488650e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,10 @@ on: pull_request: branches: - main +env: + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} jobs: lint: runs-on: ubuntu-latest @@ -22,6 +26,10 @@ jobs: cache-save: "true" - name: Lint run: mise run lint + - name: Save Rust cache (R2) + if: ${{ always() && env.POD_RUST_CACHE_SAVE == '1' }} + shell: bash + run: mise run ci:rust-cache save check: strategy: fail-fast: false @@ -128,6 +136,10 @@ jobs: - name: Build if: runner.os != 'Linux' run: cargo build --all-features + - name: Save Rust cache (R2) + if: ${{ always() && env.POD_RUST_CACHE_SAVE == '1' }} + shell: bash + run: mise run ci:rust-cache save # Swatinem/rust-cache bakes the Cargo.lock hash into each key and NEVER deletes older # entries, so lockfile churn thrashes the 10 GB per-repo cap and evicts warm caches. # Restore falls back to the NEWEST entry sharing the (shared-key, env, rust/OS) prefix, diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30cfed26..40f8532b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,11 @@ on: tags: - "[0-9]+.*" +env: + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + jobs: wait-for-build: runs-on: ubuntu-latest @@ -81,14 +86,12 @@ jobs: generate-icons: needs: wait-for-build runs-on: ubuntu-latest - env: - R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} - R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} steps: - uses: actions/checkout@v7 - uses: ./.github/actions/setup + with: + rust-cache: "false" - name: Generate item icons run: mise run generate:item-images-cache @@ -115,6 +118,9 @@ jobs: - uses: actions/checkout@v7 - uses: ./.github/actions/setup + with: + cache-save: "true" + cache-shared-key: v0-pkg - name: Download item icons uses: actions/download-artifact@v8 @@ -243,6 +249,11 @@ jobs: **/*.app.tar.gz **/*.app.tar.gz.sig + - name: Save Rust cache (R2) + if: ${{ always() && env.POD_RUST_CACHE_SAVE == '1' }} + shell: bash + run: mise run ci:rust-cache save + package-windows: needs: - wait-for-build @@ -259,6 +270,9 @@ jobs: - uses: actions/checkout@v7 - uses: ./.github/actions/setup + with: + cache-save: "true" + cache-shared-key: v0-pkg - name: Download item icons uses: actions/download-artifact@v8 @@ -324,6 +338,11 @@ jobs: **/*.msi **/*.exe.sig + - name: Save Rust cache (R2) + if: ${{ always() && env.POD_RUST_CACHE_SAVE == '1' }} + shell: bash + run: mise run ci:rust-cache save + package-linux: needs: - wait-for-build @@ -340,6 +359,9 @@ jobs: - uses: actions/checkout@v7 - uses: ./.github/actions/setup + with: + cache-save: "true" + cache-shared-key: v0-pkg - name: Download item icons uses: actions/download-artifact@v8 @@ -511,6 +533,11 @@ jobs: ${{ env.TARBALL }} dev.aaronmallen.pod.flatpak + - name: Save Rust cache (R2) + if: ${{ always() && env.POD_RUST_CACHE_SAVE == '1' }} + shell: bash + run: mise run ci:rust-cache save + publish-release: needs: - wait-for-build diff --git a/scripts/ci/rust-cache b/scripts/ci/rust-cache new file mode 100755 index 00000000..3b2cd1a7 --- /dev/null +++ b/scripts/ci/rust-cache @@ -0,0 +1,97 @@ +#!/usr/bin/env sh +#MISE description="Restore or save the Rust build cache (cargo home + target) in R2, keyed on OS + rustc + Cargo.lock" + +set -e + +shared="${POD_RUST_CACHE_SHARED_KEY:-}" +[ -n "${shared}" ] || shared="default" + +os="$(printf '%s' "${RUNNER_OS:-$(uname -s)}" | tr '[:upper:]' '[:lower:]')" +rustc_id="$(rustc -V | git hash-object --stdin | cut -c1-12)" +lock_id="$(git hash-object Cargo.lock | cut -c1-16)" + +dir="rust-cache/${shared}" +name_prefix="${os}-${rustc_id}-" +key_base="${name_prefix}${lock_id}" + +cargo_home="${CARGO_HOME:-${HOME}/.cargo}" + +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT + +# The cargo home (registry + git) and target/ have different roots, so each is a +# separate object tarred relative to its own base — no absolute paths, portable +# across the Linux, macOS, and Windows (git-bash) runners. +pick_newest() { + part="$1" + mise run ci:r2 list "${dir}/" 2>/dev/null | + awk -F';' -v pfx="${name_prefix}" -v sfx=".${part}.tar.gz" \ + 'index($2, pfx) == 1 && substr($2, length($2) - length(sfx) + 1) == sfx' | + sort -r | head -1 | cut -d';' -f2 +} + +restore_part() { + part="$1" + base="$2" + exact="${dir}/${key_base}.${part}.tar.gz" + tarball="${work}/${part}.tar.gz" + + if mise run ci:r2 get "${exact}" "${tarball}"; then + echo "rust-cache: restored ${part} (exact Cargo.lock ${lock_id})" + else + name="$(pick_newest "${part}")" + if [ -z "${name}" ]; then + echo "rust-cache: no ${part} cache — cold" + return 0 + fi + if ! mise run ci:r2 get "${dir}/${name}" "${tarball}"; then + echo "rust-cache: ${part} fallback fetch failed — cold" + return 0 + fi + echo "rust-cache: restored ${part} via fallback ${name}" + fi + + mkdir -p "${base}" + tar -xzf "${tarball}" -C "${base}" || echo "rust-cache: ${part} extract failed — continuing cold" + rm -f "${tarball}" +} + +save_part() { + part="$1" + base="$2" + shift 2 + members="" + for m in "$@"; do + if [ -d "${base}/${m}" ]; then + members="${members} ${m}" + fi + done + if [ -z "${members}" ]; then + echo "rust-cache: nothing to save for ${part}" + return 0 + fi + + tarball="${work}/${part}.tar.gz" + # shellcheck disable=SC2086 + tar -czf "${tarball}" -C "${base}" ${members} + mise run ci:r2 put "${tarball}" "${dir}/${key_base}.${part}.tar.gz" + echo "rust-cache: saved ${part} (${members# })" + rm -f "${tarball}" +} + +case "${1:-}" in +restore) + echo "rust-cache: key ${dir}/${key_base}" + restore_part cargo "${cargo_home}" + restore_part target "$(pwd)" + ;; +save) + echo "rust-cache: key ${dir}/${key_base}" + save_part cargo "${cargo_home}" registry git + save_part target "$(pwd)" target + ;; +*) + echo "usage: rust-cache " >&2 + exit 2 + ;; +esac