Repository navigation
perf(pr-merge): carry completed run attempts across post-merge-dispatch ticks #16528
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: pr-require-checks | |
| on: | |
| # A dispatch runs the full contract corpus. pr-merge.yml starts one on main | |
| # for the merges since the last, whoever merged, at most one per 30 minutes, | |
| # each of the newest tip. | |
| workflow_dispatch: | |
| # NO PUSH TRIGGER. Every commit reaches main through the merge queue (the | |
| # repository's merge-queue ruleset, with the org `pull_request` rule and no | |
| # bypass actor; github-iac GovernedRepositories.cs), and the queue merges a | |
| # commit only after this workflow passed on that same SHA in its | |
| # merge-group run. A push run re-tested the | |
| # SHA the queue had just passed: all 55 main pushes from 2026-10-03T22:40Z | |
| # to 10-04T17:41Z did, for 591 job minutes. The check runs that merge-group | |
| # run left stay on the commit. | |
| # | |
| # THE WHOLE TREE, TWICE A DAY. A pull request and a merge group test only | |
| # what changed, so this run is the net for what a | |
| # diff cannot show: a suite that asserts against the live tree, and a | |
| # dependency the affected-suite selector does not see. It takes the same path | |
| # as a dispatch: the full contract corpus, the whole-repository ShellCheck and | |
| # check-25 scans, and every diff-scoped gate at its widest. 09:17 and 16:17 | |
| # UTC are two of the quietest hours for this workflow (3.2 and 4.9 runs an | |
| # hour on average over 2026-09-25..10-02, against 36.4 at 05h and 11.6 at | |
| # 17h), so the full run takes no runners from pull requests at their peak. | |
| schedule: | |
| - cron: '17 9,16 * * *' | |
| pull_request: | |
| # `ready_for_review` is load-bearing: the draft gate below skips every | |
| # lane while a pull request is a draft, and | |
| # without this type a draft flipped to ready triggers no run at all, so the | |
| # lanes would never get their real answer. | |
| # | |
| # `edited`, `labeled` and `unlabeled` can change the pr-title / | |
| # do-not-merge / issue-linkage answers without a new commit, so the required | |
| # check has to re-evaluate. Whether such an event is CONTRACT-ONLY (lanes | |
| # gated off, ci-status carrying the recorded `ci-lanes` verdict forward) is | |
| # decided by one predicate, repeated verbatim in the `concurrency` group and | |
| # in every job's `if:` except `ci-status`: | |
| # | |
| # github.event.pull_request.head.repo.full_name == github.repository && | |
| # (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || | |
| # (github.event.action == 'edited' && !github.event.changes.base)) | |
| # | |
| # An `edited` event carrying `changes.base` is a BASE-BRANCH change: the | |
| # merge commit the lanes test changes with it, so that runs in full. A fork | |
| # pull request is never contract-only — its token is read-only on | |
| # `pull_request` whatever `permissions:` requests, so it cannot record lane | |
| # state — and runs the full workflow on every event exactly as before. | |
| types: [opened, synchronize, reopened, ready_for_review, edited, labeled, unlabeled] | |
| # A merge queue tests each queued merge commit through this event alone; a | |
| # required check that never reports on it leaves the queued pull request | |
| # unmergeable. A merge-group run carries no `github.event.pull_request`, so | |
| # every expression below that reads one takes its non-PR branch: the | |
| # contract-only and draft gates are false, so no lane is gated off; the | |
| # concurrency group falls back to `github.run_id`, so no queued run cancels | |
| # another; `select-tests` diffs against the queue's base | |
| # (`github.event.merge_group.base_sha`), so the test lanes run the suites | |
| # the group's own diff selects, as its pull request did, while the | |
| # detect-changes action, which reads only a pull request's files, fails | |
| # open; the base-branch gates decline as on a schedule run; `check-contract` finds no pull | |
| # request number and reports `skipped`, because the title and linkage were | |
| # already checked on the pull request before it could enter the queue, and a | |
| # label can land after enqueue, so a step of its own re-reads `do-not-merge` | |
| # and the other hold labels from the API; and `ci-status` aggregates the lanes and records `ci-lanes` on the | |
| # merge-group commit (`github.sha`), a SHA no contract-only run ever reads. | |
| merge_group: | |
| types: [checks_requested] | |
| permissions: | |
| contents: read | |
| # THE GROUP BRANCHES ON THE PREDICATE, AND EVERY BRANCH BUT MAIN'S DISPATCH | |
| # CANCELS. A contract-only | |
| # run takes a group of its own keyed on `github.run_id`, so it can neither be | |
| # cancelled nor evict anything, and it waits for nothing: its `ci-status` reads | |
| # the `ci-lanes` status once and finishes (see the `ci-status` job). The | |
| # previous shape queued instead, on a shared | |
| # group with `cancel-in-progress` false, and eviction is unconditional: a | |
| # pending run evicted before its `ci-status` job started left the check suite | |
| # with no `ci-status` check run at all, and the required check stopped reporting | |
| # until someone re-ran that specific run (melodic-software/github-iac#378). | |
| # | |
| # The full branch keeps one group per pull request, so a `synchronize` still | |
| # cancels everything on the old SHA; the PR number scopes cancellation to | |
| # exactly one pull request, where `head_ref` would collide across same-named | |
| # fork branches. A merge-group, schedule or other-branch dispatch run keeps | |
| # the unique `github.run_id` group, so it is never cancelled. | |
| # | |
| # A dispatch on main (pr-merge.yml's whole-suite run of main's tip) shares one | |
| # group with no `queue` key and `cancel-in-progress` false: one runs, one waits, | |
| # and a newer dispatch replaces the waiting one, which GitHub cancels before | |
| # any job starts. The newer one holds a later tip, so it covers every merge the | |
| # replaced one did. A running whole-suite run is never cancelled: the post-merge | |
| # check and post-merge-demotion-alert.yml read a cancelled run as red, and read | |
| # a replaced one (cancelled, no job) as no run. Only attempt 1 joins it: a | |
| # re-run keeps its own `github.run_id` group, so a newer dispatch can never | |
| # replace the one re-run a red run waits on. | |
| concurrency: | |
| group: >- | |
| ${{ (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && github.run_attempt == 1) | |
| && 'pr-require-checks-main-dispatch' | |
| || ((github.event.pull_request.head.repo.full_name == github.repository && | |
| (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || | |
| (github.event.action == 'edited' && !github.event.changes.base))) | |
| && format('ci-contract-{0}-{1}', github.event.pull_request.number, github.run_id) | |
| || format('{0}-{1}', github.workflow, github.event.pull_request.number || github.run_id)) }} | |
| cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' || github.ref != 'refs/heads/main' || github.run_attempt != 1 }} | |
| # Quality lanes run the ci-workflows composite actions (referenced by SHA, | |
| # bumped via Dependabot — never copied) against this repo's exact standards | |
| # materializations. Tool configs live at their tool-native root paths; | |
| # check-comment-markers' default policy is bundled with its action. The `ci-status` | |
| # gateway aggregates every lane into the single required check the org ci-gate | |
| # ruleset keys on, through the ci-workflows `aggregate-results` composite, and | |
| # carries the pull-request contract (title, do-not-merge label, issue linkage) | |
| # as steps of that one check. | |
| # | |
| # JOBS ARE NAMED <verb>-<domain>, AND THE JOB ID IS THE CHECK NAME. The verb | |
| # says what a job does: `lint` runs linters over source, `check` holds a | |
| # repository contract across files, `test` executes code. The domain says over | |
| # what: `repo`, `shell`, `plugins`, `catalog`, `skills`, or the ecosystem a test job runs | |
| # (`bash`, `python`, `node`). One marker, `mark-lanes`, one resolver, | |
| # `select-tests`, ten working jobs, | |
| # `lint-repo`, `lint-shell`, `check-plugins`, `check-catalog`, `check-skills`, `test-bash`, | |
| # `test-python`, `test-node`, `check-managed-files` and `check-tests`, and one aggregate, `ci-status`, whose name, | |
| # like this workflow's and this file's, is what the org ci-gate ruleset and its | |
| # verifiers read. No ordinals, no tool name for a multi-tool job, no runner OS | |
| # for a portable lane, and no job name another workflow here also uses. A gate | |
| # step keeps its `id`, its aggregator key, in whichever job it runs. | |
| # | |
| # A DOMAIN IS A JOB ONLY WHEN THAT SHORTENS THE CRITICAL PATH BY MORE THAN A | |
| # JOB COSTS: about 15 s of fixed setup bare, about 40 s with a toolchain, or | |
| # when a job that outgrows its timeout is split (see the timeout rule). The | |
| # three test lanes are jobs for a different reason: each runs only when the | |
| # change selects suites of its ecosystem, and skips otherwise, so a change to | |
| # shell code starts no Python or Node runner and a C# or docs change starts | |
| # none. The informational Windows lane runs in | |
| # .github/workflows/pr-test-windows.yml: nothing gates on it, and in this file it | |
| # held the run open past its own required lanes. | |
| # | |
| # EVERY JOB'S TIMEOUT IS ceil(max(1.5 x p95, 1.1 x max) / 60) MINUTES, at least | |
| # 3 and never above 16, from the job's wall over 117 pull-request and 100 push | |
| # runs (2026-09-30 to 10-02), check 25 counted in full on every pull request | |
| # (the case a checker change and the scheduled run pay). Until a job has runs | |
| # of its own, its wall is the sum of the measured seconds of the steps it | |
| # holds, plus the setup of the job whose toolchain it copies. Job, p95 / max | |
| # seconds, minutes: select-tests 24 / 35 plus the planner (up to 21 s measured), 3; | |
| # lint-repo 244 / 265, 7, sized for its whole-tree load (the scheduled runs); | |
| # lint-shell 345 / 385 with a whole-repository ShellCheck, 9; check-plugins | |
| # 74 / 82 over its 97 runs of 2026-10-28 04:27 to 10:59Z, its wall less the | |
| # composite schema steps it then ran plus 3 s for their pip-installed | |
| # replacement (those steps alone reached 121 s on a stalled uv download), 2, | |
| # held at the floor of 3; check-catalog about 77, the sum of its steps (setup | |
| # and checkout included) in the 125 s run of the job it was split from, 3; | |
| # check-skills at most 585 / 645, the wall of a measured job | |
| # that ran all of its steps and more, 15; test-bash per leg about 230 on the | |
| # whole tree (171 s of suites estimated from scripts/suite-seconds.txt, a 158 s | |
| # suite among them, plus about 55 s of setup), 9; test-python per leg about | |
| # 310 on the whole tree (255 s of suites over 5 legs estimated from | |
| # scripts/suite-seconds.txt, a 201 s suite among them, plus up to 56 s of | |
| # setup), 7; test-node about 50 (31 s | |
| # of package chains measured on a warm npm cache), sized for a cold install, 5; | |
| # check-tests about 140 on the whole tree, measured, 4; ci-status 9 / 42, 3. | |
| # A job that outgrows its timeout is made faster or split, | |
| # not given a longer one. | |
| # | |
| # CACHES ARE SAVED FROM MAIN ONLY. Each cache restores through | |
| # actions/cache/restore, and its save step runs only on refs/heads/main (the | |
| # scheduled and dispatch runs) after a miss. A pull-request or merge-group ref | |
| # can restore main's caches but no other ref's, so an entry such a ref saves | |
| # serves that ref alone: on 2026-10-09, 169 of 190 entries were, with the | |
| # repository at 10.67 GB against the 10 GB cap. The setup-node and setup-python | |
| # `cache:` input always saves, so neither step sets it. | |
| # | |
| # NO AUDIT OR FUND REQUEST ON INSTALL. `npm ci` otherwise posts an audit report | |
| # to the registry after installing, and a stalled request held jobs for npm's | |
| # fetch timeout. https://docs.npmjs.com/cli/v10/using-npm/config#audit | |
| # NPM'S FETCH TIMEOUT AND RETRIES ARE BOUNDED. .github/npm-fetch.npmrc sets them, | |
| # read as npm's global config by every npm call here, run-tests.sh's included. | |
| env: | |
| npm_config_audit: "false" | |
| npm_config_fund: "false" | |
| npm_config_globalconfig: ${{ github.workspace }}/.github/npm-fetch.npmrc | |
| jobs: | |
| # Single resolution of the diff scope. Every gated step reads a | |
| # `needs.select-tests.outputs.*` row instead of re-running the detector, so the | |
| # detector runs once per workflow rather than once per lane and there is one | |
| # place to reason about what the answer means. | |
| # | |
| # THE OUTPUTS ARE POSITIVE ON PURPOSE. A consumer writes | |
| # `<output> == 'true'` to do work and `<output> == 'false'` to report the | |
| # work not applicable. Both are plain equality against a value that | |
| # `${{ <expr> }}` can only ever render as the string `true` or the string | |
| # `false`, so the two forms are exact complements and a new lane has no | |
| # negation to spell wrong. A negated form would rely on a prose comment to | |
| # keep that polarity straight across every site. | |
| # | |
| # FAIL-CLOSED TOWARD RUNNING THE SUITE. `scripts/check-docs-only.sh` already | |
| # emits `docs_only=false` on every condition it cannot resolve. This job | |
| # covers the cases below the script: a detector that cannot complete — a | |
| # non-zero exit, or an output file it could not write — leaves | |
| # `steps.detect.outputs.docs_only` unset, which every expression below | |
| # resolves toward running, and the full suite runs. The same unset-output | |
| # path covers a run with no diff base (a schedule or dispatch run), where | |
| # the detect step does not run at all. The | |
| # narrowing clauses read the detect-changes action through fromJSON with a | |
| # `|| '{}'` default and compare `!= 'false'`, never `== 'true'`, for the same | |
| # reason: an unset filter group must run its lane, not skip it. | |
| # | |
| # Note precisely what each half contributes, because the two are easy to | |
| # conflate. `continue-on-error` does NOT clear a step's outputs: a step that | |
| # writes to $GITHUB_OUTPUT and then fails keeps what it wrote. What it does is | |
| # keep a failed detector from failing THIS job and skipping every consumer. | |
| # The output being unset on a failed run is a property of | |
| # `scripts/check-docs-only.sh`, which follows every emit with an immediate | |
| # `exit 0` and never emits `true` on a path that can subsequently fail. That | |
| # invariant is what the consumers ultimately rest on; it is stated in that | |
| # script's header and exercised by its suite. | |
| # | |
| # `scripts/check-docs-only-gate.sh` asserts the workflow-side properties | |
| # against this file, so they are checked rather than asserted in prose. It | |
| # pins every row below to its exact expression: a row it does not name is a | |
| # polarity decision made where nothing can check it. | |
| # | |
| # The detector self-test runs first and is NOT `continue-on-error`, so a | |
| # broken detector turns this job red rather than resolving a scope nobody | |
| # verified. | |
| # A FULL RUN MARKS ITS SHA IN FLIGHT AT ONCE. A contract-only run on this SHA | |
| # reads the newest bot-written `ci-lanes` status once and passes only on | |
| # `success`, so this `pending` makes it fail at once instead of carrying | |
| # forward a verdict this run is about to replace (an earlier full run's on | |
| # the same SHA, say). The red it leaves is replaced when this run's | |
| # `ci-status` re-runs it after recording its own verdict. `ci-status` needs | |
| # this job, so the marker always lands before that verdict, and a write that | |
| # keeps failing fails this job and so `ci-status`: a missing marker is the | |
| # one way a stale `success` could be carried. | |
| # | |
| # ITS OWN JOB, SO NO JOB THAT RUNS PULL REQUEST CODE HOLDS `statuses: write`. | |
| # A local `./` action resolves from the checked-out head, so a job that | |
| # checks out the pull request and runs one runs code the pull request | |
| # controls with that job's token. This job checks out nothing and runs no | |
| # local action; it is the only job here that needs to write a commit status | |
| # besides `ci-status`, which runs no pull request code either. | |
| mark-lanes: | |
| # The contract-only gate, verbatim (see `select-tests`). | |
| if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 3 | |
| permissions: | |
| statuses: write | |
| steps: | |
| - name: Mark the lanes in flight | |
| # Same repository by id, not full_name: the fleet governance verifier reads | |
| # every head.repo.full_name expression as the contract-only predicate. | |
| if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| for attempt in 1 2 3; do | |
| if gh api --method POST "repos/$GITHUB_REPOSITORY/statuses/$HEAD_SHA" \ | |
| -f state=pending -f context=ci-lanes \ | |
| -f description="full run $GITHUB_RUN_ID in flight" \ | |
| -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >/dev/null; then | |
| echo "Marked ci-lanes pending on $HEAD_SHA." | |
| exit 0 | |
| fi | |
| sleep $((attempt * 5)) | |
| done | |
| echo "::error::could not mark ci-lanes pending on $HEAD_SHA; a contract-only run could carry an older verdict forward while this run is in flight." | |
| exit 1 | |
| select-tests: | |
| # The contract-only gate, verbatim on every job but `ci-status` (see the | |
| # predicate spelled out under `on.pull_request.types`). It carries no | |
| # status-check function, so on every job that has `needs` those edges still | |
| # govern exactly as before: the gate only subtracts the label-flip and | |
| # body-edit events, where the lanes would re-run a SHA a full run already | |
| # answered and `ci-status` carries that answer forward instead. | |
| if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 3 | |
| # The detect-changes action lists the pull request's files through the | |
| # API. Nothing else in this workflow needs that read, so it is granted here | |
| # and nowhere else. No write scope: this job runs pull request code. | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| outputs: | |
| run_full: ${{ steps.detect.outputs.docs_only != 'true' }} | |
| run_tests: ${{ steps.detect.outputs.docs_only != 'true' && github.event.pull_request.draft != true }} | |
| # Whether each test lane has work, from the plan below. A lane whose row | |
| # is 'false' skips as a job, and `ci-status` passes that skip only | |
| # against this row. | |
| run_bash: ${{ steps.detect.outputs.docs_only != 'true' && github.event.pull_request.draft != true && steps.plan.outputs.bash != 'false' }} | |
| run_python: ${{ steps.detect.outputs.docs_only != 'true' && github.event.pull_request.draft != true && steps.plan.outputs.python != 'false' }} | |
| run_node: ${{ steps.detect.outputs.docs_only != 'true' && github.event.pull_request.draft != true && steps.plan.outputs.node != 'false' }} | |
| # Whether `lint-shell` has input: its filter group matched. No docs-only | |
| # term, because a shell file under a docs-only prefix is still ShellCheck | |
| # input (scripts/docs-only-paths.txt). | |
| run_shell: ${{ github.event.pull_request.draft != true && fromJSON(steps.match.outputs.results || '{}')['lint_shell'] != 'false' }} | |
| run_workflows: ${{ steps.detect.outputs.docs_only != 'true' && fromJSON(steps.match.outputs.results || '{}')['workflows'] != 'false' }} | |
| run_skill_checker: ${{ steps.detect.outputs.docs_only != 'true' && github.event.pull_request.draft != true && fromJSON(steps.match.outputs.results || '{}')['skill_checker'] != 'false' }} | |
| run_manifests: ${{ steps.detect.outputs.docs_only != 'true' && fromJSON(steps.match.outputs.results || '{}')['manifests'] != 'false' }} | |
| # VALUES, not gates: the ref every diff-scoped step diffs against (empty | |
| # for the whole tree), read only as an env entry; and the plan of each | |
| # test lane: its matrix as a JSON list of leg numbers, the suites of | |
| # each leg, and the optional toolchains each leg installs. | |
| lane_base: ${{ steps.base.outputs.ref }} | |
| bash_legs: ${{ steps.plan.outputs.bash_legs }} | |
| bash_plan: ${{ steps.plan.outputs.bash_plan }} | |
| bash_needs: ${{ steps.plan.outputs.bash_needs }} | |
| python_legs: ${{ steps.plan.outputs.python_legs }} | |
| python_plan: ${{ steps.plan.outputs.python_plan }} | |
| python_needs: ${{ steps.plan.outputs.python_needs }} | |
| node_packages: ${{ steps.plan.outputs.node_packages }} | |
| steps: | |
| - name: Check out | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Fetch base | |
| uses: ./.github/actions/download-full-history # zizmor: ignore[self-repository] | |
| # THE DIFF BASE, RESOLVED ONCE. A pull request diffs against its base | |
| # branch, and a merge group against the queue's base commit, unless its | |
| # diff touches a plugin or marketplace manifest, as a release does. A schedule | |
| # or dispatch run gets no base: the whole tree, the net for what a diff | |
| # cannot show. scripts/resolve-diff-base.sh holds the rules and logs why | |
| # it chose. | |
| - name: Resolve the diff base | |
| id: base | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} | |
| run: scripts/resolve-diff-base.sh | |
| - name: Test the docs-only detector | |
| run: bash scripts/check-docs-only.test.sh | |
| - name: Detect a docs-only diff | |
| id: detect | |
| if: steps.base.outputs.ref != '' | |
| continue-on-error: true | |
| env: | |
| DIFF_BASE: ${{ steps.base.outputs.ref }} | |
| run: scripts/check-docs-only.sh "$DIFF_BASE" | |
| # Every group repeats the toolchain and configuration paths on purpose: a | |
| # pull request touching only a lockfile, only a workflow file or only the | |
| # ShellCheck configuration changes what every lane would find, so it must | |
| # run every lane rather than none. `shell`, `python`, `docs` and | |
| # `powershell` feed no output: the unread groups exist so a consumer can | |
| # be added without touching the resolver. The test lanes read the plan below, not a group. | |
| # Patterns are ROOT-ANCHORED gitignore rules, and the action rejects `!`, | |
| # `?` and `+` outright. | |
| # | |
| # `skill_checker` is the one group that does not repeat the toolchain | |
| # paths: it names every input an UNCHANGED skill's check-25 verdict | |
| # depends on (the checker, the corpus gate and its baseline, the list | |
| # reader it sources, this workflow), and the checker runs bash, grep, sed | |
| # and awk only, so no lockfile or interpreter pin can move that verdict. | |
| # A CHANGED skill is already checked on every diff by the changed-skills | |
| # gate in `check-skills`, check 25 included as a FAIL. | |
| # | |
| # `lint_shell` names every input of a `lint-shell` gate: shell and Python | |
| # source, hook and bin directories, every file under plugins/ (the | |
| # portability lint scans skills/ and reference/ markdown, the | |
| # hook-frontmatter reader every .md under plugins/, and a plugin.json | |
| # `hooks` entry may name a hooks file of any name or extension at any | |
| # path in its plugin), the gates with their baselines and libraries, the rcfile, | |
| # the PyYAML pin, and this workflow. A change that touches none of them | |
| # starts no `lint-shell`; scripts/lint-shell-filter.test.sh pins this. | |
| - name: Match the changed files against the filter groups | |
| id: match | |
| continue-on-error: true | |
| uses: melodic-software/ci-workflows/.github/actions/detect-changes@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| with: | |
| filters: | | |
| shell: | |
| .github/** | |
| scripts/** | |
| lib/** | |
| package.json | |
| package-lock.json | |
| pyproject.toml | |
| uv.lock | |
| requirements*.txt | |
| .node-version | |
| .python-version | |
| .shellcheckrc | |
| **/*.sh | |
| **/*.bash | |
| plugins/docs-naming/skills/generate-file-name-gate/** | |
| .claude/docs-naming.json | |
| python: | |
| .github/** | |
| scripts/** | |
| package.json | |
| package-lock.json | |
| pyproject.toml | |
| uv.lock | |
| requirements*.txt | |
| .node-version | |
| .python-version | |
| .shellcheckrc | |
| ruff.toml | |
| **/*.py | |
| powershell: | |
| .github/** | |
| scripts/** | |
| package.json | |
| package-lock.json | |
| pyproject.toml | |
| uv.lock | |
| requirements*.txt | |
| .node-version | |
| .python-version | |
| .shellcheckrc | |
| **/*.ps1 | |
| **/*.psm1 | |
| **/*.psd1 | |
| docs: | |
| .github/** | |
| scripts/** | |
| package.json | |
| package-lock.json | |
| pyproject.toml | |
| uv.lock | |
| requirements*.txt | |
| .node-version | |
| .python-version | |
| .shellcheckrc | |
| docs/** | |
| **/*.md | |
| workflows: | |
| .github/** | |
| scripts/** | |
| package.json | |
| package-lock.json | |
| pyproject.toml | |
| uv.lock | |
| requirements*.txt | |
| .node-version | |
| .python-version | |
| .shellcheckrc | |
| skill_checker: | |
| plugins/skill-quality/scripts/** | |
| scripts/check-all-skills-verb-contract*.sh | |
| scripts/verb-contract-baseline.txt | |
| scripts/lib/** | |
| .github/workflows/pr-require-checks.yml | |
| manifests: | |
| .github/** | |
| .claude-plugin/** | |
| plugins/*/.claude-plugin/** | |
| scripts/check-manifest-duplicate-keys* | |
| .python-version | |
| lint_shell: | |
| .github/workflows/pr-require-checks.yml | |
| .github/actions/download-full-history/** | |
| .github/requirements-ci.txt | |
| .python-version | |
| .shellcheckrc | |
| lib/** | |
| scripts/lib/** | |
| scripts/aggregate-hygiene-results* | |
| scripts/check-killswitch-hoist* | |
| scripts/check-hook-slow-shapes* | |
| scripts/check-hook-exec-form-frontmatter* | |
| scripts/check-silent-skips* | |
| scripts/check-discriminating-test-skips* | |
| scripts/check-fixture-git-isolation* | |
| scripts/fixture-git-isolation-baseline.txt | |
| scripts/check-drive-root-litter* | |
| scripts/check-shell-portability* | |
| scripts/shell-portability-* | |
| scripts/cross-plugin-source-registry.txt | |
| scripts/orphaned-fixtures-baseline.txt | |
| scripts/check-pipefail-grep-q* | |
| scripts/pipefail-grep-q-baseline.txt | |
| scripts/check-test-tmp-cleanup* | |
| scripts/test-tmp-cleanup-baseline.txt | |
| **/*.sh | |
| **/*.bash | |
| **/*.bats | |
| **/*.py | |
| **/hooks/** | |
| **/bin/** | |
| **/agents/** | |
| **/SKILL.md | |
| plugins/** | |
| # THE TEST LANES ARE PLANNED HERE, ONCE. scripts/plan-test-lanes.sh runs | |
| # the affected-suite selector over the diff and gives each test lane its | |
| # suites, packed into legs by suite-seconds, and each leg the optional | |
| # toolchains its suites need; its header states every rule. A lane the | |
| # plan gives nothing skips as a job. No diff base plans the whole tree. | |
| # An UNMAPPED file adds the whole corpus of its language and is recorded | |
| # as a warning, so its rate is countable per run. A planner that fails | |
| # fails this job and so every lane: there is no plan to run, and a | |
| # guessed one could leave a selected suite unrun. Skipped on a draft and | |
| # on a docs-only diff, where no test lane runs. | |
| - name: Plan the test lanes | |
| id: plan | |
| if: github.event.pull_request.draft != true && steps.detect.outputs.docs_only != 'true' | |
| env: | |
| DIFF_BASE: ${{ steps.base.outputs.ref }} | |
| run: | | |
| if ! scripts/plan-test-lanes.sh ${DIFF_BASE:+--base "$DIFF_BASE"} >"$RUNNER_TEMP/plan.txt"; then | |
| echo "::error::scripts/plan-test-lanes.sh failed; no test lane can run without its plan." | |
| exit 1 | |
| fi | |
| cat "$RUNNER_TEMP/plan.txt" >>"$GITHUB_OUTPUT" | |
| unmapped=$(sed -n 's/^unmapped=//p' "$RUNNER_TEMP/plan.txt") | |
| if [ "${unmapped:-0}" != 0 ]; then | |
| echo "::warning::$unmapped changed file(s) map to no suite; the plan runs the whole corpus of each one's language." | |
| printf 'affected-tests fallback: %s unmapped file(s)\n' "$unmapped" >>"$GITHUB_STEP_SUMMARY" | |
| fi | |
| # Public repository: every lane runs on GitHub-hosted ubuntu-24.04 (free for | |
| # public repositories); the local-runner selector is not permitted here. | |
| # | |
| # EVERY REPO-INVARIANT GATE IS A STEP OF ONE OF FOUR JOBS: this one, | |
| # `lint-shell`, `check-plugins` and `check-skills`. Each gate is a | |
| # `continue-on-error` step with an `id`, and | |
| # `scripts/aggregate-hygiene-results.sh` joins their outcomes through the | |
| # CHECK_RESULTS feed at the foot of each job, so one failing gate names itself | |
| # instead of stopping every gate after it. Each `run:` gate names | |
| # `scripts/run-hygiene-gate.sh` as its shell, which keeps a copy of its | |
| # output, so the aggregator's annotation and the job summary quote why it | |
| # failed; a `uses:` gate is listed in STEP_LOG_CHECKS and quotes the job's | |
| # check-run annotations, else points at its step log. Self-tests are NOT | |
| # `continue-on-error`: a broken detector must turn its job red rather than let | |
| # its own gate pass quietly. None of the four reads anything another produces. | |
| # | |
| # DETECTOR SELF-TESTS ARE NOT STEPS. Each gate's own suite | |
| # (scripts/<gate>.test.sh) runs in `test-bash` when the affected-suite | |
| # selector reaches it: when the gate, its suite, or a file either one names | |
| # changed. The `Check ...` step each one pairs with runs on every diff, so a | |
| # regression the detector would catch is still caught on a diff that changes | |
| # no shell file. A diff that reaches neither the gate nor its suite cannot have | |
| # broken the gate, and the scheduled whole-tree run runs every suite twice a | |
| # day. | |
| # | |
| # THIS JOB LINTS THE REPOSITORY AS A WHOLE: the composite text lints and the | |
| # whole-repository scanners, the CI configuration (actionlint, the dependabot | |
| # and workflow schemas, zizmor, the runner policy, the docs-only and | |
| # lane-coverage contracts), the docs conventions, and the stale-base overlap | |
| # check. Python comes with the pinned, hash-locked requirements file because | |
| # zizmor is a wheel in it. | |
| # | |
| # Markdownlint / typos / editorconfig / gitleaks / eol-renormalize / | |
| # comment-hygiene / machine-specific-paths read the changed docs themselves | |
| # and run on every diff. actionlint and the dependabot and workflow schemas | |
| # read fixed, path-scoped inputs that a diff confined to the docs-only | |
| # allowlist (scripts/docs-only-paths.txt) cannot touch, so on such a diff they | |
| # report an honest evaluated-and-not-applicable success. Exec-bit, | |
| # hook-wiring-liveness, purged-em-dashes, and next-graph are NOT gated: they | |
| # scan the whole repository, and a shebang/hook file added under an otherwise | |
| # docs-only prefix is real input they must still catch, as is a docs-only | |
| # diff that reintroduces an em dash on a purged surface or strands a | |
| # planning-chain `## Next` target. | |
| # ShellCheck, ungated for the same reason, runs in `lint-shell`. | |
| # | |
| # The job skips only on a contract-only event or a draft; otherwise only | |
| # steps are gated. A skipped required lane's result is not `success`, which | |
| # the ci-status aggregate rejects outside a draft, and a workflow | |
| # skipped by a path filter leaves its required check Pending forever. A | |
| # draft lints nothing and its ci-status passes: the `ready_for_review` run | |
| # lints the same SHA, the merge queue re-runs every lane before a merge, and | |
| # a draft run that lints is a runner the 60-job cap pays for and nothing | |
| # gates on. | |
| # | |
| # PR-ONLY STEPS BRANCH IN THE SHELL, NOT IN `if:`. A step cannot carry both a | |
| # resolver gate and an event gate: the docs-only gate reads a step condition | |
| # whole, so a compound condition is an unsanctioned consumer form, and an | |
| # event-only condition makes the step `skipped` on a merge group, which the | |
| # fail-closed aggregator then reds. So a diff-scoped gate runs on every event | |
| # and returns early from its own script when there is no base ref to diff | |
| # against. | |
| lint-repo: | |
| needs: [select-tests] | |
| # The contract-only gate, verbatim, and the draft gate (see `ci-status`). | |
| if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && github.event.pull_request.draft != true }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 7 | |
| # checks: read lets the aggregator quote a composite gate's annotations. | |
| permissions: | |
| contents: read | |
| checks: read | |
| steps: | |
| - name: Check out | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Fetch base | |
| uses: ./.github/actions/download-full-history # zizmor: ignore[self-repository] | |
| # zizmor is a wheel in the pinned, hash-locked requirements file, so this | |
| # install is what puts the Actions security lint on PATH. | |
| - name: Set up Python | |
| uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 | |
| with: | |
| python-version-file: .python-version | |
| - name: Restore the pip cache | |
| id: pip_cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/pip | |
| key: pip-${{ runner.os }}-${{ hashFiles('.python-version', '.github/requirements-ci.txt') }} | |
| - name: Install the pinned YAML reader | |
| run: | | |
| python -m pip install --only-binary=:all: --require-hashes \ | |
| --requirement .github/requirements-ci.txt | |
| - name: Save the pip cache | |
| if: github.ref == 'refs/heads/main' && steps.pip_cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/pip | |
| key: ${{ steps.pip_cache.outputs.cache-primary-key }} | |
| # Actions security lint, previously the `zizmor` reusable-workflow job. | |
| # It is a hash-pinned wheel from .github/requirements-ci.txt now, at the | |
| # same 1.27.0 the reusable takes by default at its pinned SHA, run with | |
| # the reusable's own persona so this is a like-for-like replacement and | |
| # no security-lane behavior changes inside a performance change. The | |
| # reusable ran in SARIF mode and gated on `fail-on-severity: low`; the | |
| # github format exits non-zero on any finding, which is the same | |
| # threshold. GH_TOKEN comes from the job's read-only workflow token, for | |
| # the online audits. scripts/run-zizmor.sh runs the offline audits first, | |
| # then the online ones, where only a GitHub API network error (403, 429, | |
| # 5xx, no response) becomes a warning instead of a failure. | |
| # | |
| # Every `uses: ./.github/actions/download-full-history` in this repository | |
| # carries `# zizmor: ignore[self-repository]`. zizmor 1.30 added that | |
| # audit to recommend GitHub's `$/...` form over the workspace-relative | |
| # `./...` form, and its auto-fix rewrites the call sites. The rewrite | |
| # cannot be taken yet (#4130): actionlint (pinned by SHA in the | |
| # melodic-software/ci-workflows action this lane also runs) rejects | |
| # `$/.github/actions/download-full-history` as "invalid format because ref | |
| # is missing", so adopting the audit's advice turns the zizmor finding | |
| # into an actionlint error and the lane stays red either way. Drop the | |
| # ignore comments and take the auto-fix once that actionlint pin | |
| # understands the syntax (#4130). | |
| - name: Run zizmor | |
| id: zizmor | |
| if: needs.select-tests.outputs.run_workflows == 'true' | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| shell: bash scripts/run-hygiene-gate.sh zizmor {0} | |
| run: scripts/run-zizmor.sh | |
| # The rate-limit guard's operable floor is INLINED into six surfaces on | |
| # purpose: an installed plugin cannot read a sibling plugin's files at | |
| # runtime, so a lane body that only cited the contract could never apply | |
| # it. cross-plugin-source-drift cannot see that set — it skips SKILL.md by | |
| # basename and clusters on identical path-within-plugin, while these copies | |
| # sit at six unrelated paths. | |
| - name: Check every inlined rate-limit floor matches its reader contract | |
| id: loop_lane_floor_drift | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh loop-lane-floor-drift {0} | |
| run: scripts/check-loop-lane-floor-drift.sh --check | |
| - name: Check the conformance registry resolves | |
| id: conformance_registry | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh conformance-registry {0} | |
| run: scripts/check-conformance-registry.sh --check | |
| - name: Check docs/ and markdown filenames are lower-kebab | |
| id: docs_naming | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh docs-naming {0} | |
| run: scripts/check-docs-naming.sh --check | |
| - name: Check ADR numbers are unique | |
| id: adr_numbers | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh adr-numbers {0} | |
| run: scripts/check-adr-numbers.sh --check | |
| # Each tracked docs/conventions/<concern>.yaml against its JSON Schema | |
| # (ADR 0064 Decisions 6 and 7). Ungated: a change that edits only a | |
| # convention YAML is docs-only, and that is the edit this gate reads. | |
| # check-jsonschema comes from the hash-locked requirements install above. | |
| - name: Validate docs/conventions YAML against its schemas | |
| id: convention_yaml_schema | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh convention-yaml-schema {0} | |
| run: scripts/check-convention-yaml.sh --check | |
| # The config-cascade "Semantics at a glance" table is generated from the | |
| # Implementers rows, so the failure this catches is a row edited without | |
| # regenerating the table, or a table hand-edited away from its rows. The | |
| # gate is ungated on purpose: a change that edits only the Implementers | |
| # table is docs-only, and that is exactly the edit that can leave the | |
| # generated block stale. | |
| - name: Check the config-cascade semantics table against the Implementers rows | |
| id: config_cascade_semantics | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh config-cascade-semantics {0} | |
| run: python3 scripts/sync-config-cascade-semantics.py --check | |
| # code-metrics resolves every tunable from one defaults file and restates | |
| # every key in a reference table with its default. The setup template is | |
| # pinned to that defaults file leaf for leaf by a test; the reference | |
| # table was pinned to nothing, and a stale default in a reference reads | |
| # exactly like a current one (#3841). The gate is ungated on purpose: a | |
| # change that edits only the reference is docs-only, and that is exactly | |
| # the edit that can walk the table away from the defaults. | |
| - name: Check the code-metrics config reference against its defaults file | |
| id: code_metrics_config_reference | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh code-metrics-config-reference {0} | |
| run: python3 scripts/check-code-metrics-config-reference.py | |
| # Skill bodies restate a few defaults in prose. The reference table is | |
| # already pinned; these sentences were the remaining unbound copies | |
| # (#3768). Ungated for the same reason as the reference gate: a docs-only | |
| # edit of a skill sentence is exactly the edit that can drift. | |
| - name: Check code-metrics skill prose against its defaults file | |
| id: code_metrics_skill_prose | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh code-metrics-skill-prose {0} | |
| run: python3 scripts/check-code-metrics-skill-prose.py | |
| # A `path:N` citation in markdown goes stale when the CITED file shrinks, | |
| # usually in a change that never touches the citing file, so the scan is | |
| # whole-repository and ungated. It fails only the mechanical case, a line | |
| # past the end of the file; whether the line still says what the citation | |
| # claims is left to review. | |
| - name: Check markdown line citations stay inside the cited file | |
| id: line_citations | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh line-citations {0} | |
| run: python3 scripts/check-line-citations.py | |
| # The review-disposition-and-resolution contract is canonical in ONE file | |
| # and restated across half a dozen others; nothing checked that the | |
| # copies agreed. #1633 shipped seven such defects across five review | |
| # rounds — each an individually well-formed surface instructing a lane to | |
| # do what another surface forbids — and the HAND-RUN sweep that finally | |
| # converged then produced a false "no remaining gaps" of its own, because | |
| # it asked whether a qualifier appeared ANYWHERE in a file rather than | |
| # inside the passage that restates the rule. This gate is span-scoped for | |
| # exactly that reason. Repo-wide and static, so it always runs. See #1659. | |
| - name: Check every restatement carries the canonical clause's qualifiers | |
| id: contract_clause_coverage | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh contract-clause-coverage {0} | |
| run: python3 scripts/check-contract-clause-coverage.py | |
| # Stale-base squash merges can silently revert recently-landed fixes when | |
| # a PR is cut from a base that has since moved, and the squash deletes | |
| # the tests that would have caught the drop (claude-code-plugins#2691). | |
| # This check covers the stale-BASE class only; a branch stale in content | |
| # but current in history belongs to the post-merge silent-revert canary. | |
| # | |
| # ADVISORY ON pull_request (#7871). `--warn` reports every overlap as a | |
| # ::warning listing the overlapping paths and exits 0; only an | |
| # inconclusive git state (exit 2) fails the step. The merge queue builds | |
| # and tests each change on the current base before it lands, so a PR | |
| # that is only behind a fast-moving main is not failed here. merge_group | |
| # declines below, as before. | |
| # | |
| # THE HEAD COMMIT, NOT THE MERGE COMMIT. On a pull_request event this job | |
| # checks out the synthetic merge commit, whose merge-base with the target | |
| # IS the target tip, so the detector would report every pull request | |
| # fresh and prove nothing. Its own former job carried `ref: head.sha` for | |
| # exactly that reason. Running it in a detached worktree of the same | |
| # clone keeps that head-tip comparison while leaving every other step in | |
| # this job on the merge commit, where the diff-scoped gates want to be. | |
| - name: Warn when behind base on overlapping paths | |
| id: stale_base | |
| continue-on-error: true | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| shell: bash scripts/run-hygiene-gate.sh stale-base-overlap {0} | |
| run: | | |
| if [ "$GITHUB_EVENT_NAME" != pull_request ]; then | |
| echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no branch tip to compare with the base." | |
| exit 0 | |
| fi | |
| head_tree="$RUNNER_TEMP/stale-base-head" | |
| git worktree remove --force "$head_tree" 2>/dev/null || rm -rf "$head_tree" | |
| git worktree add --detach "$head_tree" "$HEAD_SHA" | |
| # The worktree is removed whether the detector passes, fails or the | |
| # shell dies: a registered worktree left behind would make every later | |
| # git command in this job report a stale entry, and the trap is what | |
| # keeps the failure path from doing that. Removal is best-effort and | |
| # never changes the step's own verdict. | |
| cleanup() { | |
| status=$? | |
| cd "$GITHUB_WORKSPACE" || true | |
| git worktree remove --force "$head_tree" 2>/dev/null || rm -rf "$head_tree" || true | |
| git worktree prune || true | |
| exit "$status" | |
| } | |
| trap cleanup EXIT | |
| cd "$head_tree" | |
| "$GITHUB_WORKSPACE/scripts/check-stale-base-overlap.sh" --warn "origin/$BASE_REF" | |
| # The docs-only short-circuit is a fail-open shape by construction: | |
| # getting it wrong makes a heavy lane report success without running. Its | |
| # safety properties — one resolution, an output table whose unset values | |
| # mean "run the lane", a detector failure absorbed rather than | |
| # propagated, an unweakened self-test, exactly one spelling for a | |
| # consumer to copy, and a required-lane closure in which no job-level | |
| # condition may hide — are what this step checks against this workflow. | |
| - name: Check the docs-only scope contract holds in pr-require-checks.yml | |
| id: docs_only_contract | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh docs-only-contract {0} | |
| run: scripts/check-docs-only-gate.sh --check | |
| # `ci-status` below is the single check the org ci-gate ruleset keys on, | |
| # and its own comment calls its `needs` list "the single source of truth | |
| # for the lane list" — but nothing enforced the other direction. A job | |
| # defined in THIS file and absent from that list still runs and still | |
| # turns red in the run list, while `ci-status` reports success and the | |
| # merge proceeds: informational, not a gate. This step closes the loop | |
| # mechanically: every job here is in `ci-status.needs`, or carries a | |
| # `# lane-coverage-ok: <reason>` annotation recording why it stays out. | |
| - name: Check every pr-require-checks.yml job and gate step is fed to the ci-status aggregate | |
| id: lane_coverage | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh lane-coverage {0} | |
| run: scripts/check-lane-coverage.sh --check | |
| # The whole-tree test plan of the live tree: the bash plan is the bash | |
| # runner's corpus, each suite once; the Python plan is every test module | |
| # but the eval fixtures; no leg is empty; and every Node suite runs | |
| # through a wrapper or a Node package. Each reads the whole tree, so no | |
| # test selection can say when it must run, and it runs here on every pull | |
| # request rather than as a case of scripts/plan-test-lanes.test.sh. | |
| - name: Check the whole-tree test plan covers the live corpus | |
| id: test_plan_corpus | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh test-plan-corpus {0} | |
| run: | | |
| rc=0 | |
| plan="$(bash scripts/plan-test-lanes.sh 2>/dev/null)" || { echo "::error::the whole-tree plan failed"; exit 1; } | |
| legs() { sed -n "s/^$1_plan=//p" <<<"$plan"; } | |
| diff <(legs bash | jq -r '.[][]' | LC_ALL=C sort) <(bash scripts/run-plugin-tests.sh --list | LC_ALL=C sort) || | |
| { echo "::error::the whole-tree bash plan is not the bash runner's corpus, each suite once"; rc=1; } | |
| diff <(legs python | jq -r '.[][]' | LC_ALL=C sort) <(git ls-files --cached --others --exclude-standard | | |
| awk '{ b = $0; sub(/.*\//, "", b) } b ~ /^test_.*\.py$/ && !/\/evals\/fixtures\//' | LC_ALL=C sort) || | |
| { echo "::error::the whole-tree Python plan is not every test module but the eval fixtures"; rc=1; } | |
| [[ "$(legs bash | jq '[.[] | length > 0] | all')" == true && "$(legs python | jq '[.[] | length > 0] | all')" == true ]] || | |
| { echo "::error::a leg of the whole-tree plan is empty"; rc=1; } | |
| mapfile -t node_suites < <(git ls-files | grep -E '\.test\.(js|mjs|cjs)$') | |
| bash scripts/plan-test-lanes.sh -- "${node_suites[@]}" >/dev/null || | |
| { echo "::error::a Node suite runs through neither a wrapper nor a Node package"; rc=1; } | |
| exit "$rc" | |
| # The test selector's cases against the live tree: the derived shared-lib | |
| # copy sets and real fan-outs. Each runs the selector over the whole tree, | |
| # so no test selection can say when it must run, and it runs here on every | |
| # pull request rather than as a case of scripts/affected-tests.test.sh. | |
| - name: Check the test selector against the live tree | |
| id: selector_live | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh selector-live {0} | |
| run: bash scripts/affected-tests-live.sh | |
| # A DIFF LINTS THE MARKDOWN IT CHANGED. markdownlint's rules read one file | |
| # at a time, so an unchanged file's verdict is the one its last green run | |
| # gave. The whole tree when there is no diff base (a schedule or dispatch | |
| # run), when a markdownlint config or this workflow changed, or when a | |
| # changed path holds a character the composite's whitespace-split glob | |
| # list cannot carry. A diff that changed no markdown lints README.md | |
| # alone, because the composite reads an empty list as "everything". | |
| - name: Select the changed markdown | |
| id: changed_md | |
| env: | |
| DIFF_BASE: ${{ needs.select-tests.outputs.lane_base }} | |
| run: | | |
| globs='**/*.md' | |
| if [ -n "$DIFF_BASE" ] && changed=$(git diff --name-only --diff-filter=d "$DIFF_BASE...HEAD") && | |
| ! grep -qE '(^|/)\.markdownlint[^/]*$|^\.github/workflows/pr-require-checks\.yml$' <<<"$changed"; then | |
| md=$(grep -E '\.md$' <<<"$changed" || true) | |
| if [ -z "$md" ]; then | |
| globs=README.md | |
| elif ! grep -qE '[^A-Za-z0-9._/-]' <<<"$md"; then | |
| globs=$(tr '\n' ' ' <<<"$md") | |
| fi | |
| fi | |
| echo "globs=$globs" >>"$GITHUB_OUTPUT" | |
| - name: Lint markdown | |
| id: markdown | |
| continue-on-error: true | |
| uses: melodic-software/ci-workflows/.github/actions/markdownlint@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| with: | |
| config: .markdownlint-cli2.jsonc | |
| globs: ${{ steps.changed_md.outputs.globs }} | |
| # typos skips hidden directories; naming .changes checks the changelog | |
| # fragments without pulling in .github and .claude. The release branch | |
| # consumes every fragment, and typos exits 64 on a missing path. | |
| - name: Resolve spell-check paths | |
| id: typos_paths | |
| run: | | |
| if [[ -d .changes ]]; then paths='. .changes'; else paths='.'; fi | |
| echo "paths=$paths" >>"$GITHUB_OUTPUT" | |
| - name: Spell-check | |
| id: typos | |
| continue-on-error: true | |
| uses: melodic-software/ci-workflows/.github/actions/typos@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| with: | |
| config: _typos.toml | |
| paths: ${{ steps.typos_paths.outputs.paths }} | |
| # A vendored baseline (a skill pack's, or a tool's gitleaks rules) is | |
| # replaced verbatim from upstream or held byte-equal to its canonical | |
| # copy, so any spelling correction here would read as permanent | |
| # drift. The managed config cannot carry a consumer-local | |
| # exclusion, so the exemption lives on the caller. The judge | |
| # calibration cases are code copied verbatim from other repositories' | |
| # histories, so a correction would change what the judge is measured on; | |
| # the developer-experience labeled samples are judge inputs for the same reason. | |
| exclude: plugins/*/skills/*/vendor/** plugins/*/tools/*/vendor/** plugins/testing/skills/audit/evals/judge-calibration/cases/** plugins/developer-experience/evals/sources/labels/** | |
| # Every run scans only history the commit under test can reach, so a | |
| # finding on another branch cannot fail it (#3599); each branch is scanned | |
| # on its own pull request. A pull request or merge group leaves | |
| # `log-opts` empty and the composite scans its own commits | |
| # (<base>..HEAD). A schedule or dispatch passes HEAD, main's full | |
| # ancestry: the composite's default there is --all, and the unshallow | |
| # fetch above brings in every branch. | |
| - name: Scan for secrets | |
| id: gitleaks | |
| continue-on-error: true | |
| uses: melodic-software/ci-workflows/.github/actions/gitleaks@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| with: | |
| config: .gitleaks.toml | |
| scan-mode: git | |
| log-opts: ${{ contains(fromJSON('["schedule","workflow_dispatch"]'), github.event_name) && 'HEAD' || '' }} | |
| # Offline links and anchors: local files and #fragments only, no | |
| # network. Ungated like the other whole-repo scanners, because a | |
| # docs-only diff is exactly what can break a link. The excludes are test | |
| # data whose links are meant not to resolve here: the attribution audit's | |
| # golden fixtures, and a verbatim copy of an upstream table with a | |
| # site-root-relative link. | |
| - name: Check local links and anchors | |
| id: lychee | |
| continue-on-error: true | |
| uses: melodic-software/ci-workflows/.github/actions/lychee-offline@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| with: | |
| config: lychee.toml | |
| exclude: >- | |
| (^|/)plugins/attribution/skills/audit/evals/fixtures/golden/ | |
| (^|/)scripts/fixtures/hooks-lifecycle-table\.md$ | |
| - name: Check editorconfig conformance | |
| id: editorconfig | |
| continue-on-error: true | |
| uses: melodic-software/ci-workflows/.github/actions/editorconfig-checker@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| with: | |
| config: .editorconfig-checker.json | |
| # The path-scoped readers gate on the filter group of what they read: | |
| # actionlint and the dependabot and workflow schemas on `run_workflows` | |
| # (.github/**), the two manifest schemas and the duplicate-key detector | |
| # in `check-plugins` on `run_manifests`. Both rows carry the docs-only | |
| # term, and on a merge-group, schedule or dispatch run every group reports | |
| # true. | |
| - name: Lint workflows | |
| id: actionlint | |
| if: needs.select-tests.outputs.run_workflows == 'true' | |
| continue-on-error: true | |
| uses: melodic-software/ci-workflows/.github/actions/actionlint@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| - name: Validate dependabot.yml | |
| id: dependabot_schema | |
| if: needs.select-tests.outputs.run_workflows == 'true' | |
| continue-on-error: true | |
| uses: melodic-software/ci-workflows/.github/actions/check-jsonschema@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| with: | |
| builtin-schema: vendor.dependabot | |
| files: .github/dependabot.yml | |
| - name: Validate workflows | |
| id: workflow_schema | |
| if: needs.select-tests.outputs.run_workflows == 'true' | |
| continue-on-error: true | |
| uses: melodic-software/ci-workflows/.github/actions/check-jsonschema@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| with: | |
| builtin-schema: vendor.github-workflows | |
| files: >- | |
| .github/workflows/pr-require-checks.yml | |
| .github/workflows/post-merge-detect-silent-revert.yml | |
| .github/workflows/pr-test-windows.yml | |
| .github/workflows/release-publish-plugins.yml | |
| - name: Verify shebang files are executable | |
| id: exec_bit | |
| continue-on-error: true | |
| uses: melodic-software/ci-workflows/.github/actions/check-exec-bit@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| - name: Check for machine-specific paths | |
| id: machine_paths | |
| continue-on-error: true | |
| uses: melodic-software/ci-workflows/.github/actions/check-machine-paths@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| with: | |
| # The guardrails plugin bundles a path-detection pattern lib whose | |
| # regex bodies self-match this lane's own detector. The autonomy | |
| # security-binding fixtures carry ADVERSARIAL host paths on purpose | |
| # (invented /home users the checker must reject) — sanitizing them | |
| # would gut the regressions they pin. Its graded-fixture manifest is | |
| # the same corpus: it pins each fixture's trusted --credential-roots | |
| # (a real runner home) and expected not-under-root rejections, so it | |
| # necessarily carries those same host paths. | |
| # audit-permission-state is the third case of the same kind: its | |
| # C6-winPath check EXISTS to find Windows paths in permission rules, | |
| # so its detector message quotes one, its criteria file quotes the | |
| # settings page quoting one, and its fixtures must carry them or the | |
| # check has nothing to detect. The oracle fixtures reproduce Claude | |
| # Code's own [DEBUG] narration, which prints native Windows settings | |
| # paths — rewriting those would make the fixture stop matching what | |
| # the parser has to survive. The paths are invented (`alice`, `x`), | |
| # not any operator's. source-control's same-drive unit tests need | |
| # literal `C:/` / `D:/` drive fixtures to exercise the gate (#2764). | |
| exclude: >- | |
| :(exclude)plugins/guardrails/lib/path-detection/** | |
| :(exclude)plugins/autonomy/skills/setup/evals/fixtures/security-binding/** | |
| :(exclude)plugins/autonomy/skills/setup/scripts/check-security-binding.fixtures.test.manifest.json | |
| :(exclude)plugins/harness-config/skills/audit-permission-state/reference/criteria.md | |
| :(exclude)plugins/harness-config/skills/audit-permission-state/scripts/permission-plane-lint.sh | |
| :(exclude)plugins/harness-config/skills/audit-permission-state/scripts/permission-plane-lint.test.sh | |
| :(exclude)plugins/harness-config/skills/audit-permission-state/scripts/automode-entry-diff.test.sh | |
| :(exclude)plugins/source-control/scripts/worktree-create.test.sh | |
| - name: Check index-level EOL drift | |
| id: eol | |
| continue-on-error: true | |
| uses: melodic-software/ci-workflows/.github/actions/check-line-endings@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| - name: Scan comment hygiene | |
| id: comment_hygiene | |
| continue-on-error: true | |
| uses: melodic-software/ci-workflows/.github/actions/check-comment-markers@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| with: | |
| # The audit-comment-residue skill IS a code-comment linter: its detector | |
| # fixtures and shape library necessarily contain the markers this policy | |
| # bans (TODO/FIXME, PR/issue refs) as test corpus and as the documented | |
| # TODO(#issue) sanctioned-exemption. Exempt that skill's scripts + evals | |
| # (its SKILL.md is markdown, already outside the scan's extension set). | |
| # The implementation eval scaffolds write a PLAN.md whose `### Phase N: | |
| # ... [TODO]` heading is the phase status tag the skill under test reads, | |
| # not a comment; the scan sees the heredoc line as a shell comment. | |
| exclude: >- | |
| :(exclude)plugins/code-tidying/skills/audit-comment-residue/scripts/** | |
| :(exclude)plugins/code-tidying/skills/audit-comment-residue/evals/** | |
| :(exclude)plugins/implementation/evals/*/scaffold.sh | |
| - name: Check repo-local hooks are wired in settings.json | |
| id: hook_wiring | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh hook-wiring-liveness {0} | |
| run: scripts/check-hook-wiring-liveness.sh | |
| # Deliberately UNGATED, like the other whole-repo scanners. This gate | |
| # reads markdown prose, which is exactly what a docs-only diff changes, | |
| # so gating it would disable it on precisely the pull requests most able | |
| # to trip it. A regression would then reach main under a green check | |
| # rather than an honest not-applicable. | |
| - name: Check purged surfaces stayed free of em dashes | |
| id: purged_em_dashes | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh purged-em-dashes {0} | |
| run: scripts/check-purged-em-dashes.sh | |
| # Ungated for the same reason: the planning chain's `## Next` sections | |
| # are markdown, and a rename elsewhere can strand one of their targets. | |
| - name: Check the planning chain's Next graph against the workflow ladder | |
| id: next_graph | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh next-graph {0} | |
| run: python3 scripts/check-next-graph.py | |
| # A plugin's prose counts its own skills by hand ("bundling fourteen | |
| # skills", "the other thirteen skills are zero-config") and nothing | |
| # recomputes those numbers when a skill lands, so adding one silently | |
| # falsifies every sentence that counted the old set. Measured: PR #3011 | |
| # went stale on six such counts, each caught by a different reviewer | |
| # rather than by anything mechanical. | |
| # | |
| # A DIFF THAT CHANGED NONE OF A GATE'S INPUTS SKIPS ITS SCAN: the verdict | |
| # over unchanged inputs is the one the last green run gave. Here, in the | |
| # eval-coverage gate of `check-skills` and in the fixture-isolation gate | |
| # of `lint-shell` the inputs are pathspecs beside the call, this workflow | |
| # is always one of them, a failed `git diff` runs the scan, and the | |
| # scheduled run scans the whole tree. Claims live in plugin READMEs, | |
| # manifests and skill bodies, and the count is the skills/ tree. | |
| - name: Check every skill-count claim against the tree | |
| id: skill_count_claims | |
| continue-on-error: true | |
| env: | |
| DIFF_BASE: ${{ needs.select-tests.outputs.lane_base }} | |
| shell: bash scripts/run-hygiene-gate.sh skill-count-claims {0} | |
| run: | | |
| if [ -n "$DIFF_BASE" ] && changed=$(git diff --name-only "$DIFF_BASE...HEAD" -- \ | |
| 'plugins/*.md' 'plugins/*/.claude-plugin/plugin.json' 'plugins/*/skills/*' \ | |
| 'scripts/check-skill-count-claims.sh' scripts/skill-count-claim-exemptions.txt \ | |
| .github/workflows/pr-require-checks.yml) && [ -z "$changed" ]; then | |
| echo "::notice::No skill-count claim input changed since $DIFF_BASE; the scheduled run scans the whole tree." | |
| exit 0 | |
| fi | |
| scripts/check-skill-count-claims.sh --check | |
| - name: Report docs-irrelevant checks not applicable to a docs-only diff | |
| if: needs.select-tests.outputs.run_full == 'false' | |
| run: echo "Diff is within the docs-only allowlist (scripts/docs-only-paths.txt); the path-scoped linters (actionlint, the dependabot and workflow schemas, zizmor, the runner policy) cannot be affected — reporting success for them. Exec-bit, hook-wiring-liveness, purged-em-dashes, and next-graph scan the whole repo and stay unconditional." | |
| # ONE DETERMINISTIC PROSE DETECTOR, AS WARNINGS. The ai-slop audit's own | |
| # detector reads the markdown this pull request changed and annotates | |
| # each finding on its line. It carries no `continue-on-error` and no | |
| # aggregator feed row because it cannot fail: the script exits 0 on every | |
| # path, and a step that cannot fail needs neither the flag nor a row | |
| # reading an outcome that is always `success`. Advisory is the point: the | |
| # annotations are measured before anything is allowed to block on them. | |
| # | |
| # The event branch lives in the script rather than in `if:`, because a | |
| # merge group has no base ref to diff against and a step cannot carry both an | |
| # event gate and a resolver gate. | |
| - name: Report ai-slop findings on changed markdown | |
| id: ai_slop_report | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| run: scripts/ai-slop-report.sh "origin/$BASE_REF" | |
| # Runner policy, previously its own job. Reads .github/workflows/** and | |
| # the pinned policy package, so it gates on the workflows filter group: | |
| # a change that touches nothing under .github/ cannot move a runner | |
| # label. Its Node install is gated on the same group so a run that will | |
| # not enforce the policy does not pay for its toolchain either. | |
| - name: Set up Node | |
| if: needs.select-tests.outputs.run_workflows == 'true' | |
| uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 | |
| with: | |
| node-version-file: .node-version | |
| - name: Restore the npm cache | |
| id: npm_cache | |
| if: needs.select-tests.outputs.run_workflows == 'true' | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.npm | |
| key: npm-${{ runner.os }}-${{ hashFiles('.node-version', '.github/standards/runner-policy/package-lock.json') }} | |
| - name: Install pinned runner-policy dependencies | |
| if: needs.select-tests.outputs.run_workflows == 'true' | |
| run: npm ci --prefix .github/standards/runner-policy | |
| - name: Save the npm cache | |
| if: github.ref == 'refs/heads/main' && steps.npm_cache.outcome == 'success' && steps.npm_cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.npm | |
| key: ${{ steps.npm_cache.outputs.cache-primary-key }} | |
| - name: Enforce runner policy | |
| id: runner_policy | |
| if: needs.select-tests.outputs.run_workflows == 'true' | |
| continue-on-error: true | |
| env: | |
| CI_REPOSITORY_VISIBILITY: ${{ github.event.repository.visibility }} | |
| # The surface-diff auto-approval fetches commit trees from api.github.com | |
| # (runner-policy.mjs fetchCommitTree); authenticated, it skips the shared | |
| # anonymous rate limit. The job token is read-only (`contents: read`). | |
| GITHUB_TOKEN: ${{ github.token }} | |
| shell: bash scripts/run-hygiene-gate.sh runner-policy {0} | |
| run: node .github/standards/runner-policy/runner-policy.mjs --root . | |
| # Parses workflows with the yaml package the runner-policy install | |
| # above provides, so it gates on the same workflows filter group. | |
| - name: Check no run reaching the read workflow references the App key | |
| id: read_caller_keys | |
| if: needs.select-tests.outputs.run_workflows == 'true' | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh read-caller-keys {0} | |
| run: scripts/check-read-caller-keys.sh --check | |
| # Lane tokens come from the lanes token broker; no workflow or action | |
| # may hold the lanes App key or mint as the lanes App (ADR 0058). | |
| - name: Check no workflow or action references the lanes App key | |
| id: app_key_references | |
| if: needs.select-tests.outputs.run_workflows == 'true' | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh app-key-references {0} | |
| run: scripts/check-app-key-references.sh --check | |
| # Every upload-artifact step keeps its artifact for the days its family's | |
| # row in .github/artifact-retention.json names. Parses workflows with the | |
| # yaml package the runner-policy install above provides, so it gates on | |
| # the workflows filter group, which holds the table. | |
| - name: Check every artifact upload matches the retention table | |
| id: artifact_retention | |
| if: needs.select-tests.outputs.run_workflows == 'true' | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh artifact-retention {0} | |
| run: scripts/check-artifact-retention.sh --check | |
| - name: Test hygiene result aggregation | |
| run: scripts/aggregate-hygiene-results.sh --self-test | |
| - name: Aggregate hygiene results | |
| if: always() | |
| env: | |
| # Display names (left) are annotations; step ids (right) use | |
| # underscores. A gated step's intentional skip is mapped to `success` | |
| # here BECAUSE the step provably did not run, so no real outcome is | |
| # masked. The aggregator stays fail-closed on any real non-`success` | |
| # (including `skipped`); it is never told to pass on `skipped`. Each | |
| # override tests the SAME output that gates its step, against that | |
| # output's only other value, so the two can never disagree. | |
| # `scripts/check-docs-only-gate.sh` pins both halves: that the | |
| # override is the sanctioned complement of the gate, and that every | |
| # step named on the right of an override is one that carries the | |
| # matching gate — an override on an UNGATED step, or one paired with | |
| # a different output than the step's gate, would map a skip that | |
| # never happened, and that is the direction that fails open. The | |
| # ungated whole-repo scanners feed their raw outcome. | |
| # Gates that run as `uses:` actions, whose output | |
| # scripts/run-hygiene-gate.sh cannot capture: a failure here quotes | |
| # the job's own check-run annotations (checks: read), else points at | |
| # the step log. | |
| CHECK_RUN_ID: ${{ job.check_run_id }} | |
| GH_TOKEN: ${{ github.token }} | |
| STEP_LOG_CHECKS: markdown typos gitleaks lychee-offline editorconfig actionlint dependabot-schema workflow-schema exec-bit machine-specific-paths eol-renormalize comment-hygiene | |
| CHECK_RESULTS: | | |
| zizmor=${{ needs.select-tests.outputs.run_workflows == 'false' && 'success' || steps.zizmor.outcome }} | |
| loop-lane-floor-drift=${{ steps.loop_lane_floor_drift.outcome }} | |
| conformance-registry=${{ steps.conformance_registry.outcome }} | |
| docs-naming=${{ steps.docs_naming.outcome }} | |
| adr-numbers=${{ steps.adr_numbers.outcome }} | |
| convention-yaml-schema=${{ steps.convention_yaml_schema.outcome }} | |
| read-caller-keys=${{ needs.select-tests.outputs.run_workflows == 'false' && 'success' || steps.read_caller_keys.outcome }} | |
| app-key-references=${{ needs.select-tests.outputs.run_workflows == 'false' && 'success' || steps.app_key_references.outcome }} | |
| artifact-retention=${{ needs.select-tests.outputs.run_workflows == 'false' && 'success' || steps.artifact_retention.outcome }} | |
| config-cascade-semantics=${{ steps.config_cascade_semantics.outcome }} | |
| code-metrics-config-reference=${{ steps.code_metrics_config_reference.outcome }} | |
| code-metrics-skill-prose=${{ steps.code_metrics_skill_prose.outcome }} | |
| line-citations=${{ steps.line_citations.outcome }} | |
| contract-clause-coverage=${{ steps.contract_clause_coverage.outcome }} | |
| stale-base-overlap=${{ steps.stale_base.outcome }} | |
| docs-only-contract=${{ steps.docs_only_contract.outcome }} | |
| lane-coverage=${{ steps.lane_coverage.outcome }} | |
| test-plan-corpus=${{ steps.test_plan_corpus.outcome }} | |
| selector-live=${{ steps.selector_live.outcome }} | |
| markdown=${{ steps.markdown.outcome }} | |
| typos=${{ steps.typos.outcome }} | |
| gitleaks=${{ steps.gitleaks.outcome }} | |
| lychee-offline=${{ steps.lychee.outcome }} | |
| editorconfig=${{ steps.editorconfig.outcome }} | |
| actionlint=${{ needs.select-tests.outputs.run_workflows == 'false' && 'success' || steps.actionlint.outcome }} | |
| dependabot-schema=${{ needs.select-tests.outputs.run_workflows == 'false' && 'success' || steps.dependabot_schema.outcome }} | |
| workflow-schema=${{ needs.select-tests.outputs.run_workflows == 'false' && 'success' || steps.workflow_schema.outcome }} | |
| exec-bit=${{ steps.exec_bit.outcome }} | |
| machine-specific-paths=${{ steps.machine_paths.outcome }} | |
| eol-renormalize=${{ steps.eol.outcome }} | |
| comment-hygiene=${{ steps.comment_hygiene.outcome }} | |
| hook-wiring-liveness=${{ steps.hook_wiring.outcome }} | |
| purged-em-dashes=${{ steps.purged_em_dashes.outcome }} | |
| next-graph=${{ steps.next_graph.outcome }} | |
| skill-count-claims=${{ steps.skill_count_claims.outcome }} | |
| runner-policy=${{ needs.select-tests.outputs.run_workflows == 'false' && 'success' || steps.runner_policy.outcome }} | |
| run: scripts/aggregate-hygiene-results.sh | |
| # THE SHELL LINTERS: ShellCheck, and the repo-local gates that read shell | |
| # source (GNU-only constructs, piped early-exit greps, temp-path cleanup, | |
| # fixture git isolation, discriminating skips, the kill-switch hoist, silent | |
| # prerequisite skips, slow hook shapes). Every one is a gate step: absorbed | |
| # with `continue-on-error`, fed to the aggregator at the foot of this job. Python comes with the pinned requirements file because | |
| # the slow-shapes gate reads hook frontmatter through PyYAML. | |
| # | |
| # ITS OWN JOB BECAUSE OF THE WHOLE-REPOSITORY SCAN. ShellCheck lints the | |
| # changed shell files on most diffs, but the whole repository (up to 292 s | |
| # measured) on the whole tree, or when the rcfile, this workflow or a sourced | |
| # file outside plugins/ changes; inside `lint-repo` that scan would set the | |
| # critical path. | |
| # | |
| # The job skips on a contract-only event, on a draft, and on a change that | |
| # touches none of its inputs (`select-tests`'s `run_shell` row, the `lint_shell` | |
| # filter group). `ci-status` passes that skip only against the row, as it | |
| # does a test lane's. | |
| lint-shell: | |
| needs: [select-tests] | |
| # The contract-only gate, verbatim, the draft gate (see `ci-status`), and | |
| # this lane's ordered skip. | |
| if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && github.event.pull_request.draft != true && needs.select-tests.outputs.run_shell == 'true' }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 9 | |
| # checks: read lets the aggregator quote a composite gate's annotations. | |
| permissions: | |
| contents: read | |
| checks: read | |
| steps: | |
| - name: Check out | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Fetch base | |
| uses: ./.github/actions/download-full-history # zizmor: ignore[self-repository] | |
| # PyYAML reads the frontmatter surface (YAML has no jq) for the slow-shapes | |
| # gate, which fails closed if the module is missing rather than clearing | |
| # files it never parsed, so this install is load-bearing, not convenience. | |
| - name: Set up Python | |
| uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 | |
| with: | |
| python-version-file: .python-version | |
| - name: Restore the pip cache | |
| id: pip_cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/pip | |
| key: pip-${{ runner.os }}-${{ hashFiles('.python-version', '.github/requirements-ci.txt') }} | |
| - name: Install the pinned YAML reader | |
| run: | | |
| python -m pip install --only-binary=:all: --require-hashes \ | |
| --requirement .github/requirements-ci.txt | |
| - name: Save the pip cache | |
| if: github.ref == 'refs/heads/main' && steps.pip_cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/pip | |
| key: ${{ steps.pip_cache.outputs.cache-primary-key }} | |
| # Every guard ships a `<name>_enabled` switch, and an operator who turns | |
| # one off is entitled to stop paying for it. Reading that switch through | |
| # hook::check_enabled means the whole 2,684-line hook-utils.sh is parsed | |
| # first — ~3.5 ms of a disabled standalone guard's ~5.3 ms on this runner. | |
| # The audit that prompted #3712 found the switch below the source in 43 of | |
| # 43 hooks, and scripts/sync-shared-copies.sh covers the vendored library | |
| # copies but NOT the entry scripts, so nothing else here would notice the | |
| # ordering drifting back. Rule 2 holds every other blocking event to the | |
| # same order: a Stop or Notification hook's first early exit comes before | |
| # its first source (#4415). | |
| - name: Check that hooks exit early before sourcing any library | |
| id: killswitch_hoist | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh killswitch-hoist {0} | |
| run: scripts/check-killswitch-hoist.sh | |
| # Two hook shapes a spawn budget misses or every fire pays for: a | |
| # whole-file read of the session transcript (#4408: a bash builtin, no | |
| # extra process, 11.6 s per fire at 10 MB), and a #!/usr/bin/env script | |
| # registered with no leading `bash`, which costs an env exec on every | |
| # fire (#4421). | |
| - name: Check hooks for transcript reads and env-shebang registrations | |
| id: hook_slow_shapes | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh hook-slow-shapes {0} | |
| run: scripts/check-hook-slow-shapes.sh | |
| - name: Check hook entry scripts for silent prerequisite skips | |
| id: silent_skips | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh silent-skips {0} | |
| run: scripts/check-silent-skips.sh | |
| # A skip_case that vacates the only discriminating assertions in a case | |
| # group lets a suite report green while proving nothing. | |
| - name: Check plugin test suites for discriminating skip_case sites | |
| id: discriminating_test_skips | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh discriminating-test-skips {0} | |
| run: scripts/check-discriminating-test-skips.sh | |
| # A test suite that BUILDS a git fixture must clear the inherited git | |
| # environment, in shell and in Python alike. `git -C <fixture>` is a | |
| # readability guard, not an isolation guarantee: an exported ABSOLUTE | |
| # GIT_DIR overrides repository DISCOVERY, so the fixture identity lands | |
| # in the CALLER's .git/config — shared by every worktree of the clone — | |
| # and the caller's next commit is silently re-authored. That failed | |
| # required_signatures with `no_user` and cost #2827 -> #2830 (#2840). | |
| # Skipped on a diff that touches no shell or Python file and not the | |
| # baseline: the gate reads exactly those (see the skill-count gate in | |
| # `lint-repo` for the pattern). | |
| - name: Check fixture git isolation | |
| id: fixture_git_isolation | |
| continue-on-error: true | |
| env: | |
| DIFF_BASE: ${{ needs.select-tests.outputs.lane_base }} | |
| shell: bash scripts/run-hygiene-gate.sh fixture-git-isolation {0} | |
| run: | | |
| if [ -n "$DIFF_BASE" ] && changed=$(git diff --name-only "$DIFF_BASE...HEAD" -- \ | |
| '*.sh' '*.py' scripts/fixture-git-isolation-baseline.txt .github/workflows/pr-require-checks.yml) && | |
| [ -z "$changed" ]; then | |
| echo "::notice::No shell or Python file changed since $DIFF_BASE; the scheduled run scans the whole tree." | |
| exit 0 | |
| fi | |
| scripts/check-fixture-git-isolation.sh --check | |
| # Shell-portability lint (#1491 / #2704): flags GNU-only constructs in | |
| # changed **/*.sh files and skill markdown under plugins/*/skills/ — a | |
| # class neither shellcheck (syntax/style) nor portability-lint | |
| # (skill-coupling tokens, skill files only) covers, and no runner here | |
| # uses BSD userland. Changed-file scoped so enabling a class never | |
| # red-lines main. Skill-md backlog is grandfathered in | |
| # scripts/shell-portability-skill-md-baseline.txt. | |
| - name: Gate changed shell and skill-md files for GNU-only constructs | |
| id: shell_portability | |
| continue-on-error: true | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| shell: bash scripts/run-hygiene-gate.sh shell-portability-lint {0} | |
| run: | | |
| if [ "$GITHUB_EVENT_NAME" != pull_request ]; then | |
| echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff changed shell files against." | |
| exit 0 | |
| fi | |
| scripts/check-shell-portability.sh "origin/$BASE_REF" | |
| # Piped early-exit grep: `producer | grep -q` under pipefail can report a | |
| # producer's SIGPIPE as "no match". The gate scans every *.sh in the repo | |
| # on every run, less the known offenders in | |
| # scripts/pipefail-grep-q-baseline.txt, which only shrinks. | |
| - name: Gate scripts on piped early-exit greps | |
| id: pipefail_grep_q | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh pipefail-grep-q {0} | |
| run: scripts/check-pipefail-grep-q.sh | |
| # A *.test.sh that runs mktemp must trap EXIT to remove it; the known | |
| # offenders are listed in scripts/test-tmp-cleanup-baseline.txt, which | |
| # only shrinks. | |
| - name: Gate test suites on cleaning up their temp paths | |
| id: test_tmp_cleanup | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh test-tmp-cleanup {0} | |
| run: scripts/check-test-tmp-cleanup.sh --check | |
| # WITH A DIFF BASE THE COMPOSITE GETS THE DIFF. It keeps only listed | |
| # paths that still exist and end in .sh or .bash, so a diff with no shell | |
| # file is a non-blank list that keeps nothing: a notice and exit 0, not a | |
| # scan. The filtering is left to the composite so there is one rule. | |
| # | |
| # A CHANGED SOURCED FILE WIDENS THE LIST. ShellCheck follows `source` | |
| # (external-sources in .shellcheckrc), so a library or test harness | |
| # changes the verdict on every file that sources it. Inside a plugin | |
| # (`plugins/<p>/**/lib/*.sh`, `plugins/<p>/**/*-harness.sh` or | |
| # `-helpers.sh`) the list gains every tracked shell file under that plugin | |
| # and every shell file anywhere that names the changed file's basename, | |
| # which is how a sourcing file reaches it. A non-shell file under lib/ | |
| # widens nothing. | |
| # | |
| # A BLANK LIST IS A WHOLE-REPOSITORY SCAN, NOT AN EMPTY ONE: the composite | |
| # cannot tell a blank input from an unset one, so it falls back to its own | |
| # discovery. That is the wanted answer on the whole tree (no diff base: | |
| # a schedule or dispatch run), and on any diff that changes what every script is judged | |
| # against: the rcfile, a sourced shell file outside plugins/ (lib/, | |
| # scripts/lib/, a scripts/ harness), or this workflow, which pins the | |
| # composite and so the ShellCheck version. A failed basename search also | |
| # falls back to it. Deletions stay in the diff so removing a library still | |
| # counts. A path git prints quoted (non-ASCII, tab, newline) or that holds | |
| # a space cannot survive the whitespace-separated list, so it full-scans | |
| # too. This branches in the shell rather than in `if:` because a step | |
| # cannot carry both a resolver gate and an event gate. | |
| # | |
| # THE LANE IS SCOPED, NEVER SKIPPED. Every resolver gate carries the | |
| # docs-only term: a pull request whose only change is a shell script under | |
| # a docs-only prefix resolves docs-only, and a gate would skip ShellCheck | |
| # on the very file it should read while the aggregator override recorded | |
| # that skip as success. So the step always runs and lets the composite | |
| # decide. | |
| - name: Select the changed shell scripts | |
| id: changed_shell | |
| env: | |
| DIFF_BASE: ${{ needs.select-tests.outputs.lane_base }} | |
| run: | | |
| files='' | |
| if [ -n "$DIFF_BASE" ]; then | |
| files=$(git diff --name-only "$DIFF_BASE...HEAD") | |
| whole='^\.shellcheckrc$|^\.github/workflows/pr-require-checks\.yml$|^"| ' | |
| sourced='(^|/)lib/.*\.(sh|bash)$|-(harness|helpers)\.sh$' | |
| extra='' | |
| while IFS= read -r path; do | |
| if [[ $path =~ $whole ]]; then | |
| files='' | |
| break | |
| fi | |
| [[ $path =~ $sourced ]] || continue | |
| if [[ $path != plugins/*/* ]]; then | |
| files='' | |
| break | |
| fi | |
| plugin=${path#plugins/} | |
| plugin=${plugin%%/*} | |
| extra+="$(git ls-files -- "plugins/$plugin/*.sh" "plugins/$plugin/*.bash")"$'\n' | |
| rc=0 | |
| names=$(git grep -l -w -F -e "${path##*/}" -- '*.sh' '*.bash') || rc=$? | |
| if [ "$rc" -gt 1 ]; then | |
| files='' | |
| break | |
| fi | |
| extra+="$names"$'\n' | |
| done <<<"$files" | |
| [ -z "$files" ] || files=$(printf '%s\n%s' "$files" "$extra" | sort -u) | |
| fi | |
| { | |
| printf 'files<<SHELL_FILES\n%s\nSHELL_FILES\n' "$files" | |
| } >>"$GITHUB_OUTPUT" | |
| - name: Lint shell scripts | |
| id: shellcheck | |
| continue-on-error: true | |
| uses: melodic-software/ci-workflows/.github/actions/shellcheck@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| with: | |
| files: ${{ steps.changed_shell.outputs.files }} | |
| rcfile: .shellcheckrc | |
| - name: Aggregate hygiene results | |
| if: always() | |
| env: | |
| # The feed's rules are stated on the one in `lint-repo`; the docs-only | |
| # gate pins them on every job. | |
| # Gates that run as `uses:` actions, whose output | |
| # scripts/run-hygiene-gate.sh cannot capture: a failure here quotes | |
| # the job's own check-run annotations (checks: read), else points at | |
| # the step log. | |
| CHECK_RUN_ID: ${{ job.check_run_id }} | |
| GH_TOKEN: ${{ github.token }} | |
| STEP_LOG_CHECKS: shellcheck | |
| CHECK_RESULTS: | | |
| killswitch-hoist=${{ steps.killswitch_hoist.outcome }} | |
| hook-slow-shapes=${{ steps.hook_slow_shapes.outcome }} | |
| silent-skips=${{ steps.silent_skips.outcome }} | |
| discriminating-test-skips=${{ steps.discriminating_test_skips.outcome }} | |
| fixture-git-isolation=${{ steps.fixture_git_isolation.outcome }} | |
| shell-portability-lint=${{ steps.shell_portability.outcome }} | |
| pipefail-grep-q=${{ steps.pipefail_grep_q.outcome }} | |
| test-tmp-cleanup=${{ steps.test_tmp_cleanup.outcome }} | |
| shellcheck=${{ steps.shellcheck.outcome }} | |
| run: scripts/aggregate-hygiene-results.sh | |
| # PLUGIN CONTRACTS ACROSS FILES: the manifest schemas and duplicate keys, the | |
| # catalog presence and enablement, option docs, the changelog and vendor-bump | |
| # gates, the hook-declaration gates, the performance counter ceilings, the | |
| # cross-plugin drift check, and the delivery checks of the shared-library | |
| # copies. The steps that read the root npm install (`claude plugin validate` | |
| # and the rendered-view HTML assets), with the copy and prerequisite gates | |
| # moved beside them, are `check-catalog`'s: as one job the two took up to | |
| # 125 s against a 3-minute cap that holds a p95 of at most 120. | |
| # | |
| # The hook, catalog and changelog gates run first, on Python with the pinned | |
| # requirements, then the manifest schema steps, whose composite leaves uv on | |
| # PATH for every later step. Node follows for the counter ceilings, gated | |
| # like the steps that read it. | |
| # | |
| # The shared-library bump checks need base history: `download-full-history` | |
| # runs once, at the top, and every bump check below resolves | |
| # `origin/$BASE_REF` from it. Each returns early off a pull request rather than | |
| # diffing against a ref that does not exist. | |
| # | |
| # The job skips only on a contract-only event or a draft (see `lint-repo`). | |
| check-plugins: | |
| needs: [select-tests] | |
| # The contract-only gate, verbatim, and the draft gate (see `ci-status`). | |
| if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && github.event.pull_request.draft != true }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 3 | |
| # pull-requests: read lets "Resolve the queued pull request" read the | |
| # queued pull request in a merge group. | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| steps: | |
| - name: Check out | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Fetch base | |
| uses: ./.github/actions/download-full-history # zizmor: ignore[self-repository] | |
| # PyYAML reads the frontmatter surface (YAML has no jq) for the | |
| # hook-exec-form and summary-reader-parity gates. Those gates fail closed | |
| # if the module is missing rather than clearing files they never parsed, | |
| # so this install is load-bearing, not convenience. It also installs the | |
| # check-jsonschema the manifest schema steps run. | |
| - name: Set up Python | |
| uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 | |
| with: | |
| python-version-file: .python-version | |
| - name: Restore the pip cache | |
| id: pip_cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/pip | |
| key: pip-${{ runner.os }}-${{ hashFiles('.python-version', '.github/requirements-ci.txt') }} | |
| - name: Install the pinned YAML reader | |
| run: | | |
| python -m pip install --only-binary=:all: --require-hashes \ | |
| --requirement .github/requirements-ci.txt | |
| - name: Save the pip cache | |
| if: github.ref == 'refs/heads/main' && steps.pip_cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/pip | |
| key: ${{ steps.pip_cache.outputs.cache-primary-key }} | |
| # A ${user_config.*} token in a plugin hook config silently drops the | |
| # whole hook entry whenever the key is unset (the declared userConfig | |
| # `default` is unimplemented upstream — anthropics/claude-code#46477), so | |
| # the hook never fires on a default install. The channel decision matrix | |
| # (docs/conventions/hook-config-delivery/) rules argv out for hooks; this | |
| # gate pins the exact regression #1242 fixed. | |
| - name: Check plugin hook configs for bare userConfig argv tokens | |
| id: userconfig_argv | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh userconfig-argv {0} | |
| run: scripts/check-hook-userconfig-argv.sh | |
| # An exec-form hook (a hook object carrying `args`) resolves `command` as | |
| # an executable through PATH, so a bare name is machine-dependent: on | |
| # Windows `bash` resolves to the WSL relay System32\bash.exe and `python3` | |
| # to a zero-length WindowsApps alias stub, and the hook never launches. A | |
| # failed launch is a NON-BLOCKING error, so a PreToolUse guard wired this | |
| # way silently enforces nothing. This class has shipped three times in | |
| # disk-hygiene alone (#1006 fixed it, #1504 reintroduced it, #2570 fixed | |
| # it again) while the harness-config audit checklist carried it as an | |
| # `error` row the whole time — a checklist a human reads is not a gate. | |
| - name: Check hook declarations for exec-form bare command names | |
| id: hook_exec_form | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh hook-exec-form {0} | |
| run: scripts/check-hook-exec-form.sh | |
| # Windows exec form spawns a real .exe with an args array and no shell. | |
| # The static half rejects a script, shim, or bare bash used as `command`. | |
| # The spawn half is a reported skip on this Linux lane; that skip does | |
| # not authorize converting .sh rows. The Windows lane runs the spawn | |
| # (#3686, anthropics/claude-code#90495). | |
| - name: Probe exec-form rows for a real Windows executable | |
| id: exec_form_windows_probe | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh exec-form-windows-probe {0} | |
| run: scripts/check-exec-form-windows-probe.sh | |
| # A test that cannot fail (/testing:audit's scan: no assertion, or an | |
| # expected value recomputed from the actual) passes whatever the code | |
| # does. A diff scans only its changed test files, in seconds, and reports | |
| # the gate not applicable when it changes none; with no diff base the | |
| # whole tree is `check-tests`'s. | |
| - name: Check changed tests for tests that cannot fail | |
| id: cant_fail_scan | |
| continue-on-error: true | |
| env: | |
| DIFF_BASE: ${{ needs.select-tests.outputs.lane_base }} | |
| shell: bash scripts/run-hygiene-gate.sh cant-fail-scan {0} | |
| run: | | |
| if [ -z "$DIFF_BASE" ]; then | |
| echo "::notice::No diff base on this $GITHUB_EVENT_NAME run; check-tests scans the whole tree." | |
| exit 0 | |
| fi | |
| scripts/check-cant-fail-scan.sh "$DIFF_BASE" | |
| # A plugin hooks.json takes an optional top-level `description`, the one | |
| # place a plugin labels its hooks as a set (the plugins reference, hooks | |
| # component). The 2026-09-04 hooks-reference audit found it absent in 20 | |
| # of 20 plugins; #3727 and #3750 added it everywhere, and nothing else | |
| # reads the field, so nothing else would notice a new plugin shipping | |
| # without it (#3752). | |
| - name: Check every plugin hooks.json carries a top-level description | |
| id: hooks_description | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh hooks-description {0} | |
| run: scripts/check-hooks-description.sh | |
| # A change to the guardrails PowerShell classifier or its consumers may | |
| # only add refusals (#4682). The differential runs a corpus through the | |
| # base tree and the working tree and takes minutes, so CI runs only its | |
| # suite, through the selector; a PR that touches those guards carries the | |
| # differential's cell table. | |
| # Three separate readers judge `metadata.summary`, and they can drift | |
| # apart: a malformed value reached CI twice in #3162, failing in opposite | |
| # directions, each time after clearing the local check meant to catch it. | |
| # Making them agree is not sufficient on its own, because agreement on a | |
| # wrong rule is still wrong, so this gate pins every verdict to a real | |
| # YAML parse rather than to the other readers, and fails closed without | |
| # PyYAML rather than clearing on the two regex readers alone. | |
| - name: Check that every summary reader returns the same verdict | |
| id: summary_reader_parity | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh summary-reader-parity {0} | |
| run: bash scripts/check-summary-reader-parity.test.sh | |
| # Every option a plugin declares must be documented in that plugin's | |
| # README. The block is generated from the manifest, so the failure this | |
| # catches is an option added to plugin.json whose README block was never | |
| # regenerated -- an undocumented knob is indistinguishable from one that | |
| # does not exist. | |
| - name: Check plugin option docs are in sync with each manifest | |
| id: plugin_options_docs | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh plugin-options-docs {0} | |
| run: python3 scripts/sync-plugin-options-docs.py --check | |
| # #1547: a routine `git merge origin/main` silently dropped | |
| # plugins/guardrails/.claude-plugin/plugin.json for ~14 minutes on a | |
| # branch while .claude-plugin/marketplace.json still catalogued | |
| # "guardrails" at that directory — no conflict markers, nothing to alert | |
| # the author; an AI reviewer reading the diff caught it, not CI. | |
| # check-jsonschema validates the SHAPE of a manifest that exists; it | |
| # cannot see one that is simply absent. Repo-wide and static, so it | |
| # always runs. | |
| - name: Check every catalog entry resolves to a present, name-matching plugin manifest, and no plugin has a top-level bin/ | |
| id: plugin_manifest_presence | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh plugin-manifest-presence {0} | |
| run: scripts/check-plugin-manifest-presence.sh | |
| # The cloud plugin list is derived from the catalog's defaultEnabled, and | |
| # .claude/settings.json overlays its enabledPlugins keys on it. A key that | |
| # names no catalogued plugin silently no-ops, and .claude/cloud- | |
| # bootstrap.sh installs only the marketplace it hardcodes, so the gate | |
| # holds the keys to the catalog, their order, and that marketplace | |
| # name. Repo-wide and static, so it always runs. | |
| - name: Check every enabledPlugins key names a catalogued plugin | |
| id: plugin_catalog_enablement | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh plugin-catalog-enablement {0} | |
| run: scripts/check-plugin-catalog-enablement.sh | |
| # CHANGELOG parity: a versioned plugin must keep a CHANGELOG.md whose | |
| # newest version heading does not outrun the manifest (static --check), | |
| # only the release pull request may move a plugin's version or add a | |
| # CHANGELOG heading, and its bump must be monotonic and documented | |
| # (--check-bump), and no change may DROP a version heading a changelog | |
| # already carried (--check-preserved). Every plugin is in fragment mode, | |
| # so no other pull request bumps a version. | |
| # Existing "versioned but changelog-less" debt is grandfathered by name | |
| # in scripts/changelog-parity-baseline.txt with a stale guard; the bump | |
| # gate is never relaxed by the baseline. | |
| - name: Verify every versioned plugin keeps a CHANGELOG.md at or below its manifest version | |
| id: changelog_parity_check | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh changelog-parity {0} | |
| run: scripts/check-changelog-parity.sh --check | |
| # The merge queue runs the bump, fragment and vendor gates too (#6443 merged a | |
| # hand bump the queue never checked), against HEAD^1: the queued squash | |
| # commit is this pull request's own change on top of main and every entry | |
| # ahead of it. The group event names no pull request, so this step reads | |
| # the queued one from the group's head ref | |
| # (gh-readonly-queue/<base>/pr-<number>-<base sha>): its author and its | |
| # head branch, which the release and Dependabot exemptions read on | |
| # pull_request, and its head commit, fetched so the Dependabot exemption | |
| # reads the pull request's own commits rather than the queue's squash. | |
| # The SHA in the ref is the commit the group is built on, not the pull | |
| # request's head, so the head comes from the pull request itself. | |
| - name: Resolve the queued pull request | |
| id: queued_pr | |
| if: github.event_name == 'merge_group' | |
| continue-on-error: true | |
| env: | |
| QUEUE_HEAD_REF: ${{ github.event.merge_group.head_ref }} | |
| GH_TOKEN: ${{ github.token }} | |
| shell: bash scripts/run-hygiene-gate.sh queued-pr {0} | |
| run: | | |
| if [[ ! "$QUEUE_HEAD_REF" =~ /pr-([0-9]+)-[0-9a-f]{40}$ ]]; then | |
| echo "::error::Cannot read a pull request number from the merge group's head ref '$QUEUE_HEAD_REF'." | |
| exit 1 | |
| fi | |
| number="${BASH_REMATCH[1]}" | |
| pr="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$number")" | |
| author="$(jq -r '.user.login' <<<"$pr")" | |
| head_sha="$(jq -r '.head.sha' <<<"$pr")" | |
| # A fork's branch name is not this repository's release branch. | |
| head_ref="$(jq -r --arg repo "$GITHUB_REPOSITORY" 'if .head.repo.full_name == $repo then .head.ref else "" end' <<<"$pr")" | |
| git fetch --no-tags origin "$head_sha" | |
| echo "Queued pull request #$number by $author, head ${head_sha:0:9}${head_ref:+ on $head_ref}." | |
| { | |
| echo "author=$author" | |
| echo "head_ref=$head_ref" | |
| echo "head_sha=$head_sha" | |
| } >>"$GITHUB_OUTPUT" | |
| - name: Verify only the release pull request versions a plugin, monotonically | |
| id: changelog_parity_bump | |
| continue-on-error: true | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| # The head branch of a pull request from this repository, never a | |
| # fork's: scripts/lib/changelog-fragments.sh reads `release/plugins` | |
| # here as the release pull request. | |
| CHANGELOG_HEAD_REF: ${{ github.event_name == 'merge_group' && steps.queued_pr.outputs.head_ref || github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || '' }} | |
| QUEUED_PR: ${{ steps.queued_pr.outcome }} | |
| shell: bash scripts/run-hygiene-gate.sh changelog-parity-bump {0} | |
| run: | | |
| if [ "$GITHUB_EVENT_NAME" = merge_group ]; then | |
| if [ "$QUEUED_PR" != success ]; then | |
| echo "::error::The queued pull request could not be resolved; refusing to pass the bump gate without knowing whether it is the release." | |
| exit 1 | |
| fi | |
| scripts/check-changelog-parity.sh --check-bump 'HEAD^1' | |
| elif [ "$GITHUB_EVENT_NAME" = pull_request ]; then | |
| # A version collision or regression here means the branch is only | |
| # behind a main that moved; the merge queue run above enforces it | |
| # against the tree the change lands on (#7871). | |
| CHANGELOG_PARITY_COLLISIONS=advisory scripts/check-changelog-parity.sh --check-bump "origin/$BASE_REF" | |
| else | |
| echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." | |
| fi | |
| # The other half of the same discipline: the bump gate polices what a | |
| # change set ADDS and never what it REMOVES, so a merge-forward that | |
| # writes the new release under the PREVIOUS release's heading deletes a | |
| # released section with no conflict marker and no gate to catch it | |
| # (#2264). | |
| - name: Verify no changed changelog drops a version heading it carried at the fork point | |
| id: changelog_parity_preserved | |
| continue-on-error: true | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| shell: bash scripts/run-hygiene-gate.sh changelog-parity-preserved {0} | |
| run: | | |
| if [ "$GITHUB_EVENT_NAME" != pull_request ]; then | |
| echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no fork point to compare against." | |
| exit 0 | |
| fi | |
| scripts/check-changelog-parity.sh --check-preserved "origin/$BASE_REF" | |
| # Whole-file, not per-diff: the other two modes reason about one version | |
| # at a time, so neither can see that two branches staged the same number | |
| # or that a stale-based entry landed below one already on main. | |
| - name: Verify every changelog reads newest-first with no duplicate versions | |
| id: changelog_parity_order | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh changelog-parity-order {0} | |
| run: scripts/check-changelog-parity.sh --check-order | |
| # Changelog fragments (ADR 0048). A plugin in scripts/fragment-plugins.txt | |
| # takes a fragment under .changes/<plugin>/ in each pull request, and the | |
| # release pull request (branch release/plugins) bumps it. --check | |
| # validates every fragment in the tree; --check-required asks a change to | |
| # such a plugin for one. --check-release keeps a release from merging over | |
| # a file it changes (a consumed fragment, a CHANGELOG.md, a plugin.json) | |
| # that the base changed since the release was cut: on the release pull | |
| # request against the base, and in the merge queue against the group's | |
| # base commit, `merge_group.base_sha` ("The SHA of the merge group's | |
| # parent commit", https://docs.github.com/en/webhooks/webhook-events-and-payloads#merge_group). | |
| # That base already holds the entries ahead in the queue: a group has | |
| # "changes from pull requests ahead of it in the queue" | |
| # (https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue). | |
| # The queue checks out a single-parent squash whose HEAD^1 is that base, | |
| # so the release is read from the queued pull request's head instead. | |
| - name: Verify every changelog fragment is well formed | |
| id: changelog_fragments_check | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh changelog-fragments {0} | |
| run: scripts/check-changelog-fragments.sh --check | |
| - name: Verify a change to a fragment-mode plugin adds a fragment | |
| id: changelog_fragments_required | |
| continue-on-error: true | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| CHANGELOG_HEAD_REF: ${{ github.event_name == 'merge_group' && steps.queued_pr.outputs.head_ref || github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || '' }} | |
| # A Dependabot-only pull request needs no fragment; the script checks | |
| # each commit's GitHub-verified signature before it trusts this. | |
| CHANGELOG_PR_AUTHOR: ${{ github.event_name == 'merge_group' && steps.queued_pr.outputs.author || github.event.pull_request.user.login }} | |
| CHANGELOG_PR_HEAD: ${{ steps.queued_pr.outputs.head_sha }} | |
| GH_TOKEN: ${{ github.token }} | |
| QUEUED_PR: ${{ steps.queued_pr.outcome }} | |
| shell: bash scripts/run-hygiene-gate.sh changelog-fragments-required {0} | |
| run: | | |
| if [ "$GITHUB_EVENT_NAME" = merge_group ]; then | |
| if [ "$QUEUED_PR" != success ]; then | |
| echo "::error::The queued pull request could not be resolved; refusing to pass the fragment gate without knowing its author and branch." | |
| exit 1 | |
| fi | |
| scripts/check-changelog-fragments.sh --check-required 'HEAD^1' | |
| elif [ "$GITHUB_EVENT_NAME" = pull_request ]; then | |
| scripts/check-changelog-fragments.sh --check-required "origin/$BASE_REF" | |
| else | |
| echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." | |
| fi | |
| - name: Verify a release consumes every pending fragment for the plugins it bumps | |
| id: changelog_fragments_release | |
| continue-on-error: true | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| CHANGELOG_HEAD_REF: ${{ github.event_name == 'merge_group' && steps.queued_pr.outputs.head_ref || github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || '' }} | |
| CHANGELOG_PR_HEAD: ${{ steps.queued_pr.outputs.head_sha }} | |
| MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} | |
| QUEUED_PR: ${{ steps.queued_pr.outcome }} | |
| shell: bash scripts/run-hygiene-gate.sh changelog-fragments-release {0} | |
| run: | | |
| if [ "$GITHUB_EVENT_NAME" = merge_group ]; then | |
| if [ "$QUEUED_PR" != success ]; then | |
| echo "::error::The queued pull request could not be resolved; refusing to pass the release gate without knowing whether it is the release." | |
| exit 1 | |
| fi | |
| if [ "$CHANGELOG_HEAD_REF" != release/plugins ]; then | |
| echo "::notice::Declined: the queued pull request is not the release." | |
| exit 0 | |
| fi | |
| if [ -z "$MERGE_GROUP_BASE_SHA" ]; then | |
| echo "::error::The merge group carries no base_sha; refusing to judge the release without the commit it lands on." | |
| exit 1 | |
| fi | |
| scripts/check-changelog-fragments.sh --check-release "$MERGE_GROUP_BASE_SHA" | |
| elif [ "$GITHUB_EVENT_NAME" = pull_request ] && [ "$CHANGELOG_HEAD_REF" = release/plugins ]; then | |
| scripts/check-changelog-fragments.sh --check-release "origin/$BASE_REF" | |
| else | |
| echo "::notice::Declined: only the release pull request and the merge queue release plugins." | |
| fi | |
| # Vendor version bump: ADR 0019's intra-plugin sharing keeps ONE | |
| # committed copy at the plugin root (vendor/), so no sync-*.sh byte-drift | |
| # gate applies; the invariant that replaces it is delivery-by-version — a | |
| # vendor/ source change must bump the plugin's manifest version, the | |
| # update cache key, or installed consumers never receive it. Only prose | |
| # enforced that, and two unbumped vendor edits shipped (b3445bc2, | |
| # b01dace3). | |
| - name: Verify a vendor/ source change bumps the plugin's version | |
| id: vendor_version_bump | |
| continue-on-error: true | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| # The Dependabot exemption, read as changelog_fragments_required reads it. | |
| CHANGELOG_PR_AUTHOR: ${{ github.event_name == 'merge_group' && steps.queued_pr.outputs.author || github.event.pull_request.user.login }} | |
| CHANGELOG_PR_HEAD: ${{ steps.queued_pr.outputs.head_sha }} | |
| GH_TOKEN: ${{ github.token }} | |
| QUEUED_PR: ${{ steps.queued_pr.outcome }} | |
| shell: bash scripts/run-hygiene-gate.sh vendor-version-bump {0} | |
| run: | | |
| if [ "$GITHUB_EVENT_NAME" = merge_group ]; then | |
| if [ "$QUEUED_PR" != success ]; then | |
| echo "::error::The queued pull request could not be resolved; refusing to pass the vendor gate without knowing its author." | |
| exit 1 | |
| fi | |
| scripts/check-vendor-version-bump.sh --check-bump 'HEAD^1' | |
| elif [ "$GITHUB_EVENT_NAME" = pull_request ]; then | |
| scripts/check-vendor-version-bump.sh --check-bump "origin/$BASE_REF" | |
| else | |
| echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." | |
| fi | |
| # check-jsonschema comes from the hash-locked requirements install above, | |
| # not the ci-workflows action: its setup-uv download from | |
| # releases.astral.sh stalled 116 s in merge group run 40676402542 and the | |
| # job hit its cap, and its uvx then fetched a second CPython. | |
| - name: Validate marketplace manifest | |
| id: marketplace_schema | |
| if: needs.select-tests.outputs.run_manifests == 'true' | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh marketplace-schema {0} | |
| run: check-jsonschema --schemafile https://json.schemastore.org/claude-code-marketplace.json .claude-plugin/marketplace.json | |
| - name: Validate plugin manifests | |
| id: plugin_schema | |
| if: needs.select-tests.outputs.run_manifests == 'true' | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh plugin-schema {0} | |
| run: check-jsonschema --schemafile https://json.schemastore.org/claude-code-plugin-manifest.json plugins/*/.claude-plugin/plugin.json | |
| # JSON Schema validates the ALREADY-PARSED document, so it structurally | |
| # cannot see a duplicate object key -- by the time a schema runs, the | |
| # earlier member is already gone (every JSON parser resolves a repeated | |
| # key last-wins). #1492 shipped plugins/skill-quality/.claude-plugin/ | |
| # plugin.json with two "version" members through this exact schema step | |
| # green. scripts/check-manifest-duplicate-keys.py reads the raw | |
| # key/value pairs via object_pairs_hook, BEFORE that collapse, so it | |
| # catches what the schema step next to it cannot. See #1498. | |
| - name: Detect duplicate keys in plugin/marketplace manifests | |
| id: duplicate_keys | |
| if: needs.select-tests.outputs.run_manifests == 'true' | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh manifest-duplicate-keys {0} | |
| run: python3 scripts/check-manifest-duplicate-keys.py | |
| # Node for the counter ceilings, whose hook rows start each hook through | |
| # its node launcher. They read no root npm install: that is | |
| # `check-catalog`'s. Gated like that step, so a docs-only diff does not | |
| # pay for it. | |
| - name: Set up Node | |
| if: needs.select-tests.outputs.run_tests == 'true' | |
| uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 | |
| with: | |
| node-version-file: .node-version | |
| # The shared-library delivery checks (ADR 0019): a change to a canonical | |
| # bumps every carrying plugin. That the copies match their canonical is | |
| # checked in `check-catalog`. The bump checks need a base ref, so they | |
| # return early off a pull request rather than diffing against a ref that | |
| # does not exist. | |
| - name: Verify carrying plugins bumped when a generated copy's canonical changed | |
| if: needs.select-tests.outputs.run_tests == 'true' | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| run: | | |
| if [ "$GITHUB_EVENT_NAME" != pull_request ]; then | |
| echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." | |
| exit 0 | |
| fi | |
| scripts/sync-shared-copies.sh --check-bump "origin/$BASE_REF" | |
| - name: Verify contract, changelog, and carrying plugins bumped together | |
| if: needs.select-tests.outputs.run_tests == 'true' | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| run: | | |
| if [ "$GITHUB_EVENT_NAME" != pull_request ]; then | |
| echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff against." | |
| exit 0 | |
| fi | |
| scripts/check-standards-contract-bump.sh "origin/$BASE_REF" | |
| - name: Check for unregistered or drifted cross-plugin source clusters | |
| if: needs.select-tests.outputs.run_tests == 'true' | |
| run: scripts/check-cross-plugin-source-drift.sh --check | |
| # Counter ceilings from .performance/ratchets.json: each counter's command | |
| # runs and the step fails when one rises above its checked-in ceiling. | |
| # Counters only, never durations, so the answer does not move with runner | |
| # load. A lower count is lowered in the file by hand in the same PR | |
| # (`ratchet.py propose-tighten --write`); there is no scheduled tightening | |
| # job. A runner image change can also lower a count: the check log then | |
| # shows `propose-tighten can lower it`, and the ceiling is lowered by hand | |
| # in a PR. The hook counters run through scripts/hook-census.sh under | |
| # strace, so its suite runs first here: no other lane runs a scripts/ | |
| # suite that needs strace. The counts depend on the image's bash, git, jq | |
| # and coreutils, so the versions are printed beside every check: an ABOVE | |
| # after an image bump reads in one log. The js-complexity-share counter | |
| # runs lizard from the pinned install above (.github/requirements-ci.txt), | |
| # and its version is printed the same way. | |
| - name: Check performance counter ceilings | |
| if: needs.select-tests.outputs.run_tests == 'true' | |
| run: | | |
| bash scripts/hook-census.test.sh | |
| bash scripts/hook-census.sh --versions | |
| echo "lizard $(lizard --version)" | |
| python3 plugins/performance/scripts/ratchet.py check --file .performance/ratchets.json | |
| - name: Report not applicable to a docs-only diff | |
| if: needs.select-tests.outputs.run_tests == 'false' | |
| run: echo "The diff is within the docs-only allowlist (scripts/docs-only-paths.txt); the manifest schemas and duplicate-key detector, the shared-library bump checks, the cross-plugin drift check and the counter ceilings cannot be affected — reporting success." | |
| - name: Aggregate hygiene results | |
| if: always() | |
| env: | |
| # The feed's rules are stated on the one in `lint-repo`; the docs-only | |
| # gate pins them on every job. | |
| CHECK_RESULTS: | | |
| userconfig-argv=${{ steps.userconfig_argv.outcome }} | |
| hook-exec-form=${{ steps.hook_exec_form.outcome }} | |
| exec-form-windows-probe=${{ steps.exec_form_windows_probe.outcome }} | |
| cant-fail-scan=${{ steps.cant_fail_scan.outcome }} | |
| hooks-description=${{ steps.hooks_description.outcome }} | |
| summary-reader-parity=${{ steps.summary_reader_parity.outcome }} | |
| plugin-options-docs=${{ steps.plugin_options_docs.outcome }} | |
| plugin-manifest-presence=${{ steps.plugin_manifest_presence.outcome }} | |
| plugin-catalog-enablement=${{ steps.plugin_catalog_enablement.outcome }} | |
| changelog-parity=${{ steps.changelog_parity_check.outcome }} | |
| queued-pr=${{ github.event_name != 'merge_group' && 'success' || steps.queued_pr.outcome }} | |
| changelog-parity-bump=${{ steps.changelog_parity_bump.outcome }} | |
| changelog-parity-preserved=${{ steps.changelog_parity_preserved.outcome }} | |
| changelog-parity-order=${{ steps.changelog_parity_order.outcome }} | |
| changelog-fragments=${{ steps.changelog_fragments_check.outcome }} | |
| changelog-fragments-required=${{ steps.changelog_fragments_required.outcome }} | |
| changelog-fragments-release=${{ steps.changelog_fragments_release.outcome }} | |
| vendor-version-bump=${{ steps.vendor_version_bump.outcome }} | |
| marketplace-schema=${{ needs.select-tests.outputs.run_manifests == 'false' && 'success' || steps.marketplace_schema.outcome }} | |
| plugin-schema=${{ needs.select-tests.outputs.run_manifests == 'false' && 'success' || steps.plugin_schema.outcome }} | |
| manifest-duplicate-keys=${{ needs.select-tests.outputs.run_manifests == 'false' && 'success' || steps.duplicate_keys.outcome }} | |
| run: scripts/aggregate-hygiene-results.sh | |
| # THE PLUGIN CHECKS THAT READ THE ROOT NPM INSTALL: `claude plugin validate` | |
| # over the plugins and the catalog (with the contract, catalog, cheatsheet | |
| # and native-overlap drift checks and the mod tests scripts/validate-plugins.sh | |
| # runs) and the rendered-view HTML assets, plus the shared-library copy and | |
| # declared-prerequisite gates, which moved with them and need Node at most. | |
| # | |
| # ITS OWN JOB BECAUSE OF THE 3-MINUTE CAP. Inside `check-plugins` these | |
| # steps took 54 s of a job that ran 99 to 125 s on the scheduled runs of | |
| # 2026-10-23 to 10-26, and 1.5 x 125 is over 180. Apart, each job's steps, | |
| # its own setup and checkout included, summed to under 80 s on the slowest | |
| # of those runs. Neither job sets the critical path, which `check-skills` | |
| # holds: this is the split the timeout rule asks of a job that outgrows its | |
| # cap, not a domain that shortens the critical path. | |
| # | |
| # Every step, the checkout included, is gated like the steps that read the | |
| # install, so a docs-only diff pays for no checkout or install. None is a | |
| # `continue-on-error` gate: each fails the job directly, so this job has no | |
| # aggregator feed. | |
| # | |
| # The job skips only on a contract-only event or a draft (see `lint-repo`). | |
| check-catalog: | |
| needs: [select-tests] | |
| # The contract-only gate, verbatim, and the draft gate (see `ci-status`). | |
| if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && github.event.pull_request.draft != true }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 3 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Check out | |
| if: needs.select-tests.outputs.run_tests == 'true' | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| # The plugin validation diffs against the lane base and against | |
| # origin/<base_ref>, which this fetches on a pull request. | |
| - name: Fetch base | |
| if: needs.select-tests.outputs.run_tests == 'true' | |
| uses: ./.github/actions/download-full-history # zizmor: ignore[self-repository] | |
| # The pinned interpreter for the native-overlap registry checks | |
| # scripts/validate-plugins.sh runs. They read no package, so nothing is | |
| # installed into it. | |
| - name: Set up Python | |
| if: needs.select-tests.outputs.run_tests == 'true' | |
| uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 | |
| with: | |
| python-version-file: .python-version | |
| # The root npm install, for the steps below that read it: htmlhint and the | |
| # claude CLI from node_modules/.bin, and node for the prerequisite gate. | |
| - name: Set up Node | |
| if: needs.select-tests.outputs.run_tests == 'true' | |
| uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 | |
| with: | |
| node-version-file: .node-version | |
| - name: Restore the npm cache | |
| id: npm_cache | |
| if: needs.select-tests.outputs.run_tests == 'true' | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.npm | |
| key: npm-${{ runner.os }}-${{ hashFiles('.node-version', 'package-lock.json') }} | |
| - name: Install the locked root toolchain | |
| if: needs.select-tests.outputs.run_tests == 'true' | |
| run: | | |
| npm ci | |
| echo "$GITHUB_WORKSPACE/node_modules/.bin" >> "$GITHUB_PATH" | |
| - name: Save the npm cache | |
| if: github.ref == 'refs/heads/main' && steps.npm_cache.outcome == 'success' && steps.npm_cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.npm | |
| key: ${{ steps.npm_cache.outputs.cache-primary-key }} | |
| # The html-assets gate needs the root npm ci above (its linter is the | |
| # exact-pinned htmlhint in node_modules/.bin): a tracked rendered-view | |
| # asset must lint clean and be registered in the gate's manifest, per the | |
| # rendered-views convention. | |
| - name: Check rendered-view HTML assets | |
| if: needs.select-tests.outputs.run_tests == 'true' | |
| run: scripts/check-html-assets.sh | |
| - name: Validate plugin and catalog manifests | |
| if: needs.select-tests.outputs.run_tests == 'true' | |
| # validate-plugins.sh runs scripts/validate-plugin-contracts.mjs, whose | |
| # retirements append-only check diffs each plugins/*/retirements.yaml | |
| # against this ref and skips (with a notice) when it is unset. The | |
| # download-full-history step above already deepens history and fetches | |
| # origin/<base_ref> on pull_request, so the ref resolves here. | |
| # | |
| # A diff validates the plugins it touched: each per-plugin pass reads | |
| # only its own directory. Every repository-wide pass (contracts, | |
| # catalog, the --strict catalog pass, mods) still runs. Every plugin | |
| # when there is no diff base, or when the validator, its report | |
| # renderer, the CLI's lockfile or this workflow changed. | |
| env: | |
| VALIDATE_CONTRACTS_BASE_REF: origin/${{ github.base_ref || 'main' }} | |
| DIFF_BASE: ${{ needs.select-tests.outputs.lane_base }} | |
| run: | | |
| if [ -n "$DIFF_BASE" ] && changed=$(git diff --name-only "$DIFF_BASE...HEAD") && | |
| ! grep -qE '^(\.github/workflows/pr-require-checks\.yml|scripts/validate-plugins\.sh|scripts/plugin-validate-report\.mjs|package(-lock)?\.json)$' <<<"$changed"; then | |
| touched='' | |
| while IFS= read -r plugin; do | |
| [ -d "plugins/$plugin" ] && touched+="$plugin " | |
| done < <(sed -n 's#^plugins/\([^/]*\)/.*#\1#p' <<<"$changed" | sort -u) | |
| scripts/validate-plugins.sh --only "$touched" | |
| else | |
| scripts/validate-plugins.sh | |
| fi | |
| # The shared-library gates (ADR 0019). Every per-plugin copy is generated | |
| # from one canonical source by scripts/sync-shared-copies.sh, and CI only | |
| # verifies that the copies match their canonical. That a change to a | |
| # canonical bumps every carrying plugin is checked in `check-plugins`. The | |
| # libraries' own suites are corpus suites, reached by the selector. | |
| - name: Verify generated shared-library copies match their canonical sources | |
| if: needs.select-tests.outputs.run_tests == 'true' | |
| run: scripts/sync-shared-copies.sh --check | |
| # docs/conventions/prerequisites/: the gate validates every | |
| # prerequisites.json and fails on an undeclared tool not listed in | |
| # scripts/prerequisites-baseline.txt. Its suites run through the selector. | |
| - name: Check plugin prerequisites are declared | |
| if: needs.select-tests.outputs.run_tests == 'true' | |
| run: node scripts/check-declared-prerequisites.mjs | |
| - name: Report not applicable to a docs-only diff | |
| if: needs.select-tests.outputs.run_tests == 'false' | |
| run: echo "The diff is within the docs-only allowlist (scripts/docs-only-paths.txt); the plugin validation, the rendered-view HTML assets, the shared-library copies and the declared prerequisites cannot be affected — reporting success." | |
| # SKILL AND EVAL CONTRACTS: check 25 and the changed-skills gate, the | |
| # portability lint, leaf names, spoke paths, the listing budgets, the eval | |
| # schema and quality lint, the invocation probes, the detector/eval crosswalk | |
| # and coverage, orphaned fixtures, the fleet finding and grammar gates, and | |
| # precompute composition. Python comes with the pinned requirements file, and | |
| # the eval-coverage gate reads the pinned shfmt. | |
| # | |
| # ITS OWN JOB BECAUSE OF CHECK 25. The full skill corpus (about 300 s) runs on | |
| # a diff that touches the checker or its inputs, and the changed-skills gate | |
| # checks each changed skill in turn; beside `lint-repo` either one would set | |
| # the critical path. | |
| # | |
| # The job skips only on a contract-only event or a draft (see `lint-repo`). | |
| check-skills: | |
| needs: [select-tests] | |
| # The contract-only gate, verbatim, and the draft gate (see `ci-status`). | |
| if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && github.event.pull_request.draft != true }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 15 | |
| # checks: read lets the aggregator quote a composite gate's annotations. | |
| permissions: | |
| contents: read | |
| checks: read | |
| steps: | |
| - name: Check out | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Fetch base | |
| uses: ./.github/actions/download-full-history # zizmor: ignore[self-repository] | |
| # The pinned interpreter and requirements: the fleet grammar gate probes a | |
| # collector that runs python3. | |
| - name: Set up Python | |
| uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 | |
| with: | |
| python-version-file: .python-version | |
| - name: Restore the pip cache | |
| id: pip_cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/pip | |
| key: pip-${{ runner.os }}-${{ hashFiles('.python-version', '.github/requirements-ci.txt') }} | |
| - name: Install the pinned YAML reader | |
| run: | | |
| python -m pip install --only-binary=:all: --require-hashes \ | |
| --requirement .github/requirements-ci.txt | |
| - name: Save the pip cache | |
| if: github.ref == 'refs/heads/main' && steps.pip_cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/pip | |
| key: ${{ steps.pip_cache.outputs.cache-primary-key }} | |
| - name: Check every crosswalk row argues its disposition from a stated test | |
| id: detector_findings_crosswalk | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh detector-findings-crosswalk {0} | |
| run: scripts/check-detector-findings-crosswalk.sh --check | |
| # Emit call sites are read from `shfmt --to-json`. v3.12.0 drops a call | |
| # after `#` following a command substitution; v3.13.0 is the first | |
| # release that keeps it, and this step installs the same v3.14.1 pin and | |
| # SHA-256 as .claude/cloud-bootstrap.sh. The release publishes no | |
| # sha256sums asset, so the hash is of that exact linux_amd64 binary. | |
| # A missing or older binary is exit 2, so this job cannot skip the tool. | |
| # The download is cached by version, as in `test-bash`. | |
| - name: Restore the pinned tool downloads | |
| id: tools_cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/ci-tools | |
| key: ci-tools-${{ runner.os }}-shfmt-v3.14.1 | |
| - name: Install pinned shfmt | |
| env: | |
| SHFMT_VERSION: v3.14.1 | |
| SHFMT_SHA256: 76e77641faa025814b77f153b29796b8e6fa2fca03e0c76a691608b86c7ea7bf | |
| run: | | |
| mkdir -p "$HOME/.local/bin" "$HOME/.cache/ci-tools" | |
| cached="$HOME/.cache/ci-tools/shfmt-$SHFMT_VERSION" | |
| [ -f "$cached" ] || curl -fSL --no-progress-meter --retry 3 --retry-all-errors -o "$cached" \ | |
| "https://github.com/mvdan/sh/releases/download/$SHFMT_VERSION/shfmt_${SHFMT_VERSION}_linux_amd64" | |
| cp "$cached" "$HOME/.local/bin/shfmt" | |
| echo "$SHFMT_SHA256 $HOME/.local/bin/shfmt" | sha256sum -c - | |
| chmod +x "$HOME/.local/bin/shfmt" | |
| echo "$HOME/.local/bin" >> "$GITHUB_PATH" | |
| export PATH="$HOME/.local/bin:$PATH" | |
| shfmt --version | |
| test "$(shfmt --version)" = "$SHFMT_VERSION" | |
| - name: Save the pinned tool downloads | |
| if: github.ref == 'refs/heads/main' && steps.tools_cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/ci-tools | |
| key: ${{ steps.tools_cache.outputs.cache-primary-key }} | |
| # Both directions of the detector/eval check-id crosswalk: every id a | |
| # detector emits must be named by an eval case, and every id an eval case | |
| # names must be one the detector can emit. The second direction is what | |
| # catches a suite asserting a scope the detector outgrew (#4149). | |
| # Skipped on a diff that touches no skill script or eval set: every | |
| # registered pair and every discovered candidate lives under | |
| # plugins/*/skills/*/{scripts,evals}/ (see the skill-count gate in | |
| # `lint-repo` for the pattern). | |
| - name: Check every emitted check id has an eval case, and every eval-named id is emittable | |
| id: detector_eval_coverage | |
| continue-on-error: true | |
| env: | |
| DIFF_BASE: ${{ needs.select-tests.outputs.lane_base }} | |
| shell: bash scripts/run-hygiene-gate.sh detector-eval-coverage {0} | |
| run: | | |
| if [ -n "$DIFF_BASE" ] && changed=$(git diff --name-only "$DIFF_BASE...HEAD" -- \ | |
| 'plugins/*/skills/*/scripts/*' 'plugins/*/skills/*/evals/*' \ | |
| 'scripts/check-detector-eval-coverage.sh' .github/workflows/pr-require-checks.yml) && [ -z "$changed" ]; then | |
| echo "::notice::No skill script or eval set changed since $DIFF_BASE; the scheduled run checks every pair." | |
| exit 0 | |
| fi | |
| scripts/check-detector-eval-coverage.sh --check | |
| - name: Check for unregistered cross-plugin skill leaf-name collisions | |
| id: skill_leaf_names | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh skill-leaf-names {0} | |
| run: scripts/check-skill-leaf-names.sh --check | |
| - name: Check skill spokes do not name paths through the plugin-root token | |
| id: spoke_plugin_root | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh spoke-plugin-root {0} | |
| run: scripts/check-spoke-plugin-root.sh --check | |
| # Every file under a skill's evals/fixtures/ must be consumed by a | |
| # grader — an eval files[] entry or a test assertion — so an ungraded | |
| # suite cannot sit in the tree reading as tested. Existing debt is | |
| # grandfathered in scripts/orphaned-fixtures-baseline.txt with a stale | |
| # guard. | |
| - name: Check for orphaned eval fixtures | |
| id: orphaned_fixtures | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh orphaned-fixtures {0} | |
| run: scripts/check-orphaned-fixtures.sh --check | |
| # Every finding kind audit-fleet.sh emits must have a Finding: <kind> | |
| # collector-output assertion in audit-fleet.test.sh (or be | |
| # grandfathered). Lives outside the suite so a stale-base squash that | |
| # rewrites the test file alongside the collector cannot delete its own | |
| # defender (#2656). | |
| - name: "Check every emitted fleet finding kind has a Finding: assertion" | |
| id: fleet_finding_test_coverage | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh fleet-finding-test-coverage {0} | |
| run: scripts/check-fleet-finding-test-coverage.sh --check | |
| # The audit skill's documented argument grammar must match what | |
| # audit-fleet.sh actually accepts. Lives outside both SKILL.md and the | |
| # collector so a docs-only silent revert (#2646) cannot delete its own | |
| # defender (#2713). Probes the script under a sealed env. | |
| - name: Check audit skill grammar matches the parser | |
| id: fleet_audit_doc_grammar | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh fleet-audit-doc-grammar {0} | |
| run: scripts/check-fleet-audit-doc-grammar.sh --check | |
| # Skill-regression net: the only lane that invokes the skill-quality | |
| # checker. On a pull request it runs the static contract gate | |
| # (trigger-keyword preservation vs the base ref, frontmatter, line caps, | |
| # broken refs, evals presence) over each changed skill under | |
| # plugins/*/skills/**. Eval sets are schema-validated AND quality-linted | |
| # on every event. | |
| # | |
| # The whole corpus (every SKILL.md through the full checker, about 300 s | |
| # of this job) runs on a pull request only when `skill_checker` matched, | |
| # on a merge group only when its diff touched the same inputs, and on | |
| # every schedule or dispatch run. Elsewhere an unchanged skill's check-25 | |
| # verdict cannot have moved, because nothing it reads changed, and a | |
| # changed skill is checked by the next step, which fails it on check 25 | |
| # like any other FAIL. The one verdict a diff can no longer see is a stale | |
| # scripts/verb-contract-baseline.txt row left by fixing a listed skill; | |
| # the list has no active rows, and the scheduled run still reports one. | |
| # | |
| # detect-changes reads only a pull request's files, so on a merge group | |
| # every group reports true and the diff is checked here instead, against | |
| # the `skill_checker` group's paths. | |
| - name: Gate every skill on description/verb-contract polarity (check 25) | |
| if: needs.select-tests.outputs.run_skill_checker == 'true' | |
| env: | |
| DIFF_BASE: ${{ needs.select-tests.outputs.lane_base }} | |
| run: | | |
| if [ "$GITHUB_EVENT_NAME" != pull_request ] && [ -n "$DIFF_BASE" ]; then | |
| touched=$(git diff --name-only "$DIFF_BASE" HEAD -- plugins/skill-quality/scripts \ | |
| 'scripts/check-all-skills-verb-contract*.sh' scripts/verb-contract-baseline.txt \ | |
| scripts/lib .github/workflows/pr-require-checks.yml) || | |
| touched="(git diff failed)" | |
| if [ -z "$touched" ]; then | |
| echo "::notice::No check-25 input changed since $DIFF_BASE; the scheduled run checks every skill." | |
| exit 0 | |
| fi | |
| fi | |
| bash scripts/check-all-skills-verb-contract.sh | |
| - name: Gate changed skills on the static skill-quality contract | |
| id: changed_skills | |
| continue-on-error: true | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| shell: bash scripts/run-hygiene-gate.sh changed-skills {0} | |
| run: | | |
| if [ "$GITHUB_EVENT_NAME" != pull_request ]; then | |
| echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff changed skills against." | |
| exit 0 | |
| fi | |
| scripts/check-changed-skills.sh "origin/$BASE_REF" | |
| - name: Validate every eval set against the bundled schema | |
| id: evals_schema | |
| continue-on-error: true | |
| uses: melodic-software/ci-workflows/.github/actions/check-jsonschema@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| with: | |
| schemafile: plugins/skill-quality/reference/evals.schema.json | |
| files: plugins/*/skills/*/evals/evals.json | |
| # Self-test first so a broken lint cannot mask a regression behind a | |
| # green step. | |
| - name: Test the eval-quality lint | |
| run: bash plugins/skill-quality/scripts/check-evals-quality.test.sh | |
| # Beyond-schema quality lint (FAIL tier: duplicate case ids/names, empty | |
| # criterion items, unresolvable files fixtures; WARN tier stays advisory | |
| # and never fails the step). Whole-repo and static, so it runs on every | |
| # event like the schema step above. | |
| # Each eval set is linted on its own, and its `files` entries resolve | |
| # under its skill directory (evals/ or the skill root), so a diff lints | |
| # the set of every skill directory it touched; the whole corpus when the | |
| # lint itself or this workflow changed, or when there is no diff base. | |
| - name: Lint every eval set for quality | |
| id: evals_quality | |
| continue-on-error: true | |
| env: | |
| DIFF_BASE: ${{ needs.select-tests.outputs.lane_base }} | |
| shell: bash scripts/run-hygiene-gate.sh evals-quality {0} | |
| run: | | |
| sets=(plugins/*/skills/*/evals/evals.json) | |
| if [ -n "$DIFF_BASE" ] && changed=$(git diff --name-only "$DIFF_BASE...HEAD" -- \ | |
| 'plugins/*/skills/*' 'plugins/skill-quality/scripts/check-evals-quality*' \ | |
| .github/workflows/pr-require-checks.yml); then | |
| if [ -z "$changed" ]; then | |
| sets=() | |
| elif ! grep -qvE '^plugins/[^/]+/skills/[^/]+/' <<<"$changed"; then | |
| mapfile -t sets < <(sed -nE 's#^(plugins/[^/]+/skills/[^/]+)/.*#\1/evals/evals.json#p' <<<"$changed" | | |
| sort -u | while read -r f; do [ -f "$f" ] && echo "$f"; done) | |
| fi | |
| if [ "${#sets[@]}" -eq 0 ]; then | |
| echo "::notice::No skill directory with an eval set changed since $DIFF_BASE; the scheduled run lints every set." | |
| exit 0 | |
| fi | |
| fi | |
| bash plugins/skill-quality/scripts/check-evals-quality.sh "${sets[@]}" | |
| - name: Report precompute git+multi-line violations (warn-only) | |
| id: precompute_compose | |
| continue-on-error: true | |
| shell: bash scripts/run-hygiene-gate.sh precompute-compose {0} | |
| run: scripts/check-skill-precompute-compose.sh --all | |
| - name: Test the shared listing-budget reporter | |
| run: bash plugins/skill-quality/scripts/check-listing-budget.test.sh | |
| - name: Test the invocation-probe harness | |
| run: bash plugins/skill-quality/scripts/measure-invocation.test.sh | |
| # One validate per plugins/<plugin>/probes directory; any failing | |
| # directory fails the step after every directory has reported. | |
| - name: Validate invocation probes | |
| run: | | |
| status=0 | |
| while IFS= read -r dir; do | |
| echo "::group::$dir" | |
| bash plugins/skill-quality/scripts/measure-invocation.sh validate "$dir" || status=1 | |
| echo "::endgroup::" | |
| done < <(find plugins -mindepth 2 -maxdepth 2 -name probes -type d | sort) | |
| exit "$status" | |
| # A growth tripwire, run on the schedule only: it measures the skill-listing | |
| # budget THIS repo configures, reconstructed with the script's own | |
| # documented arithmetic (TOKENS x CHARS_PER_TOKEN x FRACTION). A default | |
| # consumer's 8,000-char fallback is not measured: this fleet is | |
| # permanently many times over it, so that number carries no signal. | |
| # | |
| # THE FRACTION IS READ FROM .claude/settings.json, never repeated here. | |
| # A literal 0.05 in this file would be a second copy of a value that | |
| # lives in exactly one place, free to drift the moment anyone retunes the | |
| # setting — and it would drift silently, because a wrong basis still | |
| # prints a confident OK. Reading it means the tripwire follows the | |
| # setting by construction. A tree that stops setting the key is a warning | |
| # rather than a silently green step: the repo would then be on the | |
| # harness default 0.01 and this measurement would be measuring nothing. | |
| # | |
| # THE WINDOW IS PINNED TO THE TIGHTER BASIS, 750,000 tokens, which is | |
| # what #3505 exhibited live. The budget is a FRACTION of the live | |
| # context window, so the same fraction buys a third more characters on | |
| # a 1M-token model than at 750k. Measuring the 1M basis would put this | |
| # WARN roughly | |
| # a month after the 750k consumers had already overflowed, and a tripwire | |
| # that fires after the failure it exists to predict is worse than none. | |
| # (Neither basis speaks for a 200k-window consumer, whose budget the | |
| # fleet overflows today; docs/cloud-sessions.md records that.) | |
| # Report-only (the script always exits 0). The fix for the aggregate is | |
| # the per-skill description trim, which #6846 owns. | |
| - name: Report the skill-listing budget at this repo's configured fraction | |
| id: listing_budget_configured | |
| if: github.event_name == 'schedule' | |
| continue-on-error: true | |
| env: | |
| CHECK_SKILL_LISTING_CONTEXT_TOKENS: "750000" | |
| shell: bash scripts/run-hygiene-gate.sh listing-budget-configured {0} | |
| run: | | |
| fraction="$(jq -r '.skillListingBudgetFraction // empty' .claude/settings.json)" | |
| if [ -z "$fraction" ]; then | |
| echo "::warning::.claude/settings.json no longer sets skillListingBudgetFraction, so this repo is back on the harness default (0.01) and there is no configured fraction to measure. Restore the key or retire this step." | |
| exit 0 | |
| fi | |
| echo "skillListingBudgetFraction read from .claude/settings.json: $fraction" | |
| CHECK_SKILL_LISTING_BUDGET_FRACTION="$fraction" \ | |
| bash plugins/skill-quality/scripts/check-listing-budget.sh plugins/*/skills | |
| # Portability lint: skills declared ecosystem/forge/tracker-agnostic must | |
| # not ship bare hardcoded stack/forge/branch/tracker defaults — the | |
| # coupling class the external reviewer re-caught PR after PR. On a pull | |
| # request it scans the skill files the change touches (not the whole | |
| # corpus) against the coupling-token list, so enabling a token class | |
| # prevents NEW coupling without red-lining existing violations that | |
| # member issues own. | |
| - name: Gate changed skill files on the portability contract | |
| id: portability | |
| continue-on-error: true | |
| env: | |
| BASE_REF: ${{ github.base_ref }} | |
| shell: bash scripts/run-hygiene-gate.sh portability-lint {0} | |
| run: | | |
| if [ "$GITHUB_EVENT_NAME" != pull_request ]; then | |
| echo "::notice::Declined on a $GITHUB_EVENT_NAME event; there is no base ref to diff changed skill files against." | |
| exit 0 | |
| fi | |
| scripts/check-skill-portability.sh "origin/$BASE_REF" | |
| - name: Test hygiene result aggregation | |
| run: scripts/aggregate-hygiene-results.sh --self-test | |
| - name: Aggregate hygiene results | |
| if: always() | |
| env: | |
| # The feed's rules are stated on the one in `lint-repo`; the docs-only | |
| # gate pins them on every job. | |
| # Gates that run as `uses:` actions, whose output | |
| # scripts/run-hygiene-gate.sh cannot capture: a failure here quotes | |
| # the job's own check-run annotations (checks: read), else points at | |
| # the step log. | |
| CHECK_RUN_ID: ${{ job.check_run_id }} | |
| GH_TOKEN: ${{ github.token }} | |
| STEP_LOG_CHECKS: evals-schema | |
| CHECK_RESULTS: | | |
| detector-findings-crosswalk=${{ steps.detector_findings_crosswalk.outcome }} | |
| detector-eval-coverage=${{ steps.detector_eval_coverage.outcome }} | |
| skill-leaf-names=${{ steps.skill_leaf_names.outcome }} | |
| spoke-plugin-root=${{ steps.spoke_plugin_root.outcome }} | |
| orphaned-fixtures=${{ steps.orphaned_fixtures.outcome }} | |
| fleet-finding-test-coverage=${{ steps.fleet_finding_test_coverage.outcome }} | |
| fleet-audit-doc-grammar=${{ steps.fleet_audit_doc_grammar.outcome }} | |
| changed-skills=${{ steps.changed_skills.outcome }} | |
| evals-schema=${{ steps.evals_schema.outcome }} | |
| evals-quality=${{ steps.evals_quality.outcome }} | |
| precompute-compose=${{ steps.precompute_compose.outcome }} | |
| listing-budget-configured=${{ github.event_name != 'schedule' && 'success' || steps.listing_budget_configured.outcome }} | |
| portability-lint=${{ steps.portability.outcome }} | |
| run: scripts/aggregate-hygiene-results.sh | |
| # THE TEST LANES RUN WHAT THE SELECTION SAYS. `select-tests` plans them once | |
| # (scripts/plan-test-lanes.sh): the shell suites the change selects go to | |
| # `test-bash`, its Python suites to `test-python`, and the Node packages they | |
| # reach to `test-node`; a Node suite with a sibling .test.sh runs through | |
| # that wrapper in `test-bash`, and a Pester suite in pr-test-windows.yml. The | |
| # whole tree (a schedule, a dispatch, or a change | |
| # to this workflow or its actions) runs every suite of every lane. | |
| # | |
| # ORDERED SKIPS. Each test lane carries the contract-only and draft gates and | |
| # one more term, its own `run_<lane>` row of `select-tests`: a lane the plan gives no | |
| # work skips as a job instead of paying a runner and a queue slot to report | |
| # nothing. `ci-status` passes such a skip only where `select-tests` succeeded and | |
| # its row for that lane says 'false'; any other skipped lane is red | |
| # (scripts/check-docs-only-gate.sh pins both halves). | |
| # | |
| # ONE TO SIX LEGS, SIZED FROM THE SELECTION. The plan packs the selected | |
| # suites into legs of about 120 suite-seconds each (scripts/suite-seconds.txt), | |
| # longest first onto the least-loaded leg, at most six, and six on the whole | |
| # tree. A leg costs about 45 s of setup before it runs a suite, so the count | |
| # follows the work rather than a fixed fan-out. The job key stays one entry in | |
| # `ci-status.needs` at any size. `fail-fast: false` because a leg is a slice | |
| # of one selection: cancelling the others on the first failure would hide | |
| # every other failure behind whichever leg lost the race. | |
| # | |
| # Three suites at a time per leg, not four (scripts/run-plugin-tests.sh | |
| # --jobs 3). The runner has 4 vCPUs and the suites are spawn-bound, so four | |
| # looked like the shape that pays, but at four three separate suites failed | |
| # across two runs by producing EMPTY output from an external command on a | |
| # path with no clock in it. See #3694. The suites that assert wall-clock | |
| # ceilings run one at a time regardless, from | |
| # scripts/run-plugin-tests-serial.txt, and the plan weighs them three times. | |
| test-bash: | |
| needs: [select-tests] | |
| # The contract-only gate, verbatim, the draft gate (see `ci-status`), and | |
| # this lane's ordered skip. | |
| if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && github.event.pull_request.draft != true && needs.select-tests.outputs.run_bash == 'true' }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 9 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| leg: ${{ fromJSON(needs.select-tests.outputs.bash_legs || '[0]') }} | |
| env: | |
| LEG: ${{ strategy.job-index }} | |
| PLAN: ${{ needs.select-tests.outputs.bash_plan }} | |
| LEG_PLAN: ${{ needs.select-tests.outputs.bash_needs }} | |
| steps: | |
| - name: Check out | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Fetch base | |
| uses: ./.github/actions/download-full-history # zizmor: ignore[self-repository] | |
| # Node, Python, ShellCheck, shfmt, Biome and Ruff are declared here so | |
| # hosted and local runs exercise the same contract suites instead of | |
| # inheriting different image tool inventories. | |
| - name: Set up Node | |
| uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 | |
| with: | |
| node-version-file: .node-version | |
| - name: Restore the npm cache | |
| id: npm_cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.npm | |
| key: npm-${{ runner.os }}-${{ hashFiles('.node-version', 'package-lock.json', '.github/standards/runner-policy/package-lock.json', 'plugins/harness-ops/skills/inventory/scripts/js/package-lock.json', 'plugins/testing/skills/check-visual-parity/scripts/package-lock.json') }} | |
| - name: Set up Python | |
| uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 | |
| with: | |
| python-version-file: .python-version | |
| - name: Restore the pip cache | |
| id: pip_cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/pip | |
| key: pip-${{ runner.os }}-${{ hashFiles('.python-version', '.github/requirements-ci.txt', 'plugins/animation/requirements.txt') }} | |
| - name: Install and verify ShellCheck toolchain | |
| # This canonical action also makes the exact ShellCheck version | |
| # available to the bash-format contract suites in this same job. It | |
| # verifies the install on one small file that sources nothing; the | |
| # ShellCheck gate in `lint-shell` is what lints the repository. | |
| uses: melodic-software/ci-workflows/.github/actions/shellcheck@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| with: | |
| files: scripts/aggregate-hygiene-results.sh | |
| rcfile: .shellcheckrc | |
| # The eval-coverage gate's suite reads `shfmt --to-json` and exits 2 when | |
| # shfmt is missing, so this job installs the same v3.14.1 pin as | |
| # check-skills. The shfmt, DuckDB and lefthook downloads are cached by version (four | |
| # red main runs in two weeks were these downloads failing), and a miss | |
| # retries transient failures. `--no-progress-meter` instead of `-s` keeps | |
| # curl's "Will retry" warning in the log. The SHA-256 check runs on a hit | |
| # too. | |
| - name: Restore the pinned tool downloads | |
| id: tools_cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/ci-tools | |
| key: ci-tools-${{ runner.os }}-shfmt-v3.14.1-duckdb-v1.5.5-lefthook-2.2.2 | |
| - name: Install pinned shfmt | |
| env: | |
| SHFMT_VERSION: v3.14.1 | |
| SHFMT_SHA256: 76e77641faa025814b77f153b29796b8e6fa2fca03e0c76a691608b86c7ea7bf | |
| run: | | |
| mkdir -p "$HOME/.local/bin" "$HOME/.cache/ci-tools" | |
| cached="$HOME/.cache/ci-tools/shfmt-$SHFMT_VERSION" | |
| [ -f "$cached" ] || curl -fSL --no-progress-meter --retry 3 --retry-all-errors -o "$cached" \ | |
| "https://github.com/mvdan/sh/releases/download/$SHFMT_VERSION/shfmt_${SHFMT_VERSION}_linux_amd64" | |
| cp "$cached" "$HOME/.local/bin/shfmt" | |
| echo "$SHFMT_SHA256 $HOME/.local/bin/shfmt" | sha256sum -c - | |
| chmod +x "$HOME/.local/bin/shfmt" | |
| echo "$HOME/.local/bin" >> "$GITHUB_PATH" | |
| export PATH="$HOME/.local/bin:$PATH" | |
| shfmt --version | |
| test "$(shfmt --version)" = "$SHFMT_VERSION" | |
| # demo-video renders and probes its fixture with ffmpeg and ffprobe, which the | |
| # hosted image does not carry. Both come from BtbN's static GPL build (the | |
| # Linux static build ffmpeg.org links; GPL for libx264), a month-end tag BtbN | |
| # keeps, checked against its published SHA-256 on every run. The archive is | |
| # about 154 MB, so it has its own cache entry that only the demovideo leg | |
| # restores and saves. The step timeout makes a stalled download fail here | |
| # instead of spending the leg. | |
| - name: Restore the pinned ffmpeg download | |
| id: ffmpeg_cache | |
| if: contains(fromJSON(env.LEG_PLAN)[env.LEG], 'demovideo') | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/ci-tools-ffmpeg | |
| key: ci-tools-ffmpeg-${{ runner.os }}-n8.1.3-9-g29e619e767 | |
| - name: Install ffmpeg for demo-video | |
| timeout-minutes: 2 | |
| env: | |
| FFMPEG_RELEASE: autobuild-2026-09-30-13-08 | |
| FFMPEG_BUILD: n8.1.3-9-g29e619e767 | |
| FFMPEG_SHA256: 97ce978979194b5cf7e06a5e68020dbdaa7a4f3294c5452b6a1bc347100dbb79 | |
| run: | | |
| leg_needs=$(jq -er --arg leg "$LEG" '.[$leg]' <<<"$LEG_PLAN") || | |
| { echo "::error::the plan names no toolchains for leg $LEG"; exit 1; } | |
| [[ "$leg_needs" == *demovideo* ]] || exit 0 | |
| asset="ffmpeg-$FFMPEG_BUILD-linux64-gpl-8.1.tar.xz" | |
| mkdir -p "$HOME/.local/bin" "$HOME/.cache/ci-tools-ffmpeg" | |
| cached="$HOME/.cache/ci-tools-ffmpeg/$asset" | |
| [ -f "$cached" ] || curl -fSL --no-progress-meter --retry 3 --retry-all-errors -o "$cached" \ | |
| "https://github.com/BtbN/FFmpeg-Builds/releases/download/$FFMPEG_RELEASE/$asset" | |
| echo "$FFMPEG_SHA256 $cached" | sha256sum -c - || | |
| { echo "::error::$asset does not match FFMPEG_SHA256"; exit 1; } | |
| tar -xJf "$cached" -C "$HOME/.local/bin" --strip-components=2 --wildcards '*/bin/ffmpeg' '*/bin/ffprobe' | |
| echo "$HOME/.local/bin" >> "$GITHUB_PATH" | |
| for tool in ffmpeg ffprobe; do | |
| [ -x "$HOME/.local/bin/$tool" ] || { echo "::error::$asset holds no bin/$tool"; exit 1; } | |
| line=$("$HOME/.local/bin/$tool" -version | sed -n 1p) | |
| echo "$line" | |
| [[ "$line" == *"$FFMPEG_BUILD"* ]] || { echo "::error::$tool reports '$line', not $FFMPEG_BUILD"; exit 1; } | |
| done | |
| - name: Save the pinned ffmpeg download | |
| if: github.ref == 'refs/heads/main' && contains(fromJSON(env.LEG_PLAN)[env.LEG], 'demovideo') && steps.ffmpeg_cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/ci-tools-ffmpeg | |
| key: ${{ steps.ffmpeg_cache.outputs.cache-primary-key }} | |
| # The animation wheels (numpy and opencv; the speech suites need their | |
| # numpy), demo-video's locked numpy and Pillow plus ffmpeg, and the | |
| # inventory's parser packages go only on a leg whose suites need them, | |
| # per the plan (`LEG_PLAN`). An under-install is loud, not quiet: those | |
| # suites fail rather than skip without their dependency | |
| # (ANIMATION_REQUIRE_DEPS, SPEECH_REQUIRE_DEPS, DEMO_VIDEO_REQUIRE_DEPS, | |
| # INVENTORY_REQUIRE_ACORN). | |
| - name: Install locked plugin test toolchains | |
| run: | | |
| # Each lock must hold every pin its requirements.in declares, so an edit to the .in | |
| # without regenerating the lock fails here instead of testing a stale lock. | |
| for dir in plugins/animation plugins/playwright/skills/demo-video; do | |
| stale=$(comm -23 <(grep -oE '^[A-Za-z0-9._-]+==[^ ]+' "$dir/requirements.in" | sort) \ | |
| <(grep -oE '^[A-Za-z0-9._-]+==[^ ]+' "$dir/requirements.txt" | sort) | paste -sd ' ') | |
| [ -z "$stale" ] || { echo "::error::$dir/requirements.txt lacks $stale from requirements.in; regenerate the lock"; exit 1; } | |
| done | |
| leg_needs=$(jq -er --arg leg "$LEG" '.[$leg]' <<<"$LEG_PLAN") || | |
| { echo "::error::the plan names no toolchains for leg $LEG"; exit 1; } | |
| echo "This leg installs: ${leg_needs:-the base toolchains only}" | |
| npm ci | |
| # The yaml package scripts/check-read-caller-keys.test.sh and | |
| # scripts/check-artifact-retention.test.sh parse with. | |
| npm ci --prefix .github/standards/runner-policy | |
| requirements=(--requirement .github/requirements-ci.txt) | |
| if [[ "$leg_needs" == *animation* ]]; then | |
| requirements+=(--requirement plugins/animation/requirements.txt) | |
| fi | |
| if [[ "$leg_needs" == *demovideo* ]]; then | |
| requirements+=(--requirement plugins/playwright/skills/demo-video/requirements.txt) | |
| fi | |
| python -m pip install --user --only-binary=:all: --require-hashes "${requirements[@]}" | |
| # The animation render tests and the shared-page UI suite drive the locked playwright's headless Chromium. | |
| if [[ "$leg_needs" == *animation* || "$leg_needs" == *browser* ]]; then | |
| node node_modules/playwright/cli.js install --only-shell chromium | |
| fi | |
| echo "$GITHUB_WORKSPACE/node_modules/.bin" >> "$GITHUB_PATH" | |
| echo "$HOME/.local/bin" >> "$GITHUB_PATH" | |
| # The inventory's parser packages, installed the way a first | |
| # --reader=parser run installs them (npm ci from the committed | |
| # lockfile, here into the checkout's .work/). | |
| if [[ "$leg_needs" == *inventory* ]]; then | |
| python plugins/harness-ops/skills/inventory/scripts/parser_reader.py --install | |
| fi | |
| # check-visual-parity's pixelmatch and pngjs, installed the way its | |
| # first compare installs them, into .work/visual-compare/<lock hash>/. | |
| if [[ "$leg_needs" == *visualcheck* ]]; then | |
| bash plugins/testing/skills/check-visual-parity/scripts/visual-compare.sh install --deps-dir .work | |
| fi | |
| - name: Save the npm cache | |
| if: github.ref == 'refs/heads/main' && steps.npm_cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.npm | |
| key: ${{ steps.npm_cache.outputs.cache-primary-key }} | |
| - name: Save the pip cache | |
| if: github.ref == 'refs/heads/main' && steps.pip_cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/pip | |
| key: ${{ steps.pip_cache.outputs.cache-primary-key }} | |
| # The harness-ops observability suites (prune-otel-store, hook-latency, | |
| # session-compare) query fixture stores with the DuckDB CLI and skip those | |
| # cases without it, so the plan names every harness-ops suite for it. | |
| - name: Install DuckDB CLI | |
| env: | |
| DUCKDB_VERSION: v1.5.5 | |
| DUCKDB_SHA256: c61f21485e6e41d3a0c28ce9904ea18346309cf427b4cf9479bc3564348dc885 | |
| run: | | |
| if [[ "$(jq -r --arg leg "$LEG" '.[$leg] // ""' <<<"$LEG_PLAN")" != *duckdb* ]]; then | |
| echo "No suite on this leg reads DuckDB." | |
| exit 0 | |
| fi | |
| mkdir -p "$RUNNER_TEMP/duckdb" "$HOME/.cache/ci-tools" | |
| cached="$HOME/.cache/ci-tools/duckdb-$DUCKDB_VERSION.gz" | |
| [ -f "$cached" ] || curl -fSL --no-progress-meter --retry 3 --retry-all-errors -o "$cached" \ | |
| "https://github.com/duckdb/duckdb/releases/download/$DUCKDB_VERSION/duckdb_cli-linux-amd64.gz" | |
| cp "$cached" "$RUNNER_TEMP/duckdb/duckdb.gz" | |
| echo "$DUCKDB_SHA256 $RUNNER_TEMP/duckdb/duckdb.gz" | sha256sum -c - | |
| gunzip "$RUNNER_TEMP/duckdb/duckdb.gz" | |
| chmod +x "$RUNNER_TEMP/duckdb/duckdb" | |
| echo "$RUNNER_TEMP/duckdb" >> "$GITHUB_PATH" | |
| # The guardrails remote's end-to-end suite runs real lefthook against a | |
| # stand-in remote; a broken lefthook-remote.yml would block every | |
| # consumer's commits, so it fails here instead of skipping | |
| # (GUARDRAILS_REQUIRE_LEFTHOOK). | |
| - name: Install pinned lefthook | |
| run: | | |
| if [[ "$(jq -r --arg leg "$LEG" '.[$leg] // ""' <<<"$LEG_PLAN")" != *lefthook* ]]; then | |
| echo "No suite on this leg runs lefthook." | |
| exit 0 | |
| fi | |
| bash .github/scripts/pr-require-checks/install-lefthook.sh | |
| - name: Save the pinned tool downloads | |
| if: github.ref == 'refs/heads/main' && steps.tools_cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/ci-tools | |
| key: ${{ steps.tools_cache.outputs.cache-primary-key }} | |
| # A leg with no suites is a broken plan, never "nothing to do": the plan | |
| # makes no more legs than it has suites. | |
| - name: Run this leg's shell suites | |
| env: | |
| # The animation suites fail on a missing numpy, opencv or Chromium here instead of skipping green. | |
| ANIMATION_REQUIRE_DEPS: '1' | |
| # The speech suite fails on a missing numpy here instead of skipping green. | |
| SPEECH_REQUIRE_DEPS: '1' | |
| # The demo-video suite fails on a missing numpy, Pillow, ffmpeg or ffprobe here instead of skipping green. | |
| DEMO_VIDEO_REQUIRE_DEPS: '1' | |
| # The inventory parser suites fail on a missing acorn here instead of skipping green. | |
| INVENTORY_REQUIRE_ACORN: '1' | |
| # The code-tidying eval fixtures fail on a missing tree-sitter here instead of skipping green. | |
| CODE_TIDYING_REQUIRE_TREE_SITTER: '1' | |
| # The check-visual-parity pixel cases fail on a missing pixelmatch or pngjs here instead of skipping green. | |
| VISUAL_COMPARE_REQUIRE: '1' | |
| VISUAL_COMPARE_DEPS_DIR: .work | |
| # The guardrails remote's end-to-end cases fail on a missing lefthook here instead of skipping green. | |
| GUARDRAILS_REQUIRE_LEFTHOOK: '1' | |
| # On main (schedule, dispatch) a failing suite in | |
| # scripts/run-plugin-tests-advisory.txt warns instead of failing the | |
| # leg; pull requests and merge groups still fail on it. | |
| PLUGIN_TEST_ADVISORY: ${{ github.ref == 'refs/heads/main' && '1' || '' }} | |
| run: | | |
| jq -r --arg leg "$LEG" '.[$leg] // [] | .[]' <<<"$PLAN" >"$RUNNER_TEMP/leg-suites.txt" | |
| if [ ! -s "$RUNNER_TEMP/leg-suites.txt" ]; then | |
| echo "::error::the plan gives leg $LEG no suites; a leg exists only to run some." | |
| exit 1 | |
| fi | |
| scripts/run-plugin-tests.sh --jobs 3 --suites-from "$RUNNER_TEMP/leg-suites.txt" | |
| # THE PYTHON SUITES THE CHANGE SELECTS, one pytest process per module, on one | |
| # to four legs of about 180 suite-seconds (one module at a time). Every module | |
| # on the whole tree or after a change to a Python pin. The pinned pytest | |
| # collects unittest.TestCase suites as well as pytest-style ones, the | |
| # disk-hygiene module among them. Suites in different directories import | |
| # same-named helpers (`from conftest import ...`), and a shared process hands | |
| # each the first one loaded, hence a process per module. A planning surface | |
| # module spreads its tests over pytest-xdist workers, one test class to a | |
| # worker (`--dist loadscope`), since a class's tests share ordered state. | |
| # Node and the root | |
| # npm install come too: Python suites run the claude CLI from | |
| # node_modules/.bin and the inventory's parser packages through node. | |
| test-python: | |
| needs: [select-tests] | |
| # The contract-only gate, verbatim, the draft gate (see `ci-status`), and | |
| # this lane's ordered skip (see `test-bash`). | |
| if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && github.event.pull_request.draft != true && needs.select-tests.outputs.run_python == 'true' }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 7 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| leg: ${{ fromJSON(needs.select-tests.outputs.python_legs || '[0]') }} | |
| env: | |
| LEG: ${{ strategy.job-index }} | |
| PLAN: ${{ needs.select-tests.outputs.python_plan }} | |
| LEG_PLAN: ${{ needs.select-tests.outputs.python_needs }} | |
| steps: | |
| - name: Check out | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Fetch base | |
| uses: ./.github/actions/download-full-history # zizmor: ignore[self-repository] | |
| - name: Set up Node | |
| uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 | |
| with: | |
| node-version-file: .node-version | |
| - name: Restore the npm cache | |
| id: npm_cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.npm | |
| key: npm-${{ runner.os }}-${{ hashFiles('.node-version', 'package-lock.json', 'plugins/harness-ops/skills/inventory/scripts/js/package-lock.json') }} | |
| - name: Set up Python | |
| uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 | |
| with: | |
| python-version-file: .python-version | |
| - name: Restore the pip cache | |
| id: pip_cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/pip | |
| key: pip-${{ runner.os }}-${{ hashFiles('.python-version', '.github/requirements-ci.txt', 'plugins/animation/requirements.txt') }} | |
| # A stalled Chromium download can take minutes of this job's 7-minute limit, so the | |
| # browser is cached under the locked playwright version (the lockfile hash) and a hit | |
| # skips the download. | |
| - name: Restore the Playwright browser | |
| id: playwright_cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/ms-playwright | |
| key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} | |
| - name: Install locked plugin test toolchains | |
| id: toolchains | |
| env: | |
| PLAYWRIGHT_CACHE_HIT: ${{ steps.playwright_cache.outputs.cache-hit }} | |
| run: | | |
| # The animation lock must hold every pin its requirements.in declares, so an edit to | |
| # the .in without regenerating the lock fails here instead of testing a stale lock. | |
| stale=$(comm -23 <(grep -oE '^[A-Za-z0-9._-]+==[^ ]+' plugins/animation/requirements.in | sort) \ | |
| <(grep -oE '^[A-Za-z0-9._-]+==[^ ]+' plugins/animation/requirements.txt | sort) | paste -sd ' ') | |
| [ -z "$stale" ] || { echo "::error::plugins/animation/requirements.txt lacks $stale from requirements.in; regenerate the lock"; exit 1; } | |
| leg_needs=$(jq -er --arg leg "$LEG" '.[$leg]' <<<"$LEG_PLAN") || | |
| { echo "::error::the plan names no toolchains for leg $LEG"; exit 1; } | |
| echo "This leg installs: ${leg_needs:-the base toolchains only}" | |
| npm ci | |
| requirements=(--requirement .github/requirements-ci.txt) | |
| if [[ "$leg_needs" == *animation* ]]; then | |
| requirements+=(--requirement plugins/animation/requirements.txt) | |
| fi | |
| python -m pip install --user --only-binary=:all: --require-hashes "${requirements[@]}" | |
| # The animation render tests capture through the locked playwright's headless Chromium. | |
| if [[ "$leg_needs" == *animation* ]]; then | |
| if [[ "$PLAYWRIGHT_CACHE_HIT" == "true" ]]; then | |
| echo "The Playwright browser cache hit; no Chromium download." | |
| else | |
| node node_modules/playwright/cli.js install --only-shell chromium | |
| echo "browser_downloaded=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| fi | |
| echo "$GITHUB_WORKSPACE/node_modules/.bin" >> "$GITHUB_PATH" | |
| echo "$HOME/.local/bin" >> "$GITHUB_PATH" | |
| # test_fixture_parse.py and test_parser_reader.py run instead of skipping. | |
| if [[ "$leg_needs" == *inventory* ]]; then | |
| python plugins/harness-ops/skills/inventory/scripts/parser_reader.py --install | |
| fi | |
| - name: Save the npm cache | |
| if: github.ref == 'refs/heads/main' && steps.npm_cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.npm | |
| key: ${{ steps.npm_cache.outputs.cache-primary-key }} | |
| - name: Save the pip cache | |
| if: github.ref == 'refs/heads/main' && steps.pip_cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/pip | |
| key: ${{ steps.pip_cache.outputs.cache-primary-key }} | |
| - name: Save the Playwright browser | |
| if: github.ref == 'refs/heads/main' && steps.playwright_cache.outputs.cache-hit != 'true' && steps.toolchains.outputs.browser_downloaded == 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.cache/ms-playwright | |
| key: ${{ steps.playwright_cache.outputs.cache-primary-key }} | |
| - name: Run this leg's Python suites | |
| env: | |
| ANIMATION_REQUIRE_DEPS: '1' | |
| INVENTORY_REQUIRE_ACORN: '1' | |
| CODE_TIDYING_REQUIRE_TREE_SITTER: '1' | |
| run: | | |
| jq -r --arg leg "$LEG" '.[$leg] // [] | .[]' <<<"$PLAN" >"$RUNNER_TEMP/leg-suites.txt" | |
| if [ ! -s "$RUNNER_TEMP/leg-suites.txt" ]; then | |
| echo "::error::the plan gives leg $LEG no suites; a leg exists only to run some." | |
| exit 1 | |
| fi | |
| rc=0 | |
| while IFS= read -r suite; do | |
| workers=() | |
| [[ $suite == plugins/planning/surface/* ]] && workers=(-n auto --dist loadscope) | |
| echo "python -m pytest -q -o tmp_path_retention_policy=none ${workers[*]} -- $suite" >&2 | |
| python -m pytest -q -o tmp_path_retention_policy=none "${workers[@]}" -- "$suite" || rc=1 | |
| done <"$RUNNER_TEMP/leg-suites.txt" | |
| exit "$rc" | |
| # THE NODE PACKAGES THE CHANGE REACHES: the five sub-projects with their own | |
| # install, typecheck, lint and test chains (the miro MCP server, the | |
| # ai-briefing build, the video-digest and course-digest extraction pipelines, | |
| # the resolve-config action) | |
| # and the packages of scripts/outside-node-packages.txt. A package runs when | |
| # a file under it, its test facade or a `file:` dependency changed, when a | |
| # selected Node suite sits under it, on a Node pin, and on the whole tree | |
| # (`PACKAGES`). A step for a package the plan does not name exits 0 on | |
| # its first line. One job: the chains measure 31 s together. | |
| test-node: | |
| needs: [select-tests] | |
| # The contract-only gate, verbatim, the draft gate (see `ci-status`), and | |
| # this lane's ordered skip (see `test-bash`). | |
| if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && github.event.pull_request.draft != true && needs.select-tests.outputs.run_node == 'true' }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 5 | |
| env: | |
| PACKAGES: ${{ needs.select-tests.outputs.node_packages }} | |
| steps: | |
| - name: Check out | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| # One setup-node for every package, keyed on every lockfile they install | |
| # from, so a change to any one of them invalidates the cache the others | |
| # share. | |
| - name: Set up Node | |
| uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 | |
| with: | |
| node-version-file: .node-version | |
| - name: Restore the npm cache | |
| id: npm_cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.npm | |
| key: npm-${{ runner.os }}-${{ hashFiles('.node-version', 'plugins/miro/server/package-lock.json', 'plugins/ai-briefing/skills/generate/output/build/package-lock.json', 'plugins/knowledge/skills/video-digest/extraction/package-lock.json', 'plugins/knowledge/skills/course-digest/extraction/package-lock.json', '.github/actions/resolve-config/package-lock.json') }} | |
| # The miro plugin ships a Node MCP server as TypeScript source. Claude Code | |
| # starts src/launch.ts, which installs the runtime dependencies from the | |
| # committed lockfile into the plugin data directory on first launch. The | |
| # last step starts it that way from an empty data directory, then again to | |
| # prove the second launch reuses the install, so a server that typechecks | |
| # but cannot install or serve MCP is caught here, not on a consumer's | |
| # machine. | |
| - name: Install dependencies | |
| run: | | |
| [[ " $PACKAGES " == *" plugins/miro/server "* ]] || exit 0 | |
| npm ci | |
| working-directory: plugins/miro/server | |
| - name: Typecheck | |
| run: | | |
| [[ " $PACKAGES " == *" plugins/miro/server "* ]] || exit 0 | |
| npm run typecheck | |
| working-directory: plugins/miro/server | |
| - name: Lint | |
| run: | | |
| [[ " $PACKAGES " == *" plugins/miro/server "* ]] || exit 0 | |
| npm run lint | |
| working-directory: plugins/miro/server | |
| - name: Test | |
| run: | | |
| [[ " $PACKAGES " == *" plugins/miro/server "* ]] || exit 0 | |
| npm test | |
| working-directory: plugins/miro/server | |
| - name: Smoke-test the MCP server over stdio, installed on demand | |
| working-directory: plugins/miro/server | |
| run: | | |
| [[ " $PACKAGES " == *" plugins/miro/server "* ]] || exit 0 | |
| export CLAUDE_PLUGIN_DATA="$RUNNER_TEMP/miro-plugin-data" | |
| rm -rf "${CLAUDE_PLUGIN_DATA:?}" | |
| for run in first second; do | |
| printf '%s\n%s\n' \ | |
| '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0.0.0"}}}' \ | |
| '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ | |
| | MIRO_API_TOKEN=ci-smoke-token timeout 120 node src/launch.ts > "smoke-$run.json" | |
| grep -q '"miro_create_board"' "smoke-$run.json" | |
| # One install directory, and the second launch keeps the first's | |
| # node_modules (a reinstall renames a new one into place). | |
| stat -c '%n %i' "$CLAUDE_PLUGIN_DATA"/mcp-server/*/node_modules > "install-$run.txt" | |
| done | |
| [ "$(wc -l < install-first.txt)" = 1 ] | |
| cmp install-first.txt install-second.txt | |
| rm -f smoke-*.json install-*.txt | |
| # The knowledge plugin's video-digest extraction pipeline is a Node | |
| # package with a typecheck and vitest suite no other lane installs or | |
| # runs. Its committed lockfile pins the shared vendor/ packages as packed | |
| # installs (install-links via the package's .npmrc), so manifest/lockfile | |
| # drift breaks `npm ci` only on a clean install. | |
| - name: Install dependencies | |
| run: | | |
| [[ " $PACKAGES " == *" plugins/knowledge/skills/video-digest/extraction "* ]] || exit 0 | |
| bash plugins/knowledge/skills/video-digest/scripts/run-tests.sh install | |
| - name: Typecheck | |
| run: | | |
| [[ " $PACKAGES " == *" plugins/knowledge/skills/video-digest/extraction "* ]] || exit 0 | |
| bash plugins/knowledge/skills/video-digest/scripts/run-tests.sh build | |
| - name: Test | |
| run: | | |
| [[ " $PACKAGES " == *" plugins/knowledge/skills/video-digest/extraction "* ]] || exit 0 | |
| bash plugins/knowledge/skills/video-digest/scripts/run-tests.sh test | |
| # ai-briefing's build/render pipeline is a Node package (native | |
| # `node --test` suite) no other lane installs or runs — including | |
| # test/url-policy.test.js, which asserts the SSRF gate (lib/url-policy.js) | |
| # that decides which URLs the briefing validator is allowed to | |
| # dereference (#1488). | |
| - name: Install dependencies | |
| run: | | |
| [[ " $PACKAGES " == *" plugins/ai-briefing/skills/generate/output/build "* ]] || exit 0 | |
| bash plugins/ai-briefing/skills/generate/scripts/run-tests.sh install | |
| - name: Test | |
| run: | | |
| [[ " $PACKAGES " == *" plugins/ai-briefing/skills/generate/output/build "* ]] || exit 0 | |
| bash plugins/ai-briefing/skills/generate/scripts/run-tests.sh test | |
| # The knowledge plugin's course-digest extraction pipeline is a Node | |
| # package (typecheck + vitest suite) no other lane installs or runs — | |
| # same shape as video-digest, including the shared vendor/ packages | |
| # pulled in as file: dependencies via the package's .npmrc | |
| # install-links=true (#1507). | |
| - name: Install dependencies | |
| run: | | |
| [[ " $PACKAGES " == *" plugins/knowledge/skills/course-digest/extraction "* ]] || exit 0 | |
| bash plugins/knowledge/skills/course-digest/scripts/run-tests.sh install | |
| - name: Typecheck | |
| run: | | |
| [[ " $PACKAGES " == *" plugins/knowledge/skills/course-digest/extraction "* ]] || exit 0 | |
| bash plugins/knowledge/skills/course-digest/scripts/run-tests.sh build | |
| - name: Test | |
| run: | | |
| [[ " $PACKAGES " == *" plugins/knowledge/skills/course-digest/extraction "* ]] || exit 0 | |
| bash plugins/knowledge/skills/course-digest/scripts/run-tests.sh test | |
| # resolve-config has pinned dependencies (yaml, ajv), so it installs and | |
| # tests here; run-outside-node-suites.sh installs them only for a package | |
| # that takes resolve-config as a file: dependency. | |
| # | |
| # This suite and the next print `::error` lines on purpose, from fixtures | |
| # that exercise a script's own failure messages, so each runs with | |
| # workflow commands stopped: a green run raises no failure annotations. | |
| # Pinned by scripts/ci-test-node-stop-commands.test.sh. | |
| - name: Test resolve-config | |
| run: | | |
| [[ " $PACKAGES " == *" .github/actions/resolve-config "* ]] || exit 0 | |
| npm ci --ignore-scripts --prefix .github/actions/resolve-config | |
| token=$(openssl rand -hex 16) | |
| echo "::stop-commands::$token" | |
| rc=0 | |
| npm test --prefix .github/actions/resolve-config || rc=$? | |
| echo "::$token::" | |
| exit "$rc" | |
| # The packages of scripts/outside-node-packages.txt, each through its own | |
| # `npm test` (#3703); the runner takes a package directory as a path | |
| # under that package. | |
| - name: Test the other Node packages | |
| run: | | |
| tr ' ' '\n' <<<"$PACKAGES" | grep -Fxf <(grep -v '^#' scripts/outside-node-packages.txt | grep .) \ | |
| >"$RUNNER_TEMP/outside-node-paths.txt" || true | |
| if [ ! -s "$RUNNER_TEMP/outside-node-paths.txt" ]; then | |
| echo "The plan names none of these packages." | |
| exit 0 | |
| fi | |
| token=$(openssl rand -hex 16) | |
| echo "::stop-commands::$token" | |
| rc=0 | |
| scripts/run-outside-node-suites.sh --paths "$RUNNER_TEMP/outside-node-paths.txt" || rc=$? | |
| echo "::$token::" | |
| exit "$rc" | |
| - name: Save the npm cache | |
| if: github.ref == 'refs/heads/main' && steps.npm_cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/.npm | |
| key: ${{ steps.npm_cache.outputs.cache-primary-key }} | |
| # A pull request that hand-edits a file the standards sync-manifest manages | |
| # fails. A standards-sync pull request (melodic-standards-sync[bot], head | |
| # `chore/standards-sync`, same repository) passes only when every commit is | |
| # the sync App's and its content equals sync-manifest apply at standards main | |
| # HEAD. That replaces code-owner review of sync pull requests, so the job is a | |
| # trust boundary only while all of this holds: the action pinned by full SHA, | |
| # called from this code-owned file, exactly the permissions below, full | |
| # history, and no paths filter. A merge group run checks each queued pull | |
| # request's own diff, read from the pulls API, by the same rules; a sync | |
| # pull request must be queued alone. The synced | |
| # pr-check-managed-files-hosted.yml runs the same guard advisory and | |
| # paths-filtered; this job is the gate. | |
| check-managed-files: | |
| needs: [select-tests] | |
| # The contract-only gate, verbatim, and the draft gate (see `ci-status`). | |
| if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && github.event.pull_request.draft != true }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| # Sync mode lists the pull request's commits to verify their identity. | |
| pull-requests: read | |
| steps: | |
| - name: Check out | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| # The guard diffs base...head; both commits must be present. | |
| fetch-depth: 0 | |
| - name: Guard managed files | |
| uses: melodic-software/ci-workflows/.github/actions/check-managed-files@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| # THE WHOLE-TREE CAN'T-FAIL SCAN, WHENEVER THERE IS NO DIFF BASE: the | |
| # schedule, a dispatch, and a pull request or merge group that | |
| # `select-tests` resolves to the whole tree (no usable base, or a manifest | |
| # change). With a base, `check-plugins` scans the changed test files and | |
| # this job returns at once. The whole tree took 133 and 132 s on a hosted | |
| # runner, serial, with 1301 test files, which no existing job's timeout | |
| # holds. The job branches in the shell, not in `if:` (see `lint-repo`), so | |
| # with a base it costs its setup and checkout and stays off the critical | |
| # path. Wall about 140 s on the whole tree, so ceil(max(210, 154) / 60) = 4 | |
| # minutes. Sharded over 4 cores the scan took 64 s, for 3 minutes, but needs | |
| # a second fail-closed denominator: a shard with no test file exits 2. | |
| # | |
| # The job skips only on a contract-only event or a draft (see `lint-repo`). | |
| check-tests: | |
| needs: [select-tests] | |
| # The contract-only gate, verbatim, and the draft gate (see `ci-status`). | |
| if: ${{ !(github.event.pull_request.head.repo.full_name == github.repository && (contains(fromJSON('["labeled","unlabeled"]'), github.event.action) || (github.event.action == 'edited' && !github.event.changes.base))) && github.event.pull_request.draft != true }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 4 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Check out | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Check every test for tests that cannot fail | |
| env: | |
| DIFF_BASE: ${{ needs.select-tests.outputs.lane_base }} | |
| run: | | |
| if [ -n "$DIFF_BASE" ]; then | |
| echo "::notice::check-plugins scans the test files changed since $DIFF_BASE." | |
| exit 0 | |
| fi | |
| scripts/check-cant-fail-scan.sh | |
| ci-status: | |
| needs: | |
| - mark-lanes | |
| - select-tests | |
| - lint-repo | |
| - lint-shell | |
| - check-plugins | |
| - check-catalog | |
| - check-skills | |
| - test-bash | |
| - test-python | |
| - test-node | |
| - check-managed-files | |
| - check-tests | |
| # Fail-closed through execution: !cancelled() (never a success-guard) so a | |
| # lane failure still runs this required aggregate and the aggregation below | |
| # turns it red. A success-guard would skip the job, and a skipped required | |
| # check reports success to branch protection. This condition is safe where | |
| # a consumer's would not be: this job reads no resolver output, so there is | |
| # no empty-string state for it to misread. | |
| # | |
| # This is the ONE job that carries no contract-only gate: on a label flip or | |
| # a body edit it is the only job that runs, and it answers from the | |
| # `ci-lanes` commit status the last full run recorded on this SHA. | |
| if: ${{ !cancelled() }} | |
| runs-on: ubuntu-24.04 | |
| # 9 s p95 and 42 s max in full mode, about 10 s contract-only. The cap is | |
| # the worst case of the pinned composites' waits: two 60 s rate-limit waits | |
| # (`rate-limit-wait-seconds` on check-contract and aggregate-results), 45 s | |
| # of own-gate retries, 7 s of status-write retries and the 90 s | |
| # `rerun-wait-seconds` wait, about four and a half minutes, plus up to | |
| # 15 s of retry sleeps for each of the hold-lift step's two reads, the | |
| # review-lane step's six, the lanes-hold step's eight (in the merge | |
| # queue the verdict reader's reads more, about 73 with pr-merge's carried | |
| # reads and about 860 without them, measured 2026-10-25, up to the cold-read | |
| # headroom check's cold_cost under the listing's caps; its wall time is | |
| # unmeasured, and a cold one can reach the cap, which fails red) and the pr-unblock status step's one (two in the | |
| # merge queue), about four and a half minutes, plus the cost-hold step's | |
| # reads: one for most pull requests, which stop at the label events; for a lane-labeled one whose body cites a | |
| # decision, about eight (nine on a Refs issue), and two more per review | |
| # run since the window opened, plus the base-config checkout's fetch | |
| # (seconds; its retries replace the contents reads' 15 s each). | |
| # 20: a cold verdict reader in the merge queue takes about 6 to 11 | |
| # minutes (0.4 to 0.7 s a call, as pr-merge's reader logs, for about 860 | |
| # calls), and at most about 15 under the listing's caps (793 per-FAIL | |
| # reads at 0.7 s, plus the whole listings' shared 300 s | |
| # LISTING_TIMEOUT_SECONDS), on top of the job's other reads. | |
| timeout-minutes: 20 | |
| env: | |
| # gh_read's wait budget: a rate-limit wait must end by | |
| # GH_READ_JOB_SECONDS (timeout-minutes in seconds) less | |
| # GH_READ_MARGIN_SECONDS after the first step starts. The margin keeps | |
| # five minutes for the job's setup and the steps after the wait: | |
| # aggregate-results' 60 s rate-limit wait, 45 s of own-gate retries, 7 s | |
| # of status writes and the 90 s rerun wait, plus retry sleeps. | |
| GH_READ_JOB_SECONDS: 1200 | |
| GH_READ_MARGIN_SECONDS: 300 | |
| # The lanes App's bot account id, which the trust-hold, cost-hold and | |
| # lanes-hold steps match a pull request author against. Its one source is | |
| # the repository variable; each step that reads it fails closed or keeps | |
| # its hold when it is not a numeric id, and the cost-hold and lanes-hold | |
| # steps when it is not merge.agent-identity's trusted-actor id at the base. | |
| LANES_APP_ID: ${{ vars.AUTOMATION_LANES_APP_SENDER_ID }} | |
| # api-budget.bash, verbatim (log-api-budget.test.mjs checks): the gh | |
| # wrapper and the job-end api-budget notice, since this job has no | |
| # checkout of that file. The read helper step below puts it in | |
| # gh-read.sh, so each step that sources that file reads through it. | |
| # Its read floor and limit cap are github-token's read-floor and | |
| # limit-cap defaults in .github/actions/log-api-budget/rate-limits.json | |
| # (log-api-budget.test.mjs checks), as this job has no checkout of it. | |
| API_BUDGET_READ_FLOOR: "0.1" | |
| API_BUDGET_LIMIT_CAP: "1000" | |
| API_BUDGET_SH: | | |
| # shellcheck shell=bash | |
| # Rate-limit readings from a job's own API responses. GET /rate_limit reports | |
| # a different bucket from the one a job's calls spend, so each reading comes | |
| # from the x-ratelimit-* headers of a response the job already got. A reading | |
| # is one line in $RUNNER_TEMP/api-budget/<token>: | |
| # <status> <limit> <remaining> <used> <reset epoch> <resource> | |
| # (`na` for a missing value). api-budget.mjs and babysit_gh.py append the same | |
| # lines. A request the gh wrapper counts with no header block to read (each | |
| # page of --paginate, a call that is not `gh api`) is a line `request`, or | |
| # `request graphql` for a GraphQL request. Never | |
| # fails the caller, except that a stop the rate guard below cannot record ends | |
| # the step. | |
| # | |
| # api_budget_read <token> <file> | |
| # gh api -i output: the header block at the start | |
| # becomes a reading, the body goes to stdout byte | |
| # for byte | |
| # api_budget_wrap_gh <token> defines gh() so `gh api` runs with -i through | |
| # api_budget_read, keeping stdout, stderr and the | |
| # exit status as they were (not --paginate, whose | |
| # per-page header blocks are left out), behind | |
| # the rate guard below; every gh call also counts | |
| # each request it sends (api_budget_traced) | |
| # | |
| # The rate guard, rate-guard.mjs's shell port: once a response shows a pool | |
| # exhausted, no gh call goes to that pool before its reset for the rest of the | |
| # job; each refusal returns 1 with an error naming the reset, and so does a | |
| # call GitHub refused. A call GitHub answered with a 2xx keeps its answer and | |
| # gh's status, so no caller resends a write GitHub accepted, unless its stop | |
| # cannot be recorded, which ends the step first (below). Its stops are | |
| # lines `<pool> <reset epoch>` in $RUNNER_TEMP/api-budget/stop; a stop whose time is a guess (no header gave | |
| # it) goes in $RUNNER_TEMP/api-budget/guessed too. Readers take only lines | |
| # shaped `^([a-z_]+|\*) [0-9]{1,12}$` and ended by a newline, as | |
| # rate-guard.mjs does, so a fragment a short write left spoils only the line | |
| # it runs into. A missing stop file holds no stop; one that exists but cannot | |
| # be read holds every pool until 999999999999, with an error naming it. | |
| # | |
| # Most calls run in $(...) subshells, whose variables never reach the shell | |
| # that started them, so the file is what carries a stop from one call to the | |
| # next. A stop the file cannot take (no space, no permission) would leave | |
| # every later call free to reach the exhausted pool. So the step ends there: | |
| # the error names the pool, API_BUDGET_UNRECORDED refuses every later call in | |
| # this shell, and from a subshell every shell from its parent up to the | |
| # step's shell ($$) is sent TERM, so no caller's retry loop in a capture | |
| # around it sends again. A caller may hold a subshell's stderr in a file it | |
| # reads only after the call, so both error lines go to a copy of the stderr | |
| # the step had when api_budget_wrap_gh ran. Callers must not trap TERM or run | |
| # the wrapper under a nested bash -c, whose $$ is the inner shell: either | |
| # leaves the step running. | |
| # | |
| # api_budget_classify jq filter: rate-guard.mjs's classify over | |
| # {status, headers, body, now (ms), pool, floor, | |
| # cap} | |
| # api_budget_pool <gh args> the pool a gh call spends: graphql, search, | |
| # code_search or core, `any` for a non-api call | |
| # api_budget_stop_until <pool> | |
| # the epoch second <pool> is stopped until, if | |
| # it is | |
| # api_budget_guessed <epoch> whether a stop until <epoch> is a guess | |
| # api_budget_observe <file> <pool> <what> | |
| # reads gh api -i output and stops its pool when | |
| # it is exhausted, returning 1 unless the | |
| # answer is a 2xx; under | |
| # API_BUDGET_READ_FLOOR of the planned budget | |
| # left, warns once (unset: no floor; ci-status | |
| # sets it to the read-floor default); | |
| # the plan is the header's limit, at most | |
| # API_BUDGET_LIMIT_CAP (unset: no cap; ci-status | |
| # sets github-token's limit-cap default) | |
| # api_budget_emit <token>... the hub collector's notice for each refused | |
| # (403/429) reading and for the last reading of | |
| # each resource, core last; cost is | |
| # the rise in used since the first earlier reading | |
| # with the same reset and resource, else na | |
| # api_budget_emit_content the notice for the job's content-creating | |
| # writes: count and most in any 60 s | |
| # api_budget_emit_calls <token>... | |
| # the hub's api-calls notice for each token, | |
| # `<token>=N not-modified=M graphql=K`: N is the | |
| # job's own requests, M its 304 answers, which | |
| # GitHub does not count, and K the GraphQL ones | |
| # among N, which spend their own pool; 0 for a | |
| # token with no reading | |
| # | |
| # pr-require-checks.yml's ci-status job carries this file verbatim in its | |
| # API_BUDGET_SH env (it has no checkout of this directory); | |
| # log-api-budget.test.mjs keeps the two the same. | |
| api_budget_read() { | |
| local state=${RUNNER_TEMP:-}/api-budget/${1:-} lines | |
| if [[ -z ${RUNNER_TEMP:-} || ! ${1:-} =~ ^[a-z][a-z0-9-]{0,39}$ ]] || ! mkdir -p "${state%/*}" 2>/dev/null; then | |
| state=/dev/null | |
| fi | |
| # Only the block at the very start is headers, so a body line shaped like a | |
| # status line stays body. awk reads the block and prints its line count; | |
| # tail then writes the body byte for byte. | |
| lines=$(awk -v state="$state" ' | |
| function val(k, re) { return (k in h && h[k] ~ re) ? h[k] : "na" } | |
| function flush() { | |
| printf "%s %s %s %s %s %s\n", status, val("limit", "^[0-9]+$"), val("remaining", "^[0-9]+$"), | |
| val("used", "^[0-9]+$"), val("reset", "^[0-9]+$"), val("resource", "^[a-z_]+$") >>state | |
| close(state) | |
| print NR | |
| done = 1 | |
| exit | |
| } | |
| NR == 1 && !/^HTTP\/[0-9.]+ [0-9][0-9][0-9]/ { print 0; done = 1; exit } | |
| NR == 1 { status = $2; next } | |
| { | |
| line = $0 | |
| sub(/\r$/, "", line) | |
| if (line == "") flush() | |
| if (tolower(line) ~ /^x-ratelimit-(limit|remaining|used|reset|resource):/) { | |
| k = tolower(line); sub(/^x-ratelimit-/, "", k); sub(/:.*/, "", k) | |
| v = line; sub(/^[^:]*:[ \t]*/, "", v) | |
| h[k] = v | |
| } | |
| } | |
| END { if (!done && NR) flush(); if (!done) print 0 } | |
| ' "$2") || lines=0 | |
| tail -n +"$((${lines:-0} + 1))" "$2" | |
| } | |
| api_budget_wrap_gh() { | |
| API_BUDGET_GH_TOKEN=$1 | |
| [[ -n ${API_BUDGET_ERR_FD:-} ]] || exec {API_BUDGET_ERR_FD}>&2 | |
| # shellcheck disable=SC2329 # called by the script that sources this file | |
| gh() { | |
| local arg out pool rc=0 | |
| pool=$(api_budget_pool "$@") | |
| api_budget_refuse "$pool" "gh $*" || return 1 | |
| ! api_budget_is_content "$@" || api_budget_count_content | |
| if [[ ${1:-} != api ]]; then | |
| api_budget_pass "$@" | |
| return | |
| fi | |
| for arg; do | |
| case $arg in | |
| # --paginate prints a header block per page, which could not be told | |
| # from body text that looks like one: no reading, only its requests. | |
| --silent | --slurp | --paginate) api_budget_pass "$@"; return ;; | |
| # The caller reads the headers itself: the guard reads them too, and | |
| # the output goes on unchanged. | |
| -i | --include) | |
| out=$(mktemp 2>/dev/null) || { api_budget_pass "$@"; return; } | |
| api_budget_traced "$out" "$@" || rc=$? | |
| api_budget_observe "$out" "$pool" "gh $*" || rc=1 | |
| api_budget_read "$API_BUDGET_GH_TOKEN" "$out" >/dev/null | |
| cat "$out" | |
| rm -f "$out" | |
| return "$rc" | |
| ;; | |
| *) ;; | |
| esac | |
| done | |
| out=$(mktemp 2>/dev/null) || { api_budget_pass "$@"; return; } | |
| # The wrapper under every call, gh_read included: the same call with -i. | |
| api_budget_traced "$out" api -i "${@:2}" || rc=$? | |
| api_budget_observe "$out" "$pool" "gh $*" || rc=1 | |
| api_budget_read "$API_BUDGET_GH_TOKEN" "$out" | |
| rm -f "$out" | |
| return "$rc" | |
| } | |
| } | |
| # A call whose headers the wrapper cannot read: a failure whose text names a | |
| # rate limit, or a 429, stops every pool, for the documented minute on a | |
| # secondary limit and otherwise for an hour, past any primary reset | |
| # (rate-guard.mjs's SIGNALS). | |
| api_budget_pass() { | |
| local err rc=0 seconds=3600 | |
| err=$(mktemp 2>/dev/null) || { command gh "$@"; return; } | |
| api_budget_traced - "$@" 2>"$err" || rc=$? | |
| cat "$err" >&2 | |
| if ((rc != 0)) && grep -qiE 'rate limit|RATE_LIMITED|abuse detection|\(HTTP 429\)' "$err"; then | |
| ! grep -qiE 'secondary rate limit|abuse detection' "$err" || seconds=60 | |
| api_budget_stop '*' $(($(date +%s) + seconds)) fallback "gh $*" || : | |
| fi | |
| rm -f "$err" | |
| return "$rc" | |
| } | |
| # api_budget_traced <out|-> <gh args>: gh with GH_DEBUG=1, stdout to <out> | |
| # (`-`: left as it is). That level logs only head lines per request (`* Request | |
| # at`, `* Request to <url>`, `* Request took`), never headers or bodies, which | |
| # GH_DEBUG=api adds and which no filter could tell from gh's own text. Each | |
| # request to a GitHub API URL (a redirect to a download host is not one) is a | |
| # `request` line in the token's file, less the one an <out> header block | |
| # already gives a reading. Once gh exits, its stderr goes on without what the | |
| # debug level adds: those lines, a `* <error>` line while a request is open, | |
| # `[git ...]` command lines, and the `lookup ...` line under `error connecting | |
| # to`. Returns gh's status. | |
| api_budget_traced() { | |
| local out=$1 log rc=0 skip=0 nl=0 first state=${RUNNER_TEMP:-}/api-budget/${API_BUDGET_GH_TOKEN:-} | |
| shift | |
| log=$(mktemp 2>/dev/null) || { | |
| if [[ $out == - ]]; then command gh "$@"; else command gh "$@" >"$out"; fi | |
| return | |
| } | |
| if [[ $out == - ]]; then | |
| GH_DEBUG=1 command gh "$@" 2>"$log" || rc=$? | |
| else | |
| GH_DEBUG=1 command gh "$@" >"$out" 2>"$log" || rc=$? | |
| read -r first <"$out" 2>/dev/null | |
| [[ ! ${first:-} =~ ^HTTP/[0-9.]+\ [0-9]{3} ]] || skip=1 | |
| fi | |
| if [[ -z ${RUNNER_TEMP:-} || ! ${API_BUDGET_GH_TOKEN:-} =~ ^[a-z][a-z0-9-]{0,39}$ ]] || ! mkdir -p "${state%/*}" 2>/dev/null; then | |
| state=/dev/null | |
| fi | |
| [[ $(tail -c 1 "$log" | od -An -c) != *'\n'* ]] || nl=1 | |
| awk -v state="$state" -v skip="$skip" -v nl="$nl" ' | |
| /^\* Request at / { open++; next } | |
| /^\* Request took / { if (open) open--; next } | |
| /^\* Request to / { | |
| if ($4 ~ /^https:\/\/((api|uploads)\.github\.com|[^\/]+\/api)\// && ++n > skip) print "request" ($4 ~ /\/graphql([?]|$)/ ? " graphql" : "") >>state | |
| next | |
| } | |
| /^\* (Using proxy|Skipping TLS verification): / || (open && /^\* /) || /^\[git[] ]/ { next } | |
| /^lookup / && kept ~ /^error connecting to / { kept = ""; next } | |
| { printf "%s%s", sep, $0; sep = "\n"; kept = $0; last = NR } | |
| END { close(state); if (sep != "" && (nl || last != NR)) printf "\n" } | |
| ' "$log" >&2 | |
| rm -f "$log" | |
| return "$rc" | |
| } | |
| api_budget_pool() { | |
| local arg | |
| [[ ${1:-} == api ]] || { echo any; return; } | |
| for arg in "${@:2}"; do | |
| case $arg in | |
| graphql) echo graphql; return ;; | |
| search/code* | /search/code*) echo code_search; return ;; | |
| search/* | /search/*) echo search; return ;; | |
| *) ;; | |
| esac | |
| done | |
| echo core | |
| } | |
| api_budget_stop_until() { | |
| local file=${RUNNER_TEMP:-/tmp}/api-budget/stop up | |
| if [[ ! -e $file ]]; then | |
| # Missing (ENOENT) only when the nearest path part above it that exists | |
| # is a directory it can search; else ENOTDIR or EACCES. | |
| up=$file | |
| while [[ $up == */* ]]; do | |
| up=${up%/*} | |
| [[ -e ${up:-/} ]] && break | |
| done | |
| [[ -d ${up:-/} && -x ${up:-/} ]] && return 0 | |
| fi | |
| if [[ -d $file ]] || ! { : <"$file"; } 2>/dev/null; then | |
| echo "::error::the rate guard's stop file $file cannot be read, so every GitHub API pool counts as stopped" >&2 | |
| echo 999999999999 | |
| return 0 | |
| fi | |
| # The x ends an unterminated last line in a non-digit, so it never matches. | |
| { cat -- "$file"; printf x; } | awk -v pool="$1" -v now="$(date +%s)" '/^([a-z_]+|\*) [0-9]+$/ && length($2) <= 12 && ($1 == pool || $1 == "*" || pool == "any") && $2 + 0 > now + 0 && $2 + 0 > max { max = $2 + 0 } | |
| END { if (max) print max }' | |
| } | |
| api_budget_guessed() { | |
| local file=${RUNNER_TEMP:-/tmp}/api-budget/guessed | |
| [[ -s $file ]] && awk -v t="$1" '/^([a-z_]+|\*) [0-9]+$/ && length($2) <= 12 && $2 == t { found = 1 } END { exit !found }' "$file" | |
| } | |
| api_budget_label() { [[ $1 == '*' || $1 == any ]] && echo API || echo "$1 API"; } | |
| api_budget_when() { jq -rn --argjson t "$1" '$t | todate'; } | |
| api_budget_refuse() { | |
| local until | |
| if [[ -n ${API_BUDGET_UNRECORDED:-} ]]; then | |
| echo "::error::'$2' was not sent: GitHub's $(api_budget_label "$API_BUDGET_UNRECORDED") stop could not be recorded, so this step sends no further call" >&2 | |
| return 1 | |
| fi | |
| until=$(api_budget_stop_until "$1") | |
| [[ -z $until ]] && return 0 | |
| echo "::error::'$2' was not sent: GitHub's $(api_budget_label "$1") rate limit holds until $(api_budget_when "$until")" >&2 | |
| return 1 | |
| } | |
| api_budget_stop() { | |
| local dir=${RUNNER_TEMP:-/tmp}/api-budget lines | |
| lines=("::error::'$4' hit GitHub's $(api_budget_label "$1") rate limit: no call to it before $(api_budget_when "$2") ($3); ${5:-the call returns 1}") | |
| # One printf per line: the line and its newline go in one write. | |
| if mkdir -p "$dir" 2>/dev/null && { printf '%s %s\n' "$1" "$2" >>"$dir/stop"; } 2>/dev/null && | |
| { [[ $3 != fallback ]] || { printf '%s %s\n' "$1" "$2" >>"$dir/guessed"; } 2>/dev/null; }; then | |
| printf '%s\n' "${lines[@]}" >&2 | |
| return 1 | |
| fi | |
| API_BUDGET_UNRECORDED=$1 | |
| lines+=("::error::GitHub's $(api_budget_label "$1") stop could not be recorded in $dir; later calls would reach the exhausted pool, so this step ends") | |
| { printf '%s\n' "${lines[@]}" >&"${API_BUDGET_ERR_FD:-2}"; } 2>/dev/null || printf '%s\n' "${lines[@]}" >&2 | |
| if ((BASH_SUBSHELL > 0)); then | |
| # Each parent up to $$, from /proc/<pid>/stat's ppid; only $$ when the | |
| # walk cannot reach it. | |
| local pid=$BASHPID stat up=() | |
| while ((pid != $$)) && read -r stat 2>/dev/null <"/proc/$pid/stat"; do | |
| read -r _ pid _ <<<"${stat##*) }" | |
| up+=("$pid") | |
| done | |
| ((pid == $$)) || up=("$$") | |
| kill -s TERM "${up[@]}" 2>/dev/null | |
| fi | |
| exit 1 | |
| } | |
| # shellcheck disable=SC2016 # a jq program | |
| API_BUDGET_CLASSIFY='def num: if type == "string" and test("^[0-9]{1,12}$") then tonumber else null end; | |
| . as $in | ($in.headers // {}) as $h | |
| | ($h["x-ratelimit-remaining"] | num) as $rem | ($h["x-ratelimit-limit"] | num) as $lim | |
| | ($h["x-ratelimit-reset"] | num) as $reset | ($h["retry-after"] | num) as $ra | |
| | (if ($in.body | type) == "string" then ($in.body | try fromjson catch null) else $in.body end) as $b | |
| | ($in.pool == "graphql") as $gq | |
| | ((if ($b | type) == "object" then [$b.message] + (if $gq and (($b.errors // null) | type) == "array" then [$b.errors[] | if type == "object" then .message else null end] else [] end) else [] end) | |
| | map(. // "" | tostring)) as $texts | |
| | ($in.status == 403 or $in.status == 429 or $gq) as $flagged | |
| | ($flagged and any($texts[]; test("rate limit|abuse detection"; "i"))) as $msg | |
| | ($flagged and any($texts[]; test("secondary rate limit|abuse detection"; "i"))) as $secondary | |
| | ($in.status == 429) as $many | |
| | ((if ($b | type) == "object" then ($b.errors // []) else [] end) | |
| | if type == "array" then any(.[]; type == "object" and .type == "RATE_LIMITED") else false end) as $gql | |
| | ($rem == 0 or $ra != null or $msg or $gql or $many) as $latched | |
| | (if $in.cap != null and $lim != null then ([$lim, $in.cap] | min) else $lim end) as $planned | |
| | ($in.floor != null and $lim != null and $lim > 0 and $rem != null and $planned - ($lim - $rem) < $in.floor * $planned) as $low | |
| | if ($latched | not) then {latched: false, low: $low, reset_source: null, reset: null, pool: null} | |
| else (($in.now / 1000) | floor) as $n | |
| | [(if $ra != null then [$n + $ra, "retry-after"] else empty end), | |
| (if $rem == 0 and $reset != null then [$reset, "x-ratelimit-reset"] else empty end)] as $c | |
| | (if ($c | length) > 0 then ($c | reduce .[1:][] as $x (.[0]; if $x[0] > .[0] then $x else . end)) else null end) as $best | |
| | (if $best != null and $best[0] > $n then $best | |
| else [$n + (if $best != null or $secondary then 60 else 3600 end), "fallback"] end | |
| | .[0] |= ([., 999999999999] | min)) as $at | |
| | (($h["x-ratelimit-resource"] // "") | tostring) as $res | |
| | {latched: true, low: $low, reset_source: $at[1], reset: $at[0], | |
| pool: (if $ra != null or $secondary or (($msg or $many) and $rem != 0) then "*" elif ($res | test("^[a-z_]+$")) then $res else $in.pool end)} | |
| end' | |
| api_budget_observe() { | |
| local file=$1 pool=$2 what=$3 start verdict latched low source reset stop status dir=${RUNNER_TEMP:-/tmp}/api-budget | |
| start=$(awk 'NR == 1 && !/^HTTP\/[0-9.]+ [0-9][0-9][0-9]/ { exit } { sub(/\r$/, "") } NR > 1 && $0 == "" { print NR; exit }' "$file") | |
| [[ -n $start ]] || return 0 | |
| verdict=$(jq -rn --rawfile raw "$file" --argjson start "$start" --arg pool "$pool" --argjson now "$(($(date +%s) * 1000))" \ | |
| --arg floor "${API_BUDGET_READ_FLOOR:-}" --arg cap "${API_BUDGET_LIMIT_CAP:-}" '($raw | split("\n")) as $l | |
| | ($l[0] | split(" ")[1] | tonumber) as $status | |
| | ([$l[1:$start - 1][] | sub("\r$"; "") | capture("^(?<k>[^:]+):[ \t]*(?<v>.*)$") | {(.k | ascii_downcase): .v}] | add // {}) as $headers | |
| | {status: $status, headers: $headers, body: ($l[$start:] | join("\n")), now: $now, pool: $pool, | |
| floor: ($floor | tonumber? // null), cap: ($cap | tonumber? // null)} | |
| | '"$API_BUDGET_CLASSIFY"' | "\(.latched) \(.low) \(.reset_source) \(.reset) \(.pool)"' 2>/dev/null) || return 0 | |
| read -r latched low source reset stop <<<"$verdict" | |
| if [[ $latched == true ]]; then | |
| read -r _ status _ <"$file" | |
| if [[ $status == 2[0-9][0-9] ]]; then | |
| api_budget_stop "$stop" "$reset" "$source" "$what" "its answer stands, and later calls to it are refused" || : | |
| return 0 | |
| fi | |
| api_budget_stop "$stop" "$reset" "$source" "$what" || return 1 | |
| elif [[ $low == true ]] && mkdir -p "$dir" 2>/dev/null && [[ ! -e $dir/low-$pool ]]; then | |
| : >"$dir/low-$pool" | |
| echo "::warning::GitHub's $pool API has under its read floor left; reads continue" >&2 | |
| fi | |
| } | |
| # A gh call that creates or changes content GitHub's content-creation limit | |
| # counts, as rate-guard.mjs's isContentWrite judges a request: a gh api write | |
| # (-X POST, PATCH or PUT, or a field with no -X, which gh sends as POST) to an | |
| # issue, pull request, comment, review or label path, a graphql mutation, or | |
| # a gh issue or pr command that writes. | |
| api_budget_is_content() { | |
| local method='' fields='' mutation='' path='' arg prev='' | |
| case "${1:-} ${2:-}" in | |
| "issue create" | "issue comment" | "issue edit" | "issue close" | "issue reopen" | \ | |
| "pr create" | "pr comment" | "pr edit" | "pr review" | "pr close" | "pr reopen" | "label create") return 0 ;; | |
| api\ *) ;; | |
| *) return 1 ;; | |
| esac | |
| for arg in "${@:2}"; do | |
| # A value joined to its flag (--method=POST, -XPOST, -fquery=...) is read | |
| # as the separated form. | |
| if [[ -z $prev ]]; then | |
| case $arg in | |
| --method=* | --field=* | --raw-field=* | --input=*) prev=${arg%%=*} arg=${arg#*=} ;; | |
| -X?* | -f?* | -F?*) prev=${arg:0:2} arg=${arg:2} arg=${arg#=} ;; | |
| *) ;; | |
| esac | |
| fi | |
| case $prev in | |
| -X | --method) method=${arg^^} ;; | |
| -f | -F | --field | --raw-field) fields=1; [[ ! $arg =~ ^query=[[:space:]]*mutation ]] || mutation=1 ;; | |
| --input) fields=1 ;; | |
| -H | --header | --jq | -q | -t | --template | --hostname | --cache | -p | --preview) ;; | |
| *) [[ $arg == -* || -n $path ]] || path=$arg ;; | |
| esac | |
| case $arg in -X | --method | -f | -F | --field | --raw-field | --input | -H | --header | --jq | -q | -t | --template | --hostname | --cache | -p | --preview) prev=$arg ;; *) prev= ;; esac | |
| done | |
| [[ -n $method ]] || { [[ -n $fields ]] && method=POST; } || method=GET | |
| [[ $method == POST || $method == PATCH || $method == PUT ]] || return 1 | |
| if [[ $path == graphql ]]; then | |
| [[ -n $mutation ]] | |
| return | |
| fi | |
| [[ /${path#/} =~ ^/repos/[^/]+/[^/]+/(issues|pulls)(/[0-9]+(/(comments|labels|reviews|assignees|requested_reviewers))?|/[0-9]+/comments/[0-9]+/replies|/comments/[0-9]+)?/?(\?|$) ]] | |
| } | |
| api_budget_count_content() { | |
| local dir=${RUNNER_TEMP:-}/api-budget | |
| [[ -n ${RUNNER_TEMP:-} ]] && mkdir -p "$dir" 2>/dev/null && date +%s >>"$dir/content-creation" | |
| return 0 | |
| } | |
| # The job's content-creating writes, from the lines rate-guard.mjs and the gh | |
| # wrapper append: the count and the most in any sixty seconds. Nothing without | |
| # a write. | |
| api_budget_emit_content() { | |
| local file=${RUNNER_TEMP:-}/api-budget/content-creation where workflow=${GITHUB_WORKFLOW_REF:-} | |
| [[ -n ${RUNNER_TEMP:-} && -s $file ]] || return 0 | |
| workflow=${workflow%%@*} | |
| workflow=${workflow#*/*/} | |
| where="workflow=${workflow:-na} job=${GITHUB_JOB:-na} run=${GITHUB_RUN_ID:-na} attempt=${GITHUB_RUN_ATTEMPT:-na}" | |
| sort -n "$file" | awk -v where="$where" '/^[0-9]+$/ { t[++n] = $1; while (t[n] - t[j + 1] >= 60) j++; if (n - j > max) max = n - j } | |
| END { if (n) printf "::notice title=api-budget::content-creation %s count=%d max-per-minute=%d\n", where, n, max }' | |
| return 0 | |
| } | |
| api_budget_emit_calls() { | |
| local token file | |
| for token; do | |
| file=${RUNNER_TEMP:-}/api-budget/$token | |
| [[ -n ${RUNNER_TEMP:-} && $token =~ ^[a-z][a-z0-9-]{0,39}$ ]] || continue | |
| [[ -s $file ]] || file=/dev/null | |
| awk '$1 != 304 { n++ } $1 == 304 { m++ } $6 == "graphql" || $2 == "graphql" { g++ } END { printf "::notice title=api-calls::%s=%d not-modified=%d graphql=%d\n", token, n, m, g }' token="$token" "$file" | |
| done | |
| return 0 | |
| } | |
| api_budget_emit() { | |
| local token file where workflow=${GITHUB_WORKFLOW_REF:-} | |
| workflow=${workflow%%@*} | |
| workflow=${workflow#*/*/} | |
| where="workflow=${workflow:-na} job=${GITHUB_JOB:-na} run=${GITHUB_RUN_ID:-na} attempt=${GITHUB_RUN_ATTEMPT:-na}" | |
| for token; do | |
| file=${RUNNER_TEMP:-}/api-budget/$token | |
| [[ -n ${RUNNER_TEMP:-} && -s $file ]] || continue | |
| awk -v token="$token" -v where="$where" ' | |
| NF == 6 { n++; s[n] = $1; l[n] = $2; r[n] = $3; u[n] = $4; t[n] = $5; res[n] = $6 } | |
| function line(i, j, cost) { | |
| cost = "na" | |
| if (u[i] != "na" && t[i] != "na") { | |
| for (j = 1; j < i; j++) if (t[j] == t[i] && res[j] == res[i] && u[j] != "na") break | |
| if (j < i && u[i] + 0 >= u[j] + 0) cost = u[i] - u[j] | |
| } | |
| printf "::notice title=api-budget::token=%s %s limit=%s remaining=%s used=%s reset=%s resource=%s cost=%s\n", | |
| token, where, l[i], r[i], u[i], t[i], res[i], cost | |
| } | |
| END { | |
| for (i = 1; i <= n; i++) last[res[i]] = i | |
| for (i = 1; i <= n; i++) if ((s[i] == 403 || s[i] == 429) && last[res[i]] != i) line(i) | |
| for (i = 1; i <= n; i++) if (last[res[i]] == i && res[i] != "core") line(i) | |
| if ("core" in last) line(last["core"]) | |
| }' "$file" | |
| done | |
| return 0 | |
| } | |
| permissions: | |
| # `yield-to-full-run` lists this workflow's runs on the head SHA, and | |
| # `rerun-contract-only-siblings` re-runs the red contract-only ones once | |
| # the full run records `success`. Under an explicit permissions block every | |
| # unlisted scope defaults to none; write covers the read. | |
| actions: write | |
| # The cost-hold step reads the review lanes' hold notices from their | |
| # check-run annotations. | |
| checks: read | |
| contents: read | |
| # check-contract upserts the advisory linkage comment and moves the linkage | |
| # label; ci-status records the `ci-lanes` commit status the carry-forward | |
| # branch reads back on the next contract-only event. The cost-hold step | |
| # comments and removes the human-gated label. | |
| pull-requests: write | |
| statuses: write | |
| steps: | |
| # A RATE-LIMITED READ WAITS FOR THE RESET. The fail-closed steps below | |
| # read through gh_read, which runs `gh api` and, when that fails on a | |
| # rate limit, waits once and reads again. The rate limit and its reset | |
| # come from the api-budget gh wrapper's rate guard (API_BUDGET_SH): it | |
| # reads each response's headers and records a stop for the pool until | |
| # the reset (retry-after, else x-ratelimit-reset while remaining is 0, | |
| # else a guess: the documented minute for a secondary limit, an hour for | |
| # any other, as for a paginated read whose headers it cannot see), and | |
| # refuses every gh call to that pool before it, so nothing is sent while | |
| # limited, a probe included. gh_read waits one second past a reset a | |
| # header gave; a guessed stop fails the read at once. The job's timeout is not raised | |
| # for it: a wait is taken only when it ends inside the budget the job | |
| # env sets, so the steps after it still fit. A wait past that fails the | |
| # read at once, red and never neutral (a neutral required check counts | |
| # as passing), with an error naming the reset and asking for a re-run | |
| # after it; later reads of that pool before the reset fail without a | |
| # call. Nothing here re-runs the job: the next review lane's | |
| # rerun-ci-status, or a person, re-runs it after the reset. Any other | |
| # failure, or a second rate limit after the wait, returns gh's status at | |
| # once, so each caller's own retries and fail-closed exit stay as they | |
| # were. The cost-hold step keeps plain `gh api`: an unreadable input | |
| # keeps its hold, and a wait there would act on reads made before it. | |
| # Every other `gh api` in this job is a write or marked gh-read-exempt. | |
| # This step writes the function for the others to source. Pinned by | |
| # scripts/ci-gh-read.test.sh, which runs this body. | |
| - name: Write the rate-limit-aware read helper | |
| run: | | |
| gh_read() { | |
| local pool=core deadline err out now until wait at waited= | |
| ! declare -F api_budget_pool >/dev/null || pool=$(api_budget_pool api "$@") | |
| deadline=$(cat "$RUNNER_TEMP/gh-read.deadline") | |
| err=$(mktemp) | |
| while :; do | |
| if out=$(gh api "$@" 2>"$err"); then | |
| cat "$err" >&2 | |
| rm -f "$err" | |
| [ -z "$out" ] || printf '%s\n' "$out" | |
| return 0 | |
| fi | |
| cat "$err" >&2 | |
| until= | |
| if [ -z "$waited" ] && declare -F api_budget_stop_until >/dev/null; then | |
| until=$(api_budget_stop_until "$pool") | |
| fi | |
| # A guessed stop (no header gave its time) gets no wait. | |
| if ! [[ $until =~ ^[0-9]+$ ]] || api_budget_guessed "$until"; then | |
| rm -f "$err" | |
| return 1 | |
| fi | |
| now=$(date +%s) | |
| wait=$((until - now + 1)) | |
| if [ $((now + wait)) -gt "$deadline" ]; then | |
| at=$(jq -rn --argjson t "$until" '$t | todate') | |
| echo "::error::GitHub's $pool API rate limit blocks 'gh api $*' until $at, past the $(jq -rn --argjson t "$deadline" '$t | todate') ci-status can wait to inside its timeout; a re-run after $at is needed." >&9 | |
| rm -f "$err" | |
| return 1 | |
| fi | |
| echo "::notice::'gh api $*' is rate limited ($pool); waiting ${wait}s for the reset, then reading again." >&9 | |
| sleep "$wait" | |
| waited=1 | |
| done | |
| } | |
| echo $(($(date +%s) + GH_READ_JOB_SECONDS - GH_READ_MARGIN_SECONDS)) >"$RUNNER_TEMP/gh-read.deadline" | |
| # fd 9 is each sourcing step's stderr, so the notices reach the log | |
| # when a caller captures or redirects gh_read's own stderr. | |
| # The api-budget gh wrapper too: every `gh api` of a sourcing step, | |
| # gh_read's included, leaves its rate-limit reading for the notice | |
| # the lane-scope step writes. | |
| { echo 'exec 9>&2'; declare -f gh_read; printf '%s\n' "${API_BUDGET_SH:-}" '! declare -F api_budget_wrap_gh >/dev/null || api_budget_wrap_gh github-token'; } >"$RUNNER_TEMP/gh-read.sh" | |
| - name: Check the pull-request contract | |
| uses: melodic-software/ci-workflows/.github/actions/pr-require-checks/check-contract@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| with: | |
| token: ${{ github.token }} | |
| # Carried over verbatim from `pr-issue-linkage.yml`, the caller this | |
| # step replaces: dependabot PR bodies cannot carry the closing keyword | |
| # and the four contract sections, so its login is exempt from the | |
| # linkage check and its pull requests stay mergeable. An exact-login | |
| # match, never a `*[bot]` pattern. | |
| exempt-authors: 'dependabot[bot]' | |
| # A RESOLVE-CONFIG DEPENDENCY CHANGE NAMES ITS BROKER DEPLOY PULL REQUEST. | |
| # The lanes token broker in melodic-software/azure-iac loads resolve-config's | |
| # modules from main's tip, but imports ajv and yaml from its own deployed | |
| # package and denies every token as config-invalid while main's | |
| # package.json or package-lock.json here differs from the deployed one | |
| # (its src/lanes-token-broker/src/resolve-config-loader.mjs). BROKER_PATHS | |
| # below is those two files. The body line names the azure-iac pull request | |
| # that deploys the new dependencies; it is re-read on a body edit, since | |
| # this job runs on every contract-only event. A merge group carries no | |
| # body; the pull request was checked before enqueue. | |
| # Pinned by scripts/ci-broker-pin.test.sh, which runs this body. | |
| - name: Require a broker deploy PR for a resolve-config dependency change | |
| if: ${{ !cancelled() && github.event_name == 'pull_request' }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PR_BODY: ${{ github.event.pull_request.body }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| run: | | |
| . "$RUNNER_TEMP/gh-read.sh" | |
| BROKER_PATHS='.github/actions/resolve-config/package.json | |
| .github/actions/resolve-config/package-lock.json' | |
| for attempt in 1 2 3; do | |
| if pages=$(gh_read "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" --paginate); then | |
| break | |
| fi | |
| [ "$attempt" -lt 3 ] || { echo "::error::could not read the files of #$PR_NUMBER; re-run this run."; exit 1; } | |
| sleep $((attempt * 5)) | |
| done | |
| files=$(jq -r '.[] | .filename, (.previous_filename // empty)' <<<"$pages") | |
| # The review-lane step reads the same list from here instead of again. | |
| printf '%s\n' "$pages" >"$RUNNER_TEMP/pr-files.json" | |
| touched= | |
| for path in $BROKER_PATHS; do | |
| if grep -qxF "$path" <<<"$files"; then touched="$touched $path"; fi | |
| done | |
| if [ -z "$touched" ]; then | |
| echo "No resolve-config dependency file changed." | |
| exit 0 | |
| fi | |
| pr=$(tr -d '\r' <<<"$PR_BODY" | sed -nE 's/^Broker deploy: (melodic-software\/azure-iac#[0-9]+)[[:blank:]]*$/\1/p') | |
| if [ -z "$pr" ]; then | |
| echo "::error::this pull request changes resolve-config's dependencies:$touched. The lanes token broker denies every token while main's differ from its deployed ones. Open the azure-iac pull request that moves the broker to these dependencies, deploy it as this merges, and add a body line 'Broker deploy: melodic-software/azure-iac#<n>' naming it." | |
| exit 1 | |
| fi | |
| echo "Resolve-config dependency change:$touched; paired broker deploy pull request: $pr" | |
| # A BODY LINE MUST NOT START A SQUASH MESSAGE LINE WITH `committer `. | |
| # GitHub builds the squash message from the body (squash_merge_commit_message | |
| # PR_BODY) and wraps it: a line of at most 72 characters is kept as | |
| # written, a longer one is split on runs of spaces and tabs and filled | |
| # greedily to 72 (19,174 of 19,174 body lines across the 400 squash commits | |
| # on main before 2026-10-20 match this). A message line starting with | |
| # `committer ` is read as a header when GitHub signs the commit, so the | |
| # signature verifies invalid, the signed-commit rule refuses the push, and | |
| # the queue removes the pull request as `branch_protection_failure` | |
| # (#7279, #7360). Only that word is checked: squash messages with lines | |
| # starting `author ` (#8489) and `parent ` (#7933) verified valid, and the | |
| # other header words have no evidence either way. Fenced code wraps the same | |
| # way, so it is checked too. On a pull request the body is wrapped by that | |
| # rule; in a merge group the squash commit's own message (its subject | |
| # dropped) is read, which catches a body edited after enqueue. Pinned by | |
| # scripts/ci-squash-header-lines.test.sh, which runs this body. | |
| - name: Fail a body line that would break the squash signature | |
| if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }} | |
| env: | |
| TEXT: ${{ github.event.merge_group.head_commit.message || github.event.pull_request.body }} | |
| # Bash measures ${#line} in characters only under a UTF-8 locale. | |
| LC_ALL: C.UTF-8 | |
| run: | | |
| text=${TEXT//$'\r'/} | |
| where='pull request body line' | |
| if [ "$GITHUB_EVENT_NAME" = merge_group ]; then | |
| where='merge-group commit message line' | |
| case $text in *$'\n'*) text=${text#*$'\n'} ;; *) text= ;; esac | |
| offset=1 | |
| fi | |
| n=${offset:-0} found=0 | |
| while IFS= read -r line || [ -n "$line" ]; do | |
| n=$((n + 1)) | |
| rows=("$line") | |
| if [ "${#line}" -gt 72 ]; then | |
| rows=() cur= | |
| IFS=$' \t' read -ra words <<<"$line" | |
| for w in "${words[@]}"; do | |
| if [ -z "$cur" ]; then | |
| cur=$w | |
| elif [ $((${#cur} + 1 + ${#w})) -le 72 ]; then | |
| cur="$cur $w" | |
| else | |
| rows+=("$cur") | |
| cur=$w | |
| fi | |
| done | |
| [ -z "$cur" ] || rows+=("$cur") | |
| fi | |
| for row in "${rows[@]}"; do | |
| if [[ $row == 'committer '* ]]; then | |
| found=1 | |
| echo "::error::$where $n puts '${row//%/%25}' at the start of a squash commit message line, and that line starts with 'committer '. GitHub's signer reads such a line as the commit's committer header, so the squash commit's signature fails and the merge queue removes this pull request. Reword so no line, as GitHub wraps it at 72 characters, starts with 'committer '." | |
| fi | |
| done | |
| done <<<"$text" | |
| [ "$found" -eq 0 ] || exit 1 | |
| echo "No squash message line starts with 'committer '." | |
| # A DRAFT PASSES. Every lane skips on a draft, so its ci-status has linted | |
| # and tested nothing, and the aggregate below passes those skips. A green | |
| # draft cannot admit a merge: a draft cannot merge, and the merge-queue | |
| # ruleset has no bypass actor, so every merge waits for this workflow's | |
| # merge-group run, which runs every lane. This step says why it is green. | |
| - name: Note a draft | |
| if: github.event.pull_request.draft == true | |
| run: | | |
| msg="draft: lanes not run; the ready_for_review run lints and tests this SHA" | |
| echo "::notice::$msg" | |
| echo "$msg" >>"$GITHUB_STEP_SUMMARY" | |
| # The cost-hold and lanes-hold steps below run node; pin it to main's | |
| # .node-version, as every other Node job is, rather than the runner | |
| # image's default. The same checkout of the default branch holds the | |
| # merge queue's verdict reader as main has it: babysit_verify_evidence.py | |
| # with its sibling modules and its config parser, and the pr-merge | |
| # scripts that start its run cache and read its head-ref map and | |
| # carried artifact listing, with every | |
| # module they import (scripts/ci-lanes-pr-hold.test.sh checks the list). | |
| # No pull request code is checked out and no credential is kept. | |
| - name: Check out main's Node pin and verdict reader | |
| if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }} | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ github.event.repository.default_branch }} | |
| path: .base | |
| persist-credentials: false | |
| sparse-checkout: | | |
| /.node-version | |
| /plugins/source-control/skills/babysit-prs/scripts/*.py | |
| /plugins/source-control/lib/parse-concern-value.sh | |
| /plugins/source-control/lib/yaml-subset.awk | |
| /.github/scripts/pr-merge/head-ref-map.mjs | |
| /.github/scripts/pr-merge/carry-run-cache.mjs | |
| /.github/scripts/pr-merge/listing-carry.mjs | |
| /.github/scripts/pr-merge/rotation.mjs | |
| /.github/actions/check-trusted-trigger/check-trusted-trigger.mjs | |
| /.github/actions/check-kill-switch/check-kill-switch.mjs | |
| /.github/actions/log-api-budget/rate-guard.mjs | |
| sparse-checkout-cone-mode: false | |
| - name: Set up Node | |
| if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }} | |
| uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 | |
| with: | |
| node-version-file: .base/.node-version | |
| package-manager-cache: false | |
| # THE HOLD STEPS READ THEIR CONFIG FROM THE BASE COMMIT, ONCE. The tracker | |
| # config names the human-gated label, the trusted-actor list names who | |
| # may lift a hold and, with pr-pipeline.yaml's merge.agent-identity, the | |
| # lanes App's id that LANES_APP_ID must equal; all are read at the base | |
| # so the pull request cannot rewrite its own hold, as is merge-holds.mjs, | |
| # whose refsIssue the cost step runs. A sparse checkout of the four files at the base SHA | |
| # is a git fetch, not a REST request: as of actions/checkout v7.0.1 its | |
| # API calls (default branch, object format, the stale-merge tracepoint, the | |
| # no-git archive download) all return early for a 40-hex `ref` on a runner | |
| # with git; recheck on a checkout bump. It checks out no pull request | |
| # code: the ref is the base commit, the only code run from it is that | |
| # base module, and no credential is persisted. | |
| # The steps below read BASE_CONFIG: empty when this step did not succeed, | |
| # which each of them treats as an unreadable config; a file missing from | |
| # a checkout that succeeded is a file the base commit does not have. | |
| - name: Check out the base commit's hold config | |
| id: base_config | |
| if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }} | |
| continue-on-error: true | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} | |
| path: base-config | |
| persist-credentials: false | |
| sparse-checkout: | | |
| /.work-item-tracker.json | |
| /.github/standards/trusted-actors/trusted-actors.json | |
| /.github/scripts/pr-merge/merge-holds.mjs | |
| /docs/conventions/pr-pipeline.yaml | |
| sparse-checkout-cone-mode: false | |
| # A COST HOLD THE OWNER ALREADY DECIDED COMES OFF. A review lane re-holds | |
| # every new head, so its `cost` hold went back on after the owner had | |
| # recorded the cost decision. This step clears a hold only when all | |
| # of these hold, else it changes nothing and the two steps below judge | |
| # the label as before: | |
| # - The window opens at the newest removal of the human-gated label by a | |
| # `human` of the base commit's trusted-actor list (ever, when none). | |
| # - The label was added in it, and only by github-actions[bot] or | |
| # claude[bot], each time followed within ten minutes by a held review. | |
| # - Every hold a review lane applied in it has criteria `cost` alone. The | |
| # holds are read from the `claude-lane-hold ... criteria=` notice the | |
| # lane's status job records (any attempt, on this branch), never from | |
| # the hold comment, which anyone with write access can edit or delete. | |
| # A pull_request run takes its workflow from the pull request, so a | |
| # run in it or started before the decision whose head changes either | |
| # review-lane workflow from the base (or whose comparison lists 300 | |
| # files, the API's cap) could forge that notice, and keeps the hold. | |
| # - No lanes App escalation comment (a `<!-- work-items:escalation` | |
| # marker) was written or edited and no comment was deleted in it: a | |
| # lanes App escalation adds no label event to a label already on. Its | |
| # other comments (review replies, fix notes) keep nothing. | |
| # - The pull request body, outside HTML comments, links a decision comment | |
| # as babysit_merge_policy.py's cost_decision reads one: by a User on the | |
| # trusted-actor list, not through an App, never edited, first line | |
| # `**Decision recorded.** Cost:`. One on the pull request itself counts | |
| # as it is; one on its linked issue counts only when written after the | |
| # pull request and naming it as `#<number>` (merge-holds.mjs | |
| # namesPull). Of those, the newest on the pull request, else the | |
| # newest on the issue, is the decision. It covers the head it decided | |
| # and a later head only when every commit since is a merge and the | |
| # pull request's net change against its base branch is the same at | |
| # both, so every change since is a base merge (the base's | |
| # merge-holds.mjs baseMergeCarry; owner decisions Q17 c and Q18 d on | |
| # #9436). The decided head is the `sha=` of the | |
| # newest cost hold notice a review lane's status job recorded before | |
| # the decision, read with the holds below from runs vetted the same | |
| # way and only where it names its own run's head; never a hold | |
| # comment, which any GITHUB_TOKEN workflow can post as | |
| # github-actions[bot]. With none, or two heads at that time, it covers | |
| # no head. Nor does it while the head branch's push history (the | |
| # repository activity API, which deleting a run leaves) shows a head | |
| # pushed after the decided head's last push and before the decision | |
| # with no run of every review lane started before the decision, since | |
| # a deleted run of a newer head would make an older notice the decided | |
| # head (the base's merge-holds.mjs unranHead, #10437); a history with | |
| # no push of the decided head before it, or a full page, covers none | |
| # too. The linked issue is the one issue in this repository that | |
| # GitHub records it as closing, else, when it closes none, the one issue | |
| # its plain `Refs:` lines name (the base's merge-holds.mjs refsIssue), | |
| # read to be an issue and not a pull request. A second closing issue | |
| # counts for nothing, and an owner decision on the issue for another | |
| # change cannot clear this hold. | |
| # The pull request's live head must still be the event's head. | |
| # It then outputs cleared=true and the decision link, and, with the label | |
| # on, posts one comment citing the decision and removes the label. A | |
| # GITHUB_TOKEN removal starts no run, so the steps below read the label | |
| # off in this run, and the hold-lift step skips on cleared=true. | |
| # Unreadable input clears nothing; the step fails the job only when the | |
| # api-budget rate guard ends it on a stop it could not record. | |
| # Pinned by scripts/ci-hold-cost-decision.test.sh, which runs this body. | |
| - name: Clear a cost-only review hold the owner decided | |
| id: cost_hold | |
| if: ${{ !cancelled() && github.event_name == 'pull_request' }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| BASE_CONFIG: ${{ steps.base_config.outcome == 'success' && format('{0}/base-config', github.workspace) || '' }} | |
| run: | | |
| [ -z "${API_BUDGET_SH:-}" ] || { eval "$API_BUDGET_SH"; api_budget_wrap_gh github-token; } | |
| keep() { | |
| echo "The cost hold stays for a person: $1." | |
| exit 0 | |
| } | |
| read_api() { | |
| local attempt out | |
| for attempt in 1 2 3; do | |
| # gh-read-exempt: an unreadable input keeps the hold, and a wait would act on stale reads. | |
| if out=$(gh api "$@" 2>"$RUNNER_TEMP/cost-hold.err"); then | |
| printf '%s\n' "$out" | |
| return 0 | |
| fi | |
| ! grep -q 'HTTP 404' "$RUNNER_TEMP/cost-hold.err" || return 2 | |
| [ "$attempt" -lt 3 ] || return 1 | |
| sleep $((attempt * 5)) | |
| done | |
| } | |
| [[ $BASE_SHA =~ ^[0-9a-f]{40}$ && $HEAD_SHA =~ ^[0-9a-f]{40}$ && $PR_NUMBER =~ ^[1-9][0-9]*$ ]] || keep "the event names no commit or pull request" | |
| [[ $LANES_APP_ID =~ ^[1-9][0-9]*$ ]] || keep "AUTOMATION_LANES_APP_SENDER_ID is not set" | |
| repo=$GITHUB_REPOSITORY | |
| [ -d "$BASE_CONFIG" ] || keep "the tracker config is unreadable" | |
| tracker= | |
| if [ -e "$BASE_CONFIG/.work-item-tracker.json" ]; then | |
| tracker=$(cat "$BASE_CONFIG/.work-item-tracker.json") || keep "the tracker config is unreadable" | |
| fi | |
| label=needs-human | |
| if [ -n "$tracker" ]; then | |
| label=$(jq -er '[objects | .config | objects | .role_labels | objects | |
| | .["human-gated"] | strings | gsub("^\\s+|\\s+$"; "")][0] // "" | |
| | if . == "" then "needs-human" else . end' <<<"$tracker") || keep "the tracker config is invalid" | |
| fi | |
| actors=$(cat "$BASE_CONFIG/.github/standards/trusted-actors/trusted-actors.json") || keep "the trusted-actor list is unreadable" | |
| # A stale or mistyped variable would miss the lanes App's escalation below. | |
| identity=$(yq -er '.merge."agent-identity"' "$BASE_CONFIG/docs/conventions/pr-pipeline.yaml" 2>/dev/null) || keep "merge.agent-identity is unreadable" | |
| jq -e --arg login "$identity" --argjson id "$LANES_APP_ID" '[.actors[] | select(.login == $login) | .id] == [$id]' <<<"$actors" >/dev/null || | |
| keep "AUTOMATION_LANES_APP_SENDER_ID is not $identity's trusted-actor id" | |
| humans=$(jq -ec '[.actors[] | select(.kind == "human") | .id | numbers] | select(length > 0)' <<<"$actors") || keep "the trusted-actor list names no person" | |
| events=$(read_api "repos/$repo/issues/$PR_NUMBER/events?per_page=100" --paginate) || keep "the events are unreadable" | |
| # The window opens at the newest removal a trusted person made directly, not through an App. | |
| since=$(jq -n --arg label "$label" --argjson humans "$humans" '[inputs[] | select(.label.name == $label | |
| and .event == "unlabeled" and .performed_via_github_app == null | |
| and (.actor.id as $id | $humans | index($id))) | .created_at | fromdateiso8601] | max // -1' \ | |
| <<<"$events") || keep "the events do not parse" | |
| # Cheap reads first: most pull requests stop here. | |
| added=$(jq -nr --arg label "$label" --argjson since "$since" '[inputs[] | select(.label.name == $label | |
| and .event == "labeled" and (.created_at | fromdateiso8601) > $since)] | |
| | if length == 0 then "no label was added since a trusted person last removed it" | |
| elif any(.[]; .actor.login != "github-actions[bot]" and .actor.login != "claude[bot]") then "someone other than a review lane added the label" | |
| else "lane" end' <<<"$events") || keep "the events do not parse" | |
| [ "$added" = lane ] || keep "$added" | |
| pr=$(read_api "repos/$repo/pulls/$PR_NUMBER") || keep "the pull request is unreadable" | |
| # A late or re-run event for an older head must not lift the label off a newer one. | |
| [ "$(jq -r '.head.sha // ""' <<<"$pr")" = "$HEAD_SHA" ] || keep "the head moved since this event" | |
| ref=$(jq -r '.head.ref | @uri' <<<"$pr") || keep "the pull request has no head branch" | |
| links=$(jq -r '.body // "" | gsub("<!--[\\s\\S]*?(?:-->|\\z)"; "") | |
| | match("https://github\\.com/([A-Za-z0-9-]+)/([A-Za-z0-9._-]+)/(?:issues|pull)/([0-9]+)#issuecomment-([0-9]+)"; "g") | |
| | [.string, (.captures[] | .string)] | @tsv' <<<"$pr") || links= | |
| [ -n "$links" ] || keep "the body links no comment" | |
| # shellcheck disable=SC2016 # GraphQL variables, not shell | |
| closing=$(read_api graphql -f query='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){closingIssuesReferences(first:2){totalCount nodes{number repository{nameWithOwner}}}}}}' \ | |
| -F owner="${repo%%/*}" -F name="${repo#*/}" -F number="$PR_NUMBER") || keep "the closing issues are unreadable" | |
| # The linked issue, as babysit's _linked_issue reads it: the one issue | |
| # in this repository the pull request closes, none when it closes more | |
| # than one, and its one plain `Refs:` issue when it closes none. | |
| issues=" $(jq -r --arg repo "$repo" '.data.repository.pullRequest.closingIssuesReferences | |
| | select(.totalCount == 1) | .nodes[] | |
| | select((.repository.nameWithOwner | ascii_downcase) == ($repo | ascii_downcase)) | .number | tostring' <<<"$closing" 2>/dev/null) " | |
| refs= | |
| if [ "$(jq -r '.data.repository.pullRequest.closingIssuesReferences.totalCount' <<<"$closing" 2>/dev/null)" = 0 ]; then | |
| jq -r '.body // ""' <<<"$pr" >"$RUNNER_TEMP/cost-hold-body.txt" || keep "the pull request body is unreadable" | |
| # shellcheck disable=SC2016 # JavaScript, not shell | |
| refs=$(node --input-type=module -e 'import { readFileSync } from "node:fs"; | |
| import { pathToFileURL } from "node:url"; | |
| const { refsIssue } = await import(pathToFileURL(process.argv[2]).href); | |
| console.log(refsIssue(readFileSync(process.argv[1], "utf8")) ?? "");' \ | |
| "$RUNNER_TEMP/cost-hold-body.txt" "$BASE_CONFIG/.github/scripts/pr-merge/merge-holds.mjs") || keep "the Refs line could not be read" | |
| issues=" $refs " | |
| fi | |
| opened=$(jq -r '.created_at | fromdateiso8601' <<<"$pr") || keep "the pull request has no creation time" | |
| comments=$(read_api "repos/$repo/issues/$PR_NUMBER/comments?per_page=100" --paginate) || keep "the comments are unreadable" | |
| # shellcheck disable=SC2016 # GraphQL variables, not shell | |
| deleted=$(read_api graphql -f query='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){timelineItems(itemTypes:[COMMENT_DELETED_EVENT],last:100){filteredCount nodes{... on CommentDeletedEvent{createdAt}}}}}}' \ | |
| -F owner="${repo%%/*}" -F name="${repo#*/}" -F number="$PR_NUMBER") || keep "the deleted comments are unreadable" | |
| # holds_js <function> <json-file>...: one function of the base's | |
| # merge-holds.mjs over the files. The module goes last: as the first | |
| # argument it would run as the command line. | |
| holds_js() { | |
| # shellcheck disable=SC2016 # JavaScript, not shell | |
| node --input-type=module -e 'import { readFileSync } from "node:fs"; | |
| import { pathToFileURL } from "node:url"; | |
| const [name, ...files] = process.argv.slice(1, -1); | |
| const run = (await import(pathToFileURL(process.argv.at(-1)).href))[name]; | |
| const out = run(...files.map((file) => JSON.parse(readFileSync(file, "utf8")))); | |
| console.log(out !== null && typeof out === "object" ? JSON.stringify(out) : String(out ?? ""));' \ | |
| "$@" "$BASE_CONFIG/.github/scripts/pr-merge/merge-holds.mjs" | |
| } | |
| # carries <decided>: whether a decision whose decided head is | |
| # <decided> covers HEAD_SHA: it is HEAD_SHA, or every commit since | |
| # is a merge whose other parents the base branch holds (their | |
| # compare against it is ahead or identical, the base's | |
| # mergedParents and baseParents) and the net change is the same at | |
| # both (the base's baseMergeCarry). Every compare is against the | |
| # base branch's tip, read once by its full ref (refs/heads/), so no | |
| # tag of the same name stands in for it. No decided head, an | |
| # unusable base branch name or any read that fails carries nothing. | |
| carries() { | |
| local decided=$1 base_ref tip parents sha diff in_base='[]' | |
| [[ $decided =~ ^[0-9a-f]{40}$ ]] || return 1 | |
| [ "$decided" != "$HEAD_SHA" ] || return 0 | |
| base_ref=$(jq -r '.base.ref // ""' <<<"$pr") || return 1 | |
| jq -en --arg b "$base_ref" '$b | test("^(?!-)(?!.*\\.\\.)[A-Za-z0-9._/-]{1,255}$")' >/dev/null || return 1 | |
| tip=$(read_api "repos/$repo/git/ref/heads/$base_ref") || return 1 | |
| tip=$(jq -r '.object.sha // ""' <<<"$tip") || return 1 | |
| [[ $tip =~ ^[0-9a-f]{40}$ ]] || return 1 | |
| read_api "repos/$repo/compare/$tip...$decided" >"$RUNNER_TEMP/cost-hold-then.json" || return 1 | |
| read_api "repos/$repo/compare/$tip...$HEAD_SHA" >"$RUNNER_TEMP/cost-hold-now.json" || return 1 | |
| jq -n --arg sha "$decided" '$sha' >"$RUNNER_TEMP/cost-hold-decided.json" || return 1 | |
| parents=$(holds_js mergedParents "$RUNNER_TEMP/cost-hold-then.json" "$RUNNER_TEMP/cost-hold-now.json") || return 1 | |
| for sha in $(jq -r '.[]?' <<<"$parents"); do | |
| [[ $sha =~ ^[0-9a-f]{40}$ ]] || return 1 | |
| diff=$(read_api "repos/$repo/compare/$sha...$tip") || return 1 | |
| case $(jq -r '.status // ""' <<<"$diff") in | |
| ahead | identical) in_base=$(jq -c --arg s "$sha" '. + [$s]' <<<"$in_base") || return 1 ;; | |
| esac | |
| done | |
| jq -n --argjson b "$in_base" '$b' >"$RUNNER_TEMP/cost-hold-in-base.json" || return 1 | |
| [ "$(holds_js baseMergeCarry "$RUNNER_TEMP/cost-hold-then.json" "$RUNNER_TEMP/cost-hold-now.json" "$RUNNER_TEMP/cost-hold-decided.json" \ | |
| "$RUNNER_TEMP/cost-hold-in-base.json")" = true ] | |
| } | |
| # Every decision that counts, as {own, at, link, id, comment}. | |
| decisions='[]' refs_read= | |
| while IFS=$'\t' read -r link owner name number id; do | |
| [ -n "$link" ] || continue | |
| [ "$(tr '[:upper:]' '[:lower:]' <<<"$owner/$name")" = "$(tr '[:upper:]' '[:lower:]' <<<"$repo")" ] || continue | |
| [ "$number" = "$PR_NUMBER" ] || [[ $issues == *" $number "* ]] || continue | |
| # A Refs number may name a pull request; only an issue is consulted. | |
| if [ "$number" != "$PR_NUMBER" ] && [ -n "$refs" ] && [ -z "$refs_read" ]; then | |
| target=$(read_api "repos/$repo/issues/$refs") || keep "the Refs issue is unreadable" | |
| refs_read=1 | |
| jq -e --argjson n "$refs" '.number == $n and .pull_request == null' <<<"$target" >/dev/null 2>&1 || issues=' ' | |
| [[ $issues == *" $number "* ]] || continue | |
| fi | |
| comment=$(read_api "repos/$repo/issues/comments/$id") || continue | |
| jq -e --arg number "$number" --arg pr "$PR_NUMBER" --argjson humans "$humans" --argjson opened "$opened" ' | |
| (.issue_url // "" | endswith("/issues/\($number)")) | |
| and .user.type == "User" and .performed_via_github_app == null | |
| and (.user.id as $id | $humans | index($id) != null) | |
| and (.body // "" | sub("\\A\\s+"; "") | test("\\A\\*\\*Decision recorded\\.\\*\\*[ \\t]+Cost:")) | |
| and ($number == $pr or ((.created_at | fromdateiso8601) > $opened | |
| and (.body // "" | test("(?:^|[^A-Za-z0-9_#&/.-])#\($pr)(?:[^0-9]|$)"))))' <<<"$comment" >/dev/null 2>&1 || continue | |
| node=$(jq -r .node_id <<<"$comment") | |
| # shellcheck disable=SC2016 # GraphQL variables, not shell | |
| edited=$(read_api graphql -f query='query($id:ID!){node(id:$id){... on IssueComment{lastEditedAt}}}' -F id="$node") || continue | |
| jq -e '.data.node | objects | has("lastEditedAt") and .lastEditedAt == null' <<<"$edited" >/dev/null 2>&1 || continue | |
| decisions=$(jq -c --argjson c "$comment" --arg link "$link" --arg id "$id" --arg own "$([ "$number" = "$PR_NUMBER" ] && echo 1)" \ | |
| '. + [{own: ($own == "1"), at: ($c.created_at | fromdateiso8601? // -1), link: $link, id: $id, comment: $c}]' <<<"$decisions") || continue | |
| done <<<"$links" | |
| # The decision is the newest on the pull request, else the newest on | |
| # its linked issue (the first linked of two at one time), as the | |
| # review lanes' hold step picks it; only it may carry. | |
| chosen=$(jq -c '([.[] | select(.own)] | if length > 0 then . else null end) // [.[] | select(.own | not)] | |
| | to_entries | sort_by([-.value.at, .key]) | .[0].value // empty' <<<"$decisions") || keep "the decisions do not parse" | |
| [ -n "$chosen" ] || keep "the body cites no recorded owner cost decision covering this head" | |
| decided_at=$(jq -r '.at' <<<"$chosen") || keep "the decisions do not parse" | |
| # Every hold notice a review lane's status job recorded in a run | |
| # whose head is in this repository (a fork's same-named branch speaks | |
| # for another pull request), updated in the window or started before | |
| # the decision, as {at, criteria, sha, run}: `sha` as the notice | |
| # names it, `run` the head its run ran on. | |
| # `ran` lists, per lane, the heads of its runs from this repository | |
| # started before the decision. | |
| notices='[]' vetted='' ran='[]' | |
| while read -r file check; do | |
| runs=$(read_api "repos/$repo/actions/workflows/$file/runs?event=pull_request&branch=$ref&per_page=100" --paginate) || keep "the $file runs are unreadable" | |
| ran=$(jq -nc --argjson ran "$ran" --arg repo "$repo" --argjson decided "$decided_at" '$ran + [[inputs.workflow_runs[] | |
| | select(.head_repository.full_name == $repo and (.created_at | fromdateiso8601) < $decided) | .head_sha] | unique]' <<<"$runs") || | |
| keep "the $file runs do not parse" | |
| while read -r id sha; do | |
| [ -n "$id" ] || continue | |
| [[ $sha =~ ^[0-9a-f]{40}$ ]] || keep "run $id names no head commit" | |
| if [[ " $vetted " != *" $sha "* ]]; then | |
| diff=$(read_api "repos/$repo/compare/$BASE_SHA...$sha") || keep "the changes of $sha are unreadable" | |
| changed=$(jq -er '[.files[].filename] as $f | |
| | if ($f | length) >= 300 then "too many files" | |
| elif any($f[]; . == ".github/workflows/pr-review-hosted.yml" or . == ".github/workflows/pr-review-security-hosted.yml") then "a review-lane workflow" | |
| else "" end' <<<"$diff") || keep "the changes of $sha do not parse" | |
| [ -z "$changed" ] || keep "lane run $id ran on $sha, which changes $changed" | |
| vetted+=" $sha" | |
| fi | |
| jobs=$(read_api "repos/$repo/actions/runs/$id/jobs?filter=all&per_page=100" --paginate) || keep "the jobs of run $id are unreadable" | |
| for job in $(jq -nr --arg check "$check" '[inputs.jobs[] | select(.name == $check | |
| and .completed_at != null) | "\(.id)/\(.completed_at)"] | unique[]' <<<"$jobs"); do | |
| notes=$(read_api "repos/$repo/check-runs/${job%%/*}/annotations?per_page=100" --paginate) || keep "the annotations of job ${job%%/*} are unreadable" | |
| notices=$(jq -nc --argjson notices "$notices" --arg at "${job#*/}" --arg run "$sha" '$notices + [inputs[] | select(.annotation_level == "notice") | |
| | .message | select(startswith("claude-lane-hold ")) | |
| | (capture("^claude-lane-hold lane=[a-z-]+ sha=(?<sha>[0-9a-f]{40}) criteria=(?<c>[a-z,-]+) label=") // {sha: "", c: "unparsed"}) as $m | |
| | {at: ($at | fromdateiso8601), criteria: $m.c, sha: $m.sha, run: $run}]' \ | |
| <<<"$notes") || keep "the annotations of job ${job%%/*} do not parse" | |
| done | |
| done < <(jq -nr --arg repo "$repo" --argjson since "$since" --argjson decided "$decided_at" '[inputs.workflow_runs[] | |
| | select(.head_repository.full_name == $repo) | |
| | select((.updated_at | fromdateiso8601) > $since or (.created_at | fromdateiso8601) < $decided) | "\(.id) \(.head_sha)"] | unique[]' <<<"$runs") | |
| done <<<'pr-review-hosted.yml pr-review / claude-review-status | |
| pr-review-security-hosted.yml pr-review-security / security-review' | |
| # The decided head: the sha of the newest cost notice before the | |
| # decision that names its own run's head; none when two heads share | |
| # that time. | |
| decided=$(jq -r --argjson at "$decided_at" '[.[] | select(.at < $at and .sha == .run and any(.criteria | split(",")[]; . == "cost"))] | |
| | (map(.at) | max) as $newest | [.[] | select(.at == $newest) | .sha] | unique | |
| | if length == 1 then .[0] else "" end' <<<"$notices") || keep "the hold notices do not parse" | |
| # The decided head stands only while the head branch's push history | |
| # shows a run of every lane on each head pushed since it before the | |
| # decision (the base's unranHead; one page, as merge-holds.mjs reads it). | |
| if [ -n "$decided" ]; then | |
| read_api "repos/$repo/activity?ref=refs%2Fheads%2F$ref&direction=desc&per_page=100" >"$RUNNER_TEMP/cost-hold-activity.json" || | |
| keep "the head branch's push history is unreadable" | |
| printf '%s\n' "$ran" >"$RUNNER_TEMP/cost-hold-ran.json" | |
| jq -n --arg sha "$decided" '$sha' >"$RUNNER_TEMP/cost-hold-decided.json" | |
| jq '.comment.created_at' <<<"$chosen" >"$RUNNER_TEMP/cost-hold-decided-at.json" || keep "the decisions do not parse" | |
| gap=$(holds_js unranHead "$RUNNER_TEMP/cost-hold-activity.json" "$RUNNER_TEMP/cost-hold-ran.json" \ | |
| "$RUNNER_TEMP/cost-hold-decided.json" "$RUNNER_TEMP/cost-hold-decided-at.json") || keep "the head branch's push history could not be checked" | |
| [ -z "$gap" ] || keep "$gap" | |
| fi | |
| carries "$decided" || keep "the body cites no recorded owner cost decision covering this head" | |
| decision=$(jq -r .link <<<"$chosen") decision_id=$(jq -r .id <<<"$chosen") | |
| holds=$(jq -c --argjson since "$since" '[.[] | select(.at > $since)]' <<<"$notices") || keep "the hold notices do not parse" | |
| verdict=$(jq -nr --arg label "$label" --argjson since "$since" --argjson holds "$holds" --argjson app "$LANES_APP_ID" \ | |
| --slurpfile events <(printf '%s\n' "$events") --slurpfile comments <(printf '%s\n' "$comments") \ | |
| --slurpfile deleted <(printf '%s\n' "$deleted") ' | |
| def t: fromdateiso8601; | |
| ([$events[][] | select(.label.name == $label and .event == "labeled" and (.created_at | t) > $since)]) as $added | |
| | ($deleted[0].data.repository.pullRequest.timelineItems) as $gone | |
| | if ($holds | length) == 0 then "no review lane recorded a hold since a trusted person last removed the label" | |
| elif any($holds[]; .criteria != "cost") then "a review-lane hold since then is not cost-only" | |
| elif any($added[]; (.created_at | t) as $at | any($holds[]; .at >= $at and .at - $at <= 600) | not) then "a label was added with no held review after it" | |
| elif any($comments[][]; .user.id == $app and (.body // "" | contains("<!-- work-items:escalation")) | |
| and ([.created_at, .updated_at] | map(t) | max) > $since) then "the lanes App escalated since then" | |
| elif ($gone | type) != "object" or $gone.filteredCount > ($gone.nodes | length) | |
| or any($gone.nodes[]; (.createdAt | t) > $since) then "a comment was deleted since then" | |
| else "cost" end' 2>/dev/null) || keep "the events, holds or comments do not parse" | |
| [ "$verdict" = cost ] || keep "$verdict" | |
| { | |
| echo "cleared=true" | |
| echo "decision=$decision" | |
| } >>"$GITHUB_OUTPUT" | |
| echo "::notice::every review-lane hold on #$PR_NUMBER since a trusted person last removed '$label' is cost-only, and $decision records the owner's cost decision." | |
| labels=$(read_api "repos/$repo/issues/$PR_NUMBER/labels?per_page=100" --paginate --jq '.[].name') || { | |
| echo "::warning::could not read the labels of #$PR_NUMBER, so '$label' was not removed." | |
| exit 0 | |
| } | |
| grep -qxF -- "$label" <<<"$labels" || exit 0 | |
| marker="<!-- ci-status cost-hold-cleared decision=$decision_id -->" | |
| if ! jq -se --arg marker "$marker" 'any(.[][]; .user.login == "github-actions[bot]" and (.body | startswith($marker)))' \ | |
| <<<"$comments" >/dev/null; then | |
| gh api -X POST "repos/$repo/issues/$PR_NUMBER/comments" -f body="$marker | |
| ci-status removed \`$label\`: every review-lane hold since a trusted person last removed it is cost-only, and the owner recorded the cost decision in $decision. A person adding \`$label\` back holds it again." >/dev/null || | |
| echo "::warning::could not post the removal comment on #$PR_NUMBER." | |
| fi | |
| # shellcheck disable=SC2016 # $l is a jq variable | |
| gh api -X DELETE "repos/$repo/issues/$PR_NUMBER/labels/$(jq -rn --arg l "$label" '$l | @uri')" >/dev/null || | |
| echo "::warning::could not remove '$label' from #$PR_NUMBER; the step below holds it." | |
| # A PULL REQUEST CARRYING THE HUMAN-GATED LABEL IS HELD. A review lane or | |
| # an escalation puts the label on so a person decides; removing it is the | |
| # approval, and the `unlabeled` event re-runs this job. The label's name | |
| # is the base commit's .work-item-tracker.json role_labels.human-gated | |
| # (default `needs-human`), resolved as pr-merge.yml resolves it, and read | |
| # at the base so the pull request cannot rename its own hold. Labels are | |
| # read live, not from the event payload, which a later label event | |
| # outdates. The reason is the newest hold comment's text: a review | |
| # lane's `<!-- claude-lane-hold lane=` comment by claude[bot], or by | |
| # github-actions[bot] when its last non-blank line is the lane's | |
| # `claude-lane: <lane>` footer, or pr-merge's refusal, a | |
| # merge.agent-identity comment with `<!-- babysit-merge-risk -->` on its | |
| # first or second line, whose fenced reasons it names, so the error | |
| # names the hold that set the label. Without one the error names none. An unreadable tracker config or label list fails closed. | |
| # It outputs the label's name, and `held=false` once the live labels | |
| # read without it, for the hold-lift step below. Pinned | |
| # by scripts/ci-hold-human-gated.test.sh, which runs this body. | |
| - name: Fail a pull request carrying the human-gated label | |
| id: human_gated | |
| if: ${{ !cancelled() && github.event_name == 'pull_request' }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| BASE_CONFIG: ${{ steps.base_config.outcome == 'success' && format('{0}/base-config', github.workspace) || '' }} | |
| run: | | |
| . "$RUNNER_TEMP/gh-read.sh" | |
| if [[ ! $BASE_SHA =~ ^[0-9a-f]{40}$ ]]; then | |
| echo "::error::the base commit '$BASE_SHA' is not a commit SHA." | |
| exit 1 | |
| fi | |
| tracker= | |
| if [ ! -d "$BASE_CONFIG" ] || { [ -e "$BASE_CONFIG/.work-item-tracker.json" ] && ! tracker=$(cat "$BASE_CONFIG/.work-item-tracker.json"); }; then | |
| echo "::error::could not read .work-item-tracker.json at $BASE_SHA; re-run this run." | |
| exit 1 | |
| fi | |
| human_gated=needs-human | |
| if [ -n "$tracker" ]; then | |
| human_gated=$(jq -er '[objects | .config | objects | .role_labels | objects | |
| | .["human-gated"] | strings | gsub("^\\s+|\\s+$"; "")][0] // "" | |
| | if . == "" then "needs-human" else . end' <<<"$tracker") || { | |
| echo "::error::cannot read the human-gated role label from .work-item-tracker.json at $BASE_SHA" | |
| exit 1 | |
| } | |
| fi | |
| echo "label=$human_gated" >>"$GITHUB_OUTPUT" | |
| for attempt in 1 2 3; do | |
| if labels=$(gh_read "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels?per_page=100" --paginate --jq '.[].name'); then | |
| break | |
| fi | |
| [ "$attempt" -lt 3 ] || { echo "::error::could not read the labels of #$PR_NUMBER; re-run this run."; exit 1; } | |
| sleep $((attempt * 5)) | |
| done | |
| if ! grep -qxF -- "$human_gated" <<<"$labels"; then | |
| echo "held=false" >>"$GITHUB_OUTPUT" | |
| echo "#$PR_NUMBER carries no '$human_gated' label." | |
| exit 0 | |
| fi | |
| # The lanes App's login, best effort: unread, only the review lanes' | |
| # hold comments name a reason. | |
| identity=$(yq -er '.merge."agent-identity"' "$BASE_CONFIG/docs/conventions/pr-pipeline.yaml" 2>/dev/null) || identity= | |
| [[ $identity =~ ^[a-z0-9][a-z0-9-]*\[bot\]$ ]] || identity= | |
| # gh-read-exempt: the reason is best effort; the step fails either way. | |
| reason=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments?per_page=100" --paginate \ | |
| --jq '.[] | select(.user.type == "Bot" and (((.body | test("(^|\n)<!-- claude-lane-hold lane=")) | |
| and (.user.login == "claude[bot]" or (.user.login == "github-actions[bot]" | |
| and ([.body | splits("\r?\n") | gsub("^\\s+|\\s+$"; "") | select(. != "")] | last // "" | |
| | test("^\u0060?claude-lane: (claude-review|claude-security-review)\u0060?$"))))) | |
| or (.user.login == "'"$identity"'" | |
| and ([.body | splits("\r?\n")][0:2] | map(gsub("^\\s+|\\s+$"; "")) | index("<!-- babysit-merge-risk -->") != null | |
| and index("<!-- work-items:escalation lane=pr-merge kind=escalated -->") != null)))) | |
| | .body | @json' | tail -n 1 | | |
| jq -r '[splits("\r?\n") | gsub("^\\s+|\\s+$"; "")] as $lines | ($lines | index("\u0060\u0060\u0060text")) as $open | |
| | if $open != null and ($lines[0:2] | index("<!-- babysit-merge-risk -->")) != null | |
| then $lines[$open + 1:] | .[0:(index("\u0060\u0060\u0060") // length)] else $lines end | |
| | [.[] | select(. != "" and (test("^<!--|^\u0060?claude-lane: ") | not))] | join(" ") | .[0:300]') || reason= | |
| why= | |
| [ -z "$reason" ] || why=" (${reason//%/%25})" | |
| echo "::error::#$PR_NUMBER carries '$human_gated'$why. Removing the label approves this pull request; ci-status re-runs on the unlabel." | |
| exit 1 | |
| # ONLY A TRUSTED PERSON LIFTS A REVIEW-LANE HOLD. Removing the human-gated | |
| # label approves a pull request, so once a review lane has held it (a | |
| # hold comment as the step above reads them), the first removal of the | |
| # label after the newest hold comment must be by a `human` of the base | |
| # commit's .github/standards/trusted-actors/trusted-actors.json. A later | |
| # removal is of a later label, such as an escalation clear-escalations | |
| # lifts. A bot or another account lifting the hold, or no recorded | |
| # removal, fails; adding the label back and having a trusted person | |
| # remove it approves: a trusted person's `labeled` event after the hold | |
| # starts a new hold that only that list's removal lifts. | |
| # It runs only when the step above read the live labels and found the | |
| # label off. A hold is the newest hold comment or the newest `labeled` | |
| # event of the label by github-actions[bot] or claude[bot], whichever is | |
| # later: an event cannot be deleted or edited, so removing the comment | |
| # does not remove the hold, and a review re-run that labels the same head | |
| # again starts a new hold. Unreadable comments, events or actor list fail | |
| # closed. It skips when the cost_hold step cleared a cost-only hold, whose | |
| # label that step (or anyone) may have removed. | |
| # Pinned by scripts/ci-hold-lifted-by.test.sh, which runs this body. | |
| - name: Fail a review-lane hold an untrusted account lifted | |
| if: ${{ !cancelled() && github.event_name == 'pull_request' && steps.human_gated.outputs.held == 'false' && steps.cost_hold.outputs.cleared != 'true' }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| HUMAN_GATED: ${{ steps.human_gated.outputs.label }} | |
| BASE_CONFIG: ${{ steps.base_config.outcome == 'success' && format('{0}/base-config', github.workspace) || '' }} | |
| run: | | |
| . "$RUNNER_TEMP/gh-read.sh" | |
| if [[ ! $BASE_SHA =~ ^[0-9a-f]{40}$ ]]; then | |
| echo "::error::the base commit '$BASE_SHA' is not a commit SHA." | |
| exit 1 | |
| fi | |
| # shellcheck disable=SC2016 # the $ in the --jq filter is a regex anchor | |
| for attempt in 1 2 3; do | |
| if held_at=$(gh_read "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments?per_page=100" --paginate \ | |
| --jq '.[] | select(.user.type == "Bot" and (.body | test("(^|\n)<!-- claude-lane-hold lane=")) | |
| and (.user.login == "claude[bot]" or (.user.login == "github-actions[bot]" | |
| and ([.body | splits("\r?\n") | gsub("^\\s+|\\s+$"; "") | select(. != "")] | last // "" | |
| | test("^`?claude-lane: (claude-review|claude-security-review)`?$"))))) | |
| | .created_at'); then | |
| break | |
| fi | |
| [ "$attempt" -lt 3 ] || { echo "::error::could not read the comments of #$PR_NUMBER; re-run this run."; exit 1; } | |
| sleep $((attempt * 5)) | |
| done | |
| for attempt in 1 2 3; do | |
| if events=$(gh_read "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/events?per_page=100" --paginate); then | |
| break | |
| fi | |
| [ "$attempt" -lt 3 ] || { echo "::error::could not read the events of #$PR_NUMBER; re-run this run."; exit 1; } | |
| sleep $((attempt * 5)) | |
| done | |
| labeled_at=$(jq -rs --arg label "$HUMAN_GATED" '[add[] | select(.event == "labeled" and .label.name == $label | |
| and (.actor.login == "github-actions[bot]" or .actor.login == "claude[bot]")) | .created_at] | max // empty' <<<"$events") || { | |
| echo "::error::cannot read the events of #$PR_NUMBER." | |
| exit 1 | |
| } | |
| held_at=$(printf '%s\n%s\n' "$held_at" "$labeled_at" | sort | tail -n 1) | |
| if [ -z "$held_at" ]; then | |
| echo "No review lane has held #$PR_NUMBER." | |
| exit 0 | |
| fi | |
| if [ ! -d "$BASE_CONFIG" ] || ! actors=$(cat "$BASE_CONFIG/.github/standards/trusted-actors/trusted-actors.json"); then | |
| echo "::error::could not read the trusted-actor list at $BASE_SHA; re-run this run." | |
| exit 1 | |
| fi | |
| humans=$(jq -er '[.actors[] | select(.kind == "human") | .id | numbers | tostring] | select(length > 0) | join(" ")' <<<"$actors") || { | |
| echo "::error::the trusted-actor list at $BASE_SHA names no person." | |
| exit 1 | |
| } | |
| # A trusted person adding the label back starts a new hold that person's | |
| # removal lifts, so a hold an untrusted account lifted can be recovered. | |
| lifted=$(jq -rs --arg label "$HUMAN_GATED" --arg held_at "$held_at" --arg humans " $humans " '[add[] | |
| | select(.label.name == $label)] as $events | |
| | ([$held_at, ($events[] | select(.event == "labeled" and .created_at > $held_at | |
| and (.actor.id as $id | $humans | contains(" \($id) "))) | .created_at)] | max) as $since | |
| | [$events[] | select(.event == "unlabeled" and .created_at > $since)] | |
| | min_by(.created_at) // empty | [.created_at, (.actor.id // 0 | tostring), (.actor.login // "an unknown account"), | |
| (.performed_via_github_app | if . == null then "" else .slug // .name // "an unnamed App" end)] | @tsv' <<<"$events") || { | |
| echo "::error::cannot read the events of #$PR_NUMBER." | |
| exit 1 | |
| } | |
| IFS=$'\t' read -r lifted_at actor_id actor_login via_app <<<"$lifted" | |
| if [ -z "$lifted_at" ]; then | |
| echo "::error::a review lane held #$PR_NUMBER at $held_at, and no removal of '$HUMAN_GATED' since then is recorded. Add the label back; a person on the trusted-actor list removing it approves this pull request." | |
| exit 1 | |
| fi | |
| if [[ " $humans " != *" $actor_id "* ]]; then | |
| echo "::error::$actor_login removed '$HUMAN_GATED' from #$PR_NUMBER after a review lane held it, and is not a person on the trusted-actor list. Add the label back; a person on that list removing it approves this pull request." | |
| exit 1 | |
| fi | |
| # An App acting for a listed person is not that person's approval. | |
| if [ -n "$via_app" ]; then | |
| echo "::error::$actor_login removed '$HUMAN_GATED' from #$PR_NUMBER through the App $via_app after a review lane held it, which is not a person's own approval. Add the label back; a person on the trusted-actor list removing it directly approves this pull request." | |
| exit 1 | |
| fi | |
| echo "$actor_login, a person on the trusted-actor list, lifted the review-lane hold on #$PR_NUMBER." | |
| # BOTH REVIEW LANES REVIEW THE HEAD. Neither review check is required by a | |
| # ruleset (github-iac ADR 0017); this step makes ci-status need them. For | |
| # each lane it takes the newest pull_request run of the lane's caller | |
| # workflow on the head, from this repository's head (a fork's run on the | |
| # same commit never counts), and that run's latest jobs, which are the GitHub | |
| # Actions check runs. The lane passes when its status job succeeded and | |
| # its `Apply the hold` step succeeded, so a summary-mode run or one | |
| # without a hold label never counts. A pending, failed or cancelled | |
| # review fails, as does a skipped one (a fork, or a bot outside the | |
| # caller's allowed-bot-sender-ids), with the cause named. A lane the | |
| # kill switch stopped (its kill-switch job succeeded and ran its | |
| # `Report the kill switch` step, so a switch was on when the run | |
| # started) fails too: the kill switch pauses review, it never waives it. | |
| # Once it is off, a trusted person re-runs the review run, whose | |
| # completion re-runs ci-status (pr-feedback-dispatch.yml rerun-ci-status); | |
| # the admin bypass stays the break-glass. A | |
| # kill-switch job that failed stopped the review too, and fails. | |
| # Dependabot, the release App and the | |
| # standards-sync App, by numeric author id, are exempt: no review lane | |
| # reviews them. An exempt author's PR still fails when a changed file | |
| # (or a renamed file's old path) is a trust-gate path, since no security | |
| # lane reads it; a person merges it. A draft is exempt | |
| # (see `Note a draft`). pr-feedback-dispatch.yml re-runs this job when a | |
| # review run completes. An unreadable run or job list fails closed. | |
| # The jobs API can list a finished job's steps empty or cut short, so a | |
| # step it lists with no conclusion, or not at all, proves nothing either | |
| # way. Then the job's own check-run annotations decide. The review | |
| # counts only with its publish step's `claude-lane-review-complete` | |
| # notice naming this lane and this head, which proves a comments-mode | |
| # publish for that lane and head. The notice itself does not prove the | |
| # hold step ran: that step runs alongside the publish in comments mode | |
| # because both callers set hold-label. | |
| # A kill-switch stop counts only with the notice its report step | |
| # writes. A pull_request run takes its workflow from the pull request, | |
| # so a head that rewrites a review caller can forge these notices as it | |
| # can forge the step list. An unreadable annotation list fails closed. | |
| # Pinned by scripts/ci-review-lanes.test.sh, which runs this body. | |
| - name: Require both review lanes on this head | |
| if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.draft == false }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| AUTHOR_ID: ${{ github.event.pull_request.user.id }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} | |
| run: | | |
| . "$RUNNER_TEMP/gh-read.sh" | |
| # dependabot[bot], melodic-plugin-release[bot] and | |
| # melodic-standards-sync[bot], as | |
| # .github/standards/trusted-actors/trusted-actors.json lists them. | |
| exempt_author_ids='49699333 337859524 300666570' | |
| lanes='pr-review-hosted.yml claude-review pr-review / claude-review-status | |
| pr-review-security-hosted.yml claude-security-review pr-review-security / security-review' | |
| if [[ ! $HEAD_SHA =~ ^[0-9a-f]{40}$ ]]; then | |
| echo "::error::the head '$HEAD_SHA' is not a commit SHA." | |
| exit 1 | |
| fi | |
| read_api() { | |
| local attempt body | |
| for attempt in 1 2 3; do | |
| if body=$(gh_read "$2"); then | |
| printf '%s\n' "$body" | |
| return 0 | |
| fi | |
| [ "$attempt" -lt 3 ] || { echo "::error::could not read $1; re-run this run." >&2; return 1; } | |
| sleep $((attempt * 5)) | |
| done | |
| } | |
| if [[ $AUTHOR_ID =~ ^[0-9]+$ && " $exempt_author_ids " == *" $AUTHOR_ID "* ]]; then | |
| # The trust-gate paths, as bash patterns (`*` crosses `/`). | |
| # BEGIN hold-floor hold_patterns: generated by scripts/sync-hold-floor.py | |
| hold_patterns=('.github/standards/trusted-actors/*' '.github/actions/check-trusted-trigger/*' 'CODEOWNERS' '*/CODEOWNERS' '.gitmodules' '*/.gitmodules' '.github/actions/resolve-config/*' '.github/actions/check-kill-switch/*' '.github/actions/request-lane-token/*' '.github/actions/log-api-budget/rate-guard.mjs' '.github/actions/log-api-budget/rate-limits.json' '.github/scripts/pr-merge/merge-holds.mjs' '.github/scripts/pr-merge/enable-auto-merge.mjs' '.github/scripts/pr-merge/superseded.mjs' '.github/scripts/pr-merge/clear-escalations.mjs' '.github/scripts/pr-merge/carry-run-cache.mjs' '.github/scripts/pr-merge/gate-check.mjs' '.github/scripts/pr-merge/head-ref-map.mjs' '.github/scripts/pr-merge/lane-records.mjs' '.github/scripts/pr-merge/last-report.mjs' '.github/scripts/pr-merge/rate-stop.bash' '.github/scripts/pr-merge/refire-refused.mjs' '.github/scripts/pr-merge/rotation.mjs' '.github/scripts/pr-merge/sweep-summary.mjs' '.github/scripts/pr-merge/who.mjs' '.github/scripts/pr-merge/post-unarmed-status.mjs' '.github/scripts/pr-merge/verify-answers.mjs' '.github/scripts/pr-merge/listing-carry.mjs' '.github/actions/report-check-run/posting-run.mjs' 'scripts/sync-hold-floor.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_risk.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_check_inputs.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_checks.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_classify.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_dependabot.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_feedback.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_gh.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_merge.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_repo_config.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_review_evidence.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_review_trigger.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_run_cache.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_state.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_util.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py' 'plugins/source-control/skills/babysit-prs/scripts/babysit_whole_review.py' '.github/workflows/pr-require-checks.yml' '.github/workflows/pr-merge.yml' 'docs/conventions/pr-pipeline.yaml' '.github/scripts/pr-unblock/address-feedback.mjs' '.github/scripts/pr-unblock/escalation-routes.mjs' '.github/scripts/pr-unblock/escalation-routes.json' '.github/scripts/pr-unblock/gate-source.mjs' '.github/scripts/pr-unblock/thread-scope.mjs' '.github/scripts/pr-unblock/retry-fetch.mjs' '.github/scripts/work-resolve-issue/report-outcome.mjs' '.github/scripts/work-resolve-issue/limits.mjs' '.github/scripts/work-resolve-issue/retry-fetch.mjs' '.github/scripts/pr-unblock/report-escalation.sh' '.github/scripts/pr-unblock/adjudicate-feedback.mjs' '.github/scripts/pr-unblock/publish-feedback.mjs' '.github/scripts/pr-unblock/request-budget.mjs' '.github/scripts/pr-unblock/dependency-classify.mjs' '.github/scripts/apply-patch/apply-patch.mjs' '.github/scripts/apply-patch/predicates.mjs' '.github/scripts/apply-patch/protected-paths.mjs' '.github/scripts/apply-patch/retry-fetch.mjs' '.github/scripts/pr-unblock/fix-ci-context.mjs' '.github/scripts/pr-unblock/feedback-dispatch.mjs' '.github/scripts/pr-unblock/verify-notes.mjs' '.github/scripts/pr-unblock/limit-stop.mjs' '.github/scripts/pr-unblock/dispatch-record.mjs' '.github/scripts/maintenance-reconcile-lost-runs/infra-failure.mjs' '.github/workflows/pr-unblock.yml' '.github/scripts/work-resolve-issue/stop-routes.json' '.github/scripts/work-resolve-issue/stop-routes.mjs') | |
| # END hold-floor hold_patterns | |
| if [[ ! $PR_NUMBER =~ ^[1-9][0-9]*$ ]]; then | |
| echo "::error::the pull request number '$PR_NUMBER' is not a number." | |
| exit 1 | |
| fi | |
| pr=$(read_api "#$PR_NUMBER" "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") || exit 1 | |
| # The broker step above leaves the file list it read; read it again | |
| # only when that step could not. | |
| if [ -s "$RUNNER_TEMP/pr-files.json" ]; then | |
| files=$(jq -r '.[] | [.filename, (.previous_filename // "")] | @tsv' "$RUNNER_TEMP/pr-files.json") | |
| else | |
| for attempt in 1 2 3; do | |
| if files=$(gh_read "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" --paginate --jq '.[] | [.filename, (.previous_filename // "")] | @tsv'); then | |
| break | |
| fi | |
| [ "$attempt" -lt 3 ] || { echo "::error::could not read the files of #$PR_NUMBER; re-run this run."; exit 1; } | |
| sleep $((attempt * 5)) | |
| done | |
| fi | |
| if [ "$(grep -c . <<<"$files")" -lt "$(jq '.changed_files // 0' <<<"$pr")" ]; then | |
| echo "::error::GitHub listed fewer files than #$PR_NUMBER changes, so its hold-paths cannot be checked; a person merges it." | |
| exit 1 | |
| fi | |
| held= | |
| # Case-folded, as the floor matches: a case-insensitive checkout | |
| # reads `.github/codeowners` as CODEOWNERS. | |
| shopt -s nocasematch | |
| while IFS= read -r path; do | |
| for pattern in "${hold_patterns[@]}"; do | |
| # shellcheck disable=SC2053 # the pattern is matched as a glob on purpose | |
| [[ $path != $pattern ]] || held="$held $path" | |
| done | |
| done < <(tr '\t' '\n' <<<"$files" | grep .) | |
| shopt -u nocasematch | |
| if [ -n "$held" ]; then | |
| echo "::error::#$PR_NUMBER is by an author no review lane reviews and changes trust-gate paths:${held//%/%25}. A person merges it." | |
| exit 1 | |
| fi | |
| echo "The author ($AUTHOR_ID) is one no review lane reviews, and no trust-gate path changed; the review lanes are not required." | |
| exit 0 | |
| fi | |
| # A fork's head gets no review run here, and the lookup below reads | |
| # only runs from this repository's head, so name the fork up front. | |
| if [ "$HEAD_REPO" != "$GITHUB_REPOSITORY" ]; then | |
| while read -r _ _ check; do | |
| echo "::error::'$check' did not run on $HEAD_SHA (head repository: ${HEAD_REPO:-unknown}): a fork, or a bot outside the caller's allowed-bot-sender-ids, gets no review, and a kill-switch job that did not succeed stops it. A push by a person on the trusted-actor list starts a review." | |
| done <<<"$lanes" | |
| exit 1 | |
| fi | |
| failed=0 | |
| while read -r file lane check; do | |
| runs=$(read_api "the $file runs on $HEAD_SHA" \ | |
| "repos/$GITHUB_REPOSITORY/actions/workflows/$file/runs?head_sha=$HEAD_SHA&event=pull_request&per_page=100") || exit 1 | |
| run=$(jq -c --arg path ".github/workflows/$file" --arg sha "$HEAD_SHA" --arg repo "$GITHUB_REPOSITORY" \ | |
| '[.workflow_runs[] | select(.path == $path and .head_sha == $sha and .event == "pull_request" | |
| and .head_repository.full_name == $repo)] | max_by(.id) // empty' <<<"$runs") | |
| if [ -z "$run" ]; then | |
| echo "::error::'$check' is missing: no $file run on $HEAD_SHA yet. ci-status re-runs when the review finishes." | |
| failed=1 | |
| continue | |
| fi | |
| id=$(jq -r .id <<<"$run") | |
| url=$(jq -r .html_url <<<"$run") | |
| jobs=$(read_api "the jobs of run $id" "repos/$GITHUB_REPOSITORY/actions/runs/$id/jobs?filter=latest&per_page=100") || exit 1 | |
| verdict=$(jq -r --arg check "$check" --arg run_status "$(jq -r .status <<<"$run")" ' | |
| (.jobs | map(select(.name == $check)) | max_by(.id)) as $s | |
| | (.jobs | map(select(.name == "kill-switch")) | max_by(.id)) as $k | |
| | if $s != null and $s.status != "completed" then "pending" | |
| elif $s.conclusion == "success" then | |
| if any($s.steps[]?; .name == "Apply the hold" and .conclusion != null) | not then "unlisted \($s.id)" | |
| elif any($s.steps[]?; .name == "Apply the hold" and .conclusion == "success") then "pass" else "uncounted" end | |
| elif $s != null and $s.conclusion != "skipped" then "red \($s.conclusion)" | |
| elif $k.conclusion == "success" and any($k.steps[]?; .name == "Report the kill switch" and .conclusion == "success") then "stopped" | |
| elif $run_status != "completed" then "pending" | |
| elif $k.conclusion == "success" and (any($k.steps[]?; .name == "Report the kill switch" and .conclusion != null) | not) then "killunlisted \($k.id)" | |
| else "missing \($k.conclusion // "absent")" end' <<<"$jobs") | |
| if [[ $verdict =~ ^killunlisted\ ([1-9][0-9]*)$ ]]; then | |
| job=${BASH_REMATCH[1]} | |
| notes=$(read_api "the annotations of job $job" "repos/$GITHUB_REPOSITORY/check-runs/$job/annotations?per_page=100") || exit 1 | |
| verdict=$(jq -r 'if any(.[]?; .annotation_level == "notice" and ((.message // "") | test("^a kill switch stops this (security )?review \\("))) | |
| then "stopped" else "missing success" end' <<<"$notes") | |
| fi | |
| if [[ $verdict =~ ^unlisted\ ([1-9][0-9]*)$ ]]; then | |
| job=${BASH_REMATCH[1]} | |
| notes=$(read_api "the annotations of job $job" "repos/$GITHUB_REPOSITORY/check-runs/$job/annotations?per_page=100") || exit 1 | |
| verdict=$(jq -r --arg want "claude-lane-review-complete lane=$lane sha=$HEAD_SHA" ' | |
| if any(.[]?; .annotation_level == "notice" and .title == "claude-lane-review-complete" | |
| and ((.message // "") as $m | $m == $want or ($m | startswith($want + " ")))) | |
| then "pass-notice" else "unproven" end' <<<"$notes") | |
| fi | |
| case $verdict in | |
| pass) echo "'$check' passed on $HEAD_SHA ($url)." ;; | |
| pass-notice) echo "'$check' passed on $HEAD_SHA ($url): GitHub listed no 'Apply the hold' step with a conclusion, and the job's review-complete notice names $lane and this head." ;; | |
| unproven) | |
| echo "::error::'$check' passed in $url, but GitHub listed no 'Apply the hold' step with a conclusion and the job records no review-complete notice for $lane on $HEAD_SHA, so it is not a review that counts. Re-run this run." | |
| failed=1 | |
| ;; | |
| stopped) | |
| echo "::error::'$check' did not run on $HEAD_SHA ($url): a kill switch was on when it started. Once it is off, re-run all jobs of $url; ci-status re-runs when it finishes." | |
| failed=1 | |
| ;; | |
| pending) | |
| echo "::error::'$check' has not finished on $HEAD_SHA ($url). ci-status re-runs when the review finishes." | |
| failed=1 | |
| ;; | |
| uncounted) | |
| echo "::error::'$check' passed in $url, but its 'Apply the hold' step did not run: the lane ran in summary mode or without a hold label, so it is not a review that counts." | |
| failed=1 | |
| ;; | |
| red\ *) | |
| echo "::error::'$check' ended ${verdict#red } on $HEAD_SHA ($url): the review did not complete. Re-run the lane." | |
| failed=1 | |
| ;; | |
| *) | |
| echo "::error::'$check' did not run on $HEAD_SHA ($url; kill-switch job: ${verdict#missing }): a fork, or a bot outside the caller's allowed-bot-sender-ids, gets no review, and a kill-switch job that did not succeed stops it. A push by a person on the trusted-actor list starts a review." | |
| failed=1 | |
| ;; | |
| esac | |
| done <<<"$lanes" | |
| exit "$failed" | |
| # A QUEUED PULL REQUEST IS HELD BY ITS LABELS TOO. A merge-group payload | |
| # carries no pull request, so `check-contract` above reports `skipped`, | |
| # and a `do-not-merge` or human-gated label applied after enqueue would | |
| # merge. This step reads the live labels of the pull request the group was | |
| # built for, named in the merge-group ref (`gh-readonly-queue/<base>/pr-<N>-<sha>`), | |
| # and fails if it carries either, or `pr-verify-failed`; the queue then | |
| # removes that pull request. verify-notes.mjs puts `pr-verify-failed` on a | |
| # pull request a person opened when pr-verify fails its head, and removes | |
| # it on a pass. It is read here only: a red ci-status on a pull_request | |
| # event stops pr-verify from judging the next head (pr-verify.yml), so | |
| # the pass that removes the label would never come. The human-gated label is resolved at the group's base commit, | |
| # as the step above resolves it. It reads only that one pull request: under the ruleset's ALLGREEN grouping a | |
| # pull request merges only when its own group passes, and a failed group | |
| # removes the pull request it was built for, never one ahead of it | |
| # (HEADGREEN would need every entry read), so failing on an earlier | |
| # entry's label would eject an unlabeled pull request while the labeled | |
| # one still merged. A label that lands after this step passed does not stop the merge; | |
| # dequeue the pull request as well. An unparsable ref, an unreadable | |
| # tracker config or an unreadable label list fails closed. For a pull | |
| # request an automation opened it then applies the trust hold to its | |
| # linked issue (comment in the body). report-outcome's Hold: line covers | |
| # the label at PR open and enable-auto-merge at arm time, so this holds a | |
| # label added after either: one GraphQL read, a labels read for a Refs | |
| # issue, and one timeline read per linked issue; an unreadable one, or | |
| # the base's trusted-actor list unreadable, fails closed. Pinned by | |
| # scripts/ci-hold-merge-group.test.sh, which runs this body. | |
| - name: Fail a queued pull request carrying a hold label | |
| if: github.event_name == 'merge_group' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| BASE_SHA: ${{ github.event.merge_group.base_sha }} | |
| BASE_CONFIG: ${{ steps.base_config.outcome == 'success' && format('{0}/base-config', github.workspace) || '' }} | |
| run: | | |
| . "$RUNNER_TEMP/gh-read.sh" | |
| pr=$(sed -nE 's#^(refs/heads/)?gh-readonly-queue/.+/pr-([0-9]+)-[0-9a-f]{40}$#\2#p' <<<"$GITHUB_REF") | |
| if [ -z "$pr" ]; then | |
| echo "::error::cannot read a pull request number from the merge-group ref '$GITHUB_REF'." | |
| exit 1 | |
| fi | |
| if [[ ! $BASE_SHA =~ ^[0-9a-f]{40}$ ]]; then | |
| echo "::error::the base commit '$BASE_SHA' is not a commit SHA." | |
| exit 1 | |
| fi | |
| tracker= | |
| if [ ! -d "$BASE_CONFIG" ] || { [ -e "$BASE_CONFIG/.work-item-tracker.json" ] && ! tracker=$(cat "$BASE_CONFIG/.work-item-tracker.json"); }; then | |
| echo "::error::could not read .work-item-tracker.json at $BASE_SHA; re-run this run." | |
| exit 1 | |
| fi | |
| human_gated=needs-human | |
| if [ -n "$tracker" ]; then | |
| human_gated=$(jq -er '[objects | .config | objects | .role_labels | objects | |
| | .["human-gated"] | strings | gsub("^\\s+|\\s+$"; "")][0] // "" | |
| | if . == "" then "needs-human" else . end' <<<"$tracker") || { | |
| echo "::error::cannot read the human-gated role label from .work-item-tracker.json at $BASE_SHA" | |
| exit 1 | |
| } | |
| fi | |
| for attempt in 1 2 3; do | |
| if labels=$(gh_read "repos/$GITHUB_REPOSITORY/issues/$pr/labels?per_page=100" --paginate --jq '.[].name'); then | |
| break | |
| fi | |
| [ "$attempt" -lt 3 ] || { echo "::error::could not read the labels of #$pr; re-run this run."; exit 1; } | |
| sleep $((attempt * 5)) | |
| done | |
| held=0 | |
| if grep -qxF do-not-merge <<<"$labels"; then | |
| held=1 | |
| echo "::error::#$pr carries the 'do-not-merge' label; the merge queue removes it. Remove the label and re-queue to merge." | |
| fi | |
| if grep -qxF -- "$human_gated" <<<"$labels"; then | |
| held=1 | |
| echo "::error::#$pr carries '$human_gated'; the merge queue removes it. Removing the label approves this pull request; re-queue it to merge." | |
| fi | |
| if grep -qxF pr-verify-failed <<<"$labels"; then | |
| held=1 | |
| echo "::error::#$pr carries 'pr-verify-failed': pr-verify failed its head, and a pass at a new commit removes the label. The merge queue removes it; re-queue it once the label is off." | |
| fi | |
| [ "$held" -eq 0 ] || exit 1 | |
| echo "#$pr carries neither 'do-not-merge' nor '$human_gated'." | |
| # The trust hold, for a pull request an automation opened: its linked | |
| # issue (babysit_merge_policy._linked_issue: the closing issues here, | |
| # or with none, the one issue its plain `Refs:` lines name, by the | |
| # base's merge-holds.mjs refsIssue) carries no label naming untrusted | |
| # work, and none was ever removed other than by a person on the base's | |
| # trusted-actor list, not through an App (_untrusted, | |
| # _untrusted_removed_by_non_human). The lanes App's pull request | |
| # must link exactly one issue here, as _linked_issue refuses | |
| # otherwise, so a link dropped after arming fails; another | |
| # automation's (Dependabot, standards-sync, a plugin release) that | |
| # links none passes. A jq error fails closed. | |
| # read3 <gh api args>...: gh_read up to three times, 5 and 10 s apart. | |
| read3() { | |
| local attempt | |
| for attempt in 1 2 3; do | |
| gh_read "$@" && return 0 | |
| [ "$attempt" -lt 3 ] || return 1 | |
| sleep $((attempt * 5)) | |
| done | |
| } | |
| # shellcheck disable=SC2016 # GraphQL variables, not shell | |
| link=$(read3 graphql -f query='query($o:String!,$r:String!,$n:Int!){repository(owner:$o,name:$r){pullRequest(number:$n){author{__typename ... on Bot{databaseId}} body closingIssuesReferences(first:10){totalCount nodes{number repository{nameWithOwner} labels(first:100){totalCount nodes{name}}}}}}}' \ | |
| -F o="${GITHUB_REPOSITORY%%/*}" -F r="${GITHUB_REPOSITORY#*/}" -F n="$pr") || { | |
| echo "::error::could not read the issue link of #$pr; re-run this run." | |
| exit 1 | |
| } | |
| author=$(jq -er '.data.repository.pullRequest.author.__typename | strings' <<<"$link") || { | |
| echo "::error::the author of #$pr could not be read; re-run this run." | |
| exit 1 | |
| } | |
| if [ "$author" = User ]; then | |
| echo "#$pr is a person's pull request, which the trust hold does not read." | |
| exit 0 | |
| fi | |
| # The lanes App's bot account, as the lanes App step below matches it. | |
| # A bot with no numeric id could be it, so that fails closed. | |
| lanes=$(jq -r --argjson id "$LANES_APP_ID" '.data.repository.pullRequest.author | |
| | if .__typename != "Bot" then false | |
| elif (.databaseId | type) == "number" then .databaseId == $id | |
| else error("no numeric databaseId") end' <<<"$link") || { | |
| echo "::error::the bot author id of #$pr could not be read; re-run this run." | |
| exit 1 | |
| } | |
| closing=$(jq -ec '.data.repository.pullRequest.closingIssuesReferences | objects | select(.totalCount == (.nodes | length))' <<<"$link") || { | |
| echo "::error::the closing issues of #$pr could not all be read; a person merges it." | |
| exit 1 | |
| } | |
| # One "<number> <label names as JSON, or null when cut>" line per issue. | |
| issues=$(jq -r --arg repo "$GITHUB_REPOSITORY" '.nodes[] | |
| | select((.repository.nameWithOwner | ascii_downcase) == ($repo | ascii_downcase)) | |
| | "\(.number) \(if .labels.totalCount == (.labels.nodes | length) then [.labels.nodes[].name] | tojson else "null" end)"' <<<"$closing") | |
| total=$(jq -r .totalCount <<<"$closing") | |
| if [ "$total" = 0 ]; then | |
| jq -r '.data.repository.pullRequest.body // ""' <<<"$link" >"$RUNNER_TEMP/queued-body.txt" | |
| # shellcheck disable=SC2016 # JavaScript, not shell | |
| refs=$(node --input-type=module -e 'import { readFileSync } from "node:fs"; | |
| import { pathToFileURL } from "node:url"; | |
| const { refsIssue } = await import(pathToFileURL(process.argv[2]).href); | |
| console.log(refsIssue(readFileSync(process.argv[1], "utf8")) ?? "");' \ | |
| "$RUNNER_TEMP/queued-body.txt" "$BASE_CONFIG/.github/scripts/pr-merge/merge-holds.mjs") || { | |
| echo "::error::the Refs line of #$pr could not be read with merge-holds.mjs at $BASE_SHA; re-run this run." | |
| exit 1 | |
| } | |
| if [ -n "$refs" ]; then | |
| names=$(read3 "repos/$GITHUB_REPOSITORY/issues/$refs/labels?per_page=100" --paginate --jq '.[].name') || { | |
| echo "::error::could not read the labels of issue #$refs, which #$pr names; re-run this run." | |
| exit 1 | |
| } | |
| issues="$refs $(jq -Rsc 'split("\n") | map(select(length > 0))' <<<"$names")" | |
| fi | |
| fi | |
| if [ "$lanes" = true ] && { [ "$total" -gt 1 ] || [ -z "$issues" ]; }; then | |
| why="its Refs: lines name no single issue" | |
| [ "$total" -eq 0 ] || why="it closes $total issue(s), none or more than one here" | |
| echo "::error::#$pr is the lanes App's and does not link exactly one issue in this repository, as pr-merge's lane takes it: $why; a person merges it." | |
| exit 1 | |
| fi | |
| if [ -z "$issues" ]; then | |
| echo "#$pr links no issue in this repository for the trust hold to read." | |
| exit 0 | |
| fi | |
| humans=$(jq -ec '[.actors[] | select(.kind == "human" and (.id | type) == "number") | .id]' \ | |
| "$BASE_CONFIG/.github/standards/trusted-actors/trusted-actors.json") || { | |
| echo "::error::could not read trusted-actors.json at $BASE_SHA; re-run this run." | |
| exit 1 | |
| } | |
| while read -r number names; do | |
| if [ "$names" = null ]; then | |
| held=1 | |
| echo "::error::the labels of issue #$number, which #$pr links, could not all be read (issue-labels-unreadable); a person merges it." | |
| continue | |
| fi | |
| rc=0 | |
| jq -e 'any(.[]; ascii_downcase | contains("untrusted"))' <<<"$names" >/dev/null || rc=$? | |
| if [ "$rc" -gt 1 ]; then | |
| held=1 | |
| echo "::error::the labels of issue #$number, which #$pr links, could not be matched (jq exit $rc); a person merges it." | |
| continue | |
| fi | |
| if [ "$rc" -eq 0 ]; then | |
| held=1 | |
| echo "::error::issue #$number, which #$pr links, carries a label naming untrusted work (untrusted-provenance); the merge queue removes #$pr. A person on the trusted-actor list removing the label lifts it; re-queue to merge." | |
| continue | |
| fi | |
| events=$(read3 "repos/$GITHUB_REPOSITORY/issues/$number/timeline?per_page=100" --paginate --jq '.[] | select(.event == "unlabeled") | @json') || { | |
| echo "::error::could not read the timeline of issue #$number, which #$pr links; re-run this run." | |
| exit 1 | |
| } | |
| rc=0 | |
| jq -se --argjson humans "$humans" 'any(.[]; (.label.name // "" | ascii_downcase | contains("untrusted")) | |
| and (.actor.type != "User" or .performed_via_github_app != null or (.actor.id as $id | any($humans[]; . == $id) | not)))' <<<"$events" >/dev/null || rc=$? | |
| if [ "$rc" -gt 1 ]; then | |
| held=1 | |
| echo "::error::the label removals on issue #$number, which #$pr links, could not be matched (jq exit $rc); a person merges it." | |
| elif [ "$rc" -eq 0 ]; then | |
| held=1 | |
| echo "::error::a label naming untrusted work was removed from issue #$number, which #$pr links, by someone not on the trusted-actor list or through an App (untrusted-provenance-removed-by-non-human). No label change on that issue lifts it; a new issue a person files, and a new pull request linking it, does." | |
| fi | |
| done <<<"$issues" | |
| [ "$held" -eq 0 ] || exit 1 | |
| echo "#$pr: its linked issue carries no label naming untrusted work, and no such label was removed by a non-human." | |
| # THE MERGE QUEUE'S VERDICT READER STARTS FROM PR-MERGE'S CARRIED READS. | |
| # The reads that never change (FAIL run records, FAIL head refs, run | |
| # heads on main) that pr-merge's last scheduled or hand-started run kept, | |
| # so the lanes-hold step's reader reads only what landed since: about 73 | |
| # requests for a lanes App pull request where a cold reader makes about | |
| # 860 (one run read and compare per FAIL and a download per untied | |
| # FAIL), measured 2026-10-25 on 6,620 artifacts with 294 FAILs, and at | |
| # most the cold-read headroom check's cold_cost under the listing's | |
| # caps (that check states the derivation). They | |
| # come from that run's pr-merge-run-cache artifact, found as pr-merge.yml | |
| # finds it (carry-run-cache.mjs `read`): only that run's steps could | |
| # upload it. This job holds write scopes and is the required check, so it | |
| # restores no Actions cache, which any job in main's cache scope can | |
| # write and whose restore extracts its archive anywhere in the workspace | |
| # (#9455). Every kept read still lands in a carried-* section the reader | |
| # uses only where it counts a FAIL, never to turn one away or allow a | |
| # pass. Any error leaves the cache empty, and the step holds a cold | |
| # reader. Rules: .github/scripts/pr-merge/carry-run-cache.mjs. | |
| - name: Read the verdict reads pr-merge's last scheduled run kept | |
| id: verdict_reads_restore | |
| if: ${{ !cancelled() && github.event_name == 'merge_group' }} | |
| continue-on-error: true | |
| timeout-minutes: 2 | |
| env: | |
| ACTIONS_TOKEN: ${{ github.token }} | |
| API_BUDGET_TOKEN: github-token | |
| CARRY_TO: ${{ runner.temp }}/pr-merge-carried.json | |
| PR_MERGE_READS_PATH: ${{ runner.temp }}/pr-merge-reads.json | |
| run: node .base/.github/scripts/pr-merge/carry-run-cache.mjs read | |
| - name: Start the run cache from them | |
| id: verdict_reads_carry | |
| if: ${{ !cancelled() && github.event_name == 'merge_group' }} | |
| continue-on-error: true | |
| env: | |
| CARRY_FROM: ${{ runner.temp }}/pr-merge-carried.json | |
| CARRY_TO: ${{ runner.temp }}/babysit-run-cache.json | |
| run: node .base/.github/scripts/pr-merge/carry-run-cache.mjs | |
| # A LANES APP PULL REQUEST IS HELD BY ITS TEXT TOO. pr-merge.yml's | |
| # enable-auto-merge job arms native auto-merge on lanes App pull | |
| # requests, which then merge without pr-merge's gate, so this step fails | |
| # on the holds that gate honors: a body hold, a member's veto comment, | |
| # and a changed file or renamed-from path matching the merge lane's | |
| # denied paths (pr-pipeline.yaml merge.diff-check.denied-paths, as main | |
| # has it, plus merge-holds.mjs's HOLD_PATH_FLOOR), so a push after | |
| # arming is held on its own pull request event; a person on the | |
| # trusted-actor list (as main has it) lifts it by approving the head, and | |
| # the approval carries to a later head whose every change since is a | |
| # merge of the base changing no hold path or other file beyond it | |
| # (merge-holds.mjs liftersOf, which reads with this step's GH_TOKEN), | |
| # then re-runs ci-status. A trusted-actor list | |
| # change that adds or widens an entry (merge-holds.mjs actorsHold, both | |
| # sides read at BASE_SHA and the head) holds too, lifted the same way. | |
| # A changed cost path (merge-holds.mjs COST_PATHS) also holds, | |
| # and only the owner's cost decision linked from the body lifts it; the | |
| # module reads that decision with this step's GH_TOKEN, only when the | |
| # body links one. All by the rules in | |
| # .github/scripts/pr-merge/merge-holds.mjs, | |
| # which this step runs as main has it so a pull request cannot rewrite | |
| # its own hold: in the queue at the group's base commit, and on a pull | |
| # request event at the live base commit of a pull request into main. A | |
| # pull request into any other branch is never armed and is not read, so | |
| # it cannot choose a branch whose module runs here with this job's token. On a pull request event and in the merge queue | |
| # (the pull request named in the group's ref, as the step above reads | |
| # it), the body, comments, reviews and inline comments are read live, | |
| # never from the payload a re-run replays, and so are the changed files; | |
| # a list shorter than changed_files fails closed. A body edit re-runs this job; | |
| # a comment does not, so a veto posted while armed is enforced when the | |
| # queue builds the group, and after a veto is lifted ci-status needs a | |
| # re-run. Only the lanes App's pull requests are read, by numeric id | |
| # (pr-pipeline.yaml merge.agent-identity, as trusted-actors.json lists | |
| # it); every other pull request merges through pr-merge's gate or a | |
| # person. An unreadable pull request, module, config, file list or | |
| # discussion fails closed. | |
| # WHILE THE LANES KILL SWITCH IS ON A LANES APP PULL REQUEST FAILS, so | |
| # one armed before it was turned on stops in the merge queue, as the | |
| # gate stops: CLAUDE_LANES_DISABLED and CLAUDE_LANES_REPO_DISABLED from | |
| # the vars context, no API read, and only the exact value `false` on | |
| # both proceeds. Every other author's pull request is not affected. | |
| # Once both read `false`, maintenance-reconcile-lost-runs.yml re-runs | |
| # ci-status on each open lanes App pull request this failed, at most 3 | |
| # per half-hourly run, when ci-status was the run's only failed job on | |
| # attempt 1 (its `switch` kind keys on the error's "the lanes kill | |
| # switch is on" text, which reconcile.test.mjs pins here). | |
| # IN THE MERGE QUEUE A LANES APP PULL REQUEST ALSO NEEDS A VERIFIER PASS | |
| # AT ITS HEAD: a PASS or PASS-NOTES artifact of a completed, successful | |
| # pr-verify.yml run on main (merge-holds.mjs verdictHold), taken by | |
| # exact name from the reader's listing (below), with and without the | |
| # head-branch digest, stopping at the first name that holds such a run: | |
| # one run read per artifact taken. The same approval of the head lifts | |
| # it. Only in the queue: pr-verify runs only after a pull_request run of | |
| # this workflow that succeeded or failed only on holds an approving | |
| # review of the head lifts, so a ci-status red for anything else there | |
| # would never get a verdict, and a pull request event reads nothing for it. | |
| # A push to an armed lanes App pull request turns its auto-merge off | |
| # (one mutation, no read), so enable-auto-merge's reader judges each | |
| # new head before it is armed again; the queue still checks the head | |
| # it merges. | |
| # AND NO FAIL PR-MERGE'S READER COUNTS: the step runs | |
| # babysit_verify_evidence.py `evidence --base-sha <group base>` from | |
| # the .base checkout of main for the pull request at its head, the | |
| # reader enable-auto-merge armed it on, on a listing of its own (every | |
| # page, or the pages since pr-merge's last saved listing when they hold | |
| # GitHub's live artifact count, saved to BABYSIT_VERIFY_LISTING) with | |
| # pr-merge's head-ref map and carried run cache, so a FAIL uploaded | |
| # after enable-auto-merge's one listing of its run still holds. Its report goes to merge-holds.mjs | |
| # evidenceHold, which holds on every refusal but the three a pass decides | |
| # (those stay with verdictHold and linkHold): every FAIL class, | |
| # verify:unreadable and verify:too-old. The same approval of the head | |
| # lifts it. The reader reads with this job's token, which holds no | |
| # issues scope: on this public repository its issue reads answer as | |
| # public reads, and one that does not read refuses verify:unreadable, | |
| # which holds. Its gh calls go to the gh binary, outside this step's | |
| # gh_read wait; babysit_gh.py's own rate guard shares this job's stop | |
| # file, so a stop either side records holds the other (this checkout | |
| # has no pr-pipeline.yaml, so only a rate-limit signal stops it, with no | |
| # writer-pause or read-floor margin), and it records | |
| # its rate-limit readings under API_BUDGET_TOKEN. A reader that fails or | |
| # stops on a rate limit, or prints no report for the head holds, as | |
| # does one that allows the head with no listing. A refusal it returns | |
| # before listing (verify:too-old, an unread config) reaches | |
| # merge-holds.mjs with no pass names, where the same approval lifts it. | |
| # THE PASS MUST STILL BIND THE LIVE LINK: the counted pass's verdict.json | |
| # (one artifact download) and the closing issues (one GraphQL read) go | |
| # to merge-holds.mjs linkHold, which holds when the body now closes or | |
| # names another issue than the one pr-verify judged, as pr-merge's | |
| # reader holds it (verify:spec-changed). A record or link that does not | |
| # read holds. | |
| # Pinned by scripts/ci-lanes-pr-hold.test.sh, which runs this body. | |
| # A HEAD A PR-UNBLOCK LANE STOPPED IS HELD. A lane stop that | |
| # escalation-routes.json routes `fail` ends in a failing | |
| # `pr-unblock/<activity>` commit status on the head (pr-unblock.yml | |
| # report-stop-status), and only ci-status is required, so this step | |
| # fails while the newest status of any `pr-unblock/*` context on the | |
| # head is `failure` or `error`, naming each. Newest per context, by | |
| # github-actions[bot] only, as report-escalation.sh `clear` reads it: | |
| # its later `success` on the same head supersedes the failure, and | |
| # another account's status neither holds nor clears. On a pull request | |
| # event the head is the event's; in the merge queue it is the head of | |
| # the pull request named in the group's ref, read live (the ref's SHA is | |
| # the group's base). A status event re-runs nothing, so a stop posted | |
| # after this step passed holds at the merge queue's run. An unparsable | |
| # ref, a head that is not a commit SHA, or an unreadable pull request or | |
| # status list fails closed. | |
| # Pinned by scripts/ci-hold-pr-unblock-status.test.sh, which runs this body. | |
| - name: Fail a head a pr-unblock lane stopped | |
| id: pr_unblock_status | |
| if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| . "$RUNNER_TEMP/gh-read.sh" | |
| read3() { | |
| local attempt | |
| for attempt in 1 2 3; do | |
| gh_read "${@:2}" && return 0 | |
| [ "$attempt" -lt 3 ] || { echo "::error::could not read $1; re-run this run." >&2; return 1; } | |
| sleep $((attempt * 5)) | |
| done | |
| } | |
| head=$HEAD_SHA | |
| if [ "$GITHUB_EVENT_NAME" = merge_group ]; then | |
| pr=$(sed -nE 's#^(refs/heads/)?gh-readonly-queue/.+/pr-([0-9]+)-[0-9a-f]{40}$#\2#p' <<<"$GITHUB_REF") | |
| if [ -z "$pr" ]; then | |
| echo "::error::cannot read a pull request number from the merge-group ref '$GITHUB_REF'." | |
| exit 1 | |
| fi | |
| pull=$(read3 "#$pr" "repos/$GITHUB_REPOSITORY/pulls/$pr") || exit 1 | |
| head=$(jq -r '.head.sha // ""' <<<"$pull") || head= | |
| fi | |
| if [[ ! $head =~ ^[0-9a-f]{40}$ ]]; then | |
| echo "::error::the head '$head' is not a commit SHA." | |
| exit 1 | |
| fi | |
| # Newest first across pages, so the first row of a context is its newest. | |
| rows=$(read3 "the statuses of $head" "repos/$GITHUB_REPOSITORY/commits/$head/statuses?per_page=100" --paginate \ | |
| --jq '.[] | select((.context | startswith("pr-unblock/")) and .creator.login == "github-actions[bot]") | |
| | [.context, .state, (.description // "" | gsub("[\t\r\n]"; " "))] | @tsv') || exit 1 | |
| failing=$(awk -F'\t' '!seen[$1]++ && ($2 == "failure" || $2 == "error")' <<<"$rows") | |
| if [ -z "$failing" ]; then | |
| echo "No pr-unblock/* status fails on $head." | |
| exit 0 | |
| fi | |
| while IFS=$'\t' read -r context state description; do | |
| echo "::error::'$context' is $state on $head: ${description//%/%25}. The lane that stopped clears it by running again on this head, or a new head carries none." | |
| done <<<"$failing" | |
| exit 1 | |
| - name: Fail a lanes App pull request its body or a member holds | |
| if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| # The merge queue's verdict reader, as main has it, and its inputs | |
| # (pr-merge.yml's enable-auto-merge step passes the same). | |
| VERIFY_EVIDENCE_SCRIPT: .base/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py | |
| PR_MERGE_SCRIPTS: .base/.github/scripts/pr-merge | |
| ACTIONS_TOKEN: ${{ github.token }} | |
| BABYSIT_VERIFY_LISTING: ${{ runner.temp }}/verify-listing.json | |
| BABYSIT_VERIFY_LISTING_CARRY: ${{ runner.temp }}/verify-listing-carry.json | |
| BABYSIT_VERIFY_HEAD_REFS: ${{ runner.temp }}/head-refs-in.json | |
| HEAD_REFS_PATH: ${{ runner.temp }}/head-refs-in.json | |
| PR_MERGE_READS_PATH: ${{ runner.temp }}/pr-merge-reads.json | |
| BABYSIT_RUN_CACHE: ${{ runner.temp }}/babysit-run-cache.json | |
| API_BUDGET_TOKEN: github-token | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| EVENT_ACTION: ${{ github.event.action }} | |
| GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} | |
| GROUP_BASE_REF: ${{ github.event.merge_group.base_ref }} | |
| # The lanes kill switches, as check-kill-switch reads them: only the | |
| # exact value `false` lets a lanes App pull request merge. | |
| LANES_DISABLED: ${{ vars.CLAUDE_LANES_DISABLED }} | |
| LANES_REPO_DISABLED: ${{ vars.CLAUDE_LANES_REPO_DISABLED }} | |
| BASE_CONFIG: ${{ steps.base_config.outcome == 'success' && format('{0}/base-config', github.workspace) || '' }} | |
| run: | | |
| . "$RUNNER_TEMP/gh-read.sh" | |
| if [[ ! ${LANES_APP_ID:-} =~ ^[1-9][0-9]*$ ]]; then | |
| echo "::error::the job's LANES_APP_ID is not a numeric id." | |
| exit 1 | |
| fi | |
| # The id must be merge.agent-identity's entry in the base commit's | |
| # trusted-actor list: a stale or mistyped variable would read the | |
| # lanes App's pull request as another author's and skip every hold | |
| # here and the trust hold's link rule. This step runs on every pull | |
| # request and in the queue, so a mismatch turns ci-status red. | |
| if [ ! -d "${BASE_CONFIG:-}" ] || | |
| ! identity=$(yq -er '.merge."agent-identity"' "$BASE_CONFIG/docs/conventions/pr-pipeline.yaml") || | |
| ! want=$(jq -er --arg login "$identity" '[.actors[] | select(.login == $login) | .id | numbers] | |
| | if length == 1 then .[0] else error("not one entry") end' "$BASE_CONFIG/.github/standards/trusted-actors/trusted-actors.json"); then | |
| echo "::error::cannot read merge.agent-identity's trusted-actor id from the base commit's config; re-run this run." | |
| exit 1 | |
| fi | |
| if [ "$want" != "$LANES_APP_ID" ]; then | |
| echo "::error::the job's LANES_APP_ID ($LANES_APP_ID, the AUTOMATION_LANES_APP_SENDER_ID variable) is not $identity's trusted-actor id ($want); correct the variable." | |
| exit 1 | |
| fi | |
| pr=$PR_NUMBER | |
| if [ "$GITHUB_EVENT_NAME" = merge_group ]; then | |
| pr=$(sed -nE 's#^(refs/heads/)?gh-readonly-queue/.+/pr-([0-9]+)-[0-9a-f]{40}$#\2#p' <<<"$GITHUB_REF") | |
| if [ -z "$pr" ]; then | |
| echo "::error::cannot read a pull request number from the merge-group ref '$GITHUB_REF'." | |
| exit 1 | |
| fi | |
| fi | |
| if [[ ! $pr =~ ^[1-9][0-9]*$ ]]; then | |
| echo "::error::the pull request number '${pr//%/%25}' is not a number." | |
| exit 1 | |
| fi | |
| read_api() { | |
| local attempt body | |
| for attempt in 1 2 3; do | |
| if body=$(gh_read "${@:2}"); then | |
| printf '%s\n' "$body" | |
| return 0 | |
| fi | |
| [ "$attempt" -lt 3 ] || { echo "::error::could not read $1; re-run this run." >&2; return 1; } | |
| sleep $((attempt * 5)) | |
| done | |
| } | |
| pull=$(read_api "#$pr" "repos/$GITHUB_REPOSITORY/pulls/$pr") || exit 1 | |
| if ! jq -e '(.user.id | numbers) and (.user.type | strings)' <<<"$pull" >/dev/null; then | |
| echo "::error::the author of #$pr could not be read." | |
| exit 1 | |
| fi | |
| if ! jq -e --argjson id "$LANES_APP_ID" '.user.id == $id and .user.type == "Bot"' <<<"$pull" >/dev/null; then | |
| echo "#$pr is not the lanes App's; pr-merge's gate or a person reads its holds." | |
| exit 0 | |
| fi | |
| if [ "${LANES_DISABLED:-}" != false ] || [ "${LANES_REPO_DISABLED:-}" != false ]; then | |
| echo "::error::#$pr: the lanes kill switch is on (CLAUDE_LANES_DISABLED or CLAUDE_LANES_REPO_DISABLED is not 'false'), so no lanes App pull request merges; it merges once both read 'false' and ci-status is re-run, which the lost-runs reconciler does when this is the run's only failure." | |
| exit 1 | |
| fi | |
| BASE_SHA=$GROUP_BASE_SHA | |
| if [ "$GITHUB_EVENT_NAME" != merge_group ]; then | |
| if [ "$(jq -r '.base.ref' <<<"$pull")" != main ]; then | |
| echo "#$pr is not into main, so native auto-merge never arms it." | |
| exit 0 | |
| fi | |
| BASE_SHA=$(jq -r '.base.sha' <<<"$pull") | |
| # A push gives an armed pull request a head enable-auto-merge's | |
| # reader has not judged: turn its auto-merge off until a reader | |
| # run arms the new head. A failed mutation only warns: failing | |
| # here would hold every push to an armed pull request red if this | |
| # token cannot disarm. A missed disarm is backstopped by the merge | |
| # queue's check for a pr-verify pass at the exact head it merges | |
| # (below) and by enable-auto-merge's scheduled reader, which | |
| # disarms a head it refuses within 15 minutes. | |
| if [ "${EVENT_ACTION:-}" = synchronize ] && jq -e '.auto_merge != null' <<<"$pull" >/dev/null; then | |
| # shellcheck disable=SC2016 # $id is a GraphQL variable | |
| # gh-read-exempt: a GraphQL mutation, a write that is not retried; a failure only warns. | |
| if gh api graphql -f query='mutation($id: ID!) { disablePullRequestAutoMerge(input: {pullRequestId: $id}) { clientMutationId } }' \ | |
| -f id="$(jq -r '.node_id' <<<"$pull")" >/dev/null; then | |
| echo "#$pr: a push moved its head, so its native auto-merge is off until enable-auto-merge's next scheduled run judges the new head." | |
| else | |
| echo "::warning::could not turn off native auto-merge on #$pr after a push to its head; the merge queue still needs a pr-verify pass at the head it merges, and the next scheduled enable-auto-merge run disarms a head its reader refuses." | |
| fi | |
| fi | |
| fi | |
| if [[ ! $BASE_SHA =~ ^[0-9a-f]{40}$ ]]; then | |
| echo "::error::the base commit '$BASE_SHA' is not a commit SHA." | |
| exit 1 | |
| fi | |
| module=.github/scripts/pr-merge/merge-holds.mjs | |
| read_api "$module at $BASE_SHA" -H 'Accept: application/vnd.github.raw+json' \ | |
| "repos/$GITHUB_REPOSITORY/contents/$module?ref=$BASE_SHA" >"$RUNNER_TEMP/merge-holds.mjs" || exit 1 | |
| jq -r '.body // ""' <<<"$pull" >"$RUNNER_TEMP/pr-body.txt" | |
| config=docs/conventions/pr-pipeline.yaml | |
| read_api "$config at $BASE_SHA" -H 'Accept: application/vnd.github.raw+json' \ | |
| "repos/$GITHUB_REPOSITORY/contents/$config?ref=$BASE_SHA" >"$RUNNER_TEMP/pr-pipeline.yaml" || exit 1 | |
| yq -o=json '.merge."diff-check"."denied-paths" // []' "$RUNNER_TEMP/pr-pipeline.yaml" >"$RUNNER_TEMP/denied-paths.json" || { | |
| echo "::error::cannot read merge.diff-check.denied-paths from $config at $BASE_SHA." | |
| exit 1 | |
| } | |
| read_api "the files of #$pr" "repos/$GITHUB_REPOSITORY/pulls/$pr/files?per_page=100" --paginate \ | |
| --jq '.[] | [.filename, .previous_filename, .status] | @json' >"$RUNNER_TEMP/files.jsonl" || exit 1 | |
| changed=$(jq -er '.changed_files | numbers' <<<"$pull") || { echo "::error::the changed-file count of #$pr could not be read."; exit 1; } | |
| if [ "$(grep -c . "$RUNNER_TEMP/files.jsonl")" -lt "$changed" ]; then | |
| echo "::error::GitHub listed fewer files than #$pr changes, so its hold paths cannot be checked; a person merges it." | |
| exit 1 | |
| fi | |
| head=$(jq -r '.head.sha' <<<"$pull") | |
| if [[ ! $head =~ ^[0-9a-f]{40}$ ]]; then | |
| echo "::error::the head of #$pr could not be read." | |
| exit 1 | |
| fi | |
| actors=.github/standards/trusted-actors/trusted-actors.json | |
| read_api "$actors at $BASE_SHA" -H 'Accept: application/vnd.github.raw+json' \ | |
| "repos/$GITHUB_REPOSITORY/contents/$actors?ref=$BASE_SHA" >"$RUNNER_TEMP/trusted-actors.json" || exit 1 | |
| : >"$RUNNER_TEMP/discussion.jsonl" | |
| for kind in "issues/$pr/comments" "pulls/$pr/reviews" "pulls/$pr/comments"; do | |
| rows=$(read_api "the $kind of #$pr" "repos/$GITHUB_REPOSITORY/$kind?per_page=100" --paginate --jq '.[] | @json') || exit 1 | |
| [ -z "$rows" ] || printf '%s\n' "$rows" >>"$RUNNER_TEMP/discussion.jsonl" | |
| done | |
| verdicts=() | |
| if [ "$GITHUB_EVENT_NAME" = merge_group ]; then | |
| ref=$(jq -r '.head.ref // ""' <<<"$pull") | |
| if [ -z "$ref" ]; then | |
| echo "::error::the head branch of #$pr could not be read." | |
| exit 1 | |
| fi | |
| digest=$(printf '%s' "$ref" | sha256sum | cut -c1-16) | |
| # pr-merge's head-ref map: a read that fails leaves none, and the | |
| # reader downloads each untied FAIL instead. | |
| node "$PR_MERGE_SCRIPTS/head-ref-map.mjs" read || echo "::notice::no head-ref map from pr-merge's last run; the verdict reader reads each untied FAIL." | |
| # pr-merge's last saved artifact listing: the reader lists only the | |
| # pages since it and keeps that listing only when it holds GitHub's | |
| # live artifact count, else lists every page, as with no file. | |
| node "$PR_MERGE_SCRIPTS/listing-carry.mjs" || echo "::notice::no artifact listing from pr-merge's last run; the verdict reader lists every page." | |
| # A cold reader runs only with the headroom for it. With no carried | |
| # run cache, or one whose carried sections (carry-run-cache.mjs | |
| # carry) are all empty, the reader reads every FAIL: 860 | |
| # GITHUB_TOKEN requests measured 2026-10-25, 67 of them the one | |
| # whole listing of 6,620 artifacts, so 793 per-FAIL reads. Its | |
| # listing is capped by babysit_verify_evidence.py: INCREMENTAL_READS | |
| # (3) incremental reads on a carried listing (at most 13 pages each, | |
| # LISTING_CARRY_MAX_AGE), then LISTING_READS (3) whole listings, each up to | |
| # 1 + len(LISTING_WAITS) (3) attempts that page from the first, | |
| # and a top-up after each (at most 7 pages: LISTING_OVERLAP's 2 h | |
| # at the 255 uploads an hour seen 2026-10-27, plus one). At 77 | |
| # pages a whole listing (76 at the 7,522-artifact peak of | |
| # 2026-10-27, plus one) a cold read costs at most | |
| # 793 + 3 x 13 + 3 x (3 x 77 + 7) = 1,546, cold_cost below; | |
| # test_babysit_verify_evidence derives it from those caps and fails | |
| # when cold_cost differs. It covers this path only: an | |
| # entry with a carried run cache but no carried listing pays the | |
| # listing's part (up to 753) unguarded. A missing cache is shared, | |
| # so every entry the queue builds at once goes cold together: the | |
| # cost counts max_entries_to_build of | |
| # the base branch's merge_queue rule (one read of | |
| # repos/<repo>/rules/branches/<base>, the largest any ruleset sets). | |
| # It runs when the core pool's remaining count, read once from | |
| # /rate_limit (which "does not count against your REST API rate | |
| # limit", https://docs.github.com/en/rest/rate-limit/rate-limit), is | |
| # at least that cost plus the read floor the job env inlines from | |
| # pr-pipeline.yaml limits.api (API_BUDGET_READ_FLOOR of | |
| # min(limit, API_BUDGET_LIMIT_CAP)), so cold reads never take the | |
| # pool under the floor. A rate limit that does not read, or a | |
| # stop in the job's stop file (gh_read's rate guard refuses the | |
| # pool), holds. The reader's own babysit_gh.py guard shares that | |
| # stop file. | |
| if ! jq -e '[.["carried-run-record"], .["carried-fail-head-ref"], .["carried-on-branch"]] | map(objects | length) | add > 0' \ | |
| "$BABYSIT_RUN_CACHE" >/dev/null 2>&1; then | |
| cold="#$pr is held: no carried run cache from pr-merge's last run" | |
| branch=${GROUP_BASE_REF#refs/heads/} | |
| entries= | |
| if [[ $branch =~ ^[A-Za-z0-9._/-]+$ ]] && rules=$(read_api "the merge queue rule of $branch" "repos/$GITHUB_REPOSITORY/rules/branches/$branch"); then | |
| entries=$(jq -er '[.[] | select(.type == "merge_queue") | .parameters.max_entries_to_build | numbers | floor | select(. > 0)] | max // empty' <<<"$rules" 2>/dev/null) || entries= | |
| fi | |
| if [ -z "$entries" ]; then | |
| echo "::error::$cold, and the merge queue's max_entries_to_build could not be read, so the headroom for cold reads cannot be judged. Re-queue it." | |
| exit 1 | |
| fi | |
| cold_cost=1546 | |
| rate=$(read_api "the GITHUB_TOKEN rate limit" rate_limit) || rate= | |
| if ! remaining=$(jq -er '.resources.core.remaining | numbers | floor' <<<"$rate" 2>/dev/null) || | |
| ! limit=$(jq -er '.resources.core.limit | numbers | floor' <<<"$rate" 2>/dev/null) || | |
| ! need=$(awk -v floor="$API_BUDGET_READ_FLOOR" -v cap="$API_BUDGET_LIMIT_CAP" -v limit="$limit" -v entries="$entries" -v cost="$cold_cost" \ | |
| 'BEGIN { if (floor !~ /^[0-9.]+$/ || cap !~ /^[0-9]+$/) exit 1; if (limit < cap) cap = limit; printf "%d\n", cost * entries + int(floor * cap + 0.999999) }'); then | |
| echo "::error::$cold, and GITHUB_TOKEN's remaining requests could not be read, so a cold read (up to $cold_cost requests) is not started. Re-queue it." | |
| exit 1 | |
| fi | |
| if [ "$remaining" -lt "$need" ]; then | |
| echo "::error::$cold, and GITHUB_TOKEN has $remaining requests left, under the $need a cold read needs ($cold_cost for each of the $entries entries the queue builds, plus the read floor). Re-queue it after the reset or after pr-merge's next scheduled run uploads its cache." | |
| exit 1 | |
| fi | |
| echo "::notice::no carried run cache from pr-merge's last run; GITHUB_TOKEN has $remaining requests left (cold reads for $entries queue entries need $need), so the verdict reader reads every FAIL." | |
| fi | |
| rm -f "$BABYSIT_VERIFY_LISTING" | |
| report=$(python3 "$VERIFY_EVIDENCE_SCRIPT" evidence --repo "$GITHUB_REPOSITORY" --base-sha "$BASE_SHA" "$pr" "$head") || { | |
| echo "::error::babysit_verify_evidence.py could not judge #$pr at $head; re-run this run." | |
| exit 1 | |
| } | |
| printf '%s\n' "$report" | tail -n 1 >"$RUNNER_TEMP/evidence.json" | |
| : >"$RUNNER_TEMP/verdicts.jsonl" | |
| # A refusal returned before any listing (verify:too-old, an unread | |
| # config or commit list) goes on with no pass names: merge-holds.mjs | |
| # holds on it and on the missing pass, and a trusted approval of the | |
| # head lifts both. An allow with no listing has no pass to bind. | |
| listed=true | |
| if [ ! -s "$BABYSIT_VERIFY_LISTING" ]; then | |
| if jq -e --arg head "$head" '.head == $head and (.refusal | type) == "string"' "$RUNNER_TEMP/evidence.json" >/dev/null 2>&1; then | |
| listed=false | |
| echo "::notice::the verdict reader refused #$pr before listing artifacts; merge-holds.mjs judges its refusal with no pass." | |
| else | |
| echo "::error::the verdict reader read no artifact listing for #$pr: $(tr -d '\r\n%' <"$RUNNER_TEMP/evidence.json")" | |
| exit 1 | |
| fi | |
| fi | |
| [ "$listed" = false ] || for name in "pr-verify-$pr-$head-pass" "pr-verify-$pr-$head-pass-$digest" \ | |
| "pr-verify-$pr-$head-pass-notes" "pr-verify-$pr-$head-pass-notes-$digest"; do | |
| verdict=${name#"pr-verify-$pr-$head-"} | |
| verdict=${verdict%"-$digest"} | |
| runs=$(jq -r --arg name "$name" '.artifacts[] | select(.name == $name and .expired == false) | "\(.workflow_run.id) \(.id)"' "$BABYSIT_VERIFY_LISTING") || { | |
| echo "::error::the verdict reader's artifact listing does not parse." | |
| exit 1 | |
| } | |
| while read -r run artifact; do | |
| [[ $run =~ ^[1-9][0-9]*$ && $artifact =~ ^[1-9][0-9]*$ ]] || continue | |
| record=$(read_api "run $run" "repos/$GITHUB_REPOSITORY/actions/runs/$run") || exit 1 | |
| jq -c --arg name "$name" --arg verdict "$verdict" --argjson id "$run" --argjson artifact "$artifact" '{name: $name, verdict: $verdict, runId: $id, artifact: $artifact, run: .}' <<<"$record" >>"$RUNNER_TEMP/verdicts.jsonl" || exit 1 | |
| done <<<"$runs" | |
| # A pass merge-holds.mjs verdictHold would count ends the lookup. | |
| ! jq -se --arg repo "$GITHUB_REPOSITORY" 'any(.runId as $id | .run | .id == $id and .status == "completed" and .conclusion == "success" | |
| and .path == ".github/workflows/pr-verify.yml" and .event == "workflow_run" and .head_branch == "main" | |
| and .repository.full_name == $repo and .head_repository.full_name == $repo)' "$RUNNER_TEMP/verdicts.jsonl" >/dev/null || break | |
| done | |
| # The counted pass's record and the closing issues, for linkHold. | |
| artifact=$(jq -sr --arg repo "$GITHUB_REPOSITORY" '[.[] | select(.runId as $id | .run | .id == $id and .status == "completed" and .conclusion == "success" | |
| and .path == ".github/workflows/pr-verify.yml" and .event == "workflow_run" and .head_branch == "main" | |
| and .repository.full_name == $repo and .head_repository.full_name == $repo)] | first | .artifact // empty' "$RUNNER_TEMP/verdicts.jsonl") || exit 1 | |
| record=null | |
| if [[ $artifact =~ ^[1-9][0-9]*$ ]]; then | |
| for attempt in 1 2 3; do | |
| # gh-read-exempt: a binary archive, which gh_read's text capture would corrupt. | |
| if gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact/zip" >"$RUNNER_TEMP/verdict.zip"; then | |
| record=$(unzip -p "$RUNNER_TEMP/verdict.zip" verdict.json | jq -c .) || record=null | |
| break | |
| fi | |
| [ "$attempt" -lt 3 ] || { echo "::error::could not read the verdict of artifact $artifact; re-run this run."; exit 1; } | |
| sleep $((attempt * 5)) | |
| done | |
| fi | |
| # shellcheck disable=SC2016 # GraphQL variables, not shell | |
| closing=$(read_api "the closing issues of #$pr" graphql -f query='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){closingIssuesReferences(first:10){totalCount nodes{number repository{nameWithOwner}}}}}}' \ | |
| -F owner="${GITHUB_REPOSITORY%%/*}" -F name="${GITHUB_REPOSITORY#*/}" -F number="$pr" --jq '.data.repository.pullRequest.closingIssuesReferences') || exit 1 | |
| jq -cn --argjson record "${record:-null}" --argjson closing "${closing:-null}" '{record: $record, closing: $closing}' >"$RUNNER_TEMP/link.json" || { | |
| echo "::error::the verdict record or closing issues of #$pr do not parse." | |
| exit 1 | |
| } | |
| verdicts=("$RUNNER_TEMP/verdicts.jsonl" "$RUNNER_TEMP/link.json" "$RUNNER_TEMP/evidence.json") | |
| fi | |
| export BASE_SHA | |
| out=$(node "$RUNNER_TEMP/merge-holds.mjs" "$pr" "$RUNNER_TEMP/pr-body.txt" "$RUNNER_TEMP/discussion.jsonl" \ | |
| "$RUNNER_TEMP/files.jsonl" "$RUNNER_TEMP/denied-paths.json" "$RUNNER_TEMP/trusted-actors.json" "$head" "${verdicts[@]}") || { | |
| printf '%s\n' "$out" | |
| exit 1 | |
| } | |
| # Exit 0 without the module's no-hold line ran no check: fail closed. | |
| printf '%s\n' "$out" | |
| grep -qxF "#$pr: no body hold, no member veto and no hold path." <<<"$out" || { echo "::error::merge-holds.mjs exited 0 without its verdict."; exit 1; } | |
| if [ "${#verdicts[@]}" -gt 0 ] && ! grep -qE "^#$pr: no verifier hold: " <<<"$out"; then | |
| echo "::error::merge-holds.mjs exited 0 without its verifier verdict." | |
| exit 1 | |
| fi | |
| if [ "${#verdicts[@]}" -gt 0 ] && ! grep -qE "^#$pr: no pr-verify FAIL hold: " <<<"$out"; then | |
| echo "::error::merge-holds.mjs exited 0 without its pr-verify FAIL verdict." | |
| exit 1 | |
| fi | |
| # ORDERED SKIPS ARE CHECKED HERE. A test lane `test-<x>`, or `lint-shell` | |
| # on its `run_shell` row, skips as a job when `select-tests`'s `run_<x>` | |
| # row says the change gives it no work. Its | |
| # `skipped` becomes `success` for the aggregate only when `select-tests` | |
| # succeeded and that row is exactly 'false'; every other result passes | |
| # through as it is, so a skip that hid work stays `skipped` and the | |
| # aggregate fails it. Pinned by scripts/ci-ordered-skips.test.sh, which | |
| # runs this body. | |
| - name: Check each skipped lane against scope | |
| id: lanes | |
| if: ${{ !cancelled() }} | |
| env: | |
| LANES: ${{ toJSON(needs) }} | |
| run: | | |
| [ -z "${API_BUDGET_SH:-}" ] || { eval "$API_BUDGET_SH"; api_budget_emit github-token; api_budget_emit_calls github-token; api_budget_emit_content; } | |
| results=$(jq -r ' | |
| .["select-tests"] as $s | |
| | to_entries | |
| | map(.key as $lane | .value.result as $r | |
| | if $r == "skipped" and $s.result == "success" | |
| and ($lane | test("^(test-[a-z]+|lint-shell)$")) | |
| and ($s.outputs["run_" + ($lane | sub("^(test|lint)-"; ""))] == "false") | |
| then "success" else $r end) | |
| | join(" ")' <<<"$LANES") | |
| if [ -z "$results" ]; then | |
| echo "::error::no lane results could be read." | |
| exit 1 | |
| fi | |
| jq -r 'to_entries[] | "\(.key): \(.value.result)"' <<<"$LANES" | |
| echo "Results after the ordered-skip check: $results" | |
| echo "results=$results" >>"$GITHUB_OUTPUT" | |
| - name: Aggregate lane results | |
| # `!cancelled()` so a failing check-contract step does not skip the | |
| # aggregation: the job still fails on the contract step's exit code, and | |
| # the `ci-lanes` status is on the SHA for the next contract-only run. | |
| if: ${{ !cancelled() }} | |
| uses: melodic-software/ci-workflows/.github/actions/pr-require-checks/aggregate-results@81a603a6e2b0319428c981bf28f3fe923032afb2 # v0.65.17 | |
| with: | |
| # Every lane of the needs graph above — the single source of truth | |
| # for the lane list — through the ordered-skip check. Adding a lane | |
| # to needs automatically extends this check. | |
| results: ${{ steps.lanes.outputs.results }} | |
| # A `skipped` lane is red, except on a draft, where every lane skips | |
| # (see `Note a draft`). | |
| treat-skipped-as: ${{ github.event.pull_request.draft && 'pass' || 'fail' }} | |
| # `carry-forward-wait-seconds: '0'` means no carry-forward wait. Under | |
| # `yield-to-full-run` the composite may re-read for up to 30 s, only | |
| # when the in-flight full run has already written `success` and its | |
| # aggregate step is finishing. While a full run on the SHA is in | |
| # flight it fails with "superseded by full run <url>", and that run's | |
| # newer ci-status decides the gate. Once the full run records | |
| # `success` it re-runs those red contract-only runs, which then read | |
| # the `success` and replace their red check runs. A draft full run | |
| # skips its lanes and re-runs nothing. Operator remedy: | |
| # docs/ci-runner-routing.md, section Contract-only ci-status. | |
| yield-to-full-run: 'true' | |
| rerun-contract-only-siblings: ${{ github.event.pull_request.draft && 'false' || 'true' }} | |
| carry-forward-wait-seconds: '0' | |
| # This step's own name, so the own-step lookup never depends on | |
| # how many steps of the job are in progress. | |
| step-name: Aggregate lane results | |
| token: ${{ github.token }} |