diff --git a/scripts/resolve-nightly-release.test.ts b/scripts/resolve-nightly-release.test.ts index 18d6a2a2ed7..dda1e7081be 100644 --- a/scripts/resolve-nightly-release.test.ts +++ b/scripts/resolve-nightly-release.test.ts @@ -1,5 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Config from "effect/Config"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -10,6 +12,7 @@ import { resolveNightlyBaseVersion, resolveNightlyReleaseMetadata, resolveNightlyTargetVersion, + writeNightlyReleaseOutput, } from "./resolve-nightly-release.ts"; it("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" ); }); +it.effect("preserves the GITHUB_OUTPUT configuration cause", () => { + const metadata = resolveNightlyReleaseMetadata("1.2.4", "20260620", 42, "abcdef1234567890"); + const configCause = new ConfigProvider.SourceError({ message: "environment unavailable" }); + + return Effect.gen(function* () { + const configError = yield* writeNightlyReleaseOutput(metadata, true).pipe( + Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.make(() => Effect.fail(configCause)), + ), + Effect.flip, + ); + + if (configError._tag !== "NightlyReleaseGitHubOutputConfigError") { + return assert.fail(`Unexpected error: ${configError._tag}`); + } + assert.instanceOf(configError.cause, Config.ConfigError); + assert.strictEqual(configError.cause.cause, configCause); + assert.notInclude(configError.message, configCause.message); + }); +}); + it.layer(NodeServices.layer)("readDesktopBaseVersion", (it) => { it.effect("preserves desktop package read context and its platform cause", () => Effect.gen(function* () { diff --git a/scripts/resolve-nightly-release.ts b/scripts/resolve-nightly-release.ts index adad8c6f4f8..5b42f931d7b 100644 --- a/scripts/resolve-nightly-release.ts +++ b/scripts/resolve-nightly-release.ts @@ -11,7 +11,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { Command, Flag } from "effect/unstable/cli"; -interface NightlyReleaseMetadata { +export interface NightlyReleaseMetadata { readonly baseVersion: string; readonly version: string; readonly tag: string; @@ -53,6 +53,29 @@ export class NightlyReleaseDesktopPackageError extends Schema.TaggedErrorClass()( + "NightlyReleaseGitHubOutputConfigError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve the GITHUB_OUTPUT path for nightly release metadata."; + } +} + +export class NightlyReleaseGitHubOutputAppendError extends Schema.TaggedErrorClass()( + "NightlyReleaseGitHubOutputAppendError", + { + outputPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to append nightly release metadata to ${this.outputPath}.`; + } +} + const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), ); @@ -120,7 +143,7 @@ export const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(functi return yield* resolveNightlyTargetVersion(packageJson.version); }); -const writeOutput = Effect.fn("writeOutput")(function* ( +export const writeNightlyReleaseOutput = Effect.fn("writeNightlyReleaseOutput")(function* ( metadata: NightlyReleaseMetadata, writeGithubOutput: boolean, ) { @@ -135,9 +158,24 @@ const writeOutput = Effect.fn("writeOutput")(function* ( ] as const; if (writeGithubOutput) { - const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe( + Effect.mapError( + (cause) => + new NightlyReleaseGitHubOutputConfigError({ + cause, + }), + ), + ); const serialized = entries.map(([key, value]) => `${key}=${value}\n`).join(""); - yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" }); + yield* fs.writeFileString(githubOutputPath, serialized, { flag: "a" }).pipe( + Effect.mapError( + (cause) => + new NightlyReleaseGitHubOutputAppendError({ + outputPath: githubOutputPath, + cause, + }), + ), + ); } else { for (const [key, value] of entries) { yield* Console.log(`${key}=${value}`); @@ -172,7 +210,7 @@ const command = Command.make( ({ date, runNumber, sha, githubOutput, root }) => readDesktopBaseVersion(Option.getOrUndefined(root)).pipe( Effect.map((baseVersion) => resolveNightlyReleaseMetadata(baseVersion, date, runNumber, sha)), - Effect.flatMap((metadata) => writeOutput(metadata, githubOutput)), + Effect.flatMap((metadata) => writeNightlyReleaseOutput(metadata, githubOutput)), ), ).pipe(Command.withDescription("Resolve nightly release version metadata.")); diff --git a/scripts/resolve-previous-release-tag.test.ts b/scripts/resolve-previous-release-tag.test.ts index a9c4832c26a..5fe06d2af54 100644 --- a/scripts/resolve-previous-release-tag.test.ts +++ b/scripts/resolve-previous-release-tag.test.ts @@ -1,11 +1,17 @@ import { assert, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { listGitTags, resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts"; +import { + listGitTags, + resolvePreviousReleaseTag, + writePreviousReleaseTagOutput, +} from "./resolve-previous-release-tag.ts"; const encoder = new TextEncoder(); @@ -13,6 +19,8 @@ function mockHandle(options: { readonly exitCode: number; readonly stdout?: string; readonly stderr?: string; + readonly stdoutError?: PlatformError.PlatformError; + readonly stderrError?: PlatformError.PlatformError; }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -21,8 +29,12 @@ function mockHandle(options: { kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.make(encoder.encode(options.stdout ?? "")), - stderr: Stream.make(encoder.encode(options.stderr ?? "")), + stdout: options.stdoutError + ? Stream.fail(options.stdoutError) + : Stream.make(encoder.encode(options.stdout ?? "")), + stderr: options.stderrError + ? Stream.fail(options.stderrError) + : Stream.make(encoder.encode(options.stderr ?? "")), all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, @@ -95,6 +107,43 @@ it.effect("preserves git tag spawn context and the exact platform cause", () => }); }); +it.effect("distinguishes stdout and stderr read failures", () => + Effect.gen(function* () { + for (const [stream, operation] of [ + ["stdout", "read-stdout"], + ["stderr", "read-stderr"], + ] as const) { + const cause = PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: stream, + description: `${stream} unavailable`, + }); + const error = yield* listGitTags("/repo").pipe( + Effect.scoped, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + exitCode: 0, + ...(stream === "stdout" ? { stdoutError: cause } : { stderrError: cause }), + }), + ), + ), + ), + Effect.flip, + ); + + if (error._tag !== "ReleaseTagListProcessError") { + return assert.fail(`Unexpected error: ${error._tag}`); + } + assert.equal(error.operation, operation); + assert.strictEqual(error.cause, cause); + } + }), +); + it.effect("reports git tag non-zero exits without manufacturing a cause", () => Effect.gen(function* () { const error = yield* listGitTags("/repo").pipe( @@ -128,3 +177,37 @@ it.effect("reports git tag non-zero exits without manufacturing a cause", () => assert.notProperty(error, "stderr"); }), ); + +it.effect("preserves the GITHUB_OUTPUT append path and exact cause", () => { + const outputPath = "/tmp/previous-tag-github-output"; + const appendCause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "writeFileString", + pathOrDescriptor: outputPath, + }); + + return Effect.gen(function* () { + const appendError = yield* writePreviousReleaseTagOutput("v1.2.3", true).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + writeFileString: () => Effect.fail(appendCause), + }), + ), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnv({ env: { GITHUB_OUTPUT: outputPath } }), + ), + Effect.flip, + ); + + if (appendError._tag !== "PreviousReleaseTagGitHubOutputAppendError") { + return assert.fail(`Unexpected error: ${appendError._tag}`); + } + assert.equal(appendError.outputPath, outputPath); + assert.strictEqual(appendError.cause, appendCause); + assert.notProperty(appendError, "contents"); + assert.notInclude(appendError.message, appendCause.message); + }); +}); diff --git a/scripts/resolve-previous-release-tag.ts b/scripts/resolve-previous-release-tag.ts index 83b6e65d1e1..7acc6f456b8 100644 --- a/scripts/resolve-previous-release-tag.ts +++ b/scripts/resolve-previous-release-tag.ts @@ -26,9 +26,11 @@ export class InvalidReleaseTagError extends Schema.TaggedErrorClass()( + "PreviousReleaseTagGitHubOutputConfigError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve the GITHUB_OUTPUT path for the previous release tag."; + } +} + +export class PreviousReleaseTagGitHubOutputAppendError extends Schema.TaggedErrorClass()( + "PreviousReleaseTagGitHubOutputAppendError", + { + outputPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to append the previous release tag to ${this.outputPath}.`; + } +} + interface StableVersion { readonly major: number; readonly minor: number; @@ -238,7 +263,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd (cause) => new ReleaseTagListProcessError({ ...context, - operation: "communicate", + operation: "read-stdout", cause, }), ), @@ -248,7 +273,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd (cause) => new ReleaseTagListProcessError({ ...context, - operation: "communicate", + operation: "read-stderr", cause, }), ), @@ -280,7 +305,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd return stdout.split(/\r?\n/).map(String.trim).filter(String.isNonEmpty); }); -const writeOutput = Effect.fn("writeOutput")(function* ( +export const writePreviousReleaseTagOutput = Effect.fn("writePreviousReleaseTagOutput")(function* ( previousTag: string | undefined, writeGithubOutput: boolean, ) { @@ -288,8 +313,23 @@ const writeOutput = Effect.fn("writeOutput")(function* ( if (writeGithubOutput) { const fs = yield* FileSystem.FileSystem; - const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT"); - yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" }); + const githubOutputPath = yield* Config.nonEmptyString("GITHUB_OUTPUT").pipe( + Effect.mapError( + (cause) => + new PreviousReleaseTagGitHubOutputConfigError({ + cause, + }), + ), + ); + yield* fs.writeFileString(githubOutputPath, entry, { flag: "a" }).pipe( + Effect.mapError( + (cause) => + new PreviousReleaseTagGitHubOutputAppendError({ + outputPath: githubOutputPath, + cause, + }), + ), + ); return; } @@ -313,7 +353,7 @@ const command = Command.make( ({ channel, currentTag, githubOutput }) => listGitTags().pipe( Effect.flatMap((tags) => resolvePreviousReleaseTag(channel, currentTag, tags)), - Effect.flatMap((previousTag) => writeOutput(previousTag, githubOutput)), + Effect.flatMap((previousTag) => writePreviousReleaseTagOutput(previousTag, githubOutput)), ), ).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series."));