305 lines · 8.4 KB · TypeScript
RawDownload
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 ReleaseCliOptions = {  readonly dryRun: boolean;}; type CommandInput = {  readonly command: string;  readonly args: ReadonlyArray<string>;  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<string>): ReleaseCliOptions => {  let dryRun = false;   for (const arg of argv) {    if (arg === "--dry-run") {      dryRun = true;      continue;    }     throw new Error(`Unknown argument: ${arg}`);  }   return { dryRun };}; const runCommand = async (input: CommandInput): Promise<CommandOutput> => {  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<string> => {  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<string> => {  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<ReadonlyArray<string>> => {  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<boolean> => {  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<string | null> => {  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<string>;}): Promise<void> => {  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/<desktop-asset> 404s during the  // ~15-20 min 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,  });}; 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}`);  }   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,  });   const 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.dryRun) {    return;  }   await syncGitHubRelease({    tag,    channel,    assetPaths,  });   await runCommand({    command: "bun",    args: ["run", "src/build.ts", "publish", channel],    cwd: cliRoot,  });}; await main();