| 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 | 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" |
| 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 | 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 = ( |
| 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 |
scripts/resolve-previous-release-tag.test.ts+92-1| ... | ... | @@ -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", () => |
| 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 | ); |
scripts/resolve-previous-release-tag.ts+101-9| ... | ... | @@ -2,7 +2,6 @@ |
| 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 |
| 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 = ( |
| 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* ( |