chore: optimize CI and beta release runtime (#2231)

* chore(ci): add runtime trace summaries

* chore(ci): tighten measured workspace steps

* chore(release): tighten beta setup steps

* chore(release): slim beta windows smoke

* chore(ci): shard daemon tests

* chore(ci): harden runtime trace lookup

* chore(release): avoid mac pnpm cache in beta

* chore(ci): split critical playwright checks

* chore(release): publish beta platforms from builders

* test(e2e): update beta release workflow expectation

* chore(ci): stop gating PRs on nix check

* fix(release): keep beta latest complete
This commit is contained in:
PerishFire 2026-05-19 18:06:28 +08:00 committed by GitHub
parent 320bd4c303
commit bb13eee765
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1054 additions and 204 deletions

View file

@ -0,0 +1,188 @@
import { execFileSync } from "node:child_process";
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
function required(name) {
const value = process.env[name];
if (value == null || value.length === 0) {
throw new Error(`${name} is required`);
}
return value;
}
function optional(name, fallback = "") {
const value = process.env[name];
return value == null || value.length === 0 ? fallback : value;
}
function enabled(name) {
return process.env[name] === "true";
}
function upload(filePath, objectKey, contentType, cacheControl) {
execFileSync(
"aws",
[
"--endpoint-url",
endpointUrl,
"s3api",
"put-object",
"--bucket",
bucket,
"--key",
objectKey,
"--body",
filePath,
"--content-type",
contentType,
"--cache-control",
cacheControl,
"--no-cli-pager",
],
{ stdio: "inherit" },
);
}
function publicUrl(prefix, name) {
return `${publicOrigin}/${prefix}/${name}`;
}
function setOutput(name, value) {
const outputPath = process.env.GITHUB_OUTPUT;
if (outputPath == null || outputPath.length === 0 || value == null) return;
appendFileSync(outputPath, `${name}=${value}\n`);
}
function readManifest(key) {
const path = join(manifestRoot, `${key}.json`);
if (!existsSync(path)) return null;
return JSON.parse(readFileSync(path, "utf8"));
}
const bucket = required("CLOUDFLARE_R2_RELEASES_BUCKET");
const endpointUrl = required("CLOUDFLARE_R2_RELEASES_URL").replace(/\/+$/, "");
const publicOrigin = required("CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN").replace(/\/+$/, "");
const runnerTemp = required("RUNNER_TEMP");
const releaseChannel = required("RELEASE_CHANNEL");
if (releaseChannel !== "beta") {
throw new Error(`publish-beta-metadata only supports beta, got ${releaseChannel}`);
}
const releaseVersion = required("RELEASE_VERSION");
const assetVersionSuffix = optional("ASSET_VERSION_SUFFIX");
const versionPrefix = optional("RELEASE_VERSION_PREFIX", `${releaseChannel}/versions/${releaseVersion}${assetVersionSuffix}`);
const latestPrefix = `${releaseChannel}/latest`;
const manifestRoot = optional("PLATFORM_MANIFEST_ROOT", join(runnerTemp, "release-platform-manifests"));
const platformDefs = [
{ env: "ENABLE_MAC", key: "mac", label: "macOS arm64", result: optional("MAC_RESULT", "skipped") },
{ env: "ENABLE_WIN", key: "win", label: "Windows x64", result: optional("WIN_RESULT", "skipped") },
{ env: "ENABLE_LINUX", key: "linux", label: "Linux x64", result: optional("LINUX_RESULT", "skipped") },
{ env: "ENABLE_MAC_INTEL", key: "macIntel", label: "macOS x64 (Intel)", result: optional("MAC_INTEL_RESULT", "skipped") },
];
const platforms = {};
const expectedPlatforms = [];
const readyPlatforms = [];
const failedPlatforms = [];
for (const def of platformDefs) {
if (!enabled(def.env)) continue;
expectedPlatforms.push(def.key);
const manifest = readManifest(def.key);
if (manifest != null && def.result === "success") {
platforms[def.key] = {
...manifest,
enabled: true,
status: "published",
};
readyPlatforms.push(def.key);
} else {
const status = def.result === "success" ? "missing" : "failed";
platforms[def.key] = {
enabled: true,
label: def.label,
result: def.result,
status,
};
failedPlatforms.push(def.key);
}
}
let releaseState = "failed";
if (expectedPlatforms.length > 0 && readyPlatforms.length === expectedPlatforms.length) {
releaseState = "complete";
} else if (readyPlatforms.length > 0) {
releaseState = "partial";
}
const reportUrl = publicUrl(versionPrefix, "report/");
const latestMetadataUpdated = releaseState === "complete";
const metadata = {
assetVersionSuffix,
baseVersion: required("BASE_VERSION"),
betaNumber: Number(releaseVersion.split("-beta.")[1]),
betaVersion: releaseVersion,
channel: releaseChannel,
expectedPlatforms,
failedPlatforms,
generatedAt: new Date().toISOString(),
github: {
branch: required("BRANCH_NAME"),
commit: process.env.GITHUB_SHA ?? "",
repository: process.env.GITHUB_REPOSITORY ?? "",
runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT ?? "0"),
runId: Number(process.env.GITHUB_RUN_ID ?? "0"),
workflow: process.env.GITHUB_WORKFLOW ?? "",
},
platforms,
r2: {
latestMetadataUrl: publicUrl(latestPrefix, "metadata.json"),
latestMetadataUpdated,
latestPrefix,
publicOrigin,
report: {
type: "directory",
url: reportUrl,
},
reportUrl,
reportZipUrl: null,
versionMetadataUrl: publicUrl(versionPrefix, "metadata.json"),
versionPrefix,
},
readyPlatforms,
releaseState,
signed: process.env.RELEASE_SIGNED === "true",
stateSource: required("STATE_SOURCE"),
version: 1,
};
const metadataPath = join(runnerTemp, "release-beta-metadata.json");
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
upload(metadataPath, `${versionPrefix}/metadata.json`, "application/json; charset=utf-8", "public, max-age=31536000, immutable");
if (latestMetadataUpdated) {
upload(metadataPath, `${latestPrefix}/metadata.json`, "application/json; charset=utf-8", "public, max-age=60, must-revalidate");
} else {
console.log(`left ${metadata.r2.latestMetadataUrl} unchanged because releaseState=${releaseState}`);
}
setOutput("metadata_url", metadata.r2.latestMetadataUrl);
setOutput("latest_metadata_updated", String(latestMetadataUpdated));
setOutput("version_metadata_url", metadata.r2.versionMetadataUrl);
setOutput("version_prefix", versionPrefix);
setOutput("report_url", reportUrl);
setOutput("release_state", releaseState);
for (const [key, platform] of Object.entries(platforms)) {
if (platform.status !== "published") continue;
for (const [artifactName, artifact] of Object.entries(platform.artifacts ?? {})) {
setOutput(`${key}_${artifactName}_url`, artifact.url);
}
if (platform.feed?.latestUrl != null) {
setOutput(`${key}_feed_url`, platform.feed.latestUrl);
}
}
mkdirSync(join(runnerTemp, "release-metadata"), { recursive: true });
writeFileSync(join(runnerTemp, "release-metadata", "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
console.log(`published beta version metadata (${releaseState}) to ${metadata.r2.versionMetadataUrl}`);

View file

@ -0,0 +1,292 @@
import { execFileSync } from "node:child_process";
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { basename, join, relative, sep } from "node:path";
function required(name) {
const value = process.env[name];
if (value == null || value.length === 0) {
throw new Error(`${name} is required`);
}
return value;
}
function optional(name, fallback = "") {
const value = process.env[name];
return value == null || value.length === 0 ? fallback : value;
}
function bool(name) {
return process.env[name] === "true";
}
function normalizePath(value) {
return value.split(sep).join("/");
}
function publicUrl(prefix, name) {
return `${publicOrigin}/${prefix}/${name}`;
}
function contentType(name) {
if (name.endsWith(".dmg")) return "application/x-apple-diskimage";
if (name.endsWith(".zip")) return "application/zip";
if (name.endsWith(".exe")) return "application/vnd.microsoft.portable-executable";
if (name.endsWith(".AppImage")) return "application/octet-stream";
if (name.endsWith(".sha256")) return "text/plain; charset=utf-8";
if (name.endsWith(".yml") || name.endsWith(".yaml")) return "application/x-yaml; charset=utf-8";
if (name.endsWith(".json")) return "application/json; charset=utf-8";
if (name.endsWith(".html")) return "text/html; charset=utf-8";
if (name.endsWith(".log") || name.endsWith(".txt")) return "text/plain; charset=utf-8";
if (name.endsWith(".png")) return "image/png";
if (name.endsWith(".xml")) return "application/xml; charset=utf-8";
return "application/octet-stream";
}
function upload(filePath, objectKey, type, cacheControl) {
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
throw new Error(`expected upload file not found: ${filePath}`);
}
execFileSync(
"aws",
[
"--endpoint-url",
endpointUrl,
"s3api",
"put-object",
"--bucket",
bucket,
"--key",
objectKey,
"--body",
filePath,
"--content-type",
type,
"--cache-control",
cacheControl,
"--no-cli-pager",
],
{ stdio: "inherit" },
);
}
function fileEntry(name, type) {
const filePath = join(releaseRoot, name);
const size = statSync(filePath).size;
const entry = {
contentType: type,
name,
size,
url: publicUrl(versionPrefix, name),
};
const checksumPath = join(releaseRoot, `${name}.sha256`);
if (existsSync(checksumPath)) {
entry.sha256Url = publicUrl(versionPrefix, `${name}.sha256`);
}
return entry;
}
function uploadAsset(name) {
upload(join(releaseRoot, name), `${versionPrefix}/${name}`, contentType(name), "public, max-age=31536000, immutable");
}
function listFiles(root) {
if (!existsSync(root) || !statSync(root).isDirectory()) return [];
const files = [];
const visit = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
visit(path);
} else if (entry.isFile()) {
files.push(path);
}
}
};
visit(root);
files.sort();
return files;
}
function uploadReport(reportDirectory) {
const files = listFiles(reportRoot);
if (files.length === 0) {
throw new Error(`expected ${platform} release report files in ${reportRoot}`);
}
const reportPrefix = `${versionPrefix}/report/${reportDirectory}`;
for (const file of files) {
const relativePath = normalizePath(relative(reportRoot, file));
upload(file, `${reportPrefix}/${relativePath}`, contentType(file), "public, max-age=31536000, immutable");
}
return {
fileCount: files.length,
type: "directory",
url: `${publicOrigin}/${reportPrefix}/`,
};
}
function setOutput(name, value) {
const outputPath = process.env.GITHUB_OUTPUT;
if (outputPath == null || outputPath.length === 0 || value == null) return;
appendFileSync(outputPath, `${name}=${value}\n`);
}
const platform = required("RELEASE_PLATFORM");
const releaseChannel = required("RELEASE_CHANNEL");
const releaseVersion = required("RELEASE_VERSION");
const bucket = required("CLOUDFLARE_R2_RELEASES_BUCKET");
const endpointUrl = required("CLOUDFLARE_R2_RELEASES_URL").replace(/\/+$/, "");
const publicOrigin = required("CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN").replace(/\/+$/, "");
const runnerTemp = required("RUNNER_TEMP");
const assetVersionSuffix = optional("ASSET_VERSION_SUFFIX");
const versionPrefix = optional("RELEASE_VERSION_PREFIX", `${releaseChannel}/versions/${releaseVersion}${assetVersionSuffix}`);
const latestPrefix = `${releaseChannel}/latest`;
const releaseRoot = optional("RELEASE_ROOT", join(runnerTemp, "release-assets"));
const manifestRoot = optional("PLATFORM_MANIFEST_ROOT", join(runnerTemp, "release-platform-manifests"));
let config;
if (platform === "mac") {
const suffix = assetVersionSuffix;
const dmg = `open-design-${releaseVersion}${suffix}-mac-arm64.dmg`;
const zip = `open-design-${releaseVersion}${suffix}-mac-arm64.zip`;
const artifactMode = optional("MAC_ARTIFACT_MODE", "dmg-and-zip");
const artifacts = { dmg: fileEntry(dmg, contentType(dmg)) };
const assetNames = [dmg, `${dmg}.sha256`];
let feed = null;
if (artifactMode !== "dmg-only") {
artifacts.zip = fileEntry(zip, contentType(zip));
assetNames.push(zip, `${zip}.sha256`, "latest-mac.yml");
feed = {
latestUrl: publicUrl(latestPrefix, "latest-mac.yml"),
name: "latest-mac.yml",
url: publicUrl(versionPrefix, "latest-mac.yml"),
};
}
config = {
arch: "arm64",
artifactMode,
artifacts,
assetNames,
feed,
key: "mac",
label: "macOS arm64",
reportDirectory: "mac",
signed: bool("RELEASE_SIGNED"),
};
} else if (platform === "win") {
const suffix = optional("WIN_ASSET_SUFFIX", assetVersionSuffix);
const installer = `open-design-${releaseVersion}${suffix}-win-x64-setup.exe`;
config = {
arch: "x64",
artifacts: { installer: fileEntry(installer, contentType(installer)) },
assetNames: [installer, `${installer}.sha256`, "latest.yml"],
feed: {
latestUrl: publicUrl(latestPrefix, "latest.yml"),
name: "latest.yml",
url: publicUrl(versionPrefix, "latest.yml"),
},
key: "win",
label: "Windows x64",
reportDirectory: "win",
signed: false,
};
} else if (platform === "linux") {
const suffix = optional("LINUX_ASSET_SUFFIX", assetVersionSuffix);
const appImage = `open-design-${releaseVersion}${suffix}-linux-x64.AppImage`;
config = {
arch: "x64",
artifacts: { appImage: fileEntry(appImage, contentType(appImage)) },
assetNames: [appImage, `${appImage}.sha256`],
feed: null,
key: "linux",
label: "Linux x64",
reportDirectory: "linux",
signed: false,
};
} else if (platform === "mac-intel") {
const suffix = optional("MAC_INTEL_ASSET_SUFFIX", assetVersionSuffix);
const dmg = `open-design-${releaseVersion}${suffix}-mac-x64.dmg`;
const zip = `open-design-${releaseVersion}${suffix}-mac-x64.zip`;
config = {
arch: "x64",
artifacts: {
dmg: fileEntry(dmg, contentType(dmg)),
zip: fileEntry(zip, contentType(zip)),
},
assetNames: [dmg, `${dmg}.sha256`, zip, `${zip}.sha256`],
feed: null,
key: "macIntel",
label: "macOS x64 (Intel)",
reportDirectory: null,
signed: bool("MAC_INTEL_SIGNED"),
};
} else {
throw new Error(`unsupported RELEASE_PLATFORM: ${platform}`);
}
const reportRoot = optional(
"REPORT_ROOT",
config.reportDirectory == null ? join(runnerTemp, "release-report", config.key) : join(runnerTemp, "release-report", config.reportDirectory),
);
for (const name of config.assetNames) {
uploadAsset(name);
if (name === "latest.yml" || name === "latest-mac.yml") {
upload(join(releaseRoot, name), `${latestPrefix}/${name}`, contentType(name), "public, max-age=60, must-revalidate");
}
}
const report = config.reportDirectory == null ? null : uploadReport(config.reportDirectory);
const now = new Date().toISOString();
const versionManifestUrl = publicUrl(versionPrefix, `platforms/${config.key}.json`);
const latestManifestUrl = publicUrl(latestPrefix, `platforms/${config.key}.json`);
const manifest = {
arch: config.arch,
artifacts: config.artifacts,
channel: releaseChannel,
enabled: true,
feed: config.feed,
generatedAt: now,
github: {
branch: process.env.GITHUB_REF_NAME ?? "",
commit: process.env.GITHUB_SHA ?? "",
repository: process.env.GITHUB_REPOSITORY ?? "",
runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT ?? "0"),
runId: Number(process.env.GITHUB_RUN_ID ?? "0"),
workflow: process.env.GITHUB_WORKFLOW ?? "",
},
label: config.label,
platform,
platformKey: config.key,
r2: {
latestManifestUrl,
latestPrefix,
publicOrigin,
versionManifestUrl,
versionPrefix,
},
releaseVersion,
report,
signed: config.signed,
status: "published",
version: 1,
};
mkdirSync(manifestRoot, { recursive: true });
const manifestPath = join(manifestRoot, `${config.key}.json`);
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
upload(manifestPath, `${versionPrefix}/platforms/${config.key}.json`, "application/json; charset=utf-8", "public, max-age=31536000, immutable");
upload(manifestPath, `${latestPrefix}/platforms/${config.key}.json`, "application/json; charset=utf-8", "public, max-age=60, must-revalidate");
setOutput("platform_manifest_url", versionManifestUrl);
setOutput("platform_latest_manifest_url", latestManifestUrl);
for (const [artifactName, artifact] of Object.entries(config.artifacts)) {
setOutput(`${artifactName}_url`, artifact.url);
}
if (config.feed != null) {
setOutput("feed_url", config.feed.latestUrl);
}
if (report != null) {
setOutput("report_url", report.url);
}
console.log(`published ${config.label} beta assets to ${versionPrefix}`);

