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
127 changes: 127 additions & 0 deletions .github/workflows/governed-surface-guard.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
# The machine half of the governed-surface no-bypass rule (objectui#6596,
# maintainer ruling 2026-08-27 accepting Option C on #6325). The rule, the event
# split, the approval predicate and the exit contract all live in
# `scripts/check-governed-queue-guard.mjs`; that header is authoritative and this
# file is the invocation. Only the wiring decisions are argued here.
#
# What it is for, in one measurement: PR #6183 touched `AGENTS.md`, was correctly
# parked as a draft, and a GitHub MCP `update_pull_request` call passing only
# `reviewers` silently set `draft: false`. The pull request entered the merge
# queue and landed as `5b3290fd5` with no human approval; converting it back to a
# draft did NOT dequeue it. Nothing in this repository could have refused that —
# the whole defence was seat discipline, and the failure contained no seat
# decision at all.
name: Governed Surface Guard

on:
# BOTH legs are load-bearing and they mean DIFFERENT things — see the script
# header. `merge_group` is the leg that REFUSES: the queue build is the last
# thing between a speculative merge and `main`, and it is the path #6183 took.
# `pull_request` is an EARLY WARNING that deliberately exits 0, because a
# governed PR held as a draft for the maintainer to merge by hand is this
# regime's healthy end state, and a check that reddens on the healthy case is
# a permanently red check nobody reads.
pull_request:
# `develop` is included for the same reason every other requirable gate here
# names both: a context that does not report on a branch it could be
# required on leaves the pull request pending rather than failing it.
branches: [main, develop]
# ⚠️ Naming `types:` REPLACES GitHub's default set rather than extending it,
# so all three defaults are restated. `ready_for_review` is the addition and
# it is the point: flipping a governed draft to ready is the first move of
# the exact sequence this guard exists to interrupt, and it is not in the
# default set — without it the warning would not re-fire at the one moment a
# seat most needs to read it. `scripts/check-governed-queue-guard.mjs
# --self-test` pins this line.
types: [opened, synchronize, reopened, ready_for_review]
# Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note
# and the measurements behind it). This is the leg that refuses, and a
# requirable context that does not report on a queue build stalls the queue
# until the ruleset's 60-minute status-check timeout. `types:` is named
# although `checks_requested` is currently the only one GitHub defines.
merge_group:
types: [checks_requested]

# ⛔ NO `paths:` filter, on either leg, and this is not an oversight. A skipped
# job counts as SUCCESS in branch protection, so a path filter would hand the
# queue a green "Governed Surface Queue Guard" for a pull request the filter
# mis-scoped — on the one check whose entire job is to refuse. The path test
# belongs INSIDE the script, where "nothing governed" is a verdict that says so
# and costs zero API calls. (`merge_group` supports no path filter at all, so a
# filter would also make the two legs disagree about what they cover.) The
# script's `--self-test` fails if one is ever added; the same conclusion
# `control-bytes.yml` and `changeset-presence.yml` reached for themselves.

concurrency:
group: governed-surface-guard-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

# The default read scopes and nothing beyond them. `pull-requests: read` is what
# the review and head reads need; there is no write scope here, and adding one
# to let this check "fix" anything would be a widening no ruling covers.
permissions:
contents: read
pull-requests: read

