diff --git a/.github/workflows/lastcode-intel-artifact.yml b/.github/workflows/lastcode-intel-artifact.yml index b79835233244..7bc8bf445534 100644 --- a/.github/workflows/lastcode-intel-artifact.yml +++ b/.github/workflows/lastcode-intel-artifact.yml @@ -1,5 +1,7 @@ name: LastCode Intel artifact +run-name: Build Intel package · ${{ inputs.installable_tag }} · ${{ inputs.request_token }} + on: workflow_dispatch: inputs: @@ -11,6 +13,10 @@ on: description: Full commit SHA advertised for the installable tag required: true type: string + request_token: + description: Unique token used to correlate this dispatch to one workflow run + required: true + type: string permissions: contents: read @@ -30,6 +36,7 @@ jobs: env: INSTALLABLE_TAG: ${{ inputs.installable_tag }} INSTALLABLE_COMMIT: ${{ inputs.installable_commit }} + REQUEST_TOKEN: ${{ inputs.request_token }} steps: - name: Checkout workflow automation uses: actions/checkout@v6 @@ -75,6 +82,10 @@ jobs: echo "installable_commit must be a full lowercase commit SHA." >&2 exit 1 fi + if [[ ! "$REQUEST_TOKEN" =~ ^intel-[0-9a-f-]{36}$ ]]; then + echo "request_token must be an intel-prefixed UUID." >&2 + exit 1 + fi git fetch --force --no-tags origin "refs/tags/${INSTALLABLE_TAG}:refs/tags/${INSTALLABLE_TAG}" resolved_commit="$(git rev-parse "${INSTALLABLE_TAG}^{commit}")" diff --git a/docs/lastcode/release.md b/docs/lastcode/release.md index 51f37ed87f90..c61bf8acd27d 100644 --- a/docs/lastcode/release.md +++ b/docs/lastcode/release.md @@ -121,11 +121,38 @@ until LastCode intentionally publishes compatible releases. ## Intel Build Publication -The manually dispatched **LastCode Intel artifact** workflow accepts one exact +The resumable **Build Intel package (macOS)** Project Action dispatches the manual +**LastCode Intel artifact** workflow for one exact `lastcode/checkpoint/...` or `lastcode/revision/...` tag and its full advertised commit. It rejects moving refs and tag/commit mismatches, runs the checkpoint's full CI gate on `macos-15-intel`, and builds a certificate-free x64 artifact. +The agent chooses the target explicitly. First record and verify that exact remote +tag in the current worktree: + +```bash +pnpm lastcode:intel-build select \ + --tag lastcode/revision/v0.0.34-nightly.20260825.1185.3 +``` + +Then run the imported **Build Intel package (macOS)** Project Action. The LastCode +environment controlling this Action must run on macOS; the actual x64 build still +runs on GitHub's hosted Intel runner. For agent-triggered +one-shot continuation, enable **Allow Codex and Claude to run and resume** on that +Action in Project Settings. The Action attaches a unique request token to the +dispatch, waits for only the matching workflow run, and returns to the thread on +success, workflow failure or cancellation, missing workflow configuration, or a +run-registration timeout. It reports both the workflow-run URL and immutable +release URL. + +The request is marked before dispatch so an interrupted or ambiguous transport +result is never dispatched a second time. The Action waits for the unique token +to appear and then records the matching run ID for later reattachment. If GitHub +never registers it, select the tag again to create a deliberate new request. + +The Action only builds and publishes. It never stages, installs, promotes, +restarts, or updates an Intel target. Those remain separate agent decisions. + Successful output is attached to the installable tag as a GitHub prerelease, explicitly excluded from GitHub's latest-release selection. The release contains the complete build-manifest asset set, `build-manifest.json`, and `SHA256SUMS`. @@ -151,8 +178,8 @@ deletes an exact-tag release. Recovery from a partial or conflicting publication therefore requires a maintainer decision rather than silently changing an immutable artifact. -This workflow remains manual-only. Scheduling and installation are separate -rollout gates. +This workflow remains explicitly selected and action-dispatched. Scheduling and +installation are separate rollout gates. ### Intel target staging diff --git a/package.json b/package.json index 6b7a499b9ef0..98a7c391b01e 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "lastcode:checkpoints": "mise exec node@24.13.1 -- node scripts/lastcode-checkpoints.mjs", "lastcode:build": "mise exec node@24.13.1 -- node scripts/lastcode-build.mjs", "lastcode:install": "mise exec node@24.13.1 -- node scripts/lastcode-install.mjs", + "lastcode:intel-build": "mise exec node@24.13.1 -- node scripts/lastcode-build-intel-package.ts", "lastcode:intel-stage": "mise exec node@24.13.1 -- node scripts/lastcode-intel-stage.mjs", "lastcode:daily-update": "mise exec node@24.13.1 -- node scripts/lastcode-daily-update.mjs", "lastcode:headless-service": "mise exec node@24.13.1 -- node scripts/lastcode-headless-service.mjs", diff --git a/scripts/lastcode-build-intel-package.test.ts b/scripts/lastcode-build-intel-package.test.ts new file mode 100644 index 000000000000..3ba83581f3c6 --- /dev/null +++ b/scripts/lastcode-build-intel-package.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + findCorrelatedRun, + parseRemoteInstallableRefs, + parseIntelBuildOptions, + runSelectedIntelBuild, + selectIntelBuild, + type BuildIntelDependencies, + type BuildRequest, + type WorkflowRun, + validateInstallableTag, + workflowRunName, +} from "./lastcode-build-intel-package.ts"; + +const tag = "lastcode/revision/v0.0.34-nightly.20260825.1185.3"; +const commit = "a".repeat(40); + +const request = (overrides: Partial = {}): BuildRequest => ({ + schemaVersion: 1, + installableTag: tag, + installableCommit: commit, + requestToken: "intel-12345678-1234-1234-1234-123456789abc", + selectedAt: "2026-08-27T00:00:00.000Z", + dispatchAttemptedAt: null, + workflowRunId: null, + ...overrides, +}); + +const workflowRun = (overrides: Partial = {}): WorkflowRun => ({ + databaseId: 123, + displayTitle: workflowRunName(request()), + status: "queued", + conclusion: null, + url: "https://github.com/lastobelus/lastCode/actions/runs/123", + headSha: "b".repeat(40), + createdAt: "2026-08-27T00:01:00.000Z", + ...overrides, +}); + +function harness(input: { + readonly initialRequest?: BuildRequest; + readonly listedRuns?: ReadonlyArray>; + readonly viewedRuns?: ReadonlyArray; + readonly workflowError?: Error; +}) { + let currentRequest = input.initialRequest ?? request(); + let now = 0; + let listIndex = 0; + let viewIndex = 0; + const calls: string[] = []; + const listedRuns = input.listedRuns ?? [[workflowRun()]]; + const viewedRuns = input.viewedRuns ?? [ + workflowRun({ status: "completed", conclusion: "success" }), + ]; + const dependencies: BuildIntelDependencies = { + now: () => now, + nowIso: () => "2026-08-27T00:02:00.000Z", + sleep: async (milliseconds) => { + calls.push(`sleep:${milliseconds}`); + now += milliseconds; + }, + verifyWorkflow: () => { + calls.push("verify"); + if (input.workflowError) throw input.workflowError; + }, + dispatchWorkflow: (selected) => calls.push(`dispatch:${selected.requestToken}`), + listWorkflowRuns: () => { + calls.push("list"); + const result = listedRuns[Math.min(listIndex, listedRuns.length - 1)] ?? []; + listIndex += 1; + return result; + }, + readWorkflowRun: () => { + calls.push("view"); + const result = viewedRuns[Math.min(viewIndex, viewedRuns.length - 1)]; + viewIndex += 1; + if (!result) throw new Error("missing viewed run fixture"); + return result; + }, + readRelease: (releaseTag) => { + calls.push(`release:${releaseTag}`); + return { + tagName: releaseTag, + url: `https://github.com/lastobelus/lastCode/releases/tag/${releaseTag}`, + isDraft: false, + isImmutable: true, + isPrerelease: true, + assets: [{ name: "LastCode-x64.dmg" }, { name: "build-manifest.json" }], + }; + }, + readRequest: () => currentRequest, + writeRequest: (next) => { + calls.push("write"); + currentRequest = next; + }, + removeRequest: (token) => calls.push(`remove:${token}`), + withRequestLock: (operation) => { + calls.push("lock:enter"); + try { + return operation(); + } finally { + calls.push("lock:exit"); + } + }, + log: (message) => calls.push(`log:${message}`), + registrationTimeoutMs: 20, + registrationPollMs: 5, + runTimeoutMs: 30, + runPollMs: 10, + }; + return { + calls, + dependencies, + getRequest: () => currentRequest, + setRequest: (next: BuildRequest) => { + currentRequest = next; + }, + }; +} + +describe("lastcode-build-intel-package", () => { + it("accepts only exact checkpoint and revision tags", () => { + expect(validateInstallableTag(tag)).toBe(tag); + expect(validateInstallableTag("lastcode/checkpoint/v0.0.34-nightly.20260825.1185")).toBe( + "lastcode/checkpoint/v0.0.34-nightly.20260825.1185", + ); + for (const invalid of [ + "v0.0.34-nightly.20260825.1185.3", + "lastcode/build/v0.0.34-nightly.20260825.1185.3", + "lastcode/revision/v0.0.34-nightly.latest", + ]) { + expect(() => validateInstallableTag(invalid)).toThrow("exact lastcode/checkpoint"); + } + }); + + it("requires an explicit select or run command", () => { + expect(parseIntelBuildOptions(["select", "--tag", tag])).toEqual({ command: "select", tag }); + expect(parseIntelBuildOptions(["run"])).toEqual({ command: "run" }); + expect(() => parseIntelBuildOptions([])).toThrow("Usage:"); + expect(() => parseIntelBuildOptions(["select", tag])).toThrow("Usage:"); + }); + + it("resolves annotated and lightweight remote tags to their exact commits", () => { + const tagObject = "b".repeat(40); + expect( + parseRemoteInstallableRefs( + tag, + "origin", + `${tagObject}\trefs/tags/${tag}\n${commit}\trefs/tags/${tag}^{}\n`, + ), + ).toEqual({ tag, commit }); + expect(parseRemoteInstallableRefs(tag, "origin", `${commit}\trefs/tags/${tag}\n`)).toEqual({ + tag, + commit, + }); + expect(() => parseRemoteInstallableRefs(tag, "origin", "")).toThrow( + `does not advertise ${tag}`, + ); + expect(() => + parseRemoteInstallableRefs(tag, "origin", `not-a-sha\trefs/tags/${tag}\n`), + ).toThrow("invalid metadata"); + }); + + it("records the agent-selected remote identity and unique token", () => { + let written: BuildRequest | null = null; + const calls: string[] = []; + const selected = selectIntelBuild(tag, { + resolveTag: (candidate) => ({ tag: candidate, commit }), + writeRequest: (value) => { + written = value; + }, + nowIso: () => "2026-08-27T00:00:00.000Z", + uuid: () => "12345678-1234-1234-1234-123456789abc", + withRequestLock: (operation) => { + calls.push("lock:enter"); + try { + return operation(); + } finally { + calls.push("lock:exit"); + } + }, + }); + expect(selected).toEqual(request()); + expect(written).toEqual(selected); + expect(calls).toEqual(["lock:enter", "lock:exit"]); + }); + + it("correlates only the exact request-token run", () => { + const other = workflowRun({ + databaseId: 456, + displayTitle: workflowRunName( + request({ requestToken: "intel-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }), + ), + }); + expect(findCorrelatedRun(request(), [other, workflowRun()])?.databaseId).toBe(123); + expect(findCorrelatedRun(request(), [other])).toBeNull(); + expect(() => + findCorrelatedRun(request(), [workflowRun(), workflowRun({ databaseId: 124 })]), + ).toThrow("More than one workflow run"); + }); + + it("dispatches once, waits for registration and completion, then reports the release", async () => { + const test = harness({ + listedRuns: [[], [], [workflowRun()]], + viewedRuns: [ + workflowRun({ status: "in_progress" }), + workflowRun({ status: "completed", conclusion: "success" }), + ], + }); + const result = await runSelectedIntelBuild(test.dependencies); + expect(test.calls.filter((call) => call.startsWith("dispatch:"))).toHaveLength(1); + expect(test.calls.indexOf("lock:enter")).toBeLessThan(test.calls.indexOf("write")); + expect(test.calls.indexOf("lock:exit")).toBeGreaterThan( + test.calls.findIndex((call) => call.startsWith("dispatch:")), + ); + expect(test.getRequest().dispatchAttemptedAt).toBe("2026-08-27T00:02:00.000Z"); + expect(test.getRequest().workflowRunId).toBe(123); + expect(result).toMatchObject({ tag, commit, runId: 123 }); + expect(result.assets).toEqual(["LastCode-x64.dmg", "build-manifest.json"]); + expect(test.calls.slice(-3)).toEqual([ + "lock:enter", + `remove:${request().requestToken}`, + "lock:exit", + ]); + }); + + it("reattaches to an already attempted request without dispatching again", async () => { + const test = harness({ + initialRequest: request({ dispatchAttemptedAt: "2026-08-27T00:01:00.000Z" }), + listedRuns: [[], [workflowRun({ status: "completed", conclusion: "success" })]], + }); + await runSelectedIntelBuild(test.dependencies); + expect(test.calls.some((call) => call.startsWith("dispatch:"))).toBe(false); + }); + + it("reattaches directly to a stored run id without relying on the recent-run listing", async () => { + const test = harness({ + initialRequest: request({ + dispatchAttemptedAt: "2026-08-27T00:01:00.000Z", + workflowRunId: 123, + }), + listedRuns: [[]], + viewedRuns: [workflowRun({ status: "completed", conclusion: "success" })], + }); + await runSelectedIntelBuild(test.dependencies); + expect(test.calls).not.toContain("list"); + expect(test.calls.some((call) => call.startsWith("dispatch:"))).toBe(false); + }); + + it("waits for token registration after an uncertain dispatch error without dispatching twice", async () => { + const test = harness({ + listedRuns: [[], [workflowRun({ status: "completed", conclusion: "success" })]], + }); + const originalDispatch = test.dependencies.dispatchWorkflow; + const dependencies: BuildIntelDependencies = { + ...test.dependencies, + dispatchWorkflow: (selected) => { + originalDispatch(selected); + throw new Error("transport closed before response"); + }, + }; + await runSelectedIntelBuild(dependencies); + expect(test.calls.filter((call) => call.startsWith("dispatch:"))).toHaveLength(1); + expect(test.calls.some((call) => call.includes("waiting for request-token registration"))).toBe( + true, + ); + }); + + it("does not overwrite a newer selection when the old run registers", async () => { + const test = harness({ listedRuns: [[], [workflowRun()]] }); + const newer = request({ requestToken: "intel-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }); + const originalSleep = test.dependencies.sleep; + const dependencies: BuildIntelDependencies = { + ...test.dependencies, + sleep: async (milliseconds) => { + test.setRequest(newer); + await originalSleep(milliseconds); + }, + }; + await runSelectedIntelBuild(dependencies); + expect(test.getRequest()).toEqual(newer); + }); + + it("returns configuration, registration, failure, and cancellation as terminal errors", async () => { + await expect( + runSelectedIntelBuild( + harness({ workflowError: new Error("workflow disabled") }).dependencies, + ), + ).rejects.toThrow("workflow disabled"); + + const registration = harness({ listedRuns: [[]] }); + await expect(runSelectedIntelBuild(registration.dependencies)).rejects.toThrow( + "did not register workflow request", + ); + expect(registration.calls.filter((call) => call.startsWith("dispatch:"))).toHaveLength(1); + + for (const conclusion of ["failure", "cancelled"]) { + const terminal = harness({ + listedRuns: [[workflowRun({ status: "completed", conclusion })]], + }); + await expect(runSelectedIntelBuild(terminal.dependencies)).rejects.toThrow( + `ended with ${conclusion}`, + ); + expect(terminal.calls.slice(-3)).toEqual([ + "lock:enter", + `remove:${request().requestToken}`, + "lock:exit", + ]); + } + }); +}); diff --git a/scripts/lastcode-build-intel-package.ts b/scripts/lastcode-build-intel-package.ts new file mode 100644 index 000000000000..8b402a7fd608 --- /dev/null +++ b/scripts/lastcode-build-intel-package.ts @@ -0,0 +1,500 @@ +#!/usr/bin/env node + +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off globalDate:off globalTimers:off -- Host-side GitHub workflow orchestration. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { acquirePortableLock } from "./lastcode-lock.mjs"; + +const DEFAULT_REPOSITORY = "lastobelus/lastCode"; +const DEFAULT_REMOTE = "origin"; +const DEFAULT_BRANCH = "lastcode/main"; +const WORKFLOW_FILE = "lastcode-intel-artifact.yml"; +const REQUEST_SCHEMA_VERSION = 1; +const GH_TIMEOUT_MS = 30_000; +const REGISTRATION_TIMEOUT_MS = 2 * 60_000; +const REGISTRATION_POLL_MS = 5_000; +const RUN_TIMEOUT_MS = 2 * 60 * 60_000; +const RUN_POLL_MS = 30_000; + +const installableTagPattern = + /^lastcode\/(?:checkpoint|revision)\/v[0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{8}\.[0-9]+(?:\.[0-9]+)?$/u; +const fullCommitPattern = /^[0-9a-f]{40}$/u; + +export interface BuildRequest { + readonly schemaVersion: 1; + readonly installableTag: string; + readonly installableCommit: string; + readonly requestToken: string; + readonly selectedAt: string; + readonly dispatchAttemptedAt: string | null; + readonly workflowRunId: number | null; +} + +export interface WorkflowRun { + readonly databaseId: number; + readonly displayTitle: string; + readonly status: string; + readonly conclusion: string | null; + readonly url: string; + readonly headSha: string; + readonly createdAt: string; +} + +interface IntelRelease { + readonly tagName: string; + readonly url: string; + readonly isDraft: boolean; + readonly isImmutable: boolean; + readonly isPrerelease: boolean; + readonly assets: ReadonlyArray<{ readonly name: string }>; +} + +export interface BuildIntelDependencies { + readonly now: () => number; + readonly nowIso: () => string; + readonly sleep: (milliseconds: number) => Promise; + readonly verifyWorkflow: () => void; + readonly dispatchWorkflow: (request: BuildRequest) => void; + readonly listWorkflowRuns: () => ReadonlyArray; + readonly readWorkflowRun: (runId: number) => WorkflowRun; + readonly readRelease: (tag: string) => IntelRelease; + readonly readRequest: () => BuildRequest; + readonly writeRequest: (request: BuildRequest) => void; + readonly removeRequest: (requestToken: string) => void; + readonly withRequestLock: (operation: () => T) => T; + readonly log: (message: string) => void; + readonly registrationTimeoutMs: number; + readonly registrationPollMs: number; + readonly runTimeoutMs: number; + readonly runPollMs: number; +} + +export type BuildIntelResult = { + readonly tag: string; + readonly commit: string; + readonly requestToken: string; + readonly runId: number; + readonly runUrl: string; + readonly workflowCommit: string; + readonly releaseUrl: string; + readonly assets: ReadonlyArray; +}; + +function fail(message: string): never { + throw new Error(message); +} + +function runCommand(command: string, args: ReadonlyArray): string { + const result = NodeChildProcess.spawnSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: GH_TIMEOUT_MS, + maxBuffer: 16 * 1024 * 1024, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + fail(result.stderr.trim() || `${command} ${args.join(" ")} failed.`); + } + return result.stdout.trim(); +} + +function runGhJson(args: ReadonlyArray): T { + return JSON.parse(runCommand("gh", args)) as T; +} + +export function validateInstallableTag(tag: string): string { + if (!installableTagPattern.test(tag)) { + fail("Expected an exact lastcode/checkpoint/... or lastcode/revision/... nightly tag."); + } + return tag; +} + +export function resolveRemoteInstallableTag( + tag: string, + remote = DEFAULT_REMOTE, +): { readonly tag: string; readonly commit: string } { + const validatedTag = validateInstallableTag(tag); + const tagRef = `refs/tags/${validatedTag}`; + const peeledRef = `${tagRef}^{}`; + const output = runCommand("git", ["ls-remote", "--tags", remote, tagRef, peeledRef]); + return parseRemoteInstallableRefs(validatedTag, remote, output); +} + +export function parseRemoteInstallableRefs( + tag: string, + remote: string, + output: string, +): { readonly tag: string; readonly commit: string } { + const validatedTag = validateInstallableTag(tag); + const tagRef = `refs/tags/${validatedTag}`; + const peeledRef = `${tagRef}^{}`; + const lines = output.split("\n").filter((line) => line.length > 0); + const refs = new Map( + lines.map((line) => { + const [sha, ref, ...rest] = line.split("\t"); + if (!sha || !ref || rest.length > 0 || !fullCommitPattern.test(sha)) { + fail(`Remote returned invalid metadata for ${validatedTag}.`); + } + return [ref, sha] as const; + }), + ); + const commit = refs.get(peeledRef) ?? refs.get(tagRef); + if (!commit) fail(`Remote ${remote} does not advertise ${validatedTag}.`); + return { tag: validatedTag, commit }; +} + +export function workflowRunName(request: Pick) { + return `Build Intel package · ${request.installableTag} · ${request.requestToken}`; +} + +export function findCorrelatedRun( + request: Pick, + runs: ReadonlyArray, +): WorkflowRun | null { + const expectedTitle = workflowRunName(request); + const matches = runs.filter(({ displayTitle }) => displayTitle === expectedTitle); + if (matches.length > 1) { + fail(`More than one workflow run carries request token ${request.requestToken}.`); + } + return matches[0] ?? null; +} + +function validateRequest(value: unknown): BuildRequest { + if (typeof value !== "object" || value === null) fail("Intel build selection is invalid."); + const request = value as Partial; + if ( + request.schemaVersion !== REQUEST_SCHEMA_VERSION || + typeof request.installableTag !== "string" || + typeof request.installableCommit !== "string" || + typeof request.requestToken !== "string" || + typeof request.selectedAt !== "string" || + (request.dispatchAttemptedAt !== null && typeof request.dispatchAttemptedAt !== "string") || + (request.workflowRunId !== null && + (typeof request.workflowRunId !== "number" || + !Number.isSafeInteger(request.workflowRunId) || + request.workflowRunId <= 0)) + ) { + fail("Intel build selection is invalid."); + } + validateInstallableTag(request.installableTag); + if (!fullCommitPattern.test(request.installableCommit)) { + fail("Intel build selection has an invalid commit."); + } + if (!/^intel-[0-9a-f-]{36}$/u.test(request.requestToken)) { + fail("Intel build selection has an invalid request token."); + } + return request as BuildRequest; +} + +function requestPath(): string { + return NodePath.resolve( + runCommand("git", ["rev-parse", "--git-path", "lastcode-actions/build-intel-package.json"]), + ); +} + +function withRequestFileLock(operation: () => T): T { + const path = requestPath(); + const release = acquirePortableLock( + NodePath.dirname(path), + "build-intel-package.lock", + "Intel package dispatch", + ); + try { + return operation(); + } finally { + release(); + } +} + +function writeRequestFile(request: BuildRequest): void { + const path = requestPath(); + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + const temporaryPath = `${path}.tmp-${process.pid}`; + NodeFS.writeFileSync(temporaryPath, `${JSON.stringify(request, null, 2)}\n`, { mode: 0o600 }); + NodeFS.renameSync(temporaryPath, path); +} + +function readRequestFile(): BuildRequest { + const path = requestPath(); + let contents: string; + try { + contents = NodeFS.readFileSync(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + fail( + "No Intel build is selected. Run 'pnpm lastcode:intel-build select --tag ' first.", + ); + } + throw error; + } + return validateRequest(JSON.parse(contents) as unknown); +} + +function removeRequestFile(requestToken: string): void { + const path = requestPath(); + if (!NodeFS.existsSync(path)) return; + const current = readRequestFile(); + if (current.requestToken === requestToken) NodeFS.rmSync(path); +} + +function defaultDependencies(): BuildIntelDependencies { + const repository = process.env.LASTCODE_GITHUB_REPOSITORY ?? DEFAULT_REPOSITORY; + return { + now: () => Date.now(), + nowIso: () => new Date().toISOString(), + sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + verifyWorkflow: () => { + const workflow = runGhJson<{ readonly path?: string; readonly state?: string }>([ + "api", + `repos/${repository}/actions/workflows/${WORKFLOW_FILE}`, + ]); + if (workflow.path !== `.github/workflows/${WORKFLOW_FILE}` || workflow.state !== "active") { + fail(`GitHub workflow ${WORKFLOW_FILE} is not active on the repository default branch.`); + } + }, + dispatchWorkflow: (request) => { + runCommand("gh", [ + "workflow", + "run", + WORKFLOW_FILE, + "--repo", + repository, + "--ref", + DEFAULT_BRANCH, + "--field", + `installable_tag=${request.installableTag}`, + "--field", + `installable_commit=${request.installableCommit}`, + "--field", + `request_token=${request.requestToken}`, + ]); + }, + listWorkflowRuns: () => + runGhJson>([ + "run", + "list", + "--repo", + repository, + "--workflow", + WORKFLOW_FILE, + "--event", + "workflow_dispatch", + "--limit", + "100", + "--json", + "databaseId,displayTitle,status,conclusion,url,headSha,createdAt", + ]), + readWorkflowRun: (runId) => + runGhJson([ + "run", + "view", + String(runId), + "--repo", + repository, + "--json", + "databaseId,displayTitle,status,conclusion,url,headSha,createdAt", + ]), + readRelease: (tag) => + runGhJson([ + "release", + "view", + tag, + "--repo", + repository, + "--json", + "assets,isDraft,isImmutable,isPrerelease,tagName,url", + ]), + readRequest: readRequestFile, + writeRequest: writeRequestFile, + removeRequest: removeRequestFile, + withRequestLock: withRequestFileLock, + log: (message) => console.log(message), + registrationTimeoutMs: REGISTRATION_TIMEOUT_MS, + registrationPollMs: REGISTRATION_POLL_MS, + runTimeoutMs: RUN_TIMEOUT_MS, + runPollMs: RUN_POLL_MS, + }; +} + +export function selectIntelBuild( + tag: string, + input: { + readonly resolveTag?: typeof resolveRemoteInstallableTag; + readonly writeRequest?: (request: BuildRequest) => void; + readonly withRequestLock?: (operation: () => T) => T; + readonly nowIso?: () => string; + readonly uuid?: () => string; + } = {}, +): BuildRequest { + const target = (input.resolveTag ?? resolveRemoteInstallableTag)(tag); + const request: BuildRequest = { + schemaVersion: REQUEST_SCHEMA_VERSION, + installableTag: target.tag, + installableCommit: target.commit, + requestToken: `intel-${(input.uuid ?? NodeCrypto.randomUUID)()}`, + selectedAt: (input.nowIso ?? (() => new Date().toISOString()))(), + dispatchAttemptedAt: null, + workflowRunId: null, + }; + (input.withRequestLock ?? withRequestFileLock)(() => + (input.writeRequest ?? writeRequestFile)(request), + ); + return request; +} + +async function waitForRegistration( + request: BuildRequest, + dependencies: BuildIntelDependencies, +): Promise { + const deadline = dependencies.now() + dependencies.registrationTimeoutMs; + while (true) { + const run = findCorrelatedRun(request, dependencies.listWorkflowRuns()); + if (run) return run; + if (dependencies.now() >= deadline) { + fail( + `GitHub did not register workflow request ${request.requestToken} within ${dependencies.registrationTimeoutMs}ms.`, + ); + } + await dependencies.sleep(dependencies.registrationPollMs); + } +} + +async function waitForCompletion( + initialRun: WorkflowRun, + dependencies: BuildIntelDependencies, +): Promise { + const deadline = dependencies.now() + dependencies.runTimeoutMs; + let run = initialRun; + let previousStatus = ""; + while (run.status !== "completed") { + if (run.status !== previousStatus) { + dependencies.log(`[build-intel] Workflow ${run.databaseId} is ${run.status}: ${run.url}`); + previousStatus = run.status; + } + if (dependencies.now() >= deadline) { + fail( + `Intel workflow ${run.databaseId} did not finish within the configured timeout: ${run.url}`, + ); + } + await dependencies.sleep(dependencies.runPollMs); + run = dependencies.readWorkflowRun(run.databaseId); + } + return run; +} + +export async function runSelectedIntelBuild( + dependencies: BuildIntelDependencies = defaultDependencies(), +): Promise { + dependencies.verifyWorkflow(); + + let { request, run } = dependencies.withRequestLock(() => { + let lockedRequest = dependencies.readRequest(); + const lockedRun = + lockedRequest.workflowRunId === null + ? findCorrelatedRun(lockedRequest, dependencies.listWorkflowRuns()) + : dependencies.readWorkflowRun(lockedRequest.workflowRunId); + if (lockedRun && lockedRun.displayTitle !== workflowRunName(lockedRequest)) { + fail( + `Stored workflow run ${lockedRun.databaseId} does not match request ${lockedRequest.requestToken}.`, + ); + } + if (!lockedRun && lockedRequest.dispatchAttemptedAt === null) { + lockedRequest = { ...lockedRequest, dispatchAttemptedAt: dependencies.nowIso() }; + dependencies.writeRequest(lockedRequest); + try { + dependencies.dispatchWorkflow(lockedRequest); + } catch (error) { + dependencies.log( + `[build-intel] Dispatch returned an error; waiting for request-token registration before deciding whether it was accepted: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + return { request: lockedRequest, run: lockedRun }; + }); + run ??= await waitForRegistration(request, dependencies); + if (request.workflowRunId === null) { + dependencies.withRequestLock(() => { + const current = dependencies.readRequest(); + if (current.requestToken !== request.requestToken) return; + request = current; + if (request.workflowRunId === null) { + request = { ...request, workflowRunId: run.databaseId }; + dependencies.writeRequest(request); + } + }); + } + dependencies.log( + `[build-intel] Registered ${workflowRunName(request)} as run ${run.databaseId}: ${run.url}`, + ); + + const completed = await waitForCompletion(run, dependencies); + if (completed.conclusion !== "success") { + dependencies.withRequestLock(() => dependencies.removeRequest(request.requestToken)); + fail( + `Intel workflow ${completed.databaseId} ended with ${completed.conclusion ?? "no conclusion"}: ${completed.url}`, + ); + } + + const release = dependencies.readRelease(request.installableTag); + if ( + release.tagName !== request.installableTag || + release.isDraft || + !release.isImmutable || + !release.isPrerelease || + release.assets.length === 0 + ) { + fail( + `Intel workflow succeeded but ${request.installableTag} is not a complete immutable prerelease.`, + ); + } + dependencies.withRequestLock(() => dependencies.removeRequest(request.requestToken)); + return { + tag: request.installableTag, + commit: request.installableCommit, + requestToken: request.requestToken, + runId: completed.databaseId, + runUrl: completed.url, + workflowCommit: completed.headSha, + releaseUrl: release.url, + assets: release.assets.map(({ name }) => name).sort(), + }; +} + +export function parseIntelBuildOptions( + argv: ReadonlyArray, +): { readonly command: "select"; readonly tag: string } | { readonly command: "run" } { + const [command, ...rest] = argv; + if (command === "run" && rest.length === 0) return { command }; + if (command === "select") { + const tagIndex = rest.indexOf("--tag"); + const tag = tagIndex >= 0 ? rest[tagIndex + 1] : undefined; + if (tag && rest.length === 2 && tagIndex === 0) return { command, tag }; + } + fail( + "Usage: lastcode-build-intel-package.ts select --tag | lastcode-build-intel-package.ts run", + ); +} + +async function main(): Promise { + const options = parseIntelBuildOptions(process.argv.slice(2)); + if (options.command === "select") { + const request = selectIntelBuild(options.tag); + console.log( + `[build-intel] Selected ${request.installableTag} at ${request.installableCommit}.\n` + + `[build-intel] Request token: ${request.requestToken}. Run the Build Intel package Project Action.`, + ); + return; + } + const result = await runSelectedIntelBuild(); + console.log(`[build-intel] Result ${JSON.stringify(result)}`); +} + +if (import.meta.main) { + main().catch((error: unknown) => { + console.error(`[build-intel] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/scripts/lastcode-intel-release.test.mjs b/scripts/lastcode-intel-release.test.mjs index a565ff6413db..476edd9c26b4 100644 --- a/scripts/lastcode-intel-release.test.mjs +++ b/scripts/lastcode-intel-release.test.mjs @@ -102,6 +102,19 @@ describe("immutable Intel release validation", () => { NodePath.resolve(import.meta.dirname, "../.github/workflows/lastcode-intel-artifact.yml"), "utf8", ); + expect(workflow).toContain( + "run-name: Build Intel package · ${{ inputs.installable_tag }} · ${{ inputs.request_token }}", + ); + expect(workflow).toContain("request_token:"); + expect(workflow).toContain('if [[ ! "$REQUEST_TOKEN" =~ ^intel-[0-9a-f-]{36}$ ]]'); + const project = JSON.parse( + NodeFS.readFileSync(NodePath.resolve(import.meta.dirname, "../t3.json"), "utf8"), + ); + expect(project.scripts).toContainEqual({ + name: "Build Intel package (macOS)", + command: "mise exec node@24.13.1 -- node scripts/lastcode-build-intel-package.ts run", + icon: "build", + }); const automationCheckout = workflow.slice( workflow.indexOf("- name: Checkout workflow automation"), workflow.indexOf("- name: Checkout exact installable"), diff --git a/scripts/lastcode-lock.d.mts b/scripts/lastcode-lock.d.mts new file mode 100644 index 000000000000..2949f6d1f7c3 --- /dev/null +++ b/scripts/lastcode-lock.d.mts @@ -0,0 +1,8 @@ +export const LOCK_MODULE_MANAGED_MARKER: string; +export const DARWIN_O_EXLOCK: number; + +export function acquirePortableLock( + lockDirectory: string, + lockName: string, + activity: string, +): () => void; diff --git a/t3.json b/t3.json index a10ed441db30..633f2c57414e 100644 --- a/t3.json +++ b/t3.json @@ -13,6 +13,11 @@ "command": "mise exec node@24.13.1 -- node scripts/lastcode-wait-for-pr.ts", "icon": "test" }, + { + "name": "Build Intel package (macOS)", + "command": "mise exec node@24.13.1 -- node scripts/lastcode-build-intel-package.ts run", + "icon": "build" + }, { "name": "Run Full CI", "command": "mise exec node@24.13.1 -- node scripts/lastcode-local-ci.ts --full",