View file

@ -0,0 +1,100 @@
function required(name) {
const value = process.env[name];
if (value == null || value.length === 0) {
throw new Error(`${name} is required`);
}
return value;
}
async function fetchJson(url) {
const response = await fetch(`${url}${url.includes("?") ? "&" : "?"}run=${process.env.GITHUB_RUN_ID ?? "local"}`, {
headers: { "Cache-Control": "no-cache" },
});
if (!response.ok) {
throw new Error(`GET ${url} failed with HTTP ${response.status}`);
}
return await response.json();
}
function md(value) {
return String(value ?? "-").replaceAll("|", "\\|").replaceAll("\n", " ");
}
function code(value) {
return value == null || value === "" ? "-" : `\`${md(value)}\``;
}
function link(label, url) {
return url == null || url === "" ? "-" : `[${md(label)}](${url})`;
}
function linkList(items) {
const links = items
.filter((item) => item.url != null && item.url !== "")
.map((item) => link(item.label, item.url));
return links.length === 0 ? "-" : links.join("<br>");
}
const metadata = await fetchJson(required("R2_METADATA_URL"));
const overviewRows = [
["Channel", code(metadata.channel)],
["Version", code(metadata.betaVersion)],
["Release state", code(metadata.releaseState)],
["Ready platforms", code((metadata.readyPlatforms ?? []).join(", "))],
["Expected platforms", code((metadata.expectedPlatforms ?? []).join(", "))],
["State source", code(metadata.stateSource)],
];
const overviewTable = [
"| Field | Value |",
"| --- | --- |",
...overviewRows.map(([field, value]) => `| ${md(field)} | ${value} |`),
].join("\n");
const releaseLinks = [
["Latest metadata", metadata.r2?.latestMetadataUrl],
["Version metadata", metadata.r2?.versionMetadataUrl],
["Report root", metadata.r2?.reportUrl],
]
.filter(([, url]) => url != null)
.map(([label, url]) => `- ${link(label, url)}`)
.join("\n");
const platformLabels = {
linux: "Linux x64",
mac: "macOS arm64",
macIntel: "macOS x64 (Intel)",
win: "Windows x64",
};
const platformRows = Object.entries(platformLabels).map(([key, labelText]) => {
const platform = metadata.platforms?.[key];
if (platform == null) {
return [labelText, "Skipped", "-", "-", "-"];
}
const artifacts = platform.artifacts ?? {};
return [
labelText,
platform.status ?? "-",
linkList(Object.entries(artifacts).map(([name, artifact]) => ({ label: name, url: artifact.url }))),
link(platform.feed?.name ?? "feed", platform.feed?.latestUrl),
link("report", platform.report?.url),
];
});
const platformTable = [
"| Platform | Status | Assets | Feed | Report |",
"| --- | --- | --- | --- | --- |",
...platformRows.map((row) => `| ${row.map(md).join(" | ")} |`),
].join("\n");
console.log(`## Beta release summary
${overviewTable}
### Release links
${releaseLinks || "-"}
### Platform assets
${platformTable}
`);

