diff --git a/.changeset/dependabot-automerge-gate-4973.md b/.changeset/dependabot-automerge-gate-4973.md new file mode 100644 index 0000000000..a98dff81a1 --- /dev/null +++ b/.changeset/dependabot-automerge-gate-4973.md @@ -0,0 +1,35 @@ +--- +--- + +CI + tooling + test only (objectui#4973). No published package changes: the files touched are +`.github/workflows/dependabot-auto-merge.yml`, a new `scripts/` helper with its pin test, and the +CI/CD guide page. + +`dependabot-auto-merge.yml` no longer runs `gh pr merge --auto --squash` unconditionally. `--auto` +lands the merge the moment GitHub considers the pull request mergeable — i.e. the moment the +*branch-protection required set* is satisfied, which is a different set from "the checks this +repository runs". On 2026-08-17 that difference put a red commit on `main`: #4959 merged at +08:13:36Z with nine of its nineteen check runs still in flight, and its shard 3/4 and shard 1/4 +then reported `failure` 5m25s and 8m20s later. The four-way test shard matrix is the slowest job +in the repository by construction (it exists to cut a ~9 minute wall clock), so it is the check +`--auto` systematically outruns; `main` went red for every parallel agent until #4968 repaired it, +the second such block in seven days (#4098). The channel was never specific to lockfile ranges — +any red on a slow job could ride it, which is #3523 and #3243 again. + +The wait is now explicit and this workflow owns it. `scripts/dependabot-merge-gate.mjs` polls the +Checks API for the pull request's head SHA and returns a verdict; approval and enqueue are both +behind `gate == 'green'`, and the merge is pinned to the judged SHA with `--match-head-commit`. A +required context that is missing, still running at the deadline, or anything other than `success` +is not green — nothing merges, the job goes red, and a PR comment names what refused. The semver +policy (patch/minor auto, major comment-only) is unchanged, and `--auto` is still the merge action +because an enforced merge queue rejects a direct merge with 405. + +`scripts/__tests__/dependabot-merge-gate.test.ts` replays #4959's measured check-run timeline and +asserts the counterfactual — `pending` at 08:13:36Z, `red` once shard 3/4 reports — and asserts +that the gate's three declared buckets partition exactly the check names that +`pull_request`-triggered workflows produce, so a renamed or added job fails a test instead of +quietly dropping out of the wait. + +Not addressed here, because it is a repository-**settings** surface this repository can neither +read nor change: the branch-protection / merge-queue required set itself, which provably contains +none of the four shards (a merge happened while all four were `in_progress`). diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 3452d6e096..b8e51d5d25 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -4,14 +4,84 @@ on: pull_request: branches: [main, develop] +# ── Why this workflow no longer enables auto-merge unconditionally (#4973) ──── +# +# It used to run, for every semver-patch/minor bump and with nothing else asked: +# +# gh pr merge --auto --squash "$PR_URL" +# +# `--auto` lands the merge as soon as GitHub thinks the pull request is +# mergeable, i.e. as soon as the BRANCH-PROTECTION required set is satisfied — +# not when the checks this repository runs are green. Those are different sets, +# and on 2026-08-17 the difference put a red commit on `main`: +# +# 08:13:07Z #4959's four `Test (shard N/4)` jobs start +# 08:13:36Z github-actions[bot] merges #4959 into `main` +# 08:21:01Z shard 3/4 -> failure (5m25s AFTER the merge) +# 08:21:56Z shard 1/4 -> failure (8m20s AFTER the merge) +# 08:22:33Z Type Check reports (8m57s AFTER the merge) +# +# Nine of the nineteen check runs on that head SHA were still in flight at the +# moment of the merge. The shard matrix is the slowest job in the repo by +# construction (it exists to cut a ~9 minute wall clock — see `ci.yml`), so it +# is the check `--auto` systematically outruns; #4968 then had to repair `main` +# for every parallel agent. The failure mode is not specific to lockfile ranges: +# any red on a slow job could ride the same channel. Same family as #3523 (an +# empty merge-queue required set let #3503/#3510/#3516 land with `Type Check` at +# conclusion=failure) and #3243. +# +# So the wait is now EXPLICIT and this workflow owns it: `scripts/dependabot- +# merge-gate.mjs` polls the Checks API for this exact head SHA until every +# context it declares has reported `success`, and only then are the two +# mutations — approve, enqueue — allowed to run. A context that is missing, +# still running at the deadline, or non-`success` is not green; nothing merges, +# the job goes red and a comment says which context refused. The declared set, +# the reasons behind each bucket and the partition test that keeps it honest all +# live in that script and in `scripts/__tests__/dependabot-merge-gate.test.ts`, +# which replays the real #4959 timeline and asserts this gate would have stopped +# it. +# +# Two things this deliberately does NOT do: +# +# * It does not ask GitHub which checks are required. That set is a +# repository-SETTINGS surface nothing here can read or change +# (`content/docs/guide/ci-cd-pipeline.md`, "Merge Queue", step 3) — and it +# provably does not contain the shards today, since a merge happened while +# all four were `in_progress`. Reading it would reproduce the hole. +# * It does not replace `--auto` with a direct merge. `main` is behind an +# enforced merge queue: a direct merge is rejected with 405 `Changes must be +# made through the merge queue` (measured in #3243, recorded in AGENTS.md +# §9). Enabling auto-merge IS the enqueue action here. What changes is that +# it happens only after the full check set is green on this SHA, instead of +# 29 seconds after the shards started. permissions: contents: write pull-requests: write + # The gate reads check runs for the pull request's head SHA. With an explicit + # `permissions:` block every scope not named is `none`, so without this the + # gate would 403 — and it fails closed: a throw exits non-zero, the job is red + # and nothing is merged. + checks: read + +# A second push to a Dependabot branch supersedes the first gate run. Without +# this, two runs would sit in their poll loops for the same pull request and the +# older one could enqueue a SHA that is no longer the head. Cancellation is the +# safe direction: a cancelled gate merges nothing. +concurrency: + group: dependabot-auto-merge-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: dependabot: runs-on: ubuntu-latest if: ${{ github.actor == 'dependabot[bot]' }} + # The gate waits for the slowest check in the repository. Measured on #4959 + # the last required context reported 9m26s after the jobs started, so the + # script's own deadline is 40 minutes (`GATE_TIMEOUT_SECONDS`) and the job is + # given a little more, so the deadline is always reached by the script — it + # renders a report and comments — rather than by the runner killing the job + # with nothing to read. + timeout-minutes: 50 steps: - name: Checkout code uses: actions/checkout@v7 @@ -45,6 +115,8 @@ jobs: with: github-token: "${{ secrets.GITHUB_TOKEN }}" + # Unchanged (#4973 does not touch the semver policy): patch and minor may + # be merged automatically, major may not. - name: Check if auto-mergeable id: check-update run: | @@ -55,20 +127,74 @@ jobs: echo "auto_merge=false" >> $GITHUB_OUTPUT fi - - name: Enable auto-merge for Dependabot PRs + # The wait. Polls the Checks API for THIS head SHA; writes `gate=green|red` + # plus a rendered report. It never mutates anything — the two steps below + # are the only things that can write, and both are behind `gate == green`. + # + # Mechanism note (#4973 left the choice open): a poll loop in this job was + # chosen over re-triggering on `check_suite: completed`. The `check_suite` + # route wakes ~8 times per pull request instead of holding a runner, but it + # arrives without the pull-request context `dependabot/fetch-metadata` + # needs and without `github.actor == 'dependabot[bot]'`, so the semver + # policy above would have to be re-derived on a different event — more + # moving parts around the decision that just went wrong. The cost of this + # shape is one mostly-idle runner for the ~10 minutes the shards take, per + # Dependabot pull request; that is the price of not corrupting `main`. + - name: Wait for the full check set on this head SHA + id: gate if: steps.check-update.outputs.auto_merge == 'true' - run: gh pr merge --auto --squash "$PR_URL" + run: node scripts/dependabot-merge-gate.mjs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + GATE_TIMEOUT_SECONDS: '2400' + GATE_INTERVAL_SECONDS: '20' + GATE_REPORT_FILE: dependabot-merge-gate.md + + # Requirement: a refusal must be visible on the pull request, not only in + # a log nobody opens. The step summary is written by the script; this puts + # the same text where a reviewer of the PR will see it. + - name: Report the refusal on the pull request + if: steps.gate.outputs.gate == 'red' + run: gh pr comment "$PR_URL" --body-file dependabot-merge-gate.md env: PR_URL: ${{ github.event.pull_request.html_url }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Approval moved behind the gate too. An approval on a pull request whose + # tests are red is a false signal to every human reading the PR list, and + # where a ruleset counts approvals it is also half of the merge decision. - name: Approve PR - if: steps.check-update.outputs.auto_merge == 'true' + if: steps.gate.outputs.gate == 'green' run: gh pr review --approve "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # `--match-head-commit` pins the merge to the SHA the gate actually + # judged: if Dependabot pushed a new commit while the gate was waiting, + # this refuses instead of enqueueing an unverified head. If a future `gh` + # ever drops the flag the step exits non-zero, so the failure direction is + # "nothing merged, job red", not "merged unverified". + - name: Enqueue for merge (auto-merge = enter the merge queue) + if: steps.gate.outputs.gate == 'green' + run: gh pr merge --auto --squash --match-head-commit "$GATED_SHA" "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GATED_SHA: ${{ steps.gate.outputs.gated_sha }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Make the refusal red. The gate script exits 0 on a DECIDED red so that + # the comment above can run (a crash inside it exits non-zero on its own + # and fails the job here regardless), so the job's own conclusion has to be + # set explicitly — otherwise a refused merge would report as a green + # `dependabot` check, which is the shape of silence this issue is about. + - name: Fail the job when the gate refused + if: steps.check-update.outputs.auto_merge == 'true' && steps.gate.outputs.gate != 'green' + run: | + echo "::error::Dependabot merge gate did not go green — see the job summary and the PR comment." + exit 1 + - name: Comment on major updates if: steps.metadata.outputs.update-type == 'version-update:semver-major' uses: actions/github-script@v9 diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 04f23b988c..a3c9460c1d 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -34,7 +34,7 @@ one has its own section below. | `performance-budget.yml` | Bundle Analysis | Push / PR touching `packages/**`, `apps/console/**`, `pnpm-lock.yaml` | **Yes** — the console entry gzip budget | | `live-e2e.yml` | Live E2E (informational) | PR to `main`, `develop` (code paths); nightly cron `30 6 * * *`; manual | No — informational lane, `continue-on-error` | | `labeler.yml` | Auto Label PRs | PR `opened`, `synchronize`, `reopened` | No | -| `dependabot-auto-merge.yml` | Dependabot Auto-merge | PR to `main`/`develop` authored by `dependabot[bot]` | No | +| `dependabot-auto-merge.yml` | Dependabot Auto-merge | PR to `main`/`develop` authored by `dependabot[bot]` | No — but it gates *its own* merge, and goes red instead of merging when the check set is not green | | `cross-repo-issue-closer.yml` | Cross-repo Issue Closer | PR `closed` (acts only when merged) | No — runs after merge | | `changeset-release.yml` | Changeset Release | Push to `main` | n/a | | `release.yml` | Release | Push of a `v*` tag | n/a | @@ -844,10 +844,50 @@ both resources ([#3724](https://github.com/objectstack-ai/objectui/issues/3724)) **Trigger:** PRs on `main`/`develop` authored by `dependabot[bot]`. -- **Patch/minor updates**: Auto-approved and squash-merged. -- **Major updates**: Approved with a comment for manual review. +- **Patch/minor updates**: approved and enqueued — **but only after an explicit wait**, see below. +- **Major updates**: commented for manual review; never approved, never enqueued. - Configures a pnpm-lock.yaml merge driver for conflict resolution. +**The wait, and why it exists.** This workflow used to run `gh pr merge --auto --squash` +unconditionally for every patch/minor bump. `--auto` lands the merge as soon as GitHub considers +the PR mergeable — that is, as soon as the *branch-protection required set* is satisfied, which is +a different set from "the checks this repository runs". On 2026-08-17 the difference put a red +commit on `main`: [#4959](https://github.com/objectstack-ai/objectui/issues/4959) merged at +08:13:36Z with nine of its nineteen check runs still in flight, and shard 3/4 then reported +`failure` at 08:21:01Z, shard 1/4 at 08:21:56Z. The four-way test shard matrix is the slowest job +here **by construction** — it exists to cut a ~9 minute wall clock — so it is the check `--auto` +systematically outruns, and the resulting red `main` blocked every parallel agent until +[#4968](https://github.com/objectstack-ai/objectui/issues/4968) repaired it. It was the second +time in seven days ([#4098](https://github.com/objectstack-ai/objectui/issues/4098)). + +So the wait is now explicit and this workflow owns it +([#4973](https://github.com/objectstack-ai/objectui/issues/4973)): +`scripts/dependabot-merge-gate.mjs` polls the Checks API for the pull request's head SHA until +every context it declares has reported `success`, and only then may the two mutations — approve, +enqueue — run. The declared set is the unfiltered blocking contexts (all four shards, **Type +Check**, **Lint**, **Build & E2E**, **Build Docs** and the five one-`node`-call gates); the +path-filtered ones (**Bundle Analysis**, **Changeset Bump Policy**) must be green *if they +reported*; everything else is listed with the reason it cannot gate. A context that is missing, +still running at the deadline, or anything other than `success` is **not** green: nothing merges, +the job goes red, and a comment on the PR names what refused. + +Two properties are worth keeping in mind when editing it: + +- The gate does **not** ask GitHub which checks are required, because that set is a + repository-settings surface nothing here can read (see the three ordered steps under + [Merge Queue](#merge-queue)) — and it provably does not contain the shards today, since a merge + happened while all four were `in_progress`. Reading it would reproduce the hole. +- It does **not** replace `--auto` with a direct merge. `main` is behind an enforced merge queue, + where a direct merge is rejected with 405; enabling auto-merge *is* the enqueue action. What + changed is that it happens after the check set is green on that SHA, not 29 seconds after the + shards started. + +`scripts/__tests__/dependabot-merge-gate.test.ts` holds both halves: it replays #4959's measured +check-run timeline and asserts the gate says `pending` at the instant of the old merge and `red` +once the shards report, and it asserts the declared buckets partition exactly the set of check +names that `pull_request`-triggered workflows produce — so a renamed or added job fails that test +instead of quietly dropping out of the wait. + ### Shadcn Component Check (`shadcn-check.yml`) **Trigger:** Weekly on Monday at 9:00 AM UTC, or manual dispatch. diff --git a/scripts/__tests__/dependabot-merge-gate.test.ts b/scripts/__tests__/dependabot-merge-gate.test.ts new file mode 100644 index 0000000000..df741f752a --- /dev/null +++ b/scripts/__tests__/dependabot-merge-gate.test.ts @@ -0,0 +1,597 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + NOT_A_GATE, + main, + OPTIONAL_CONTEXTS, + REQUIRED_CONTEXTS, + evaluateGate, + latestByName, + renderVerdict, + waitForGate, +} from '../dependabot-merge-gate.mjs'; + +/** + * objectui#4973 — `dependabot-auto-merge.yml` merged a pull request 8m20s before + * its own test shard reported `failure`. + * + * `gh pr merge --auto --squash` was run unconditionally for every semver-patch + * and semver-minor bump. `--auto` lands the merge the moment GitHub considers + * the pull request mergeable — the moment the BRANCH-PROTECTION required set is + * satisfied — which is a different set from "the checks this repository runs". + * On 2026-08-17 #4959 (`lucide-react` 1.29.0 -> 1.31.0) was red on its own head + * SHA `31745d8b` and merged anyway; `main` went red for every parallel agent and + * #4968 had to repair it. It was the second time in seven days (#4098 was the + * same test and the same dependency). + * + * ## Why the #4959 timeline is a FIXTURE here + * + * A workflow cannot be executed locally, so the usual way to argue about one is + * a prose walkthrough in the pull-request body — which proves nothing later. + * The decision therefore lives in `scripts/dependabot-merge-gate.mjs`, and the + * measured check-run timeline of the incident lives below, so the central claim + * of the fix is an assertion instead of a paragraph: + * + * at 08:13:36Z, the second the old workflow merged #4959, this gate says + * `pending` -> nothing is merged; + * at 08:21:01Z, when shard 3/4 reports, it says `red` -> nothing is merged. + * + * Every field in `INCIDENT_4959` is copied from the check runs GitHub still has + * for that SHA (19 runs; `started_at` / `completed_at` / `conclusion` verbatim). + * `snapshotAt()` reconstructs what the API would have returned at any instant, + * the same way the gate reads it. + * + * ## The other half: the buckets have to stay honest + * + * A declared required set is a second source of truth about which checks exist, + * and the failure mode of a stale one is silence — a renamed job simply stops + * being waited for, and the gate goes green a little earlier every time. So the + * three buckets are asserted to PARTITION the set of check names that + * `pull_request`-triggered workflows actually produce: nothing produced may be + * unclassified, nothing classified may be unproduced. That is the assertion a + * new `ci.yml` job or a renamed shard trips. + * + * The buckets' *reasons* are pinned too, not just their membership: every + * required context must come from a workflow whose `pull_request` trigger has no + * path filter (the #3523 rule — only an unfiltered gate reports on every pull + * request and can therefore be waited for), and every optional context must come + * from one that has such a filter (which is the entire reason it cannot be + * required). Move a filter and the bucket, not just the comment, goes red. + */ + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '..', '..'); +const workflowDir = path.join(repoRoot, '.github', 'workflows'); + +// ── The incident, as GitHub recorded it ────────────────────────────────────── +// PR #4959, head SHA 31745d8b6805dc829c05660a9592e92ca537bc3b, merged by +// github-actions[bot] at 2026-08-17T08:13:36Z. +const MERGED_AT = '2026-08-17T08:13:36Z'; + +const INCIDENT_4959 = [ + { name: 'Test (coverage)', started: '08:13:05Z', completed: '08:13:05Z', conclusion: 'skipped' }, + { name: 'Skill Guide Path Check', started: '08:13:07Z', completed: '08:13:17Z', conclusion: 'success' }, + { name: 'Test (shard 2/4)', started: '08:13:07Z', completed: '08:22:08Z', conclusion: 'success' }, + { name: 'Test (shard 4/4)', started: '08:13:07Z', completed: '08:18:55Z', conclusion: 'success' }, + { name: 'Bundle Analysis', started: '08:13:07Z', completed: '08:17:12Z', conclusion: 'success' }, + { name: 'Test (shard 3/4)', started: '08:13:07Z', completed: '08:21:01Z', conclusion: 'failure' }, + { name: 'Build & E2E', started: '08:13:07Z', completed: '08:16:51Z', conclusion: 'success' }, + { name: 'Test (shard 1/4)', started: '08:13:07Z', completed: '08:21:56Z', conclusion: 'failure' }, + { name: 'Changeset Fixed Group Check', started: '08:13:08Z', completed: '08:13:16Z', conclusion: 'success' }, + { name: 'Type Check', started: '08:13:07Z', completed: '08:22:33Z', conclusion: 'success' }, + { name: 'Live E2E (informational)', started: '08:13:07Z', completed: '08:15:13Z', conclusion: 'success' }, + { name: 'Build Docs', started: '08:13:08Z', completed: '08:18:46Z', conclusion: 'success' }, + { name: 'Control Byte Scan', started: '08:13:07Z', completed: '08:13:17Z', conclusion: 'success' }, + { name: 'Internal Docs Link Check', started: '08:13:07Z', completed: '08:13:13Z', conclusion: 'success' }, + { name: 'Changeset Declaration', started: '08:13:07Z', completed: '08:13:18Z', conclusion: 'success' }, + { name: 'Lint', started: '08:13:07Z', completed: '08:17:13Z', conclusion: 'success' }, + { name: 'Doc Component Type Check', started: '08:13:07Z', completed: '08:13:15Z', conclusion: 'success' }, + { name: 'dependabot', started: '08:13:07Z', completed: '08:13:30Z', conclusion: 'success' }, + { name: 'label', started: '08:13:07Z', completed: '08:13:15Z', conclusion: 'success' }, +] as const; + +const at = (hms: string) => Date.parse(`2026-08-17T${hms}`); + +/** What `GET /commits/{sha}/check-runs` would have returned at one instant. */ +function snapshotAt(instant: string) { + const t = Date.parse(instant); + return INCIDENT_4959.map((run, index) => { + const id = 95_325_240_000 + index; + if (at(run.completed) <= t) return { id, name: run.name, status: 'completed', conclusion: run.conclusion }; + if (at(run.started) <= t) return { id, name: run.name, status: 'in_progress', conclusion: null }; + return { id, name: run.name, status: 'queued', conclusion: null }; + }); +} + +/** The same 19 names, all green — the shape the gate is allowed to merge. */ +function allGreenSnapshot() { + return INCIDENT_4959.map((run, index) => ({ + id: 95_325_240_000 + index, + name: run.name, + status: 'completed', + conclusion: run.name === 'Test (coverage)' ? 'skipped' : 'success', + })); +} + +describe('the #4959 counterfactual: this gate stops the merge that happened', () => { + it('is PENDING at 08:13:36Z — the instant the old workflow merged #4959', () => { + const result = evaluateGate({ checkRuns: snapshotAt(MERGED_AT) }); + + expect(result.verdict).toBe('pending'); + // Nothing is merged on `pending`; the workflow's merge step is behind + // `gate == 'green'` and nothing else. + expect(result.verdict).not.toBe('green'); + + // Nine contexts were still running when the merge landed: these eight + // required ones plus `Bundle Analysis`, which is optional-if-present and was + // also in flight. The exact count is asserted so a fixture edit cannot + // quietly weaken the case. + expect(result.pending).toHaveLength(9); + expect(result.pending).toContain('Bundle Analysis (in_progress)'); + expect(result.pending).toEqual( + expect.arrayContaining([ + 'Type Check (in_progress)', + 'Test (shard 1/4) (in_progress)', + 'Test (shard 2/4) (in_progress)', + 'Test (shard 3/4) (in_progress)', + 'Test (shard 4/4) (in_progress)', + 'Build & E2E (in_progress)', + 'Build Docs (in_progress)', + 'Lint (in_progress)', + ]), + ); + + // And nothing had failed YET. This is why no configuration that only looks + // at *reported* checks could have caught it, and why the fix has to be a + // wait rather than a stricter reading of what had already reported. + expect(result.failing).toEqual([]); + for (const run of snapshotAt(MERGED_AT)) { + if (run.status === 'completed') expect(['success', 'skipped']).toContain(run.conclusion); + } + }); + + it('turns RED the moment shard 3/4 reports, 5m25s after the old merge', () => { + const result = evaluateGate({ checkRuns: snapshotAt('2026-08-17T08:21:01Z') }); + + expect(result.verdict).toBe('red'); + expect(result.failing).toContain('Test (shard 3/4) (failure)'); + }); + + it('names both failing shards once the run has finished', () => { + const result = evaluateGate({ checkRuns: snapshotAt('2026-08-17T08:22:33Z') }); + + expect(result.verdict).toBe('red'); + expect(result.failing.sort()).toEqual(['Test (shard 1/4) (failure)', 'Test (shard 3/4) (failure)']); + // Shards 2 and 4 passed. A gate that waited for "Test" as one name, or for + // whichever shard reported first, would have merged this pull request. + expect(result.failing.join()).not.toContain('shard 2/4'); + expect(result.failing.join()).not.toContain('shard 4/4'); + }); + + it('is green on the same 19 contexts when they all pass', () => { + const result = evaluateGate({ checkRuns: allGreenSnapshot() }); + + expect(result).toEqual({ verdict: 'green', failing: [], pending: [], missing: [] }); + }); + + it('classifies every context the incident produced — no unknown name is ignored', () => { + const classified = new Set([ + ...REQUIRED_CONTEXTS, + ...Object.keys(OPTIONAL_CONTEXTS), + ...Object.keys(NOT_A_GATE), + ]); + + for (const run of INCIDENT_4959) expect(classified).toContain(run.name); + }); +}); + +describe('absence is never green (#3523 in mirror image)', () => { + it('waits for a required context that has not reported at all', () => { + const runs = allGreenSnapshot().filter((run) => run.name !== 'Test (shard 1/4)'); + const result = evaluateGate({ checkRuns: runs }); + + expect(result.verdict).toBe('pending'); + expect(result.missing).toEqual(['Test (shard 1/4)']); + }); + + it('refuses a required context that reported `skipped`', () => { + const runs = allGreenSnapshot().map((run) => + run.name === 'Control Byte Scan' ? { ...run, conclusion: 'skipped' } : run, + ); + const result = evaluateGate({ checkRuns: runs }); + + expect(result.verdict).toBe('red'); + expect(result.failing).toEqual(['Control Byte Scan (skipped)']); + }); + + it('refuses `cancelled` and `timed_out` as well as `failure`', () => { + for (const conclusion of ['cancelled', 'timed_out', 'action_required', 'neutral', 'stale']) { + const runs = allGreenSnapshot().map((run) => (run.name === 'Lint' ? { ...run, conclusion } : run)); + expect(evaluateGate({ checkRuns: runs }).verdict).toBe('red'); + } + }); + + it('reads the newest run per name, so a re-run in flight is not green', () => { + const runs = [ + ...allGreenSnapshot(), + { id: 99_999_999_999, name: 'Type Check', status: 'queued', conclusion: null }, + ]; + + expect(latestByName(runs).get('Type Check')?.status).toBe('queued'); + expect(evaluateGate({ checkRuns: runs }).verdict).toBe('pending'); + }); + + it('holds an empty check-run list at pending, never at green', () => { + const result = evaluateGate({ checkRuns: [] }); + + expect(result.verdict).toBe('pending'); + expect(result.missing).toEqual([...REQUIRED_CONTEXTS]); + }); +}); + +describe('the path-filtered blocking checks (optional bucket)', () => { + it('ignores one that did not report — its trigger filtered it out', () => { + const runs = allGreenSnapshot().filter((run) => run.name !== 'Bundle Analysis'); + + expect(evaluateGate({ checkRuns: runs }).verdict).toBe('green'); + }); + + it('refuses one that reported `failure`', () => { + const runs = allGreenSnapshot().map((run) => + run.name === 'Bundle Analysis' ? { ...run, conclusion: 'failure' } : run, + ); + const result = evaluateGate({ checkRuns: runs }); + + expect(result.verdict).toBe('red'); + expect(result.failing).toEqual(['Bundle Analysis (failure)']); + }); + + it('waits for one that is present but still running', () => { + const runs = allGreenSnapshot().map((run) => + run.name === 'Bundle Analysis' ? { ...run, status: 'in_progress', conclusion: null } : run, + ); + + expect(evaluateGate({ checkRuns: runs }).verdict).toBe('pending'); + }); +}); + +describe('waitForGate: the deadline fails closed', () => { + const fakeClock = () => { + let nowMs = 0; + return { + now: () => nowMs, + sleep: async (ms: number) => { + nowMs += ms; + }, + }; + }; + + it('polls until the verdict is decided, then returns it', async () => { + const clock = fakeClock(); + const timeline = [snapshotAt(MERGED_AT), snapshotAt('2026-08-17T08:18:55Z'), allGreenSnapshot()]; + let call = 0; + + const result = await waitForGate({ + api: { listCheckRuns: async () => timeline[Math.min(call++, timeline.length - 1)] }, + sha: 'deadbeef', + timeoutMs: 60_000, + intervalMs: 1_000, + now: clock.now, + sleep: clock.sleep, + }); + + expect(result.verdict).toBe('green'); + expect(result.polls).toBe(3); + expect(result.timedOut).toBe(false); + }); + + it('stops at the first red without waiting for the rest of the matrix', async () => { + const clock = fakeClock(); + const result = await waitForGate({ + api: { listCheckRuns: async () => snapshotAt('2026-08-17T08:21:01Z') }, + sha: 'deadbeef', + timeoutMs: 60_000, + intervalMs: 1_000, + now: clock.now, + sleep: clock.sleep, + }); + + expect(result.verdict).toBe('red'); + expect(result.polls).toBe(1); + expect(result.failing).toContain('Test (shard 3/4) (failure)'); + }); + + it('turns an undecided gate RED at the deadline — a timeout is not a pass', async () => { + const clock = fakeClock(); + const result = await waitForGate({ + // A context that never reports: the shape a renamed job produces. + api: { listCheckRuns: async () => allGreenSnapshot().filter((r) => r.name !== 'Type Check') }, + sha: 'deadbeef', + timeoutMs: 10_000, + intervalMs: 1_000, + now: clock.now, + sleep: clock.sleep, + }); + + expect(result.verdict).toBe('red'); + expect(result.timedOut).toBe(true); + expect(result.missing).toEqual(['Type Check']); + }); + + it('surfaces an API failure as a throw, so the job fails and nothing merges', async () => { + await expect( + waitForGate({ + api: { + listCheckRuns: async () => { + throw new Error('GET /repos/o/r/commits/sha/check-runs -> HTTP 403'); + }, + }, + sha: 'deadbeef', + }), + ).rejects.toThrow('HTTP 403'); + }); +}); + +describe('main(): a misconfigured deadline fails loudly, not silently', () => { + const greenApi = { listCheckRuns: async () => allGreenSnapshot() }; + const baseEnv = { HEAD_SHA: 'deadbeef', GATE_REPORT_FILE: '/dev/null' }; + + it('rejects a non-numeric timeout instead of polling forever', async () => { + await expect( + main({ api: greenApi, env: { ...baseEnv, GATE_TIMEOUT_SECONDS: '40 minutes' } }), + ).rejects.toThrow('GATE_TIMEOUT_SECONDS must be a positive number of seconds'); + }); + + it('rejects a zero or negative interval', async () => { + await expect( + main({ api: greenApi, env: { ...baseEnv, GATE_INTERVAL_SECONDS: '0' } }), + ).rejects.toThrow('GATE_INTERVAL_SECONDS must be a positive number of seconds'); + }); + + it('refuses to run without being told which SHA it is judging', async () => { + await expect(main({ api: greenApi, env: { GATE_REPORT_FILE: '/dev/null' } })).rejects.toThrow('HEAD_SHA'); + }); +}); + +describe('the refusal is legible', () => { + it('says nothing was merged, and names what refused', () => { + const body = renderVerdict({ + ...evaluateGate({ checkRuns: snapshotAt('2026-08-17T08:22:33Z') }), + sha: '31745d8b6805dc829c05660a9592e92ca537bc3b', + elapsedMs: 540_000, + }); + + expect(body).toContain('NOT merged'); + expect(body).toContain('31745d8'); + expect(body).toContain('Test (shard 1/4) (failure)'); + expect(body).toContain('Test (shard 3/4) (failure)'); + }); + + it('distinguishes a deadline from a failure', () => { + const body = renderVerdict({ + verdict: 'red', + failing: [], + pending: ['Type Check (in_progress)'], + missing: [], + timedOut: true, + sha: 'deadbeef', + }); + + expect(body).toContain('deadline'); + expect(body).toContain('Type Check (in_progress)'); + }); +}); + +// ── The buckets versus the workflows that actually produce the checks ──────── + +type Workflow = { + file: string; + text: string; + /** Lines with comments stripped, so prose mentioning `pull_request:` cannot count. */ + lines: string[]; +}; + +function readWorkflows(): Workflow[] { + return fs + .readdirSync(workflowDir) + .filter((file) => file.endsWith('.yml') || file.endsWith('.yaml')) + .map((file) => { + const text = fs.readFileSync(path.join(workflowDir, file), 'utf8'); + return { file, text, lines: text.split('\n').filter((line) => !/^\s*#/.test(line)) }; + }); +} + +/** The `on:` block of a workflow, comment lines already stripped. */ +function triggerBlock(workflow: Workflow): string[] { + const start = workflow.lines.findIndex((line) => /^on:/.test(line)); + if (start === -1) return []; + const rest = workflow.lines.slice(start + 1); + const end = rest.findIndex((line) => /^[A-Za-z]/.test(line)); + return end === -1 ? rest : rest.slice(0, end); +} + +/** + * Does this workflow subscribe `pull_request`, and does that subscription carry + * a path filter? `pull_request_target` deliberately does not count: + * `cross-repo-issue-closer.yml` uses it with `types: [closed]`, so it acts after + * a merge and has no verdict to contribute to one. + */ +function pullRequestTrigger(workflow: Workflow): { subscribes: boolean; filtered: boolean } { + const block = triggerBlock(workflow); + const start = block.findIndex((line) => /^ {2}pull_request:\s*$/.test(line)); + if (start === -1) return { subscribes: false, filtered: false }; + + const rest = block.slice(start + 1); + const end = rest.findIndex((line) => /^ {2}\S/.test(line)); + const sub = end === -1 ? rest : rest.slice(0, end); + return { subscribes: true, filtered: sub.some((line) => /^ {4}paths(-ignore)?:/.test(line)) }; +} + +/** + * The check names a workflow's jobs appear under. A job's `name:` if it has one, + * else its id (`labeler.yml`'s job is simply `label`), with `matrix.shard` + * expanded. Every job of a subscribing workflow produces a check run on a pull + * request — including one skipped by a job-level `if:`, which reports + * `conclusion=skipped` rather than not existing. + */ +function checkNames(workflow: Workflow): string[] { + const jobsAt = workflow.lines.findIndex((line) => /^jobs:\s*$/.test(line)); + if (jobsAt === -1) return []; + const body = workflow.lines.slice(jobsAt + 1); + + const starts: number[] = []; + body.forEach((line, index) => { + if (/^ {2}[A-Za-z0-9_-]+:\s*$/.test(line)) starts.push(index); + }); + + return starts.flatMap((start, i) => { + const block = body.slice(start, starts[i + 1] ?? body.length); + const id = block[0].trim().replace(/:$/, ''); + const named = block.find((line) => /^ {4}name:/.test(line)); + const name = named ? named.replace(/^ {4}name:\s*/, '').trim() : id; + + const shards = block.find((line) => /^ {8}shard: \[/.test(line)); + if (!shards || !name.includes('matrix.shard')) return [name]; + + return (shards.match(/\[(.*)\]/)?.[1] ?? '') + .split(',') + .map((shard) => shard.trim()) + .filter(Boolean) + .map((shard) => name.replace(/\$\{\{\s*matrix\.shard\s*\}\}/g, shard)); + }); +} + +describe('the declared buckets partition what a pull request actually produces', () => { + const workflows = readWorkflows().filter((workflow) => pullRequestTrigger(workflow).subscribes); + + const produced = new Map(); + for (const workflow of workflows) { + for (const name of checkNames(workflow)) produced.set(name, workflow.file); + } + + it('found the workflows and the shard matrix (the parser still parses)', () => { + expect(workflows.map((w) => w.file)).toContain('ci.yml'); + expect([...produced.keys()]).toEqual( + expect.arrayContaining(['Test (shard 1/4)', 'Test (shard 4/4)', 'Lint', 'dependabot', 'label']), + ); + // No unexpanded template survived the matrix expansion. + for (const name of produced.keys()) expect(name).not.toContain('${{'); + }); + + it('classifies every produced check exactly once', () => { + const buckets = [ + ...REQUIRED_CONTEXTS.map((name) => [name, 'required'] as const), + ...Object.keys(OPTIONAL_CONTEXTS).map((name) => [name, 'optional'] as const), + ...Object.keys(NOT_A_GATE).map((name) => [name, 'not-a-gate'] as const), + ]; + + const seen = new Map(); + const duplicated: string[] = []; + for (const [name, bucket] of buckets) { + if (seen.has(name)) duplicated.push(`${name} (${seen.get(name)} + ${bucket})`); + seen.set(name, bucket); + } + expect(duplicated).toEqual([]); + + const unclassified = [...produced.keys()].filter((name) => !seen.has(name)); + expect( + unclassified, + `these checks run on every pull request but the gate does not classify them: ${unclassified.join(', ')}`, + ).toEqual([]); + + const unproduced = [...seen.keys()].filter((name) => !produced.has(name)); + expect( + unproduced, + `the gate names checks that no pull-request workflow produces (renamed or deleted?): ${unproduced.join(', ')}`, + ).toEqual([]); + }); + + it('requires only contexts whose workflow carries NO path filter', () => { + const filtered = new Map( + readWorkflows().map((workflow) => [workflow.file, pullRequestTrigger(workflow).filtered]), + ); + + for (const name of REQUIRED_CONTEXTS) { + const file = produced.get(name); + expect(file, `${name} is required but nothing produces it`).toBeTruthy(); + expect( + filtered.get(file as string), + `${name} is required, but ${file} filters its pull_request trigger by path — it cannot be waited for on every PR (#3523)`, + ).toBe(false); + } + }); + + it('marks optional exactly those blocking contexts whose workflow IS path-filtered', () => { + for (const name of Object.keys(OPTIONAL_CONTEXTS)) { + const file = produced.get(name); + expect(pullRequestTrigger(readWorkflows().find((w) => w.file === file) as Workflow).filtered).toBe(true); + } + }); + + it('writes down the check-runs-only boundary, so the scope is not inferred from the endpoint', () => { + // A third-party commit STATUS (this repository has one: `Vercel`) is not a + // check run and is not read by the gate. That boundary has to be stated + // rather than left to whoever next wonders why a red status did not block. + const script = fs.readFileSync(path.join(repoRoot, 'scripts', 'dependabot-merge-gate.mjs'), 'utf8'); + + expect(script).toMatch(/commit STATUS/i); + expect(script).toContain('Vercel'); + }); + + it('states a reason for every context it declines to gate on', () => { + for (const [name, reason] of Object.entries({ ...OPTIONAL_CONTEXTS, ...NOT_A_GATE })) { + expect(reason.length, `${name} needs a reason, not just an entry`).toBeGreaterThan(40); + } + }); +}); + +describe('the workflow cannot merge without consulting the gate', () => { + const workflow = fs.readFileSync(path.join(workflowDir, 'dependabot-auto-merge.yml'), 'utf8'); + const steps = workflow.split(/^ {6}- name: /m).slice(1); + + it('has no `gh pr merge` that is not behind a green gate', () => { + const merging = steps.filter((step) => /gh pr merge/.test(step)); + + expect(merging.length).toBeGreaterThan(0); + for (const step of merging) { + expect(step, 'a `gh pr merge` step must be conditioned on the gate verdict').toMatch( + /if:\s*steps\.gate\.outputs\.gate == 'green'/, + ); + } + }); + + it('does not approve before the gate is green either', () => { + for (const step of steps.filter((step) => /gh pr review --approve/.test(step))) { + expect(step).toMatch(/if:\s*steps\.gate\.outputs\.gate == 'green'/); + } + }); + + it('runs the gate script and tells it which SHA to judge', () => { + expect(workflow).toContain('node scripts/dependabot-merge-gate.mjs'); + expect(workflow).toMatch(/HEAD_SHA: \$\{\{ github\.event\.pull_request\.head\.sha }}/); + // Pins the merge to the judged SHA: a push that lands during the wait must + // not be enqueued on the strength of the previous commit's checks. + expect(workflow).toContain('--match-head-commit'); + }); + + it('grants `checks: read`, without which the gate 403s', () => { + expect(workflow).toMatch(/^\s*checks: read$/m); + }); + + it('fails the job when the gate refuses, so the PR carries a red check', () => { + expect(workflow).toMatch(/steps\.gate\.outputs\.gate != 'green'/); + }); + + it('keeps the semver policy the issue did not ask to change', () => { + expect(workflow).toContain('version-update:semver-patch'); + expect(workflow).toContain('version-update:semver-minor'); + expect(workflow).toMatch(/steps\.metadata\.outputs\.update-type == 'version-update:semver-major'/); + }); + + it('keeps the lockfile merge driver `ci-cd-pipeline.md` pins it for', () => { + expect(workflow).toContain('merge.pnpm-merge.driver'); + }); +}); diff --git a/scripts/dependabot-merge-gate.mjs b/scripts/dependabot-merge-gate.mjs new file mode 100644 index 0000000000..e8df0b35cb --- /dev/null +++ b/scripts/dependabot-merge-gate.mjs @@ -0,0 +1,485 @@ +#!/usr/bin/env node +/** + * The merge gate `.github/workflows/dependabot-auto-merge.yml` consults before + * it approves or enqueues a Dependabot pull request (objectui#4973). + * + * ## What went wrong, measured + * + * The workflow used to run, unconditionally, for every semver-patch/minor bump: + * + * gh pr merge --auto --squash "$PR_URL" + * + * `--auto` lands the merge the moment GitHub considers the pull request + * mergeable — that is, the moment the *branch-protection required set* is + * satisfied. It does NOT wait for the checks this repository actually runs. On + * 2026-08-17 that difference was cashed in on #4959 (`lucide-react` 1.29.0 -> + * 1.31.0), whose own head SHA `31745d8b` was red: + * + * 08:07:41Z PR opened + * 08:13:07Z the four `Test (shard N/4)` jobs start + * 08:13:30Z this workflow's own `dependabot` job completes + * 08:13:36Z github-actions[bot] MERGES it into `main` + * 08:18:55Z shard 4/4 reports success + * 08:21:01Z shard 3/4 reports FAILURE (5m25s after the merge) + * 08:21:56Z shard 1/4 reports FAILURE (8m20s after the merge) + * 08:22:33Z `Type Check` reports (8m57s after the merge) + * + * Nine of the nineteen check runs on that SHA were still in flight when the + * merge happened — all four shards, `Type Check`, `Lint`, `Build & E2E`, + * `Build Docs` and `Bundle Analysis`. The four-way test shard matrix is the + * slowest job in the repository BY CONSTRUCTION (it exists to cut a ~9 minute + * wall clock, see `ci.yml`), so it is the check that `--auto` systematically + * outruns. `main` went red for every parallel agent, which is what #4968 then + * had to repair. + * + * The hole is not specific to lockfile ranges: ANY red on a slow job could ride + * through it. Same family as objectui#3523 (an empty merge-queue required set + * let #3503/#3510/#3516 land with `Type Check` at conclusion=failure) and + * #3243. + * + * ## Why this is a script and not a `run:` block + * + * Same reason `scripts/render-budget-comment.mjs` and + * `scripts/shadcn-check-report.mjs` exist: logic that lives in YAML is logic + * nothing ever executes between real firings, and this particular logic decides + * whether something lands on the shared `main`. Here it buys something extra — + * `scripts/__tests__/dependabot-merge-gate.test.ts` replays the REAL #4959 + * check-run timeline through `evaluateGate()` and asserts the counterfactual + * directly: `pending` at 08:13:36Z (so the merge does not happen) and `red` + * once the shards report. A workflow cannot be run locally; this decision can. + * + * ## The contract: declared = enforced, and absence is never green + * + * The gate does NOT ask GitHub "are the required checks green?" — that question + * is answered by the branch-protection required set, which is a + * repository-SETTINGS surface this repository can neither read nor change + * (`content/docs/guide/ci-cd-pipeline.md`, "Merge Queue", step 3), and which is + * demonstrably not carrying the shards today: a merge occurred while all four + * were `in_progress`, so none of them can be in it. + * + * So the set is declared here, and three rules keep the declaration honest: + * + * 1. Every context in `REQUIRED_CONTEXTS` must be PRESENT and `success`. + * Missing counts as not-green, never as green — a renamed or skipped job + * must stall the gate and then fail it, which is the one direction that + * cannot silently let a red PR through. (objectui#3523's lesson stated the + * other way round: a check that never reports does not fail a required-set + * rule, it leaves the PR pending — so a gate that reads "absent = fine" is + * the same bug wearing the fix's clothes.) + * 2. `OPTIONAL_CONTEXTS` are blocking checks whose WORKFLOW carries a + * trigger-level path filter, so they legitimately do not report on every + * pull request. Present -> must be `success`; absent -> not waited for. + * 3. `NOT_A_GATE` names every remaining context a pull request produces, each + * with the reason it cannot gate. `dependabot-merge-gate.test.ts` asserts + * the three buckets partition the produced set EXACTLY — no name produced + * by a `pull_request`-triggered workflow may be unclassified, and no + * classified name may be unproduced. A new job or a renamed one fails that + * test instead of quietly shrinking the gate. + * + * Only `success` is green for a required context. `skipped` is not: every + * required context here belongs to a workflow that reports on every pull + * request by design (the path decision moved INTO the jobs in #3523, so an + * ignored-paths run still reports `success`), which means a `skipped` required + * context is a change in the pipeline, not a pass. + * + * ## What this file deliberately does not do + * + * It never mutates anything — no merge, no approval, no comment. It reads check + * runs and returns a verdict; the workflow performs the two mutations, under an + * `if:` on that verdict, so the whole set of things that can write is visible + * in the YAML. A crash here (403 on `checks: read`, network, bad input) exits + * non-zero and merges nothing, which is the correct direction to fail. + * + * ## The boundary of the declared set: check RUNS, not commit STATUSES + * + * Everything above is about check runs — what GitHub Actions jobs produce. + * Third-party apps can also attach a legacy commit STATUS to the same SHA, and + * this repository has one: `Vercel`, which on a pull request reports + * `success` / "Canceled by Ignored Build Step" for changes its ignored-build + * step filters out. The gate does not read statuses, so a red Vercel status + * would not stop a Dependabot merge. + * + * That is a stated boundary, not an oversight, and it is not a regression: the + * unconditional `--auto` this replaced waited for nothing at all. It is left + * outside deliberately — requiring a preview deployment from a third-party + * service would let an unrelated outage block every dependency bump, and + * whether that trade is worth making is a maintainer's call, not a hot fix's. + * It is written down here so the next reader does not have to infer the scope + * from the endpoint being called. + */ + +import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +/** + * Blocking checks whose workflow subscribes `pull_request` with NO trigger-level + * path filter, so every pull request produces every one of them. Source of each + * name, in the checks list rather than the file name: + * + * ci.yml Changeset Fixed Group Check, Type Check, + * Test (shard 1..4/4), Build & E2E, Build Docs + * lint.yml Lint + * control-bytes.yml Control Byte Scan + * docs-links.yml Internal Docs Link Check + * skills-paths.yml Skill Guide Path Check + * changeset-presence.yml Changeset Declaration + * doc-component-types.yml Doc Component Type Check + * + * The four shards are spelled out individually on purpose. A single `Test` + * entry, or any pattern match, would be satisfied by whichever shard happened + * to exist — and #4959 failed on shards 1 and 3 while 2 and 4 passed. + */ +export const REQUIRED_CONTEXTS = Object.freeze([ + 'Changeset Fixed Group Check', + 'Type Check', + 'Test (shard 1/4)', + 'Test (shard 2/4)', + 'Test (shard 3/4)', + 'Test (shard 4/4)', + 'Build & E2E', + 'Build Docs', + 'Lint', + 'Control Byte Scan', + 'Internal Docs Link Check', + 'Skill Guide Path Check', + 'Changeset Declaration', + 'Doc Component Type Check', +]); + +/** + * Blocking checks that a pull request may legitimately not produce, because + * their workflow filters at the trigger. Waiting for one of these to appear + * would hang the gate until its deadline on a pull request that was never going + * to start it; requiring nothing of them when they DO run would let a real red + * through. So: present -> must be `success`, absent -> ignored. + * + * They are evaluated on the same poll as the required set, i.e. only once every + * required context has already reported. By then the whole check-run list for + * the SHA has existed for minutes (on #4959 all nineteen runs were created + * within three seconds of each other), so "absent" here means "the path filter + * kept it out", not "it has not been created yet". + */ +export const OPTIONAL_CONTEXTS = Object.freeze({ + 'Bundle Analysis': + "performance-budget.yml filters on paths: packages/**, apps/console/**, pnpm-lock.yaml. Blocking when it runs (console gzip budget); absent on a PR that touches none of them.", + 'Changeset Bump Policy': + 'changeset-guard.yml filters on paths: .changeset/**. A Dependabot PR carries no changeset, so it normally does not report at all.', +}); + +/** + * Everything else a pull request to `main` produces, and why it cannot gate. + * Kept as a named list rather than an implicit "anything not required is + * ignored", so that a new check has to be classified by a human instead of + * defaulting into silence. + */ +export const NOT_A_GATE = Object.freeze({ + dependabot: + 'This workflow itself. The gate runs inside this job, so requiring it would deadlock at its own deadline.', + 'Test (coverage)': + "ci.yml's push lane (`if: github.event_name == 'push'`), so on a pull request it reports conclusion=skipped by design. The PR lane is the four shards above.", + 'Live E2E (informational)': + 'live-e2e.yml is declared INFORMATIONAL and NON-REQUIRED in its own header and runs `continue-on-error: true`; ci-cd-pipeline.md says in as many words not to add it to required checks.', + label: + 'labeler.yml applies labels. It is a mutation, not a verdict — nothing about the change is judged by it.', +}); + +/** + * GitHub keeps every check run for a SHA, including superseded re-runs. The + * verdict must come from the newest run per name: an old `success` next to a + * fresh `queued` re-run means the answer is "wait", not "green". + * + * @param {Array<{ name: string, id?: number, status?: string, conclusion?: string|null }>} checkRuns + * @returns {Map} + */ +export function latestByName(checkRuns = []) { + /** @type {Map} */ + const latest = new Map(); + for (const run of checkRuns) { + if (!run?.name) continue; + const seen = latest.get(run.name); + if (!seen || Number(run.id ?? 0) >= Number(seen.id ?? 0)) latest.set(run.name, run); + } + return latest; +} + +/** A required context is green only when it has completed with `success`. */ +function classify(run) { + if (!run) return 'missing'; + if (run.status !== 'completed') return 'pending'; + return run.conclusion === 'success' ? 'green' : 'not-green'; +} + +/** + * One verdict over one snapshot of a SHA's check runs. + * + * `red` wins over `pending`: once a required context has failed, the remaining + * shards can only confirm it, and continuing to wait would just delay a report + * the author already needs. + * + * @param {{ checkRuns?: Array, required?: readonly string[], optional?: readonly string[] }} input + * @returns {{ verdict: 'green'|'red'|'pending', failing: string[], pending: string[], missing: string[] }} + */ +export function evaluateGate({ + checkRuns = [], + required = REQUIRED_CONTEXTS, + optional = Object.keys(OPTIONAL_CONTEXTS), +} = {}) { + const latest = latestByName(checkRuns); + /** @type {string[]} */ const failing = []; + /** @type {string[]} */ const pending = []; + /** @type {string[]} */ const missing = []; + + for (const name of required) { + const run = latest.get(name); + switch (classify(run)) { + case 'missing': + missing.push(name); + break; + case 'pending': + pending.push(`${name} (${run.status})`); + break; + case 'not-green': + failing.push(`${name} (${run.conclusion ?? 'no conclusion'})`); + break; + default: + break; + } + } + + for (const name of optional) { + const run = latest.get(name); + // Absent, or skipped: the path filter decided this pull request is not one + // this check judges. Nothing to wait for and nothing to hold against it. + if (!run || (run.status === 'completed' && run.conclusion === 'skipped')) continue; + if (run.status !== 'completed') pending.push(`${name} (${run.status})`); + else if (run.conclusion !== 'success') failing.push(`${name} (${run.conclusion ?? 'no conclusion'})`); + } + + const verdict = failing.length > 0 ? 'red' : pending.length + missing.length > 0 ? 'pending' : 'green'; + return { verdict, failing, pending, missing }; +} + +/** + * The Checks REST surface the gate needs, injectable so tests never reach the + * network. Needs `checks: read`; a 403 here throws, which fails the job and + * merges nothing. + */ +export function createChecksApi({ + token = process.env.GITHUB_TOKEN ?? '', + apiUrl = process.env.GITHUB_API_URL ?? 'https://api.github.com', + repository = process.env.GITHUB_REPOSITORY ?? '', + fetchImpl = globalThis.fetch, +} = {}) { + if (!repository) throw new Error('GITHUB_REPOSITORY is not set'); + + const get = async (route) => { + const res = await fetchImpl(`${apiUrl}${route}`, { + headers: { + accept: 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + }); + if (!res.ok) throw new Error(`GET ${route} -> HTTP ${res.status}`); + return res.json(); + }; + + return { + /** + * Every check run on one SHA. `filter=latest` is GitHub's default and is + * spelled out so a future default change cannot start feeding the gate + * stale runs; `latestByName()` de-duplicates again on our side because the + * endpoint's notion of "latest" is per check SUITE, not per name. + */ + listCheckRuns: async (sha) => { + /** @type {Array} */ const all = []; + for (let page = 1; page <= 10; page += 1) { + const body = await get(`/repos/${repository}/commits/${sha}/check-runs?filter=latest&per_page=100&page=${page}`); + const runs = body?.check_runs ?? []; + all.push(...runs); + if (runs.length < 100) break; + } + return all; + }, + }; +} + +/** + * Poll until the verdict is decided, or until the deadline turns an undecided + * gate into a red one. A timeout is a failure, never a pass: the reason a + * context is still missing at the deadline (renamed job, cancelled workflow, + * a filter that stopped it reporting) is exactly the kind of thing that must + * reach a human instead of a merge. + * + * @param {{ api: { listCheckRuns: (sha: string) => Promise> }, sha: string, + * timeoutMs?: number, intervalMs?: number, + * now?: () => number, sleep?: (ms: number) => Promise, log?: (msg: string) => void }} input + */ +export async function waitForGate({ + api, + sha, + timeoutMs = 40 * 60 * 1000, + intervalMs = 20 * 1000, + now = () => Date.now(), + sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + log = () => {}, +}) { + if (!sha) throw new Error('waitForGate needs the head SHA the verdict is about'); + const startedAt = now(); + let polls = 0; + + for (;;) { + const checkRuns = await api.listCheckRuns(sha); + const result = evaluateGate({ checkRuns }); + polls += 1; + const elapsedMs = now() - startedAt; + + if (result.verdict !== 'pending') return { ...result, polls, elapsedMs, timedOut: false, sha }; + + if (elapsedMs >= timeoutMs) { + return { + ...result, + verdict: /** @type {'red'} */ ('red'), + polls, + elapsedMs, + timedOut: true, + sha, + }; + } + + log( + `waiting: ${result.pending.length} in flight, ${result.missing.length} not reported yet ` + + `(${Math.round(elapsedMs / 1000)}s of ${Math.round(timeoutMs / 1000)}s)`, + ); + await sleep(intervalMs); + } +} + +/** + * The visible artefact of a non-green gate: what is red, what never reported, + * and the fact that nothing was merged. Rendered for both the step summary and + * the pull-request comment so the outcome is legible without opening the run. + * + * @param {{ verdict: string, failing: string[], pending: string[], missing: string[], + * timedOut?: boolean, sha?: string, runUrl?: string, elapsedMs?: number }} result + */ +export function renderVerdict(result) { + const lines = []; + const sha7 = (result.sha ?? '').slice(0, 7); + + if (result.verdict === 'green') { + lines.push(`### Dependabot merge gate: green (\`${sha7}\`)`); + lines.push(''); + lines.push(`All ${REQUIRED_CONTEXTS.length} required contexts reported \`success\` on this head SHA.`); + } else { + lines.push(`### Dependabot merge gate: NOT merged (\`${sha7}\`)`); + lines.push(''); + lines.push( + result.timedOut + ? 'The gate hit its deadline with contexts still undecided. A context that has not reported is **not** treated as green (objectui#4973), so nothing was merged.' + : 'A required context is not green, so auto-merge was **not** enabled and this pull request was **not** approved (objectui#4973).', + ); + } + + if (result.failing?.length) { + lines.push(''); + lines.push('**Failing:**'); + for (const name of result.failing) lines.push(`- ${name}`); + } + if (result.pending?.length) { + lines.push(''); + lines.push('**Still running at the deadline:**'); + for (const name of result.pending) lines.push(`- ${name}`); + } + if (result.missing?.length) { + lines.push(''); + lines.push('**Never reported:**'); + for (const name of result.missing) lines.push(`- ${name}`); + } + if (typeof result.elapsedMs === 'number') { + lines.push(''); + lines.push(`Waited ${Math.round(result.elapsedMs / 1000)}s.`); + } + if (result.runUrl) { + lines.push(''); + lines.push(`[Gate run](${result.runUrl})`); + } + return `${lines.join('\n')}\n`; +} + +/** + * A duration from the environment, in seconds, or a loud failure. + * + * `Number('40 minutes')` is `NaN`, and `elapsedMs >= NaN` is false forever — a + * typo in the workflow would turn the deadline off and leave the gate polling + * until the runner killed the job. That direction is still fail-closed (nothing + * merges) but it burns 50 minutes of a runner and reports as a timeout rather + * than as the misconfiguration it is. So a bad value throws here instead. + * + * @param {string|undefined} raw + * @param {number} fallbackSeconds + * @param {string} name + */ +function readDuration(raw, fallbackSeconds, name) { + const seconds = raw === undefined || raw === '' ? fallbackSeconds : Number(raw); + if (!Number.isFinite(seconds) || seconds <= 0) { + throw new Error(`${name} must be a positive number of seconds, got ${JSON.stringify(raw)}`); + } + return seconds * 1000; +} + +function appendTo(envVar, text) { + const target = process.env[envVar]; + if (!target) return; + fs.appendFileSync(target, `${text}\n`); +} + +/** + * The workflow's entry point. `api` is injectable for the tests; `env` is split + * out for the same reason. Typed explicitly because a binding with no default in + * a destructured JS parameter is dropped from the inferred signature under + * `strict`, which would make the injected `api` a type error at the call site + * (`tsconfig.scripts.json` compiles the pin tests). + * + * @param {{ api?: { listCheckRuns: (sha: string) => Promise> }, + * env?: Record }} [input] + */ +export async function main({ api, env = process.env } = {}) { + const sha = env.HEAD_SHA ?? ''; + if (!sha) throw new Error('HEAD_SHA is not set — the gate must be told which commit it is judging'); + + const timeoutMs = readDuration(env.GATE_TIMEOUT_SECONDS, 2400, 'GATE_TIMEOUT_SECONDS'); + const intervalMs = readDuration(env.GATE_INTERVAL_SECONDS, 20, 'GATE_INTERVAL_SECONDS'); + const reportFile = env.GATE_REPORT_FILE ?? 'dependabot-merge-gate.md'; + const runUrl = + env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID + ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` + : ''; + + const result = await waitForGate({ + api: api ?? createChecksApi(), + sha, + timeoutMs, + intervalMs, + log: (msg) => console.log(msg), + }); + + const body = renderVerdict({ ...result, runUrl }); + fs.writeFileSync(reportFile, body); + appendTo('GITHUB_STEP_SUMMARY', body); + appendTo('GITHUB_OUTPUT', [`gate=${result.verdict}`, `gated_sha=${sha}`, `timed_out=${result.timedOut}`].join('\n')); + + if (result.verdict === 'green') { + console.log(`::notice::Dependabot merge gate green on ${sha} after ${Math.round(result.elapsedMs / 1000)}s.`); + } else { + const detail = [...result.failing, ...result.missing.map((n) => `${n} (never reported)`)].join(', '); + console.log(`::error::Dependabot merge gate refused ${sha}: ${detail || 'undecided at deadline'}. Nothing merged.`); + } + + return result; +} + +const invokedDirectly = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url; +if (invokedDirectly) { + await main(); +}