authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 19:20:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 19:20:01-07:00
loge4a84a348e59d59e03bca15baa627aed99aec790
treebf455396c10034ff8daca6c3bbb3c1a65a447a6b
parent61e6d89d69240120dd08fae72459e5fd7e7a2908
signature Signed by PGP key B5690EEEBB952194

[codex] Structure release output failures (#3470)

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

4 files changed, 205 insertions(+), 18 deletions(-)

scripts/resolve-nightly-release.test.ts+26
......@@ -1,5 +1,7 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
22import { assert, it } from "@effect/vitest";
3import * as Config from "effect/Config";
4import * as ConfigProvider from "effect/ConfigProvider";
35import * as Effect from "effect/Effect";
46import * as FileSystem from "effect/FileSystem";
57import * as Path from "effect/Path";
......@@ -10,6 +12,7 @@ import {
1012 resolveNightlyBaseVersion,
1113 resolveNightlyReleaseMetadata,
1214 resolveNightlyTargetVersion,
15 writeNightlyReleaseOutput,
1316} from "./resolve-nightly-release.ts";
1417
1518it("strips prerelease and build metadata when deriving the nightly base version", () => {
......@@ -49,6 +52,29 @@ it("derives nightly metadata including the short commit sha in the release name"
4952 );
5053});
5154
55it.effect("preserves the GITHUB_OUTPUT configuration cause", () => {
56 const metadata = resolveNightlyReleaseMetadata("1.2.4", "20260620", 42, "abcdef1234567890");
57 const configCause = new ConfigProvider.SourceError({ message: "environment unavailable" });
58
59 return Effect.gen(function* () {
60 const configError = yield* writeNightlyReleaseOutput(metadata, true).pipe(
61 Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})),
62 Effect.provideService(
63 ConfigProvider.ConfigProvider,
64 ConfigProvider.make(() => Effect.fail(configCause)),
65 ),
66 Effect.flip,
67 );
68
69 if (configError._tag !== "NightlyReleaseGitHubOutputConfigError") {
70 return assert.fail(`Unexpected error: ${configError._tag}`);
71 }
72 assert.instanceOf(configError.cause, Config.ConfigError);
73 assert.strictEqual(configError.cause.cause, configCause);
74 assert.notInclude(configError.message, configCause.message);
75 });
76});
77
5278it.layer(NodeServices.layer)("readDesktopBaseVersion", (it) => {
5379 it.effect("preserves desktop package read context and its platform cause", () =>
5480 Effect.gen(function* () {
scripts/resolve-nightly-release.ts+43-5
......@@ -11,7 +11,7 @@ import * as Path from "effect/Path";
1111import * as Schema from "effect/Schema";
1212import { Command, Flag } from "effect/unstable/cli";
1313
14interface NightlyReleaseMetadata {
14export interface NightlyReleaseMetadata {
1515 readonly baseVersion: string;
1616 readonly version: string;
1717 readonly tag: string;
......@@ -53,6 +53,29 @@ export class NightlyReleaseDesktopPackageError extends Schema.TaggedErrorClass<N
5353 }
5454}
5555
56export class NightlyReleaseGitHubOutputConfigError extends Schema.TaggedErrorClass<NightlyReleaseGitHubOutputConfigError>()(
57 "NightlyReleaseGitHubOutputConfigError",
58 {
59 cause: Schema.Defect(),
60 },
61) {
62 override get message(): string {
63 return "Failed to resolve the GITHUB_OUTPUT path for nightly release metadata.";
64 }
65}
66
67export class NightlyReleaseGitHubOutputAppendError extends Schema.TaggedErrorClass<NightlyReleaseGitHubOutputAppendError>()(
68 "NightlyReleaseGitHubOutputAppendError",
69 {
70 outputPath: Schema.String,
71 cause: Schema.Defect(),
72 },
73) {
74 override get message(): string {
75 return `Failed to append nightly release metadata to ${this.outputPath}.`;
76 }
77}
78
5679const RepoRoot = Effect.service(Path.Path).pipe(
5780 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
5881);
......@@ -120,7 +143,7 @@ export const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(functi
120143 return yield* resolveNightlyTargetVersion(packageJson.version);
121144});
122145
123const writeOutput = Effect.fn("writeOutput")(function* (
146export const writeNightlyReleaseOutput = Effect.fn("writeNightlyReleaseOutput")(function* (
124147 metadata: NightlyReleaseMetadata,
125148 writeGithubOutput: boolean,
126149) {
......@@ -135,9 +158,24 @@ const writeOutput = Effect.fn("writeOutput")(function* (
135158 ] as const;
136159
137160 if (writeGithubOutput) {
138 const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT");
161 const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe(
162 Effect.mapError(
163 (cause) =>
164 new NightlyReleaseGitHubOutputConfigError({
165 cause,
166 }),
167 ),
168 );
139169 const serialized = entries.map(([key, value]) => `${key}=${value}\n`).join("");
140 yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" });
170 yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" }).pipe(
171 Effect.mapError(
172 (cause) =>
173 new NightlyReleaseGitHubOutputAppendError({
174 outputPath: githubOutputPath,
175 cause,
176 }),
177 ),
178 );
141179 } else {
142180 for (const [key, value] of entries) {
143181 yield* Console.log(`${key}=${value}`);
......@@ -172,7 +210,7 @@ const command = Command.make(
172210 ({ date, runNumber, sha, githubOutput, root }) =>
173211 readDesktopBaseVersion(Option.getOrUndefined(root)).pipe(
174212 Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)),
175 Effect.flatMap((metadata) => writeOutput(metadata, githubOutput)),
213 Effect.flatMap((metadata) => writeNightlyReleaseOutput(metadata, githubOutput)),
176214 ),
177215).pipe(Command.withDescription("Resolve nightly release version metadata."));
178216
scripts/resolve-previous-release-tag.test.ts+86-3
......@@ -1,11 +1,17 @@
11import { assert, it } from "@effect/vitest";
2import * as ConfigProvider from "effect/ConfigProvider";
23import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
35import * as PlatformError from "effect/PlatformError";
46import * as Sink from "effect/Sink";
57import * as Stream from "effect/Stream";
68import { ChildProcessSpawner } from "effect/unstable/process";
79
8import { listGitTags, resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts";
10import {
11 listGitTags,
12 resolvePreviousReleaseTag,
13 writePreviousReleaseTagOutput,
14} from "./resolve-previous-release-tag.ts";
915
1016const encoder = new TextEncoder();
1117
......@@ -13,6 +19,8 @@ function mockHandle(options: {
1319 readonly exitCode: number;
1420 readonly stdout?: string;
1521 readonly stderr?: string;
22 readonly stdoutError?: PlatformError.PlatformError;
23 readonly stderrError?: PlatformError.PlatformError;
1624}) {
1725 return ChildProcessSpawner.makeHandle({
1826 pid: ChildProcessSpawner.ProcessId(1),
......@@ -21,8 +29,12 @@ function mockHandle(options: {
2129 kill: () => Effect.void,
2230 unref: Effect.succeed(Effect.void),
2331 stdin: Sink.drain,
24 stdout: Stream.make(encoder.encode(options.stdout ?? "")),
25 stderr: Stream.make(encoder.encode(options.stderr ?? "")),
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 ?? "")),
2638 all: Stream.empty,
2739 getInputFd: () => Sink.drain,
2840 getOutputFd: () => Stream.empty,
......@@ -95,6 +107,43 @@ it.effect("preserves git tag spawn context and the exact platform cause", () =>
95107 });
96108});
97109
110it.effect("distinguishes stdout and stderr read failures", () =>
111 Effect.gen(function* () {
112 for (const [stream, operation] of [
113 ["stdout", "read-stdout"],
114 ["stderr", "read-stderr"],
115 ] as const) {
116 const cause = PlatformError.systemError({
117 _tag: "Unknown",
118 module: "ChildProcess",
119 method: stream,
120 description: `${stream} unavailable`,
121 });
122 const error = yield* listGitTags("/repo").pipe(
123 Effect.scoped,
124 Effect.provideService(
125 ChildProcessSpawner.ChildProcessSpawner,
126 ChildProcessSpawner.make(() =>
127 Effect.succeed(
128 mockHandle({
129 exitCode: 0,
130 ...(stream === "stdout" ? { stdoutError: cause } : { stderrError: cause }),
131 }),
132 ),
133 ),
134 ),
135 Effect.flip,
136 );
137
138 if (error._tag !== "ReleaseTagListProcessError") {
139 return assert.fail(`Unexpected error: ${error._tag}`);
140 }
141 assert.equal(error.operation, operation);
142 assert.strictEqual(error.cause, cause);
143 }
144 }),
145);
146
98147it.effect("reports git tag non-zero exits without manufacturing a cause", () =>
99148 Effect.gen(function* () {
100149 const error = yield* listGitTags("/repo").pipe(
......@@ -128,3 +177,37 @@ it.effect("reports git tag non-zero exits without manufacturing a cause", () =>
128177 assert.notProperty(error, "stderr");
129178 }),
130179);
180
181it.effect("preserves the GITHUB_OUTPUT append path and exact cause", () => {
182 const outputPath = "/tmp/previous-tag-github-output";
183 const appendCause = PlatformError.systemError({
184 _tag: "PermissionDenied",
185 module: "FileSystem",
186 method: "writeFileString",
187 pathOrDescriptor: outputPath,
188 });
189
190 return Effect.gen(function* () {
191 const appendError = yield* writePreviousReleaseTagOutput("v1.2.3", true).pipe(
192 Effect.provideService(
193 FileSystem.FileSystem,
194 FileSystem.makeNoop({
195 writeFileString: () => Effect.fail(appendCause),
196 }),
197 ),
198 Effect.provideService(
199 ConfigProvider.ConfigProvider,
200 ConfigProvider.fromEnv({ env: { GITHUB_OUTPUT: outputPath } }),
201 ),
202 Effect.flip,
203 );
204
205 if (appendError._tag !== "PreviousReleaseTagGitHubOutputAppendError") {
206 return assert.fail(`Unexpected error: ${appendError._tag}`);
207 }
208 assert.equal(appendError.outputPath, outputPath);
209 assert.strictEqual(appendError.cause, appendCause);
210 assert.notProperty(appendError, "contents");
211 assert.notInclude(appendError.message, appendCause.message);
212 });
213});
scripts/resolve-previous-release-tag.ts+50-10
......@@ -26,9 +26,11 @@ export class InvalidReleaseTagError extends Schema.TaggedErrorClass<InvalidRelea
2626 }
2727}
2828
29const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
30
2931const releaseTagListProcessContext = {
3032 executable: Schema.Literal("git"),
31 argumentCount: Schema.Number,
33 argumentCount: NonNegativeInt,
3234 cwd: Schema.String,
3335};
3436
......@@ -36,7 +38,7 @@ export class ReleaseTagListProcessError extends Schema.TaggedErrorClass<ReleaseT
3638 "ReleaseTagListProcessError",
3739 {
3840 ...releaseTagListProcessContext,
39 operation: Schema.Literals(["spawn", "communicate", "wait-for-exit"]),
41 operation: Schema.Literals(["spawn", "read-stdout", "read-stderr", "wait-for-exit"]),
4042 cause: Schema.Defect(),
4143 },
4244) {
......@@ -50,8 +52,8 @@ export class ReleaseTagListProcessExitError extends Schema.TaggedErrorClass<Rele
5052 {
5153 ...releaseTagListProcessContext,
5254 exitCode: Schema.Number,
53 stdoutLength: Schema.Number,
54 stderrLength: Schema.Number,
55 stdoutLength: NonNegativeInt,
56 stderrLength: NonNegativeInt,
5557 },
5658) {
5759 override get message(): string {
......@@ -59,6 +61,29 @@ export class ReleaseTagListProcessExitError extends Schema.TaggedErrorClass<Rele
5961 }
6062}
6163
64export class PreviousReleaseTagGitHubOutputConfigError extends Schema.TaggedErrorClass<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.TaggedErrorClass<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
6287interface StableVersion {
6388 readonly major: number;
6489 readonly minor: number;
......@@ -238,7 +263,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd
238263 (cause) =>
239264 new ReleaseTagListProcessError({
240265 ...context,
241 operation: "communicate",
266 operation: "read-stdout",
242267 cause,
243268 }),
244269 ),
......@@ -248,7 +273,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd
248273 (cause) =>
249274 new ReleaseTagListProcessError({
250275 ...context,
251 operation: "communicate",
276 operation: "read-stderr",
252277 cause,
253278 }),
254279 ),
......@@ -280,7 +305,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd
280305 return stdout.split(/\r?\n/).map(String.trim).filter(String.isNonEmpty);
281306});
282307
283const writeOutput = Effect.fn("writeOutput")(function* (
308export const writePreviousReleaseTagOutput = Effect.fn("writePreviousReleaseTagOutput")(function* (
284309 previousTag: string | undefined,
285310 writeGithubOutput: boolean,
286311) {
......@@ -288,8 +313,23 @@ const writeOutput = Effect.fn("writeOutput")(function* (
288313
289314 if (writeGithubOutput) {
290315 const fs = yield* FileSystem.FileSystem;
291 const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT");
292 yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" });
316 const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe(
317 Effect.mapError(
318 (cause) =>
319 new PreviousReleaseTagGitHubOutputConfigError({
320 cause,
321 }),
322 ),
323 );
324 yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" }).pipe(
325 Effect.mapError(
326 (cause) =>
327 new PreviousReleaseTagGitHubOutputAppendError({
328 outputPath: githubOutputPath,
329 cause,
330 }),
331 ),
332 );
293333 return;
294334 }
295335
......@@ -313,7 +353,7 @@ const command = Command.make(
313353 ({ channel, currentTag, githubOutput }) =>
314354 listGitTags().pipe(
315355 Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)),
316 Effect.flatMap((previousTag) => writeOutput(previousTag, githubOutput)),
356 Effect.flatMap((previousTag) => writePreviousReleaseTagOutput(previousTag, githubOutput)),
317357 ),
318358).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series."));
319359