Skip to content

Fix Drift: authenticate via copilotkit-devops-bot app token#305

Merged
jpr5 merged 2 commits into
mainfrom
harden/drift-app-token
Jul 16, 2026
Merged

Fix Drift: authenticate via copilotkit-devops-bot app token#305
jpr5 merged 2 commits into
mainfrom
harden/drift-app-token

Conversation

@jpr5

@jpr5 jpr5 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Problem

The Fix Drift workflow was using GITHUB_TOKEN for every authenticated operation (git push, PR create, auto-merge). This has two critical consequences:

  1. PRs created by GITHUB_TOKEN don't trigger pull_request events — GitHub suppresses the event to prevent recursive workflows, so drift fix PRs never get CI checks.
  2. 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 using actions/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 bot
  • Create PRGH_TOKEN is the bot token, so gh pr create runs as devops-bot; GitHub fires pull_request — CI triggers
  • Auto-merge PRGH_TOKEN is the bot token; devops-bot is a scoped Integration bypass actor on main's ruleset

Required secret: DEVOPS_BOT_PRIVATE_KEY must 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:

  • Poll until at least one check is registered (up to 10 x 30 s = 5 min); fail hard if none ever appear
  • Run gh pr checks --watch --fail-fast to block until all checks pass; exit non-zero kills the step
  • Only then call gh 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

Location Change
After actions/checkout Add Mint app token step (id: app-token) using actions/create-github-app-token pinned to v3.2.0
Configure git for push env GITHUB_TOKEN replaced with steps.app-token.outputs.token
Create PR env GITHUB_TOKEN replaced with steps.app-token.outputs.token
Auto-merge PR env + run GITHUB_TOKEN replaced with steps.app-token.outputs.token; one-liner gh pr merge --auto replaced with poll-then-watch-then-merge guard

🤖 Generated with Claude Code

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.
Comment thread .github/workflows/fix-drift.yml Fixed
@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@305

commit: f3365a6

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
jpr5 merged commit fd93a52 into main Jul 16, 2026
17 checks passed
@jpr5
jpr5 deleted the harden/drift-app-token branch July 16, 2026 21:59
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.**
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.

2 participants