View file

@ -0,0 +1,150 @@
function required(name) {
const value = process.env[name];
if (value == null || value.length === 0) {
throw new Error(`${name} is required`);
}
return value;
}
function optional(name, fallback = "") {
const value = process.env[name];
return value == null || value.length === 0 ? fallback : value;
}
function enabled(name) {
return process.env[name] === "true";
}
async function fetchText(url) {
const response = await fetch(`${url}${url.includes("?") ? "&" : "?"}run=${process.env.GITHUB_RUN_ID ?? "local"}`, {
headers: { "Cache-Control": "no-cache" },
});
if (!response.ok) {
throw new Error(`GET ${url} failed with HTTP ${response.status}`);
}
return await response.text();
}
async function head(url) {
const response = await fetch(url, { method: "HEAD" });
if (!response.ok) {
throw new Error(`HEAD ${url} failed with HTTP ${response.status}`);
}
}
function joinUrl(base, path) {
return `${base.replace(/\/+$/, "")}/${path}`;
}
function reportFilesFor(key) {
if (key === "mac") {
return [
"manifest.json",
"screenshots/open-design-mac-smoke.png",
"suite-result.json",
"tools-pack.json",
"tools-pack.log",
"vitest.log",
];
}
if (key === "win") {
return [
"manifest.json",
"screenshots/open-design-win-smoke.png",
"suite-result.json",
"tools-pack.json",
"vitest.log",
];
}
if (key === "linux") {
return ["manifest.json", "screenshots/open-design-linux-smoke.png", "vitest.log"];
}
return [];
}
async function verifyReport(def, platform) {
const expectedFiles = reportFilesFor(def.key);
if (expectedFiles.length === 0) return;
if (platform.report == null || platform.report.type !== "directory" || platform.report.url == null) {
throw new Error(`${def.key} is missing release report metadata`);
}
if (typeof platform.report.fileCount !== "number" || platform.report.fileCount <= 0) {
throw new Error(`${def.key} release report has no files`);
}
for (const file of expectedFiles) {
await head(joinUrl(platform.report.url, file));
}
}
const metadataUrl = required("R2_METADATA_URL");
const releaseVersion = required("RELEASE_VERSION");
const metadata = JSON.parse(await fetchText(metadataUrl));
if (metadata.channel !== "beta") {
throw new Error(`unexpected metadata channel: ${metadata.channel}`);
}
if (metadata.betaVersion !== releaseVersion) {
throw new Error(`unexpected metadata betaVersion: ${metadata.betaVersion}`);
}
const platformDefs = [
{ env: "ENABLE_MAC", key: "mac", result: optional("MAC_RESULT", "skipped") },
{ env: "ENABLE_WIN", key: "win", result: optional("WIN_RESULT", "skipped") },
{ env: "ENABLE_LINUX", key: "linux", result: optional("LINUX_RESULT", "skipped") },
{ env: "ENABLE_MAC_INTEL", key: "macIntel", result: optional("MAC_INTEL_RESULT", "skipped") },
];
const expected = platformDefs.filter((def) => enabled(def.env));
const expectedKeys = expected.map((def) => def.key).sort();
const metadataExpected = [...(metadata.expectedPlatforms ?? [])].sort();
if (JSON.stringify(expectedKeys) !== JSON.stringify(metadataExpected)) {
throw new Error(`unexpected expectedPlatforms: ${JSON.stringify(metadata.expectedPlatforms)}`);
}
const expectedReady = expected.filter((def) => def.result === "success").map((def) => def.key).sort();
const metadataReady = [...(metadata.readyPlatforms ?? [])].sort();
if (JSON.stringify(expectedReady) !== JSON.stringify(metadataReady)) {
throw new Error(`unexpected readyPlatforms: ${JSON.stringify(metadata.readyPlatforms)}`);
}
const expectedReleaseState =
expectedReady.length === expected.length ? "complete" : expectedReady.length > 0 ? "partial" : "failed";
if (metadata.releaseState !== expectedReleaseState) {
throw new Error(`unexpected releaseState: ${metadata.releaseState}; expected ${expectedReleaseState}`);
}
for (const def of expected) {
const platform = metadata.platforms?.[def.key];
if (platform == null) {
throw new Error(`metadata missing platform ${def.key}`);
}
if (def.result !== "success") {
if (platform.status !== "failed" && platform.status !== "missing") {
throw new Error(`unexpected failed platform status for ${def.key}: ${platform.status}`);
}
continue;
}
if (platform.status !== "published") {
throw new Error(`unexpected platform status for ${def.key}: ${platform.status}`);
}
for (const artifact of Object.values(platform.artifacts ?? {})) {
await head(artifact.url);
if (artifact.sha256Url != null) {
await head(artifact.sha256Url);
}
}
if (platform.feed?.latestUrl != null) {
const feed = await fetchText(platform.feed.latestUrl);
if (!feed.includes(`version: "${releaseVersion}"`)) {
throw new Error(`${def.key} feed does not reference ${releaseVersion}`);
}
}
if (platform.r2?.versionManifestUrl != null) {
await head(platform.r2.versionManifestUrl);
}
if (platform.r2?.latestManifestUrl != null) {
await head(platform.r2.latestManifestUrl);
}
await verifyReport(def, platform);
}
console.log(`verified beta metadata ${metadataUrl} (${metadata.releaseState})`);

