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 = {}) {