Unified · Split
| @@ -1,7 +1,12 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 1 | 2 | import { assert, it } from "@effect/vitest"; |
| 2 | 3 | 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"; | |
| 3 | 7 | |
| 4 | 8 | import { |
| 9 | + readDesktopBaseVersion, | |
| 5 | 10 | resolveNightlyBaseVersion, |
| 6 | 11 | resolveNightlyReleaseMetadata, |
| 7 | 12 | resolveNightlyTargetVersion, |
| @@ -43,3 +48,49 @@ it("derives nightly metadata including the short commit sha in the release name" · 35 unmodified lines | ||
| 43 | 48 | }, |
| 44 | 49 | ); |
| 45 | 50 | }); |
| 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 | +}); | |
| @@ -40,6 +40,19 @@ export class InvalidDesktopPackageVersionError extends Schema.TaggedErrorClass<I · 39 unmodified lines | ||
| 40 | 40 | } |
| 41 | 41 | } |
| 42 | 42 | |
| 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 | + | |
| 43 | 56 | const RepoRoot = Effect.service(Path.Path).pipe( |
| 44 | 57 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), |
| 45 | 58 | ); |
| @@ -77,16 +90,33 @@ export const resolveNightlyReleaseMetadata = ( · 31 unmodified lines | ||
| 77 | 90 | }; |
| 78 | 91 | }; |
| 79 | 92 | |
| 80 | −const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* ( | |
| 93 | +export const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* ( | |
| 81 | 94 | rootDir: string | undefined, |
| 82 | 95 | ) { |
| 83 | 96 | const fs = yield* FileSystem.FileSystem; |
| 84 | 97 | const path = yield* Path.Path; |
| 85 | 98 | const workspaceRoot = rootDir ? path.resolve(rootDir) : yield* RepoRoot; |
| 86 | 99 | 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 | + ); | |
| 90 | 120 | return yield* resolveNightlyTargetVersion(packageJson.version); |
| 91 | 121 | }); |
| 92 | 122 | |
| @@ -1,7 +1,33 @@ | ||
| 1 | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | 2 | 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"; | |
| 3 | 7 | |
| 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 | +} | |
| 5 | 31 | |
| 6 | 32 | it.effect("selects the latest earlier stable tag and ignores nightlies", () => |
| 7 | 33 | Effect.gen(function* () { |
| @@ -37,3 +63,68 @@ it.effect("reports the invalid tag with its release channel", () => · 29 unmodified lines | ||
| 37 | 63 | assert.equal(error.message, "Invalid nightly release tag 'v1.2.0'."); |
| 38 | 64 | }), |
| 39 | 65 | ); |
| 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 | +); | |
| @@ -2,7 +2,6 @@ · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 4 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | −import * as Array from "effect/Array"; | |
| 6 | 5 | import * as Config from "effect/Config"; |
| 7 | 6 | import * as Effect from "effect/Effect"; |
| 8 | 7 | import * as FileSystem from "effect/FileSystem"; |
| @@ -27,6 +26,39 @@ export class InvalidReleaseTagError extends Schema.TaggedErrorClass<InvalidRelea · 18 unmodified lines | ||
| 27 | 26 | } |
| 28 | 27 | } |
| 29 | 28 | |
| 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 | + | |
| 30 | 62 | interface StableVersion { |
| 31 | 63 | readonly major: number; |
| 32 | 64 | readonly minor: number; |
| @@ -172,20 +204,80 @@ export const resolvePreviousReleaseTag = ( · 139 unmodified lines | ||
| 172 | 204 | return candidates[0]?.tag; |
| 173 | 205 | }); |
| 174 | 206 | |
| 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( | |
| 179 | 209 | Stream.decodeText(), |
| 180 | 210 | Stream.runFold( |
| 181 | 211 | () => "", |
| 182 | 212 | (acc, chunk) => acc + chunk, |
| 183 | 213 | ), |
| 184 | − Effect.map(String.split(/\r?\n/)), | |
| 185 | − Effect.map(Array.map(String.trim)), | |
| 186 | − Effect.map(Array.filter(String.isNonEmpty)), | |
| 187 | 214 | ); |
| 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); | |
| 189 | 281 | }); |
| 190 | 282 | |
| 191 | 283 | const writeOutput = Effect.fn("writeOutput")(function* ( |