diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 731707eed4d2..274e0e8cb55a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,16 @@ name: CI +run-name: "CI ${{ github.event_name }} PR #${{ github.event.pull_request.number }} head ${{ github.event.pull_request.head.sha }} base ${{ github.event.pull_request.base.sha }} merge ${{ github.sha }}" on: pull_request: + branches: + - lastcode/main push: branches: - - main + - lastcode/main + +permissions: + contents: read concurrency: group: ci-${{ github.event.pull_request.number || github.sha }} @@ -13,20 +19,27 @@ concurrency: jobs: check: name: Check - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v6 with: + # The pull-request merge checkout plus both parents are enough to + # compare the exact event head and base without fetching full history. + fetch-depth: 2 sparse-checkout: | /* !/.repos/ sparse-checkout-cone-mode: false - name: Reject repository-owned PR assets + if: ${{ github.event_name == 'pull_request' }} + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - files="$(git ls-files .github/pr-assets)" + files="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- .github/pr-assets)" if test -n "$files"; then printf 'PR evidence must be uploaded to GitHub, not committed:\n%s\n' "$files" >&2 exit 1 @@ -61,10 +74,10 @@ jobs: # dependency ordering that `vp run` applies by default: these `test` tasks # declare no `dependsOn` and resolve workspace deps from source, so ordering # only bought us idle runners between dependency layers. The concurrency - # limit stays at the default 4 so peak load per runner is unchanged. + # limit stays at 4 until standard-runner measurements justify a change. test: name: Test - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -94,7 +107,7 @@ jobs: # isolation that flag buys is preserved exactly. test_server: name: Test Server ${{ matrix.shard }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 strategy: fail-fast: false @@ -157,11 +170,10 @@ jobs: if-no-files-found: ignore retention-days: 30 - # Split out of Check and Test: both paid ~7-9s to install a Rust toolchain - # for checks that take under 3s, on the critical path of every PR. + # Keep the Rust toolchain and checks off the JavaScript jobs' critical paths. rust: name: Rust - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -183,13 +195,12 @@ jobs: - name: Test resource monitor run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml - # The static analysis below needs a macOS runner, which bills ~6.7x a Linux - # minute, so gate it on the native sources it actually lints instead of paying - # for it on every push. Detection is API-only (no checkout) and fails open: if - # the diff cannot be resolved, the lint runs. + # The static analysis below needs a macOS runner, so gate it on the native + # sources it actually lints. Detection is API-only (no checkout) and fails + # open: if the diff cannot be resolved, the lint runs. mobile_native_changes: name: Mobile Native Changes - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: contents: read @@ -267,7 +278,7 @@ jobs: # Skip only on an explicit "no": a gate job that failed or errored leaves the # output empty, and that must run the lint rather than silently skip it. if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != 'false' }} - runs-on: blacksmith-6vcpu-macos-26 + runs-on: macos-26 timeout-minutes: 10 steps: - name: Checkout @@ -295,7 +306,7 @@ jobs: release_smoke: name: Release Smoke - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -317,3 +328,42 @@ jobs: - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts + + ci_gate: + name: CI Gate + if: ${{ always() }} + needs: + - check + - test + - test_server + - rust + - mobile_native_changes + - mobile_native_static_analysis + - release_smoke + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + steps: + - name: Verify required jobs + env: + CHECK_RESULT: ${{ needs.check.result }} + TEST_RESULT: ${{ needs.test.result }} + TEST_SERVER_RESULT: ${{ needs.test_server.result }} + RUST_RESULT: ${{ needs.rust.result }} + MOBILE_CHANGES_RESULT: ${{ needs.mobile_native_changes.result }} + MOBILE_CHANGED: ${{ needs.mobile_native_changes.outputs.changed }} + MOBILE_STATIC_RESULT: ${{ needs.mobile_native_static_analysis.result }} + RELEASE_SMOKE_RESULT: ${{ needs.release_smoke.result }} + run: | + set -euo pipefail + test "$CHECK_RESULT" = success + test "$TEST_RESULT" = success + test "$TEST_SERVER_RESULT" = success + test "$RUST_RESULT" = success + test "$MOBILE_CHANGES_RESULT" = success + test "$RELEASE_SMOKE_RESULT" = success + if test "$MOBILE_CHANGED" = false; then + test "$MOBILE_STATIC_RESULT" = skipped + else + test "$MOBILE_STATIC_RESULT" = success + fi diff --git a/docs/lastcode/nightly-workflow.md b/docs/lastcode/nightly-workflow.md index e35c6ed58d1b..83f785c8891e 100644 --- a/docs/lastcode/nightly-workflow.md +++ b/docs/lastcode/nightly-workflow.md @@ -419,9 +419,11 @@ Configure the fork so that: - checkpoint, revision, and build tags cannot be modified or deleted; - only the owner or automation identity can force-push `lastcode/main`; - ordinary LastCode changes arrive through PRs targeting `lastcode/main`; and -- GitHub Actions are enabled only for the manually dispatched - [LastCode Intel artifact workflow](release.md#intel-build-publication), while - ordinary CI remains disabled because local CI is authoritative. +- GitHub Actions are enabled for the manually dispatched + [LastCode Intel artifact workflow](release.md#intel-build-publication) and the + pull-request CI workflow. During the hosted-CI proof stage, guarded merge still + requires the exact local Full CI stamp; the hosted run is observed and measured + before it replaces that local PR authority. Branch protection must permit the intentional force-with-lease promotion model. If GitHub cannot express that narrowly enough for a personal repository, rely on diff --git a/scripts/lastcode-ci-workflow.test.ts b/scripts/lastcode-ci-workflow.test.ts new file mode 100644 index 000000000000..e2fc1aeadb81 --- /dev/null +++ b/scripts/lastcode-ci-workflow.test.ts @@ -0,0 +1,100 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +const workflow = NodeFS.readFileSync( + NodePath.resolve(import.meta.dirname, "../.github/workflows/ci.yml"), + "utf8", +); + +const gateBlock = /^ ci_gate:\n(?[\s\S]*)$/mu.exec(workflow)?.groups?.body; +if (!gateBlock) throw new Error("CI workflow is missing the ci_gate job."); + +const gateScriptBody = / run: \|\n(?(?: .*\n?)*)$/u.exec(gateBlock)?.groups + ?.body; +if (!gateScriptBody) throw new Error("CI workflow is missing the CI Gate decision script."); + +const gateScript = gateScriptBody + .split("\n") + .map((line) => line.replace(/^ {10}/u, "")) + .join("\n"); + +const successfulGateEnvironment = { + CHECK_RESULT: "success", + TEST_RESULT: "success", + TEST_SERVER_RESULT: "success", + RUST_RESULT: "success", + MOBILE_CHANGES_RESULT: "success", + MOBILE_CHANGED: "false", + MOBILE_STATIC_RESULT: "skipped", + RELEASE_SMOKE_RESULT: "success", +}; + +const runGate = (overrides: Readonly> = {}): number | null => + NodeChildProcess.spawnSync("bash", ["-c", gateScript], { + env: { ...process.env, ...successfulGateEnvironment, ...overrides }, + stdio: "ignore", + }).status; + +describe("LastCode GitHub CI workflow", () => { + it("targets the downstream branch on standard GitHub runners", () => { + expect(workflow).toContain( + 'run-name: "CI ${{ github.event_name }} PR #${{ github.event.pull_request.number }} head ${{ github.event.pull_request.head.sha }} base ${{ github.event.pull_request.base.sha }} merge ${{ github.sha }}"', + ); + expect(workflow).toContain("pull_request:\n branches:\n - lastcode/main"); + expect(workflow).toContain("push:\n branches:\n - lastcode/main"); + expect(workflow).toContain("permissions:\n contents: read"); + expect(workflow).toContain("fetch-depth: 2"); + expect(workflow).toContain('files="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA"'); + expect(workflow).not.toContain("blacksmith-"); + expect(workflow).toContain("runs-on: ubuntu-24.04"); + expect(workflow).toContain("runs-on: macos-26"); + }); + + it("makes the stable gate depend on every validation job", () => { + for (const job of [ + "check", + "test", + "test_server", + "rust", + "mobile_native_changes", + "mobile_native_static_analysis", + "release_smoke", + ]) { + expect(gateBlock).toContain(` - ${job}`); + } + expect(gateBlock).toContain("name: CI Gate"); + expect(gateBlock).toContain("if: ${{ always() }}"); + }); + + it("passes when mandatory jobs succeed and irrelevant mobile analysis is skipped", () => { + expect(runGate()).toBe(0); + }); + + it("passes when required mobile analysis succeeds", () => { + expect(runGate({ MOBILE_CHANGED: "true", MOBILE_STATIC_RESULT: "success" })).toBe(0); + }); + + it("fails closed for every unsuccessful mandatory result", () => { + for (const variable of [ + "CHECK_RESULT", + "TEST_RESULT", + "TEST_SERVER_RESULT", + "RUST_RESULT", + "MOBILE_CHANGES_RESULT", + "RELEASE_SMOKE_RESULT", + ]) { + expect(runGate({ [variable]: "failure" })).not.toBe(0); + expect(runGate({ [variable]: "cancelled" })).not.toBe(0); + expect(runGate({ [variable]: "skipped" })).not.toBe(0); + } + }); + + it("accepts a skipped mobile job only after an explicit no-change result", () => { + expect(runGate({ MOBILE_CHANGED: "", MOBILE_STATIC_RESULT: "skipped" })).not.toBe(0); + expect(runGate({ MOBILE_CHANGED: "true", MOBILE_STATIC_RESULT: "skipped" })).not.toBe(0); + expect(runGate({ MOBILE_CHANGED: "false", MOBILE_STATIC_RESULT: "success" })).not.toBe(0); + }); +}); diff --git a/scripts/lastcode-github-ci.test.ts b/scripts/lastcode-github-ci.test.ts new file mode 100644 index 000000000000..de9cd9a41159 --- /dev/null +++ b/scripts/lastcode-github-ci.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + evaluateGithubCi, + githubBranchRulesArgs, + githubCiJobsArgs, + githubCiRunTitle, + githubCiRunsArgs, + githubCiWorkflowArgs, + testedMergeShaFromGithubCiRunTitle, + type GithubCiEvaluationInput, +} from "./lastcode-github-ci.ts"; + +const MERGE = "1234567890abcdef1234567890abcdef12345678"; +const HEAD = "abcdef1234567890abcdef1234567890abcdef12"; +const BASE = "fedcba0987654321fedcba0987654321fedcba09"; +const TITLE = `CI pull_request PR #104 head ${HEAD} base ${BASE} merge ${MERGE}`; + +const input = (overrides: Partial = {}): GithubCiEvaluationInput => ({ + workflow: { id: 123, state: "active" }, + branchRules: [], + pullRequestNumber: 104, + headSha: HEAD, + baseSha: BASE, + workflowRuns: [ + { + id: 456, + display_title: TITLE, + event: "pull_request", + head_sha: HEAD, + status: "completed", + conclusion: "success", + created_at: "2026-08-27T10:00:00Z", + html_url: "https://github.com/lastobelus/lastCode/actions/runs/456", + }, + ], + jobs: [{ name: "CI Gate", status: "completed", conclusion: "success" }], + ...overrides, +}); + +describe("LastCode GitHub CI evidence", () => { + it("builds exact workflow, rules, run, and job queries", () => { + expect(githubCiWorkflowArgs("lastobelus/lastCode")).toEqual([ + "api", + "repos/lastobelus/lastCode/actions/workflows/ci.yml", + ]); + expect(githubBranchRulesArgs("lastobelus/lastCode", "lastcode/main")).toEqual([ + "api", + "repos/lastobelus/lastCode/rules/branches/lastcode%2Fmain", + ]); + expect(githubCiRunsArgs("lastobelus/lastCode", HEAD)).toEqual([ + "api", + `repos/lastobelus/lastCode/actions/workflows/ci.yml/runs?event=pull_request&head_sha=${HEAD}&per_page=100`, + ]); + expect( + githubCiRunTitle({ pullRequestNumber: 104, headSha: HEAD, baseSha: BASE, mergeSha: MERGE }), + ).toBe(TITLE); + expect( + testedMergeShaFromGithubCiRunTitle(TITLE, { + pullRequestNumber: 104, + headSha: HEAD, + baseSha: BASE, + }), + ).toBe(MERGE); + expect(githubCiJobsArgs("lastobelus/lastCode", 456)).toEqual([ + "api", + "repos/lastobelus/lastCode/actions/runs/456/jobs?filter=latest&per_page=100", + ]); + }); + + it("satisfies disabled, non-required hosted CI without a run", () => { + for (const state of ["disabled_fork", "disabled_inactivity", "disabled_manually"]) { + expect( + evaluateGithubCi(input({ workflow: { state }, workflowRuns: [], jobs: null })), + ).toEqual({ state: "satisfied", reason: "not-expected" }); + } + }); + + it("fails closed when a required gate belongs to a disabled workflow", () => { + expect( + evaluateGithubCi( + input({ + workflow: { state: "disabled_manually" }, + branchRules: [ + { + type: "required_status_checks", + parameters: { required_status_checks: [{ context: "CI Gate" }] }, + }, + ], + workflowRuns: [], + jobs: null, + }), + ), + ).toMatchObject({ state: "failure", reason: "configuration" }); + }); + + it("waits for exact workflow registration", () => { + expect(evaluateGithubCi(input({ workflowRuns: [], jobs: null }))).toEqual({ + state: "pending", + reason: "run-registration", + }); + }); + + it("ignores runs for a different head or base", () => { + expect( + evaluateGithubCi( + input({ + workflowRuns: [ + { + id: 1, + display_title: TITLE, + event: "pull_request", + head_sha: "a".repeat(40), + status: "completed", + conclusion: "success", + }, + ], + jobs: [{ name: "CI Gate", status: "completed", conclusion: "success" }], + }), + ), + ).toEqual({ state: "pending", reason: "run-registration" }); + expect( + evaluateGithubCi( + input({ + workflowRuns: [ + { + id: 1, + display_title: githubCiRunTitle({ + pullRequestNumber: 104, + headSha: HEAD, + baseSha: "b".repeat(40), + mergeSha: MERGE, + }), + event: "pull_request", + head_sha: HEAD, + status: "completed", + conclusion: "success", + }, + ], + }), + ), + ).toEqual({ state: "pending", reason: "run-registration" }); + }); + + it("retains the immutable tested merge SHA without comparing it to a later PR snapshot", () => { + expect(evaluateGithubCi(input())).toMatchObject({ + state: "satisfied", + testedMergeSha: MERGE, + }); + }); + + it("uses the newest exact run and waits while it is active", () => { + expect( + evaluateGithubCi( + input({ + workflowRuns: [ + { + id: 1, + display_title: TITLE, + event: "pull_request", + head_sha: HEAD, + status: "completed", + conclusion: "success", + created_at: "2026-08-27T09:00:00Z", + }, + { + id: 2, + display_title: TITLE, + event: "pull_request", + head_sha: HEAD, + status: "in_progress", + conclusion: null, + created_at: "2026-08-27T10:00:00Z", + }, + ], + jobs: null, + }), + ), + ).toMatchObject({ state: "pending", reason: "run-in-progress", runId: 2 }); + }); + + it("reports terminal workflow failures without accepting an older success", () => { + expect( + evaluateGithubCi( + input({ + workflowRuns: [ + { + id: 2, + display_title: TITLE, + event: "pull_request", + head_sha: HEAD, + status: "completed", + conclusion: "cancelled", + created_at: "2026-08-27T10:00:00Z", + }, + ], + jobs: null, + }), + ), + ).toMatchObject({ state: "failure", reason: "terminal-run", runId: 2 }); + }); + + it("requires one successful aggregate gate on a successful exact run", () => { + expect(evaluateGithubCi(input())).toMatchObject({ + state: "satisfied", + reason: "exact-run", + runId: 456, + }); + for (const conclusion of ["failure", "cancelled", "neutral", "skipped"] as const) { + expect( + evaluateGithubCi(input({ jobs: [{ name: "CI Gate", status: "completed", conclusion }] })), + ).toMatchObject({ state: "failure", reason: "aggregate-gate" }); + } + expect(evaluateGithubCi(input({ jobs: [] }))).toMatchObject({ + state: "failure", + reason: "configuration", + }); + }); + + it("fails closed for unknown workflow and run states", () => { + expect(evaluateGithubCi(input({ workflow: { state: "unknown" } }))).toMatchObject({ + state: "failure", + reason: "configuration", + }); + expect( + evaluateGithubCi( + input({ + workflowRuns: [{ id: 1, display_title: TITLE, event: "pull_request", head_sha: HEAD }], + }), + ), + ).toMatchObject({ state: "failure", reason: "configuration" }); + }); +}); diff --git a/scripts/lastcode-github-ci.ts b/scripts/lastcode-github-ci.ts new file mode 100644 index 000000000000..12c97c7c91df --- /dev/null +++ b/scripts/lastcode-github-ci.ts @@ -0,0 +1,226 @@ +export const LASTCODE_CI_WORKFLOW = "ci.yml"; +export const LASTCODE_CI_GATE = "CI Gate"; + +export type GithubWorkflow = { + readonly id?: number; + readonly path?: string; + readonly state?: string; +}; + +export type GithubBranchRule = { + readonly type?: string; + readonly parameters?: { + readonly required_status_checks?: ReadonlyArray<{ + readonly context?: string; + }>; + }; +}; + +export type GithubWorkflowRun = { + readonly id?: number; + readonly display_title?: string; + readonly event?: string; + readonly head_sha?: string; + readonly status?: string; + readonly conclusion?: string | null; + readonly created_at?: string; + readonly run_attempt?: number; + readonly html_url?: string; +}; + +export type GithubWorkflowJob = { + readonly name?: string; + readonly status?: string; + readonly conclusion?: string | null; +}; + +export type GithubCiEvidence = + | { + readonly state: "satisfied"; + readonly reason: "exact-run" | "not-expected"; + readonly runId?: number; + readonly runUrl?: string; + readonly testedMergeSha?: string; + } + | { + readonly state: "pending"; + readonly reason: "run-in-progress" | "run-registration"; + readonly runId?: number; + readonly runUrl?: string; + readonly testedMergeSha?: string; + } + | { + readonly state: "failure"; + readonly reason: "aggregate-gate" | "configuration" | "terminal-run"; + readonly detail: string; + readonly runId?: number; + readonly runUrl?: string; + readonly testedMergeSha?: string; + }; + +export interface GithubCiEvaluationInput { + readonly workflow: GithubWorkflow; + readonly branchRules: ReadonlyArray; + readonly pullRequestNumber: number; + readonly headSha: string; + readonly baseSha: string; + readonly workflowRuns: ReadonlyArray; + readonly jobs: ReadonlyArray | null; +} + +const runTimestamp = (run: GithubWorkflowRun): number => { + const parsed = Date.parse(run.created_at ?? ""); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +export function githubCiWorkflowArgs(repository: string): ReadonlyArray { + return ["api", `repos/${repository}/actions/workflows/${LASTCODE_CI_WORKFLOW}`]; +} + +export function githubBranchRulesArgs( + repository: string, + baseBranch: string, +): ReadonlyArray { + return ["api", `repos/${repository}/rules/branches/${encodeURIComponent(baseBranch)}`]; +} + +export function githubCiRunsArgs(repository: string, headSha: string): ReadonlyArray { + return [ + "api", + `repos/${repository}/actions/workflows/${LASTCODE_CI_WORKFLOW}/runs?event=pull_request&head_sha=${headSha}&per_page=100`, + ]; +} + +export function githubCiRunTitle(input: { + readonly pullRequestNumber: number; + readonly headSha: string; + readonly baseSha: string; + readonly mergeSha: string; +}): string { + return `CI pull_request PR #${input.pullRequestNumber} head ${input.headSha} base ${input.baseSha} merge ${input.mergeSha}`; +} + +export function testedMergeShaFromGithubCiRunTitle( + title: string | undefined, + input: { + readonly pullRequestNumber: number; + readonly headSha: string; + readonly baseSha: string; + }, +): string | null { + const prefix = `CI pull_request PR #${input.pullRequestNumber} head ${input.headSha} base ${input.baseSha} merge `; + if (!title?.startsWith(prefix)) return null; + const testedMergeSha = title.slice(prefix.length); + return /^[0-9a-f]{40}$/u.test(testedMergeSha) ? testedMergeSha : null; +} + +export function githubCiJobsArgs(repository: string, runId: number): ReadonlyArray { + return ["api", `repos/${repository}/actions/runs/${runId}/jobs?filter=latest&per_page=100`]; +} + +export function requiredGithubCiGate(branchRules: ReadonlyArray): boolean { + return branchRules.some( + (rule) => + rule.type === "required_status_checks" && + rule.parameters?.required_status_checks?.some(({ context }) => context === LASTCODE_CI_GATE), + ); +} + +const configurationFailure = (detail: string): GithubCiEvidence => ({ + state: "failure", + reason: "configuration", + detail, +}); + +const disabledWorkflowStates = new Set([ + "disabled_fork", + "disabled_inactivity", + "disabled_manually", +]); + +export function evaluateGithubCi(input: GithubCiEvaluationInput): GithubCiEvidence { + const required = requiredGithubCiGate(input.branchRules); + const workflowActive = input.workflow.state === "active"; + const workflowDisabled = disabledWorkflowStates.has(input.workflow.state ?? ""); + + if (!workflowActive && !workflowDisabled) { + return configurationFailure( + `Workflow ${LASTCODE_CI_WORKFLOW} has unsupported state ${input.workflow.state ?? "missing"}.`, + ); + } + if (workflowDisabled && required) { + return configurationFailure( + `Required check ${LASTCODE_CI_GATE} cannot run while ${LASTCODE_CI_WORKFLOW} is disabled.`, + ); + } + if (!workflowActive) return { state: "satisfied", reason: "not-expected" }; + + const exactRuns = input.workflowRuns + .filter( + (run) => + run.event === "pull_request" && + run.head_sha === input.headSha && + testedMergeShaFromGithubCiRunTitle(run.display_title, input) !== null && + run.id !== undefined, + ) + .sort( + (left, right) => runTimestamp(right) - runTimestamp(left) || (right.id ?? 0) - (left.id ?? 0), + ); + const run = exactRuns[0]; + if (!run) return { state: "pending", reason: "run-registration" }; + + const runId = run.id; + if (runId === undefined) return configurationFailure("Exact workflow run has no ID."); + const testedMergeSha = testedMergeShaFromGithubCiRunTitle(run.display_title, input); + if (!testedMergeSha) return configurationFailure("Exact workflow run has no tested merge SHA."); + const runIdentity = run.html_url + ? { runId, runUrl: run.html_url, testedMergeSha } + : { runId, testedMergeSha }; + if (run.status !== "completed") { + if (["queued", "in_progress", "pending", "requested", "waiting"].includes(run.status ?? "")) { + return { state: "pending", reason: "run-in-progress", ...runIdentity }; + } + return { + state: "failure", + reason: "configuration", + detail: `Workflow run ${runId} has unsupported status ${run.status ?? "missing"}.`, + ...runIdentity, + }; + } + if (run.conclusion !== "success") { + return { + state: "failure", + reason: "terminal-run", + detail: `Workflow run ${runId} completed with ${run.conclusion ?? "no conclusion"}.`, + ...runIdentity, + }; + } + + const gates = (input.jobs ?? []).filter(({ name }) => name === LASTCODE_CI_GATE); + if (gates.length !== 1) { + return { + state: "failure", + reason: "configuration", + detail: `Workflow run ${runId} reported ${gates.length} ${LASTCODE_CI_GATE} jobs.`, + ...runIdentity, + }; + } + const gate = gates[0]; + if (gate?.status !== "completed") { + return { + state: "failure", + reason: "configuration", + detail: `${LASTCODE_CI_GATE} did not complete with its workflow run.`, + ...runIdentity, + }; + } + if (gate.conclusion !== "success") { + return { + state: "failure", + reason: "aggregate-gate", + detail: `${LASTCODE_CI_GATE} completed with ${gate.conclusion ?? "no conclusion"}.`, + ...runIdentity, + }; + } + return { state: "satisfied", reason: "exact-run", ...runIdentity }; +} diff --git a/scripts/lastcode-wait-for-pr.test.ts b/scripts/lastcode-wait-for-pr.test.ts index e5a5b29ac4e1..700439866ff5 100644 --- a/scripts/lastcode-wait-for-pr.test.ts +++ b/scripts/lastcode-wait-for-pr.test.ts @@ -2,20 +2,26 @@ import { describe, expect, it } from "vite-plus/test"; import { - classifyStatusChecks, + assertWaitStart, + CI_REGISTRATION_TIMEOUT_MS, decideWaitForPr, + decideWaitTimeout, deriveReviewState, latestCodexReviewTrigger, + MERGE_RECOMPUTE_TIMEOUT_MS, pullRequestViewArgs, + REVIEW_TIMEOUT_MS, requiresReadyConfirmation, reviewThreadsArgs, samePullRequestRevision, + waitTimeoutClass, type ReviewState, type WaitObservation, } from "./lastcode-wait-for-pr.ts"; const HEAD = "1234567890abcdef1234567890abcdef12345678"; const BASE = "abcdef1234567890abcdef1234567890abcdef12"; +const MERGE = "fedcba0987654321fedcba0987654321fedcba09"; const reviewRequest = (head = HEAD): string => `@codex review\n`; @@ -35,9 +41,18 @@ const handledReview: ReviewState = { latestTriggerId: 10, }; +const pendingCi: WaitObservation["ci"] = { state: "pending", reason: "run-in-progress" }; +const satisfiedCi: WaitObservation["ci"] = { state: "satisfied", reason: "exact-run" }; +const failedCi: WaitObservation["ci"] = { + state: "failure", + reason: "terminal-run", + detail: "CI failed.", +}; + function observation( input: { readonly ci?: WaitObservation["ci"]; + readonly number?: number; readonly review?: ReviewState; readonly head?: string; readonly base?: string; @@ -47,11 +62,15 @@ function observation( readonly mergeStateStatus?: string; readonly baseRefName?: string; readonly unresolvedReviewThreads?: number; + readonly merge?: string | null; + readonly localHead?: string; + readonly localBranch?: string; + readonly clean?: boolean; } = {}, ): WaitObservation { return { pullRequest: { - number: 87, + number: input.number ?? 87, url: "https://github.com/lastobelus/lastCode/pull/88", state: input.state ?? "OPEN", isDraft: input.isDraft ?? false, @@ -60,11 +79,17 @@ function observation( baseRefName: input.baseRefName ?? "lastcode/main", mergeable: input.mergeable ?? "MERGEABLE", mergeStateStatus: input.mergeStateStatus ?? "CLEAN", - statusCheckRollup: [], + potentialMergeCommit: + input.merge === null ? null : { oid: input.merge === undefined ? MERGE : input.merge }, }, - ci: input.ci ?? "pending", + ci: input.ci ?? pendingCi, review: input.review ?? pendingReview, unresolvedReviewThreads: input.unresolvedReviewThreads ?? 0, + local: { + branch: input.localBranch ?? "lastcode/wait-for-pr", + head: input.localHead ?? HEAD, + clean: input.clean ?? true, + }, }; } @@ -77,14 +102,14 @@ describe("lastcode-wait-for-pr", () => { "--repo", "lastobelus/lastCode", "--json", - "number,url,state,isDraft,headRefOid,baseRefOid,baseRefName,mergeable,mergeStateStatus,statusCheckRollup", + "number,url,state,isDraft,headRefOid,baseRefOid,baseRefName,mergeable,mergeStateStatus,potentialMergeCommit", ]); expect(() => pullRequestViewArgs("lastobelus/lastCode", "")).toThrow( "requires a checked-out branch", ); }); - it("discards review observations when the head or base changes during collection", () => { + it("discards observations when the exact PR revision changes during collection", () => { const initial = observation().pullRequest; expect(samePullRequestRevision(initial, observation().pullRequest)).toBe(true); expect( @@ -93,18 +118,22 @@ describe("lastcode-wait-for-pr", () => { expect( samePullRequestRevision(initial, observation({ base: "3".repeat(40) }).pullRequest), ).toBe(false); + expect( + samePullRequestRevision(initial, observation({ merge: "4".repeat(40) }).pullRequest), + ).toBe(true); + expect(samePullRequestRevision(initial, observation({ number: 88 }).pullRequest)).toBe(false); }); it("requires a matching second review snapshot before returning ready", () => { expect( requiresReadyConfirmation( - observation({ ci: "success", review: handledReview, unresolvedReviewThreads: 0 }), + observation({ ci: satisfiedCi, review: handledReview, unresolvedReviewThreads: 0 }), ), ).toBe(true); expect(requiresReadyConfirmation(observation({ review: handledReview }))).toBe(false); expect( requiresReadyConfirmation( - observation({ ci: "success", review: handledReview, unresolvedReviewThreads: 1 }), + observation({ ci: satisfiedCi, review: handledReview, unresolvedReviewThreads: 1 }), ), ).toBe(false); }); @@ -121,13 +150,13 @@ describe("lastcode-wait-for-pr", () => { it("keeps waiting when CI succeeds while the current-head review is pending", () => { const baseline = observation(); - expect(decideWaitForPr(baseline, observation({ ci: "success" }))).toEqual({ + expect(decideWaitForPr(baseline, observation({ ci: satisfiedCi }))).toEqual({ kind: "wait", reason: "review-pending", }); }); - it("wakes for a new clean or finding-bearing review even while CI is pending", () => { + it("keeps a new clean review asleep while CI is pending", () => { const baseline = observation(); const currentReview = { ...handledReview, @@ -136,15 +165,15 @@ describe("lastcode-wait-for-pr", () => { { key: "review:21", observedAt: "2026-08-24T10:06:00Z" }, ], }; - expect(decideWaitForPr(baseline, observation({ review: currentReview }))).toMatchObject({ - kind: "wake", - reason: "review-completed", + expect(decideWaitForPr(baseline, observation({ review: currentReview }))).toEqual({ + kind: "wait", + reason: "ci-pending", }); }); it("wakes for current-head CI failure even while review is pending", () => { const baseline = observation(); - expect(decideWaitForPr(baseline, observation({ ci: "failure" }))).toMatchObject({ + expect(decideWaitForPr(baseline, observation({ ci: failedCi }))).toMatchObject({ kind: "wake", reason: "ci-failed", }); @@ -160,7 +189,7 @@ describe("lastcode-wait-for-pr", () => { it("returns ready after CI succeeds with a previously handled review", () => { const baseline = observation({ review: handledReview }); expect( - decideWaitForPr(baseline, observation({ ci: "success", review: handledReview })), + decideWaitForPr(baseline, observation({ ci: satisfiedCi, review: handledReview })), ).toMatchObject({ kind: "wake", reason: "ready" }); }); @@ -169,18 +198,18 @@ describe("lastcode-wait-for-pr", () => { expect( decideWaitForPr( baseline, - observation({ ci: "success", review: handledReview, mergeable: "UNKNOWN" }), + observation({ ci: satisfiedCi, review: handledReview, mergeable: "UNKNOWN" }), ), ).toEqual({ kind: "wait", reason: "mergeability-pending" }); expect( decideWaitForPr( baseline, - observation({ ci: "success", review: handledReview, mergeStateStatus: "UNKNOWN" }), + observation({ ci: satisfiedCi, review: handledReview, mergeStateStatus: "UNKNOWN" }), ), ).toEqual({ kind: "wait", reason: "mergeability-pending" }); }); - it("wakes when the exact head or base drifts", () => { + it("wakes when the exact head or base drifts without treating regenerated merge SHAs as drift", () => { const baseline = observation(); expect(decideWaitForPr(baseline, observation({ head: "2".repeat(40) }))).toMatchObject({ kind: "wake", @@ -190,6 +219,56 @@ describe("lastcode-wait-for-pr", () => { kind: "wake", reason: "base-changed", }); + expect(decideWaitForPr(baseline, observation({ merge: "4".repeat(40) }))).toEqual({ + kind: "wait", + reason: "review-pending", + }); + }); + + it("wakes when the checked-out branch resolves to a different pull request", () => { + expect(decideWaitForPr(observation(), observation({ number: 88 }))).toMatchObject({ + kind: "wake", + reason: "pr-changed", + }); + }); + + it("rejects dirty or mismatched local state at launch and wakes for later drift", () => { + expect(() => assertWaitStart(observation())).not.toThrow(); + expect(() => assertWaitStart(observation({ clean: false }))).toThrow("clean worktree"); + expect(() => assertWaitStart(observation({ localHead: "5".repeat(40) }))).toThrow( + "does not match PR head", + ); + + const baseline = observation(); + expect(decideWaitForPr(baseline, observation({ clean: false }))).toMatchObject({ + kind: "wake", + reason: "worktree-changed", + }); + expect(decideWaitForPr(baseline, observation({ localHead: "5".repeat(40) }))).toMatchObject({ + kind: "wake", + reason: "local-head-changed", + }); + }); + + it("bounds only registration, merge recomputation, and review pending waits", () => { + expect(waitTimeoutClass("mergeability-pending")).toBe("merge-recompute"); + expect(waitTimeoutClass("ci-registration")).toBe("ci-registration"); + expect(waitTimeoutClass("review-pending")).toBe("review"); + expect(waitTimeoutClass("ci-pending")).toBeNull(); + expect(decideWaitTimeout("ci-registration", CI_REGISTRATION_TIMEOUT_MS - 1)).toBeNull(); + expect(decideWaitTimeout("ci-registration", CI_REGISTRATION_TIMEOUT_MS)).toMatchObject({ + kind: "wake", + reason: "ci-registration-timeout", + }); + expect(decideWaitTimeout("mergeability-pending", MERGE_RECOMPUTE_TIMEOUT_MS)).toMatchObject({ + kind: "wake", + reason: "merge-recompute-timeout", + }); + expect(decideWaitTimeout("review-pending", REVIEW_TIMEOUT_MS)).toMatchObject({ + kind: "wake", + reason: "review-timeout", + }); + expect(decideWaitTimeout("ci-pending", REVIEW_TIMEOUT_MS * 10)).toBeNull(); }); it("wakes for blocked mergeability without treating ordinary BLOCKED status as a conflict", () => { @@ -210,7 +289,7 @@ describe("lastcode-wait-for-pr", () => { decideWaitForPr( observation({ review: handledReview, mergeStateStatus: "BLOCKED" }), observation({ - ci: "success", + ci: satisfiedCi, review: handledReview, mergeStateStatus: "BLOCKED", }), @@ -537,9 +616,9 @@ describe("lastcode-wait-for-pr", () => { ]); const baseline = observation(); - expect(decideWaitForPr(baseline, observation({ ci: "success", review }))).toMatchObject({ + expect(decideWaitForPr(baseline, observation({ ci: satisfiedCi, review }))).toMatchObject({ kind: "wake", - reason: "review-completed", + reason: "review-unhandled", }); expect(decideWaitForPr(observation({ review }), observation({ review }))).toMatchObject({ @@ -567,7 +646,7 @@ describe("lastcode-wait-for-pr", () => { expect( decideWaitForPr( observation({ review: handled }), - observation({ ci: "success", review: handled }), + observation({ ci: satisfiedCi, review: handled }), ), ).toMatchObject({ kind: "wake", reason: "ready" }); @@ -649,82 +728,34 @@ describe("lastcode-wait-for-pr", () => { expect(review.terminalArtifacts).toEqual([]); }); - it("classifies check runs and status contexts without accepting cancellations", () => { - expect(classifyStatusChecks([])).toBe("pending"); - expect( - classifyStatusChecks([{ status: "COMPLETED", conclusion: "SUCCESS" }, { state: "SUCCESS" }]), - ).toBe("success"); - expect( - classifyStatusChecks([ - { status: "COMPLETED", conclusion: "SUCCESS" }, - { status: "IN_PROGRESS", conclusion: null }, - ]), - ).toBe("pending"); - expect( - classifyStatusChecks([ - { status: "COMPLETED", conclusion: "CANCELLED" }, - { status: "IN_PROGRESS", conclusion: null }, - ]), - ).toBe("failure"); - }); - - it("classifies only the newest run when a failed check is rerun", () => { + it("distinguishes GitHub registration, execution, and configuration states", () => { + const baseline = observation({ review: handledReview }); expect( - classifyStatusChecks([ - { - name: "build", - workflowName: "CI", - status: "COMPLETED", - conclusion: "FAILURE", - startedAt: "2026-08-24T10:00:00Z", - completedAt: "2026-08-24T10:01:00Z", - }, - { - name: "build", - workflowName: "CI", - status: "IN_PROGRESS", - conclusion: null, - startedAt: "2026-08-24T10:02:00Z", - completedAt: "0001-01-01T00:00:00Z", - }, - ]), - ).toBe("pending"); - }); - - it("keeps same-named checks from distinct kinds and providers", () => { + decideWaitForPr( + baseline, + observation({ + review: handledReview, + ci: { state: "pending", reason: "run-registration" }, + }), + ), + ).toEqual({ kind: "wait", reason: "ci-registration" }); expect( - classifyStatusChecks([ - { - __typename: "StatusContext", - context: "build", - state: "FAILURE", - }, - { - __typename: "CheckRun", - name: "build", - status: "COMPLETED", - conclusion: "SUCCESS", - startedAt: "2026-08-24T10:00:00Z", - }, - ]), - ).toBe("failure"); + decideWaitForPr( + baseline, + observation({ + review: handledReview, + ci: { state: "pending", reason: "run-in-progress" }, + }), + ), + ).toEqual({ kind: "wait", reason: "ci-pending" }); expect( - classifyStatusChecks([ - { - __typename: "CheckRun", - name: "verify", - detailsUrl: "https://checks.example-a.com/runs/1", - status: "COMPLETED", - conclusion: "FAILURE", - }, - { - __typename: "CheckRun", - name: "verify", - detailsUrl: "https://checks.example-b.com/runs/2", - status: "COMPLETED", - conclusion: "SUCCESS", - }, - ]), - ).toBe("failure"); + decideWaitForPr( + baseline, + observation({ + review: handledReview, + ci: { state: "failure", reason: "configuration", detail: "CI is disabled." }, + }), + ), + ).toMatchObject({ kind: "wake", reason: "ci-configuration" }); }); }); diff --git a/scripts/lastcode-wait-for-pr.ts b/scripts/lastcode-wait-for-pr.ts index a30c22ad9550..be2174bc0b8a 100644 --- a/scripts/lastcode-wait-for-pr.ts +++ b/scripts/lastcode-wait-for-pr.ts @@ -1,14 +1,30 @@ #!/usr/bin/env node -// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalTimers:off -- Read-only host-side GitHub polling. +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalDate:off globalTimers:off -- Read-only host-side GitHub polling. import * as NodeChildProcess from "node:child_process"; +import { + evaluateGithubCi, + githubBranchRulesArgs, + githubCiJobsArgs, + githubCiRunsArgs, + githubCiWorkflowArgs, + type GithubBranchRule, + type GithubCiEvidence, + type GithubWorkflow, + type GithubWorkflowJob, + type GithubWorkflowRun, +} from "./lastcode-github-ci.ts"; + const LASTCODE_GITHUB_REPOSITORY = process.env.LASTCODE_GITHUB_REPOSITORY ?? "lastobelus/lastCode"; const LASTCODE_BASE_BRANCH = "lastcode/main"; const CODEX_BOT_LOGIN = "chatgpt-codex-connector[bot]"; const TRUSTED_AUTHOR_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); const POLL_INTERVAL_MS = 60_000; const GH_TIMEOUT_MS = 30_000; +export const CI_REGISTRATION_TIMEOUT_MS = 10 * 60_000; +export const MERGE_RECOMPUTE_TIMEOUT_MS = 10 * 60_000; +export const REVIEW_TIMEOUT_MS = 30 * 60_000; type PullRequestState = { readonly number: number; @@ -20,20 +36,7 @@ type PullRequestState = { readonly baseRefName: string; readonly mergeable: string; readonly mergeStateStatus: string; - readonly statusCheckRollup: ReadonlyArray | null; -}; - -type StatusCheck = { - readonly __typename?: string; - readonly name?: string; - readonly context?: string; - readonly workflowName?: string | null; - readonly detailsUrl?: string | null; - readonly startedAt?: string | null; - readonly completedAt?: string | null; - readonly status?: string; - readonly conclusion?: string | null; - readonly state?: string; + readonly potentialMergeCommit?: { readonly oid?: string } | null; }; type GitHubActor = { @@ -71,8 +74,6 @@ type CommentReaction = { readonly created_at?: string; }; -export type CiState = "pending" | "success" | "failure"; - export interface ReviewArtifact { readonly key: string; readonly observedAt: string; @@ -88,112 +89,47 @@ export interface ReviewState { export interface WaitObservation { readonly pullRequest: PullRequestState; - readonly ci: CiState; + readonly ci: GithubCiEvidence; readonly review: ReviewState; readonly unresolvedReviewThreads: number; + readonly local: LocalState; +} + +export interface LocalState { + readonly branch: string; + readonly head: string; + readonly clean: boolean; } export type WaitDecision = | { readonly kind: "wait"; - readonly reason: "ci-pending" | "mergeability-pending" | "review-pending"; + readonly reason: "ci-pending" | "ci-registration" | "mergeability-pending" | "review-pending"; } | { readonly kind: "wake"; readonly reason: | "base-changed" + | "ci-configuration" | "ci-failed" + | "ci-registration-timeout" | "head-changed" + | "local-head-changed" | "merge-blocked" + | "merge-recompute-timeout" + | "pr-changed" | "pr-closed" | "pr-draft" | "ready" - | "review-completed" | "review-not-requested" + | "review-timeout" | "review-unhandled" | "review-unresolved" - | "unexpected-base"; + | "unexpected-base" + | "worktree-changed"; readonly detail: string; }; -const successfulCheckConclusions = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]); - -const checkProvider = (detailsUrl: string | null | undefined): string | null => { - if (!detailsUrl) return null; - try { - const url = new URL(detailsUrl); - const pathIdentity = url.pathname.split("/").filter(Boolean).slice(0, 2).join("/"); - return `${url.origin}/${pathIdentity}`; - } catch { - return null; - } -}; - -const checkIdentity = (check: StatusCheck, index: number): string => { - const kind = check.__typename ?? (check.context ? "StatusContext" : "CheckRun"); - const name = check.name ?? check.context; - if (!name) return `nameless\u0000${index}`; - if (kind === "StatusContext") return `${kind}\u0000${name}`; - if (check.workflowName) return `${kind}\u0000${check.workflowName}\u0000${name}`; - const provider = checkProvider(check.detailsUrl); - return provider ? `${kind}\u0000${provider}\u0000${name}` : `${kind}\u0000${name}\u0000${index}`; -}; - -const checkTimestamp = (check: StatusCheck): number | null => { - for (const value of [check.completedAt, check.startedAt]) { - if (value) { - const parsed = Date.parse(value); - if (!Number.isNaN(parsed) && parsed > 0) return parsed; - } - } - return null; -}; - -const latestStatusChecks = (checks: ReadonlyArray): ReadonlyArray => { - const newestByIdentity = new Map< - string, - { readonly check: StatusCheck; readonly at: number | null } - >(); - for (const [index, check] of checks.entries()) { - const identity = checkIdentity(check, index); - const candidate = { check, at: checkTimestamp(check) }; - const kept = newestByIdentity.get(identity); - if ( - kept === undefined || - (candidate.at === null ? kept.at === null : kept.at === null || candidate.at >= kept.at) - ) { - newestByIdentity.set(identity, candidate); - } - } - return [...newestByIdentity.values()].map(({ check }) => check); -}; - -export function classifyStatusChecks(checks: PullRequestState["statusCheckRollup"]): CiState { - if (!checks || checks.length === 0) return "pending"; - - let pending = false; - for (const check of latestStatusChecks(checks)) { - if (check.status !== undefined) { - if (check.status !== "COMPLETED") { - pending = true; - continue; - } - if (!check.conclusion || !successfulCheckConclusions.has(check.conclusion)) { - return "failure"; - } - continue; - } - - if (check.state === "SUCCESS") continue; - if (check.state === "PENDING" || check.state === "EXPECTED" || check.state === undefined) { - pending = true; - continue; - } - return "failure"; - } - return pending ? "pending" : "success"; -} - export function pullRequestViewArgs(repository: string, branch: string): ReadonlyArray { if (branch.length === 0) { throw new Error("Wait for PR requires a checked-out branch."); @@ -205,15 +141,19 @@ export function pullRequestViewArgs(repository: string, branch: string): Readonl "--repo", repository, "--json", - "number,url,state,isDraft,headRefOid,baseRefOid,baseRefName,mergeable,mergeStateStatus,statusCheckRollup", + "number,url,state,isDraft,headRefOid,baseRefOid,baseRefName,mergeable,mergeStateStatus,potentialMergeCommit", ]; } export function samePullRequestRevision( - initial: Pick, - final: Pick, + initial: Pick, + final: Pick, ): boolean { - return initial.headRefOid === final.headRefOid && initial.baseRefOid === final.baseRefOid; + return ( + initial.number === final.number && + initial.headRefOid === final.headRefOid && + initial.baseRefOid === final.baseRefOid + ); } const reviewThreadsQuery = `query($owner:String!,$name:String!,$number:Int!,$endCursor:String){ @@ -424,6 +364,30 @@ export function deriveReviewState(input: { export function decideWaitForPr(baseline: WaitObservation, current: WaitObservation): WaitDecision { const pullRequest = current.pullRequest; + if (!current.local.clean) { + return { + kind: "wake", + reason: "worktree-changed", + detail: "The worktree became dirty while waiting for pull request gates.", + }; + } + if ( + current.local.branch !== baseline.local.branch || + current.local.head !== baseline.local.head + ) { + return { + kind: "wake", + reason: "local-head-changed", + detail: `Local revision changed from ${baseline.local.branch}@${baseline.local.head} to ${current.local.branch}@${current.local.head}.`, + }; + } + if (pullRequest.number !== baseline.pullRequest.number) { + return { + kind: "wake", + reason: "pr-changed", + detail: `Checked-out branch now resolves to pull request #${pullRequest.number}, not #${baseline.pullRequest.number}.`, + }; + } if (pullRequest.state !== "OPEN") { return { kind: "wake", @@ -471,22 +435,11 @@ export function decideWaitForPr(baseline: WaitObservation, current: WaitObservat }; } - const baselineArtifacts = new Set(baseline.review.terminalArtifacts.map(({ key }) => key)); - const newArtifacts = current.review.terminalArtifacts.filter( - ({ key }) => !baselineArtifacts.has(key), - ); - if (newArtifacts.length > 0) { + if (current.ci.state === "failure") { return { kind: "wake", - reason: "review-completed", - detail: `Codex delivered ${newArtifacts.length} new current-head review artifact${newArtifacts.length === 1 ? "" : "s"}.`, - }; - } - if (current.ci === "failure") { - return { - kind: "wake", - reason: "ci-failed", - detail: `Current-head CI for pull request #${pullRequest.number} needs attention.`, + reason: current.ci.reason === "configuration" ? "ci-configuration" : "ci-failed", + detail: current.ci.detail, }; } if (!current.review.requestPresent) { @@ -514,7 +467,7 @@ export function decideWaitForPr(baseline: WaitObservation, current: WaitObservat return { kind: "wait", reason: "mergeability-pending" }; } if ( - current.ci === "success" && + current.ci.state === "satisfied" && !current.review.pending && current.review.ready && pullRequest.mergeStateStatus === "BLOCKED" @@ -525,16 +478,57 @@ export function decideWaitForPr(baseline: WaitObservation, current: WaitObservat detail: `Pull request #${pullRequest.number} is blocked by a repository merge requirement.`, }; } - if (current.ci === "success" && !current.review.pending && current.review.ready) { + if (current.ci.state === "satisfied" && !current.review.pending && current.review.ready) { return { kind: "wake", reason: "ready", detail: `GitHub CI and the handled Codex review are complete for pull request #${pullRequest.number}.`, }; } - return current.review.pending - ? { kind: "wait", reason: "review-pending" } - : { kind: "wait", reason: "ci-pending" }; + if (current.ci.state === "pending" && current.ci.reason === "run-registration") { + return { kind: "wait", reason: "ci-registration" }; + } + if (current.review.pending) return { kind: "wait", reason: "review-pending" }; + return { kind: "wait", reason: "ci-pending" }; +} + +export function decideWaitTimeout( + reason: Extract["reason"], + elapsedMs: number, +): WaitDecision | null { + if (reason === "ci-registration" && elapsedMs >= CI_REGISTRATION_TIMEOUT_MS) { + return { + kind: "wake", + reason: "ci-registration-timeout", + detail: `Expected GitHub CI did not register within ${CI_REGISTRATION_TIMEOUT_MS / 60_000} minutes.`, + }; + } + if (reason === "mergeability-pending" && elapsedMs >= MERGE_RECOMPUTE_TIMEOUT_MS) { + return { + kind: "wake", + reason: "merge-recompute-timeout", + detail: `GitHub did not establish the PR merge revision within ${MERGE_RECOMPUTE_TIMEOUT_MS / 60_000} minutes.`, + }; + } + if (reason === "review-pending" && elapsedMs >= REVIEW_TIMEOUT_MS) { + return { + kind: "wake", + reason: "review-timeout", + detail: `Codex review remained pending for ${REVIEW_TIMEOUT_MS / 60_000} minutes.`, + }; + } + return null; +} + +export function waitTimeoutClass( + reason: Extract["reason"], +): "ci-registration" | "merge-recompute" | "review" | null { + if (reason === "ci-registration") return "ci-registration"; + if (reason === "mergeability-pending") { + return "merge-recompute"; + } + if (reason === "review-pending") return "review"; + return null; } function runGhJson(args: ReadonlyArray): T { @@ -551,6 +545,43 @@ function runGhJson(args: ReadonlyArray): T { return JSON.parse(result.stdout) as T; } +type WorkflowRunsResponse = { + readonly workflow_runs?: ReadonlyArray; +}; + +type WorkflowJobsResponse = { + readonly jobs?: ReadonlyArray; +}; + +function readGithubCi(repository: string, pullRequest: PullRequestState): GithubCiEvidence { + const workflow = runGhJson(githubCiWorkflowArgs(repository)); + const branchRules = runGhJson>( + githubBranchRulesArgs(repository, pullRequest.baseRefName), + ); + const workflowRuns = + runGhJson(githubCiRunsArgs(repository, pullRequest.headRefOid)) + .workflow_runs ?? []; + const evaluation = { + workflow, + branchRules, + pullRequestNumber: pullRequest.number, + headSha: pullRequest.headRefOid, + baseSha: pullRequest.baseRefOid, + workflowRuns, + jobs: null, + } as const; + const provisional = evaluateGithubCi(evaluation); + const completedSuccessRun = workflowRuns.find( + (run) => + run.id === provisional.runId && run.status === "completed" && run.conclusion === "success", + ); + if (!completedSuccessRun?.id) return provisional; + const jobs = + runGhJson(githubCiJobsArgs(repository, completedSuccessRun.id)).jobs ?? + []; + return evaluateGithubCi({ ...evaluation, jobs }); +} + function paginatedGhApi(endpoint: string): ReadonlyArray { const pages = runGhJson>>([ "api", @@ -593,21 +624,45 @@ function reviewThreadsSnapshot( }; } -function currentBranch(): string { - const result = NodeChildProcess.spawnSync("git", ["branch", "--show-current"], { +function runGitText(args: ReadonlyArray): string { + const result = NodeChildProcess.spawnSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: GH_TIMEOUT_MS, }); if (result.error) throw result.error; if (result.status !== 0) { - throw new Error(result.stderr.trim() || "Failed to resolve the current Git branch."); + throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed.`); } - const branch = result.stdout.trim(); + return result.stdout.trim(); +} + +function currentBranch(): string { + const branch = runGitText(["branch", "--show-current"]); if (branch.length === 0) throw new Error("Wait for PR requires a checked-out branch."); return branch; } +function readLocalState(): LocalState { + return { + branch: currentBranch(), + head: runGitText(["rev-parse", "HEAD"]), + clean: runGitText(["status", "--porcelain=v1", "--untracked-files=all"]).length === 0, + }; +} + +const sameLocalState = (left: LocalState, right: LocalState): boolean => + left.branch === right.branch && left.head === right.head && left.clean === right.clean; + +export function assertWaitStart(observation: WaitObservation): void { + if (!observation.local.clean) throw new Error("Wait for PR requires a clean worktree."); + if (observation.local.head !== observation.pullRequest.headRefOid) { + throw new Error( + `Local HEAD ${observation.local.head} does not match PR head ${observation.pullRequest.headRefOid}.`, + ); + } +} + type ReviewDataSnapshot = { readonly review: ReviewState; readonly unresolvedReviewThreads: number; @@ -652,7 +707,7 @@ function readReviewData(repository: string, pullRequest: PullRequestState): Revi export function requiresReadyConfirmation(observation: WaitObservation): boolean { return ( - observation.ci === "success" && + observation.ci.state === "satisfied" && !observation.review.pending && observation.review.ready && observation.unresolvedReviewThreads === 0 @@ -662,34 +717,48 @@ export function requiresReadyConfirmation(observation: WaitObservation): boolean const observationFrom = ( pullRequest: PullRequestState, reviewData: ReviewDataSnapshot, + ci: GithubCiEvidence, + local: LocalState, ): WaitObservation => ({ pullRequest, - ci: classifyStatusChecks(pullRequest.statusCheckRollup), + ci, review: reviewData.review, unresolvedReviewThreads: reviewData.unresolvedReviewThreads, + local, }); function readObservation(repository: string, branch: string): WaitObservation { while (true) { + const initialLocal = readLocalState(); const initialPullRequest = runGhJson(pullRequestViewArgs(repository, branch)); const initialReviewData = readReviewData(repository, initialPullRequest); + const ci = readGithubCi(repository, initialPullRequest); const pullRequest = runGhJson(pullRequestViewArgs(repository, branch)); - if (!samePullRequestRevision(initialPullRequest, pullRequest)) continue; + const local = readLocalState(); + if ( + !samePullRequestRevision(initialPullRequest, pullRequest) || + !sameLocalState(initialLocal, local) + ) { + continue; + } - const observation = observationFrom(pullRequest, initialReviewData); + const observation = observationFrom(pullRequest, initialReviewData, ci, local); if (!requiresReadyConfirmation(observation)) return observation; const confirmedReviewData = readReviewData(repository, pullRequest); + const confirmedCi = readGithubCi(repository, pullRequest); const confirmedPullRequest = runGhJson( pullRequestViewArgs(repository, branch), ); + const confirmedLocal = readLocalState(); if ( !samePullRequestRevision(pullRequest, confirmedPullRequest) || + !sameLocalState(local, confirmedLocal) || initialReviewData.fingerprint !== confirmedReviewData.fingerprint ) { continue; } - return observationFrom(confirmedPullRequest, confirmedReviewData); + return observationFrom(confirmedPullRequest, confirmedReviewData, confirmedCi, confirmedLocal); } } @@ -698,7 +767,9 @@ const summary = (observation: WaitObservation): string => pr: observation.pullRequest.number, head: observation.pullRequest.headRefOid, base: observation.pullRequest.baseRefOid, - ci: observation.ci, + merge: observation.pullRequest.potentialMergeCommit?.oid ?? null, + ci: observation.ci.state, + ciReason: observation.ci.reason, review: observation.review.pending ? "pending" : observation.review.ready @@ -714,13 +785,24 @@ const sleep = (durationMs: number): Promise => async function main(): Promise { const branch = currentBranch(); - const baseline = readObservation(LASTCODE_GITHUB_REPOSITORY, branch); + let baseline = readObservation(LASTCODE_GITHUB_REPOSITORY, branch); + assertWaitStart(baseline); console.log(`[wait-for-pr] Baseline ${summary(baseline)}`); let previousSummary = ""; let current = baseline; + let pendingClass: ReturnType = null; + let pendingSince = Date.now(); while (true) { - const decision = decideWaitForPr(baseline, current); + let decision = decideWaitForPr(baseline, current); + if (decision.kind === "wait") { + const timeoutClass = waitTimeoutClass(decision.reason); + if (pendingClass !== timeoutClass) { + pendingClass = timeoutClass; + pendingSince = Date.now(); + } + decision = decideWaitTimeout(decision.reason, Date.now() - pendingSince) ?? decision; + } if (decision.kind === "wake") { console.log( `[wait-for-pr] Result ${JSON.stringify({ @@ -730,6 +812,7 @@ async function main(): Promise { url: current.pullRequest.url, head: current.pullRequest.headRefOid, base: current.pullRequest.baseRefOid, + merge: current.pullRequest.potentialMergeCommit?.oid ?? null, ci: current.ci, reviewPending: current.review.pending, reviewReady: current.review.ready, diff --git a/scripts/lib/lastcode-packaged-server-runtime.test.ts b/scripts/lib/lastcode-packaged-server-runtime.test.ts index 209285565528..3cb66baa4bd4 100644 --- a/scripts/lib/lastcode-packaged-server-runtime.test.ts +++ b/scripts/lib/lastcode-packaged-server-runtime.test.ts @@ -59,22 +59,17 @@ function temporaryDirectory() { } function programArgumentsFromPlist(plist: string): ReadonlyArray { - const result = NodeChildProcess.spawnSync( - "/usr/bin/plutil", - ["-convert", "json", "-o", "-", "-"], - { encoding: "utf8", input: plist }, + const array = /ProgramArguments<\/key>\s*(?[\s\S]*?)<\/array>/u.exec(plist) + ?.groups?.body; + if (!array) throw new Error("Rendered LaunchAgent has invalid ProgramArguments."); + return [...array.matchAll(/(?[\s\S]*?)<\/string>/gu)].map(({ groups }) => + (groups?.value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'"), ); - if (result.status !== 0) { - throw new Error(`Could not decode rendered LaunchAgent: ${result.stderr}`); - } - const parsed = JSON.parse(result.stdout) as { readonly ProgramArguments?: unknown }; - if ( - !Array.isArray(parsed.ProgramArguments) || - !parsed.ProgramArguments.every((argument) => typeof argument === "string") - ) { - throw new Error("Rendered LaunchAgent has invalid ProgramArguments."); - } - return parsed.ProgramArguments; } function writeBuildManifest(root: string, overrides: Record = {}) {