From c436eaf3cadbb2a1773094e91e493ff01dc1079d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:00:28 +0800 Subject: [PATCH] fix(ci): preserve user-enabled auto-merge --- .claude/skills/handoff/SKILL.md | 5 +- .claude/skills/newtask/SKILL.md | 6 +- .claude/skills/run-pr/SKILL.md | 8 ++- .cursor/agents/pr-babysit.md | 2 +- .githooks/pre-push | 19 ++--- .github/workflows/keep-pr-auto-merge.yml | 72 ------------------- AGENTS.md | 22 +++--- CLAUDE.md | 6 +- docs/codebase-index.md | 2 +- docs/process-hardening.md | 6 +- scripts/guard-push.mjs | 89 +++++++++++++----------- scripts/sync-open-pr-branches.mjs | 3 +- tests/ci-cache-safety.test.ts | 6 +- tests/guard-push.test.ts | 32 ++++++++- tests/sync-open-pr-branches.test.ts | 7 ++ 15 files changed, 136 insertions(+), 149 deletions(-) delete mode 100644 .github/workflows/keep-pr-auto-merge.yml diff --git a/.claude/skills/handoff/SKILL.md b/.claude/skills/handoff/SKILL.md index 871025e97b..c03ad5d58e 100644 --- a/.claude/skills/handoff/SKILL.md +++ b/.claude/skills/handoff/SKILL.md @@ -54,7 +54,10 @@ force-push, or discard work. paths (`git show --name-only --format=fuller HEAD`), and the same branch name recorded in step 1. End the message with: `Co-Authored-By: Claude Fable 5 `. -5. **Push** the feature branch: `git push -u origin `. Never pipe the push through `tail`, +5. **Push** the feature branch: `git push -u origin `. Per-PR auto-merge state is user-owned: + automation must not disable it. If the branch already has an open PR with auto-merge armed, + leave it mutation-frozen; do not push or change the branch/base until it merges or the user + manually changes that state. Never pipe the push through `tail`, `head`, or another command that can mask its status. Confirm the remote tip equals local HEAD with `git ls-remote` before reporting success. The pre-push guards run (auto-merge sentinel, format, drift) — heed a block rather than overriding blindly. diff --git a/.claude/skills/newtask/SKILL.md b/.claude/skills/newtask/SKILL.md index 79a1f0545b..45ae42b99c 100644 --- a/.claude/skills/newtask/SKILL.md +++ b/.claude/skills/newtask/SKILL.md @@ -5,9 +5,9 @@ description: Bootstrap a clean session for new work in this repo — create a fr # newtask — start a clean, current working copy -This repo moves fast (`claude/*` branches auto-merge on green) and shares ~40 worktrees -and one stash stack, so starting work on a stale base or a cold worktree is the default -failure. This skill sets up an isolated, current worktree so new work starts clean. +This repo moves fast and shares ~40 worktrees and one stash stack, so starting work on a +stale base or a cold worktree is the default failure. This skill sets up an isolated, +current worktree so new work starts clean. ## Before you start diff --git a/.claude/skills/run-pr/SKILL.md b/.claude/skills/run-pr/SKILL.md index 66ebe1208c..cb53109962 100644 --- a/.claude/skills/run-pr/SKILL.md +++ b/.claude/skills/run-pr/SKILL.md @@ -21,7 +21,8 @@ re-running failed hosted CI jobs; updating a PR branch from `main`. Nothing else Never, even during a sweep: - Never merge a pull request into `main` or any protected branch, and never enable auto-merge; - the sweep fixes and reports, the user merges. + the sweep fixes and reports, the user merges. Per-PR auto-merge state is user-owned: + automation must not disable it. - Never close a pull request, delete or rename branches, force-push (no `--force`, no `--force-with-lease`), or rebase. - Never run provider-backed gates: `eval:rag`, `eval:quality`, `eval:retrieval:quality`, @@ -53,6 +54,11 @@ Never, even during a sweep: ## Per-PR algorithm +Before any branch-changing action, inspect `autoMergeRequest`. If it is non-null, treat the PR as +mutation-frozen: do not push, update the branch/base, or otherwise change its head. Continue +read-only diagnosis and reporting, but leave the armed state untouched until the PR merges or the +user manually changes it. Never disable auto-merge as a workaround for maintenance. + ### Step 0 — skip gates (record every skip with its reason) - `skip-codex-review` label → skip the PR entirely. diff --git a/.cursor/agents/pr-babysit.md b/.cursor/agents/pr-babysit.md index c1682fa3a2..cf809a8d4f 100644 --- a/.cursor/agents/pr-babysit.md +++ b/.cursor/agents/pr-babysit.md @@ -19,7 +19,7 @@ When invoked: 6. After fixing a review thread, reply first (never resolve silently), then use the authorized direct resolution tool. Delegated Bugbot threads follow the same path. Only the trusted Codex autofix identity may fall back to ``; otherwise leave the thread open and report the missing capability. 7. Respect provider confirmation boundaries: no live Supabase/OpenAI/eval spend without separate explicit authorization for that provider action. A Run PR sweep never authorizes provider-backed gates. 8. Require explicit user authorization before commits, pushes, hosted-CI reruns, replies, or thread resolution. The Run PR shortcut supplies authorization only for the GitHub actions enumerated in `AGENTS.md`. Do not edit PR titles/bodies during Run PR sweeps unless the user explicitly asks. Re-check CI until mergeable + green + comments triaged. -9. Never merge into `main`, force-push, close the PR, enable auto-merge, or delete branches unless the user explicitly asks. +9. Never merge into `main`, force-push, close the PR, enable auto-merge, or delete branches unless the user explicitly asks. Per-PR auto-merge state is user-owned: automation must not disable it. If auto-merge is already armed, treat the PR as mutation-frozen and do not push, update its branch/base, or otherwise change its head until it merges or the user manually changes that state. 10. Follow `docs/codex-review-protocol.md` and record every completed review or sweep — including pure and no-op reviews — with `npm run ledger:append`. Report before/after: merge state, CI, threads fixed vs left open, commits pushed, and any remaining human decision. diff --git a/.githooks/pre-push b/.githooks/pre-push index d8726926c4..776ed1606b 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,9 +1,9 @@ #!/bin/sh -# Pre-push safety net. Delegates to scripts/guard-push.mjs, which runs four -# guards: auto-merge race sentinel, format-before-push, drift-manifest -# freshness, static gate (lint + source typecheck), and ledger-write discipline. Each guard has an -# explicit override env var (see the script header) — including -# SKIP_STATIC_GUARD=1 for the static gate. +# Pre-push safety net. Delegates to scripts/guard-push.mjs, which runs five +# guards: auto-merge ownership, format-before-push, drift-manifest freshness, +# static gate (lint + source typecheck), and ledger-write discipline. The +# auto-merge guard is not bypassable; the other guards have explicit override +# env vars (see the script header), including SKIP_STATIC_GUARD=1. # # Installed by scripts/install-git-hooks.mjs via `git config core.hooksPath # .githooks` (run automatically on npm install/ci). Git passes the pushed refs @@ -11,12 +11,15 @@ # script reads to scope the format, drift, and static checks to what is # actually pushed. # -# Set GUARD_PUSH_DISABLE=1 to bypass every guard at once (individual guards also -# have their own override flags). +# Set GUARD_PUSH_DISABLE=1 to bypass every guard except auto-merge ownership. +# Individual non-auto-merge guards also have their own override flags. set -eu if [ "${GUARD_PUSH_DISABLE:-}" = "1" ]; then - exit 0 + export SKIP_FORMAT_GUARD=1 + export SKIP_DRIFT_GUARD=1 + export SKIP_STATIC_GUARD=1 + export SKIP_LEDGER_WRITE_GUARD=1 fi # Resolve node; if unavailable, do not block the push (fail open). diff --git a/.github/workflows/keep-pr-auto-merge.yml b/.github/workflows/keep-pr-auto-merge.yml deleted file mode 100644 index f4a285eaa6..0000000000 --- a/.github/workflows/keep-pr-auto-merge.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Keep PR auto-merge enabled - -on: - pull_request_target: - branches: [main] - types: [opened, synchronize, reopened, ready_for_review, auto_merge_disabled] - -concurrency: - group: keep-pr-auto-merge-${{ github.event.pull_request.number }} - cancel-in-progress: true - -permissions: - contents: read - pull-requests: write - -jobs: - ensure: - name: Ensure auto-merge - if: github.event.pull_request.state == 'open' && github.event.pull_request.draft == false - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - name: Enable auto-merge when missing - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const number = context.payload.pull_request.number; - const owner = context.repo.owner; - const repo = context.repo.repo; - - const data = await github.graphql( - `query($owner: String!, $repo: String!, $number: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $number) { - id - state - isDraft - autoMergeRequest { mergeMethod } - } - } - }`, - { owner, repo, number }, - ); - - const pullRequest = data.repository.pullRequest; - if (!pullRequest || pullRequest.state !== "OPEN" || pullRequest.isDraft) { - core.notice(`PR #${number} is closed or draft; auto-merge guard skipped.`); - return; - } - - if (pullRequest.autoMergeRequest) { - core.notice(`PR #${number} already has auto-merge enabled.`); - return; - } - - const enabled = await github.graphql( - `mutation($input: EnablePullRequestAutoMergeInput!) { - enablePullRequestAutoMerge(input: $input) { - pullRequest { number autoMergeRequest { mergeMethod } } - } - }`, - { - input: { - pullRequestId: pullRequest.id, - mergeMethod: "MERGE", - }, - }, - ); - - core.notice( - `Enabled auto-merge for PR #${enabled.enablePullRequestAutoMerge.pullRequest.number}.`, - ); diff --git a/AGENTS.md b/AGENTS.md index 86fa94472f..6ccb54fe42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -631,7 +631,7 @@ Nothing else inherits this authorization. Only the user's own task message can t Hard guardrails (never, even during a sweep): -- Never merge a pull request into `main` or any protected branch, and never enable auto-merge; the sweep fixes and reports, the user merges. +- Never merge a pull request into `main` or any protected branch, and never enable auto-merge; the sweep fixes and reports, the user merges. Per-PR auto-merge state is user-owned: automation must not disable it. When auto-merge is already armed, do not push, update the branch/base, or perform another head-changing action; report the frozen state and leave the PR untouched until it merges or the user manually changes that state. - Never close a pull request, delete or rename branches, force-push, or rebase. - Never run provider-backed gates: `eval:rag`, `eval:quality`, `eval:retrieval:quality`, `verify:release`, `check:supabase-project`, `test:live`, or anything else that touches live Supabase/OpenAI. - Respect the `skip-codex-review` label as a full per-PR opt-out. @@ -707,14 +707,12 @@ A settle-then-push addition also lands after this repo's one automatic Codex rev already have run against the earlier head — in practice the connector re-reviews each new push (observed on this same PR), but if it doesn't, request a fresh review explicitly before merging rather than assuming the addition was covered. **If the target PR has -auto-merge armed, settling-then-pushing races the merge itself** — `claude/*` branches -auto-merge on green by this repo's own default (`.claude/skills/newtask/SKILL.md`), so -"wait for CI to settle" can mean "wait for it to squash-merge and close" before the -bundled commit ever gets pushed, silently dropping it. `guard-push.mjs`'s auto-merge -sentinel exists to catch this but fails open without `gh` available (observed directly -in this repo's own sessions) — don't rely on it. Before using the settle-then-push path, -confirm the target PR does not have auto-merge enabled, or disable it first and -re-enable only after the bundled commit is pushed. +auto-merge armed, settling-then-pushing races the merge itself.** Treat that PR as +mutation-frozen: do not disable or re-enable auto-merge, push, update its branch/base, or +otherwise change its head. Let the armed merge land, or wait for the user to manually +change the auto-merge state before doing further branch work. `guard-push.mjs` enforces +this for every locally pushed PR branch when authenticated `gh` is available; agent +policy remains the backstop in environments where local hooks or `gh` are unavailable. Bundle only when every item being combined is: - **Independently low-risk, checked two ways — neither is exhaustive alone.** @@ -803,9 +801,9 @@ named PR). Future process only. `npm run verify:pr-local` (or the smallest gate that covers the change). Format is in `static-pr` but not in `verify:cheap`; an uncommitted format leaves CI red on the pushed blob. Whole-tree Prettier, not a single edited file. -- If a `claude/*` PR has auto-merge armed, disable it before a settle-then-push bundle, push, - then re-enable — otherwise the first green head can squash-merge before the bundled commit - lands. +- If a PR has auto-merge armed, its auto-merge state is user-owned and automation must not disable + it. Treat the branch as mutation-frozen: no push, update-branch, base change, or bundled addition + until it merges or the user manually changes that state. - Missing CI checks are not a green pass. `pull_request` workflows do not run when GitHub cannot build `refs/pull//merge`. The `PR mergeability` check uses trusted `pull_request_target` events and refreshes unchanged PR heads after protected-base diff --git a/CLAUDE.md b/CLAUDE.md index 8496eec07c..a400c323a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,9 +150,9 @@ Verification pyramid — run the **smallest gate that covers the change**, then `verify:cheap` deliberately does **not** run formatting, which is why changed-file CI and the installed pre-push hook (`.githooks/pre-push` → `scripts/guard-push.mjs`) block on unformatted files. -It also guards the auto-merge race on `claude/*` branches, drift-manifest staleness, and a -static gate (lint + source typecheck; override `SKIP_STATIC_GUARD=1`). Each guard has a -documented override env var. +It also guards user-owned auto-merge state on every PR branch, drift-manifest staleness, and a +static gate (lint + source typecheck; override `SKIP_STATIC_GUARD=1`). The auto-merge guard has no +automation override; the other guards retain their documented override env vars. Domain changes (auth, Supabase, ingestion, answer generation, search/ranking, clinical output, source governance) additionally want the smallest relevant domain check plus diff --git a/docs/codebase-index.md b/docs/codebase-index.md index bf4d71e005..bc3537845d 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -45,7 +45,7 @@ Smaller top-level directories that are easy to miss: | `.codex/` | Trusted Desktop/CLI config; tracked `config.toml` has disabled, secret-free Figma, Supabase, Railway, and Sentry MCP templates. Hosted ChatGPT/Codex apps are installed and authenticated separately; OAuth stays in the host credential store. | | `.cursor/` | Cursor project rules and local-agent configuration | | `.design-sync/` | Generated design-system package metadata, validation notes, and project-sync artifacts | -| `.githooks/` | Installed by `npm install`; `pre-push` runs `scripts/guard-push.mjs` (format, auto-merge race, drift staleness, static lint+typecheck) | +| `.githooks/` | Installed by `npm install`; `pre-push` runs `scripts/guard-push.mjs` (user-owned auto-merge preservation, format, drift staleness, static lint+typecheck, ledger write discipline) | | `.vscode/` | Shared VS Code workspace recommendations and settings | **Do not commit:** `.next/`, `node_modules/`, `coverage/`, `.env*`, `sample-documents/`, logs. diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 33f5a9d820..d941e9f760 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -636,9 +636,9 @@ Machinery added to retire repeated traps and surface live-product signal proacti the durable index for the tooling; `docs/operator-backlog.md` tracks the human-only enablement steps. - **Pre-push guards** (`.githooks/pre-push` → `scripts/guard-push.mjs`, auto-installed by the - `postinstall` → `scripts/install-git-hooks.mjs`, which sets `core.hooksPath=.githooks`): four guards, - each with an explicit override env var — auto-merge race sentinel (`claude/*`, blocks a push when the - PR's auto-merge is armed; `ALLOW_AUTOMERGE_PUSH=1`), format-before-push (closes the `verify:cheap` vs + `postinstall` → `scripts/install-git-hooks.mjs`, which sets `core.hooksPath=.githooks`): five guards. + The non-bypassable auto-merge ownership guard blocks a push on every PR branch when the PR's + auto-merge is armed. The other guards retain explicit overrides: format-before-push (closes the `verify:cheap` vs CI `format:check` gap; it reuses only an exact-lock worktree dependency tree and otherwise blocks with `npm ci --include=dev`; `SKIP_FORMAT_GUARD=1`), drift-manifest freshness (`SKIP_DRIFT_GUARD=1`), and static gate (changed-file lint + source-only typecheck through the run diff --git a/scripts/guard-push.mjs b/scripts/guard-push.mjs index 513460b42b..9618e24421 100644 --- a/scripts/guard-push.mjs +++ b/scripts/guard-push.mjs @@ -2,14 +2,14 @@ /** * guard-push — pre-push safety net for this repo's known, repeated traps. * - * Runs five independent guards; any one can BLOCK the push (non-zero exit) and - * each honours an explicit override env var so you are never truly stuck: + * Runs five independent guards; any one can BLOCK the push (non-zero exit). + * All except the auto-merge ownership guard have an explicit override env var: * - * 1. Auto-merge race sentinel (claude/* branches only) - * This repo auto-merges claude/* PRs on green. Pushing a late follow-up - * commit to a PR whose auto-merge is already armed races the merge and has - * orphaned commits before. If `gh` reports an armed autoMergeRequest for the - * current branch's open PR, block. Override: ALLOW_AUTOMERGE_PUSH=1. + * 1. Auto-merge ownership guard (all PR branches) + * Per-PR auto-merge state is user-owned. Pushing to a PR whose auto-merge is + * already armed can race the merge and, for actors without write permission, + * GitHub disables auto-merge. If `gh` reports an armed autoMergeRequest for + * the current branch's open PR, block without an automation override. * Fails OPEN (never blocks) when gh is missing/unauthenticated, so * contributors without gh can still push. * @@ -96,13 +96,24 @@ export function parsePushRanges(stdinText) { for (const raw of stdinText.split("\n")) { const line = raw.trim(); if (!line) continue; - const [localRef, localSha, , remoteSha] = line.split(/\s+/); + const [localRef, localSha, remoteRef, remoteSha] = line.split(/\s+/); if (!localSha || localSha === ZERO_SHA) continue; // branch deletion — nothing to push - ranges.push({ localRef, localSha, remoteSha: remoteSha ?? ZERO_SHA }); + ranges.push({ localRef, localSha, remoteRef, remoteSha: remoteSha ?? ZERO_SHA }); } return ranges; } +/** Exported for tests: resolve the remote PR branches a push will mutate. */ +export function pushedBranchNames(ranges, fallbackBranch = "") { + const branches = new Set(); + for (const range of ranges) { + const ref = range.remoteRef || range.localRef || ""; + if (ref.startsWith("refs/heads/")) branches.add(ref.slice("refs/heads/".length)); + } + if (branches.size === 0 && fallbackBranch) branches.add(fallbackBranch); + return [...branches]; +} + function changedFilesForRange(range) { const base = range.remoteSha && range.remoteSha !== ZERO_SHA @@ -162,7 +173,6 @@ function ghIsAvailable() { /** Exported for tests: decide from a parsed `gh pr view` payload. */ export function autoMergeVerdict(branch, prPayload) { - if (!branch.startsWith("claude/")) return { block: false, reason: "not-a-claude-branch" }; if (!prPayload) return { block: false, reason: "no-open-pr" }; if (prPayload.state && prPayload.state !== "OPEN") return { block: false, reason: "pr-not-open" }; if (prPayload.autoMergeRequest) { @@ -171,36 +181,33 @@ export function autoMergeVerdict(branch, prPayload) { return { block: false, reason: "auto-merge-not-armed" }; } -function autoMergeGuard(branch) { - if (process.env.ALLOW_AUTOMERGE_PUSH === "1") { - return { name: "auto-merge", ok: true, skipped: "ALLOW_AUTOMERGE_PUSH=1" }; - } - if (!branch.startsWith("claude/")) return { name: "auto-merge", ok: true }; +function autoMergeGuard(branches) { if (!ghIsAvailable()) { return { name: "auto-merge", ok: true, note: "gh not available — auto-merge check skipped (fail-open)" }; } - let payload; - try { - const raw = execFileSync("gh", ["pr", "view", "--json", "autoMergeRequest,state,number"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); - payload = JSON.parse(raw); - } catch { - // No PR for this branch, or gh unauthenticated: fail open. - return { name: "auto-merge", ok: true, note: "no open PR resolvable — auto-merge check skipped" }; - } - const verdict = autoMergeVerdict(branch, payload); - if (verdict.block) { - return { - name: "auto-merge", - ok: false, - message: - `PR #${verdict.number} on ${branch} has auto-merge ARMED.\n` + - ` Pushing now races the squash-merge and can orphan this commit (it has happened before).\n` + - ` Let the armed merge land first, or disable auto-merge on the PR, then push.\n` + - ` To push anyway: ALLOW_AUTOMERGE_PUSH=1 git push`, - }; + for (const branch of branches) { + let payload; + try { + const raw = execFileSync("gh", ["pr", "view", branch, "--json", "autoMergeRequest,state,number"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + payload = JSON.parse(raw); + } catch { + // No PR for this branch, or gh unauthenticated: fail open. + continue; + } + const verdict = autoMergeVerdict(branch, payload); + if (verdict.block) { + return { + name: "auto-merge", + ok: false, + message: + `PR #${verdict.number} on ${branch} has auto-merge ARMED.\n` + + ` Auto-merge state is user-owned; automation must not disable it or push through it.\n` + + ` Leave this PR untouched until it merges or the user manually changes that state.`, + }; + } } return { name: "auto-merge", ok: true }; } @@ -918,10 +925,11 @@ function main() { const ranges = parsePushRanges(stdin); if (ranges.length === 0) process.exit(0); // deletion-only push or nothing to do const branch = currentBranch(); + const pushedBranches = pushedBranchNames(ranges, branch); const changedFiles = collectChangedFiles(ranges); // formatGuard reads the pushed blobs; drift/static only need the paths. const results = [ - autoMergeGuard(branch), + autoMergeGuard(pushedBranches), formatGuard(collectChangedBlobs(ranges)), driftGuard(changedFiles), staticGuard(changedFiles, { ranges }), @@ -951,7 +959,10 @@ function assert(condition, label) { function selfTest() { // auto-merge verdicts - assert(autoMergeVerdict("main", { autoMergeRequest: {} }).block === false, "non-claude branch never blocks"); + assert( + autoMergeVerdict("codex/x", { autoMergeRequest: { enabledAt: "t" }, state: "OPEN", number: 6 }).block === true, + "armed auto-merge on codex/* blocks", + ); assert( autoMergeVerdict("claude/x", { autoMergeRequest: { enabledAt: "t" }, state: "OPEN", number: 7 }).block === true, "armed auto-merge on claude/* blocks", diff --git a/scripts/sync-open-pr-branches.mjs b/scripts/sync-open-pr-branches.mjs index 0788eba698..1a293f97be 100644 --- a/scripts/sync-open-pr-branches.mjs +++ b/scripts/sync-open-pr-branches.mjs @@ -48,6 +48,7 @@ function shouldSkip(pr) { export function classifyPr(pr, behindBy) { const skip = shouldSkip(pr); if (skip) return { action: "skip", reason: skip }; + if (pr.autoMergeRequest) return { action: "skip", reason: "auto-merge-armed" }; if ((behindBy ?? 0) <= 0) return { action: "skip", reason: "already-current" }; if (pr.requiredCiInFlight) return { action: "skip", reason: "required-ci-in-flight" }; return { action: "update", reason: `behind=${behindBy}` }; @@ -96,7 +97,7 @@ function main() { "--limit", "100", "--json", - "number,title,headRefName,headRefOid,labels,isCrossRepository,isDraft,url", + "number,title,headRefName,headRefOid,labels,isCrossRepository,isDraft,url,autoMergeRequest", ]); const plan = []; diff --git a/tests/ci-cache-safety.test.ts b/tests/ci-cache-safety.test.ts index 2073e357cf..2bb9f0f98f 100644 --- a/tests/ci-cache-safety.test.ts +++ b/tests/ci-cache-safety.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { readdirSync, readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; @@ -18,6 +18,10 @@ const liveWebVitalsWorkflow = readFileSync( const opsDigestWorkflow = readFileSync(new URL("../.github/workflows/ops-digest.yml", import.meta.url), "utf8"); describe("CI cache safety", () => { + it("does not add a PR workflow that changes user-owned auto-merge state", () => { + expect(existsSync(new URL("../.github/workflows/keep-pr-auto-merge.yml", import.meta.url))).toBe(false); + }); + it("uses npm's download cache but recreates node_modules on every job", () => { expect(nodeSetup).toContain("cache: npm"); expect(nodeSetup).toContain("cache-dependency-path: package-lock.json"); diff --git a/tests/guard-push.test.ts b/tests/guard-push.test.ts index 3ef995574e..000aa6070a 100644 --- a/tests/guard-push.test.ts +++ b/tests/guard-push.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -19,6 +19,7 @@ import { needsTypecheck, normalizedSchemaSha256 as guardSha, parsePushRanges, + pushedBranchNames, pushedTipMatchesHead, staticGuard, } from "../scripts/guard-push.mjs"; @@ -60,8 +61,10 @@ describe("guard-push sha parity", () => { }); describe("auto-merge verdict", () => { - it("never blocks a non-claude branch", () => { - expect(autoMergeVerdict("main", { autoMergeRequest: {}, state: "OPEN" }).block).toBe(false); + it("blocks any PR branch with armed auto-merge", () => { + expect(autoMergeVerdict("codex/x", { autoMergeRequest: { enabledAt: "t" }, state: "OPEN", number: 6 }).block).toBe( + true, + ); }); it("blocks a claude/* branch with an armed auto-merge on an open PR", () => { @@ -83,6 +86,23 @@ describe("auto-merge verdict", () => { }); }); +describe("manual auto-merge ownership policy", () => { + it("keeps active agent policies aligned on preserving an armed PR", () => { + const policyFiles = [ + "../AGENTS.md", + "../.claude/skills/run-pr/SKILL.md", + "../.claude/skills/handoff/SKILL.md", + "../.cursor/agents/pr-babysit.md", + ]; + + for (const file of policyFiles) { + const policy = readFileSync(new URL(file, import.meta.url), "utf8"); + expect(policy, file).toContain("auto-merge state is user-owned"); + expect(policy, file).toContain("must not disable"); + } + }); +}); + describe("drift verdict", () => { const text = "create table t();\n"; it("is fresh when the manifest sha matches", () => { @@ -100,9 +120,15 @@ describe("push-range parsing", () => { it("parses a new-branch push (zero remote sha)", () => { const ranges = parsePushRanges(`refs/heads/x abc123 refs/heads/x ${ZERO}\n`); expect(ranges).toHaveLength(1); + expect(ranges[0].remoteRef).toBe("refs/heads/x"); expect(ranges[0].remoteSha).toBe(ZERO); }); + it("guards the remote branch even when a different branch is checked out", () => { + const ranges = parsePushRanges(`refs/heads/local abc123 refs/heads/pr-head ${ZERO}\n`); + expect(pushedBranchNames(ranges, "main")).toEqual(["pr-head"]); + }); + it("skips a branch-deletion push (zero local sha)", () => { expect(parsePushRanges(`refs/heads/x ${ZERO} refs/heads/x abc\n`)).toHaveLength(0); }); diff --git a/tests/sync-open-pr-branches.test.ts b/tests/sync-open-pr-branches.test.ts index 1f4258ab4b..4d089991ea 100644 --- a/tests/sync-open-pr-branches.test.ts +++ b/tests/sync-open-pr-branches.test.ts @@ -7,6 +7,13 @@ import { } from "../scripts/sync-open-pr-branches.mjs"; describe("sync-open-pr-branches classifyPr", () => { + it("never updates a PR whose user-owned auto-merge is armed", () => { + expect(classifyPr({ autoMergeRequest: { enabledAt: "2026-08-13T00:00:00Z" } }, 5)).toEqual({ + action: "skip", + reason: "auto-merge-armed", + }); + }); + it("skips hold / do-not-merge / skip-branch-sync labels", () => { expect(classifyPr({ title: "x", labels: [{ name: "hold" }] }, 3)).toEqual({ action: "skip",