diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d8f1643ce..e1b70a43fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,22 +5,25 @@ on: branches: [master, feature/holographic-memory] pull_request: branches: ['**'] + # `ready_for_review` and `labeled` are what lift a pull request from the + # light gates into full CI, so they must start a run. + types: [opened, synchronize, reopened, ready_for_review, labeled] permissions: contents: read -# One run per pull request (or per pushed ref). A newer push to a pull -# request supersedes the run in flight: its verdict is for a head nobody can -# merge any more, and letting it finish only holds runners the current head is -# waiting for. Keeping master-bound runs to completion did not buy a verdict -# per tip either: GitHub holds one pending run per group, so while a 100-min -# run finished, every intermediate push was queued and then cancelled by the -# next (runs 34311300344, 34312118984, 34313237349 never scheduled a job) and -# only the head at the moment the old run ended got a verdict, ~100 min late. -# `push` runs are never cancelled. +# One run per pull request (or per pushed ref), and a newer head always +# supersedes the run in flight: its verdict is for a commit nobody can merge +# or ship any more, and on 20 free runner slots every job it still holds is a +# job the current head waits for. Master is not exempt. Keeping master runs +# to completion never bought a verdict per tip: GitHub holds one pending run +# per group, so intermediate pushes were queued and cancelled anyway (runs +# 34311300344, 34312118984, 34313237349 never scheduled a job) and only the +# head at the moment the old run ended got a verdict, ~100 min late. The tip +# is what ships; it is the only master commit that needs a verdict. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: true env: CARGO_TERM_COLOR: always @@ -30,34 +33,97 @@ env: AST_GREP_VERSION: "0.44.0" jobs: - # Single source of truth for the fork/base-branch guard: heavy jobs run on - # pushes, on PRs from this repository, and on fork PRs that target an - # integration branch. Guarded jobs `needs:`-check this job's output instead - # of each repeating the expression, so the branch list lives in one place. + # Single source of truth for what a run is allowed to spend. Guarded jobs + # `needs:`-check this job's outputs instead of each repeating the + # expressions, so the policy lives in one place: + # + # * `run-heavy`: the Linux build/test/lint lane. Pushes always; pull + # requests only when trusted (this repository, or a fork targeting an + # integration branch) and either ready for review or labelled `ci-full`. + # A draft gets the light gates (this job, commit lint, drift, harness) + # and nothing else: with 20 free runner slots, a swarm of drafts each + # spending ~30 jobs per push queued the integration branch for hours. + # * `run-os`: the macOS and Windows matrices. Pushes, or a pull request + # labelled `ci-os`. macOS has its own 5-slot cap and Windows shards take + # 7 jobs; both verify the same code the Linux lane already covers and + # both run on master after the merge. + # * `run-hosts`: stock Hermes / Claude Code / OpenCode integrations. + # Pushes, or a pull request labelled `ci-hosts`. scope-gate: name: Scope gate runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read + actions: write outputs: run-heavy: ${{ steps.decide.outputs.run-heavy }} + run-os: ${{ steps.decide.outputs.run-os }} + run-hosts: ${{ steps.decide.outputs.run-hosts }} linux-partitions: ${{ steps.linux-partitions.outputs.matrix }} macos-groups: ${{ steps.macos-groups.outputs.matrix }} steps: - - name: Decide whether guarded jobs run + - name: Decide what this run may spend id: decide - run: echo "run-heavy=${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON('["master","feature/holographic-memory"]'), github.event.pull_request.base.ref) }}" >> "$GITHUB_OUTPUT" + env: + EVENT: ${{ github.event_name }} + TRUSTED: ${{ github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON('["master","feature/holographic-memory"]'), github.event.pull_request.base.ref) }} + DRAFT: ${{ github.event.pull_request.draft }} + LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }} + run: | + has_label() { [[ " $LABELS " == *" $1 "* ]]; } + heavy=false; os=false; hosts=false + if [[ $EVENT != pull_request ]]; then + heavy=true; os=true; hosts=true + elif [[ $TRUSTED == true ]] && { [[ $DRAFT != true ]] || has_label ci-full; }; then + heavy=true + has_label ci-os && os=true + has_label ci-hosts && hosts=true + fi + { + echo "run-heavy=$heavy" + echo "run-os=$os" + echo "run-hosts=$hosts" + } >>"$GITHUB_OUTPUT" + echo "run-heavy=$heavy run-os=$os run-hosts=$hosts (event=$EVENT draft=${DRAFT:-n/a} labels='$LABELS')" # The Linux and macOS test matrices come from the same manifest the # partition jobs select their targets from, so a partition cannot exist - # without a job or a job without a partition. This needs the checkout - # and nothing else. + # without a job or a job without a partition. The cache prune below + # needs `scripts/` too; nothing else is checked out. - uses: actions/checkout@v7 with: sparse-checkout: | .github/linux-test-partitions.json - scripts/linux-test-partitions.py + scripts sparse-checkout-cone-mode: false + # Cache entries are restorable only from the ref that saved them, and + # the Rust lanes restore by key prefix (newest `v0-rust---`), + # so everything a push supersedes on `refs/pull/N/merge` is dead weight + # against the 10 GB budget until LRU evicts something live instead + # (measured 2026-09-08: 9.6 GB saved per push, every lane rebuilding + # cold). This used to be its own workflow run per push; it rides here + # because this job already holds a runner for every push. Best effort: + # a prune failure must not withhold the run's verdict. + - name: Delete the pull request's superseded cache entries + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + MERGE_REF: refs/pull/${{ github.event.pull_request.number }}/merge + run: | + set -euo pipefail + superseded="$(gh api --paginate "repos/$REPO/actions/caches?ref=$MERGE_REF&per_page=100" \ + | python3 scripts/prune-superseded-actions-caches.py)" + deleted=0 + for cache in $superseded; do + gh api -X DELETE "repos/$REPO/actions/caches/$cache" >/dev/null + deleted=$((deleted + 1)) + done + echo "deleted $deleted superseded cache entry(ies) for $MERGE_REF" + - name: Derive the Linux test matrix id: linux-partitions run: echo "matrix=$(python3 scripts/linux-test-partitions.py matrix)" >> "$GITHUB_OUTPUT" @@ -190,6 +256,12 @@ jobs: - name: Test bounded commit range linting run: python3 scripts/test-lint-commit-range.py + - name: Check Rust cache key lineage and the superseded-entry selection + run: | + python3 scripts/test-prune-superseded-actions-caches.py + python3 scripts/test-rust-cache-lineage.py + python3 scripts/check-rust-cache-lineage.py + - name: Test and check dev skill host copies run: | python3 scripts/test-check-dev-skill-mirrors.py @@ -298,7 +370,7 @@ jobs: macos-test-partition: name: Test macOS ${{ matrix.group }} needs: scope-gate - if: ${{ needs.scope-gate.outputs.run-heavy == 'true' }} + if: ${{ needs.scope-gate.outputs.run-os == 'true' }} runs-on: macos-14 # Each group's budget is set in the manifest beside the group, with the # measurement it rests on. @@ -434,7 +506,7 @@ jobs: # per group holding a junit.xml per partition. macos-test: name: Test macOS - if: ${{ !cancelled() && needs.scope-gate.outputs.run-heavy == 'true' }} + if: ${{ !cancelled() && needs.scope-gate.outputs.run-os == 'true' }} needs: [macos-test-partition, scope-gate] runs-on: ubuntu-latest timeout-minutes: 10 @@ -743,7 +815,7 @@ jobs: windows-build: name: Build Windows tests needs: scope-gate - if: ${{ needs.scope-gate.outputs.run-heavy == 'true' }} + if: ${{ needs.scope-gate.outputs.run-os == 'true' }} runs-on: windows-latest # Build every Windows test once, then shard execution from the archive. # Keep a bounded guard around the hosted build; Cargo timings are retained @@ -1016,7 +1088,7 @@ jobs: windows-test: name: Test Windows - if: ${{ !cancelled() && needs.scope-gate.outputs.run-heavy == 'true' }} + if: ${{ !cancelled() && needs.scope-gate.outputs.run-os == 'true' }} needs: [windows-test-shard, scope-gate] runs-on: ubuntu-latest timeout-minutes: 10 @@ -1162,7 +1234,7 @@ jobs: hermes-integration: name: Hermes integration (stock) needs: [scope-gate, debug-cli] - if: ${{ needs.scope-gate.outputs.run-heavy == 'true' }} + if: ${{ needs.scope-gate.outputs.run-hosts == 'true' }} runs-on: ubuntu-24.04-arm timeout-minutes: 30 env: @@ -1216,7 +1288,7 @@ jobs: host-stock-integration: name: Claude Code + OpenCode integration (stock) needs: [scope-gate, debug-cli] - if: ${{ needs.scope-gate.outputs.run-heavy == 'true' }} + if: ${{ needs.scope-gate.outputs.run-hosts == 'true' }} runs-on: ubuntu-24.04-arm timeout-minutes: 30 env: diff --git a/.github/workflows/hotpath-comment.yml b/.github/workflows/hotpath-comment.yml index 5033b223f9..ab720dd1d7 100644 --- a/.github/workflows/hotpath-comment.yml +++ b/.github/workflows/hotpath-comment.yml @@ -11,6 +11,12 @@ permissions: contents: read pull-requests: write +# One comment per profiled pull request; a newer profile supersedes the +# comment for the older head. +concurrency: + group: hotpath-comment-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.head_sha }} + cancel-in-progress: true + jobs: comment: runs-on: ubuntu-latest diff --git a/.github/workflows/hotpath-coverage.yml b/.github/workflows/hotpath-coverage.yml index a6999adff4..b4c62bbf91 100644 --- a/.github/workflows/hotpath-coverage.yml +++ b/.github/workflows/hotpath-coverage.yml @@ -22,6 +22,7 @@ name: hotpath-coverage on: pull_request: + types: [opened, synchronize, reopened, labeled] # Only Rust inputs can change what these slice tests compile or measure; # dashboard, plugin, docs and script-only pull requests do not queue it. paths: @@ -56,6 +57,10 @@ env: jobs: slice-tests: + # Opt-in per pull request: label `perf`. Each lane builds the workspace + # more than once, which no draft or routine PR should charge to the 20 + # free runner slots by default. Dispatch runs unconditionally. + if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'perf') runs-on: ubuntu-latest timeout-minutes: 45 steps: @@ -97,6 +102,10 @@ jobs: tracedecay-rusqlite-runtime/hotpath,tracedecay-global-db/hotpath,tracedecay-global-db/test-helpers sessions-slice-tests: + # Opt-in per pull request: label `perf`. Each lane builds the workspace + # more than once, which no draft or routine PR should charge to the 20 + # free runner slots by default. Dispatch runs unconditionally. + if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'perf') runs-on: ubuntu-latest timeout-minutes: 60 steps: diff --git a/.github/workflows/hotpath-profile.yml b/.github/workflows/hotpath-profile.yml index c486f5713b..122361efd1 100644 --- a/.github/workflows/hotpath-profile.yml +++ b/.github/workflows/hotpath-profile.yml @@ -17,6 +17,7 @@ name: hotpath-profile on: pull_request: + types: [opened, synchronize, reopened, labeled] # The profiled workload is the Rust indexing pipeline over the committed # corpus; only these inputs can move its numbers, so dashboard, plugin, # docs and script-only pull requests do not queue a comparison. @@ -61,6 +62,10 @@ env: jobs: profile: + # Opt-in per pull request: label `perf`. Each lane builds the workspace + # more than once, which no draft or routine PR should charge to the 20 + # free runner slots by default. Dispatch runs unconditionally. + if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'perf') runs-on: ubuntu-latest timeout-minutes: 45 steps: diff --git a/.github/workflows/hotpath-runtime-core.yml b/.github/workflows/hotpath-runtime-core.yml index e25aa959f5..a5f2d7046e 100644 --- a/.github/workflows/hotpath-runtime-core.yml +++ b/.github/workflows/hotpath-runtime-core.yml @@ -22,6 +22,7 @@ name: hotpath-runtime-core on: pull_request: + types: [opened, synchronize, reopened, labeled] paths: - crates/tracedecay-runtime-core/src/git_repository.rs - crates/tracedecay-runtime-core/src/git_repository/** @@ -56,6 +57,10 @@ env: jobs: git-authority: + # Opt-in per pull request: label `perf`. Each lane builds the workspace + # more than once, which no draft or routine PR should charge to the 20 + # free runner slots by default. Dispatch runs unconditionally. + if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'perf') name: git_repository_authority hotpath lanes runs-on: ubuntu-latest timeout-minutes: 60 diff --git a/.github/workflows/plugin-validation.yml b/.github/workflows/plugin-validation.yml index b02d15bfcf..cf4f77c105 100644 --- a/.github/workflows/plugin-validation.yml +++ b/.github/workflows/plugin-validation.yml @@ -9,6 +9,7 @@ on: push: branches: [master] pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - "plugin/**" - "plugin/cursor-native-extension/**" @@ -35,6 +36,7 @@ concurrency: jobs: dashboard-assets: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: Build dashboard artifact runs-on: ubuntu-latest timeout-minutes: 15 @@ -67,6 +69,7 @@ jobs: include-hidden-files: true manifest-schema: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: Manifest schema runs-on: ubuntu-latest timeout-minutes: 5 @@ -107,6 +110,7 @@ jobs: done claude-native-validation: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: Claude native plugin validation runs-on: ubuntu-latest timeout-minutes: 10 @@ -125,6 +129,7 @@ jobs: # Uses MCP Inspector's TypeScript SDK client to cover stdio handshake and # schema compatibility beyond the in-repo Rust MCP tests. mcp-conformance-smoke: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft name: MCP conformance smoke needs: dashboard-assets runs-on: ubuntu-latest diff --git a/.github/workflows/pr-run-cleanup.yml b/.github/workflows/pr-run-cleanup.yml index 81883e8146..bfbc07dff8 100644 --- a/.github/workflows/pr-run-cleanup.yml +++ b/.github/workflows/pr-run-cleanup.yml @@ -1,78 +1,34 @@ -# Keeps a pull request's runner queue and Actions cache footprint bounded. +# Releases what a closed or merged pull request still holds. # -# On every push, drops the cache entries the push supersedes. Cache entries -# are restorable only from the ref that saved them or from the default -# branch, and the Rust lanes restore by key prefix: rust-cache picks the -# newest `v0-rust---` entry. Everything older on -# `refs/pull/N/merge` is unreachable, yet it counts against the repository's -# 10 GB cache budget until GitHub's least-recently-used eviction removes it, -# and LRU does not know which entries are dead. Measured 2026-09-08 (PR 707, -# run 34231734416, 2.5 hours after the previous push's run): CI alone saved -# 9.6 GB per push, so the `ci-dev`, `ci-clippy-full`, optional Hawk, and Windows -# rust-cache lanes (`No cache found`) all rebuilt cold. Deleting the -# superseded generations here leaves LRU nothing to choose. +# GitHub does not do this itself: a merged PR's queued CI, profiling and +# validation runs keep their place in the account's FIFO runner queue and +# their concurrency-group slots, so an afternoon of merges leaves dozens of +# runs that can never be acted on ahead of the integration branch's own run +# (measured 2026-09-18: 61 queued runs, the release tagging run pending 70 +# minutes behind profiles of merged branches). The same event drops all of +# the pull request's Actions caches, which nothing can restore any more +# (measured 2026-09-07: 4.6 GB held by seven merged child PRs while the +# Linux lane's live cache had been evicted). # -# On close or merge, cancels every workflow run still queued or in progress -# for the pull request. GitHub does not do this itself: a merged PR's queued -# CI, profiling and validation runs keep their place in the account's FIFO -# runner queue and their concurrency-group slots, so an afternoon of merges -# leaves dozens of runs that can never be acted on ahead of the integration -# branch's own run. The same event drops all of the pull request's Actions -# caches, which nothing can restore any more. Measured 2026-09-07: 4.6 GB of -# the budget held entries from seven merged child PRs while the Linux test -# lane's cache had been evicted, so every one of its runs was a cold build. +# Per-push cache pruning lives in CI's scope-gate job, which already holds a +# runner for every push; this workflow spends one only on close. name: Pull request run hygiene on: pull_request: - types: [opened, reopened, synchronize, closed] + types: [closed] permissions: actions: write -jobs: - # The selection is `scripts/prune-superseded-actions-caches.py`, tested - # here before it runs. Only this repository's own pull requests: a fork's - # token cannot delete caches, and the guarded jobs that save them do not - # run for forks outside the integration branches anyway. - prune: - name: Prune superseded caches - if: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false - sparse-checkout: | - scripts - .github/workflows - - - name: Test the superseded-entry selection - run: | - python3 scripts/test-prune-superseded-actions-caches.py - python3 scripts/test-rust-cache-lineage.py - python3 scripts/check-rust-cache-lineage.py - - - name: Delete the pull request's superseded cache entries - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - MERGE_REF: refs/pull/${{ github.event.pull_request.number }}/merge - run: | - set -euo pipefail - superseded="$(gh api --paginate "repos/$REPO/actions/caches?ref=$MERGE_REF&per_page=100" \ - | python3 scripts/prune-superseded-actions-caches.py)" - deleted=0 - for cache in $superseded; do - gh api -X DELETE "repos/$REPO/actions/caches/$cache" >/dev/null - deleted=$((deleted + 1)) - done - echo "deleted $deleted superseded cache entry(ies) for $MERGE_REF" +# A reopen-and-close pair must not leave two of these racing each other. +concurrency: + group: pr-run-hygiene-${{ github.event.pull_request.number }} + cancel-in-progress: true +jobs: cancel: name: Cancel superseded runs - if: github.event.action == 'closed' runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.github/workflows/sdk-conformance.yml b/.github/workflows/sdk-conformance.yml index 83a0ba4e94..463bce20bf 100644 --- a/.github/workflows/sdk-conformance.yml +++ b/.github/workflows/sdk-conformance.yml @@ -2,6 +2,7 @@ name: SDK conformance on: pull_request: + types: [opened, synchronize, reopened, ready_for_review] paths: - ".github/workflows/**" - ".github/release-targets.json" @@ -34,6 +35,7 @@ concurrency: jobs: publish-workflow-policy: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -68,6 +70,7 @@ jobs: .github/workflows/release-please.yml .github/workflows/release-pr-integrity.yml packages: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -88,6 +91,7 @@ jobs: working-directory: sdks/typescript production-router: + if: github.event_name != 'pull_request' || !github.event.pull_request.draft runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b33af635d1..9607db6f13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -249,6 +249,25 @@ behavior. - Do not hand-edit `CHANGELOG.md`; release automation generates it from conventional commit messages. +### What CI spends on a pull request + +CI runs on GitHub's free hosted runners: 20 concurrent jobs for the whole +account, 5 of them macOS. Every job a pull request queues is a job the +integration branch waits behind, so a run spends only what its state earns: + +| State | Runs | +|---|---| +| Draft | Light gates only: scope gate, commit lint, release drift, benchmark-harness self-tests. | +| Ready for review (or labelled `ci-full`) | The Linux lane: build, clippy, fmt, feature gates, dashboard, Linux test partitions, hotpath parity, PR dogfood. | +| Labelled `ci-os` | Adds the macOS and Windows matrices. | +| Labelled `ci-hosts` | Adds the stock Hermes / Claude Code / OpenCode integrations. | +| Labelled `perf` | Runs the hotpath profile, coverage, and runtime-core workflows. | +| Push to `master` | Everything. | + +Marking a PR ready or adding a label starts the run; a newer push cancels +the one in flight, on every branch including `master`. Closing or merging a +PR cancels its remaining runs and drops its Actions caches. + ## Reporting Issues Open an issue at https://github.com/ScriptedAlchemy/tracedecay/issues with: