1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as Config from "effect/Config";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Schema from "effect/Schema";
9import * as Stream from "effect/Stream";
10import * as String from "effect/String";
11import { Command, Flag } from "effect/cli";
12import { ChildProcess, ChildProcessSpawner } from "effect/process";
13
14const ReleaseChannel = Schema.Literals(["stable", "nightly", "preview"]);
15type ReleaseChannel = typeof ReleaseChannel.Type;
16
17export class InvalidReleaseTagError extends Schema.TaggedError<InvalidReleaseTagError>()(
18 "InvalidReleaseTagError",
19 {
20 channel: ReleaseChannel,
21 currentTag: Schema.String,
22 },
23) {
24 override get message(): string {
25 return `Invalid ${this.channel} release tag '${this.currentTag}'.`;
26 }
27}
28
29const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
30
31const releaseTagListProcessContext = {
32 executable: Schema.Literal("git"),
33 argumentCount: NonNegativeInt,
34 cwd: Schema.String,
35};
36
37export class ReleaseTagListProcessError extends Schema.TaggedError<ReleaseTagListProcessError>()(
38 "ReleaseTagListProcessError",
39 {
40 ...releaseTagListProcessContext,
41 operation: Schema.Literals(["spawn", "read-stdout", "read-stderr", "wait-for-exit"]),
42 cause: Schema.Defect(),
43 },
44) {
45 override get message(): string {
46 return `Failed to list release tags during process operation "${this.operation}".`;
47 }
48}
49
50export class ReleaseTagListProcessExitError extends Schema.TaggedError<ReleaseTagListProcessExitError>()(
51 "ReleaseTagListProcessExitError",
52 {
53 ...releaseTagListProcessContext,
54 exitCode: Schema.Number,
55 stdoutLength: NonNegativeInt,
56 stderrLength: NonNegativeInt,
57 },
58) {
59 override get message(): string {
60 return `Release tag listing exited with code ${this.exitCode}.`;
61 }
62}
63
64export class PreviousReleaseTagGitHubOutputConfigError extends Schema.TaggedError<PreviousReleaseTagGitHubOutputConfigError>()(
65 "PreviousReleaseTagGitHubOutputConfigError",
66 {
67 cause: Schema.Defect(),
68 },
69) {
70 override get message(): string {
71 return "Failed to resolve the GITHUB_OUTPUT path for the previous release tag.";
72 }
73}
74
75export class PreviousReleaseTagGitHubOutputAppendError extends Schema.TaggedError<PreviousReleaseTagGitHubOutputAppendError>()(
76 "PreviousReleaseTagGitHubOutputAppendError",
77 {
78 outputPath: Schema.String,
79 cause: Schema.Defect(),
80 },
81) {
82 override get message(): string {
83 return `Failed to append the previous release tag to ${this.outputPath}.`;
84 }
85}
86
87interface StableVersion {
88 readonly major: number;
89 readonly minor: number;
90 readonly patch: number;
91 readonly prerelease: ReadonlyArray<string>;
92}
93
94interface NightlyVersion {
95 readonly major: number;
96 readonly minor: number;
97 readonly patch: number;
98 readonly date: number;
99 readonly runNumber: number;
100}
101
102const parseNumericIdentifier = (identifier: string): number | undefined =>
103 /^\d+$/.test(identifier) ? Number(identifier) : undefined;
104
105const comparePrereleaseIdentifiers = (left: string, right: string): number => {
106 const leftNumeric = parseNumericIdentifier(left);
107 const rightNumeric = parseNumericIdentifier(right);
108
109 if (leftNumeric !== undefined && rightNumeric !== undefined) {
110 return leftNumeric - rightNumeric;
111 }
112 if (leftNumeric !== undefined) {
113 return -1;
114 }
115 if (rightNumeric !== undefined) {
116 return 1;
117 }
118 return left.localeCompare(right);
119};
120
121const compareStableVersions = (left: StableVersion, right: StableVersion): number => {
122 if (left.major !== right.major) return left.major - right.major;
123 if (left.minor !== right.minor) return left.minor - right.minor;
124 if (left.patch !== right.patch) return left.patch - right.patch;
125
126 const leftHasPrerelease = left.prerelease.length > 0;
127 const rightHasPrerelease = right.prerelease.length > 0;
128 if (!leftHasPrerelease && !rightHasPrerelease) return 0;
129 if (!leftHasPrerelease) return 1;
130 if (!rightHasPrerelease) return -1;
131
132 const maxLength = Math.max(left.prerelease.length, right.prerelease.length);
133 for (let index = 0; index < maxLength; index += 1) {
134 const leftIdentifier = left.prerelease[index];
135 const rightIdentifier = right.prerelease[index];
136 if (leftIdentifier === undefined) return -1;
137 if (rightIdentifier === undefined) return 1;
138
139 const comparison = comparePrereleaseIdentifiers(leftIdentifier, rightIdentifier);
140 if (comparison !== 0) return comparison;
141 }
142
143 return 0;
144};
145
146const parseStableTag = (tag: string): StableVersion | undefined => {
147 const match = /^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(tag);
148 if (!match) return undefined;
149
150 const [, major, minor, patch, prerelease] = match;
151 if (!major || !minor || !patch) return undefined;
152
153 const prereleaseIdentifiers = prerelease ? prerelease.split(".") : [];
154 // Nightly and preview tags also start with `v` and carry their channel as
155 // the prerelease identifier. They must not be considered stable candidates
156 // when resolving the previous stable tag.
157 if (prereleaseIdentifiers[0] === "nightly" || prereleaseIdentifiers[0] === "preview") {
158 return undefined;
159 }
160
161 return {
162 major: Number(major),
163 minor: Number(minor),
164 patch: Number(patch),
165 prerelease: prereleaseIdentifiers,
166 };
167};
168
169const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): number => {
170 if (left.major !== right.major) return left.major - right.major;
171 if (left.minor !== right.minor) return left.minor - right.minor;
172 if (left.patch !== right.patch) return left.patch - right.patch;
173 if (left.date !== right.date) return left.date - right.date;
174 return left.runNumber - right.runNumber;
175};
176
177const parseNightlyTag = (
178 tag: string,
179 channel: "nightly" | "preview" = "nightly",
180): NightlyVersion | undefined => {
181 // Accept both the current `v<semver>` format and the legacy `nightly-v<semver>`
182 // format so release note diffs keep working across the tag-format transition.
183 const match = new RegExp(
184 `^(?:nightly-)?v(\\d+)\\.(\\d+)\\.(\\d+)-${channel}\\.(\\d{8})\\.(\\d+)$`,
185 ).exec(tag);
186 if (!match) return undefined;
187
188 const [, major, minor, patch, date, runNumber] = match;
189 if (!major || !minor || !patch || !date || !runNumber) return undefined;
190
191 return {
192 major: Number(major),
193 minor: Number(minor),
194 patch: Number(patch),
195 date: Number(date),
196 runNumber: Number(runNumber),
197 };
198};
199
200export const resolvePreviousReleaseTag = (
201 channel: ReleaseChannel,
202 currentTag: string,
203 tags: ReadonlyArray<string>,
204) =>
205 Effect.gen(function* () {
206 if (channel === "stable") {
207 const current = parseStableTag(currentTag);
208 if (!current) {
209 return yield* new InvalidReleaseTagError({ channel, currentTag });
210 }
211
212 const candidates = tags
213 .map((tag) => ({ tag, parsed: parseStableTag(tag) }))
214 .filter(
215 (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined,
216 )
217 .filter((entry) => compareStableVersions(entry.parsed, current) < 0)
218 .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed));
219
220 return candidates[0]?.tag;
221 }
222
223 const current = parseNightlyTag(currentTag, channel);
224 if (!current) {
225 return yield* new InvalidReleaseTagError({ channel, currentTag });
226 }
227
228 const candidates = tags
229 .map((tag) => ({ tag, parsed: parseNightlyTag(tag, channel) }))
230 .filter(
231 (entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined,
232 )
233 .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0)
234 .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed));
235
236 return candidates[0]?.tag;
237 });
238
239const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
240 stream.pipe(
241 Stream.decodeText(),
242 Stream.runFold(
243 () => "",
244 (acc, chunk) => acc + chunk,
245 ),
246 );
247
248export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd()) {
249 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
250 const args = ["tag", "--list"] as const;
251 const context = {
252 executable: "git",
253 argumentCount: args.length,
254 cwd,
255 } as const;
256 const child = yield* spawner.spawn(ChildProcess.make("git", args, { cwd })).pipe(
257 Effect.mapError(
258 (cause) =>
259 new ReleaseTagListProcessError({
260 ...context,
261 operation: "spawn",
262 cause,
263 }),
264 ),
265 );
266 const [stdout, stderr, exitCode] = yield* Effect.all(
267 [
268 collectStreamAsString(child.stdout).pipe(
269 Effect.mapError(
270 (cause) =>
271 new ReleaseTagListProcessError({
272 ...context,
273 operation: "read-stdout",
274 cause,
275 }),
276 ),
277 ),
278 collectStreamAsString(child.stderr).pipe(
279 Effect.mapError(
280 (cause) =>
281 new ReleaseTagListProcessError({
282 ...context,
283 operation: "read-stderr",
284 cause,
285 }),
286 ),
287 ),
288 child.exitCode.pipe(
289 Effect.map(Number),
290 Effect.mapError(
291 (cause) =>
292 new ReleaseTagListProcessError({
293 ...context,
294 operation: "wait-for-exit",
295 cause,
296 }),
297 ),
298 ),
299 ],
300 { concurrency: "unbounded" },
301 );
302
303 if (exitCode !== 0) {
304 return yield* new ReleaseTagListProcessExitError({
305 ...context,
306 exitCode,
307 stdoutLength: stdout.length,
308 stderrLength: stderr.length,
309 });
310 }
311
312 return stdout.split(/\r?\n/).map(String.trim).filter(String.isNonEmpty);
313});
314
315export const writePreviousReleaseTagOutput = Effect.fn("writePreviousReleaseTagOutput")(function* (
316 previousTag: string | undefined,
317 writeGithubOutput: boolean,
318) {
319 const entry = `previous_tag=${previousTag ?? ""}\n`;
320
321 if (writeGithubOutput) {
322 const fs = yield* FileSystem.FileSystem;
323 const githubOutputPath = yield* Config.NonEmptyString("GITHUB_OUTPUT").pipe(
324 Effect.mapError(
325 (cause) =>
326 new PreviousReleaseTagGitHubOutputConfigError({
327 cause,
328 }),
329 ),
330 );
331 yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" }).pipe(
332 Effect.mapError(
333 (cause) =>
334 new PreviousReleaseTagGitHubOutputAppendError({
335 outputPath: githubOutputPath,
336 cause,
337 }),
338 ),
339 );
340 return;
341 }
342
343 process.stdout.write(entry);
344});
345
346const command = Command.make(
347 "resolve-previous-release-tag",
348 {
349 channel: Flag.Literals("channel", ReleaseChannel.literals).pipe(
350 Flag.withDescription("Release channel whose previous tag should be resolved."),
351 ),
352 currentTag: Flag.String("current-tag").pipe(
353 Flag.withDescription("Current release tag to compare against."),
354 ),
355 githubOutput: Flag.Boolean("github-output").pipe(
356 Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."),
357 Flag.withDefault(false),
358 ),
359 },
360 ({ channel, currentTag, githubOutput }) =>
361 listGitTags().pipe(
362 Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)),
363 Effect.flatMap((previousTag) => writePreviousReleaseTagOutput(previousTag, githubOutput)),
364 ),
365).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series."));
366
367if (import.meta.main) {
368 Command.run(command, { version: "0.0.0" }).pipe(
369 Effect.scoped,
370 Effect.provide(NodeServices.layer),
371 NodeRuntime.runMain,
372 );
373}