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 @@...@@ -1,5 +1,7 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
3import * as Config from "effect/Config";
4import * as ConfigProvider from "effect/ConfigProvider";
3import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";7import * as Path from "effect/Path";
...@@ -10,6 +12,7 @@ import {...@@ -10,6 +12,7 @@ import {
10 resolveNightlyBaseVersion,12 resolveNightlyBaseVersion,
11 resolveNightlyReleaseMetadata,13 resolveNightlyReleaseMetadata,
12 resolveNightlyTargetVersion,14 resolveNightlyTargetVersion,
15 writeNightlyReleaseOutput,
13} from "./resolve-nightly-release.ts";16} from "./resolve-nightly-release.ts";
1417
15it("strips prerelease and build metadata when deriving the nightly base version", () => {18it("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"...@@ -49,6 +52,29 @@ it("derives nightly metadata including the short commit sha in the release name"
49 );52 );
50});53});
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
52it.layer(NodeServices.layer)("readDesktopBaseVersion", (it) => {78it.layer(NodeServices.layer)("readDesktopBaseVersion", (it) => {
53 it.effect("preserves desktop package read context and its platform cause", () =>79 it.effect("preserves desktop package read context and its platform cause", () =>
54 Effect.gen(function* () {80 Effect.gen(function* () {
scripts/resolve-nightly-release.ts+43-5
...@@ -11,7 +11,7 @@ import * as Path from "effect/Path";...@@ -11,7 +11,7 @@ import * as Path from "effect/Path";
11import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
12import { Command, Flag } from "effect/unstable/cli";12import { Command, Flag } from "effect/unstable/cli";
1313
14interface NightlyReleaseMetadata {14export interface NightlyReleaseMetadata {
15 readonly baseVersion: string;15 readonly baseVersion: string;
16 readonly version: string;16 readonly version: string;
17 readonly tag: string;17 readonly tag: string;
...@@ -53,6 +53,29 @@ export class NightlyReleaseDesktopPackageError extends Schema.TaggedErrorClass<N...@@ -53,6 +53,29 @@ export class NightlyReleaseDesktopPackageError extends Schema.TaggedErrorClass<N
53 }53 }
54}54}
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
56const RepoRoot = Effect.service(Path.Path).pipe(79const RepoRoot = Effect.service(Path.Path).pipe(
57 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),80 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
58);81);
...@@ -120,7 +143,7 @@ export const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(functi...@@ -120,7 +143,7 @@ export const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(functi
120 return yield* resolveNightlyTargetVersion(packageJson.version);143 return yield* resolveNightlyTargetVersion(packageJson.version);
121});144});
122145
123const writeOutput = Effect.fn("writeOutput")(function* (146export const writeNightlyReleaseOutput = Effect.fn("writeNightlyReleaseOutput")(function* (
124 metadata: NightlyReleaseMetadata,147 metadata: NightlyReleaseMetadata,
125 writeGithubOutput: boolean,148 writeGithubOutput: boolean,
126) {149) {
...@@ -135,9 +158,24 @@ const writeOutput = Effect.fn("writeOutput")(function* (...@@ -135,9 +158,24 @@ const writeOutput = Effect.fn("writeOutput")(function* (
135 ] as const;158 ] as const;
136159
137 if (writeGithubOutput) {160 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 );
139 const serialized = entries.map(([key, value]) => `${key}=${value}\n`).join("");169 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 );
141 } else {179 } else {
142 for (const [key, value] of entries) {180 for (const [key, value] of entries) {
143 yield* Console.log(`${key}=${value}`);181 yield* Console.log(`${key}=${value}`);
...@@ -172,7 +210,7 @@ const command = Command.make(...@@ -172,7 +210,7 @@ const command = Command.make(
172 ({ date, runNumber, sha, githubOutput, root }) =>210 ({ date, runNumber, sha, githubOutput, root }) =>
173 readDesktopBaseVersion(Option.getOrUndefined(root)).pipe(211 readDesktopBaseVersion(Option.getOrUndefined(root)).pipe(
174 Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)),212 Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)),
175 Effect.flatMap((metadata) => writeOutput(metadata, githubOutput)),213 Effect.flatMap((metadata) => writeNightlyReleaseOutput(metadata, githubOutput)),
176 ),214 ),
177).pipe(Command.withDescription("Resolve nightly release version metadata."));215).pipe(Command.withDescription("Resolve nightly release version metadata."));
178216
scripts/resolve-previous-release-tag.test.ts+86-3
...@@ -1,11 +1,17 @@...@@ -1,11 +1,17 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import * as ConfigProvider from "effect/ConfigProvider";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
3import * as PlatformError from "effect/PlatformError";5import * as PlatformError from "effect/PlatformError";
4import * as Sink from "effect/Sink";6import * as Sink from "effect/Sink";
5import * as Stream from "effect/Stream";7import * as Stream from "effect/Stream";
6import { ChildProcessSpawner } from "effect/unstable/process";8import { 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
10const encoder = new TextEncoder();16const encoder = new TextEncoder();
1117
...@@ -13,6 +19,8 @@ function mockHandle(options: {...@@ -13,6 +19,8 @@ function mockHandle(options: {
13 readonly exitCode: number;19 readonly exitCode: number;
14 readonly stdout?: string;20 readonly stdout?: string;
15 readonly stderr?: string;21 readonly stderr?: string;
22 readonly stdoutError?: PlatformError.PlatformError;
23 readonly stderrError?: PlatformError.PlatformError;
16}) {24}) {
17 return ChildProcessSpawner.makeHandle({25 return ChildProcessSpawner.makeHandle({
18 pid: ChildProcessSpawner.ProcessId(1),26 pid: ChildProcessSpawner.ProcessId(1),
...@@ -21,8 +29,12 @@ function mockHandle(options: {...@@ -21,8 +29,12 @@ function mockHandle(options: {
21 kill: () => Effect.void,29 kill: () => Effect.void,
22 unref: Effect.succeed(Effect.void),30 unref: Effect.succeed(Effect.void),
23 stdin: Sink.drain,31 stdin: Sink.drain,
24 stdout: Stream.make(encoder.encode(options.stdout ?? "")),32 stdout: options.stdoutError
25 stderr: Stream.make(encoder.encode(options.stderr ?? "")),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 ?? "")),
26 all: Stream.empty,38 all: Stream.empty,
27 getInputFd: () => Sink.drain,39 getInputFd: () => Sink.drain,
28 getOutputFd: () => Stream.empty,40 getOutputFd: () => Stream.empty,
...@@ -95,6 +107,43 @@ it.effect("preserves git tag spawn context and the exact platform cause", () =>...@@ -95,6 +107,43 @@ it.effect("preserves git tag spawn context and the exact platform cause", () =>
95 });107 });
96});108});
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
98it.effect("reports git tag non-zero exits without manufacturing a cause", () =>147it.effect("reports git tag non-zero exits without manufacturing a cause", () =>
99 Effect.gen(function* () {148 Effect.gen(function* () {
100 const error = yield* listGitTags("/repo").pipe(149 const error = yield* listGitTags("/repo").pipe(
...@@ -128,3 +177,37 @@ it.effect("reports git tag non-zero exits without manufacturing a cause", () =>...@@ -128,3 +177,37 @@ it.effect("reports git tag non-zero exits without manufacturing a cause", () =>
128 assert.notProperty(error, "stderr");177 assert.notProperty(error, "stderr");
129 }),178 }),
130);179);
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...@@ -26,9 +26,11 @@ export class InvalidReleaseTagError extends Schema.TaggedErrorClass<InvalidRelea
26 }26 }
27}27}
2828
29const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
30
29const releaseTagListProcessContext = {31const releaseTagListProcessContext = {
30 executable: Schema.Literal("git"),32 executable: Schema.Literal("git"),
31 argumentCount: Schema.Number,33 argumentCount: NonNegativeInt,
32 cwd: Schema.String,34 cwd: Schema.String,
33};35};
3436
...@@ -36,7 +38,7 @@ export class ReleaseTagListProcessError extends Schema.TaggedErrorClass<ReleaseT...@@ -36,7 +38,7 @@ export class ReleaseTagListProcessError extends Schema.TaggedErrorClass<ReleaseT
36 "ReleaseTagListProcessError",38 "ReleaseTagListProcessError",
37 {39 {
38 ...releaseTagListProcessContext,40 ...releaseTagListProcessContext,
39 operation: Schema.Literals(["spawn", "communicate", "wait-for-exit"]),41 operation: Schema.Literals(["spawn", "read-stdout", "read-stderr", "wait-for-exit"]),
40 cause: Schema.Defect(),42 cause: Schema.Defect(),
41 },43 },
42) {44) {
...@@ -50,8 +52,8 @@ export class ReleaseTagListProcessExitError extends Schema.TaggedErrorClass<Rele...@@ -50,8 +52,8 @@ export class ReleaseTagListProcessExitError extends Schema.TaggedErrorClass<Rele
50 {52 {
51 ...releaseTagListProcessContext,53 ...releaseTagListProcessContext,
52 exitCode: Schema.Number,54 exitCode: Schema.Number,
53 stdoutLength: Schema.Number,55 stdoutLength: NonNegativeInt,
54 stderrLength: Schema.Number,56 stderrLength: NonNegativeInt,
55 },57 },
56) {58) {
57 override get message(): string {59 override get message(): string {
...@@ -59,6 +61,29 @@ export class ReleaseTagListProcessExitError extends Schema.TaggedErrorClass<Rele...@@ -59,6 +61,29 @@ export class ReleaseTagListProcessExitError extends Schema.TaggedErrorClass<Rele
59 }61 }
60}62}
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
62interface StableVersion {87interface StableVersion {
63 readonly major: number;88 readonly major: number;
64 readonly minor: number;89 readonly minor: number;
...@@ -238,7 +263,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd...@@ -238,7 +263,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd
238 (cause) =>263 (cause) =>
239 new ReleaseTagListProcessError({264 new ReleaseTagListProcessError({
240 ...context,265 ...context,
241 operation: "communicate",266 operation: "read-stdout",
242 cause,267 cause,
243 }),268 }),
244 ),269 ),
...@@ -248,7 +273,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd...@@ -248,7 +273,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd
248 (cause) =>273 (cause) =>
249 new ReleaseTagListProcessError({274 new ReleaseTagListProcessError({
250 ...context,275 ...context,
251 operation: "communicate",276 operation: "read-stderr",
252 cause,277 cause,
253 }),278 }),
254 ),279 ),
...@@ -280,7 +305,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd...@@ -280,7 +305,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd
280 return stdout.split(/\r?\n/).map(String.trim).filter(String.isNonEmpty);305 return stdout.split(/\r?\n/).map(String.trim).filter(String.isNonEmpty);
281});306});
282307
283const writeOutput = Effect.fn("writeOutput")(function* (308export const writePreviousReleaseTagOutput = Effect.fn("writePreviousReleaseTagOutput")(function* (
284 previousTag: string | undefined,309 previousTag: string | undefined,
285 writeGithubOutput: boolean,310 writeGithubOutput: boolean,
286) {311) {
...@@ -288,8 +313,23 @@ const writeOutput = Effect.fn("writeOutput")(function* (...@@ -288,8 +313,23 @@ const writeOutput = Effect.fn("writeOutput")(function* (
288313
289 if (writeGithubOutput) {314 if (writeGithubOutput) {
290 const fs = yield* FileSystem.FileSystem;315 const fs = yield* FileSystem.FileSystem;
291 const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT");316 const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe(
292 yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" });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 );
293 return;333 return;
294 }334 }
295335
...@@ -313,7 +353,7 @@ const command = Command.make(...@@ -313,7 +353,7 @@ const command = Command.make(
313 ({ channel, currentTag, githubOutput }) =>353 ({ channel, currentTag, githubOutput }) =>
314 listGitTags().pipe(354 listGitTags().pipe(
315 Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)),355 Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)),
316 Effect.flatMap((previousTag) => writeOutput(previousTag, githubOutput)),356 Effect.flatMap((previousTag) => writePreviousReleaseTagOutput(previousTag, githubOutput)),
317 ),357 ),
318).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series."));358).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series."));
319359