1import { $ } from "bun";
2import { readdir, readFile, writeFile } from "node:fs/promises";
3import path from "node:path";
4
5type Channel = "release" | "beta" | "alpha" | "rc" | "tag";
6
7const spec = (process.argv[2] ?? "").trim();
8const forceLatest = process.env.FORCE_LATEST === "true";
9const root = path.resolve(import.meta.dir, "../..");
10const entries = await readdir(path.join(root, "packages"), {
11 withFileTypes: true,
12});
13const packages = (
14 await Promise.all(
15 entries
16 .filter((entry) => entry.isDirectory())
17 .map(async (entry) => {
18 const dir = `packages/${entry.name}`;
19 const manifest = JSON.parse(
20 await readFile(path.join(root, dir, "package.json"), "utf8"),
21 );
22 return manifest.private === true ? undefined : { dir, manifest };
23 }),
24 )
25).filter((pkg) => pkg !== undefined);
26
27async function versions(name: string): Promise<Array<string>> {
28 const response = await fetch(
29 `https://registry.npmjs.org/${encodeURIComponent(name)}`,
30 );
31 if (!response.ok) return [];
32 const json = (await response.json()) as {
33 versions?: Record<string, unknown>;
34 };
35 return Object.keys(json.versions ?? {});
36}
37
38function compare(a: string, b: string): number {
39 const aa = a.split(".").map(Number);
40 const bb = b.split(".").map(Number);
41 return aa[0]! - bb[0]! || aa[1]! - bb[1]! || aa[2]! - bb[2]!;
42}
43
44let channel: Channel;
45let version: string;
46const prerelease = spec.match(/^(beta|alpha|rc)(?:\.(\d+))?$/);
47if (spec === "" || prerelease) {
48 channel = (prerelease?.[1] ?? "beta") as Channel;
49 const explicit = prerelease?.[2];
50 if (explicit) {
51 version = `2.0.0-${channel}.${explicit}`;
52 } else {
53 const maxima = await Promise.all(
54 packages.map(async ({ manifest }) => {
55 const matcher = new RegExp(`^2\\.0\\.0-${channel}\\.(\\d+)$`);
56 return Math.max(
57 0,
58 ...(await versions(manifest.name)).map((candidate) =>
59 Number(candidate.match(matcher)?.[1] ?? 0),
60 ),
61 );
62 }),
63 );
64 const maximum = Math.max(0, ...maxima);
65 const remoteTag =
66 maximum > 0
67 ? await $`git ls-remote --exit-code --tags origin ${`refs/tags/v2.0.0-${channel}.${maximum}`}`
68 .nothrow()
69 .quiet()
70 : undefined;
71 const complete = maximum > 0 && maxima.every((value) => value === maximum);
72 const next =
73 complete && remoteTag?.exitCode === 0 ? maximum + 1 : maximum || 1;
74 version = `2.0.0-${channel}.${next}`;
75 }
76} else if (/^\d+\.\d+\.\d+$/.test(spec)) {
77 channel = "release";
78 version = spec;
79} else if (["patch", "minor", "major"].includes(spec)) {
80 channel = "release";
81 const stable = (await versions(packages[0]!.manifest.name))
82 .filter((candidate) => /^\d+\.\d+\.\d+$/.test(candidate))
83 .sort(compare)
84 .at(-1);
85 if (!stable)
86 throw new Error(
87 "Cannot calculate a stable bump without a published stable version",
88 );
89 let [major, minor, patch] = stable.split(".").map(Number) as [
90 number,
91 number,
92 number,
93 ];
94 if (spec === "major") [major, minor, patch] = [major + 1, 0, 0];
95 if (spec === "minor") [minor, patch] = [minor + 1, 0];
96 if (spec === "patch") patch += 1;
97 version = `${major}.${minor}.${patch}`;
98} else {
99 if (!/^[A-Za-z][A-Za-z0-9.-]*$/.test(spec))
100 throw new Error(`Invalid release spec: ${spec}`);
101 channel = "tag";
102 version = `0.0.0-${spec}`;
103}
104
105for (const pkg of packages) {
106 pkg.manifest.version = version;
107 await writeFile(
108 path.join(root, pkg.dir, "package.json"),
109 `${JSON.stringify(pkg.manifest, null, 2)}\n`,
110 );
111}
112await writeFile(
113 path.join(root, "release-packages.json"),
114 `${JSON.stringify(
115 packages.map(({ dir, manifest }) => ({ dir, name: manifest.name })),
116 null,
117 2,
118 )}\n`,
119);
120
121await $`pnpm install --lockfile-only`.cwd(root).quiet();
122// stdout is piped into $GITHUB_OUTPUT by the workflow, so keep subcommand
123// logs on stderr — only the key=value lines below may reach stdout.
124const validation = await $`bun validate:publish-packages`.cwd(root).quiet();
125console.error(validation.stdout.toString().trimEnd());
126
127const tag = forceLatest
128 ? "latest"
129 : channel === "release"
130 ? "latest"
131 : channel === "tag"
132 ? version.slice("0.0.0-".length).replaceAll(".", "-")
133 : "next";
134console.log(`version=${version}`);
135console.log(`channel=${channel}`);
136console.log(`tag=${tag}`);