| author | |
| committer | |
| log | e4a84a348e59d59e03bca15baa627aed99aec790 |
| tree | bf455396c10034ff8daca6c3bbb3c1a65a447a6b |
| parent | 61e6d89d69240120dd08fae72459e5fd7e7a2908 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { assert, it } from "@effect/vitest"; |
| 3 | import * as Config from "effect/Config"; | |
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; | |
| 3 | 5 | import * as Effect from "effect/Effect"; |
| 4 | 6 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 7 | import * as Path from "effect/Path"; |
| ... | ... | @@ -10,6 +12,7 @@ import { |
| 10 | 12 | resolveNightlyBaseVersion, |
| 11 | 13 | resolveNightlyReleaseMetadata, |
| 12 | 14 | resolveNightlyTargetVersion, |
| 15 | writeNightlyReleaseOutput, | |
| 13 | 16 | } from "./resolve-nightly-release.ts"; |
| 14 | 17 | |
| 15 | 18 | 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" |
| 49 | 52 | ); |
| 50 | 53 | }); |
| 51 | 54 | |
| 55 | it.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 | ||
| 52 | 78 | it.layer(NodeServices.layer)("readDesktopBaseVersion", (it) => { |
| 53 | 79 | it.effect("preserves desktop package read context and its platform cause", () => |
| 54 | 80 | Effect.gen(function* () { |
scripts/resolve-nightly-release.ts+43-5| ... | ... | @@ -11,7 +11,7 @@ import * as Path from "effect/Path"; |
| 11 | 11 | import * as Schema from "effect/Schema"; |
| 12 | 12 | import { Command, Flag } from "effect/unstable/cli"; |
| 13 | 13 | |
| 14 | interface NightlyReleaseMetadata { | |
| 14 | export interface NightlyReleaseMetadata { | |
| 15 | 15 | readonly baseVersion: string; |
| 16 | 16 | readonly version: string; |
| 17 | 17 | readonly tag: string; |
| ... | ... | @@ -53,6 +53,29 @@ export class NightlyReleaseDesktopPackageError extends Schema.TaggedErrorClass<N |
| 53 | 53 | } |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | export 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 | ||
| 67 | export 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 | ||
| 56 | 79 | const RepoRoot = Effect.service(Path.Path).pipe( |
| 57 | 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 | 143 | return yield* resolveNightlyTargetVersion(packageJson.version); |
| 121 | 144 | }); |
| 122 | 145 | |
| 123 | const writeOutput = Effect.fn("writeOutput")(function* ( | |
| 146 | export const writeNightlyReleaseOutput = Effect.fn("writeNightlyReleaseOutput")(function* ( | |
| 124 | 147 | metadata: NightlyReleaseMetadata, |
| 125 | 148 | writeGithubOutput: boolean, |
| 126 | 149 | ) { |
| ... | ... | @@ -135,9 +158,24 @@ const writeOutput = Effect.fn("writeOutput")(function* ( |
| 135 | 158 | ] as const; |
| 136 | 159 | |
| 137 | 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 | 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 | 179 | } else { |
| 142 | 180 | for (const [key, value] of entries) { |
| 143 | 181 | yield* Console.log(`${key}=${value}`); |
| ... | ... | @@ -172,7 +210,7 @@ const command = Command.make( |
| 172 | 210 | ({ date, runNumber, sha, githubOutput, root }) => |
| 173 | 211 | readDesktopBaseVersion(Option.getOrUndefined(root)).pipe( |
| 174 | 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 | 215 | ).pipe(Command.withDescription("Resolve nightly release version metadata.")); |
| 178 | 216 |
scripts/resolve-previous-release-tag.test.ts+86-3| ... | ... | @@ -1,11 +1,17 @@ |
| 1 | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import * as ConfigProvider from "effect/ConfigProvider"; | |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 3 | 5 | import * as PlatformError from "effect/PlatformError"; |
| 4 | 6 | import * as Sink from "effect/Sink"; |
| 5 | 7 | import * as Stream from "effect/Stream"; |
| 6 | 8 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 7 | 9 | |
| 8 | import { listGitTags, resolvePreviousReleaseTag } from "./resolve-previous-release-tag.ts"; | |
| 10 | import { | |
| 11 | listGitTags, | |
| 12 | resolvePreviousReleaseTag, | |
| 13 | writePreviousReleaseTagOutput, | |
| 14 | } from "./resolve-previous-release-tag.ts"; | |
| 9 | 15 | |
| 10 | 16 | const encoder = new TextEncoder(); |
| 11 | 17 | |
| ... | ... | @@ -13,6 +19,8 @@ function mockHandle(options: { |
| 13 | 19 | readonly exitCode: number; |
| 14 | 20 | readonly stdout?: string; |
| 15 | 21 | readonly stderr?: string; |
| 22 | readonly stdoutError?: PlatformError.PlatformError; | |
| 23 | readonly stderrError?: PlatformError.PlatformError; | |
| 16 | 24 | }) { |
| 17 | 25 | return ChildProcessSpawner.makeHandle({ |
| 18 | 26 | pid: ChildProcessSpawner.ProcessId(1), |
| ... | ... | @@ -21,8 +29,12 @@ function mockHandle(options: { |
| 21 | 29 | kill: () => Effect.void, |
| 22 | 30 | unref: Effect.succeed(Effect.void), |
| 23 | 31 | 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 ?? "")), | |
| 26 | 38 | all: Stream.empty, |
| 27 | 39 | getInputFd: () => Sink.drain, |
| 28 | 40 | getOutputFd: () => Stream.empty, |
| ... | ... | @@ -95,6 +107,43 @@ it.effect("preserves git tag spawn context and the exact platform cause", () => |
| 95 | 107 | }); |
| 96 | 108 | }); |
| 97 | 109 | |
| 110 | it.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 | ||
| 98 | 147 | it.effect("reports git tag non-zero exits without manufacturing a cause", () => |
| 99 | 148 | Effect.gen(function* () { |
| 100 | 149 | const error = yield* listGitTags("/repo").pipe( |
| ... | ... | @@ -128,3 +177,37 @@ it.effect("reports git tag non-zero exits without manufacturing a cause", () => |
| 128 | 177 | assert.notProperty(error, "stderr"); |
| 129 | 178 | }), |
| 130 | 179 | ); |
| 180 | ||
| 181 | it.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 | 26 | } |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); | |
| 30 | ||
| 29 | 31 | const releaseTagListProcessContext = { |
| 30 | 32 | executable: Schema.Literal("git"), |
| 31 | argumentCount: Schema.Number, | |
| 33 | argumentCount: NonNegativeInt, | |
| 32 | 34 | cwd: Schema.String, |
| 33 | 35 | }; |
| 34 | 36 | |
| ... | ... | @@ -36,7 +38,7 @@ export class ReleaseTagListProcessError extends Schema.TaggedErrorClass<ReleaseT |
| 36 | 38 | "ReleaseTagListProcessError", |
| 37 | 39 | { |
| 38 | 40 | ...releaseTagListProcessContext, |
| 39 | operation: Schema.Literals(["spawn", "communicate", "wait-for-exit"]), | |
| 41 | operation: Schema.Literals(["spawn", "read-stdout", "read-stderr", "wait-for-exit"]), | |
| 40 | 42 | cause: Schema.Defect(), |
| 41 | 43 | }, |
| 42 | 44 | ) { |
| ... | ... | @@ -50,8 +52,8 @@ export class ReleaseTagListProcessExitError extends Schema.TaggedErrorClass<Rele |
| 50 | 52 | { |
| 51 | 53 | ...releaseTagListProcessContext, |
| 52 | 54 | exitCode: Schema.Number, |
| 53 | stdoutLength: Schema.Number, | |
| 54 | stderrLength: Schema.Number, | |
| 55 | stdoutLength: NonNegativeInt, | |
| 56 | stderrLength: NonNegativeInt, | |
| 55 | 57 | }, |
| 56 | 58 | ) { |
| 57 | 59 | override get message(): string { |
| ... | ... | @@ -59,6 +61,29 @@ export class ReleaseTagListProcessExitError extends Schema.TaggedErrorClass<Rele |
| 59 | 61 | } |
| 60 | 62 | } |
| 61 | 63 | |
| 64 | export 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 | ||
| 75 | export 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 | ||
| 62 | 87 | interface StableVersion { |
| 63 | 88 | readonly major: number; |
| 64 | 89 | readonly minor: number; |
| ... | ... | @@ -238,7 +263,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd |
| 238 | 263 | (cause) => |
| 239 | 264 | new ReleaseTagListProcessError({ |
| 240 | 265 | ...context, |
| 241 | operation: "communicate", | |
| 266 | operation: "read-stdout", | |
| 242 | 267 | cause, |
| 243 | 268 | }), |
| 244 | 269 | ), |
| ... | ... | @@ -248,7 +273,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd |
| 248 | 273 | (cause) => |
| 249 | 274 | new ReleaseTagListProcessError({ |
| 250 | 275 | ...context, |
| 251 | operation: "communicate", | |
| 276 | operation: "read-stderr", | |
| 252 | 277 | cause, |
| 253 | 278 | }), |
| 254 | 279 | ), |
| ... | ... | @@ -280,7 +305,7 @@ export const listGitTags = Effect.fn("listGitTags")(function* (cwd = process.cwd |
| 280 | 305 | return stdout.split(/\r?\n/).map(String.trim).filter(String.isNonEmpty); |
| 281 | 306 | }); |
| 282 | 307 | |
| 283 | const writeOutput = Effect.fn("writeOutput")(function* ( | |
| 308 | export const writePreviousReleaseTagOutput = Effect.fn("writePreviousReleaseTagOutput")(function* ( | |
| 284 | 309 | previousTag: string | undefined, |
| 285 | 310 | writeGithubOutput: boolean, |
| 286 | 311 | ) { |
| ... | ... | @@ -288,8 +313,23 @@ const writeOutput = Effect.fn("writeOutput")(function* ( |
| 288 | 313 | |
| 289 | 314 | if (writeGithubOutput) { |
| 290 | 315 | 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 | ); | |
| 293 | 333 | return; |
| 294 | 334 | } |
| 295 | 335 | |
| ... | ... | @@ -313,7 +353,7 @@ const command = Command.make( |
| 313 | 353 | ({ channel, currentTag, githubOutput }) => |
| 314 | 354 | listGitTags().pipe( |
| 315 | 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 | 358 | ).pipe(Command.withDescription("Resolve the previous release tag for a stable or nightly series.")); |
| 319 | 359 |