From 44f690682aa8efab9602b1434f16d43efb69f0d9 Mon Sep 17 00:00:00 2001 From: Chris Hodapp Date: Mon, 7 Sep 2026 17:46:57 -0700 Subject: [PATCH] ci: cached checks, cachix push, and the cache declared for consumers The two-job check.yml becomes the three-file set the other public repositories run: main's copy with the cached store, the two-hop trusted build for pull requests, and the push of the kept roots' closure to the clhodapp cache; the request's own copy, building only when .github/ changed; and the close-time cache cleanup. nixConfig declares the cache and its upstreams for direct evaluation, and the README states the three substituters and keys a consumer needs. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/cache-cleanup.yml | 33 +++ .github/workflows/check-pr.yml | 198 +++++++++++++++ .github/workflows/check.yml | 380 +++++++++++++++++++++++++--- README.md | 26 ++ flake.nix | 20 ++ 5 files changed, 621 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/cache-cleanup.yml create mode 100644 .github/workflows/check-pr.yml diff --git a/.github/workflows/cache-cleanup.yml b/.github/workflows/cache-cleanup.yml new file mode 100644 index 0000000..ccc89a5 --- /dev/null +++ b/.github/workflows/cache-cleanup.yml @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: MIT +# +# GitHub allows 10 GB of Actions cache per repository and evicts by last +# access once that is exceeded. main keeps one entry (check.yml purges +# the older ones after each save), but a pull request that changes the +# pipeline saves entries under its own merge ref, and those would sit +# for seven days after the request closes. Dropping them at close time +# keeps the total near one entry, so main's is never the one evicted. +# +# Triggered through `pull_request_target`, so the copy that runs is +# main's: this job holds a token that can delete cache entries, and a +# request must not be able to edit what it deletes. Nothing from the +# request's tree runs here. + +name: cache-cleanup + +on: + pull_request_target: + types: [closed] + +permissions: + actions: write + +jobs: + cleanup: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Delete the cache entries scoped to this pull request + env: + GH_TOKEN: ${{ github.token }} + REF: refs/pull/${{ github.event.pull_request.number }}/merge + run: gh cache delete --all --ref "$REF" --succeed-on-no-caches --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/check-pr.yml b/.github/workflows/check-pr.yml new file mode 100644 index 0000000..9d6a0bc --- /dev/null +++ b/.github/workflows/check-pr.yml @@ -0,0 +1,198 @@ +# SPDX-License-Identifier: MIT +# +# The pull request's own check: this file is the pull request's copy, +# run in the pull request's context, where any cache it saves is +# scoped to the request's merge ref and can be restored only by that +# request's later runs, never by main. +# +# check.yml (main's copy, triggered by this run's completion) builds +# every pull request that leaves the pipeline alone, so this run +# succeeds at once for those. A request that changes .github/ is +# exactly the one check.yml declines to build, and the only one whose +# new pipeline needs exercising; it is built here, restoring main's +# entry and saving under its own scope. Between the two files, each +# push is built exactly once. +# +# The roots and the priority pruning are the same as in check.yml, with +# one difference: this run's token cannot delete cache entries, so it +# never touches another scope's and does not purge its own; entries in +# a request's scope go when the request closes (cache-cleanup.yml) or +# when GitHub expires them. + +name: check-pr + +on: + pull_request: + +permissions: + contents: read + pull-requests: read + actions: read + +concurrency: + group: check-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check-pr: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Decide whether this run builds + id: guard + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + if gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate \ + --jq '.[].filename' | grep -q '^\.github/'; then + echo "build=true" >> "$GITHUB_OUTPUT" + else + echo "This pull request leaves the pipeline alone; check.yml builds it." + echo "build=false" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + if: steps.guard.outputs.build == 'true' + with: + persist-credentials: false + + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31 + if: steps.guard.outputs.build == 'true' + with: + github_access_token: ${{ secrets.GITHUB_TOKEN }} + extra_nix_config: | + experimental-features = nix-command flakes + sandbox = true + + # Restores the newest entry this run can see (its own earlier one, + # else main's); saves under this request's scope, one entry per + # head commit. cache-cleanup.yml removes them all when the request + # closes. The cap of zero makes the pruning before the save remove + # every path the roots do not reach. + - uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7 + if: steps.guard.outputs.build == 'true' + with: + primary-key: nix-${{ runner.os }}-${{ github.event.pull_request.head.sha }} + restore-prefixes-first-match: nix-${{ runner.os }}- + gc-max-store-size-linux: 0 + purge: false + + # Pull from the clhodapp cache; a public cache needs no credentials + # to read, and this run has none. Nothing is pushed from here. + - uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17 + if: steps.guard.outputs.build == 'true' + with: + name: clhodapp + skipPush: true + # The clhodapp cache's upstreams, which a user of it pulls + # from too. + extraPullNames: nix-community,numtide + + - name: Run the flake checks + if: steps.guard.outputs.build == 'true' + run: nix flake check --print-build-logs + + - name: Root this build and apply the cache priorities + if: steps.guard.outputs.build == 'true' + env: + GH_TOKEN: ${{ github.token }} + ROOT_CLASS: pr + ROOT_ID: ${{ format('{0}/{1}', github.event.pull_request.number, github.event.pull_request.head.sha) }} + CURRENT_MAIN: ${{ github.event.pull_request.base.sha }} + OWN_REF: ${{ github.ref }} + KEY_PREFIX: nix-${{ runner.os }}- + MAY_DELETE: "false" + run: | + set -euo pipefail + # The persistent root tree, under /nix but outside + # /nix/var/nix, which the cache action excludes apart from the + # database; mirrored into Nix's root directory at the end. + roots=/nix/ci-roots + + # 1. Root what this run built. + own="$roots/$ROOT_CLASS/$ROOT_ID" + sudo rm -rf "$own" + sudo mkdir -p "$own" + # Checks and packages both: a check's output need not reference + # what it built, and the pruning keeps only what the roots reach. + # An output the flake does not provide is skipped; any other + # evaluation error fails. + for kind in checks packages; do + if ! names=$(nix eval --json ".#$kind.x86_64-linux" --apply builtins.attrNames 2>"$RUNNER_TEMP/eval.err"); then + grep -q 'does not provide attribute' "$RUNNER_TEMP/eval.err" || { cat "$RUNNER_TEMP/eval.err"; exit 1; } + continue + fi + for name in $(jq -r '.[]' <<<"$names"); do + out=$(nix build --no-link --print-out-paths ".#$kind.x86_64-linux.$name") + sudo ln -s "$out" "$own/$kind-$name" + done + done + + # 2. Order every root directory, highest priority first. + open=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 500 --json number --jq '.[].number') + is_open() { grep -qx "$1" <<<"$open"; } + # Entry names under a directory, newest first by modification time. + newest_in() { + [[ -d "$1" ]] || return 0 + find "$1" -mindepth 1 -maxdepth 1 -printf '%T@ %f\n' | sort -rn | awk '{print $2}' + } + ordered=() + [[ -d "$roots/main/$CURRENT_MAIN" ]] && ordered+=("$roots/main/$CURRENT_MAIN") + shopt -s nullglob + for prdir in "$roots"/pr/*/; do + n=$(basename "$prdir") + newest=$(newest_in "$prdir" | head -n1) + for sha in "$prdir"/*/; do + [[ "$(basename "$sha")" == "$newest" ]] || sudo rm -rf "$sha" + done + is_open "$n" && ordered+=("$prdir$newest") + done + for sha in $(newest_in "$roots/main"); do + [[ "$sha" == "$CURRENT_MAIN" ]] || ordered+=("$roots/main/$sha") + done + for prdir in "$roots"/pr/*/; do + n=$(basename "$prdir") + is_open "$n" || ordered+=("$prdir$(newest_in "$prdir" | head -n1)") + done + + # 3. The budget, as in check.yml. + gib=$((1024 * 1024 * 1024)) + caches=$(gh cache list --repo "$GITHUB_REPOSITORY" --limit 1000 --json key,ref,sizeInBytes) + used_by_own_scope=$(jq --arg ref "$OWN_REF" --arg p "$KEY_PREFIX" \ + '[.[] | select(.ref == $ref and (.key | startswith($p))) | .sizeInBytes] | add // 0' <<<"$caches") + used_by_others=$(jq --arg ref "$OWN_REF" \ + '[.[] | select(.ref != $ref) | .sizeInBytes] | add // 0' <<<"$caches") + budget=$((10 * gib - used_by_own_scope - used_by_others)) + echo "Budget for this entry: $budget bytes ($used_by_others used by other scopes, $used_by_own_scope by this one)." + + closure_size() { + local targets + targets=$(for d in "$@"; do find "$d" -maxdepth 1 -type l -exec readlink {} +; done) + [[ -n "$targets" ]] || { echo 0; return; } + # shellcheck disable=SC2086 + nix path-info -r $targets | sort -u | xargs nix path-info -s | awk '{s += $2} END {print s + 0}' + } + + # 4. Keep the highest-priority roots that fit, drop the rest. + # This run deletes nothing outside its own scope; if not + # even the current main fits, it is saved anyway and + # GitHub's own eviction is the backstop. + kept=() + for d in "${ordered[@]}"; do + size=$(closure_size "${kept[@]}" "$d") + if (( size <= budget )) || (( ${#kept[@]} == 0 )); then + (( size <= budget )) || echo "::warning::The current main build ($size bytes) exceeds the cache budget ($budget bytes); saving it anyway." + kept+=("$d") + continue + fi + echo "Dropping roots $d (closure would be $size bytes, budget $budget)." + sudo rm -rf "$d" + done + echo "Kept roots:" + printf ' %s\n' "${kept[@]}" + + # 5. Mirror the surviving tree into Nix's root directory so the + # pruning before the save honours it. + sudo rm -rf /nix/var/nix/gcroots/ci + sudo cp -a "$roots" /nix/var/nix/gcroots/ci diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index cf3d14f..6e73aa6 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,63 +1,371 @@ -# Build and test flake-sync on every push and pull request. +# SPDX-License-Identifier: MIT # -# Two jobs so a syntax or formatting mistake reports in under a minute -# instead of behind the VM boot: `package` builds the derivation (which -# shellchecks the script) and runs the formatter check, while `vm` -# drives the packaged executable through the full convergence lifecycle -# against a fake GitHub of local bare repos. Both are subsets of -# `nix flake check`, which is what a contributor runs locally. +# The trusted check: it runs on pushes to main and, for pull requests, +# as a dispatched run carrying the request's head. It is the run that +# reads and writes the build cache scoped to main. A cold runner builds +# the package and the offline NixOS VM test's closure, then boots the VM +# through the whole convergence lifecycle; with the cache restored, a +# run builds only what changed, and the VM boot is the bulk of what +# remains. +# +# Only a few triggers may write the cache in main's scope (push, +# workflow_dispatch, repository_dispatch, schedule and a few more); a +# run reached through workflow_run or pull_request_target gets a +# read-only cache token even in main's context. So a pull request takes +# two hops, both executing main's copy of this file: the `dispatch` job +# fires when the request's check-pr run completes, applies the guard, +# and dispatches this workflow on main with the request's head SHA and +# number as inputs; the `check` job then runs as a workflow_dispatch +# with a writable token. The request cannot edit the guard, the inputs +# name a commit rather than a branch, and the only command that runs +# from the checkout is `nix flake check` (pure evaluation, sandboxed +# builds). The one route from a request to the runner's store outside +# the sandbox is this pipeline itself, so a request that touches +# .github/ gets no build here; check-pr.yml builds it instead, under +# its own cache scope, which main can never restore. The result is +# posted to the request's head commit as the "check" status. +# +# One cache entry, holding a union of builds. What stays in it is +# decided by garbage-collection roots kept in a tree the cache carries, +# /nix/ci-roots, and mirrored into Nix's root directory before each +# save: main/ for main's builds, pr// for requests'. +# Each run adds its own, drops the lowest-priority roots until the +# closure fits the repository's 10 GB of cache, and then every +# unreachable path goes. The priority order, highest first: the current +# main; each open request's newest build; older mains; closed requests. +# Requests that changed the pipeline have entries of their own in their +# scope, which this run only ever deletes, closed ones first, when +# space is short. Older entries in this scope are removed by the +# `prune` job, after the new entry is confirmed to exist, never before. name: check on: push: branches: [main] - pull_request: + workflow_run: + workflows: [check-pr] + types: [completed] workflow_dispatch: + inputs: + head: + description: Commit to check (a pull request's head); main's own when empty + required: false + type: string + pr: + description: The pull request that head belongs to, if any + required: false + type: string +# `actions: write` dispatches this workflow and deletes cache entries; +# `statuses: write` reports the result on a request's head commit. permissions: contents: read + pull-requests: read + actions: write + statuses: write +# Every run that writes main's cache entry (a push, or a dispatched +# build of a request) waits for the previous one: two writers at once +# each restore the same entry and the last save wins, dropping the +# other's roots. The dispatch hop has its own group per request, so a +# newer push to a request supersedes an older pending dispatch, and so +# the hop is not cancelled by the very build it started. concurrency: - group: check-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.event_name == 'workflow_run' && format('dispatch-{0}', github.event.workflow_run.pull_requests[0].number) || 'cache-writer' }} + cancel-in-progress: ${{ github.event_name == 'workflow_run' }} jobs: - package: - name: package + format + # The first hop for a pull request: guard, then dispatch the builder. + # A workflow_run fires for check-pr on any event; only requests from + # this repository carry a pull request in the payload. + dispatch: + if: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0] != null runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - name: Guard, then dispatch the trusted build + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.workflow_run.pull_requests[0].number }} + HEAD: ${{ github.event.workflow_run.head_sha }} + run: | + if gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate \ + --jq '.[].filename' | grep -q '^\.github/'; then + echo "This pull request changes the pipeline; check-pr.yml built it." + gh api "repos/$GITHUB_REPOSITORY/statuses/$HEAD" -f state=success -f context=check \ + -f description='Pipeline change: built by check-pr' > /dev/null + exit 0 + fi + gh api "repos/$GITHUB_REPOSITORY/statuses/$HEAD" -f state=pending -f context=check \ + -f description='Queued' > /dev/null + gh workflow run check.yml --repo "$GITHUB_REPOSITORY" --ref main -f "head=$HEAD" -f "pr=$PR" + + check: + if: github.event_name != 'workflow_run' + runs-on: ubuntu-latest + timeout-minutes: 30 + # The cachix credentials live in this environment, whose deployment + # branch policy admits only runs on main: a push, or the dispatched + # build of a request. A request's own workflow copy cannot reach + # them whatever it says, which is what keeps the rule "only main + # pushes to the cache" out of the files a request can edit. + environment: cachix + outputs: + head: ${{ steps.target.outputs.head }} + steps: + - name: Resolve what to build + id: target + env: + GH_TOKEN: ${{ github.token }} + HEAD: ${{ inputs.head || github.sha }} + PR: ${{ inputs.pr }} + run: | + if [[ -z "$PR" ]]; then + { + echo "head=$HEAD" + echo "class=main" + echo "id=$HEAD" + echo "base=$HEAD" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + { + echo "head=$HEAD" + echo "class=pr" + echo "id=$PR/$HEAD" + echo "base=$(gh pr view "$PR" --repo "$GITHUB_REPOSITORY" --json baseRefOid --jq .baseRefOid)" + } >> "$GITHUB_OUTPUT" + gh api "repos/$GITHUB_REPOSITORY/statuses/$HEAD" -f state=pending -f context=check \ + -f description='Building' \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" > /dev/null + + # By SHA, never by branch name, so a push after the guard ran + # cannot swap the tree under it. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ steps.target.outputs.head }} + persist-credentials: false - - uses: cachix/install-nix-action@v31 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} extra_nix_config: | experimental-features = nix-command flakes + sandbox = true - # Building the package shellchecks the script and proves its - # declared runtime closure is complete. - - name: Build the package - run: nix build --no-link --print-build-logs '.#checks.x86_64-linux.package' + # Restores the newest entry and saves under this commit's key. The + # cap of zero makes the pruning before the save remove every path + # the roots do not reach, so the entry is exactly their closure. + # Purging is left to the `prune` job, which checks the save landed. + - uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7 + with: + primary-key: nix-${{ runner.os }}-${{ steps.target.outputs.head }} + restore-prefixes-first-match: nix-${{ runner.os }}- + gc-max-store-size-linux: 0 + purge: false - - name: Check formatting - run: nix build --no-link --print-build-logs '.#checks.x86_64-linux.treefmt' + # Configures the clhodapp cache as a substituter and installs the + # cachix command. Pushing is left to the explicit step at the end: + # the action's own push covers only paths built in this run, and + # a warm run builds nothing. + - uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17 + with: + name: clhodapp + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + signingKey: ${{ secrets.CACHIX_SIGNING_KEY }} + skipPush: true + # The clhodapp cache's upstreams: cachix.org skips uploading + # what they already hold, so a user of that cache pulls from + # them too. + extraPullNames: nix-community,numtide - vm: - name: VM lifecycle test - runs-on: ubuntu-latest - timeout-minutes: 60 - steps: - - uses: actions/checkout@v4 + - name: Run the flake checks + run: nix flake check --print-build-logs - - uses: cachix/install-nix-action@v31 - with: - github_access_token: ${{ secrets.GITHUB_TOKEN }} - extra_nix_config: | - experimental-features = nix-command flakes + - name: Root this build and apply the cache priorities + env: + GH_TOKEN: ${{ github.token }} + ROOT_CLASS: ${{ steps.target.outputs.class }} + ROOT_ID: ${{ steps.target.outputs.id }} + CURRENT_MAIN: ${{ steps.target.outputs.base }} + OWN_REF: ${{ github.ref }} + KEY_PREFIX: nix-${{ runner.os }}- + MAY_DELETE: "true" + run: | + set -euo pipefail + # The persistent root tree. It lives under /nix so the cache + # carries it, but not under /nix/var/nix, which the cache + # action excludes from its archive apart from the database. + # Nix reads roots only from /nix/var/nix/gcroots, so the last + # step mirrors the surviving tree there before the save's + # pruning runs. + roots=/nix/ci-roots + + # 1. Root what this run built: the checks and the packages. + # Checks alone would not do; a check's output need not + # reference what it built (the VM test's is its log), and + # the pruning keeps only what the roots reach. Everything + # is already built, so this evaluates and links. An output + # the flake does not provide is skipped; any other + # evaluation error fails. + own="$roots/$ROOT_CLASS/$ROOT_ID" + sudo rm -rf "$own" + sudo mkdir -p "$own" + for kind in checks packages; do + if ! names=$(nix eval --json ".#$kind.x86_64-linux" --apply builtins.attrNames 2>"$RUNNER_TEMP/eval.err"); then + grep -q 'does not provide attribute' "$RUNNER_TEMP/eval.err" || { cat "$RUNNER_TEMP/eval.err"; exit 1; } + continue + fi + for name in $(jq -r '.[]' <<<"$names"); do + out=$(nix build --no-link --print-out-paths ".#$kind.x86_64-linux.$name") + sudo ln -s "$out" "$own/$kind-$name" + done + done + + # 2. Order every root directory, highest priority first. Older + # builds of the same request are superseded outright. + open=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --limit 500 --json number --jq '.[].number') + is_open() { grep -qx "$1" <<<"$open"; } + # Entry names under a directory, newest first by modification time. + newest_in() { + [[ -d "$1" ]] || return 0 + find "$1" -mindepth 1 -maxdepth 1 -printf '%T@ %f\n' | sort -rn | awk '{print $2}' + } + ordered=() + [[ -d "$roots/main/$CURRENT_MAIN" ]] && ordered+=("$roots/main/$CURRENT_MAIN") + shopt -s nullglob + for prdir in "$roots"/pr/*/; do + n=$(basename "$prdir") + newest=$(newest_in "$prdir" | head -n1) + for sha in "$prdir"/*/; do + [[ "$(basename "$sha")" == "$newest" ]] || sudo rm -rf "$sha" + done + is_open "$n" && ordered+=("$prdir$newest") + done + for sha in $(newest_in "$roots/main"); do + [[ "$sha" == "$CURRENT_MAIN" ]] || ordered+=("$roots/main/$sha") + done + for prdir in "$roots"/pr/*/; do + n=$(basename "$prdir") + is_open "$n" || ordered+=("$prdir$(newest_in "$prdir" | head -n1)") + done + + # 3. The budget: the repository's 10 GB, less the entries of + # requests that changed the pipeline (they live in their own + # scopes), less this scope's current entry, which coexists + # with the new one until the `prune` job removes it. Sizes + # are compared as uncompressed closure sizes, which errs on + # the safe side since entries are compressed. + gib=$((1024 * 1024 * 1024)) + caches=$(gh cache list --repo "$GITHUB_REPOSITORY" --limit 1000 --json key,ref,sizeInBytes,lastAccessedAt) + used_by_own_scope=$(jq --arg ref "$OWN_REF" --arg p "$KEY_PREFIX" \ + '[.[] | select(.ref == $ref and (.key | startswith($p))) | .sizeInBytes] | add // 0' <<<"$caches") + used_by_others=$(jq --arg ref "$OWN_REF" \ + '[.[] | select(.ref != $ref) | .sizeInBytes] | add // 0' <<<"$caches") + budget=$((10 * gib - used_by_own_scope - used_by_others)) + echo "Budget for this entry: $budget bytes ($used_by_others used by other scopes, $used_by_own_scope by this one)." + + closure_size() { + # Union closure size of the roots under the given directories. + local targets + targets=$(for d in "$@"; do find "$d" -maxdepth 1 -type l -exec readlink {} +; done) + [[ -n "$targets" ]] || { echo 0; return; } + # shellcheck disable=SC2086 + nix path-info -r $targets | sort -u | xargs nix path-info -s | awk '{s += $2} END {print s + 0}' + } - # A NixOS VM that converges a workspace of local bare repos: no - # network, nothing real is touched. - - name: Run the lifecycle test - run: nix build --no-link --print-build-logs '.#checks.x86_64-linux.vm-lifecycle' + # 4. Keep the highest-priority roots that fit, drop the rest. + # If not even the current main fits, make room by deleting + # pipeline requests' entries, closed ones first, then the + # oldest open ones. + kept=() + for d in "${ordered[@]}"; do + size=$(closure_size "${kept[@]}" "$d") + if (( size <= budget )); then + kept+=("$d") + continue + fi + if (( ${#kept[@]} == 0 )) && [[ "$MAY_DELETE" == "true" ]]; then + while (( size > budget )); do + victim=$(jq -r --arg ref "$OWN_REF" \ + '[.[] | select(.ref != $ref)] | sort_by(.lastAccessedAt) | .[0].ref // empty' <<<"$caches") + [[ -n "$victim" ]] || break + n=$(sed -E 's|refs/pull/([0-9]+)/merge|\1|' <<<"$victim") + if is_open "$n"; then + # Closed ones first: pick the oldest closed entry if any remain. + closed_victim=$(jq -r --arg ref "$OWN_REF" --argjson open "[$(paste -sd, <<<"$open")]" \ + '[.[] | select(.ref != $ref) | select((.ref | capture("refs/pull/(?[0-9]+)/merge").n | tonumber) as $n | ($open | index($n)) == null)] | sort_by(.lastAccessedAt) | .[0].ref // empty' <<<"$caches") + [[ -n "$closed_victim" ]] && victim=$closed_victim + fi + echo "Deleting cache entries for $victim to make room." + gh cache delete --all --ref "$victim" --succeed-on-no-caches --repo "$GITHUB_REPOSITORY" + caches=$(jq --arg ref "$victim" '[.[] | select(.ref != $ref)]' <<<"$caches") + used_by_others=$(jq --arg ref "$OWN_REF" '[.[] | select(.ref != $ref) | .sizeInBytes] | add // 0' <<<"$caches") + budget=$((10 * gib - used_by_own_scope - used_by_others)) + done + if (( size <= budget )); then + kept+=("$d") + continue + fi + echo "::warning::The current main build ($size bytes) exceeds the cache budget ($budget bytes); saving it anyway." + kept+=("$d") + continue + fi + echo "Dropping roots $d (closure would be $size bytes, budget $budget)." + sudo rm -rf "$d" + done + echo "Kept roots:" + printf ' %s\n' "${kept[@]}" + + # 5. Mirror the surviving tree into Nix's root directory so the + # pruning before the save honours it (symlinks stay symlinks). + sudo rm -rf /nix/var/nix/gcroots/ci + sudo cp -a "$roots" /nix/var/nix/gcroots/ci + + # What main's cache entry holds is what hosts and consumers may + # pull: the closure of the surviving roots, signed with the key + # from the environment. Paths the cache already has are skipped. + - name: Push the kept closure to the clhodapp cache + run: | + find /nix/ci-roots -type l -exec readlink {} + | sort -u | xargs cachix push clhodapp + + - name: Report the result on the pull request + if: always() && inputs.pr != '' + env: + GH_TOKEN: ${{ github.token }} + HEAD: ${{ steps.target.outputs.head }} + STATE: ${{ job.status == 'success' && 'success' || 'failure' }} + run: | + gh api "repos/$GITHUB_REPOSITORY/statuses/$HEAD" -f state="$STATE" -f context=check \ + -f description="Flake checks: $STATE" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" > /dev/null + + # Older entries in this scope go only once the new one is confirmed + # to exist. The cache action would purge regardless, and a save can + # fail (a read-only token did, twice), which would leave nothing. + prune: + needs: check + if: needs.check.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: write + steps: + - name: Remove the older entries in this scope + env: + GH_TOKEN: ${{ github.token }} + KEY: nix-${{ runner.os }}-${{ needs.check.outputs.head }} + KEY_PREFIX: nix-${{ runner.os }}- + OWN_REF: ${{ github.ref }} + run: | + set -euo pipefail + entries=$(gh cache list --repo "$GITHUB_REPOSITORY" --ref "$OWN_REF" --key "$KEY_PREFIX" --limit 100 --json id,key) + if ! jq -e --arg k "$KEY" 'any(.[]; .key == $k)' <<<"$entries" > /dev/null; then + echo "::warning::No entry with key $KEY exists; keeping the older entries." + exit 0 + fi + for id in $(jq -r --arg k "$KEY" '.[] | select(.key != $k) | .id' <<<"$entries"); do + gh cache delete "$id" --repo "$GITHUB_REPOSITORY" + echo "Deleted cache entry $id." + done diff --git a/README.md b/README.md index a9f4f08..3bd9c48 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,32 @@ time. `nix flake check` builds the package and an offline NixOS VM test convergence lifecycle against a fake GitHub of local bare repos; `nix fmt` formats. +In CI the same `nix flake check` runs with the Nix store cached between +runs. A pull request that leaves `.github/` alone is checked by `main`'s +copy of the workflow, in `main`'s context once its own check completes, +and adds its build to the shared cache; one that changes the pipeline is +checked by its own copy, under a cache only it can see. The comments at +the top of the two workflow files say why that split is what makes the +cache safe to write from a pull request. + +## Binary cache + +What `main` builds is pushed to the `clhodapp` cachix cache, signed with +its key, so `nix run github:clhodapp/flake-sync` at the same pins +downloads the packaged tool instead of building it. That cache skips +paths its upstreams already hold, so using it means using them too: + +| Substituter | Public key | +|---|---| +| `https://clhodapp.cachix.org` | `clhodapp.cachix.org-1:EW/0conxH0OQyo0o4ub/grdkFspholmQMSnQyj0vrZI=` | +| `https://nix-community.cachix.org` | `nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=` | +| `https://numtide.cachix.org` | `numtide.cachix.org-1:2ps1kLBUWjxIneOy1Ik6cQjb41X0iXVXeHigGmycPPE=` | + +Add all three to `extra-substituters` and `extra-trusted-public-keys`. +The flake also declares them in `nixConfig`, which applies when it is +evaluated directly and the prompt (or `--accept-flake-config`) accepts +them. + ## Stability `main` rolls. There are no tagged releases, and the command surface may diff --git a/flake.nix b/flake.nix index a431fe7..9c044b7 100644 --- a/flake.nix +++ b/flake.nix @@ -3,6 +3,26 @@ description = "Assess and converge a workspace's flake repos in DAG order"; + # Honored only when this flake is evaluated directly (`nix build`, + # `nix flake check`) and the settings are accepted: answer the prompt, + # or pass `--accept-flake-config` (a non-interactive run otherwise + # ignores them with a warning). A consumer that takes this flake as an + # input gets nothing from it and must declare the caches itself. The + # two upstreams are part of the deal: the clhodapp cache skips + # uploading paths they already hold. + nixConfig = { + extra-substituters = [ + "https://clhodapp.cachix.org" + "https://nix-community.cachix.org" + "https://numtide.cachix.org" + ]; + extra-trusted-public-keys = [ + "clhodapp.cachix.org-1:EW/0conxH0OQyo0o4ub/grdkFspholmQMSnQyj0vrZI=" + "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=" + "numtide.cachix.org-1:2ps1kLBUWjxIneOy1Ik6cQjb41X0iXVXeHigGmycPPE=" + ]; + }; + inputs = { caisson.url = "github:nix-caisson/caisson"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";