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`.