Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 106 additions & 1 deletion .github/workflows/desktop-release-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
74 changes: 74 additions & 0 deletions docs/product/release-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <reviewed-workflow-ref> \
-f tag=<existing-release-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 <run-id> --json headSha,event,status,conclusion,jobs
gh run download <run-id> -n desktop-publication-preflight-<attempt> -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-<attempt>` 制品及前后 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
Expand Down
112 changes: 112 additions & 0 deletions examples/desktop-release-preflight-smoke.mjs
Original file line number Diff line number Diff line change
@@ -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)`);
Loading
Loading