Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/dependabot-automerge-gate-4973.md
Original file line numberDiff line numberDiff line change
@@ -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`).
132 changes: 129 additions & 3 deletions .github/workflows/dependabot-auto-merge.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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: |
Expand All@@ -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
Expand Down
46 changes: 43 additions & 3 deletions content/docs/guide/ci-cd-pipeline.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |
Expand DownExpand Up@@ -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.
Expand Down
Loading
Loading