jobs:
governed-surface-guard:
# ⚠️ THIS LITERAL IS THE CHECK-RUN NAME branch protection would pin, and it
# is duplicated in `CHECK_CONTEXT_NAME` in the script — deliberately, and
# pinned in both directions: the script's `--self-test` reads THIS FILE and
# fails if the two ever disagree. Renaming a job silently detaches a
# required context, and a name that lives in exactly one place is a name
# nothing can pin. It is also registered in `REQUIRED_CONTEXTS` in
# `scripts/dependabot-merge-gate.mjs`, which is this repository's own
# written-down answer to "which checks are blocking".
#
# ⛔ Making it a REQUIRED context in the live branch-protection ruleset is
# not this pull request's step and cannot be: that is a repository-settings
# surface only the maintainer can change. Until it is flipped, this job
# REPORTS on a queue build without stopping it.
name: Governed Surface Queue Guard
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Checkout code
uses: actions/checkout@v7
with:
# A governed-surface question answered over a truncated history
# answers with SILENCE, and silence reads as compliance. A merge
# group's base sha can predate a shallow fetch's floor, so the whole
# history is the only depth that cannot under-report. The script
# refuses outright (exit 1) if either sha is missing, rather than
# diffing what it happens to have.
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22.x'

# The self-test runs FIRST and in its own step, because it is the
# precondition for trusting the run after it: this guard's predicates
# decide whether a merge lands, so a rotted predicate must redden here
# rather than quietly wave a governed diff through. It also holds the
# wiring pins — the job name above, the triggers, `fetch-depth: 0`, the
# absent path filter, and the `REQUIRED_CONTEXTS` registration.
#
# No install and no build: the script imports node builtins and one local
# module (`scripts/invoked-as.mjs`) only, which is also what keeps it
# clear of `scripts/check-pre-install-import-graph.mjs`.
- name: Guard predicate self-test
run: node scripts/check-governed-queue-guard.mjs --self-test

