import { createHash, } from "node:crypto"; import { createReadStream, existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve, sep } from "node:path"; import { spawn, spawnSync } from "node:child_process"; import { nativeReleaseAssetsReady } from "./check-native-release-assets.mjs"; import { planNativeRelease } from "./plan-native-release.mjs"; import { assertWindowsUpdateSigningKey, signWindowsUpdateManifest, } from "./sign-windows-update-manifest.mjs"; const DEFAULT_REPOSITORY = "tianma-if/edgeever"; const VERSION_BUMPS = new Set(["patch", "minor", "major"]); const POLL_INTERVAL_MS = 10_000; const RUN_DISCOVERY_TIMEOUT_MS = 60_000; export const RELEASE_WORKFLOWS = { desktop: "desktop-build.yml", mobile: "mobile-build.yml", androidPlaySignature: "android-play-signature-audit.yml", storeDelivery: "store-delivery.yml", docker: "docker-image.yml", demo: "deploy-demo.yml", timings: "release-timings.yml", }; export const RELEASE_VALIDATIONS = [ { label: "Project regression tests", args: ["run", "test"] }, { label: "Web typecheck", args: ["run", "typecheck"] }, { label: "Mobile typecheck", args: ["run", "typecheck:mobile"] }, { label: "Web build", args: ["run", "build:web"] }, { label: "Native release planning tests", args: [ "test", "scripts/plan-native-release.test.mjs", "scripts/check-native-release-assets.test.mjs", "scripts/windows-update-metadata.test.mjs", "scripts/release.test.mjs", "scripts/validate-store-delivery.test.mjs", "scripts/store-delivery.test.mjs", "scripts/download-play-universal-apk.test.mjs", "scripts/desktop-icns.test.mjs", "scripts/verify-desktop-cross-version-startup.test.mjs", "apps/web/src/lib/version-check.test.mjs", "apps/mobile/src/lib/mobile-release.test.ts", ], }, ]; const usage = `Usage: bun run release -- \\ --bump patch \\ --issue-title "Release issue title" \\ --label bug \\ --change-en "English user-facing change" \\ --change-zh "中文用户更新说明" \\ --change-commit "abcdef1" Repeat --change-en, --change-zh, and --change-commit for multiple paired release bullets. Use comma-separated SHAs when one bullet covers multiple commits. Every other commit requires --ignore-commit "abcdef1:reason". Options: --bump Required version bump: patch, minor, or major --repository GitHub repository (default: ${DEFAULT_REPOSITORY}) --issue-title Required umbrella Issue title --label <label> Required Issue label; may be repeated --change-en <text> Required English release bullet; may be repeated --change-zh <text> Required Chinese release bullet; may be repeated --change-locale <tag:text> Optional localized bullet; repeat once per change and locale --change-commit <sha,...> Commits covered by the corresponding bilingual bullet --ignore-commit <sha:why> Explicitly exclude a non-user-facing commit; may be repeated --install-desktop Install and launch the final DMG after publication --dry-run Print the plan and generated notes without mutations --help Show this help `; export const parseReleaseArgs = (argv) => { const options = { repository: DEFAULT_REPOSITORY, bump: "", issueTitle: "", labels: [], changesEn: [], changesZh: [], localizedChanges: [], changeCommits: [], ignoredCommits: [], installDesktop: false, dryRun: false, help: false, }; const valueOptions = new Map([ ["--repository", "repository"], ["--bump", "bump"], ["--issue-title", "issueTitle"], ["--label", "labels"], ["--change-en", "changesEn"], ["--change-zh", "changesZh"], ["--change-locale", "localizedChanges"], ["--change-commit", "changeCommits"], ["--ignore-commit", "ignoredCommits"], ]); for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === "--install-desktop") { options.installDesktop = true; continue; } if (argument === "--dry-run") { options.dryRun = true; continue; } if (argument === "--help") { options.help = true; continue; } const key = valueOptions.get(argument); if (!key) { throw new Error(`Unknown option: ${argument}`); } const value = argv[index + 1]?.trim(); if (!value || value.startsWith("--")) { throw new Error(`${argument} requires a value.`); } index += 1; if (Array.isArray(options[key])) { options[key].push(value); } else { options[key] = value; } } if (options.help) { return options; } if (!/^[^/\s]+\/[^/\s]+$/.test(options.repository)) { throw new Error("--repository must use owner/name format."); } if (!VERSION_BUMPS.has(options.bump)) { throw new Error("--bump must be patch, minor, or major."); } if (!options.issueTitle) { throw new Error("--issue-title is required."); } if (options.labels.length === 0) { throw new Error("At least one --label is required."); } if (options.changesEn.length === 0 || options.changesZh.length === 0) { throw new Error("At least one --change-en and --change-zh are required."); } if (options.changesEn.length !== options.changesZh.length) { throw new Error("--change-en and --change-zh must have the same count."); } if (options.changesEn.length !== options.changeCommits.length) { throw new Error("Each bilingual change requires one corresponding --change-commit value."); } const localizedChanges = {}; for (const value of options.localizedChanges) { const separator = value.indexOf(":"); const locale = separator === -1 ? "" : value.slice(0, separator).trim(); const change = separator === -1 ? "" : value.slice(separator + 1).trim(); if (!/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(locale) || !change) { throw new Error('--change-locale must use "<locale>:<user-facing change>".'); } if (["en-us", "zh-cn"].includes(locale.toLowerCase())) { throw new Error("Use --change-en and --change-zh for en-US and zh-CN release changes."); } (localizedChanges[locale] ??= []).push(change); } for (const [locale, changes] of Object.entries(localizedChanges)) { if (changes.length !== options.changesEn.length) { throw new Error(`--change-locale ${locale} must provide one translation for every release change.`); } } options.localizedChanges = localizedChanges; return options; }; const RELEASE_COMMIT_PATTERN = /^chore: release v\d+\.\d+\.\d+ \[skip ci\]$/; const COMMIT_REF_PATTERN = /^[0-9a-f]{7,40}$/i; const parseCommitRefs = (value, option) => { const refs = value.split(",").map((item) => item.trim()).filter(Boolean); if (refs.length === 0 || refs.some((ref) => !COMMIT_REF_PATTERN.test(ref))) { throw new Error(`${option} must contain comma-separated commit SHAs with 7 to 40 hexadecimal characters.`); } return refs; }; const resolveCommitRef = (ref, commits, option) => { const matches = commits.filter((commit) => commit.sha.toLowerCase().startsWith(ref.toLowerCase())); if (matches.length === 0) { throw new Error(`${option} references ${ref}, which is not in the release commit range.`); } if (matches.length > 1) { throw new Error(`${option} commit ${ref} is ambiguous; use a longer SHA.`); } return matches[0]; }; export const auditReleaseCommitCoverage = ({ commits, changeCommits, ignoredCommits }) => { const mappings = changeCommits.map((value, index) => ({ changeIndex: index, commits: parseCommitRefs(value, "--change-commit").map((ref) => resolveCommitRef(ref, commits, "--change-commit") ), })); const coveredShas = new Set(mappings.flatMap((mapping) => mapping.commits.map((commit) => commit.sha) )); const ignored = ignoredCommits.map((value) => { const separator = value.indexOf(":"); const ref = separator === -1 ? "" : value.slice(0, separator).trim(); const reason = separator === -1 ? "" : value.slice(separator + 1).trim(); if (!COMMIT_REF_PATTERN.test(ref) || !reason) { throw new Error('--ignore-commit must use "<commit-sha>:<reason>".'); } const commit = resolveCommitRef(ref, commits, "--ignore-commit"); if (coveredShas.has(commit.sha)) { throw new Error(`Commit ${ref} cannot be both covered and ignored.`); } return { commit, reason }; }); const ignoredShas = new Set(ignored.map(({ commit }) => commit.sha)); if (ignoredShas.size !== ignored.length) { throw new Error("A commit may only be ignored once."); } const automatic = commits .filter((commit) => RELEASE_COMMIT_PATTERN.test(commit.subject)) .map((commit) => ({ commit, reason: "release automation commit" })); const automaticShas = new Set(automatic.map(({ commit }) => commit.sha)); const uncovered = commits.filter((commit) => !coveredShas.has(commit.sha) && !ignoredShas.has(commit.sha) && !automaticShas.has(commit.sha) ); if (uncovered.length > 0) { throw new Error([ "Release notes do not account for every commit since the previous Release:", ...uncovered.map((commit) => `- ${commit.sha.slice(0, 8)} ${commit.subject}`), 'Cover each commit with --change-commit, or use --ignore-commit "<sha>:<reason>".', ].join("\n")); } return { mappings, ignored: [...ignored, ...automatic] }; }; export const nextVersion = (version, bump) => { const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version); if (!match) { throw new Error(`Expected a stable X.Y.Z version, received: ${version}`); } if (!VERSION_BUMPS.has(bump)) { throw new Error(`Expected patch, minor, or major bump, received: ${bump}`); } const major = Number(match[1]); const minor = Number(match[2]); const patch = Number(match[3]); if (bump === "major") return `${major + 1}.0.0`; if (bump === "minor") return `${major}.${minor + 1}.0`; return `${major}.${minor}.${patch + 1}`; }; export const resolveReleaseVersion = ({ previousVersion, packageVersion, bump, headSha, draftCandidate = null, draftTargetIsAncestor = false, }) => { const expectedNextVersion = nextVersion(previousVersion, bump); if (packageVersion === previousVersion) { return { releaseVersion: expectedNextVersion, releaseBaseTag: `v${previousVersion}`, resumedDraft: null, withdrawnDraft: null, }; } if ( !draftCandidate || draftCandidate.tagName !== `v${packageVersion}` || !draftCandidate.isDraft || draftCandidate.isPrerelease ) { throw new Error( `package.json version ${packageVersion} must match ${previousVersion}, or a compatible stable Draft.`, ); } if (draftCandidate.targetCommitish === headSha) { if (packageVersion !== expectedNextVersion) { throw new Error( `${draftCandidate.tagName} cannot resume because --bump ${bump} expects v${expectedNextVersion}.`, ); } return { releaseVersion: packageVersion, releaseBaseTag: `v${previousVersion}`, resumedDraft: draftCandidate, withdrawnDraft: null, }; } if (!draftTargetIsAncestor) { throw new Error( `${draftCandidate.tagName} is not compatible with the current HEAD or its history.`, ); } return { releaseVersion: nextVersion(packageVersion, bump), // A withdrawn release does not become the audit baseline. Keep the latest // published release as the source for commit coverage, changed-file plans, // and reusable native assets so the replacement release stays cumulative. releaseBaseTag: `v${previousVersion}`, resumedDraft: null, withdrawnDraft: draftCandidate, }; }; export const buildReleaseTitle = (tag) => { if (!/^v\d+\.\d+\.\d+$/.test(tag)) { throw new Error(`Expected a stable vX.Y.Z tag, received: ${tag}`); } return tag; }; export const buildIssueBody = ({ changesEn, changesZh, commitCoverageAudit }) => [ "## Summary", "", ...changesEn.map((change) => `- ${change}`), "", "## 中文说明", "", ...changesZh.map((change) => `- ${change}`), "", ...(commitCoverageAudit ? [ "## Commit coverage audit", "", ...commitCoverageAudit.mappings.map((mapping) => `- Change ${mapping.changeIndex + 1}: ${mapping.commits.map((commit) => `\`${commit.sha.slice(0, 8)}\``).join(", ")}` ), ...commitCoverageAudit.ignored.map(({ commit, reason }) => `- Excluded \`${commit.sha.slice(0, 8)}\`: ${reason}` ), "", ] : []), "## Acceptance criteria", "", "- Required type checks, Web build, and native release planning tests pass.", "- The Draft Release contains audited macOS arm64/x64 DMGs, an unsigned Windows x64 Preview with an independently signed update manifest, a Linux x64 AppImage Preview with verified updater metadata, checksum, and cross-version installation, and a Play-signed Android arm64 APK.", "- Post-publication native asset audits pass.", ].join("\n"); export const buildReleaseNotes = ({ changesEn, changesZh, issueNumber, }) => [ "## 🇨🇳 中文说明 / Chinese Changelog", "", "## 主要更新", "", ...changesZh.map((change) => `- ${change}`), "", `关联 Issue:#${issueNumber}`, "", "## Key Changes", "", ...changesEn.map((change) => `- ${change}`), "", `Related Issue: #${issueNumber}`, "", ].join("\n"); export const buildReleaseSummary = ({ version, changesEn, changesZh, localizedChanges = {} }) => ({ version, changes: { "en-US": [...changesEn], "zh-CN": [...changesZh], ...Object.fromEntries(Object.entries(localizedChanges).map(([locale, changes]) => [locale, [...changes]])), }, }); export const reusedAssetMatches = (previousAssets, currentAssets, name) => { const previous = previousAssets.find((asset) => asset.name === name); const current = currentAssets.find((asset) => asset.name === name); return Boolean( previous && current && previous.digest && previous.digest === current.digest && previous.size === current.size, ); }; export const selectPublishedDmg = (assets, arch = process.arch) => { if (!["arm64", "x64"].includes(arch)) { throw new Error(`Unsupported macOS architecture for installation: ${arch}.`); } const matches = assets.filter((asset) => new RegExp(`^EdgeEver-(.+)-mac-${arch}\\.dmg$`).test(asset.name) ); if (matches.length !== 1) { throw new Error(`Expected exactly one macOS ${arch} DMG, found ${matches.length}.`); } const version = new RegExp(`^EdgeEver-(.+)-mac-${arch}\\.dmg$`).exec(matches[0].name)?.[1]; if (!version || !matches[0].digest?.startsWith("sha256:")) { throw new Error("Published DMG is missing its version or SHA-256 digest."); } return { asset: matches[0], version }; }; const run = (executable, args, { capture = false, allowFailure = false } = {}) => { const result = spawnSync(executable, args, { cwd: resolve("."), encoding: "utf8", env: process.env, stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", }); if (result.status !== 0 && !allowFailure) { const detail = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); throw new Error( `${executable} ${args.join(" ")} exited with status ${result.status ?? 1}${detail ? `:\n${detail}` : ""}`, ); } return capture ? String(result.stdout ?? "").trim() : result; }; const ghJson = (args) => JSON.parse(run("gh", args, { capture: true })); const wait = (milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds)); export const runParallelValidations = async ({ executable = process.execPath, validations = RELEASE_VALIDATIONS, } = {}) => { console.log(`[release] running ${validations.length} validations in parallel`); const results = await Promise.all( validations.map(({ label, args }) => new Promise((resolveValidation) => { console.log(`[release] start: ${label}`); const child = spawn(executable, args, { cwd: resolve("."), env: process.env, stdio: "inherit", }); child.on("error", (error) => resolveValidation({ label, error })); child.on("exit", (code, signal) => resolveValidation({ label, code, signal })); })), ); const failures = results.filter((result) => result.error || result.code !== 0); if (failures.length > 0) { throw new Error( `Release validation failed: ${failures.map(({ label }) => label).join(", ")}`, ); } console.log("[release] all parallel validations passed"); }; const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); const writeJson = (path, value) => writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); const changedFilesBetween = (baseRef, headRef) => run( "git", ["diff", "--name-only", `${baseRef}...${headRef}`], { capture: true }, ).split("\n").filter(Boolean); const releaseCommitsBetween = (baseRef, headRef) => run( "git", ["log", "--reverse", "--format=%H%x09%s", `${baseRef}..${headRef}`], { capture: true }, ).split("\n").filter(Boolean).map((line) => { const separator = line.indexOf("\t"); return { sha: line.slice(0, separator), subject: line.slice(separator + 1), }; }); const printReleaseCoverageAudit = ({ audit, changesEn }) => { console.log("[release] commit coverage audit:"); for (const mapping of audit.mappings) { const commits = mapping.commits.map((commit) => commit.sha.slice(0, 8)).join(", "); console.log(`[release] change ${mapping.changeIndex + 1} (${commits}): ${changesEn[mapping.changeIndex]}`); } for (const { commit, reason } of audit.ignored) { console.log(`[release] ignored ${commit.sha.slice(0, 8)}: ${reason}`); } }; const assertReleasePreconditions = ({ repository, previousTag }) => { if (run("git", ["branch", "--show-current"], { capture: true }) !== "main") { throw new Error("Releases must run directly from the main branch."); } if (run("git", ["status", "--porcelain"], { capture: true })) { throw new Error("The working tree must be clean before starting a release."); } run("git", ["fetch", "origin", "main", "--tags"]); const [behind, ahead] = run( "git", ["rev-list", "--left-right", "--count", "origin/main...main"], { capture: true }, ).split(/\s+/).map(Number); if (behind !== 0 || ahead !== 0) { throw new Error("main must exactly match origin/main before starting a release."); } if (run("gh", ["auth", "status"], { allowFailure: true }).status !== 0) { throw new Error("GitHub CLI authentication is required."); } run("git", ["rev-parse", "--verify", `${previousTag}^{commit}`], { capture: true }); const remote = run("gh", ["repo", "view", repository, "--json", "nameWithOwner", "--jq", ".nameWithOwner"], { capture: true }); if (remote !== repository) { throw new Error(`Unable to resolve repository ${repository}.`); } }; const updateReleaseVersions = ({ nextVersion, desktopRebuild, mobileRebuild, changesEn, changesZh, localizedChanges }) => { const changedPaths = ["package.json", "release-summary.json"]; const rootPackage = readJson("package.json"); rootPackage.version = nextVersion; writeJson("package.json", rootPackage); writeJson("release-summary.json", buildReleaseSummary({ version: nextVersion, changesEn, changesZh, localizedChanges, })); if (desktopRebuild) { const desktopPackage = readJson("apps/desktop/package.json"); desktopPackage.version = nextVersion; writeJson("apps/desktop/package.json", desktopPackage); changedPaths.push("apps/desktop/package.json"); } if (mobileRebuild) { const mobileConfig = readJson("apps/mobile/app.json"); mobileConfig.expo.version = nextVersion; mobileConfig.expo.android.versionCode += 1; writeJson("apps/mobile/app.json", mobileConfig); changedPaths.push("apps/mobile/app.json"); } return changedPaths; }; const parseRunId = (output) => { const match = output.match(/\/actions\/runs\/(\d+)/); return match ? Number(match[1]) : null; }; export const waitForRun = async ({ repository, runId, label, viewRun = () => ghJson([ "run", "view", String(runId), "--repo", repository, "--json", "status,conclusion,url,headSha", ]), waitForNextPoll = () => wait(POLL_INTERVAL_MS), maxConsecutiveFailures = 5, }) => { let lastStatus = ""; let consecutiveFailures = 0; while (true) { let runView; try { runView = viewRun(); consecutiveFailures = 0; } catch (error) { consecutiveFailures += 1; if (consecutiveFailures >= maxConsecutiveFailures) { throw error; } console.warn( `[release] ${label}: GitHub status check failed; retrying (${consecutiveFailures}/${maxConsecutiveFailures - 1})`, ); await waitForNextPoll(); continue; } const statusLabel = `${runView.status}${runView.conclusion ? `/${runView.conclusion}` : ""}`; if (statusLabel !== lastStatus) { console.log(`[release] ${label}: ${statusLabel} (${runView.url})`); lastStatus = statusLabel; } if (runView.status === "completed") { if (runView.conclusion !== "success") { throw new Error(`${label} failed: ${runView.url}`); } return runView; } await waitForNextPoll(); } }; const listWorkflowRuns = ({ repository, workflow, event }) => ghJson([ "run", "list", "--repo", repository, "--workflow", workflow, "--event", event, "--limit", "20", "--json", "databaseId,displayTitle,headSha,createdAt,url,status,conclusion", ]); const releaseCheckpointMarker = (tag) => `<!-- edgeever-release-checkpoint:${tag}\n`; const CHECKPOINT_RUN_FIELDS = [ "desktopRunId", "mobileRunId", "dockerRunId", "storeRunId", "storeRecoveryRunId", "androidPlaySignatureRunId", "windowsUpdateAuditRunId", ]; const CANCELLABLE_CHECKPOINT_RUN_FIELDS = new Set([ "desktopRunId", "mobileRunId", "dockerRunId", "androidPlaySignatureRunId", "windowsUpdateAuditRunId", ]); export const recordCheckpointRun = (checkpoint, field, runId) => { if (!CHECKPOINT_RUN_FIELDS.includes(field)) { throw new Error(`Unsupported checkpoint Run field: ${field}`); } checkpoint[field] = Number(runId); checkpoint.runHistory ??= []; if (!checkpoint.runHistory.some((entry) => Number(entry.runId) === Number(runId))) { checkpoint.runHistory.push({ field, runId: Number(runId), releaseSha: checkpoint.releaseSha, }); } return Number(runId); }; export const checkpointRunIds = (checkpoint) => [...new Set([ ...(checkpoint.runHistory ?? []).map((entry) => Number(entry.runId)), ...CHECKPOINT_RUN_FIELDS.map((field) => Number(checkpoint[field])), ].filter((runId) => Number.isInteger(runId) && runId > 0))]; export const prepareReleaseCheckpoint = ({ storedState, releaseSha }) => { if (storedState.releaseSha === releaseSha) return storedState; const legacyHistory = CHECKPOINT_RUN_FIELDS.flatMap((field) => { const runId = Number(storedState[field]); return Number.isInteger(runId) && runId > 0 ? [{ field, runId, releaseSha: storedState.releaseSha ?? null }] : []; }); const runHistory = [...(storedState.runHistory ?? []), ...legacyHistory] .filter((entry, index, entries) => entries.findIndex((candidate) => Number(candidate.runId) === Number(entry.runId)) === index ); return { releaseSha, ...(runHistory.length > 0 ? { runHistory } : {}), ...(storedState.playDelivery ? { playDelivery: storedState.playDelivery } : {}), }; }; export const playDeliveryResumeAction = ({ playDelivery, tag, headSha, mobileInputsChanged = false, }) => { if (!playDelivery || playDelivery.tag !== tag) return "upload"; if (playDelivery.releaseSha === headSha) return "verify"; return mobileInputsChanged ? "block" : "verify"; }; export const shouldCancelSupersededRun = ({ runView, headSha }) => Boolean( runView && runView.headSha !== headSha && runView.status !== "completed", ); export const parseReleaseCheckpoint = (body, tag) => { const marker = releaseCheckpointMarker(tag); if (!body?.startsWith(marker)) return null; const jsonEnd = body.lastIndexOf("\n-->"); if (jsonEnd <= marker.length) return null; try { return JSON.parse(body.slice(marker.length, jsonEnd)); } catch { return null; } }; const loadReleaseCheckpoint = ({ repository, issueNumber, tag }) => { const comments = ghJson([ "api", `repos/${repository}/issues/${issueNumber}/comments?per_page=100`, ]); const comment = comments.find(({ body }) => body?.startsWith(releaseCheckpointMarker(tag)), ); return { commentId: comment?.id ?? null, state: parseReleaseCheckpoint(comment?.body, tag) ?? {}, }; }; const saveReleaseCheckpoint = ({ repository, issueNumber, tag, commentId, state, }) => { const body = `${releaseCheckpointMarker(tag)}${JSON.stringify(state)}\n-->`; if (commentId) { run("gh", [ "api", "--method", "PATCH", `repos/${repository}/issues/comments/${commentId}`, "--raw-field", `body=${body}`, ], { capture: true }); return commentId; } const comment = ghJson([ "api", "--method", "POST", `repos/${repository}/issues/${issueNumber}/comments`, "--raw-field", `body=${body}`, ]); return comment.id; }; const viewWorkflowRun = ({ repository, runId }) => ghJson([ "run", "view", String(runId), "--repo", repository, "--json", "status,conclusion,url,headSha,jobs", ]); const cancelSupersededCheckpointRuns = ({ repository, checkpoint, headSha }) => { const candidates = CHECKPOINT_RUN_FIELDS.flatMap((field) => checkpoint[field] ? [{ field, runId: checkpoint[field] }] : []).filter(({ field }, index, entries) => CANCELLABLE_CHECKPOINT_RUN_FIELDS.has(field) && entries.findIndex((candidate) => Number(candidate.runId) === Number(entries[index].runId)) === index ); for (const { field, runId } of candidates) { let runView; try { runView = viewWorkflowRun({ repository, runId }); } catch { console.warn(`[release] could not inspect superseded ${field} Run ${runId}; continuing`); continue; } if (!shouldCancelSupersededRun({ runView, headSha })) continue; const cancellation = run( "gh", ["run", "cancel", String(runId), "--repo", repository], { allowFailure: true }, ); if (cancellation.status === 0) { console.log(`[release] cancelled superseded ${field}: ${runView.url}`); } else { console.warn(`[release] superseded ${field} was no longer cancellable: ${runView.url}`); } } }; export const signedWindowsUpdateAuditPassed = (runView) => runView.jobs?.some( (job) => job.name === "Audit signed Windows update" && job.conclusion === "success", ) ?? false; const waitForRerunStart = async ({ repository, runId }) => { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { const current = viewWorkflowRun({ repository, runId }); if (current.status !== "completed" || current.conclusion === "success") return; await wait(2_000); } throw new Error(`Timed out waiting for rerun ${runId} to start.`); }; export const draftRunResumeAction = ({ runId, runView, headSha }) => { if (!runId || !runView || runView.headSha !== headSha) return "dispatch"; if (runView.status === "completed" && runView.conclusion !== "success") { return "rerun"; } return "reuse"; }; const resumeDraftWorkflowRun = async ({ repository, runId, headSha, label }) => { if (!runId) return null; const existing = viewWorkflowRun({ repository, runId }); const action = draftRunResumeAction({ runId, runView: existing, headSha }); if (action === "dispatch") return null; if (action === "rerun") { console.log(`[release] rerunning failed ${label}: ${existing.url}`); run("gh", [ "run", "rerun", String(runId), "--repo", repository, "--failed", ]); await waitForRerunStart({ repository, runId }); } else { console.log(`[release] reusing ${label}: ${existing.url}`); } return Number(runId); }; export const playDeliveryFailureStrategy = (runView) => { const androidJob = runView.jobs?.find( (job) => job.name === "Deliver Google Play", ); const uploadStep = androidJob?.steps?.find( (step) => step.name === "Upload bundle to Google Play", ); if (!uploadStep || ["skipped", null, undefined].includes(uploadStep.conclusion)) { return "rerun"; } return "recover"; }; const dispatchReleaseWorkflow = async ({ repository, workflow, tag, headSha, inputs = { release_tag: tag }, }) => { const existingRunIds = new Set( listWorkflowRuns({ repository, workflow, event: "workflow_dispatch", }).map((candidate) => candidate.databaseId), ); const dispatchedAt = Date.now(); const output = run("gh", [ "workflow", "run", workflow, "--repo", repository, "--ref", "main", ...Object.entries(inputs).flatMap(([key, value]) => [ "-f", `${key}=${value}`, ]), ], { capture: true }); const returnedRunId = parseRunId(output); if (returnedRunId) { return returnedRunId; } const deadline = Date.now() + RUN_DISCOVERY_TIMEOUT_MS; while (Date.now() < deadline) { const match = listWorkflowRuns({ repository, workflow, event: "workflow_dispatch", }).find((candidate) => !existingRunIds.has(candidate.databaseId) && candidate.headSha === headSha && Date.parse(candidate.createdAt) >= dispatchedAt - 5_000 ); if (match) { return match.databaseId; } await wait(2_000); } throw new Error(`Timed out discovering dispatched ${workflow} run for ${tag}.`); }; const dispatchStoreDeliveryWorkflow = ({ repository, tag, headSha, recoverPlayApk, }) => dispatchReleaseWorkflow({ repository, workflow: RELEASE_WORKFLOWS.storeDelivery, tag, headSha, inputs: { release_tag: tag, platform: "android", android_track: "production", recover_play_apk: recoverPlayApk, }, }); const dispatchAndWaitForPlayRecovery = async ({ repository, tag, headSha, checkpoint, persistCheckpoint, }) => { const recoveryRunId = await dispatchStoreDeliveryWorkflow({ repository, tag, headSha, recoverPlayApk: true, }); recordCheckpointRun(checkpoint, "storeRecoveryRunId", recoveryRunId); persistCheckpoint(); await waitForRun({ repository, runId: recoveryRunId, label: "Recover Play-signed Draft APK", }); return recoveryRunId; }; const ensurePlayDelivery = async ({ repository, tag, headSha, checkpoint, persistCheckpoint, resumeAction = "upload", }) => { const recordCompletedDelivery = ({ storeRunId = null, recoveryRunId = null }) => { checkpoint.playDelivery = { tag, releaseSha: headSha, ...(storeRunId ? { storeRunId: Number(storeRunId) } : {}), ...(recoveryRunId ? { recoveryRunId: Number(recoveryRunId) } : {}), }; persistCheckpoint(); }; if (resumeAction === "verify") { console.log( "[release] mobile inputs are unchanged since Play delivery; skipping upload and verifying the existing Play-signed APK", ); return checkpoint.playDelivery?.recoveryRunId ?? checkpoint.playDelivery?.storeRunId ?? null; } let storeRunId = checkpoint.storeRunId; if (!storeRunId) { storeRunId = await dispatchStoreDeliveryWorkflow({ repository, tag, headSha, recoverPlayApk: false, }); recordCheckpointRun(checkpoint, "storeRunId", storeRunId); persistCheckpoint(); } else { const existing = viewWorkflowRun({ repository, runId: storeRunId }); if (existing.headSha !== headSha) { throw new Error("Stored Play delivery Run targets a different commit."); } console.log(`[release] reusing Google Play delivery: ${existing.url}`); } try { await waitForRun({ repository, runId: storeRunId, label: "Google Play delivery", }); recordCompletedDelivery({ storeRunId }); return storeRunId; } catch (error) { const failedRun = viewWorkflowRun({ repository, runId: storeRunId }); if (playDeliveryFailureStrategy(failedRun) === "recover") { console.log( "[release] Play upload may have completed; recovering its signed APK without re-uploading", ); const recoveryRunId = await dispatchAndWaitForPlayRecovery({ repository, tag, headSha, checkpoint, persistCheckpoint, }); recordCompletedDelivery({ storeRunId, recoveryRunId }); return storeRunId; } console.log("[release] Play upload did not start; dispatching a clean delivery retry"); storeRunId = await dispatchStoreDeliveryWorkflow({ repository, tag, headSha, recoverPlayApk: false, }); recordCheckpointRun(checkpoint, "storeRunId", storeRunId); persistCheckpoint(); await waitForRun({ repository, runId: storeRunId, label: "Google Play delivery retry", }); recordCompletedDelivery({ storeRunId }); return storeRunId; } }; const requirePlaySignedDraftApk = async ({ repository, tag, headSha, checkpoint, persistCheckpoint, allowRecovery, }) => { const dispatchGate = async () => { const runId = await dispatchReleaseWorkflow({ repository, workflow: RELEASE_WORKFLOWS.androidPlaySignature, tag, headSha, }); recordCheckpointRun(checkpoint, "androidPlaySignatureRunId", runId); persistCheckpoint(); await waitForRun({ repository, runId, label: "Draft Android Play signature gate", }); }; try { await dispatchGate(); } catch (error) { if (!allowRecovery) throw error; console.log( "[release] restoring the already delivered Play-signed APK before retrying its signature gate", ); await dispatchAndWaitForPlayRecovery({ repository, tag, headSha, checkpoint, persistCheckpoint, }); await dispatchGate(); } }; const findReleaseRun = async ({ repository, workflow, tag, headSha, publishedAfter, }) => { const deadline = Date.now() + RUN_DISCOVERY_TIMEOUT_MS; while (Date.now() < deadline) { const runs = listWorkflowRuns({ repository, workflow, event: "release" }); const match = runs.find((candidate) => candidate.displayTitle === tag && candidate.headSha === headSha && Date.parse(candidate.createdAt) >= publishedAfter - 5_000 ); if (match) { return match; } await wait(2_000); } throw new Error(`Timed out discovering ${workflow} for ${tag}.`); }; const assertDraftAssets = ({ assets, previousAssets, tag, version, desktopRebuild, mobileRebuild, }) => { const assetNames = assets.map((asset) => asset.name); if (!nativeReleaseAssetsReady({ platform: "desktop", rebuild: desktopRebuild, currentTag: tag, desktopVersion: version, assetNames, })) { throw new Error("Draft Release desktop assets are incomplete or incompatible."); } if (!nativeReleaseAssetsReady({ platform: "mobile", rebuild: mobileRebuild, currentTag: tag, assetNames, })) { throw new Error("Draft Release Android assets are incomplete or incompatible."); } if (!mobileRebuild) { const previousApk = previousAssets.find((asset) => /^edgeever-android-v.*-arm64-v8a\.apk$/.test(asset.name) ); if (!previousApk || !reusedAssetMatches(previousAssets, assets, previousApk.name)) { throw new Error("Reused Android APK filename, size, or checksum changed."); } } if (!desktopRebuild) { const previousDesktopNames = previousAssets .map((asset) => asset.name) .filter((name) => /^EdgeEver-.*-mac-(?:arm64|x64)\.(?:dmg|zip)(?:\.blockmap)?$/.test(name) || /^EdgeEver-.*-windows-x64\.exe$/.test(name) || /^EdgeEver-.*-linux-x64\.AppImage$/.test(name) || [ "latest-mac.yml", "latest.yml", "latest-windows.json", "latest-windows.json.sig", "SHA256SUMS-windows.txt", "latest-linux.yml", "SHA256SUMS-linux.txt", ].includes(name) ); if ( previousDesktopNames.length !== 17 || !previousDesktopNames.every((name) => reusedAssetMatches(previousAssets, assets, name) ) ) { throw new Error("Reused desktop asset filename, size, or checksum changed."); } } }; const signDraftWindowsUpdate = ({ repository, tag }) => { const temporaryDirectory = mkdtempSync(join(tmpdir(), "edgeever-windows-update-signing-")); const manifestPath = join(temporaryDirectory, "latest-windows.json"); const signaturePath = `${manifestPath}.sig`; try { run("gh", [ "release", "download", tag, "--repo", repository, "--pattern", "latest-windows.json", "--dir", temporaryDirectory, ]); signWindowsUpdateManifest({ manifestPath, signaturePath, privateKeyPath: process.env.EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY, }); run("gh", [ "release", "upload", tag, signaturePath, "--repo", repository, "--clobber", ]); console.log(`[release] signed Windows update manifest for ${tag}`); } finally { if (temporaryDirectory.startsWith(`${tmpdir()}${sep}edgeever-windows-update-signing-`)) { rmSync(temporaryDirectory, { recursive: true, force: true }); } } }; const sha256File = (path) => new Promise((resolveHash, rejectHash) => { const hash = createHash("sha256"); const stream = createReadStream(path); stream.on("error", rejectHash); stream.on("data", (chunk) => hash.update(chunk)); stream.on("end", () => resolveHash(hash.digest("hex"))); }); export const installPublishedDmg = async ({ repository, tag, assets }) => { if (process.platform !== "darwin") { throw new Error("Final DMG installation requires macOS."); } const { asset: dmg, version: nativeVersion } = selectPublishedDmg(assets); const temporaryDirectory = mkdtempSync(join(tmpdir(), "edgeever-release-")); const mountDirectory = join(temporaryDirectory, "mount"); const dmgPath = join(temporaryDirectory, dmg.name); mkdirSync(mountDirectory); let mounted = false; let backupPath = ""; try { run("gh", [ "release", "download", tag, "--repo", repository, "--pattern", dmg.name, "--dir", temporaryDirectory, ]); const digest = await sha256File(dmgPath); if (`sha256:${digest}` !== dmg.digest) { throw new Error(`Downloaded DMG checksum mismatch: ${digest}`); } run("hdiutil", [ "attach", dmgPath, "-nobrowse", "-readonly", "-mountpoint", mountDirectory, ]); mounted = true; const sourceApp = join(mountDirectory, "EdgeEver.app"); if (!existsSync(sourceApp)) { throw new Error("Mounted DMG does not contain EdgeEver.app."); } run("osascript", ["-e", 'tell application "EdgeEver" to quit'], { allowFailure: true }); await wait(2_000); const installedApp = "/Applications/EdgeEver.app"; if (existsSync(installedApp)) { const trashDirectory = join(process.env.HOME || "", ".Trash"); if (!trashDirectory.startsWith(`${sep}Users${sep}`)) { throw new Error("Could not resolve a safe Trash directory for the previous app."); } backupPath = join(trashDirectory, `EdgeEver.app.pre-${tag}`); if (existsSync(backupPath)) { backupPath = `${backupPath}.${Date.now()}`; } renameSync(installedApp, backupPath); } try { run("ditto", [sourceApp, installedApp]); const installedVersion = run( "defaults", ["read", join(installedApp, "Contents/Info.plist"), "CFBundleShortVersionString"], { capture: true }, ); if (installedVersion !== nativeVersion) { throw new Error( `Installed app version is ${installedVersion}, expected reused native version ${nativeVersion}.`, ); } run("codesign", ["--verify", "--deep", "--strict", installedApp]); } catch (error) { if (existsSync(installedApp) && backupPath) { renameSync(installedApp, `${backupPath}.failed-${Date.now()}`); } if (backupPath && existsSync(backupPath)) { renameSync(backupPath, installedApp); } throw error; } run("open", ["-a", installedApp]); console.log( `[release] installed and launched EdgeEver ${nativeVersion} from ${tag}`, ); if (backupPath) { console.log(`[release] previous app backup: ${backupPath}`); } } finally { if (mounted) { run("hdiutil", ["detach", mountDirectory], { allowFailure: true }); } if (temporaryDirectory.startsWith(`${tmpdir()}${sep}edgeever-release-`)) { rmSync(temporaryDirectory, { recursive: true, force: true }); } } }; export const installReleaseDmg = async ({ repository, tag }) => { const published = ghJson([ "release", "view", tag, "--repo", repository, "--json", "assets", ]); await installPublishedDmg({ repository, tag, assets: published.assets }); }; const releaseMain = async (options) => { const latestRelease = ghJson([ "api", `repos/${options.repository}/releases/latest`, "--jq", "{tagName:.tag_name,assets:[.assets[]|{name,size,digest}]}", ]); const previousTag = latestRelease.tagName; assertReleasePreconditions({ repository: options.repository, previousTag }); const rootPackage = readJson("package.json"); const previousVersion = previousTag.replace(/^v/, ""); const headShaBeforeRelease = run("git", ["rev-parse", "HEAD"], { capture: true }); let draftCandidate = null; let draftTargetIsAncestor = false; if (rootPackage.version !== previousVersion) { draftCandidate = ghJson([ "release", "view", `v${rootPackage.version}`, "--repo", options.repository, "--json", "tagName,isDraft,isPrerelease,targetCommitish,body,assets,url", ]); if (draftCandidate.targetCommitish !== headShaBeforeRelease) { draftTargetIsAncestor = run( "git", ["merge-base", "--is-ancestor", draftCandidate.targetCommitish, headShaBeforeRelease], { allowFailure: true }, ).status === 0; } } const { releaseVersion, releaseBaseTag, resumedDraft, withdrawnDraft, } = resolveReleaseVersion({ previousVersion, packageVersion: rootPackage.version, bump: options.bump, headSha: headShaBeforeRelease, draftCandidate, draftTargetIsAncestor, }); const tag = `v${releaseVersion}`; if (withdrawnDraft) { console.log( `[release] ${withdrawnDraft.tagName} is a withdrawn Draft; reserving that version and continuing with ${tag}.`, ); } const changedFiles = changedFilesBetween(releaseBaseTag, headShaBeforeRelease); if (changedFiles.length === 0) { throw new Error(`There are no committed changes after ${releaseBaseTag}.`); } const releaseCommits = releaseCommitsBetween(releaseBaseTag, headShaBeforeRelease); const commitCoverageAudit = auditReleaseCommitCoverage({ commits: releaseCommits, changeCommits: options.changeCommits, ignoredCommits: options.ignoredCommits, }); printReleaseCoverageAudit({ audit: commitCoverageAudit, changesEn: options.changesEn }); const desktopPlan = planNativeRelease("desktop", changedFiles); const mobilePlan = planNativeRelease("mobile", changedFiles); console.log(`[release] ${releaseBaseTag} -> ${tag}`); console.log(`[release] desktop: ${desktopPlan.rebuild ? "rebuild" : "reuse"}`); console.log(`[release] Android: ${mobilePlan.rebuild ? "rebuild" : "reuse"}`); if (options.dryRun) { console.log(buildReleaseNotes({ changesEn: options.changesEn, changesZh: options.changesZh, localizedChanges: options.localizedChanges, issueNumber: 0, })); return; } if (desktopPlan.rebuild) { assertWindowsUpdateSigningKey({ privateKeyPath: process.env.EDGE_EVER_WINDOWS_UPDATE_SIGNING_KEY, }); console.log("[release] Windows update signing key matches the pinned desktop trust anchor"); } let issueNumber; let releaseSha; if (resumedDraft) { const issueMatch = resumedDraft.body.match(/Related Issue: #(\d+)/); if (!issueMatch) { throw new Error(`${tag} Draft notes do not contain a Related Issue number.`); } issueNumber = Number(issueMatch[1]); releaseSha = headShaBeforeRelease; console.log(`[release] resuming existing Draft: ${resumedDraft.url}`); } else { await runParallelValidations(); const issueUrl = run("gh", [ "issue", "create", "--repo", options.repository, "--title", options.issueTitle, ...options.labels.flatMap((label) => ["--label", label]), "--body", buildIssueBody({ ...options, commitCoverageAudit }), ], { capture: true }); const issueMatch = issueUrl.match(/\/issues\/(\d+)/); if (!issueMatch) { throw new Error(`Could not parse created Issue URL: ${issueUrl}`); } issueNumber = Number(issueMatch[1]); console.log(`[release] created Issue #${issueNumber}: ${issueUrl}`); const versionPaths = updateReleaseVersions({ nextVersion: releaseVersion, desktopRebuild: desktopPlan.rebuild, mobileRebuild: mobilePlan.rebuild, changesEn: options.changesEn, changesZh: options.changesZh, localizedChanges: options.localizedChanges, }); run("git", ["add", ...versionPaths]); run("git", ["diff", "--cached", "--check"]); run("git", ["commit", "-m", `chore: release ${tag} [skip ci]`]); run("git", ["push", "origin", "main"]); releaseSha = run("git", ["rev-parse", "HEAD"], { capture: true }); const notes = buildReleaseNotes({ changesEn: options.changesEn, changesZh: options.changesZh, issueNumber, }); const draftUrl = run("gh", [ "release", "create", tag, "--repo", options.repository, "--target", releaseSha, "--title", buildReleaseTitle(tag), "--draft", "--notes", notes, ], { capture: true }); console.log(`[release] Draft created: ${draftUrl}`); } const storedCheckpoint = loadReleaseCheckpoint({ repository: options.repository, issueNumber, tag, }); let checkpointCommentId = storedCheckpoint.commentId; if (storedCheckpoint.state.releaseSha && storedCheckpoint.state.releaseSha !== releaseSha) { cancelSupersededCheckpointRuns({ repository: options.repository, checkpoint: storedCheckpoint.state, headSha: releaseSha, }); } const checkpoint = prepareReleaseCheckpoint({ storedState: storedCheckpoint.state, releaseSha, }); const persistCheckpoint = () => { checkpointCommentId = saveReleaseCheckpoint({ repository: options.repository, issueNumber, tag, commentId: checkpointCommentId, state: checkpoint, }); }; persistCheckpoint(); let playDeliveryAction = "upload"; if (mobilePlan.rebuild && checkpoint.playDelivery?.tag === tag) { const deliveredSha = checkpoint.playDelivery.releaseSha; const deliveredTargetIsAncestor = run( "git", ["merge-base", "--is-ancestor", deliveredSha, releaseSha], { allowFailure: true }, ).status === 0; if (!deliveredTargetIsAncestor) { throw new Error("Stored Play delivery is not an ancestor of the current Draft target."); } const mobileInputsChanged = planNativeRelease( "mobile", changedFilesBetween(deliveredSha, releaseSha), ).rebuild; playDeliveryAction = playDeliveryResumeAction({ playDelivery: checkpoint.playDelivery, tag, headSha: releaseSha, mobileInputsChanged, }); if (playDeliveryAction === "block") { throw new Error( "Mobile release inputs changed after this versionCode was delivered to Google Play. Withdraw this Draft and prepare a new version/versionCode instead of re-uploading the immutable Play version.", ); } } const resolveDraftRun = async (field, workflow, label) => { const reusableRunId = resumedDraft ? await resumeDraftWorkflowRun({ repository: options.repository, runId: checkpoint[field], headSha: releaseSha, label, }) : null; const runId = reusableRunId ?? await dispatchReleaseWorkflow({ repository: options.repository, workflow, tag, headSha: releaseSha, }); recordCheckpointRun(checkpoint, field, runId); persistCheckpoint(); return runId; }; const [desktopRunId, mobileRunId, dockerRunId] = await Promise.all([ resolveDraftRun("desktopRunId", RELEASE_WORKFLOWS.desktop, "Draft desktop assets"), resolveDraftRun("mobileRunId", RELEASE_WORKFLOWS.mobile, "Draft Android assets"), resolveDraftRun("dockerRunId", RELEASE_WORKFLOWS.docker, "Draft Docker image"), ]); const androidReleaseReady = (async () => { await waitForRun({ repository: options.repository, runId: mobileRunId, label: "Draft Android assets", }); if (mobilePlan.rebuild) { await ensurePlayDelivery({ repository: options.repository, tag, headSha: releaseSha, checkpoint, persistCheckpoint, resumeAction: playDeliveryAction, }); } await requirePlaySignedDraftApk({ repository: options.repository, tag, headSha: releaseSha, checkpoint, persistCheckpoint, allowRecovery: mobilePlan.rebuild, }); })(); await Promise.all([ waitForRun({ repository: options.repository, runId: desktopRunId, label: "Draft desktop assets", }), waitForRun({ repository: options.repository, runId: dockerRunId, label: "Draft Docker image", }), androidReleaseReady, ]); let windowsUpdateAuditRunId; for (let attempt = 1; attempt <= 3; attempt += 1) { if (desktopPlan.rebuild) { signDraftWindowsUpdate({ repository: options.repository, tag }); } windowsUpdateAuditRunId = await dispatchReleaseWorkflow({ repository: options.repository, workflow: RELEASE_WORKFLOWS.desktop, tag, headSha: releaseSha, }); recordCheckpointRun(checkpoint, "windowsUpdateAuditRunId", windowsUpdateAuditRunId); persistCheckpoint(); await waitForRun({ repository: options.repository, runId: windowsUpdateAuditRunId, label: "Draft signed Windows update audit", }); const auditRun = viewWorkflowRun({ repository: options.repository, runId: windowsUpdateAuditRunId, }); if (signedWindowsUpdateAuditPassed(auditRun)) break; if (!desktopPlan.rebuild || attempt === 3) { throw new Error("Draft signed Windows update workflow completed without running its audit job."); } console.log( "[release] desktop assets changed while preparing the signature; signing the latest manifest and retrying its audit", ); } const draft = ghJson([ "release", "view", tag, "--repo", options.repository, "--json", "isDraft,isPrerelease,targetCommitish,body,assets", ]); if (!draft.isDraft || draft.isPrerelease || draft.targetCommitish !== releaseSha) { throw new Error("Draft Release metadata does not match the release commit."); } if (draft.body.includes("\\n")) { throw new Error("Release notes contain a literal \\\\n sequence."); } assertDraftAssets({ assets: draft.assets, previousAssets: latestRelease.assets, tag, version: releaseVersion, desktopRebuild: desktopPlan.rebuild, mobileRebuild: mobilePlan.rebuild, }); const publishedAt = Date.now(); const releaseUrl = run("gh", [ "release", "edit", tag, "--repo", options.repository, "--draft=false", "--latest", ], { capture: true }); console.log(`[release] published: ${releaseUrl}`); const [desktopAudit, mobileAudit, dockerAudit] = await Promise.all([ findReleaseRun({ repository: options.repository, workflow: RELEASE_WORKFLOWS.desktop, tag, headSha: releaseSha, publishedAfter: publishedAt, }), findReleaseRun({ repository: options.repository, workflow: RELEASE_WORKFLOWS.mobile, tag, headSha: releaseSha, publishedAfter: publishedAt, }), findReleaseRun({ repository: options.repository, workflow: RELEASE_WORKFLOWS.docker, tag, headSha: releaseSha, publishedAfter: publishedAt, }), ]); const demoRuns = listWorkflowRuns({ repository: options.repository, workflow: RELEASE_WORKFLOWS.demo, event: "release", }); const demoRun = demoRuns.find((candidate) => candidate.displayTitle === tag && candidate.headSha === releaseSha ); console.log( demoRun ? `[release] Demo deployment continues in background: ${demoRun.url}` : `[release] Demo deployment continues in background: https://github.com/${options.repository}/actions/workflows/${RELEASE_WORKFLOWS.demo}`, ); try { await Promise.all([ waitForRun({ repository: options.repository, runId: desktopAudit.databaseId, label: "Published desktop asset audit", }), waitForRun({ repository: options.repository, runId: mobileAudit.databaseId, label: "Published Android asset audit", }), waitForRun({ repository: options.repository, runId: dockerAudit.databaseId, label: "Published Docker image audit", }), ]); } catch (error) { run("gh", [ "release", "edit", tag, "--repo", options.repository, "--draft=true", ], { allowFailure: true }); throw error; } run("gh", [ "issue", "comment", String(issueNumber), "--repo", options.repository, "--body", `Released in [${tag}](${releaseUrl}).\n\nRequired local validations, Draft asset and image preparation, and post-publication audits passed.`, ]); const timingStoreRunId = checkpoint.storeRecoveryRunId ?? checkpoint.storeRunId ?? checkpoint.playDelivery?.recoveryRunId ?? checkpoint.playDelivery?.storeRunId; const timingAttemptRunIds = checkpointRunIds(checkpoint).join(","); const timingDispatch = run("gh", [ "workflow", "run", RELEASE_WORKFLOWS.timings, "--repo", options.repository, "--ref", "main", "-f", `release_tag=${tag}`, "-f", `release_sha=${releaseSha}`, "-f", `issue_number=${issueNumber}`, "-f", `desktop_run_id=${desktopRunId}`, "-f", `desktop_mode=${desktopPlan.rebuild ? "rebuild" : "reuse"}`, "-f", `mobile_run_id=${mobileRunId}`, "-f", `mobile_mode=${mobilePlan.rebuild ? "rebuild" : "reuse"}`, "-f", `docker_run_id=${dockerRunId}`, ...(timingStoreRunId ? [ "-f", `store_run_id=${timingStoreRunId}`, ] : []), ...(timingAttemptRunIds ? ["-f", `attempt_run_ids=${timingAttemptRunIds}`] : []), ], { allowFailure: true }); if (timingDispatch.status === 0) { console.log( `[release] endpoint timing report continues in background: https://github.com/${options.repository}/actions/workflows/${RELEASE_WORKFLOWS.timings}`, ); } else { console.warn("[release] failed to dispatch the non-blocking endpoint timing report"); } run("gh", [ "issue", "close", String(issueNumber), "--repo", options.repository, "--reason", "completed", ]); console.log(`[release] ${tag} is complete; Demo deployment is not blocking completion`); if (options.installDesktop) { await installReleaseDmg({ repository: options.repository, tag, }); } }; if (import.meta.main) { try { const options = parseReleaseArgs(process.argv.slice(2)); if (options.help) { console.log(usage); } else { await releaseMain(options); } } catch (error) { console.error(`[release] failed: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); } }