Fix Drift: authenticate via copilotkit-devops-bot app token#305
Merged
Conversation
GITHUB_TOKEN-created PRs don't trigger downstream CI and github-actions[bot] can't satisfy main's approval rule, so drift PRs couldn't get real CI or auto-merge. Mint a devops-bot (app-id 1108748) installation token via actions/create-github-app-token and use it for the push, PR create, and merge. App-token PRs trigger pull_request/checks, and devops-bot is a scoped Integration bypass actor on main's ruleset. Because a ruleset bypass actor bypasses the ENTIRE ruleset (no required-checks rule holds the bot's PR), enforce CI-green in the workflow: wait for at least one check to register, watch checks with --fail-fast, and only then merge.
commit: |
Adds permission-contents and permission-pull-requests inputs to the create-github-app-token step to limit the minted token's scope (fixes zizmor github-app finding). Also converts single-quoted app-id value to double quotes to satisfy prettier.
jpr5
added a commit
that referenced
this pull request
Jul 17, 2026
…ed contexts, no false-green) (#306) ## 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 #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 - `actionlint .github/workflows/fix-drift.yml` — clean - `shellcheck scripts/ci-merge-gate.sh` — clean - `pnpm format:check` — clean - `pnpm lint` — clean - gate re-validated against real PR #305 JSON (all-required-green) => exit 0 ## 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.**
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The Fix Drift workflow was using
GITHUB_TOKENfor every authenticated operation (git push, PR create, auto-merge). This has two critical consequences:GITHUB_TOKENdon't triggerpull_requestevents — GitHub suppresses the event to prevent recursive workflows, so drift fix PRs never get CI checks.github-actions[bot]can't satisfy main's approval rule — the bot identity is not a permitted bypass actor, so even if CI somehow ran, the PR couldn't auto-merge.Fix
Mint a copilotkit-devops-bot (app-id
1108748) installation token early in the job usingactions/create-github-app-token, then use that token for all three authenticated operations:Configure git for push— injects the token into the git URL so the fix branch push is attributable to the botCreate PR—GH_TOKENis the bot token, sogh pr createruns as devops-bot; GitHub firespull_request— CI triggersAuto-merge PR—GH_TOKENis the bot token; devops-bot is a scoped Integration bypass actor on main's rulesetRequired secret:
DEVOPS_BOT_PRIVATE_KEYmust be added to the repo/org Actions secrets (being added separately).Required ruleset entry: devops-bot must be added as an Integration bypass actor on main's ruleset (being added separately).
In-workflow CI green gate
A ruleset bypass actor bypasses the entire ruleset — including any required-status-checks rule. This means a bypassing actor's PR can merge immediately without waiting for CI. To prevent merging on a red or empty build:
gh pr checks --watch --fail-fastto block until all checks pass; exit non-zero kills the stepgh pr merge --merge(no--auto— we do the waiting ourselves)This makes CI-green a hard precondition enforced in the workflow, not the ruleset.
Diff summary
actions/checkoutMint app tokenstep (id:app-token) usingactions/create-github-app-tokenpinned to v3.2.0Configure git for pushenvGITHUB_TOKENreplaced withsteps.app-token.outputs.tokenCreate PRenvGITHUB_TOKENreplaced withsteps.app-token.outputs.tokenAuto-merge PRenv + runGITHUB_TOKENreplaced withsteps.app-token.outputs.token; one-linergh pr merge --autoreplaced with poll-then-watch-then-merge guard🤖 Generated with Claude Code