# The live judgment. Everything it reads arrives through `env:` or the
# event payload on disk — no `${{ }}` interpolation into the shell line,
# so no pull-request-controlled text ever becomes part of a command.
#
# GITHUB_TOKEN is the workflow's own default token at the read scopes
# declared above; it is what makes the review read possible at all, and an
# unreadable review list is a REFUSAL with its own exit code, never a
# pass. ⛔ Fail-open is wrong in this file specifically — it exists
# because every other layer in this chain failed open.
- name: Governed surfaces may not enter the merge queue unreviewed
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/check-governed-queue-guard.mjs
55 changes: 55 additions & 0 deletions content/docs/guide/ci-cd-pipeline.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,7 @@ one has its own section below.
| `shell-escape-residue.yml` | Shell Escape Residue Scan | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a fenced block in `AGENTS.md`, `CLAUDE.md`, `skills/**` or `content/docs/**` carries the enumerated machine-produced shell escape, or a scan root fails to resolve |
| `readme-exports.yml` | README Export Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `packages/**/README.md` imports a name from its own package that the package does not export, or the scan's population collapses |
| `docs-route-eager-closure.yml` | Docs Route Eager Closure Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a package named in `apps/site/app/components/registerCatalogBlocks.ts` is not already reachable from the docs route's module graph (exit 1), or when the gate's own gauge cannot be trusted (exit 2) |
| `governed-surface-guard.yml` | Governed Surface Queue Guard | PR to `main`, `develop` (incl. `ready_for_review`) — **no path filter**; merge-queue builds | **Yes on a queue build only** — a governed-surface diff with no authorized approval pinned to the PR's current head is refused there; on the pull request itself it is deliberately green and prints an early warning |
| `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 |
Expand DownExpand Up@@ -1053,6 +1054,60 @@ record it in `MEASURED_PAYLOAD` with what it is for. Run it locally with
`pnpm check:docs-route-closure`; a green run prints the full classification, including which file
each *free* package is already imported by.

## Governed Surface Guard (`governed-surface-guard.yml`)

**Trigger:** Pull request to `main` / `develop` (`opened`, `synchronize`, `reopened`,
`ready_for_review`) and merge-queue builds — **no path filter** on either leg.
**Appears as:** **Governed Surface Queue Guard**.
**Blocks a PR?** Not on the pull request, by design. On a merge-queue build it refuses.

The **governed surface** is a fixed list — `AGENTS.md`, `CLAUDE.md`, `.claude/**`, `skills/**`,
`docs/adr/**` — and the rule about it is that a change to any of them is merged by a human, not by
the queue. That rule used to live only in prose. On
[#6183](https://github.com/objectstack-ai/objectui/pull/6183) an `AGENTS.md` change was correctly
parked as a draft; a GitHub MCP `update_pull_request` call passing only `reviewers` silently also
set `draft: false`; the pull request entered the merge queue and landed with no human approval, and
converting it back to a draft did not dequeue it. Nothing in CI could have refused that. This
workflow is the refusal ([#6596](https://github.com/objectstack-ai/objectui/issues/6596)).

**The two legs mean different things, and that is the whole design.** On a **pull request** the
check is deliberately green whatever it finds, and prints an early warning naming the governed paths
and the sequence not to start. A governed pull request sitting as a draft for the maintainer to
merge by hand is the *healthy* end state, so a check that reddened on it would be red on the healthy
case forever — and a permanently red check is one everybody learns to ignore. On a **merge-queue
build** the same finding is a refusal: that is a state a governed pull request should never be in at
all, so red there is red on the anomaly.

**What clears the queue leg** is an `APPROVED` review by an account in `GOVERNED_APPROVERS` whose
`commit_id` equals the pull request's *current* head sha. The sha pin is what makes the approval an
approval *of something*: a push after the approval goes stale and reopens the refusal, so a
clearance cannot outlive the bytes it was given for. Dismissed and superseded approvals never count.
The remedy the refusal prints **first** is not approval at all — convert the pull request back to a
draft and leave the merge to the maintainer.

**What it costs when nothing is governed:** nothing. The path test runs before any request is
constructed, so an ordinary pull request produces a `CLEAR` verdict and **zero** GitHub API calls;
an API outage cannot block a diff that touches no governed path. The mirrored requirement is that an
API error on a diff that *is* governed is a refusal with its own exit code (4, distinct from 3 for
"nobody approved"), never a pass — this gate exists because every other layer in the chain failed
open.

**What it deliberately does not do.** It does not govern its own workflow or CI configuration
generally: that would be a larger rule than the one that was ruled. It cannot stop a maintainer
merging a governed pull request by hand, and does not try — under this regime the human merge *is*
the review record. And it does not make itself required: that is a branch-protection setting only
the maintainer can flip. Until it is flipped, the queue leg reports without stopping anything. What
this repository can write down, and has, is `REQUIRED_CONTEXTS` in `scripts/dependabot-merge-gate.mjs`.

**If it fails:** read the verdict — it names every governed path that matched, the pull request each
belongs to, and the two ways out. To ask the same question about a file list before pushing, run
`pnpm governed -- AGENTS.md packages/core/src/index.ts` (or
`node scripts/check-governed-queue-guard.mjs --test <paths>`); it exits 0 when nothing is governed.
The predicates are covered by `node scripts/check-governed-queue-guard.mjs --self-test`, which the
workflow runs as its own first step because a rotted predicate must redden rather than wave a
governed diff through, and the wiring is pinned by
`scripts/__tests__/check-governed-queue-guard.test.ts`.

## Link Checking (`check-links.yml`)

**Trigger:** Weekly cron (`17 4 * * 0` — Sundays, off the top of the hour, when the scheduled-run
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -59,6 +59,8 @@
"check:docs-route-closure": "node scripts/check-docs-route-eager-closure.mjs",
"check:entry-guard": "node scripts/check-entry-guard.mjs",
"check:upstream-port-parity": "node scripts/check-upstream-port-parity.mjs",
"check:governed-queue-guard": "node scripts/check-governed-queue-guard.mjs --self-test",
"governed": "node scripts/check-governed-queue-guard.mjs --test",
"check:pre-install-import-graph": "node scripts/check-pre-install-import-graph.mjs",
"check:vi-mock-specifiers": "node scripts/check-vi-mock-specifiers.mjs",
"check:shell-escape-residue": "node scripts/check-shell-escape-residue.mjs",
Expand Down
Loading
Loading