| author | |
| committer | |
| log | cdd03591efb09baf06299e3764ddbd01c313a936 |
| tree | 989d03d046d25d06e745551e0854a19016842609 |
| parent | 2fbb0565d7976cbb338f5593a566ff53c6165419 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>4 files changed, 278 insertions(+), 14 deletions(-)
scripts/resolve-nightly-release.test.ts+51| ... | @@ -1,7 +1,12 @@ | ... | @@ -1,7 +1,12 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 1 | import { assert, it } from "@effect/vitest"; | 2 | import { assert, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 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 | import { | 8 | import { |
| 9 | readDesktopBaseVersion, | ||
| 5 | resolveNightlyBaseVersion, | 10 | resolveNightlyBaseVersion, |
| 6 | resolveNightlyReleaseMetadata, | 11 | resolveNightlyReleaseMetadata, |
| 7 | resolveNightlyTargetVersion, | 12 | resolveNightlyTargetVersion, |
| ... | @@ -43,3 +48,49 @@ it("derives nightly metadata including the short commit sha in the release name" | ... | @@ -43,3 +48,49 @@ it("derives nightly metadata including the short commit sha in the release name" |
| 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 | }); |
scripts/resolve-nightly-release.ts+34-4| ... | @@ -40,6 +40,19 @@ export class InvalidDesktopPackageVersionError extends Schema.TaggedErrorClass<I | ... | @@ -40,6 +40,19 @@ export class InvalidDesktopPackageVersionError extends Schema.TaggedErrorClass<I |
| 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 | const RepoRoot = Effect.service(Path.Path).pipe( | 56 | const RepoRoot = Effect.service(Path.Path).pipe( |
| 44 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), | 57 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), |
| 45 | ); | 58 | ); |
| ... | @@ -77,16 +90,33 @@ export const resolveNightlyReleaseMetadata = ( | ... | @@ -77,16 +90,33 @@ export const resolveNightlyReleaseMetadata = ( |
| 77 | }; | 90 | }; |
| 78 | }; | 91 | }; |
| 79 | 92 | ||
| 80 | const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* ( | 93 | export const readDesktopBaseVersion = Effect.fn("readDesktopBaseVersion")(function* ( |
| 81 | rootDir: string | undefined, | 94 | rootDir: string | undefined, |
| 82 | ) { | 95 | ) { |
| 83 | const fs = yield* FileSystem.FileSystem; | 96 | const fs = yield* FileSystem.FileSystem; |
| 84 | const path = yield* Path.Path; | 97 | const path = yield* Path.Path; |
| 85 | const workspaceRoot = rootDir ? path.resolve(rootDir) : yield* RepoRoot; | 98 | const workspaceRoot = rootDir ? path.resolve(rootDir) : yield* RepoRoot; |
| 86 | const packageJsonPath = path.join(workspaceRoot, "apps/desktop/package.json"); | 99 | const packageJsonPath = path.join(workspaceRoot, "apps/desktop/package.json"); |
| 87 | const packageJson = yield* fs | 100 | const packageJsonSource = yield* fs.readFileString(packageJsonPath).pipe( |
| 88 | .readFileString(packageJsonPath) | 101 | Effect.mapError( |
| 89 | .pipe(Effect.flatMap(decodeDesktopPackageJson)); | 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 | return yield* resolveNightlyTargetVersion(packageJson.version); | 120 | return yield* resolveNightlyTargetVersion(packageJson.version); |
| 91 | }); | 121 | }); |
| 92 | 122 |
scripts/resolve-previous-release-tag.test.ts+92-1| ... | @@ -1,7 +1,33 @@ | ... | @@ -1,7 +1,33 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 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 | it.effect("selects the latest earlier stable tag and ignores nightlies", () => | 32 | it.effect("selects the latest earlier stable tag and ignores nightlies", () => |
| 7 | Effect.gen(function* () { | 33 | Effect.gen(function* () { |
| ... | @@ -37,3 +63,68 @@ it.effect("reports the invalid tag with its release channel", () => | ... | @@ -37,3 +63,68 @@ it.effect("reports the invalid tag with its release channel", () => |
| 37 | assert.equal(error.message, "Invalid nightly release tag 'v1.2.0'."); | 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 | ); |
scripts/resolve-previous-release-tag.ts+101-9| ... | @@ -2,7 +2,6 @@ | ... | @@ -2,7 +2,6 @@ |
| 2 | 2 | ||
| 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import * as Array from "effect/Array"; | ||
| 6 | import * as Config from "effect/Config"; | 5 | import * as Config from "effect/Config"; |
| 7 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; | 7 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -27,6 +26,39 @@ export class InvalidReleaseTagError extends Schema.TaggedErrorClass<InvalidRelea | ... | @@ -27,6 +26,39 @@ export class InvalidReleaseTagError extends Schema.TaggedErrorClass<InvalidRelea |
| 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 | interface StableVersion { | 62 | interface StableVersion { |
| 31 | readonly major: number; | 63 | readonly major: number; |
| 32 | readonly minor: number; | 64 | readonly minor: number; |
| ... | @@ -172,20 +204,80 @@ export const resolvePreviousReleaseTag = ( | ... | @@ -172,20 +204,80 @@ export const resolvePreviousReleaseTag = ( |
| 172 | return candidates[0]?.tag; | 204 | return candidates[0]?.tag; |
| 173 | }); | 205 | }); |
| 174 | 206 | ||
| 175 | const listGitTags = Effect.fn("listGitTags")(function* () { | 207 | const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> => |
| 176 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 208 | stream.pipe( |
| 177 | const child = yield* spawner.spawn(ChildProcess.make("git", ["tag", "--list"])); | ||
| 178 | const tags = yield* child.stdout.pipe( | ||
| 179 | Stream.decodeText(), | 209 | Stream.decodeText(), |
| 180 | Stream.runFold( | 210 | Stream.runFold( |
| 181 | () => "", | 211 | () => "", |
| 182 | (acc, chunk) => acc + chunk, | 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 | const writeOutput = Effect.fn("writeOutput")(function* ( | 283 | const writeOutput = Effect.fn("writeOutput")(function* ( |