import { existsSync } from "node:fs"; import { mkdir, readdir, readFile, rename, rm } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; type ReleaseChannel = "latest" | "beta"; type ReleaseMode = "full" | "dry-run" | "stage-only" | "publish-only"; type ReleaseCliOptions = { readonly mode: ReleaseMode; readonly skipBuild: boolean; }; type CommandInput = { readonly command: string; readonly args: ReadonlyArray; readonly cwd: string; readonly captureOutput?: boolean; }; type CommandOutput = { readonly stdout: string; readonly stderr: string; }; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const cliRoot = resolve(repoRoot, "apps/cli"); const distDir = resolve(cliRoot, "dist"); const releaseDir = resolve(distDir, "release"); const wrapperDir = resolve(distDir, "executor"); const versionPackagePath = resolve(cliRoot, "package.json"); const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; const parseArgs = (argv: ReadonlyArray): ReleaseCliOptions => { let mode: ReleaseMode = "full"; let skipBuild = false; for (const arg of argv) { if (arg === "--skip-build") { skipBuild = true; continue; } const next: ReleaseMode | null = arg === "--dry-run" ? "dry-run" : arg === "--stage-only" ? "stage-only" : arg === "--publish-only" ? "publish-only" : null; if (next === null) { throw new Error(`Unknown argument: ${arg}`); } if (mode !== "full") { throw new Error(`--${next} conflicts with --${mode}; pass at most one mode flag`); } mode = next; } if (mode === "publish-only" && skipBuild) { throw new Error("--skip-build is implied by --publish-only; drop it"); } if (mode === "dry-run" && skipBuild) { throw new Error("--skip-build with --dry-run would validate artifacts and do nothing else"); } return { mode, skipBuild }; }; const runCommand = async (input: CommandInput): Promise => { const proc = Bun.spawn([input.command, ...input.args], { cwd: input.cwd, env: process.env, stdio: input.captureOutput ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"], }); const exitCode = await proc.exited; const stdout = input.captureOutput && proc.stdout ? await new Response(proc.stdout).text() : ""; const stderr = input.captureOutput && proc.stderr ? await new Response(proc.stderr).text() : ""; if (exitCode !== 0) { throw new Error( [ `${input.command} ${input.args.join(" ")} exited with code ${exitCode}`, stdout.trim().length > 0 ? `stdout:\n${stdout.trim()}` : null, stderr.trim().length > 0 ? `stderr:\n${stderr.trim()}` : null, ] .filter((part) => part !== null) .join("\n\n"), ); } return { stdout: stdout.trim(), stderr: stderr.trim(), }; }; const readVersion = async (): Promise => { const pkg = (await Bun.file(versionPackagePath).json()) as { version?: string }; const version = pkg.version?.trim(); if (!version) { throw new Error(`Missing version in ${versionPackagePath}`); } return version; }; const validateVersion = (version: string): void => { if (!semverPattern.test(version)) { throw new Error(`${versionPackagePath} version is not valid semver: ${version}`); } }; const resolveChannel = (version: string): ReleaseChannel => version.includes("-") ? "beta" : "latest"; const resolveTagFromEnvironment = (): string | undefined => { const refName = process.env.GITHUB_REF_NAME?.trim(); if (process.env.GITHUB_REF_TYPE === "tag" && refName) { return refName; } const ref = process.env.GITHUB_REF?.trim(); if (ref?.startsWith("refs/tags/")) { return ref.slice("refs/tags/".length); } return undefined; }; const resolveGitHubRepository = (): string => { const repository = process.env.GH_REPO?.trim() || process.env.GITHUB_REPOSITORY?.trim(); if (!repository) { throw new Error("Set GH_REPO or GITHUB_REPOSITORY before creating a GitHub release."); } return repository; }; const packWrapperPackage = async (): Promise => { if (!existsSync(wrapperDir)) { throw new Error(`Wrapper package directory not found: ${wrapperDir}`); } await mkdir(releaseDir, { recursive: true }); const before = new Set((await readdir(wrapperDir)).filter((entry) => entry.endsWith(".tgz"))); await runCommand({ command: "bun", args: ["pm", "pack"], cwd: wrapperDir, }); const after = (await readdir(wrapperDir)).filter((entry) => entry.endsWith(".tgz")); const archiveName = after.find((entry) => !before.has(entry)) ?? after[0]; if (!archiveName) { throw new Error(`bun pm pack did not create a .tgz archive in ${wrapperDir}`); } const sourcePath = join(wrapperDir, archiveName); const destinationPath = join(releaseDir, archiveName); await rm(destinationPath, { force: true }); await rename(sourcePath, destinationPath); return destinationPath; }; const collectReleaseAssetPaths = async ( wrapperArchivePath: string, ): Promise> => { const assetNames = (await readdir(distDir)) .filter((entry) => /^executor-.*\.(?:tar\.gz|zip)$/.test(entry)) .sort(); return [wrapperArchivePath, ...assetNames.map((entry) => join(distDir, entry))]; }; const githubReleaseExists = async (tag: string, repository: string): Promise => { const proc = Bun.spawn(["gh", "release", "view", tag, "--repo", repository], { cwd: repoRoot, env: process.env, stdio: ["ignore", "ignore", "ignore"], }); return (await proc.exited) === 0; }; /** The CHANGELOG section Changesets generated for `version` (compiled from * `.changeset/*.md` bodies at Version Packages time) — the GitHub Release * body. Returns null when the section is missing so the caller can fall back * to GitHub's auto-generated notes. */ const changelogSectionForVersion = async (version: string): Promise => { const changelogPath = resolve(cliRoot, "CHANGELOG.md"); if (!existsSync(changelogPath)) return null; const lines = (await readFile(changelogPath, "utf8")).split("\n"); const start = lines.findIndex((line) => line.trim() === `## ${version}`); if (start === -1) return null; let end = lines.length; for (let i = start + 1; i < lines.length; i += 1) { if (/^## /.test(lines[i] ?? "")) { end = i; break; } } const body = lines .slice(start + 1, end) .join("\n") .trim(); return body.length > 0 ? body : null; }; const syncGitHubRelease = async (input: { readonly tag: string; readonly channel: ReleaseChannel; readonly assetPaths: ReadonlyArray; }): Promise => { if (!process.env.GH_TOKEN?.trim()) { throw new Error("GH_TOKEN is required to create or update a GitHub release."); } const repository = resolveGitHubRepository(); if (await githubReleaseExists(input.tag, repository)) { await runCommand({ command: "gh", args: [ "release", "upload", input.tag, ...input.assetPaths, "--repo", repository, "--clobber", ], cwd: repoRoot, }); return; } const notes = await changelogSectionForVersion(input.tag.replace(/^v/, "")); // Draft until publish-desktop.yml finishes uploading installers and flips // it; otherwise /releases/latest/download/ 404s during the // desktop build window. const args = [ "release", "create", input.tag, ...input.assetPaths, "--repo", repository, "--title", input.tag, ...(notes ? ["--notes", notes] : ["--generate-notes"]), "--verify-tag", "--draft", ]; if (input.channel === "beta") { args.push("--prerelease"); } await runCommand({ command: "gh", args, cwd: repoRoot, }); }; /** Locate the wrapper archive a previous build of this exact version left in * dist/release. Used by --skip-build and --publish-only so a run that just * built (release:check's dry-run, or a --stage-only step) is not repeated. * Fails hard on any mismatch — reusing stale artifacts must never be a * silent fallback. */ const locateBuiltArtifacts = async (version: string): Promise => { const wrapperPkgPath = join(wrapperDir, "package.json"); if (!existsSync(wrapperPkgPath)) { throw new Error(`No built wrapper package at ${wrapperPkgPath}; run without --skip-build.`); } const wrapperPkg = (await Bun.file(wrapperPkgPath).json()) as { version?: string; optionalDependencies?: Record; }; if (wrapperPkg.version !== version) { throw new Error( `Built wrapper version ${wrapperPkg.version} does not match ${version}; run without --skip-build.`, ); } const expectedArchive = join(releaseDir, `executor-${version}.tgz`); if (!existsSync(expectedArchive)) { throw new Error(`Missing packed wrapper ${expectedArchive}; run without --skip-build.`); } // The wrapper's optionalDependencies are the source of truth for which // platform variants this release ships. `build.ts publish` globs the // dist/executor-*/ directories and publishes whatever it finds, so a // missing dir would ship a wrapper referencing a variant that never // reached npm, and an extra stale dir (say a leftover beta variant) // would be published alongside. Require the exact set, each at the exact // aliased version, each with its release archive present. const optional = wrapperPkg.optionalDependencies ?? {}; const expectedVariants = Object.keys(optional).sort(); if (expectedVariants.length === 0) { throw new Error(`Built wrapper has no optionalDependencies; run without --skip-build.`); } const variantDirs = [...new Bun.Glob("executor-*/package.json").scanSync({ cwd: distDir })] .map((entry) => dirname(entry)) .sort(); if (variantDirs.join(",") !== expectedVariants.join(",")) { throw new Error( `Platform variant dirs [${variantDirs.join(", ")}] do not match the wrapper's ` + `optionalDependencies [${expectedVariants.join(", ")}]; run without --skip-build.`, ); } for (const name of expectedVariants) { const spec = optional[name]!; const aliasPrefix = "npm:executor@"; if (!spec.startsWith(aliasPrefix)) { throw new Error(`Unexpected optionalDependency spec for ${name}: ${spec}`); } const aliasVersion = spec.slice(aliasPrefix.length); const variantPkg = (await Bun.file(join(distDir, name, "package.json")).json()) as { version?: string; }; if (variantPkg.version !== aliasVersion) { throw new Error( `${name} version ${variantPkg.version} does not match the wrapper's ` + `${aliasVersion}; run without --skip-build.`, ); } if (!existsSync(join(distDir, `${name}.tar.gz`)) && !existsSync(join(distDir, `${name}.zip`))) { throw new Error( `Missing release archive for ${name} in ${distDir}; run without --skip-build.`, ); } } return expectedArchive; }; const main = async () => { const options = parseArgs(process.argv.slice(2)); const version = await readVersion(); const tag = `v${version}`; const refTag = resolveTagFromEnvironment(); const channel = resolveChannel(version); validateVersion(version); if (refTag && refTag !== tag) { throw new Error(`GitHub tag ${refTag} does not match ${versionPackagePath} version ${version}`); } if (options.mode === "publish-only") { await locateBuiltArtifacts(version); await runCommand({ command: "bun", args: ["run", "src/build.ts", "publish", channel], cwd: cliRoot, }); return; } let wrapperArchivePath: string; if (options.skipBuild) { wrapperArchivePath = await locateBuiltArtifacts(version); } else { await rm(releaseDir, { recursive: true, force: true }); await mkdir(releaseDir, { recursive: true }); await runCommand({ command: "bun", args: ["run", "src/build.ts", "binary"], cwd: cliRoot, }); await runCommand({ command: "bun", args: ["run", "src/build.ts", "release-assets"], cwd: cliRoot, }); wrapperArchivePath = await packWrapperPackage(); } const assetPaths = await collectReleaseAssetPaths(wrapperArchivePath); console.log(`Prepared executor@${version} for ${channel}`); for (const assetPath of assetPaths) { console.log(`- ${assetPath}`); } if (options.mode === "dry-run") { return; } await syncGitHubRelease({ tag, channel, assetPaths, }); if (options.mode === "stage-only") { return; } await runCommand({ command: "bun", args: ["run", "src/build.ts", "publish", channel], cwd: cliRoot, }); }; await main();