1#!/usr/bin/env bun
2/**
3 * Create a GitHub release for a tag, with channel-aware prerelease/latest flags.
4 *
5 * GitHub's API does not allow a release to be both `prerelease=true` and
6 * `latest=true`. To show an alpha/beta as "Latest" when no stable release
7 * exists yet, we publish it with `prerelease=false` — a masquerade. This
8 * script compensates on the next release: if the currently-latest release
9 * has a pre-release-style tag and prerelease=false, we flip it back to
10 * `prerelease=true` before publishing the new one. That keeps the latest
11 * badge where the user wants it without leaving stale "stable" markers on
12 * alpha/beta tags.
13 *
14 * Channel → flags on the new release:
15 * release prerelease=false, latest=true
16 * beta|alpha|rc if any true-stable release exists: prerelease=true, latest=false
17 * else: prerelease=false, latest=true (masquerade)
18 * tag prerelease=true, latest=false (always)
19 *
20 * Usage: bun github-release.ts <tag> <release|beta|alpha|tag>
21 *
22 * Reads ALCHEMY_REPO for the GitHub repo to query commit history from.
23 */
24import { $ } from "bun";
25import { generate } from "./changelog.ts";
26import { repo } from "./config.ts";
27
28type Channel = "release" | "beta" | "alpha" | "rc" | "tag";
29const CHANNELS: readonly Channel[] = ["release", "beta", "alpha", "rc", "tag"];
30
31function isStableTag(tag: string): boolean {
32 return /^v?\d+\.\d+\.\d+$/.test(tag);
33}
34
35const tag = process.argv[2];
36const channel = process.argv[3] as Channel | undefined;
37if (!tag || !channel || !CHANNELS.includes(channel)) {
38 console.error(
39 "Usage: bun github-release.ts <tag> <release|beta|alpha|rc|tag>",
40 );
41 process.exit(1);
42}
43
44const view = await $`gh release view ${tag}`.nothrow().quiet();
45if (view.exitCode === 0) {
46 console.log(`Release ${tag} already exists on GitHub, skipping`);
47 process.exit(0);
48}
49
50let prerelease: boolean;
51let latest: boolean;
52if (channel === "release") {
53 prerelease = false;
54 latest = true;
55} else if (channel === "tag") {
56 prerelease = true;
57 latest = false;
58} else {
59 const list = await $`gh release list --limit 500 --json tagName,isPrerelease`
60 .nothrow()
61 .quiet();
62 let hasStable = false;
63 if (list.exitCode === 0) {
64 const raw = list.stdout.toString().trim();
65 const releases = raw
66 ? (JSON.parse(raw) as Array<{ tagName: string; isPrerelease: boolean }>)
67 : [];
68 hasStable = releases.some((r) => isStableTag(r.tagName) && !r.isPrerelease);
69 }
70 if (hasStable) {
71 prerelease = true;
72 latest = false;
73 } else {
74 prerelease = false;
75 latest = true;
76 console.log(
77 "No true-stable release exists; publishing this prerelease with prerelease=false so it can be marked latest.",
78 );
79 }
80}
81
82if (latest) {
83 const cur = await $`gh release view --latest --json tagName,isPrerelease`
84 .nothrow()
85 .quiet();
86 if (cur.exitCode === 0) {
87 const raw = cur.stdout.toString().trim();
88 if (raw) {
89 const current = JSON.parse(raw) as {
90 tagName: string;
91 isPrerelease: boolean;
92 };
93 if (
94 current.tagName !== tag &&
95 !isStableTag(current.tagName) &&
96 !current.isPrerelease
97 ) {
98 console.log(
99 `Demoting previous masquerading latest ${current.tagName}: prerelease=false → true`,
100 );
101 await $`gh release edit ${current.tagName} --prerelease=true --latest=false`;
102 }
103 }
104 }
105}
106
107const prev = await $`git describe --tags --abbrev=0 ${`${tag}^`}`
108 .nothrow()
109 .quiet();
110const from = prev.exitCode === 0 ? prev.stdout.toString().trim() : undefined;
111
112console.log(
113 `Generating release notes for ${tag}${from ? ` from ${from}` : ""}`,
114);
115const { md } = await generate({
116 from,
117 to: tag,
118 emoji: true,
119 contributors: true,
120 repo: repo(),
121});
122
123const args = [
124 "release",
125 "create",
126 tag,
127 "--title",
128 tag,
129 "--notes",
130 md,
131 `--latest=${latest ? "true" : "false"}`,
132];
133if (prerelease) args.push("--prerelease");
134
135await $`gh ${args}`;