Skip to content

fix(drift): enforce real green-gate before auto-merge (pass>0, required contexts, no false-green)#306

Merged
jpr5 merged 2 commits into
mainfrom
harden/drift-merge-gate
Jul 17, 2026
Merged

fix(drift): enforce real green-gate before auto-merge (pass>0, required contexts, no false-green)#306
jpr5 merged 2 commits into
mainfrom
harden/drift-merge-gate

Conversation

@jpr5

@jpr5 jpr5 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

What & why

The in-workflow auto-merge "green gate" in fix-drift.yml did not actually enforce green. It counted gh pr checks | wc -l rows (a "no checks reported" message is itself a row) and relied on gh pr checks --watch --fail-fast, which exits 0 on empty / pending / skipped / neutral. That is a false-green: an unverified drift PR could auto-merge to main. This fix-forward replaces the gate with a real, testable assertion.

Findings addressed

  1. CRITICAL — false-green gate. Replaced row-count + --watch-exit-0 with a machine-readable assertion: poll on gh pr checks --json length for registration, --watch under a hard timeout, then re-query JSON and require pass>=1 AND zero in {pending, fail, cancel}. neutral/skipped do not count toward pass.
  2. CRITICAL — required contexts present. Gate asserts the expected required contexts (prettier, eslint, exports, commitlint, test (20/22/24), agui-schema-drift, drift-live-pr, zizmor — derived from the real check set on drift PR Fix Drift: authenticate via copilotkit-devops-bot app token #305) are present and concluded green, so one unrelated fast check can't satisfy the gate and a registration race can't slip an incomplete set through.
  3. HIGH — wrong-PR risk. Select the PR by head-SHA match (headRefOid == git rev-parse HEAD), not gh pr list ... .[0]. Added set -euo pipefail to the multi-command run blocks.
  4. HIGH — checks:read. Added checks: read + statuses: read to the job permissions: and to the minted app-token permissions.
  5. HIGH — masked autofix failure. continue-on-error: true autofix that fails now triggers a distinct "autofix STEP failed" Slack alert and fails the job (steps.autofix.outcome != 'success').
  6. HIGH — exit-5 quarantine silent skip. Collector exit 5 now sets a quarantine=true output and routes to a distinct "drift quarantine — needs human" Slack alert instead of the silent skip path.
  7. MED — Slack correctness/coverage. Success message says "merged to main" only when the merge command returned 0 (merged=true output); otherwise "PR open, not yet merged". Missing SLACK_WEBHOOK on the failure path is now a visible ::error:: annotation, not a silent exit 0. Dropped curl -s for curl -fsS --show-error.
  8. MED — watch timeout. --watch wrapped in timeout 1200; the failure Slack distinguishes timeout / not-green / no-checks reasons.

Design

The gate decision is factored into a standalone, unit-testable script scripts/ci-merge-gate.sh that reads the gh pr checks --json name,state,bucket array (file arg or stdin) and exits 0 only on true-green. The workflow calls this script as the authoritative decision (the --watch exit status is advisory only). Required contexts default to the drift-PR set and are overridable via REQUIRED_CONTEXTS.

Red-green proof

The gate is exercised by src/__tests__/ci-merge-gate.test.ts (13 tests) plus a direct shell comparison.

RED — OLD workflow logic (row-count + --watch) treats false-green as mergeable

[a-empty]          OLD gate => WOULD MERGE (rows=1, watch exit 0)  <-- FALSE GREEN
[b-pending]        OLD gate => WOULD MERGE (rows=1, watch exit 0)  <-- FALSE GREEN
[c-skipped]        OLD gate => WOULD MERGE (rows=2, watch exit 0)  <-- FALSE GREEN
[d-unrelated-pass] OLD gate => WOULD MERGE (rows=1, watch exit 0)  <-- FALSE GREEN

GREEN — NEW gate (scripts/ci-merge-gate.sh)

[a-empty]          NEW gate => REFUSE (exit 1)  <-- correctly blocked
[b-pending]        NEW gate => REFUSE (exit 1)  <-- correctly blocked
[c-skipped]        NEW gate => REFUSE (exit 1)  <-- correctly blocked
[d-unrelated-pass] NEW gate => REFUSE (exit 1)  <-- correctly blocked
[e-all-green]      NEW gate => ACCEPT (exit 0)

vitest

 ✓ src/__tests__/ci-merge-gate.test.ts (13 tests)
   ci-merge-gate.sh — RED: old logic treats false-green as mergeable
     ✓ (a) empty/no-checks — OLD gate would MERGE
     ✓ (b) pending-only — OLD gate would MERGE
     ✓ (c) skipped/neutral-only — OLD gate would MERGE
     ✓ (d) one unrelated pass, required missing — OLD gate would MERGE
   ci-merge-gate.sh — GREEN: new gate refuses false-green, accepts true-green
     ✓ (a) empty/no-checks — REFUSES (exit 1)
     ✓ (b) pending-only — REFUSES (exit 1)
     ✓ (c) skipped/neutral-only — REFUSES (exit 1) and does not count skips as pass
     ✓ (d) one unrelated pass with required missing — REFUSES (exit 1)
     ✓ (e) all-required-green (extras skipped) — ACCEPTS (exit 0)
     ✓ fails when a required context is in the fail bucket
     ✓ fails when a required context is still pending even if others pass
     ✓ honors REQUIRED_CONTEXTS override (comma-separated)
     ✓ derives bucket from raw state when bucket field is absent
 Test Files  1 passed (1)
      Tests  13 passed (13)

Local verification

Scope

Diff touches only: .github/workflows/fix-drift.yml, scripts/ci-merge-gate.sh, src/__tests__/ci-merge-gate.test.ts. No ruleset, secret, or GitHub App / bypass-config changes.

Opened as DRAFT — do not merge; orchestrator gates the merge.

@pkg-pr-new

pkg-pr-new Bot commented Jul 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@copilotkit/aimock@306

commit: 0479f9a

Replace the row-count and --watch based false-green with a jq-based gate
that asserts pass>=1 and zero pending, failing, cancelled, or unknown
checks, and that every required context is present and green.

The gate is fail-closed by construction: an explicit jq preflight plus
integer assertions, with no `:-0` defaulting that could mask a missing
or malformed field. A non-string field guard rejects unexpected JSON
shapes rather than silently treating them as green.

Pin the evaluated commit with HEAD_SHA and pass --match-head-commit so
the gate cannot pass against a stale or moved head. Emit distinct Slack
reasons per failure mode so operators can tell why a merge was blocked.

Add a full red-green regression lock in ci-merge-gate.test.ts covering
each false-green hole the previous gate allowed through.
@jpr5
jpr5 force-pushed the harden/drift-merge-gate branch from f865f05 to 8d9a425 Compare July 16, 2026 23:57
…lert gaps

Structural: replace the ~9 scattered VAR=$(jq) scoring calls with ONE guarded
jq verdict computation. set -e does not guard a command-substitution RHS, so a
jq crash in any of them yielded empty — and for UNACCEPTED_CHECKS/MISSING_REQUIRED
that empty read as the green signal. Now the whole pass/fail decision + reasons +
scalars are computed inside one jq program emitting a single verdict object, and
the shell asserts once (jq rc==0, non-empty valid JSON, boolean .green) before
reading any field. Any jq/parse failure -> exit 2, never green. No bare VAR=$(jq)
whose emptiness can read as green remains.

Observability (fix-drift.yml): quarantine alert now emits a visible ::error:: and
FAILS when SLACK_WEBHOOK is missing (was silent exit 0); a Step-1 collector crash
gets its own distinct alert/reason instead of the misleading auto-fix STEP failed;
a gh pr merge --match-head-commit failure sets a specific merge-command-failed
reason.

Tests: tightened loose refusal-reason regexes to the specific reason; corrected
the factually-wrong jq-crash-exit-5 rationale (the field-type guard actually
closes a false-GREEN via the state fallback) and added an isolated proof of that
vector; locked the [1,2,3] object-guard branch distinctly from the field-type
guard; exercised the derived-state legs individually; added consolidated-verdict
jq-failure/empty-verdict/non-boolean-green guards and a structural invariant test.
@jpr5
jpr5 marked this pull request as ready for review July 17, 2026 00:25
@jpr5
jpr5 merged commit b633111 into main Jul 17, 2026
29 checks passed
@jpr5
jpr5 deleted the harden/drift-merge-gate branch July 17, 2026 00:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant