History

[codex] Structure release metadata failures (#3467)

Julius Marminge committed 4 months ago

Commit cdd03591 parent 2fbb0564 files · +278 −14Browse files

Unified · Split

4 files
  1. scripts/resolve-nightly-release.test.ts +51 −0
  2. scripts/resolve-nightly-release.ts +34 −4
  3. scripts/resolve-previous-release-tag.test.ts +92 −1
  4. scripts/resolve-previous-release-tag.ts +101 −9
scripts/resolve-nightly-release.test.ts+51−0
@@ -1,7 +1,12 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
12 import { assert, it } from "@effect/vitest";
23 import * as Effect from "effect/Effect";
4+import * as FileSystem from "effect/FileSystem";
5+import * as Path from "effect/Path";
6+import * as PlatformError from "effect/PlatformError";
37
48 import {
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" · 35 unmodified lines
4348 },
4449 );
4550 });
51+
52+it.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 · 39 unmodified lines
4040 }
4141 }
4242
43+export 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+
4356 const 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 = ( · 31 unmodified lines
7790 };
7891 };
7992
80−const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* (
93+export 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 @@
11 import { assert, it } from "@effect/vitest";
22 import * as Effect from "effect/Effect";
3+import * as PlatformError from "effect/PlatformError";
4+import * as Sink from "effect/Sink";
5+import * as Stream from "effect/Stream";
6+import { ChildProcessSpawner } from "effect/unstable/process";
37
4−import { resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts";
8+import { listGitTags, resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts";
9+
10+const encoder = new TextEncoder();
11+
12+function 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
632 it.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", () => · 29 unmodified lines
3763 assert.equal(error.message, "Invalid nightly release tag 'v1.2.0'.");
3864 }),
3965 );
66+
67+it.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+
98+it.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 @@ · 1 unmodified line
22
33 import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
44 import * as NodeServices from "@effect/platform-node/NodeServices";
5−import * as Array from "effect/Array";
65 import * as Config from "effect/Config";
76 import * as Effect from "effect/Effect";
87 import * as FileSystem from "effect/FileSystem";
@@ -27,6 +26,39 @@ export class InvalidReleaseTagError extends Schema.TaggedErrorClass<InvalidRelea · 18 unmodified lines
2726 }
2827 }
2928
29+const releaseTagListProcessContext = {
30+ executable: Schema.Literal("git"),
31+ argumentCount: Schema.Number,
32+ cwd: Schema.String,
33+};
34+
35+export 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+
48+export 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+
3062 interface StableVersion {
3163 readonly major: number;
3264 readonly minor: number;
@@ -172,20 +204,80 @@ export const resolvePreviousReleaseTag = ( · 139 unmodified lines
172204 return candidates[0]?.tag;
173205 });
174206
175−const 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(
207+const 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+
216+export 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
191283 const writeOutput = Effect.fn("writeOutput")(function* (