From bb13eee7650d9c8d2fc5e443605d22d16e043006 Mon Sep 17 00:00:00 2001
From: PerishFire <39043006+PerishCode@users.noreply.github.com>
Date: Tue, 19 May 2026 18:06:28 +0800
Subject: [PATCH] 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
---
.../release/r2/publish-beta-metadata.ts | 188 +++++++++++
.../scripts/release/r2/publish-platform.ts | 292 ++++++++++++++++
.github/scripts/release/r2/summary-beta.ts | 100 ++++++
.../release/r2/verify-beta-metadata.ts | 150 +++++++++
.github/workflows/ci.yml | 113 ++++++-
.github/workflows/nix-check.yml | 40 +--
.github/workflows/release-beta.yml | 316 +++++++++++-------
e2e/specs/win.spec.ts | 40 ++-
e2e/tests/packaged-smoke-workflow.test.ts | 8 +-
nix/README.md | 11 +-
10 files changed, 1054 insertions(+), 204 deletions(-)
create mode 100644 .github/scripts/release/r2/publish-beta-metadata.ts
create mode 100644 .github/scripts/release/r2/publish-platform.ts
create mode 100644 .github/scripts/release/r2/summary-beta.ts
create mode 100644 .github/scripts/release/r2/verify-beta-metadata.ts
diff --git a/.github/scripts/release/r2/publish-beta-metadata.ts b/.github/scripts/release/r2/publish-beta-metadata.ts
new file mode 100644
index 000000000..0bc48f790
--- /dev/null
+++ b/.github/scripts/release/r2/publish-beta-metadata.ts
@@ -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}`);
diff --git a/.github/scripts/release/r2/publish-platform.ts b/.github/scripts/release/r2/publish-platform.ts
new file mode 100644
index 000000000..4acf56828
--- /dev/null
+++ b/.github/scripts/release/r2/publish-platform.ts
@@ -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}`);
diff --git a/.github/scripts/release/r2/summary-beta.ts b/.github/scripts/release/r2/summary-beta.ts
new file mode 100644
index 000000000..b2f8ea736
--- /dev/null
+++ b/.github/scripts/release/r2/summary-beta.ts
@@ -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("
");
+}
+
+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}
+`);
diff --git a/.github/scripts/release/r2/verify-beta-metadata.ts b/.github/scripts/release/r2/verify-beta-metadata.ts
new file mode 100644
index 000000000..25d07442c
--- /dev/null
+++ b/.github/scripts/release/r2/verify-beta-metadata.ts
@@ -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})`);
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c2dac40c6..3489356e4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,7 +14,9 @@ on:
workflow_dispatch:
permissions:
+ actions: read
contents: read
+ pull-requests: read
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
@@ -36,13 +38,10 @@ jobs:
workspace_validation_required: ${{ steps.detect.outputs.workspace_validation_required }}
steps:
- - name: Checkout
- uses: actions/checkout@v6.0.2
- with:
- fetch-depth: 0
-
- name: Detect workspace and app test scopes
id: detect
+ env:
+ GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
@@ -52,7 +51,9 @@ jobs:
tools_pack_tests_required=false
workspace_validation_required=false
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
if [[ "$file" == "apps/daemon/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
daemon_tests_required=true
@@ -172,7 +173,9 @@ jobs:
pnpm --filter @open-design/web build:sidecar
- 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
run: pnpm guard
@@ -269,11 +272,15 @@ jobs:
fi
daemon_workspace_tests:
- name: Daemon workspace tests
+ name: Daemon workspace tests (${{ matrix.shard }}/2)
needs: [change_scopes]
if: ${{ needs.change_scopes.outputs.daemon_tests_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2]
steps:
- name: Checkout
@@ -308,7 +315,7 @@ jobs:
run: pnpm --filter @open-design/daemon build
- 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:
name: Web workspace tests
@@ -429,11 +436,21 @@ jobs:
run: pnpm --filter @open-design/e2e test
ui_e2e_critical:
- name: Playwright critical
+ name: Playwright critical (${{ matrix.group }})
needs: [change_scopes]
if: ${{ needs.change_scopes.outputs.workspace_validation_required == 'true' }}
runs-on: ubuntu-latest
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:
- name: Checkout
@@ -491,7 +508,7 @@ jobs:
- name: Playwright critical
run: |
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:
name: Build workspaces
@@ -541,7 +558,7 @@ jobs:
# current workspace is small enough that safer logs and fewer shared-FS
# races outweigh the lost parallelism; revisit if the package count grows.
- 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:
name: Validate workspace
@@ -569,3 +586,75 @@ jobs:
echo "$failures"
exit 1
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"
diff --git a/.github/workflows/nix-check.yml b/.github/workflows/nix-check.yml
index fd772b32d..6f3e820d5 100644
--- a/.github/workflows/nix-check.yml
+++ b/.github/workflows/nix-check.yml
@@ -24,27 +24,7 @@ on:
- .github/ISSUE_TEMPLATE/**
- .github/PULL_REQUEST_TEMPLATE.md
- .github/CODEOWNERS
- pull_request:
- 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
+ workflow_dispatch:
permissions:
contents: read
@@ -75,21 +55,3 @@ jobs:
- name: nix flake check
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
diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml
index 5829aec10..9303d4583 100644
--- a/.github/workflows/release-beta.yml
+++ b/.github/workflows/release-beta.yml
@@ -101,8 +101,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- with:
- fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
@@ -290,11 +288,28 @@ jobs:
TOOLS_PACK_NAMESPACE: release-beta
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
with:
- name: open-design-beta-mac-release-assets
- path: ${{ runner.temp }}/release-assets
+ name: open-design-beta-mac-publish-manifest
+ path: ${{ runner.temp }}/release-platform-manifests/mac.json
build_mac_intel:
name: Build beta mac x64
@@ -304,8 +319,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- with:
- fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
@@ -338,16 +351,33 @@ jobs:
id: assets
env:
ASSET_VERSION_SUFFIX: .unsigned
+ CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
RELEASE_CHANNEL: beta
RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }}
TOOLS_PACK_NAMESPACE: release-beta-intel
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
with:
- name: open-design-beta-mac-intel-release-assets
- path: ${{ runner.temp }}/release-assets
+ name: open-design-beta-mac-intel-publish-manifest
+ path: ${{ runner.temp }}/release-platform-manifests/macIntel.json
build_win:
name: Build beta win x64
@@ -357,8 +387,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- with:
- fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
@@ -468,6 +496,7 @@ jobs:
env:
OD_PACKAGED_E2E_BUILD_JSON_PATH: ${{ runner.temp }}/windows-tools-pack-build.json
OD_PACKAGED_E2E_WIN: "1"
+ OD_PACKAGED_E2E_WIN_VERIFY_REINSTALL: "0"
OD_PACKAGED_E2E_NAMESPACE: release-beta-win
OD_PACKAGED_E2E_REPORT_DIR: ${{ runner.temp }}/release-report/win
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
@@ -486,35 +515,6 @@ jobs:
path: ${{ runner.temp }}/release-report/win
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
shell: pwsh
env:
@@ -527,11 +527,27 @@ jobs:
WINDOWS_ASSET_SUFFIX: .unsigned
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
with:
- name: open-design-beta-win-release-assets
- path: ${{ runner.temp }}/release-assets
+ name: open-design-beta-win-publish-manifest
+ path: ${{ runner.temp }}/release-platform-manifests/win.json
build_linux:
name: Build beta linux x64
@@ -541,8 +557,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- with:
- fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
@@ -553,6 +567,8 @@ jobs:
uses: actions/setup-node@v6
with:
node-version: 24
+ cache: pnpm
+ cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -631,14 +647,30 @@ jobs:
TOOLS_PACK_NAMESPACE: release-beta-linux
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
with:
- name: open-design-beta-linux-release-assets
- path: ${{ runner.temp }}/release-assets
+ name: open-design-beta-linux-publish-manifest
+ path: ${{ runner.temp }}/release-platform-manifests/linux.json
publish:
- name: Publish beta release to R2
+ name: Publish beta metadata to R2
needs:
- metadata
- build_mac
@@ -650,11 +682,7 @@ jobs:
always() &&
!cancelled() &&
needs.metadata.result == 'success' &&
- (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')
+ (inputs.enable_mac || inputs.enable_win || inputs.enable_mac_intel || inputs.enable_linux)
}}
runs-on: ubuntu-latest
env:
@@ -674,110 +702,142 @@ jobs:
ENABLE_MAC: ${{ inputs.enable_mac }}
ENABLE_MAC_INTEL: ${{ inputs.enable_mac_intel }}
ENABLE_WIN: ${{ inputs.enable_win }}
- GITHUB_RELEASE_ENABLED: "false"
- LINUX_ASSET_SUFFIX: .unsigned
- MAC_ARTIFACT_MODE: dmg-only
- MAC_INTEL_ASSET_SUFFIX: .unsigned
+ LINUX_RESULT: ${{ needs.build_linux.result }}
+ MAC_INTEL_RESULT: ${{ needs.build_mac_intel.result }}
+ MAC_RESULT: ${{ needs.build_mac.result }}
RELEASE_CHANNEL: beta
RELEASE_VERSION: ${{ needs.metadata.outputs.beta_version }}
RELEASE_SIGNED: "true"
- REPORT_MODE: zip
STATE_SOURCE: ${{ needs.metadata.outputs.state_source }}
- WIN_ASSET_SUFFIX: .unsigned
+ WIN_RESULT: ${{ needs.build_win.result }}
steps:
- name: Checkout
uses: actions/checkout@v6.0.2
- with:
- fetch-depth: 0
- - name: Download mac release bundle
- if: ${{ inputs.enable_mac }}
+ - name: Download mac publish manifest
+ if: ${{ inputs.enable_mac && needs.build_mac.result == 'success' }}
uses: actions/download-artifact@v8
with:
- name: open-design-beta-mac-release-assets
- path: ${{ runner.temp }}/release-assets/mac
+ name: open-design-beta-mac-publish-manifest
+ path: ${{ runner.temp }}/release-platform-manifests
- - name: Download mac intel release bundle
- if: ${{ inputs.enable_mac_intel }}
+ - name: Download mac intel publish manifest
+ if: ${{ inputs.enable_mac_intel && needs.build_mac_intel.result == 'success' }}
uses: actions/download-artifact@v8
with:
- name: open-design-beta-mac-intel-release-assets
- path: ${{ runner.temp }}/release-assets/mac-intel
+ name: open-design-beta-mac-intel-publish-manifest
+ path: ${{ runner.temp }}/release-platform-manifests
- - name: Download windows release bundle
- if: ${{ inputs.enable_win }}
+ - name: Download windows publish manifest
+ if: ${{ inputs.enable_win && needs.build_win.result == 'success' }}
uses: actions/download-artifact@v8
with:
- name: open-design-beta-win-release-assets
- path: ${{ runner.temp }}/release-assets/win
+ name: open-design-beta-win-publish-manifest
+ path: ${{ runner.temp }}/release-platform-manifests
- - name: Download linux release bundle
- if: ${{ inputs.enable_linux }}
+ - name: Download linux publish manifest
+ if: ${{ inputs.enable_linux && needs.build_linux.result == 'success' }}
uses: actions/download-artifact@v8
with:
- name: open-design-beta-linux-release-assets
- path: ${{ runner.temp }}/release-assets/linux
-
- - 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: open-design-beta-linux-publish-manifest
+ path: ${{ runner.temp }}/release-platform-manifests
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- - name: Publish beta assets and metadata to R2
+ - name: Publish beta metadata to 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:
- R2_LINUX_APPIMAGE_URL: ${{ steps.r2.outputs.linux_appimage_url }}
- R2_MAC_DMG_URL: ${{ steps.r2.outputs.mac_dmg_url }}
- 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
+ R2_METADATA_URL: ${{ steps.r2.outputs.version_metadata_url }}
+ run: node --experimental-strip-types .github/scripts/release/r2/verify-beta-metadata.ts
- name: Publish summary
env:
- R2_LINUX_APPIMAGE_URL: ${{ steps.r2.outputs.linux_appimage_url }}
- R2_MAC_DMG_URL: ${{ steps.r2.outputs.mac_dmg_url }}
- 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
+ R2_METADATA_URL: ${{ steps.r2.outputs.version_metadata_url }}
+ run: node --experimental-strip-types .github/scripts/release/r2/summary-beta.ts >> "$GITHUB_STEP_SUMMARY"
- name: Cleanup workflow artifacts
- if: ${{ success() }}
+ if: ${{ success() && steps.r2.outputs.release_state == 'complete' }}
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"
diff --git a/e2e/specs/win.spec.ts b/e2e/specs/win.spec.ts
index 19337ce38..ea3903dcb 100644
--- a/e2e/specs/win.spec.ts
+++ b/e2e/specs/win.spec.ts
@@ -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 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 verifyReinstallWhileRunning = process.env.OD_PACKAGED_E2E_WIN_VERIFY_REINSTALL !== '0';
const installIdentity = resolveInstallIdentity(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.version).toEqual(expect.any(String));
- const reinstall = await measureSmokeStep(timings, 'direct reinstall while running', async () =>
- runDirectInstaller(install.installerPath, install.installDir),
- );
- started = false;
- 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');
+ let reinstall: DirectInstallerResult | { skipped: true } = { skipped: true };
+ if (verifyReinstallWhileRunning) {
+ reinstall = await measureSmokeStep(timings, 'direct reinstall while running', async () =>
+ runDirectInstaller(install.installerPath, install.installDir),
+ );
+ started = false;
+ 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 () =>
- runToolsPackJson('start'),
- );
- started = true;
- expect(start.namespace).toBe(namespace);
- expect(start.source).toBe('installed');
- expectPathInside(start.executablePath, install.installDir);
+ start = await measureSmokeStep(timings, 'restart after direct reinstall', async () =>
+ runToolsPackJson('start'),
+ );
+ started = true;
+ expect(start.namespace).toBe(namespace);
+ expect(start.source).toBe('installed');
+ expectPathInside(start.executablePath, install.installDir);
- const postReinstallInspect = await measureSmokeStep(timings, 'wait healthy inspect after reinstall', async () =>
- waitForHealthyDesktop(),
- );
- expect(postReinstallInspect.status?.state).toBe('running');
+ const postReinstallInspect = await measureSmokeStep(timings, 'wait healthy inspect after reinstall', async () =>
+ waitForHealthyDesktop(),
+ );
+ expect(postReinstallInspect.status?.state).toBe('running');
+ }
await mkdir(dirname(screenshotPath), { recursive: true });
const screenshot = await measureSmokeStep(timings, 'inspect screenshot', async () =>
diff --git a/e2e/tests/packaged-smoke-workflow.test.ts b/e2e/tests/packaged-smoke-workflow.test.ts
index 6fa8fae03..7018949e4 100644
--- a/e2e/tests/packaged-smoke-workflow.test.ts
+++ b/e2e/tests/packaged-smoke-workflow.test.ts
@@ -24,7 +24,7 @@ describe("packaged smoke workflow", () => {
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 linuxBuildStep = workflow.match(
/- 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("Upload linux e2e spec 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");
expectReleaseLinuxSmokePreservesEvidenceBeforeApt(workflow, "Smoke beta linux AppImage runtime");
});
diff --git a/nix/README.md b/nix/README.md
index 5465af72f..7e4a30a1b 100644
--- a/nix/README.md
+++ b/nix/README.md
@@ -229,8 +229,9 @@ at the top of each file and re-run. Bump the hash whenever
## CI
-`.github/workflows/nix-check.yml` runs `nix flake check` followed by
-separate `nix build .#daemon` and `nix build .#web` steps on each push
-that touches the flake or the lockfile. Build artifacts are cached on
-the `nexu-open-design` Cachix instance — PRs from forks read from the
-cache without needing the auth token.
+`.github/workflows/nix-check.yml` runs `nix flake check` on pushes to
+`main` and can also be started manually with `workflow_dispatch`. It is
+not a default pull request gate: the flake is a community installation
+and deployment surface, while regular PR validation stays focused on the
+primary product delivery checks. The flake check already builds the
+`daemon` and `web` checks declared in `flake.nix`.