diff --git a/.github/workflows/desktop-release-artifacts.yml b/.github/workflows/desktop-release-artifacts.yml index 1ae285a944..565a09265e 100644 --- a/.github/workflows/desktop-release-artifacts.yml +++ b/.github/workflows/desktop-release-artifacts.yml @@ -17,6 +17,11 @@ on: description: "Existing GitHub Release tag to attach desktop artifacts to" required: false type: string + publication_probe: + description: "Read-only builder publication preflight; never attaches Release assets" + required: false + type: boolean + default: false permissions: contents: read @@ -79,6 +84,22 @@ jobs: apps/desktop/loopx-control-plane/package-lock.json apps/presentation/dashboard/package-lock.json + - name: Check out immutable preflight tooling + if: github.event_name == 'workflow_dispatch' && inputs.publication_probe == true + uses: actions/checkout@v7 + with: + ref: ${{ github.workflow_sha }} + path: .release-tooling + persist-credentials: false + + - name: Capture read-only release source + if: github.event_name == 'workflow_dispatch' && inputs.publication_probe == true + timeout-minutes: 2 + env: + PUBLICATION_PROBE: "true" + RELEASE_TAG: ${{ steps.identity.outputs.release-tag }} + run: node .release-tooling/scripts/ci/desktop_release_preflight.ts capture + - name: Set up Rust uses: dtolnay/rust-toolchain@stable @@ -168,11 +189,47 @@ jobs: Copy-Item apps/desktop/loopx-control-plane/src-tauri/target/release/bundle/nsis/*.exe dist/desktop/ - name: Attest desktop artifacts + id: binary-attestation if: github.event_name != 'pull_request' uses: actions/attest@v4 with: subject-path: ${{ matrix.artifact-path }} + - name: Record preflight source subjects + if: github.event_name == 'workflow_dispatch' && inputs.publication_probe == true + timeout-minutes: 2 + env: + PUBLICATION_PROBE: "true" + RELEASE_TAG: ${{ steps.identity.outputs.release-tag }} + run: node .release-tooling/scripts/ci/desktop_release_preflight.ts record + + - name: Attest preflight source record + if: github.event_name == 'workflow_dispatch' && inputs.publication_probe == true + id: source-attestation + timeout-minutes: 2 + uses: actions/attest@v4 + with: + subject-path: .desktop-preflight/source/source.json + + - name: Preserve preflight attestation bundles + if: github.event_name == 'workflow_dispatch' && inputs.publication_probe == true + env: + PUBLICATION_PROBE: "true" + RELEASE_TAG: ${{ steps.identity.outputs.release-tag }} + SOURCE_BUNDLE: ${{ steps.source-attestation.outputs.bundle-path }} + BINARY_BUNDLE: ${{ steps.binary-attestation.outputs.bundle-path }} + run: node .release-tooling/scripts/ci/desktop_release_preflight.ts bundles + + - name: Upload isolated preflight source + if: github.event_name == 'workflow_dispatch' && inputs.publication_probe == true + uses: actions/upload-artifact@v7 + with: + name: desktop-source-${{ runner.os == 'macOS' && 'macos' || 'windows' }}-${{ github.run_attempt }} + path: .desktop-preflight/source + include-hidden-files: true + if-no-files-found: error + retention-days: 30 + - name: Upload desktop artifact uses: actions/upload-artifact@v7 with: @@ -181,8 +238,56 @@ jobs: if-no-files-found: error retention-days: 30 + - name: Check peer success and publication budget + if: github.event_name == 'workflow_dispatch' && inputs.publication_probe == true && runner.os == 'Windows' + id: preflight + timeout-minutes: 2 + env: + PUBLICATION_PROBE: "true" + RELEASE_TAG: ${{ steps.identity.outputs.release-tag }} + run: node .release-tooling/scripts/ci/desktop_release_preflight.ts admit + + - name: Download same-run Mac desktop artifact + id: mac-desktop + if: github.event_name == 'workflow_dispatch' && inputs.publication_probe == true && runner.os == 'Windows' && steps.preflight.outputs.eligible == 'true' + timeout-minutes: 2 + uses: actions/download-artifact@v7 + with: + name: loopx-macos-${{ steps.identity.outputs.release-tag }} + path: dist/desktop + + - name: Download same-run Mac source proof + id: mac-source + if: github.event_name == 'workflow_dispatch' && inputs.publication_probe == true && runner.os == 'Windows' && steps.preflight.outputs.eligible == 'true' + timeout-minutes: 2 + uses: actions/download-artifact@v7 + with: + name: ${{ steps.preflight.outputs.mac-source }} + path: .desktop-preflight/mac-source + + - name: Verify read-only publication preflight + if: always() && github.event_name == 'workflow_dispatch' && inputs.publication_probe == true && runner.os == 'Windows' && steps.preflight.outputs.eligible == 'true' + timeout-minutes: 5 + env: + PUBLICATION_PROBE: "true" + RELEASE_TAG: ${{ steps.identity.outputs.release-tag }} + GH_TOKEN: ${{ github.token }} + PEER_ARTIFACT_DOWNLOAD: ${{ steps.mac-desktop.outcome }} + PEER_SOURCE_DOWNLOAD: ${{ steps.mac-source.outcome }} + run: node .release-tooling/scripts/ci/desktop_release_preflight.ts verify + + - name: Preserve read-only preflight result + if: always() && github.event_name == 'workflow_dispatch' && inputs.publication_probe == true && runner.os == 'Windows' + uses: actions/upload-artifact@v7 + with: + name: desktop-publication-preflight-${{ github.run_attempt }} + path: .desktop-preflight/report.json + include-hidden-files: true + if-no-files-found: error + retention-days: 30 + upload-release: - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' && !(github.event_name == 'workflow_dispatch' && inputs.publication_probe == true) needs: build runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/docs/product/release-readiness.md b/docs/product/release-readiness.md index d98240126b..329e9bccb2 100644 --- a/docs/product/release-readiness.md +++ b/docs/product/release-readiness.md @@ -263,6 +263,80 @@ Trusted Publisher configuration fails closed; it does not become a successful or skipped publication. GitHub packages and PyPI publication retain separate readbacks. +## Desktop Builder Publication Preflight + +The Desktop Release Artifacts workflow has a default-off, **read-only** +`publication_probe` mode. It measures whether an already admitted Windows builder +can verify the complete release set without waiting for another runner. It does +not publish assets or reserve organization capacity. Ordinary release events, +PR/fork builds and manual dispatches with this input omitted or false retain the +existing separate Ubuntu publisher, job permissions, signing, complete asset set, +backup and manifest-last ordering. + +Activate only from a reviewed workflow ref with an existing public Release tag: + +```bash +gh workflow run desktop-release-artifacts.yml --ref \ + -f tag= -f publication_probe=true +``` + +The probe checks out immutable workflow tooling separately from the product tag. +It records and attests each platform's actual product commit, workflow commit, +run/attempt and artifact digests in isolated source artifacts. Windows requires a +successful terminal Mac job and at least 10 minutes plus a 60-second margin within +its existing 30-minute job. It performs bounded metadata reads without polling or +holding a runner while another builder is queued. On a failed-jobs-only rerun, an +earlier successful Mac attempt is usable only with matching attested source +lineage; a later failed/unfinished Mac attempt cannot fall back to older success. +The bounded probe supports at most ten attempts and ten metadata pages per attempt. + +Read back the actual run and both build jobs, then download the report: + +```bash +gh run view --json headSha,event,status,conclusion,jobs +gh run download -n desktop-publication-preflight- -D preflight-result +cat preflight-result/report.json +``` + +The report distinguishes `eligible`, `held` and `unavailable` and always sets +`publicationAuthorized=false`. An eligible report requires same-run downloads, +verified source/binary attestations pinned to the workflow commit, the canonical +four-asset/checksum owner, an unchanged Release inventory and a final budget check. +It still precedes the Windows job's post-actions and final outcome. Missing Release, +changed tag/source, incomplete or ambiguous jobs, failed downloads, API 403/429, +timeouts, missing/extra/stale bytes and verification failures never qualify a +writer. A green build or report is not permission to publish. + +In probe mode, the existing `upload-release` job is disabled, including on manual +dispatch: only CI artifacts, their provenance attestations and a job summary are produced. The builder retains +`contents:read` and its existing attestation permissions. The probe grants no +release-content, infrastructure, billing, credential or merge authority. Check the +before/after Release asset inventory when qualifying a hosted pilot; synthetic +metadata/local smokes do not establish Windows transport, signing, timing or cost. +A writing fast path would change job-wide credentials and the two-build-terminal +success condition and requires its own exact-head review and maintainer authorization. + +Disable by omitting `publication_probe` or setting it to false; this restores the +ordinary isolated publisher. Reverting the probe change removes the optional CI +mode without modifying already published Release assets. Publication recovery keeps +the existing backup, complete replacement, manifest-last and remote readback rules; +never launch a second writer after a mutation's response is unknown. Standard runner +reuse cannot guarantee the first admission or organization-wide release priority. +See the [versioned release contract](https://github.com/loopx-project/loopx/blob/v1.4.0/docs/product/release-readiness.md) +for the published baseline; this probe remains unqualified until its reviewed +workflow has actual hosted evidence. No hosted pilot or release mutation is part +of a local validation run. + +桌面发布只读预检默认关闭。仅在既有公开 Release tag 和已评审 workflow ref 上显式 +传入 `publication_probe=true` 激活;该模式禁用原上传 job,不写 Release。通过真实 +run/attempt 的完整 jobs、`desktop-publication-preflight-` 制品及前后 Release +库存验收。`eligible` 只表示源码、四资产、证明和预算的只读条件满足,Windows 最终 +job 结果仍待确认,`publicationAuthorized` 始终为 false。API/下载/校验失败、移动 tag、 +不明 lineage 或预算不足都不能放行;不占槽轮询等另一平台。省略该输入或设为 false +恢复既有隔离 publisher;撤回代码不影响已发布资产。写入快路的权限、双 job 终态门、 +实际 Windows 路径及发布恢复须独立评审和维护者授权;本机 smoke 不等于 hosted 采用, +复用 runner 也不构成组织级容量预留。 + ## Public Release Timeline The public GitHub release timeline starts at `v0.1.3`. Earlier work should be diff --git a/examples/desktop-release-preflight-smoke.mjs b/examples/desktop-release-preflight-smoke.mjs new file mode 100644 index 0000000000..afa553baab --- /dev/null +++ b/examples/desktop-release-preflight-smoke.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +/** Semantic probe cases. Synthetic CI metadata; real asset owner and disk bytes. */ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { resolveTestPython } from "../scripts/test-python.mjs"; +import { admit, apiGet, assetsInventory, completeJobs, identity, remainingBudget, verifySource, verifySubjects } from "../scripts/ci/desktop_release_preflight.ts"; + +const sha = "a".repeat(40), now = Date.parse("2026-01-01T12:00:00Z"); +const c = { repository: "loopx-project/loopx", runId: 100, attempt: 1, tag: "v0.0.1", productCommit: "b".repeat(40), toolingCommit: sha, runCommit: sha, releaseAssets: [] }; +const win = { id: 1, name: "Windows desktop", status: "in_progress", conclusion: null, head_sha: sha, runner_id: 10, steps: [{ name: "Set up job", started_at: "2026-01-01T11:52:00Z" }] }; +const mac = { id: 2, name: "macOS desktop", status: "completed", conclusion: "success", head_sha: sha, runner_id: 11, steps: [] }; +const page = (jobs) => ({ total_count: jobs.length, jobs }); +const get = async () => page([win, mac]); +let cases = 0; +async function check(name, fn) { await fn(); cases++; console.log(`ok: ${name}`); } +await check("manual opt-in only; PR, fork repository and default-off never admitted", async () => { + const env = { GITHUB_REPOSITORY: c.repository, GITHUB_RUN_ID: "100", GITHUB_RUN_ATTEMPT: "1", GITHUB_EVENT_NAME: "workflow_dispatch", PUBLICATION_PROBE: "true", RELEASE_TAG: c.tag, GITHUB_WORKFLOW_SHA: sha, GITHUB_SHA: sha }; + assert.equal(identity(env).runId, 100); + for (const change of [{ PUBLICATION_PROBE: "false" }, { PUBLICATION_PROBE: undefined }, { GITHUB_EVENT_NAME: "pull_request" }, { GITHUB_REPOSITORY: "fork/loopx" }, { RELEASE_TAG: "v0.0.1;echo" }]) assert.throws(() => identity({ ...env, ...change })); +}); +await check("peer terminal success and a real setup timestamp required", async () => { + assert.equal((await admit(c, get, now)).peerJobId, 2); + for (const peer of [{ ...mac, status: "in_progress", conclusion: null }, { ...mac, conclusion: "failure" }, { ...mac, conclusion: "cancelled" }, { ...mac, conclusion: "skipped" }]) assert.equal(await admit(c, async () => page([win, peer]), now), undefined); + await assert.rejects(admit(c, async () => page([{ ...win, steps: [] }, mac]), now)); + await assert.rejects(admit(c, async () => page([win, { ...mac, head_sha: "c".repeat(40) }]), now)); +}); +await check("no waiting; 10m plus 60s reserve stays within the original 30m job", async () => { + assert.equal(remainingBudget("2026-01-01T11:41:00Z", now), 660); + assert.equal(await admit(c, get, now + 1_000_000), undefined); + assert.throws(() => remainingBudget("invalid", now)); + assert.throws(() => remainingBudget("2026-01-01T13:00:00Z", now)); +}); +await check("explicit older upstream lineage on failed-jobs-only rerun; later failure wins", async () => { + const rerun = { ...c, attempt: 2 }; + const result = await admit(rerun, async (url) => url.includes("attempts/2/") ? page([win]) : page([mac]), now); + assert.equal(result.peerAttempt, 1); + let calls = 0; + assert.equal(await admit(rerun, async () => { calls++; return page([win, { ...mac, conclusion: "failure" }]); }, now), undefined); + assert.equal(calls, 1); +}); +await check("complete pagination; duplicate, changed count and incomplete pages rejected", async () => { + const first = Array.from({ length: 100 }, (_, id) => ({ id })); + assert.equal((await completeJobs(c.repository, 100, 1, async (url) => ({ total_count: 101, jobs: url.endsWith("page=1") ? first : [{ id: 100 }] }))).length, 101); + await assert.rejects(completeJobs(c.repository, 100, 1, async () => ({ total_count: 101, jobs: first }))); + await assert.rejects(completeJobs(c.repository, 100, 1, async () => ({ total_count: 3, jobs: [{ id: 1 }] }))); + await assert.rejects(admit(c, async () => page([win, mac, { ...mac, id: 3 }]), now)); +}); +await check("403/429/deadline/timeout never yield metadata", async () => { + for (const status of [403, 429]) await assert.rejects(apiGet("repos/example", Date.now() + 1000, async () => ({ ok: false, status })), new RegExp(String(status))); + await assert.rejects(apiGet("repos/example", Date.now() - 1)); + await assert.rejects(apiGet("repos/example", Date.now() + 1000, async () => { throw new DOMException("timeout", "TimeoutError"); })); +}); +await check("source run, actual product, tooling, platform and attempt must match", async () => { + const source = { ...c, platform: "macOS", files: { "LoopX.app.zip": "0".repeat(64) } }; + verifySource(c, source, "macOS", 1); + for (const change of [{ runId: 101 }, { attempt: 2 }, { productCommit: sha }, { toolingCommit: "c".repeat(40) }, { platform: "Windows" }, { files: { "../escape": "0".repeat(64) } }]) assert.throws(() => verifySource(c, { ...source, ...change }, "macOS", 1)); +}); +await check("ambiguous release inventory rejected", async () => { + assert.deepEqual(assetsInventory([]), []); + assert.throws(() => assetsInventory([{ id: 1, name: "same", size: 1 }, { id: 2, name: "same", size: 2 }])); +}); +await check("real disk digests and Python manifest owner; missing, extra, stale and verification errors", async () => { + const temp = await mkdtemp(join(tmpdir(), "loopx-desktop-preflight-")); + try { + const dist = join(temp, "dist"), w = join(temp, "win"), m = join(temp, "mac"); + await Promise.all([dist, w, m].map((dir) => mkdir(dir))); + const names = ["LoopX.app.zip", "LoopX_0.0.1_aarch64.dmg", "LoopX_0.0.1_x64-setup.exe", "LoopX_0.0.1_x64_en-US.msi"]; + const digestSets = [{}, {}]; + for (const [i, name] of names.entries()) { + const bytes = Buffer.from(`synthetic artifact ${name}`); + await writeFile(join(dist, name), bytes); + digestSets[i < 2 ? 1 : 0][name] = createHash("sha256").update(bytes).digest("hex"); + } + for (const [dir, platform, files] of [[w, "Windows", digestSets[0]], [m, "macOS", digestSets[1]]]) await writeFile(join(dir, "source.json"), JSON.stringify({ ...c, platform, files })); + const inputs = [[w, "Windows", 1], [m, "macOS", 1]]; + // Only cryptographic transport is substituted here. The owner and byte IO are real. + const owner = new URL("../scripts/desktop_release_artifacts.py", import.meta.url); + const python = resolveTestPython(); + const runOwner = (args) => execFileSync(python, [fileURLToPath(owner), ...args], { stdio: "pipe" }); + let verifications = 0; + const verify = () => { verifications++; }; + for (const newline of ["\n", "\r\n"]) { + const manifest = join(dist, "DESKTOP-SHA256SUMS"); + // Force each host's bytes after the real writer; the real verifier still runs. + const runOwnerWithNewline = (args) => { + runOwner(args); + if (args[0] === "write-checksums") writeFileSync(manifest, readFileSync(manifest, "ascii").replace(/\r?\n/g, newline), "ascii"); + }; + verifications = 0; + await verifySubjects(c, inputs, dist, verify, runOwnerWithNewline); + assert.equal(verifications, 6); + assert.deepEqual((await readFile(manifest, "ascii")).trim().split(/\r?\n/).map((line) => line.split(" ")), + names.map((name) => [digestSets[1][name] ?? digestSets[0][name], name])); + } + await assert.rejects(verifySubjects(c, inputs, dist, () => { throw new Error("signature verification failed"); }, runOwner), /signature verification/); + await writeFile(join(dist, names[0]), "tampered"); + await assert.rejects(verifySubjects(c, inputs, dist, verify, runOwner), /digest mismatch/); + await writeFile(join(dist, names[0]), `synthetic artifact ${names[0]}`); + await writeFile(join(dist, "extra.bin"), "extra"); + await assert.rejects(verifySubjects(c, inputs, dist, verify, runOwner)); + await rm(join(dist, "extra.bin")); + await rm(join(dist, names[3])); + await assert.rejects(verifySubjects(c, inputs, dist, verify, runOwner)); + } finally { await rm(temp, { recursive: true, force: true }); } +}); +console.log(`desktop-release-preflight-smoke: ${cases} semantic cases passed (hosted Windows/cryptographic transport not qualified)`); diff --git a/examples/desktop-release-workflow-smoke.py b/examples/desktop-release-workflow-smoke.py index 63483ba237..a6f69a25ad 100644 --- a/examples/desktop-release-workflow-smoke.py +++ b/examples/desktop-release-workflow-smoke.py @@ -4,6 +4,8 @@ from __future__ import annotations import importlib.util +import os +import subprocess import sys import tempfile from pathlib import Path @@ -92,6 +94,24 @@ def main() -> int: "- name: Replace desktop checksum manifest last" ) + # Opt-in is a read-only CI provider mode; ordinary publishing keeps its owner. + assert text.count("contents: write") == 1 + assert_contains(text, "contents: read\n id-token: write\n attestations: write") + assert_contains(text, "publication_probe:") + assert_contains(text, "type: boolean\n default: false") + assert_contains( + text, + "if: github.event_name != 'pull_request' && !(github.event_name == 'workflow_dispatch' && inputs.publication_probe == true)", + ) + assert_contains(text, "ref: ${{ github.workflow_sha }}") + assert_contains(text, "path: .release-tooling") + assert_contains(text, "persist-credentials: false") + assert_contains(text, "name: desktop-source-") + assert_contains(text, "timeout-minutes: 30") + assert_contains(text, "timeout-minutes: 10") + assert_absent(text, "continue-on-error") + assert_absent(text, "actions: write") + with tempfile.TemporaryDirectory(prefix="loopx-desktop-release-") as temporary: dist_dir = Path(temporary) version = "0.5.4" @@ -130,6 +150,13 @@ def main() -> int: else: raise AssertionError("desktop release accepted a stale checksum manifest") + subprocess.run( + ["node", str(REPO_ROOT / "examples/desktop-release-preflight-smoke.mjs")], + cwd=REPO_ROOT, + env={**os.environ, "LOOPX_TEST_PYTHON": sys.executable}, + check=True, + timeout=30, + ) print("desktop-release-workflow-smoke: ok") return 0 diff --git a/scripts/ci/desktop_release_preflight.ts b/scripts/ci/desktop_release_preflight.ts new file mode 100644 index 0000000000..8e430cdb3e --- /dev/null +++ b/scripts/ci/desktop_release_preflight.ts @@ -0,0 +1,250 @@ +/** Read-only desktop CI probe. Asset/manifest decisions remain in the Python owner. */ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { appendFile, copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +type Platform = "macOS" | "Windows"; +type ProbeState = "eligible" | "held" | "unavailable"; // Local CI report, never publication authority. +type Asset = { id: number; name: string; size: number; digest: string | null }; +type Context = { + repository: string; runId: number; attempt: number; tag: string; + productCommit: string; toolingCommit: string; runCommit: string; releaseAssets: Asset[]; +}; +type Source = Context & { platform: Platform; files: Record }; +type Job = { + id: number; name: string; status: string; conclusion: string | null; + head_sha: string; run_attempt?: number; runner_id: number | null; + steps: { name: string; started_at: string | null }[]; +}; +type Admission = { peerAttempt: number; peerJobId: number; ownJobId: number; startedAt: string }; +type Report = { + state: ProbeState; reason: string; publicationAuthorized: false; + runId?: number; attempt?: number; productCommit?: string; toolingCommit?: string; + admission?: Admission; +}; +const ROOT = ".desktop-preflight"; +const WORKFLOW = "loopx-project/loopx/.github/workflows/desktop-release-artifacts.yml"; +const HASH = /^[0-9a-f]{40}$/; +const DIGEST = /^[0-9a-f]{64}$/; +const TAG = /^v[0-9]+\.[0-9]+\.[0-9]+(?:[+-][0-9A-Za-z.-]+)?$/; +const toolingRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +function requireValue(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} +export function identity(env: NodeJS.ProcessEnv): Omit { + const repository = env.GITHUB_REPOSITORY ?? ""; + const runId = Number(env.GITHUB_RUN_ID), attempt = Number(env.GITHUB_RUN_ATTEMPT); + const tag = env.RELEASE_TAG ?? "", toolingCommit = env.GITHUB_WORKFLOW_SHA ?? ""; + const runCommit = env.GITHUB_SHA ?? ""; + requireValue(repository === "loopx-project/loopx", "unexpected repository"); + requireValue(env.GITHUB_EVENT_NAME === "workflow_dispatch" && env.PUBLICATION_PROBE === "true", "manual opt-in required"); + requireValue(Number.isSafeInteger(runId) && runId > 0 && Number.isSafeInteger(attempt) && attempt > 0 && attempt <= 10, "unsupported run/attempt"); + requireValue(TAG.test(tag) && HASH.test(toolingCommit) && HASH.test(runCommit), "invalid tag or source identity"); + return { repository, runId, attempt, tag, toolingCommit, runCommit }; +} +export function assetsInventory(value: unknown): Asset[] { + requireValue(Array.isArray(value), "release asset inventory unavailable"); + const seen = new Set(); + return value.map((a) => { + requireValue(a && Number.isSafeInteger(a.id) && typeof a.name === "string" && Number.isSafeInteger(a.size) && a.size >= 0 && !seen.has(a.name), "ambiguous release asset inventory"); + seen.add(a.name); + return { id: a.id, name: a.name, size: a.size, digest: a.digest ?? null }; + }).sort((a, b) => a.name.localeCompare(b.name)); +} +export async function apiGet(path: string, deadline: number, request: typeof fetch = fetch): Promise { + const remaining = deadline - Date.now(); + requireValue(remaining > 0, "metadata deadline exhausted"); + const response = await request(`https://api.github.com/${path}`, { + headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" }, + signal: AbortSignal.timeout(Math.min(15_000, remaining)), + }); + requireValue(response.ok, `metadata HTTP ${response.status}`); + return response.json(); +} +export async function completeJobs(repository: string, runId: number, attempt: number, get: (path: string) => Promise): Promise { + const jobs: Job[] = []; + let total: number | undefined; + for (let page = 1; page <= 10; page++) { + const x = await get(`repos/${repository}/actions/runs/${runId}/attempts/${attempt}/jobs?per_page=100&page=${page}`); + requireValue(Number.isSafeInteger(x.total_count) && x.total_count >= 0 && Array.isArray(x.jobs), "invalid jobs page"); + requireValue(total === undefined || total === x.total_count, "jobs changed during pagination"); + total = x.total_count; + jobs.push(...x.jobs); + requireValue(new Set(jobs.map((j) => j.id)).size === jobs.length, "duplicate jobs page"); + requireValue(jobs.length <= total!, "jobs count overflow"); + if (jobs.length === total) return jobs; + requireValue(x.jobs.length === 100, "incomplete jobs page"); + } + throw new Error("jobs pagination limit"); +} +export function remainingBudget(startedAt: string, now: number): number { + const start = Date.parse(startedAt); + requireValue(Number.isFinite(start) && start <= now, "real Setup job timestamp unavailable"); + return 1800 - (now - start) / 1000; +} +export async function admit(c: Context, get: (path: string) => Promise, now: number): Promise { + const ownJobs = await completeJobs(c.repository, c.runId, c.attempt, get); + const own = ownJobs.filter((j) => j.name === "Windows desktop"); + requireValue(own.length === 1, "Windows job identity ambiguous"); + const j = own[0], startedAt = j.steps?.find((s) => s.name === "Set up job")?.started_at; + requireValue(Number.isSafeInteger(j.id) && j.id > 0 && Number.isSafeInteger(j.runner_id) && Number(j.runner_id) > 0 && j.status === "in_progress" && j.head_sha === c.runCommit && startedAt, "current Windows execution unproved"); + if (remainingBudget(startedAt, now) < 660) return undefined; + // A later failed/unfinished Mac attempt supersedes earlier success. Missing jobs + // on a failed-jobs-only rerun may reuse an explicitly proved earlier attempt. + for (let attempt = c.attempt; attempt >= 1; attempt--) { + const jobs = attempt === c.attempt ? ownJobs : await completeJobs(c.repository, c.runId, attempt, get); + const matches = jobs.filter((peer) => peer.name === "macOS desktop"); + requireValue(matches.length <= 1, "Mac job identity ambiguous"); + if (!matches.length) continue; + const peer = matches[0]; + if (peer.status !== "completed" || peer.conclusion !== "success") return undefined; + requireValue(Number.isSafeInteger(peer.id) && peer.id > 0 && Number.isSafeInteger(peer.runner_id) && Number(peer.runner_id) > 0 && peer.head_sha === c.runCommit, "Mac runner/source unproved"); + const peerAttempt = peer.run_attempt ?? attempt; + requireValue(Number.isSafeInteger(peerAttempt) && peerAttempt > 0 && peerAttempt <= attempt, "Mac attempt lineage ambiguous"); + return { peerAttempt, peerJobId: peer.id, ownJobId: j.id, startedAt }; + } + return undefined; +} +export function verifySource(c: Context, s: Source, platform: Platform, attempt: number): void { + for (const k of ["repository", "runId", "tag", "productCommit", "toolingCommit", "runCommit"] as const) { + requireValue(s[k] === c[k], `source identity mismatch: ${k}`); + } + requireValue(s.platform === platform && s.attempt === attempt, "source platform/attempt mismatch"); + requireValue(s.files && Object.keys(s.files).length > 0, "source digest set missing"); + for (const [name, digest] of Object.entries(s.files)) { + requireValue(name !== "DESKTOP-SHA256SUMS" && /^[A-Za-z0-9_.+-]+$/.test(name) && DIGEST.test(digest), "invalid source subject"); + } +} +async function tagCommit(c: Pick, get: (path: string) => Promise): Promise { + let x = (await get(`repos/${c.repository}/git/ref/tags/${encodeURIComponent(c.tag)}`)).object; + for (let i = 0; i < 8; i++) { + requireValue(x && HASH.test(x.sha), "tag object unavailable"); + if (x.type === "commit") return x.sha; + requireValue(x.type === "tag", "tag does not resolve to commit"); + x = (await get(`repos/${c.repository}/git/tags/${x.sha}`)).object; + } + throw new Error("tag indirection limit"); +} +async function sha256(path: string): Promise { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return hash.digest("hex"); +} +async function json(path: string): Promise { return JSON.parse(await readFile(path, "utf8")); } +async function save(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(value, null, 2) + "\n"); +} +async function report(value: Report): Promise { + await save(join(ROOT, "report.json"), value); + if (process.env.GITHUB_STEP_SUMMARY) { + await appendFile(process.env.GITHUB_STEP_SUMMARY, `### Desktop publication preflight\n\nState: **${value.state}**. ${value.reason}\n\nRead-only observation; publication is not authorized. Both build-job terminal outcomes and a separately reviewed writer remain required.\n`); + } + console.log(JSON.stringify(value)); +} +function run(executable: string, args: string[], deadline: number): string { + const timeout = Math.min(45_000, deadline - Date.now()); + requireValue(timeout > 0, "verification deadline exhausted"); + return execFileSync(executable, args, { encoding: "utf8", timeout, maxBuffer: 8 * 1024 * 1024 }); +} +function attest(path: string, bundle: string, c: Context, deadline: number): void { + const verified = JSON.parse(run("gh", ["attestation", "verify", path, "--bundle", bundle, + "--repo", c.repository, "--signer-workflow", WORKFLOW, "--signer-digest", c.toolingCommit, + "--deny-self-hosted-runners", "--format", "json"], deadline)); + requireValue(Array.isArray(verified) && verified.length > 0, "attestation verification empty"); +} +export async function verifySubjects( + c: Context, inputs: readonly (readonly [string, Platform, number])[], distDir: string, + verifyAttestation: (path: string, bundle: string) => void, + runOwner: (args: string[]) => void, +): Promise { + const files: Record = {}; + for (const [dir, platform, attempt] of inputs) { + const sourcePath = join(dir, "source.json"); + verifyAttestation(sourcePath, join(dir, "source-bundle.json")); + const source: Source = await json(sourcePath); + verifySource(c, source, platform, attempt); + for (const [name, digest] of Object.entries(source.files)) { + requireValue(!files[name], "duplicate platform subject"); + requireValue(await sha256(join(distDir, name)) === digest, "download/source digest mismatch"); + verifyAttestation(join(distDir, name), join(dir, "binary-bundle.json")); + files[name] = digest; + } + } + const manifest = join(distDir, "DESKTOP-SHA256SUMS"); + const args = ["--dist-dir", distDir, "--version", c.tag.slice(1)]; + runOwner(["write-checksums", ...args, "--output", manifest]); + runOwner(["verify-checksums", ...args, "--checksum-file", manifest]); + const lines = (await readFile(manifest, "ascii")).trim().split(/\r?\n/); + requireValue(lines.length === Object.keys(files).length && lines.every((line) => { + const [digest, name] = line.split(" "); return files[name] === digest; + }), "manifest/source subject set mismatch"); + +} + +export async function main(mode: string): Promise { + const deadline = Date.now() + (mode === "verify" ? 240_000 : 90_000); + const get = (path: string) => apiGet(path, deadline); + const base = identity(process.env); + const contextPath = join(ROOT, "context.json"), sourceDir = join(ROOT, "source"); + if (mode === "capture") { + const productCommit = run("git", ["rev-parse", "HEAD"], deadline).trim(); + requireValue(HASH.test(productCommit) && productCommit === await tagCommit(base, get), "product checkout/tag mismatch"); + const release = await get(`repos/${base.repository}/releases/tags/${encodeURIComponent(base.tag)}`); + requireValue(release.tag_name === base.tag && !release.draft, "existing public Release required"); + await save(contextPath, { ...base, productCommit, releaseAssets: assetsInventory(release.assets) }); + return; + } + const c: Context = await json(contextPath); + for (const [k, value] of Object.entries(base)) requireValue(c[k as keyof Context] === value, "capture identity changed"); + if (mode === "record") { + requireValue(run("git", ["rev-parse", "HEAD"], deadline).trim() === c.productCommit, "checkout changed during build"); + const files: Record = {}; + for (const file of await readdir("dist/desktop", { withFileTypes: true })) { + requireValue(file.isFile(), "unexpected desktop directory entry"); + files[file.name] = await sha256(join("dist/desktop", file.name)); + } + const platform = process.env.RUNNER_OS; + requireValue(platform === "macOS" || platform === "Windows", "unsupported platform"); + await save(join(sourceDir, "source.json"), { ...c, platform, files }); + } else if (mode === "bundles") { + requireValue(process.env.SOURCE_BUNDLE && process.env.BINARY_BUNDLE, "attestation bundles missing"); + await copyFile(process.env.SOURCE_BUNDLE, join(sourceDir, "source-bundle.json")); + await copyFile(process.env.BINARY_BUNDLE, join(sourceDir, "binary-bundle.json")); + } else if (mode === "admit") { + const admission = await admit(c, get, Date.now()); + await save(join(ROOT, "admission.json"), admission ?? null); + requireValue(process.env.GITHUB_OUTPUT, "Actions output unavailable"); + await appendFile(process.env.GITHUB_OUTPUT, `eligible=${Boolean(admission)}\nmac-source=desktop-source-macos-${admission?.peerAttempt ?? c.attempt}\n`); + if (!admission) await report({ ...base, state: "held", reason: "Peer terminal success or 10m + 60s remaining budget absent; no peer wait.", publicationAuthorized: false }); + } else if (mode === "verify") { + const admission: Admission | null = await json(join(ROOT, "admission.json")); + if (!admission) return; + requireValue(process.env.PEER_ARTIFACT_DOWNLOAD === "success" && process.env.PEER_SOURCE_DOWNLOAD === "success", "peer artifact download failed or skipped"); + requireValue(remainingBudget(admission.startedAt, Date.now()) >= 660, "remaining job budget insufficient"); + requireValue(await tagCommit(c, get) === c.productCommit, "product tag moved"); + const inputs = [[sourceDir, "Windows", c.attempt], [join(ROOT, "mac-source"), "macOS", admission.peerAttempt]] as const; + const python = process.env.PREFLIGHT_PYTHON ?? "python"; + const owner = join(toolingRoot, "scripts/desktop_release_artifacts.py"); + await verifySubjects(c, inputs, "dist/desktop", (path, bundle) => attest(path, bundle, c, deadline), + (args) => { run(python, [owner, ...args], deadline); }); + const release = await get(`repos/${c.repository}/releases/tags/${encodeURIComponent(c.tag)}`); + requireValue(JSON.stringify(assetsInventory(release.assets)) === JSON.stringify(c.releaseAssets), "Release assets changed during probe"); + requireValue(await tagCommit(c, get) === c.productCommit && remainingBudget(admission.startedAt, Date.now()) >= 660, "source or remaining budget changed during verification"); + await report({ ...base, productCommit: c.productCommit, state: "eligible", reason: "Peer success, attested sources, complete four assets and manifest verified; Release inventory unchanged. Windows final job outcome remains pending.", publicationAuthorized: false, admission }); + } else { + throw new Error("unknown probe mode"); + } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const mode = process.argv[2]; + main(mode).catch(async (error) => { + await report({ state: "unavailable", reason: error instanceof Error ? error.message : "verification unavailable", publicationAuthorized: false }); + if (mode === "admit" && process.env.GITHUB_OUTPUT) await appendFile(process.env.GITHUB_OUTPUT, "eligible=false\n"); + process.exitCode = 1; + }); +}