authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 18:58:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 18:58:13-07:00
logcdd03591efb09baf06299e3764ddbd01c313a936
tree989d03d046d25d06e745551e0854a19016842609
parent2fbb0565d7976cbb338f5593a566ff53c6165419
signature Signed by PGP key B5690EEEBB952194

[codex] Structure release metadata failures (#3467)

Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 278 insertions(+), 14 deletions(-)

scripts/resolve-nightly-release.test.ts+51
......@@ -1,7 +1,12 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
12import { assert, it } from "@effect/vitest";
23import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as PlatformError from "effect/PlatformError";
37
48import {
9 readDesktopBaseVersion,
510 resolveNightlyBaseVersion,
611 resolveNightlyReleaseMetadata,
712 resolveNightlyTargetVersion,
......@@ -43,3 +48,49 @@ it("derives nightly metadata including the short commit sha in the release name"
4348 },
4449 );
4550});
51
52it.layer(NodeServices.layer)("readDesktopBaseVersion", (it) => {
53 it.effect("preserves desktop package read context and its platform cause", () =>
54 Effect.gen(function* () {
55 const fs = yield* FileSystem.FileSystem;
56 const path = yield* Path.Path;
57 const rootDir = yield* fs.makeTempDirectoryScoped({
58 prefix: "resolve-nightly-release-read-",
59 });
60 const packageJsonPath = path.join(rootDir, "apps/desktop/package.json");
61
62 const error = yield* readDesktopBaseVersion(rootDir).pipe(Effect.flip);
63
64 if (error._tag !== "NightlyReleaseDesktopPackageError") {
65 return assert.fail(`Unexpected error: ${error._tag}`);
66 }
67 assert.equal(error.operation, "read");
68 assert.equal(error.packageJsonPath, packageJsonPath);
69 assert.instanceOf(error.cause, PlatformError.PlatformError);
70 assert.notInclude(error.message, String((error.cause as Error).message));
71 }),
72 );
73
74 it.effect("preserves desktop package decode context and its schema cause", () =>
75 Effect.gen(function* () {
76 const fs = yield* FileSystem.FileSystem;
77 const path = yield* Path.Path;
78 const rootDir = yield* fs.makeTempDirectoryScoped({
79 prefix: "resolve-nightly-release-decode-",
80 });
81 const packageJsonPath = path.join(rootDir, "apps/desktop/package.json");
82 yield* fs.makeDirectory(path.dirname(packageJsonPath), { recursive: true });
83 yield* fs.writeFileString(packageJsonPath, "{");
84
85 const error = yield* readDesktopBaseVersion(rootDir).pipe(Effect.flip);
86
87 if (error._tag !== "NightlyReleaseDesktopPackageError") {
88 return assert.fail(`Unexpected error: ${error._tag}`);
89 }
90 assert.equal(error.operation, "decode");
91 assert.equal(error.packageJsonPath, packageJsonPath);
92 assert.ok(error.cause !== undefined);
93 assert.notInclude(error.message, String((error.cause as Error).message));
94 }),
95 );
96});
scripts/resolve-nightly-release.ts+34-4
......@@ -40,6 +40,19 @@ export class InvalidDesktopPackageVersionError extends Schema.TaggedErrorClass<I
4040 }
4141}
4242
43export class NightlyReleaseDesktopPackageError extends Schema.TaggedErrorClass<NightlyReleaseDesktopPackageError>()(
44 "NightlyReleaseDesktopPackageError",
45 {
46 operation: Schema.Literals(["read", "decode"]),
47 packageJsonPath: Schema.String,
48 cause: Schema.Defect(),
49 },
50) {
51 override get message(): string {
52 return `Failed to ${this.operation} desktop package metadata at ${this.packageJsonPath}.`;
53 }
54}
55
4356const RepoRoot = Effect.service(Path.Path).pipe(
4457 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
4558);
......@@ -77,16 +90,33 @@ export const resolveNightlyReleaseMetadata = (
7790 };
7891};
7992
80const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* (
93export const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* (
8194 rootDir: string | undefined,
8295) {
8396 const fs = yield* FileSystem.FileSystem;
8497 const path = yield* Path.Path;
8598 const workspaceRoot = rootDir ? path.resolve(rootDir) : yield* RepoRoot;
8699 const packageJsonPath = path.join(workspaceRoot, "apps/desktop/package.json");
87 const packageJson = yield* fs
88 .readFileString(packageJsonPath)
89 .pipe(Effect.flatMap(decodeDesktopPackageJson));
100 const packageJsonSource = yield* fs.readFileString(packageJsonPath).pipe(
101 Effect.mapError(
102 (cause) =>
103 new NightlyReleaseDesktopPackageError({
104 operation: "read",
105 packageJsonPath,
106 cause,
107 }),
108 ),
109 );
110 const packageJson = yield* decodeDesktopPackageJson(packageJsonSource).pipe(
111 Effect.mapError(
112 (cause) =>
113 new NightlyReleaseDesktopPackageError({
114 operation: "decode",
115 packageJsonPath,
116 cause,
117 }),
118 ),
119 );
90120 return yield* resolveNightlyTargetVersion(packageJson.version);
91121});
92122
scripts/resolve-previous-release-tag.test.ts+92-1
......@@ -1,7 +1,33 @@
11import { assert, it } from "@effect/vitest";
22import * as Effect from "effect/Effect";
3import * as PlatformError from "effect/PlatformError";
4import * as Sink from "effect/Sink";
5import * as Stream from "effect/Stream";
6import { ChildProcessSpawner } from "effect/unstable/process";
37
4import { resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts";
8import { listGitTags, resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts";
9
10const encoder = new TextEncoder();
11
12function mockHandle(options: {
13 readonly exitCode: number;
14 readonly stdout?: string;
15 readonly stderr?: string;
16}) {
17 return ChildProcessSpawner.makeHandle({
18 pid: ChildProcessSpawner.ProcessId(1),
19 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(options.exitCode)),
20 isRunning: Effect.succeed(false),
21 kill: () => Effect.void,
22 unref: Effect.succeed(Effect.void),
23 stdin: Sink.drain,
24 stdout: Stream.make(encoder.encode(options.stdout ?? "")),
25 stderr: Stream.make(encoder.encode(options.stderr ?? "")),
26 all: Stream.empty,
27 getInputFd: () => Sink.drain,
28 getOutputFd: () => Stream.empty,
29 });
30}
531
632it.effect("selects the latest earlier stable tag and ignores nightlies", () =>
733 Effect.gen(function* () {
......@@ -37,3 +63,68 @@ it.effect("reports the invalid tag with its release channel", () =>
3763 assert.equal(error.message, "Invalid nightly release tag 'v1.2.0'.");
3864 }),
3965);
66
67it.effect("preserves git tag spawn context and the exact platform cause", () => {
68 const cause = PlatformError.systemError({
69 _tag: "NotFound",
70 module: "ChildProcess",
71 method: "spawn",
72 description: "git was not found",
73 });
74
75 return Effect.gen(function* () {
76 const error = yield* listGitTags("/repo").pipe(
77 Effect.scoped,
78 Effect.provideService(
79 ChildProcessSpawner.ChildProcessSpawner,
80 ChildProcessSpawner.make(() => Effect.fail(cause)),
81 ),
82 Effect.flip,
83 );
84
85 if (error._tag !== "ReleaseTagListProcessError") {
86 return assert.fail(`Unexpected error: ${error._tag}`);
87 }
88 assert.equal(error.operation, "spawn");
89 assert.equal(error.executable, "git");
90 assert.equal(error.argumentCount, 2);
91 assert.equal(error.cwd, "/repo");
92 assert.strictEqual(error.cause, cause);
93 assert.notProperty(error, "args");
94 assert.notInclude(error.message, cause.message);
95 });
96});
97
98it.effect("reports git tag non-zero exits without manufacturing a cause", () =>
99 Effect.gen(function* () {
100 const error = yield* listGitTags("/repo").pipe(
101 Effect.scoped,
102 Effect.provideService(
103 ChildProcessSpawner.ChildProcessSpawner,
104 ChildProcessSpawner.make(() =>
105 Effect.succeed(
106 mockHandle({
107 exitCode: 17,
108 stdout: "v1.2.3\n",
109 stderr: "fatal: repository unavailable\n",
110 }),
111 ),
112 ),
113 ),
114 Effect.flip,
115 );
116
117 if (error._tag !== "ReleaseTagListProcessExitError") {
118 return assert.fail(`Unexpected error: ${error._tag}`);
119 }
120 assert.equal(error.executable, "git");
121 assert.equal(error.argumentCount, 2);
122 assert.equal(error.cwd, "/repo");
123 assert.equal(error.exitCode, 17);
124 assert.equal(error.stdoutLength, 7);
125 assert.equal(error.stderrLength, 30);
126 assert.notProperty(error, "cause");
127 assert.notProperty(error, "stdout");
128 assert.notProperty(error, "stderr");
129 }),
130);
scripts/resolve-previous-release-tag.ts+101-9
......@@ -2,7 +2,6 @@
22
33import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
44import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as Array from "effect/Array";
65import * as Config from "effect/Config";
76import * as Effect from "effect/Effect";
87import * as FileSystem from "effect/FileSystem";
......@@ -27,6 +26,39 @@ export class InvalidReleaseTagError extends Schema.TaggedErrorClass<InvalidRelea
2726 }
2827}
2928
29const releaseTagListProcessContext = {
30 executable: Schema.Literal("git"),
31 argumentCount: Schema.Number,
32 cwd: Schema.String,
33};
34
35export class ReleaseTagListProcessError extends Schema.TaggedErrorClass<ReleaseTagListProcessError>()(
36 "ReleaseTagListProcessError",
37 {
38 ...releaseTagListProcessContext,
39 operation: Schema.Literals(["spawn", "communicate", "wait-for-exit"]),
40 cause: Schema.Defect(),
41 },
42) {
43 override get message(): string {
44 return `Failed to list release tags during process operation "${this.operation}".`;
45 }
46}
47
48export class ReleaseTagListProcessExitError extends Schema.TaggedErrorClass<ReleaseTagListProcessExitError>()(
49 "ReleaseTagListProcessExitError",
50 {
51 ...releaseTagListProcessContext,
52 exitCode: Schema.Number,
53 stdoutLength: Schema.Number,
54 stderrLength: Schema.Number,
55 },
56) {
57 override get message(): string {
58 return `Release tag listing exited with code ${this.exitCode}.`;
59 }
60}
61
3062interface StableVersion {
3163 readonly major: number;
3264 readonly minor: number;
......@@ -172,20 +204,80 @@ export const resolvePreviousReleaseTag = (
172204 return candidates[0]?.tag;
173205 });
174206
175const listGitTags = Effect.fn("listGitTags")(function* () {
176 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
177 const child = yield* spawner.spawn(ChildProcess.make("git", ["tag", "--list"]));
178 const tags = yield* child.stdout.pipe(
207const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
208 stream.pipe(
179209 Stream.decodeText(),
180210 Stream.runFold(
181211 () => "",
182212 (acc, chunk) => acc + chunk,
183213 ),
184 Effect.map(String.split(/\r?\n/)),
185 Effect.map(Array.map(String.trim)),
186 Effect.map(Array.filter(String.isNonEmpty)),
187214 );
188 return tags;
215
216export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd()) {
217 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
218 const args = ["tag", "--list"] as const;
219 const context = {
220 executable: "git",
221 argumentCount: args.length,
222 cwd,
223 } as const;
224 const child = yield* spawner.spawn(ChildProcess.make("git", args, { cwd })).pipe(
225 Effect.mapError(
226 (cause) =>
227 new ReleaseTagListProcessError({
228 ...context,
229 operation: "spawn",
230 cause,
231 }),
232 ),
233 );
234 const [stdout, stderr, exitCode] = yield* Effect.all(
235 [
236 collectStreamAsString(child.stdout).pipe(
237 Effect.mapError(
238 (cause) =>
239 new ReleaseTagListProcessError({
240 ...context,
241 operation: "communicate",
242 cause,
243 }),
244 ),
245 ),
246 collectStreamAsString(child.stderr).pipe(
247 Effect.mapError(
248 (cause) =>
249 new ReleaseTagListProcessError({
250 ...context,
251 operation: "communicate",
252 cause,
253 }),
254 ),
255 ),
256 child.exitCode.pipe(
257 Effect.map(Number),
258 Effect.mapError(
259 (cause) =>
260 new ReleaseTagListProcessError({
261 ...context,
262 operation: "wait-for-exit",
263 cause,
264 }),
265 ),
266 ),
267 ],
268 { concurrency: "unbounded" },
269 );
270
271 if (exitCode !== 0) {
272 return yield* new ReleaseTagListProcessExitError({
273 ...context,
274 exitCode,
275 stdoutLength: stdout.length,
276 stderrLength: stderr.length,
277 });
278 }
279
280 return stdout.split(/\r?\n/).map(String.trim).filter(String.isNonEmpty);
189281});
190282
191283const writeOutput = Effect.fn("writeOutput")(function* (