View file

@ -14,7 +14,9 @@ on:
workflow_dispatch: workflow_dispatch:
permissions: permissions:
actions: read
contents: read contents: read
pull-requests: read
concurrency: concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }} group: ci-${{ github.event.pull_request.number || github.ref }}
@ -36,13 +38,10 @@ jobs:
workspace_validation_required: ${{ steps.detect.outputs.workspace_validation_required }} workspace_validation_required: ${{ steps.detect.outputs.workspace_validation_required }}
steps: steps:
- name: Checkout
uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Detect workspace and app test scopes - name: Detect workspace and app test scopes
id: detect id: detect
env:
GH_TOKEN: ${{ github.token }}
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
@ -52,7 +51,9 @@ jobs:
tools_pack_tests_required=false tools_pack_tests_required=false
workspace_validation_required=false workspace_validation_required=false
if [ "${{ github.event_name }}" = "pull_request" ]; then if [ "${{ github.event_name }}" = "pull_request" ]; then
git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" > "$RUNNER_TEMP/changed-files.txt" gh api --paginate \
"repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \
--jq '.[].filename' > "$RUNNER_TEMP/changed-files.txt"
while IFS= read -r file; do while IFS= read -r file; do
if [[ "$file" == "apps/daemon/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then if [[ "$file" == "apps/daemon/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
daemon_tests_required=true daemon_tests_required=true
@ -172,7 +173,9 @@ jobs:
pnpm --filter @open-design/web build:sidecar pnpm --filter @open-design/web build:sidecar
- name: Typecheck workspaces - name: Typecheck workspaces
run: pnpm -r --workspace-concurrency=4 --if-present run typecheck run: |
pnpm -r --filter '!open-design' --filter '!@open-design/landing-page' --workspace-concurrency=4 --if-present run typecheck
pnpm exec tsc -p scripts/tsconfig.json --noEmit
- name: Check repository layout policies - name: Check repository layout policies
run: pnpm guard run: pnpm guard
@ -269,11 +272,15 @@ jobs:
fi fi
daemon_workspace_tests: daemon_workspace_tests:
name: Daemon workspace tests name: Daemon workspace tests (${{ matrix.shard }}/2)
needs: [change_scopes] needs: [change_scopes]
if: ${{ needs.change_scopes.outputs.daemon_tests_required == 'true' }} if: ${{ needs.change_scopes.outputs.daemon_tests_required == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps: steps:
- name: Checkout - name: Checkout
@ -308,7 +315,7 @@ jobs:
run: pnpm --filter @open-design/daemon build run: pnpm --filter @open-design/daemon build
- name: Daemon workspace tests - name: Daemon workspace tests
run: pnpm --filter @open-design/daemon test run: pnpm --filter @open-design/daemon exec vitest run -c vitest.config.ts --shard=${{ matrix.shard }}/2
web_workspace_tests: web_workspace_tests:
name: Web workspace tests name: Web workspace tests
@ -429,11 +436,21 @@ jobs:
run: pnpm --filter @open-design/e2e test run: pnpm --filter @open-design/e2e test
ui_e2e_critical: ui_e2e_critical:
name: Playwright critical name: Playwright critical (${{ matrix.group }})
needs: [change_scopes] needs: [change_scopes]
if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }} if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- group: core
grep_flag: --grep-invert
grep_pattern: home starters|home hero
- group: starters
grep_flag: --grep
grep_pattern: home starters|home hero
steps: steps:
- name: Checkout - name: Checkout
@ -491,7 +508,7 @@ jobs:
- name: Playwright critical - name: Playwright critical
run: | run: |
pnpm -C e2e exec tsx scripts/playwright.ts clean pnpm -C e2e exec tsx scripts/playwright.ts clean
pnpm --filter @open-design/e2e run test:ui:critical pnpm -C e2e exec playwright test -c playwright.config.ts ${{ matrix.grep_flag }} '${{ matrix.grep_pattern }}' ui/critical-smoke.test.ts ui/entry-chrome-flows.test.ts
build_workspaces: build_workspaces:
name: Build workspaces name: Build workspaces
@ -541,7 +558,7 @@ jobs:
# current workspace is small enough that safer logs and fewer shared-FS # current workspace is small enough that safer logs and fewer shared-FS
# races outweigh the lost parallelism; revisit if the package count grows. # races outweigh the lost parallelism; revisit if the package count grows.
- name: Build workspaces - name: Build workspaces
run: pnpm -r --workspace-concurrency=1 --if-present run build run: pnpm -r --filter '!@open-design/landing-page' --workspace-concurrency=1 --if-present run build
validate: validate:
name: Validate workspace name: Validate workspace
@ -569,3 +586,75 @@ jobs:
echo "$failures" echo "$failures"
exit 1 exit 1
fi fi
runtime_trace:
name: Runtime trace
needs:
- validate
if: ${{ always() && github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Summarize workflow runtime
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
run_json="$RUNNER_TEMP/run.json"
gh run view "$RUN_ID" --repo "$GITHUB_REPOSITORY" --json conclusion,createdAt,databaseId,displayTitle,event,headBranch,jobs,updatedAt,url > "$run_json"
jq -r '
def parse_ts: sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601;
def seconds($start; $end):
if ($start and $end) then (($end | parse_ts) - ($start | parse_ts)) else null end;
def fmt($seconds):
if $seconds == null then "n/a"
elif $seconds >= 60 then "\(((($seconds / 60) * 10 | round) / 10))m"
else "\(($seconds | round))s"
end;
def row($cells): "| \($cells | join(" | ")) |";
.jobs as $jobs |
[
"## Runtime trace",
"",
"Run: [\(.displayTitle)](\(.url))",
"Event: `\(.event)`",
"Branch: `\(.headBranch)`",
"Elapsed: \(fmt(seconds(.createdAt; .updatedAt)))",
"",
"### Jobs",
"| Job | Result | Duration | Slowest step |",
"| --- | --- | ---: | --- |",
(
$jobs
| sort_by(seconds(.startedAt; .completedAt) // 0)
| reverse
| .[]
| select(.conclusion != "skipped")
| (
[(.steps // [])[] | select(.startedAt and .completedAt and .conclusion != "skipped") | {name, duration: seconds(.startedAt; .completedAt)}]
| max_by(.duration // 0)
) as $slow
| row([.name, (.conclusion // .status), fmt(seconds(.startedAt; .completedAt)), "\($slow.name // "n/a") (\(fmt($slow.duration)))"])
),
"",
"### Slowest steps",
"| Step | Job | Duration |",
"| --- | --- | ---: |",
(
[
$jobs[] as $job
| ($job.steps // [])[]
| select(.startedAt and .completedAt and .conclusion != "skipped")
| {job: $job.name, name, duration: seconds(.startedAt; .completedAt)}
]
| sort_by(.duration // 0)
| reverse
| .[0:20][]
| row([.name, .job, fmt(.duration)])
)
][]
' "$run_json" >> "$GITHUB_STEP_SUMMARY"

View file

@ -24,27 +24,7 @@ on:
- .github/ISSUE_TEMPLATE/** - .github/ISSUE_TEMPLATE/**
- .github/PULL_REQUEST_TEMPLATE.md - .github/PULL_REQUEST_TEMPLATE.md
- .github/CODEOWNERS - .github/CODEOWNERS
pull_request: workflow_dispatch:
paths-ignore:
- '**/*.md'
- '**/*.mdx'
- '**/*.txt'
- LICENSE
- .gitignore
- .editorconfig
- .vscode/**
- .idea/**
- docs/**
- assets/**
- '**/*.png'
- '**/*.jpg'
- '**/*.jpeg'
- '**/*.gif'
- '**/*.svg'
- '**/*.webp'
- .github/ISSUE_TEMPLATE/**
- .github/PULL_REQUEST_TEMPLATE.md
- .github/CODEOWNERS
permissions: permissions:
contents: read contents: read
@ -75,21 +55,3 @@ jobs:
- name: nix flake check - name: nix flake check
run: nix flake check --print-build-logs --keep-going run: nix flake check --print-build-logs --keep-going
- name: nix build .#daemon
id: build-daemon
run: nix build .#daemon --print-build-logs --log-format raw |& tee daemon-build.log
- name: nix build .#web
id: build-web
run: nix build .#web --print-build-logs --log-format raw |& tee web-build.log
- name: Upload build logs (on failure)
if: failure()
uses: actions/upload-artifact@v4
with:
name: nix-build-logs
path: |
daemon-build.log
web-build.log
if-no-files-found: ignore

View file

@ -101,8 +101,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v5 uses: pnpm/action-setup@v5
@ -290,11 +288,28 @@ jobs:
TOOLS_PACK_NAMESPACE: release-beta TOOLS_PACK_NAMESPACE: release-beta
run: bash .github/scripts/release/assets/mac.sh run: bash .github/scripts/release/assets/mac.sh
- name: Upload mac release bundle - name: Publish beta mac assets to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: "true"
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
ASSET_VERSION_SUFFIX: ${{ needs.metadata.outputs.asset_version_suffix }}
MAC_ARTIFACT_MODE: dmg-only
RELEASE_CHANNEL: beta
RELEASE_PLATFORM: mac
RELEASE_SIGNED: "true"
RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }}
run: node --experimental-strip-types .github/scripts/release/r2/publish-platform.ts
- name: Upload mac publish manifest
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: open-design-beta-mac-release-assets name: open-design-beta-mac-publish-manifest
path: ${{ runner.temp }}/release-assets path: ${{ runner.temp }}/release-platform-manifests/mac.json
build_mac_intel: build_mac_intel:
name: Build beta mac x64 name: Build beta mac x64
@ -304,8 +319,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v5 uses: pnpm/action-setup@v5
@ -338,16 +351,33 @@ jobs:
id: assets id: assets
env: env:
ASSET_VERSION_SUFFIX: .unsigned ASSET_VERSION_SUFFIX: .unsigned
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_CHANNEL: beta RELEASE_CHANNEL: beta
RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }} RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }}
TOOLS_PACK_NAMESPACE: release-beta-intel TOOLS_PACK_NAMESPACE: release-beta-intel
run: bash .github/scripts/release/assets/mac-intel.sh run: bash .github/scripts/release/assets/mac-intel.sh
- name: Upload mac intel release bundle - name: Publish beta mac intel assets to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: "true"
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
ASSET_VERSION_SUFFIX: ${{ needs.metadata.outputs.asset_version_suffix }}
MAC_INTEL_ASSET_SUFFIX: .unsigned
RELEASE_CHANNEL: beta
RELEASE_PLATFORM: mac-intel
RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }}
run: node --experimental-strip-types .github/scripts/release/r2/publish-platform.ts
- name: Upload mac intel publish manifest
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: open-design-beta-mac-intel-release-assets name: open-design-beta-mac-intel-publish-manifest
path: ${{ runner.temp }}/release-assets path: ${{ runner.temp }}/release-platform-manifests/macIntel.json
build_win: build_win:
name: Build beta win x64 name: Build beta win x64
@ -357,8 +387,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v5 uses: pnpm/action-setup@v5
@ -468,6 +496,7 @@ jobs:
env: env:
OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/windows-tools-pack-build.json OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/windows-tools-pack-build.json
OD_PACKAGED_E2E_WIN: "1" OD_PACKAGED_E2E_WIN: "1"
OD_PACKAGED_E2E_WIN_VERIFY_REINSTALL: "0"
OD_PACKAGED_E2E_NAMESPACE: release-beta-win OD_PACKAGED_E2E_NAMESPACE: release-beta-win
OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/win OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/win
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
@ -486,35 +515,6 @@ jobs:
path: ${{ runner.temp }}/release-report/win path: ${{ runner.temp }}/release-report/win
if-no-files-found: warn if-no-files-found: warn
- name: Prune Windows tools-pack cache
shell: pwsh
continue-on-error: true
run: ./.github/scripts/release/cache/win.ps1
- name: Save Windows tools-pack cache
if: ${{ success() && (steps.win_tools_pack_cache_restore.outputs.cache-hit != 'true' || steps.win_tools_pack_build.outputs.cache_failed == 'true') }}
uses: actions/cache/save@v5
continue-on-error: true
with:
path: ${{ runner.temp }}/tools-pack-cache
key: ${{ steps.win_tools_pack_cache_key.outputs.key }}
- name: Retain recent Windows tools-pack caches
if: ${{ success() }}
shell: pwsh
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
$prefix = "${{ steps.win_tools_pack_cache_key.outputs.prefix }}"
$keep = 3
$caches = @(gh cache list --key $prefix --sort created_at --order desc --limit 100 --json id,key,createdAt | ConvertFrom-Json)
$stale = @($caches | Select-Object -Skip $keep)
foreach ($cache in $stale) {
gh cache delete $cache.id
}
"actionsCachePrefix=$prefix kept=$([Math]::Min($caches.Count, $keep)) deleted=$($stale.Count)"
- name: Prepare windows beta assets - name: Prepare windows beta assets
shell: pwsh shell: pwsh
env: env:
@ -527,11 +527,27 @@ jobs:
WINDOWS_ASSET_SUFFIX: .unsigned WINDOWS_ASSET_SUFFIX: .unsigned
run: ./.github/scripts/release/assets/win.ps1 run: ./.github/scripts/release/assets/win.ps1
- name: Upload windows release bundle - name: Publish beta windows assets to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: "true"
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
ASSET_VERSION_SUFFIX: ${{ needs.metadata.outputs.asset_version_suffix }}
RELEASE_CHANNEL: beta
RELEASE_PLATFORM: win
RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }}
WIN_ASSET_SUFFIX: .unsigned
run: node --experimental-strip-types .github/scripts/release/r2/publish-platform.ts
- name: Upload windows publish manifest
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: open-design-beta-win-release-assets name: open-design-beta-win-publish-manifest
path: ${{ runner.temp }}/release-assets path: ${{ runner.temp }}/release-platform-manifests/win.json
build_linux: build_linux:
name: Build beta linux x64 name: Build beta linux x64
@ -541,8 +557,6 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v5 uses: pnpm/action-setup@v5
@ -553,6 +567,8 @@ jobs:
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: 24 node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@ -631,14 +647,30 @@ jobs:
TOOLS_PACK_NAMESPACE: release-beta-linux TOOLS_PACK_NAMESPACE: release-beta-linux
run: bash .github/scripts/release/assets/linux.sh run: bash .github/scripts/release/assets/linux.sh
- name: Upload linux release bundle - name: Publish beta linux assets to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }}
AWS_DEFAULT_REGION: auto
AWS_EC2_METADATA_DISABLED: "true"
CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }}
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }}
ASSET_VERSION_SUFFIX: ${{ needs.metadata.outputs.asset_version_suffix }}
LINUX_ASSET_SUFFIX: .unsigned
RELEASE_CHANNEL: beta
RELEASE_PLATFORM: linux
RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }}
run: node --experimental-strip-types .github/scripts/release/r2/publish-platform.ts
- name: Upload linux publish manifest
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: open-design-beta-linux-release-assets name: open-design-beta-linux-publish-manifest
path: ${{ runner.temp }}/release-assets path: ${{ runner.temp }}/release-platform-manifests/linux.json
publish: publish:
name: Publish beta release to R2 name: Publish beta metadata to R2
needs: needs:
- metadata - metadata
- build_mac - build_mac
@ -650,11 +682,7 @@ jobs:
always() && always() &&
!cancelled() && !cancelled() &&
needs.metadata.result == 'success' && needs.metadata.result == 'success' &&
(inputs.enable_mac || inputs.enable_win || inputs.enable_mac_intel || inputs.enable_linux) && (inputs.enable_mac || inputs.enable_win || inputs.enable_mac_intel || inputs.enable_linux)
(!inputs.enable_mac || needs.build_mac.result == 'success') &&
(!inputs.enable_mac_intel || needs.build_mac_intel.result == 'success') &&
(!inputs.enable_win || needs.build_win.result == 'success') &&
(!inputs.enable_linux || needs.build_linux.result == 'success')
}} }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
@ -674,110 +702,142 @@ jobs:
ENABLE_MAC: ${{ inputs.enable_mac }} ENABLE_MAC: ${{ inputs.enable_mac }}
ENABLE_MAC_INTEL: ${{ inputs.enable_mac_intel }} ENABLE_MAC_INTEL: ${{ inputs.enable_mac_intel }}
ENABLE_WIN: ${{ inputs.enable_win }} ENABLE_WIN: ${{ inputs.enable_win }}
GITHUB_RELEASE_ENABLED: "false" LINUX_RESULT: ${{ needs.build_linux.result }}
LINUX_ASSET_SUFFIX: .unsigned MAC_INTEL_RESULT: ${{ needs.build_mac_intel.result }}
MAC_ARTIFACT_MODE: dmg-only MAC_RESULT: ${{ needs.build_mac.result }}
MAC_INTEL_ASSET_SUFFIX: .unsigned
RELEASE_CHANNEL: beta RELEASE_CHANNEL: beta
RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }} RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }}
RELEASE_SIGNED: "true" RELEASE_SIGNED: "true"
REPORT_MODE: zip
STATE_SOURCE: ${{ needs.metadata.outputs.state_source }} STATE_SOURCE: ${{ needs.metadata.outputs.state_source }}
WIN_ASSET_SUFFIX: .unsigned WIN_RESULT: ${{ needs.build_win.result }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- name: Download mac release bundle - name: Download mac publish manifest
if: ${{ inputs.enable_mac }} if: ${{ inputs.enable_mac && needs.build_mac.result == 'success' }}
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
name: open-design-beta-mac-release-assets name: open-design-beta-mac-publish-manifest
path: ${{ runner.temp }}/release-assets/mac path: ${{ runner.temp }}/release-platform-manifests
- name: Download mac intel release bundle - name: Download mac intel publish manifest
if: ${{ inputs.enable_mac_intel }} if: ${{ inputs.enable_mac_intel && needs.build_mac_intel.result == 'success' }}
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
name: open-design-beta-mac-intel-release-assets name: open-design-beta-mac-intel-publish-manifest
path: ${{ runner.temp }}/release-assets/mac-intel path: ${{ runner.temp }}/release-platform-manifests
- name: Download windows release bundle - name: Download windows publish manifest
if: ${{ inputs.enable_win }} if: ${{ inputs.enable_win && needs.build_win.result == 'success' }}
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
name: open-design-beta-win-release-assets name: open-design-beta-win-publish-manifest
path: ${{ runner.temp }}/release-assets/win path: ${{ runner.temp }}/release-platform-manifests
- name: Download linux release bundle - name: Download linux publish manifest
if: ${{ inputs.enable_linux }} if: ${{ inputs.enable_linux && needs.build_linux.result == 'success' }}
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
name: open-design-beta-linux-release-assets name: open-design-beta-linux-publish-manifest
path: ${{ runner.temp }}/release-assets/linux path: ${{ runner.temp }}/release-platform-manifests
- name: Download linux e2e spec report
if: ${{ inputs.enable_linux }}
uses: actions/download-artifact@v8
with:
name: open-design-beta-linux-e2e-report
path: ${{ runner.temp }}/release-report/linux
- name: Download mac e2e spec report
if: ${{ inputs.enable_mac }}
uses: actions/download-artifact@v8
with:
name: open-design-beta-mac-e2e-report
path: ${{ runner.temp }}/release-report/mac
- name: Download windows e2e spec report
if: ${{ inputs.enable_win }}
uses: actions/download-artifact@v8
with:
name: open-design-beta-win-e2e-report
path: ${{ runner.temp }}/release-report/win
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: 24 node-version: 24
- name: Publish beta assets and metadata to R2 - name: Publish beta metadata to R2
id: r2 id: r2
run: bash .github/scripts/release/r2/publish.sh run: node --experimental-strip-types .github/scripts/release/r2/publish-beta-metadata.ts
- name: Verify R2 beta publish - name: Verify R2 beta metadata
env: env:
R2_LINUX_APPIMAGE_URL: ${{ steps.r2.outputs.linux_appimage_url }} R2_METADATA_URL: ${{ steps.r2.outputs.version_metadata_url }}
R2_MAC_DMG_URL: ${{ steps.r2.outputs.mac_dmg_url }} run: node --experimental-strip-types .github/scripts/release/r2/verify-beta-metadata.ts
R2_MAC_FEED_URL: ${{ steps.r2.outputs.mac_feed_url }}
R2_MAC_INTEL_DMG_URL: ${{ steps.r2.outputs.mac_intel_dmg_url }}
R2_MAC_INTEL_ZIP_URL: ${{ steps.r2.outputs.mac_intel_zip_url }}
R2_MAC_ZIP_URL: ${{ steps.r2.outputs.mac_zip_url }}
R2_METADATA_URL: ${{ steps.r2.outputs.metadata_url }}
R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }}
R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }}
R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }}
run: bash .github/scripts/release/r2/verify.sh
- name: Publish summary - name: Publish summary
env: env:
R2_LINUX_APPIMAGE_URL: ${{ steps.r2.outputs.linux_appimage_url }} R2_METADATA_URL: ${{ steps.r2.outputs.version_metadata_url }}
R2_MAC_DMG_URL: ${{ steps.r2.outputs.mac_dmg_url }} run: node --experimental-strip-types .github/scripts/release/r2/summary-beta.ts >> "$GITHUB_STEP_SUMMARY"
R2_MAC_FEED_URL: ${{ steps.r2.outputs.mac_feed_url }}
R2_MAC_INTEL_DMG_URL: ${{ steps.r2.outputs.mac_intel_dmg_url }}
R2_MAC_INTEL_ZIP_URL: ${{ steps.r2.outputs.mac_intel_zip_url }}
R2_MAC_ZIP_URL: ${{ steps.r2.outputs.mac_zip_url }}
R2_METADATA_URL: ${{ steps.r2.outputs.metadata_url }}
R2_REPORT_ZIP_URL: ${{ steps.r2.outputs.report_zip_url }}
R2_VERSION_METADATA_URL: ${{ steps.r2.outputs.version_metadata_url }}
R2_VERSION_PREFIX: ${{ steps.r2.outputs.version_prefix }}
R2_WIN_FEED_URL: ${{ steps.r2.outputs.win_feed_url }}
R2_WIN_INSTALLER_URL: ${{ steps.r2.outputs.win_installer_url }}
run: bash .github/scripts/release/r2/summary.sh
- name: Cleanup workflow artifacts - name: Cleanup workflow artifacts
if: ${{ success() }} if: ${{ success() && steps.r2.outputs.release_state == 'complete' }}
run: bash .github/scripts/release/github/cleanup-artifacts.sh run: bash .github/scripts/release/github/cleanup-artifacts.sh
runtime_trace:
name: Runtime trace
needs:
- metadata
- build_mac
- build_mac_intel
- build_win
- build_linux
- publish
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Summarize workflow runtime
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
run_json="$RUNNER_TEMP/run.json"
gh run view "$RUN_ID" --repo "$GITHUB_REPOSITORY" --json conclusion,createdAt,databaseId,displayTitle,event,headBranch,jobs,updatedAt,url > "$run_json"
jq -r '
def parse_ts: sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601;
def seconds($start; $end):
if ($start and $end) then (($end | parse_ts) - ($start | parse_ts)) else null end;
def fmt($seconds):
if $seconds == null then "n/a"
elif $seconds >= 60 then "\(((($seconds / 60) * 10 | round) / 10))m"
else "\(($seconds | round))s"
end;
def row($cells): "| \($cells | join(" | ")) |";
.jobs as $jobs |
[
"## Runtime trace",
"",
"Run: [\(.displayTitle)](\(.url))",
"Event: `\(.event)`",
"Branch: `\(.headBranch)`",
"Elapsed: \(fmt(seconds(.createdAt; .updatedAt)))",
"",
"### Jobs",
"| Job | Result | Duration | Slowest step |",
"| --- | --- | ---: | --- |",
(
$jobs
| sort_by(seconds(.startedAt; .completedAt) // 0)
| reverse
| .[]
| select(.conclusion != "skipped")
| (
[(.steps // [])[] | select(.startedAt and .completedAt and .conclusion != "skipped") | {name, duration: seconds(.startedAt; .completedAt)}]
| max_by(.duration // 0)
) as $slow
| row([.name, (.conclusion // .status), fmt(seconds(.startedAt; .completedAt)), "\($slow.name // "n/a") (\(fmt($slow.duration)))"])
),
"",
"### Slowest steps",
"| Step | Job | Duration |",
"| --- | --- | ---: |",
(
[
$jobs[] as $job
| ($job.steps // [])[]
| select(.startedAt and .completedAt and .conclusion != "skipped")
| {job: $job.name, name, duration: seconds(.startedAt; .completedAt)}
]
| sort_by(.duration // 0)
| reverse
| .[0:20][]
| row([.name, .job, fmt(.duration)])
)
][]
' "$run_json" >> "$GITHUB_STEP_SUMMARY"

View file

@ -17,6 +17,7 @@ const toolsPackDir = resolveFromWorkspace(process.env.OD_PACKAGED_E2E_TOOLS_PACK
const namespace = process.env.OD_PACKAGED_E2E_NAMESPACE ?? 'release-beta-win'; const namespace = process.env.OD_PACKAGED_E2E_NAMESPACE ?? 'release-beta-win';
const toolsPackBin = join(workspaceRoot, 'tools', 'pack', 'bin', 'tools-pack.mjs'); const toolsPackBin = join(workspaceRoot, 'tools', 'pack', 'bin', 'tools-pack.mjs');
const maxInstallDurationMs = Number.parseInt(process.env.OD_PACKAGED_E2E_WIN_MAX_INSTALL_MS ?? '120000', 10); const maxInstallDurationMs = Number.parseInt(process.env.OD_PACKAGED_E2E_WIN_MAX_INSTALL_MS ?? '120000', 10);
const verifyReinstallWhileRunning = process.env.OD_PACKAGED_E2E_WIN_VERIFY_REINSTALL !== '0';
const installIdentity = resolveInstallIdentity(namespace); const installIdentity = resolveInstallIdentity(namespace);
const outputNamespaceRoot = join(toolsPackDir, 'out', 'win', 'namespaces', namespace); const outputNamespaceRoot = join(toolsPackDir, 'out', 'win', 'namespaces', namespace);
@ -196,26 +197,29 @@ winDescribe('packaged windows runtime smoke', () => {
expect(value.health.ok).toBe(true); expect(value.health.ok).toBe(true);
expect(value.health.version).toEqual(expect.any(String)); expect(value.health.version).toEqual(expect.any(String));
const reinstall = await measureSmokeStep(timings, 'direct reinstall while running', async () => let reinstall: DirectInstallerResult | { skipped: true } = { skipped: true };
runDirectInstaller(install.installerPath, install.installDir), if (verifyReinstallWhileRunning) {
); reinstall = await measureSmokeStep(timings, 'direct reinstall while running', async () =>
started = false; runDirectInstaller(install.installerPath, install.installDir),
expect(reinstall.code).toBe(0); );
expect(reinstall.nsisLogTail.join('\n')).toContain('running instances detected before silent install'); started = false;
expect(reinstall.nsisLogTail.join('\n')).toContain('running instances close exit=0'); expect(reinstall.code).toBe(0);
expect(reinstall.nsisLogTail.join('\n')).toContain('running instances detected before silent install');
expect(reinstall.nsisLogTail.join('\n')).toContain('running instances close exit=0');
start = await measureSmokeStep(timings, 'restart after direct reinstall', async () => start = await measureSmokeStep(timings, 'restart after direct reinstall', async () =>
runToolsPackJson<WinStartResult>('start'), runToolsPackJson<WinStartResult>('start'),
); );
started = true; started = true;
expect(start.namespace).toBe(namespace); expect(start.namespace).toBe(namespace);
expect(start.source).toBe('installed'); expect(start.source).toBe('installed');
expectPathInside(start.executablePath, install.installDir); expectPathInside(start.executablePath, install.installDir);
const postReinstallInspect = await measureSmokeStep(timings, 'wait healthy inspect after reinstall', async () => const postReinstallInspect = await measureSmokeStep(timings, 'wait healthy inspect after reinstall', async () =>
waitForHealthyDesktop(), waitForHealthyDesktop(),
); );
expect(postReinstallInspect.status?.state).toBe('running'); expect(postReinstallInspect.status?.state).toBe('running');
}
await mkdir(dirname(screenshotPath), { recursive: true }); await mkdir(dirname(screenshotPath), { recursive: true });
const screenshot = await measureSmokeStep(timings, 'inspect screenshot', async () => const screenshot = await measureSmokeStep(timings, 'inspect screenshot', async () =>

View file

@ -24,7 +24,7 @@ describe("packaged smoke workflow", () => {
expect(workflow).not.toContain("actions/cache/save"); expect(workflow).not.toContain("actions/cache/save");
}); });
it("preserves beta linux AppImage smoke reports for release publication", async () => { it("preserves beta linux AppImage smoke reports for platform publication", async () => {
const workflow = await readFile(releaseBetaWorkflowPath, "utf8"); const workflow = await readFile(releaseBetaWorkflowPath, "utf8");
const linuxBuildStep = workflow.match( const linuxBuildStep = workflow.match(
/- name: Build beta linux artifacts\n(?:.+\n)+?(?=\n - name: Smoke beta linux AppImage runtime)/m, /- name: Build beta linux artifacts\n(?:.+\n)+?(?=\n - name: Smoke beta linux AppImage runtime)/m,
@ -38,7 +38,11 @@ describe("packaged smoke workflow", () => {
expect(workflow).toContain("tools-pack.json"); expect(workflow).toContain("tools-pack.json");
expect(workflow).toContain("Upload linux e2e spec report"); expect(workflow).toContain("Upload linux e2e spec report");
expect(workflow).toContain("open-design-beta-linux-e2e-report"); expect(workflow).toContain("open-design-beta-linux-e2e-report");
expect(workflow).toContain("Download linux e2e spec report"); expect(workflow).toContain("Publish beta linux assets to R2");
expect(workflow).toContain("RELEASE_PLATFORM: linux");
expect(workflow).toContain("Upload linux publish manifest");
expect(workflow).toContain("open-design-beta-linux-publish-manifest");
expect(workflow).not.toContain("Download linux e2e spec report");
expectReleaseLinuxBuildPreservesEvidence(workflow, "Build beta linux artifacts"); expectReleaseLinuxBuildPreservesEvidence(workflow, "Build beta linux artifacts");
expectReleaseLinuxSmokePreservesEvidenceBeforeApt(workflow, "Smoke beta linux AppImage runtime"); expectReleaseLinuxSmokePreservesEvidenceBeforeApt(workflow, "Smoke beta linux AppImage runtime");
}); });

View file

@ -229,8 +229,9 @@ at the top of each file and re-run. Bump the hash whenever
## CI ## CI
`.github/workflows/nix-check.yml` runs `nix flake check` followed by `.github/workflows/nix-check.yml` runs `nix flake check` on pushes to
separate `nix build .#daemon` and `nix build .#web` steps on each push `main` and can also be started manually with `workflow_dispatch`. It is
that touches the flake or the lockfile. Build artifacts are cached on not a default pull request gate: the flake is a community installation
the `nexu-open-design` Cachix instance — PRs from forks read from the and deployment surface, while regular PR validation stays focused on the
cache without needing the auth token. primary product delivery checks. The flake check already builds the
`daemon` and `web` checks declared in `flake.nix`.