Open History to see the last commit
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 if (prereleaseIdentifiers[0] === "nightly" || prereleaseIdentifiers[0] === "preview") {
155 return undefined;
156 }
157
158 return {
159 major: Number(major),
160 minor: Number(minor),
161 patch: Number(patch),
162 prerelease: prereleaseIdentifiers,
163 };
164};
165
166const compareNightlyVersions = (left: NightlyVersion, right: NightlyVersion): number => {
167 if (left.major !== right.major) return left.major - right.major;
168 if (left.minor !== right.minor) return left.minor - right.minor;
169 if (left.patch !== right.patch) return left.patch - right.patch;
170 if (left.date !== right.date) return left.date - right.date;
171 return left.runNumber - right.runNumber;
172};
173
174const parseNightlyTag = (
175 tag: string,
176 channel: "nightly" | "preview" = "nightly",
177): NightlyVersion | undefined => {
178 const match = new RegExp(
179 `^(?:nightly-)?v(\\d+)\\.(\\d+)\\.(\\d+)-${channel}\\.(\\d{8})\\.(\\d+)$`,
180 ).exec(tag);
181 if (!match) return undefined;
182
183 const [, major, minor, patch, date, runNumber] = match;
184 if (!major || !minor || !patch || !date || !runNumber) return undefined;
185
186 return {
187 major: Number(major),
188 minor: Number(minor),
189 patch: Number(patch),
190 date: Number(date),
191 runNumber: Number(runNumber),
192 };
193};
194
195export const resolvePreviousReleaseTag = Effect.fn(
196 "resolve-previous-release-tag.resolvePreviousReleaseTag",
197)(function* (channel: ReleaseChannel, currentTag: string, tags: ReadonlyArray<string>) {
198 if (channel === "stable") {
199 const current = parseStableTag(currentTag);
200 if (!current) {
201 return yield* new InvalidReleaseTagError({ channel, currentTag });
202 }
203
204 const candidates = tags
205 .map((tag) => ({ tag, parsed: parseStableTag(tag) }))
206 .filter(
207 (entry): entry is { tag: string; parsed: StableVersion } => entry.parsed !== undefined,
208 )
209 .filter((entry) => compareStableVersions(entry.parsed, current) < 0)
210 .toSorted((left, right) => compareStableVersions(right.parsed, left.parsed));
211
212 return candidates[0]?.tag;
213 }
214
215 const current = parseNightlyTag(currentTag, channel);
216 if (!current) {
217 return yield* new InvalidReleaseTagError({ channel, currentTag });
218 }
219
220 const candidates = tags
221 .map((tag) => ({ tag, parsed: parseNightlyTag(tag, channel) }))
222 .filter((entry): entry is { tag: string; parsed: NightlyVersion } => entry.parsed !== undefined)
223 .filter((entry) => compareNightlyVersions(entry.parsed, current) < 0)
224 .toSorted((left, right) => compareNightlyVersions(right.parsed, left.parsed));
225
226 return candidates[0]?.tag;
227});
228
229const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
230 stream.pipe(
231 Stream.decodeText(),
232 Stream.runFold(
233 () => "",
234 (acc, chunk) => acc + chunk,
235 ),
236 );
237
238export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd()) {
239 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
240 const args = ["tag", "--list"] as const;
241 const context = {
242 executable: "git",
243 argumentCount: args.length,
244 cwd,
245 } as const;
246 const child = yield* spawner.spawn(ChildProcess.make("git", args, { cwd })).pipe(
247 Effect.mapError(
248 (cause) =>
249 new ReleaseTagListProcessError({
250 ...context,
251 operation: "spawn",
252 cause,
253 }),
254 ),
255 );
256 const [stdout, stderr, exitCode] = yield* Effect.all(
257 [
258 collectStreamAsString(child.stdout).pipe(
259 Effect.mapError(
260 (cause) =>
261 new ReleaseTagListProcessError({
262 ...context,
263 operation: "read-stdout",
264 cause,
265 }),
266 ),
267 ),
268 collectStreamAsString(child.stderr).pipe(
269 Effect.mapError(
270 (cause) =>
271 new ReleaseTagListProcessError({
272 ...context,
273 operation: "read-stderr",
274 cause,
275 }),
276 ),
277 ),
278 child.exitCode.pipe(
279 Effect.map(Number),
280 Effect.mapError(
281 (cause) =>
282 new ReleaseTagListProcessError({
283 ...context,
284 operation: "wait-for-exit",
285 cause,
286 }),
287 ),
288 ),
289 ],
290 { concurrency: "unbounded" },
291 );
292
293 if (exitCode !== 0) {
294 return yield* new ReleaseTagListProcessExitError({
295 ...context,
296 exitCode,
297 stdoutLength: stdout.length,
298 stderrLength: stderr.length,
299 });
300 }
301
302 return stdout.split(/\r?\n/).map(String.trim).filter(String.isNonEmpty);
303});
304
305export const writePreviousReleaseTagOutput = Effect.fn("writePreviousReleaseTagOutput")(function* (
306 previousTag: string | undefined,
307 writeGithubOutput: boolean,
308) {
309 const entry = `previous_tag=${previousTag ?? ""}\n`;
310
311 if (writeGithubOutput) {
312 const fs = yield* FileSystem.FileSystem;
313 const githubOutputPath = yield* Config.NonEmptyString("GITHUB_OUTPUT").pipe(
314 Effect.mapError(
315 (cause) =>
316 new PreviousReleaseTagGitHubOutputConfigError({
317 cause,
318 }),
319 ),
320 );
321 yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" }).pipe(
322 Effect.mapError(
323 (cause) =>
324 new PreviousReleaseTagGitHubOutputAppendError({
325 outputPath: githubOutputPath,
326 cause,
327 }),
328 ),
329 );
330 return;
331 }
332
333 process.stdout.write(entry);
334});
335
336const command = Command.make(
337 "resolve-previous-release-tag",
338 {
339 channel: Flag.Literals("channel", ReleaseChannel.literals).pipe(
340 Flag.withDescription("Release channel whose previous tag should be resolved."),
341 ),
342 currentTag: Flag.String("current-tag").pipe(
343 Flag.withDescription("Current release tag to compare against."),
344 ),
345 githubOutput: Flag.Boolean("github-output").pipe(
346 Flag.withDescription("Write values to GITHUB_OUTPUT instead of stdout."),
347 Flag.withDefault(false),
348 ),
349 },
350 ({ channel, currentTag, githubOutput }) =>
351 listGitTags().pipe(
352 Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)),
353 Effect.flatMap((previousTag) => writePreviousReleaseTagOutput(previousTag, githubOutput)),
354 ),
355).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series."));
356
357if (import.meta.main) {
358 Command.run(command, { version: "0.0.0" }).pipe(
359 Effect.scoped,
360 Effect.provide(NodeServices.layer),
361 NodeRuntime.runMain,
362 );
363}