diff --git a/.githooks/pre-push b/.githooks/pre-push index 73b9011cd90..c4b0918ba37 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,6 +1,6 @@ # Agent-only ship gate (humans: no-op). See scripts/agent-pre-push.mjs. -# Draft / no PR: free agent push. Ready PR: vp check + typecheck + tests. -# Publish: pnpm pr:ready, or raw `gh pr ready` — the shim runs the same gate first. +# Draft / no PR: static gate (vp check + typecheck). Ready PR: + unit tests. +# Publish: pnpm pr:ready, or raw `gh pr ready` — the shim runs the same full gate first. # # Humans: SKIP_AGENT_PREPUSH=1 git push # Agents: never SKIP_AGENT_PREPUSH / never --no-verify diff --git a/AGENTS.md b/AGENTS.md index 30a192b049b..88d5486c0e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,26 +189,27 @@ When implementation work for a user request is done (code, docs, config — not `t3-discord/f7d37879-desktop-deeplinks`), not a duplicate of that work in `fork/changes`. 3. **Let the agent ship gate own validation** before saying “updated the PR” or finishing (see _Task Completion Requirements → Agent ship gate_): - - **Push freely while the PR is a draft.** The gate is a no-op on draft / no-PR pushes — use them - for early sharing and the GitHub Diff UI. Do **not** hand-run `vp check` / `vpr typecheck` / - `vp run test` as routine draft validation (focused, scoped proof while iterating is still fine). - - **Publishing always runs the ship gate** (`vp check` → `vpr typecheck` → `vp run test`), and - only a failing gate stops it. `pnpm pr:ready` is the explicit path; raw `gh pr ready` is not + - **Every agent push is gated.** Draft / no-PR pays the **static** half (`vp check` + + `vpr typecheck`). Ready PRs and publish pay the **full** gate (+ `vp run test`). Do **not** + hand-run those as routine validation — the hook owns them and caches the SHA (focused, scoped + proof while iterating is still fine). + - **Publishing always runs the full ship gate** (`vp check` → `vpr typecheck` → `vp run test`), + and only a failing gate stops it. `pnpm pr:ready` is the explicit path; raw `gh pr ready` is not refused — the `.tools/bin/gh` shim runs the same gate first and then lets the command through. Either way the checks run, so there is no way to publish around them and nothing to remember. - The husky `pre-push` hook enforces the same gate on every push to a ready PR and fails closed - when PR state can’t be resolved. + The husky `pre-push` hook enforces the gate on every agent push and fails closed when PR state + can’t be resolved. - **Same gate for overlay-child PRs.** Base = overlay does **not** relax it; the gate keys off the PR’s ready state, not its base. Compose success or draft-lock green is **not** the gate. - `pnpm fork:stack update --push` (current branch) or `pnpm fork:stack update --push ` to - rebase/retarget; the ensuing push runs the ship gate when the PR is ready. + rebase/retarget; the ensuing push runs the appropriate gate scope. - Confirm with `gh pr view --json baseRefName,mergeable,mergeStateStatus,url` - `baseRefName` must be `fork/changes` for ordinary features or the intended overlay/parent branch for a dependent/overlay-child PR. `mergeable` should be `MERGEABLE` (CI may still be `UNSTABLE` while checks run). 4. **Before pushing follow-ups**, verify PR state with `gh pr view` (or equivalent): - - If the PR is **open** → update that branch (prefer `fork:stack update --push`) and push; a ready - PR re-runs the ship gate automatically, a draft pushes free. + - If the PR is **open** → update that branch (prefer `fork:stack update --push`) and push; the + gate re-runs for that HEAD (static if draft, full if ready). - If the PR is **merged** or **closed** → do **not** keep committing on that branch. Start a new branch, re-apply unmerged work, and open a **new PR** against the same intended base (`fork/changes` or the overlay). @@ -250,11 +251,12 @@ Prefer the thread starter’s Discord id/display name from turn context. Do not ## Task Completion Requirements -### Agent ship gate (draft-free push, gate on ready / publish) +### Agent ship gate (static on every push, full on ready / publish) Publish validation is **automated**. Agents do **not** hand-run the linter/typechecker/tests as a routine pre-handoff ritual — the ship gate runs them once, at the right moment, and caches the -result. Push freely while drafting; the gate fires on ready PRs and on `pnpm pr:ready`. +result. **No agent push is free:** draft / no-PR still pays the static half; ready and +`pnpm pr:ready` pay the full gate. **Mechanism** (`scripts/agent-pre-push.mjs`, `scripts/agent-pr-ready.mjs`, `scripts/lib/*.mjs`): @@ -263,48 +265,49 @@ result. Push freely while drafting; the gate fires on ready PRs and on `pnpm pr: markers). Humans opt out per push with `SKIP_AGENT_PREPUSH=1` — **agents never set that flag** and never use `git push --no-verify`. - When it runs, the gate mirrors the CI JS quality path, in order: - 1. **`vp check`** — format + lint (the Fork CI **Check** JS path) - 2. **`vpr typecheck`** — workspace TypeScript - 3. **`vp run test`** — unit tests + 1. **`vp check`** — format + lint (the Fork CI **Check** JS path) — **every agent push** + 2. **`vpr typecheck`** — workspace TypeScript — **every agent push** + 3. **`vp run test`** — unit tests — **ready PR / publish only** Cargo, mobile native, desktop packaging, and Release Smoke stay **CI-only** — do not hand-run them for ordinary PR handoff. -- It **skips when the HEAD SHA is already cached** in `.run/agent-ship-gate.json`, so a push and the - publish step never double-run for the same commit. Force with `AGENT_SHIP_GATE_FORCE=1`. +- It **skips when the HEAD SHA is already cached** in `.run/agent-ship-gate.json` (static vs complete + stages), so a draft push and the publish step never double-run the work already paid for that + commit. Force with `AGENT_SHIP_GATE_FORCE=1`. **When it runs:** -| Branch PR state | Agent push | -| ---------------------- | ----------------------------------------- | -| No open PR | **free push** (open a draft to share) | -| **Draft** PR | **free push** (review via GitHub Diff UI) | -| **Ready** PR | full ship gate on every push | -| PR state can’t resolve | full ship gate (**fail closed**) | +| Branch PR state | Agent push | +| ---------------------- | ------------------------------------------- | +| No open PR | **static** (`vp check` + `vpr typecheck`) | +| **Draft** PR | **static** (unit tests deferred to publish) | +| **Ready** PR | **full** ship gate on every push | +| PR state can’t resolve | **full** ship gate (**fail closed**) | This is identical for `fork/changes`, every registered overlay base (`fork/discord`, `fork/vscode`, `fork/identity`, desktop deeplinks), and dependent / overlay-child PRs — the gate keys off the PR’s **ready state**, not its base. Overlay Compose success or Managed-PR draft-lock green is **not** the gate. -**Publish path:** `pnpm pr:ready` — runs the ship gate, then marks the open draft PR ready. Raw -`gh pr ready` (and the ready-for-review APIs) reach the same place: the `.tools/bin/gh` policy shim -runs the ship gate first and passes the command through when it is green, so publishing is gated -rather than forbidden and a red gate is the only thing that stops it. `AGENT_PR_SHIP=1` marks a gate -already passed — `pr:ready` sets it for its own undraft call so the gate runs once, not twice. -`.envrc` puts -`$REPO/.tools/bin` first on `PATH` so the shim wins over system `gh`, and sets `GH_REPO` to the fork -so bare `gh pr` commands target it instead of gh's upstream-parent default (explicit `--repo` still -overrides); `scripts/install-git-hooks.mjs` installs both the hooks and the shim on `prepare`. +**Publish path:** `pnpm pr:ready` — runs the **full** ship gate, then marks the open draft PR ready. +Raw `gh pr ready` (and the ready-for-review APIs) reach the same place: the `.tools/bin/gh` policy +shim runs the ship gate first and passes the command through when it is green, so publishing is +gated rather than forbidden and a red gate is the only thing that stops it. `AGENT_PR_SHIP=1` marks +a gate already passed — `pr:ready` sets it for its own undraft call so the gate runs once, not twice. +`.envrc` puts `$REPO/.tools/bin` first on `PATH` so the shim wins over system `gh`, and sets +`GH_REPO` to the fork so bare `gh pr` commands target it instead of gh's upstream-parent default +(explicit `--repo` still overrides); `scripts/install-git-hooks.mjs` installs both the hooks and the +shim on `prepare`. **Agent workflow:** - **Open the draft immediately** once a PR is in scope (user asked, or Discord/turn rules require it) - and the first meaningful commit is useful to review. Keep committing and pushing freely while it is - a draft — no separate validation is expected on draft pushes. + and the first meaningful commit is useful to review. Keep committing and pushing while it is a + draft — each push pays the **static** gate only (cached per HEAD SHA). - **Publish when the work is done — do not leave a finished PR in draft.** The moment implementation - is complete and verified, run `pnpm pr:ready` to run the ship gate and mark the PR ready; this is - the immediate next action, before handoff notes, so full CI can start. Draft is only for + is complete and verified, run `pnpm pr:ready` to run the full ship gate and mark the PR ready; this + is the immediate next action, before handoff notes, so full CI can start. Draft is only for work-in-progress. Do **not** run lint / check / typecheck / tests separately first — the gate owns - the complete JS validation and caches the passing SHA. + JS validation and caches the passing SHA. - **Mid-loop feedback only:** while drafting, focused proof is fine — `vp test run ` for tests you touched, targeted lint/typecheck for the scope you changed. That is edit-loop signal, not a second gate. @@ -316,8 +319,8 @@ overrides); `scripts/install-git-hooks.mjs` installs both the hooks and the shim behavior; the gate’s `vp run test` runs them. Pre-commit: husky runs `pnpm lint-staged` — `vp fmt` on all staged files plus `vp lint --fix` on -staged code files (format + lint on commit; typecheck and tests stay in the ship gate). If the hook -rewrites files, stage those rewrites, commit, and push again. +staged code files (format + lint on commit; typecheck stays in the ship gate; unit tests on ready / +publish). If the hook rewrites files, stage those rewrites, commit, and push again. **Explicitly forbidden:** @@ -329,7 +332,7 @@ rewrites files, stage those rewrites, commit, and push again. - Treating Compose, Managed-PR draft-lock, or “integration CI will catch it” as a substitute for publishing through the gate. If a PR’s checks panel shows only Compose / draft-lock and **no** Check job, it has not been through the gate — publish with `pnpm pr:ready`. -- Leaving Discord/agent work with commits but **no** PR (open a draft; pushes stay free). +- Leaving Discord/agent work with commits but **no** PR (open a draft; draft pushes pay static). - Leaving a **finished** PR in draft. Draft is for work-in-progress only; when the work is done, publish it with `pnpm pr:ready` — a completed PR sitting in draft is an incomplete handoff. diff --git a/docs/internals/scripts.md b/docs/internals/scripts.md index 959d5e1fe05..19e2d1ba993 100644 --- a/docs/internals/scripts.md +++ b/docs/internals/scripts.md @@ -65,8 +65,9 @@ authenticated. - `pnpm test:agent-gate`: Unit tests for the agent pre-push / PR-state / gh-policy helpers. - Husky: `pre-commit` runs `pnpm lint-staged` (`vp fmt` on staged files + `vp lint --fix` on staged code files); - `pre-push` runs `scripts/agent-pre-push.mjs` (agents only: free push on draft / no PR; full ship - gate on ready PRs; SHA cache in `.run/agent-ship-gate.json`). + `pre-push` runs `scripts/agent-pre-push.mjs` (agents only: static gate — `vp check` + typecheck — + on draft / no PR; full ship gate including unit tests on ready PRs / publish; staged SHA cache in + `.run/agent-ship-gate.json`). - `node apps/server/scripts/t3-sqlite-state.ts --base-dir ...`: Inspects or seeds an isolated T3 SQLite database; writes create a private backup first. diff --git a/scripts/agent-pre-push.mjs b/scripts/agent-pre-push.mjs index 1cabdf62756..0094bbad4db 100644 --- a/scripts/agent-pre-push.mjs +++ b/scripts/agent-pre-push.mjs @@ -5,15 +5,17 @@ * Humans: no-op (exit 0) — self-responsible; not forced by the hook. * * Agents: - * - Draft PR or no open PR: free push (GitHub Diff UI / early share). - * - Ready-for-review PR (or unknown PR state): ship gate below. - * - Publish path is `pnpm pr:ready` (not raw `gh pr ready`) — same gate, + * - Draft PR or no open PR: static gate only — steps 1–2 below. + * A push that does not lint/typecheck helps nobody, draft or not; what + * draft buys is skipping the unit suite, not skipping correctness. + * - Ready-for-review PR (or unknown PR state): full ship gate (1–3). + * - Publish path is `pnpm pr:ready` (not raw `gh pr ready`) — same full gate, * then undraft. See scripts/agent-pr-ready.mjs + scripts/agent-gh.mjs. * - * Ship gate (when enforced) mirrors the CI JS quality path: + * Ship gate mirrors the CI JS quality path: * 1. `vp check` — format + lint * 2. `vpr typecheck` — workspace TypeScript - * 3. `vp run test` — unit tests + * 3. `vp run test` — unit tests (full / publish only) * * Detection: GROK_AGENT / T3_AGENT / AI_AGENT / Claude / Cursor / Codex env. * Humans only: SKIP_AGENT_PREPUSH=1 git push @@ -24,10 +26,11 @@ import * as NodePath from "node:path"; import * as NodeProcess from "node:process"; import * as NodeURL from "node:url"; import { isCodingAgent } from "./lib/agent-env.mjs"; -import { resolveOpenPrState, shouldRunShipGateOnPush } from "./lib/agent-pr-state.mjs"; +import { resolveOpenPrState, shipGateScopeForPush } from "./lib/agent-pr-state.mjs"; import { isShipGateForce, isShipGateShaCached, + isShipGateStaticCached, readHeadSha, readShipGateCache, writeShipGateCache, @@ -70,35 +73,74 @@ const run = (label, args, opts = {}) => { }; /** - * Full agent ship gate: check + typecheck + unit tests. - * Shared by pre-push (ready PRs) and `pnpm pr:ready`. + * Agent ship gate: static (check + typecheck) and optionally full (+ unit tests). + * Shared by pre-push (every agent push) and `pnpm pr:ready`. + * + * `scope: "static"` stops after typecheck — draft / no-PR pushes. + * `scope: "full"` (default) adds `vp run test` — ready PRs and publish. + * * Caches HEAD SHA under `.run/agent-ship-gate.json` so push + ready never * double-run for an already-validated commit. Force: AGENT_SHIP_GATE_FORCE=1. * - * @param {{ root?: string, force?: boolean, env?: NodeJS.ProcessEnv }} [opts] - * @returns {Promise<{ status: "cached" | "ok", sha: string | null }>} + * @param {{ root?: string, scope?: "full" | "static", force?: boolean, env?: NodeJS.ProcessEnv }} [opts] + * @returns {Promise<{ status: "cached" | "ok", sha: string | null, scope: "full" | "static" }>} */ export const runAgentShipGate = async (opts = {}) => { const root = opts.root ?? NodeProcess.cwd(); const env = withRepoBin(root, opts.env ?? NodeProcess.env); const force = opts.force === true || isShipGateForce(env); + const scope = opts.scope === "static" ? "static" : "full"; const headSha = readHeadSha(root); + const cache = readShipGateCache(root); + + const alreadyCached = + scope === "static" + ? !force && headSha && isShipGateStaticCached(headSha, cache) + : !force && headSha && isShipGateShaCached(headSha, cache); + + if (alreadyCached) { + console.error( + `agent ship-gate: skip — ${headSha.slice(0, 12)} already validated${ + scope === "static" ? " (static)" : "" + } (cache .run/agent-ship-gate.json; force with AGENT_SHIP_GATE_FORCE=1)`, + ); + return { status: "cached", sha: headSha, scope }; + } + + const staticCached = !force && headSha && isShipGateStaticCached(headSha, cache); - if (!force && headSha && isShipGateShaCached(headSha, readShipGateCache(root))) { + // Mirror CI check + typecheck (always). Unit tests only on full scope. + if (staticCached) { console.error( - `agent ship-gate: skip — ${headSha.slice(0, 12)} already validated (cache .run/agent-ship-gate.json; force with AGENT_SHIP_GATE_FORCE=1)`, + `agent ship-gate: skip check/typecheck — ${headSha.slice(0, 12)} already passed static`, ); - return { status: "cached", sha: headSha }; + } else { + run("vp check", ["vp", "check"], { cwd: root, env }); + run("vpr typecheck", ["vpr", "typecheck"], { cwd: root, env }); + } + + if (scope === "static") { + if (headSha) { + try { + writeShipGateCache(root, headSha, { stage: "static" }); + console.error( + `agent ship-gate: cached static ${headSha.slice(0, 12)} (.run/agent-ship-gate.json)`, + ); + } catch (error) { + console.error(`agent ship-gate: could not write static cache: ${error?.message ?? error}`); + } + } + console.error( + "agent ship-gate: static ok — unit tests run on ready / publish (`pnpm pr:ready`)", + ); + return { status: "ok", sha: headSha, scope }; } - // Mirror CI check + test jobs (JS path). Cargo/mobile/desktop builds stay CI-only. - run("vp check", ["vp", "check"], { cwd: root, env }); - run("vpr typecheck", ["vpr", "typecheck"], { cwd: root, env }); run("vp run test", ["vp", "run", "test"], { cwd: root, env }); if (headSha) { try { - writeShipGateCache(root, headSha); + writeShipGateCache(root, headSha, { stage: "complete" }); console.error(`agent ship-gate: cached ${headSha.slice(0, 12)} (.run/agent-ship-gate.json)`); } catch (error) { console.error(`agent ship-gate: could not write cache: ${error?.message ?? error}`); @@ -106,7 +148,7 @@ export const runAgentShipGate = async (opts = {}) => { } console.error("agent ship-gate: ok"); - return { status: "ok", sha: headSha }; + return { status: "ok", sha: headSha, scope }; }; const thisFile = NodeURL.fileURLToPath(import.meta.url); @@ -119,26 +161,26 @@ if (invokedAs === thisFile) { const root = NodeProcess.cwd(); const prState = resolveOpenPrState({ cwd: root }); + const scope = shipGateScopeForPush(prState.mode); - if (!shouldRunShipGateOnPush(prState.mode)) { + if (scope === "static") { const why = prState.mode === "draft" - ? `draft PR${prState.pr?.number != null ? ` #${prState.pr.number}` : ""} — free push (ship gate runs on publish via pnpm pr:ready)` - : "no open PR — free push (open a draft to share; ship gate on ready PRs / pnpm pr:ready)"; - console.error(`agent pre-push: skip ship gate (${why})`); - NodeProcess.exit(0); - } - - if (prState.mode === "unknown") { + ? `draft PR${prState.pr?.number != null ? ` #${prState.pr.number}` : ""}` + : "no open PR"; + console.error( + `agent pre-push: ${why} — static gate only (check + typecheck; unit tests on publish via pnpm pr:ready)`, + ); + } else if (prState.mode === "unknown") { console.error( - `agent pre-push: PR state unknown (${prState.detail ?? "gh failed"}) — fail closed, running ship gate`, + `agent pre-push: PR state unknown (${prState.detail ?? "gh failed"}) — fail closed, running full ship gate`, ); } else { console.error( - `agent pre-push: ready PR${prState.pr?.number != null ? ` #${prState.pr.number}` : ""} — running ship gate`, + `agent pre-push: ready PR${prState.pr?.number != null ? ` #${prState.pr.number}` : ""} — running full ship gate`, ); } - await runAgentShipGate({ root }); + await runAgentShipGate({ root, scope }); NodeProcess.exit(0); } diff --git a/scripts/agent-pre-push.test.mjs b/scripts/agent-pre-push.test.mjs index 66e8074b24b..6e5d9e432a7 100644 --- a/scripts/agent-pre-push.test.mjs +++ b/scripts/agent-pre-push.test.mjs @@ -5,11 +5,13 @@ import { classifyPrPayload, parseRepoSlug, resolveOpenPrState, + shipGateScopeForPush, shouldRunShipGateOnPush, } from "./lib/agent-pr-state.mjs"; import { isShipGateForce, isShipGateShaCached, + isShipGateStaticCached, readShipGateCache, writeShipGateCache, } from "./lib/agent-ship-gate-cache.mjs"; @@ -47,13 +49,20 @@ it("PR payload: draft / ready / closed", () => { assert.equal(classifyPrPayload({ isDraft: true, state: "CLOSED" }), "none"); }); -it("ship gate on push: only ready + unknown", () => { +it("full ship gate on push: only ready + unknown", () => { assert.equal(shouldRunShipGateOnPush("none"), false); assert.equal(shouldRunShipGateOnPush("draft"), false); assert.equal(shouldRunShipGateOnPush("ready"), true); assert.equal(shouldRunShipGateOnPush("unknown"), true); }); +it("push scope: draft/none = static, ready/unknown = full", () => { + assert.equal(shipGateScopeForPush("none"), "static"); + assert.equal(shipGateScopeForPush("draft"), "static"); + assert.equal(shipGateScopeForPush("ready"), "full"); + assert.equal(shipGateScopeForPush("unknown"), "full"); +}); + const pinned = (runGh) => ({ branch: "feature", repoSlug: "owner/repo", runGh }); it("parseRepoSlug: ssh / https / trailing .git", () => { @@ -220,20 +229,28 @@ it("ship-gate cache: match / miss / force", () => { assert.equal(isShipGateShaCached(other, { sha }), false); assert.equal(isShipGateShaCached(null, { sha }), false); assert.equal(isShipGateShaCached(sha, null), false); + assert.equal(isShipGateStaticCached(sha, { staticSha: sha }), true); + assert.equal(isShipGateStaticCached(sha, { sha }), true); + assert.equal(isShipGateStaticCached(sha, { staticSha: other }), false); assert.equal(isShipGateForce({}), false); assert.equal(isShipGateForce({ AGENT_SHIP_GATE_FORCE: "1" }), true); assert.equal(isShipGateForce({ AGENT_SHIP_GATE_FORCE: "0" }), false); }); -it("ship-gate cache: write + read roundtrip", () => { +it("ship-gate cache: write + read roundtrip (complete)", () => { const files = new Map(); const root = "/tmp/agent-ship-gate-test-root"; const sha = "c".repeat(40); writeShipGateCache(root, sha, { + stage: "complete", mkdirSync: () => {}, writeFileSync: (file, data) => { files.set(file, data); }, + readFileSync: (file) => { + if (!files.has(file)) throw new Error("ENOENT"); + return files.get(file); + }, now: () => new Date("2026-08-02T00:00:00.000Z"), }); assert.equal(files.size, 1); @@ -245,6 +262,30 @@ it("ship-gate cache: write + read roundtrip", () => { return files.get(file); }, }); - assert.deepEqual(cache, { sha, validatedAt: "2026-08-02T00:00:00.000Z" }); + assert.equal(cache?.sha, sha); + assert.equal(cache?.staticSha, sha); + assert.equal(isShipGateShaCached(sha, cache), true); + assert.equal(isShipGateStaticCached(sha, cache), true); +}); + +it("ship-gate cache: static does not demote complete", () => { + const files = new Map(); + const root = "/tmp/agent-ship-gate-static-test"; + const sha = "d".repeat(40); + const io = { + mkdirSync: () => {}, + writeFileSync: (file, data) => { + files.set(file, data); + }, + readFileSync: (file) => { + if (!files.has(file)) throw new Error("ENOENT"); + return files.get(file); + }, + now: () => new Date("2026-08-02T00:00:00.000Z"), + }; + writeShipGateCache(root, sha, { ...io, stage: "complete" }); + writeShipGateCache(root, sha, { ...io, stage: "static" }); + const cache = readShipGateCache(root, io); + assert.equal(cache?.sha, sha); assert.equal(isShipGateShaCached(sha, cache), true); }); diff --git a/scripts/lib/agent-pr-state.mjs b/scripts/lib/agent-pr-state.mjs index 0e4cbc7d077..eb9cd590164 100644 --- a/scripts/lib/agent-pr-state.mjs +++ b/scripts/lib/agent-pr-state.mjs @@ -2,10 +2,10 @@ * Resolve whether the current branch's open PR should enforce the agent ship gate. * * Modes: - * none — no open PR (or closed/merged): free agent push - * draft — open draft PR: free agent push (GitHub Diff UI) - * ready — open non-draft PR: ship gate on every agent push - * unknown — gh missing / failed: fail closed (run the gate) + * none — no open PR (or closed/merged): static gate only + * draft — open draft PR: static gate only (lighter than publish) + * ready — open non-draft PR: full ship gate on every agent push + * unknown — gh missing / failed: fail closed (run the full gate) * * This fork lives under `patroza/t3code` but `gh` defaults to the upstream * parent (`pingdotgg/t3code`). A bare `gh pr view` therefore never finds the @@ -37,6 +37,18 @@ export const classifyPrPayload = (pr) => { */ export const shouldRunShipGateOnPush = (mode) => mode === "ready" || mode === "unknown"; +/** + * How much of the gate a push pays for. + * + * Nothing is free any more: a push that does not format, lint or typecheck is + * worthless to a reviewer whether or not the PR says "draft". What draft buys + * is the *expensive* half — full unit suite stays on the ready/publish path. + * + * @param {"none" | "draft" | "ready" | "unknown"} mode + * @returns {"full" | "static"} + */ +export const shipGateScopeForPush = (mode) => (shouldRunShipGateOnPush(mode) ? "full" : "static"); + /** Strip ANSI color codes so `gh --json` stays parseable when color is forced. */ const stripAnsi = (text) => String(text).replace(/\u001b\[[0-9;]*m/g, ""); diff --git a/scripts/lib/agent-ship-gate-cache.mjs b/scripts/lib/agent-ship-gate-cache.mjs index 9d3326db497..11b84f72364 100644 --- a/scripts/lib/agent-ship-gate-cache.mjs +++ b/scripts/lib/agent-ship-gate-cache.mjs @@ -3,6 +3,10 @@ * pre-push and `pnpm pr:ready` share this so the same commit is never double-paid. * * Path: `.run/agent-ship-gate.json` (under gitignored `.run/`). + * + * Checkpoints, cheapest first — each implies the ones before it: + * static — `vp check` + `vpr typecheck` (what a draft / no-PR push pays) + * complete — plus `vp run test` (ready PR / publish) */ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; @@ -37,59 +41,127 @@ export const readHeadSha = (root = NodeProcess.cwd(), opts = {}) => { return /^[0-9a-f]{40}$/i.test(sha) ? sha.toLowerCase() : null; }; +export const SHIP_GATE_STAGES = ["static", "complete"]; + /** * @param {string} [root] * @param {{ readFileSync?: typeof NodeFS.readFileSync }} [opts] - * @returns {{ sha: string, validatedAt?: string } | null} + * @returns {{ sha?: string, validatedAt?: string, staticSha?: string, staticAt?: string } | null} */ export const readShipGateCache = (root = NodeProcess.cwd(), opts = {}) => { const readFileSync = opts.readFileSync ?? NodeFS.readFileSync; const file = shipGateCachePath(root); + const readSha = (value) => { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + return /^[0-9a-f]{40}$/i.test(normalized) ? normalized : undefined; + }; try { const raw = readFileSync(file, "utf8"); const parsed = JSON.parse(raw); - const sha = typeof parsed?.sha === "string" ? parsed.sha.trim().toLowerCase() : ""; - if (!/^[0-9a-f]{40}$/i.test(sha)) return null; + const sha = readSha(parsed?.sha); + // Legacy caches only had `sha` meaning full complete; treat as both stages. + const staticSha = readSha(parsed?.staticSha) ?? sha; + if (!sha && !staticSha) return null; return { sha, validatedAt: typeof parsed.validatedAt === "string" ? parsed.validatedAt : undefined, + staticSha, + staticAt: + typeof parsed.staticAt === "string" + ? parsed.staticAt + : typeof parsed.validatedAt === "string" + ? parsed.validatedAt + : undefined, }; } catch { return null; } }; +/** + * Highest checkpoint the cache records for `headSha`, as an index into + * SHIP_GATE_STAGES; -1 when the cache describes another commit. + * @param {string | null | undefined} headSha + * @param {{ sha?: string, staticSha?: string } | null | undefined} cache + */ +export const cachedStage = (headSha, cache) => { + if (!headSha || !cache) return -1; + const h = headSha.toLowerCase(); + if (cache.sha && cache.sha.toLowerCase() === h) return SHIP_GATE_STAGES.indexOf("complete"); + if (cache.staticSha && cache.staticSha.toLowerCase() === h) + return SHIP_GATE_STAGES.indexOf("static"); + return -1; +}; + /** * @param {string} root * @param {string} sha - * @param {{ writeFileSync?: typeof NodeFS.writeFileSync, mkdirSync?: typeof NodeFS.mkdirSync, now?: () => Date }} [opts] + * @param {{ + * stage?: "static" | "complete" + * writeFileSync?: typeof NodeFS.writeFileSync + * mkdirSync?: typeof NodeFS.mkdirSync + * readFileSync?: typeof NodeFS.readFileSync + * now?: () => Date + * }} [opts] */ export const writeShipGateCache = (root, sha, opts = {}) => { const writeFileSync = opts.writeFileSync ?? NodeFS.writeFileSync; const mkdirSync = opts.mkdirSync ?? NodeFS.mkdirSync; const now = opts.now ?? (() => new Date()); + const stage = opts.stage ?? "complete"; const normalized = String(sha).trim().toLowerCase(); if (!/^[0-9a-f]{40}$/i.test(normalized)) { throw new Error(`writeShipGateCache: invalid sha ${sha}`); } + // Never demote the same commit: a draft push (static) landing after a full + // run would otherwise make the next publish re-pay tests it already passed. + if (SHIP_GATE_STAGES.indexOf(stage) < cachedStage(normalized, readShipGateCache(root, opts))) { + return; + } const file = shipGateCachePath(root); + const at = now().toISOString(); + const existing = readShipGateCache(root, opts) ?? {}; + const value = + stage === "static" + ? { + ...existing, + staticSha: normalized, + staticAt: at, + } + : { + sha: normalized, + validatedAt: at, + staticSha: normalized, + staticAt: existing.staticAt ?? at, + }; mkdirSync(NodePath.dirname(file), { recursive: true }); - writeFileSync( - file, - `${JSON.stringify({ sha: normalized, validatedAt: now().toISOString() }, null, 2)}\n`, - "utf8", - ); + writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); }; /** + * Full gate (check + typecheck + test) already passed for this SHA. * @param {string | null | undefined} headSha - * @param {{ sha: string } | null | undefined} cache + * @param {{ sha?: string } | null | undefined} cache */ export const isShipGateShaCached = (headSha, cache) => { if (!headSha || !cache?.sha) return false; return headSha.toLowerCase() === cache.sha.toLowerCase(); }; +/** + * Static gate (check + typecheck) already passed for this SHA. + * Full cache also satisfies static. + * @param {string | null | undefined} headSha + * @param {{ sha?: string, staticSha?: string } | null | undefined} cache + */ +export const isShipGateStaticCached = (headSha, cache) => { + if (!headSha || !cache) return false; + const h = headSha.toLowerCase(); + if (cache.sha && cache.sha.toLowerCase() === h) return true; + if (cache.staticSha && cache.staticSha.toLowerCase() === h) return true; + return false; +}; + /** * Force re-run even when SHA is cached. * @param {NodeJS.ProcessEnv} [env]