1import { assert, it } from "@effect/vitest";
2import * as ConfigProvider from "effect/ConfigProvider";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as PlatformError from "effect/PlatformError";
6import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";
8import { ChildProcessSpawner } from "effect/process";
9
10import {
11 listGitTags,
12 resolvePreviousReleaseTag,
13 writePreviousReleaseTagOutput,
14} from "./resolve-previous-release-tag.ts";
15
16const encoder = new TextEncoder();
17
18function mockHandle(options: {
19 readonly exitCode: number;
20 readonly stdout?: string;
21 readonly stderr?: string;
22 readonly stdoutError?: PlatformError.PlatformError;
23 readonly stderrError?: PlatformError.PlatformError;
24}) {
25 return ChildProcessSpawner.makeHandle({
26 pid: ChildProcessSpawner.ProcessId(1),
27 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(options.exitCode)),
28 isRunning: Effect.succeed(false),
29 kill: () => Effect.void,
30 unref: Effect.succeed(Effect.void),
31 stdin: Sink.drain,
32 stdout: options.stdoutError
33 ? Stream.fail(options.stdoutError)
34 : Stream.make(encoder.encode(options.stdout ?? "")),
35 stderr: options.stderrError
36 ? Stream.fail(options.stderrError)
37 : Stream.make(encoder.encode(options.stderr ?? "")),
38 all: Stream.empty,
39 getInputFd: () => Sink.drain,
40 getOutputFd: () => Stream.empty,
41 });
42}
43
44it.effect("selects the latest earlier stable tag and ignores nightlies", () =>
45 Effect.gen(function* () {
46 const previous = yield* resolvePreviousReleaseTag("stable", "v1.2.0", [
47 "v1.1.0",
48 "v1.1.1-nightly.20260619.1",
49 "v1.1.2",
50 "v1.2.0",
51 ]);
52
53 assert.equal(previous, "v1.1.2");
54 }),
55);
56
57it.effect("accepts legacy nightly tags when selecting the previous nightly", () =>
58 Effect.gen(function* () {
59 const previous = yield* resolvePreviousReleaseTag("nightly", "v1.2.0-nightly.20260620.2", [
60 "nightly-v1.2.0-nightly.20260620.1",
61 "v1.1.0-nightly.20260619.9",
62 ]);
63
64 assert.equal(previous, "nightly-v1.2.0-nightly.20260620.1");
65 }),
66);
67
68it.effect("keeps preview tags in their own series", () =>
69 Effect.gen(function* () {
70 const previous = yield* resolvePreviousReleaseTag("preview", "v1.2.0-preview.20260620.2", [
71 "v1.2.0-nightly.20260620.3",
72 "v1.2.0-preview.20260620.1",
73 "v1.1.9",
74 ]);
75 assert.equal(previous, "v1.2.0-preview.20260620.1");
76
77 const stable = yield* resolvePreviousReleaseTag("stable", "v1.2.0", [
78 "v1.1.9",
79 "v1.2.0-preview.20260620.1",
80 ]);
81 assert.equal(stable, "v1.1.9");
82 }),
83);
84
85it.effect("reports the invalid tag with its release channel", () =>
86 Effect.gen(function* () {
87 const error = yield* resolvePreviousReleaseTag("nightly", "v1.2.0", []).pipe(Effect.flip);
88
89 assert.equal(error._tag, "InvalidReleaseTagError");
90 assert.equal(error.channel, "nightly");
91 assert.equal(error.currentTag, "v1.2.0");
92 assert.equal(error.message, "Invalid nightly release tag 'v1.2.0'.");
93 }),
94);
95
96it.effect("preserves git tag spawn context and the exact platform cause", () => {
97 const cause = PlatformError.systemError({
98 _tag: "NotFound",
99 module: "ChildProcess",
100 method: "spawn",
101 description: "git was not found",
102 });
103
104 return Effect.gen(function* () {
105 const error = yield* listGitTags("/repo").pipe(
106 Effect.scoped,
107 Effect.provideService(
108 ChildProcessSpawner.ChildProcessSpawner,
109 ChildProcessSpawner.make(() => Effect.fail(cause)),
110 ),
111 Effect.flip,
112 );
113
114 if (error._tag !== "ReleaseTagListProcessError") {
115 return assert.fail(`Unexpected error: ${error._tag}`);
116 }
117 assert.equal(error.operation, "spawn");
118 assert.equal(error.executable, "git");
119 assert.equal(error.argumentCount, 2);
120 assert.equal(error.cwd, "/repo");
121 assert.strictEqual(error.cause, cause);
122 assert.notProperty(error, "args");
123 assert.notInclude(error.message, cause.message);
124 });
125});
126
127it.effect("distinguishes stdout and stderr read failures", () =>
128 Effect.gen(function* () {
129 for (const [stream, operation] of [
130 ["stdout", "read-stdout"],
131 ["stderr", "read-stderr"],
132 ] as const) {
133 const cause = PlatformError.systemError({
134 _tag: "Unknown",
135 module: "ChildProcess",
136 method: stream,
137 description: `${stream} unavailable`,
138 });
139 const error = yield* listGitTags("/repo").pipe(
140 Effect.scoped,
141 Effect.provideService(
142 ChildProcessSpawner.ChildProcessSpawner,
143 ChildProcessSpawner.make(() =>
144 Effect.succeed(
145 mockHandle({
146 exitCode: 0,
147 ...(stream === "stdout" ? { stdoutError: cause } : { stderrError: cause }),
148 }),
149 ),
150 ),
151 ),
152 Effect.flip,
153 );
154
155 if (error._tag !== "ReleaseTagListProcessError") {
156 return assert.fail(`Unexpected error: ${error._tag}`);
157 }
158 assert.equal(error.operation, operation);
159 assert.strictEqual(error.cause, cause);
160 }
161 }),
162);
163
164it.effect("reports git tag non-zero exits without manufacturing a cause", () =>
165 Effect.gen(function* () {
166 const error = yield* listGitTags("/repo").pipe(
167 Effect.scoped,
168 Effect.provideService(
169 ChildProcessSpawner.ChildProcessSpawner,
170 ChildProcessSpawner.make(() =>
171 Effect.succeed(
172 mockHandle({
173 exitCode: 17,
174 stdout: "v1.2.3\n",
175 stderr: "fatal: repository unavailable\n",
176 }),
177 ),
178 ),
179 ),
180 Effect.flip,
181 );
182
183 if (error._tag !== "ReleaseTagListProcessExitError") {
184 return assert.fail(`Unexpected error: ${error._tag}`);
185 }
186 assert.equal(error.executable, "git");
187 assert.equal(error.argumentCount, 2);
188 assert.equal(error.cwd, "/repo");
189 assert.equal(error.exitCode, 17);
190 assert.equal(error.stdoutLength, 7);
191 assert.equal(error.stderrLength, 30);
192 assert.notProperty(error, "cause");
193 assert.notProperty(error, "stdout");
194 assert.notProperty(error, "stderr");
195 }),
196);
197
198it.effect("preserves the GITHUB_OUTPUT append path and exact cause", () => {
199 const outputPath = "/tmp/previous-tag-github-output";
200 const appendCause = PlatformError.systemError({
201 _tag: "PermissionDenied",
202 module: "FileSystem",
203 method: "writeFileString",
204 pathOrDescriptor: outputPath,
205 });
206
207 return Effect.gen(function* () {
208 const appendError = yield* writePreviousReleaseTagOutput("v1.2.3", true).pipe(
209 Effect.provideService(
210 FileSystem.FileSystem,
211 FileSystem.makeNoop({
212 writeFileString: () => Effect.fail(appendCause),
213 }),
214 ),
215 Effect.provideService(
216 ConfigProvider.ConfigProvider,
217 ConfigProvider.fromEnv({ env: { GITHUB_OUTPUT: outputPath } }),
218 ),
219 Effect.flip,
220 );
221
222 if (appendError._tag !== "PreviousReleaseTagGitHubOutputAppendError") {
223 return assert.fail(`Unexpected error: ${appendError._tag}`);
224 }
225 assert.equal(appendError.outputPath, outputPath);
226 assert.strictEqual(appendError.cause, appendCause);
227 assert.notProperty(appendError, "contents");
228 assert.notInclude(appendError.message, appendCause.message);
229 });
230});