diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index d7582143a6..6964884c1d 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -9,9 +9,7 @@ "!packages/api/**", "!packages/config/**", "!packages/stack/**", - "!apps/cli/src/**", - "!apps/cli/tests/**", - "!apps/cli/vitest.config.ts", + "!apps/cli/**", // Last match wins across the whole list: keep bare re-exclusions after every // `!` entry that would otherwise re-include them. "apps/cli/src/shared/cli/bin.ts", diff --git a/apps/cli/scripts/apply-release-notes.ts b/apps/cli/scripts/apply-release-notes.ts index 9027208231..c63c90effa 100644 --- a/apps/cli/scripts/apply-release-notes.ts +++ b/apps/cli/scripts/apply-release-notes.ts @@ -5,12 +5,19 @@ // // Usage: // bun apps/cli/scripts/apply-release-notes.ts --tag v2.101.0 +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { $ } from "bun"; -import { existsSync } from "node:fs"; -import path from "node:path"; +import { Console, Data, Effect, FileSystem, Layer, Path } from "effect"; import process from "node:process"; import { parseArgs } from "node:util"; +class ShellCommandFailed extends Data.TaggedError("ShellCommandFailed")<{ + readonly cause: unknown; +}> {} + +const shell = (command: () => Promise) => + Effect.tryPromise({ try: command, catch: (cause) => new ShellCommandFailed({ cause }) }); + const { values } = parseArgs({ options: { tag: { type: "string" }, @@ -18,20 +25,36 @@ const { values } = parseArgs({ strict: true, }); -const tag = values.tag; -if (!tag) { - console.error("--tag is required (e.g. --tag v2.101.0)"); - process.exit(2); -} -const version = tag.replace(/^v/, ""); +const main = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const tag = values.tag; + if (!tag) { + yield* Console.error("--tag is required (e.g. --tag v2.101.0)"); + return yield* Effect.sync(() => process.exit(2)); + } + const version = tag.replace(/^v/, ""); + + const repoRoot = (yield* shell(() => $`git rev-parse --show-toplevel`.text())).trim(); + const notesPath = path.join(repoRoot, "release-notes", `v${version}.md`); + if (!(yield* fs.exists(notesPath).pipe(Effect.orElseSucceed(() => false)))) { + yield* Console.error(`No notes file at ${path.relative(repoRoot, notesPath)}`); + return yield* Effect.sync(() => process.exit(1)); + } + + yield* Console.error(`==> Updating GitHub Release body for ${tag}`); + yield* shell(() => $`gh release edit ${tag} --notes-file ${notesPath}`.cwd(repoRoot)); + yield* Console.error(`==> Done`); +}); -const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim(); -const notesPath = path.join(repoRoot, "release-notes", `v${version}.md`); -if (!existsSync(notesPath)) { - console.error(`No notes file at ${path.relative(repoRoot, notesPath)}`); - process.exit(1); +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + return error.cause !== undefined ? error.cause : new Error(error.message); } -console.error(`==> Updating GitHub Release body for ${tag}`); -await $`gh release edit ${tag} --notes-file ${notesPath}`.cwd(repoRoot); -console.error(`==> Done`); +await Effect.runPromise( + main.pipe( + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + Effect.catch((error) => Effect.die(thrownValue(error))), + ), +); diff --git a/apps/cli/scripts/backfill-release-notes.ts b/apps/cli/scripts/backfill-release-notes.ts index e578450a53..e5b81aed56 100644 --- a/apps/cli/scripts/backfill-release-notes.ts +++ b/apps/cli/scripts/backfill-release-notes.ts @@ -18,15 +18,39 @@ // --tag Required. Release tag to refresh (e.g. v2.99.0-beta.1). // --apply Update the GitHub Release body via `gh release edit`. // Without it, raw markdown notes are printed to stdout. +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { $ } from "bun"; -import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { Console, Data, Effect, FileSystem, Layer, Path, Schema } from "effect"; import process from "node:process"; import { parseArgs } from "node:util"; import semanticRelease from "semantic-release"; import { channelForTag, releaseBranchForTag } from "./release-channels.ts"; +class JsonParseFailed extends Data.TaggedError("JsonParseFailed")<{ readonly cause: unknown }> {} + +class ShellCommandFailed extends Data.TaggedError("ShellCommandFailed")<{ + readonly cause: unknown; +}> {} + +class SemanticReleaseFailed extends Data.TaggedError("SemanticReleaseFailed")<{ + readonly cause: unknown; +}> {} + +class LocalReleasePluginOutsideCli extends Data.TaggedError("LocalReleasePluginOutsideCli")<{ + readonly message: string; +}> {} + +const shell = (command: () => Promise) => + Effect.tryPromise({ try: command, catch: (cause) => new ShellCommandFailed({ cause }) }); + +const utf8 = new TextDecoder("utf-8", { ignoreBOM: true }); +/* Native `JSON.parse`, so malformed JSON fails with its own `SyntaxError`. */ +const parseJson = (text: string) => JSON.parse(text); +const decodeJson = (text: string) => + Effect.try({ try: () => parseJson(text), catch: (cause) => new JsonParseFailed({ cause }) }); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const encodePackageJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 })); + const { values } = parseArgs({ options: { tag: { type: "string" }, @@ -35,232 +59,287 @@ const { values } = parseArgs({ strict: true, }); -const tag = values.tag; -if (!tag) { - console.error("--tag is required (e.g. --tag v2.99.0-beta.1)"); - process.exit(2); -} -const apply = values.apply ?? false; - -const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim(); -const cliDir = path.join(repoRoot, "apps/cli"); - -const rootPkg = JSON.parse(await readFile(path.join(cliDir, "package.json"), "utf8")); - function releasePluginSpecifier(plugin: unknown): string | undefined { if (typeof plugin === "string") return plugin; if (Array.isArray(plugin) && typeof plugin[0] === "string") return plugin[0]; return undefined; } -async function copyLocalReleasePlugins(clone: string): Promise { - const plugins = Array.isArray(rootPkg.release?.plugins) ? rootPkg.release.plugins : []; - const historicalCliDir = path.join(clone, "apps/cli"); - - for (const plugin of plugins) { - const specifier = releasePluginSpecifier(plugin); - if (!specifier || path.isAbsolute(specifier) || !specifier.startsWith(".")) continue; - - const sourcePath = path.resolve(cliDir, specifier); - const relativePath = path.relative(cliDir, sourcePath); - if ( - relativePath === ".." || - relativePath.startsWith(`..${path.sep}`) || - path.isAbsolute(relativePath) - ) { - throw new Error(`Local release plugin must stay inside apps/cli: ${specifier}`); - } +const main = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; - const destinationPath = path.join(historicalCliDir, relativePath); - await mkdir(path.dirname(destinationPath), { recursive: true }); - await copyFile(sourcePath, destinationPath); + const tag = values.tag; + if (!tag) { + yield* Console.error("--tag is required (e.g. --tag v2.99.0-beta.1)"); + return yield* Effect.sync(() => process.exit(2)); } -} + const apply = values.apply ?? false; -const repoField = rootPkg.repository?.url ?? rootPkg.repository ?? ""; -const repoUrl = `${String(repoField) - .replace(/^git\+/, "") - .replace(/\.git$/, "") - .replace(/\/$/, "")}.git`; -if (!repoUrl.startsWith("http")) { - console.error(`Could not derive repository URL from apps/cli/package.json (got: ${repoUrl})`); - process.exit(1); -} + const repoRoot = (yield* shell(() => $`git rev-parse --show-toplevel`.text())).trim(); + const cliDir = path.join(repoRoot, "apps/cli"); -const tagCheck = await $`git rev-parse -q --verify refs/tags/${tag}` - .cwd(repoRoot) - .nothrow() - .quiet(); -if (tagCheck.exitCode !== 0) { - console.error(`Tag ${tag} not found locally. Try: git fetch --tags origin`); - process.exit(1); -} + const rootPkg = yield* decodeJson( + utf8.decode(yield* fs.readFile(path.join(cliDir, "package.json"))), + ); -const work = await mkdtemp(path.join(tmpdir(), "backfill-release-notes.")); -const clone = path.join(work, "repo"); - -try { - console.error(`==> Cloning ${repoRoot} -> ${clone}`); - await $`git clone --quiet --no-local ${repoRoot} ${clone}`; - - // `git notes add` (used below to seed channel notes) requires a committer - // identity, which CI runners don't ship in ~/.gitconfig. - await $`git -C ${clone} config --local user.email backfill-release-notes@supabase.local`; - await $`git -C ${clone} config --local user.name backfill-release-notes`; - // A signing key inherited from a user's global config would also make - // `git notes add` fail; the temp clone never publishes anything, so disable it. - await $`git -C ${clone} config --local commit.gpgsign false`; - await $`git -C ${clone} config --local tag.gpgsign false`; - - const originUrl = (await $`git -C ${repoRoot} remote get-url origin`.text()).trim(); - - // Notes refs aren't fetched by `git clone`. Pull from the source repo first - // (network-free), then from origin so even tags whose notes haven't been - // sync'd locally yet are available. - await $`git -C ${clone} fetch --no-tags --quiet ${repoRoot} +refs/notes/*:refs/notes/*` - .nothrow() - .quiet(); - await $`git -C ${clone} fetch --no-tags --quiet ${originUrl} +refs/notes/*:refs/notes/*` - .nothrow() - .quiet(); - // One fetch per branch: git aborts a multi-ref fetch when any named ref is missing, and `next` - // may not exist. The `v*.x` glob matches zero or more branches without failing. - for (const branchSpec of ["main", "develop", "next", "v*.x"]) { - await $`git -C ${clone} fetch --no-tags --quiet ${originUrl} +refs/heads/${branchSpec}:refs/remotes/origin/${branchSpec}` - .nothrow() - .quiet(); - } + const copyLocalReleasePlugins = Effect.fnUntraced(function* (clone: string) { + const plugins = Array.isArray(rootPkg.release?.plugins) ? rootPkg.release.plugins : []; + const historicalCliDir = path.join(clone, "apps/cli"); - const sha = (await $`git -C ${clone} rev-list -n 1 ${tag}`.text()).trim(); - const onMain = - ( - await $`git -C ${clone} merge-base --is-ancestor ${sha} refs/remotes/origin/main` - .nothrow() - .quiet() - ).exitCode === 0; - const branch = releaseBranchForTag(tag, onMain); - if ( - branch !== "main" && - branch !== "develop" && - branch !== "next" && - (await $`git -C ${clone} rev-parse --verify -q refs/remotes/origin/${branch}`.nothrow().quiet()) - .exitCode !== 0 - ) { - console.error(`${tag} belongs to ${branch}, but origin/${branch} does not exist.`); - process.exit(1); - } - // Delete the target tag and any other local tag pointing at the same commit: - // when a stable and a beta share a commit, semantic-release picks the - // higher-semver one as lastRelease (which becomes HEAD itself, leaving "no - // release"). Dropping the co-incident tags falls back to the genuine prior release. - const coincidentTagsOut = await $`git -C ${clone} tag --points-at ${sha}`.text(); - const coincidentTags = coincidentTagsOut - .split("\n") - .map((t) => t.trim()) - .filter(Boolean); - for (const ct of coincidentTags) { - await $`git -C ${clone} tag -d ${ct}`.quiet().nothrow(); - } - await $`git -C ${clone} checkout -B ${branch} ${sha} --quiet`; - - // The clone only carries refs/heads/$BRANCH locally; seed the other - // configured branches from origin's tracking refs so semantic-release's - // branch validator sees them. A maintenance branch needs main present to compute its range. - for (const cfg of ["main", "develop", "next"]) { - if (cfg === branch) continue; - const refSha = await $`git -C ${clone} rev-parse --verify -q refs/remotes/origin/${cfg}` - .nothrow() - .quiet(); - if (refSha.exitCode === 0) { - await $`git -C ${clone} update-ref refs/heads/${cfg} ${refSha.text().trim()}`; + for (const plugin of plugins) { + const specifier = releasePluginSpecifier(plugin); + if (!specifier || path.isAbsolute(specifier) || !specifier.startsWith(".")) continue; + + const sourcePath = path.resolve(cliDir, specifier); + const relativePath = path.relative(cliDir, sourcePath); + if ( + relativePath === ".." || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + return yield* new LocalReleasePluginOutsideCli({ + message: `Local release plugin must stay inside apps/cli: ${specifier}`, + }); + } + + const destinationPath = path.join(historicalCliDir, relativePath); + yield* fs.makeDirectory(path.dirname(destinationPath), { recursive: true }); + yield* fs.copyFile(sourcePath, destinationPath); } - } + }); - // semantic-release's notes reader returns channels=[null] for any tag - // missing an annotation, and the prerelease filter drops those tags - // entirely — walking lastRelease back far enough to drag unrelated commits - // into the changelog. Seed a channel note for every reachable tag that - // lacks one, inferring the channel from the tag name. - const mergedTagsOut = await $`git -C ${clone} tag --merged HEAD --sort=v:refname`.text(); - const mergedTags = mergedTagsOut.split("\n").filter((t) => t && t !== tag); - for (const prevTag of mergedTags) { - const noteCheck = await $`git -C ${clone} notes --ref semantic-release show ${prevTag}` - .nothrow() - .quiet(); - if (noteCheck.exitCode === 0) continue; - const payload = JSON.stringify({ channels: [channelForTag(prevTag)] }); - await $`git -C ${clone} notes --ref semantic-release add -f -m ${payload} ${prevTag}^{commit}`.quiet(); + const repoField = rootPkg.repository?.url ?? rootPkg.repository ?? ""; + const repoUrl = `${String(repoField) + .replace(/^git\+/, "") + .replace(/\.git$/, "") + .replace(/\/$/, "")}.git`; + if (!repoUrl.startsWith("http")) { + yield* Console.error( + `Could not derive repository URL from apps/cli/package.json (got: ${repoUrl})`, + ); + return yield* Effect.sync(() => process.exit(1)); } - // Apply the current release config to the historical checkout, so the notes - // come out in the right shape regardless of what release config shipped at the tag. - const clonePkgPath = path.join(clone, "apps/cli/package.json"); - const clonePkg = JSON.parse(await readFile(clonePkgPath, "utf8")); - clonePkg.release = rootPkg.release; - await writeFile(clonePkgPath, `${JSON.stringify(clonePkg, null, 2)}\n`); - await copyLocalReleasePlugins(clone); - - // semantic-release exits with "behind remote" whenever the remote tip - // differs from HEAD, which it always does when backfilling an old tag. Use - // git's insteadOf to redirect the GitHub URL to the local clone for this - // run, while semantic-release still renders commit/PR links against the real URL. - await $`git -C ${clone} config --local url.file://${clone}.insteadOf ${repoUrl}`; - - console.error(`==> Re-staged on ${branch} @ ${sha} (without tag ${tag})`); - console.error(`==> Running semantic-release --dry-run`); - - // env-ci detects the branch from GITHUB_REF (and friends) even with `noCi: - // true`, which only bypasses the "not in CI" guard. That can name a branch - // other than the one the clone's HEAD actually points at, so strip the - // GitHub Actions detection vars and let env-ci read HEAD directly. - const childEnv = { ...process.env }; - for (const key of [ - "GITHUB_ACTIONS", - "GITHUB_REF", - "GITHUB_REF_NAME", - "GITHUB_HEAD_REF", - "GITHUB_BASE_REF", - "GITHUB_EVENT_NAME", - "CI", - ]) { - delete childEnv[key]; + const tagCheck = yield* shell(() => + $`git rev-parse -q --verify refs/tags/${tag}`.cwd(repoRoot).nothrow().quiet(), + ); + if (tagCheck.exitCode !== 0) { + yield* Console.error(`Tag ${tag} not found locally. Try: git fetch --tags origin`); + return yield* Effect.sync(() => process.exit(1)); } - const result = await semanticRelease( - { dryRun: true, noCi: true, repositoryUrl: repoUrl }, - { - cwd: path.join(clone, "apps/cli"), - env: childEnv, - stdout: process.stderr, - stderr: process.stderr, - }, - ); + const backfill = (work: string) => + Effect.gen(function* () { + const clone = path.join(work, "repo"); - if (!result || !result.nextRelease) { - console.error(`semantic-release did not compute a next release for ${tag}`); - process.exit(1); - } + yield* Console.error(`==> Cloning ${repoRoot} -> ${clone}`); + yield* shell(() => $`git clone --quiet --no-local ${repoRoot} ${clone}`); - const expected = tag.replace(/^v/, ""); - if (result.nextRelease.version !== expected) { - console.error( - `semantic-release computed v${result.nextRelease.version} but expected ${tag}; ` + - `check channel notes and release config`, - ); - process.exit(1); - } + // `git notes add` (used below to seed channel notes) requires a committer + // identity, which CI runners don't ship in ~/.gitconfig. + yield* shell( + () => $`git -C ${clone} config --local user.email backfill-release-notes@supabase.local`, + ); + yield* shell(() => $`git -C ${clone} config --local user.name backfill-release-notes`); + // A signing key inherited from a user's global config would also make + // `git notes add` fail; the temp clone never publishes anything, so disable it. + yield* shell(() => $`git -C ${clone} config --local commit.gpgsign false`); + yield* shell(() => $`git -C ${clone} config --local tag.gpgsign false`); - const notes = result.nextRelease.notes ?? ""; + const originUrl = (yield* shell(() => + $`git -C ${repoRoot} remote get-url origin`.text(), + )).trim(); - if (apply) { - const notesFile = path.join(work, "notes.md"); - await writeFile(notesFile, notes); - console.error(`==> Updating GitHub Release body for ${tag}`); - await $`gh release edit ${tag} --notes-file ${notesFile}`; - } else { - process.stdout.write(notes); - if (!notes.endsWith("\n")) process.stdout.write("\n"); - } -} finally { - await rm(work, { recursive: true, force: true }); + // Notes refs aren't fetched by `git clone`. Pull from the source repo first + // (network-free), then from origin so even tags whose notes haven't been + // sync'd locally yet are available. + yield* shell(() => + $`git -C ${clone} fetch --no-tags --quiet ${repoRoot} +refs/notes/*:refs/notes/*` + .nothrow() + .quiet(), + ); + yield* shell(() => + $`git -C ${clone} fetch --no-tags --quiet ${originUrl} +refs/notes/*:refs/notes/*` + .nothrow() + .quiet(), + ); + // One fetch per branch: git aborts a multi-ref fetch when any named ref is missing, and `next` + // may not exist. The `v*.x` glob matches zero or more branches without failing. + for (const branchSpec of ["main", "develop", "next", "v*.x"]) { + yield* shell(() => + $`git -C ${clone} fetch --no-tags --quiet ${originUrl} +refs/heads/${branchSpec}:refs/remotes/origin/${branchSpec}` + .nothrow() + .quiet(), + ); + } + + const sha = (yield* shell(() => $`git -C ${clone} rev-list -n 1 ${tag}`.text())).trim(); + const onMain = + (yield* shell(() => + $`git -C ${clone} merge-base --is-ancestor ${sha} refs/remotes/origin/main` + .nothrow() + .quiet(), + )).exitCode === 0; + const branch = releaseBranchForTag(tag, onMain); + if ( + branch !== "main" && + branch !== "develop" && + branch !== "next" && + (yield* shell(() => + $`git -C ${clone} rev-parse --verify -q refs/remotes/origin/${branch}`.nothrow().quiet(), + )).exitCode !== 0 + ) { + yield* Console.error(`${tag} belongs to ${branch}, but origin/${branch} does not exist.`); + return yield* Effect.sync(() => process.exit(1)); + } + // Delete the target tag and any other local tag pointing at the same commit: + // when a stable and a beta share a commit, semantic-release picks the + // higher-semver one as lastRelease (which becomes HEAD itself, leaving "no + // release"). Dropping the co-incident tags falls back to the genuine prior release. + const coincidentTagsOut = yield* shell(() => + $`git -C ${clone} tag --points-at ${sha}`.text(), + ); + const coincidentTags = coincidentTagsOut + .split("\n") + .map((t) => t.trim()) + .filter(Boolean); + for (const ct of coincidentTags) { + yield* shell(() => $`git -C ${clone} tag -d ${ct}`.quiet().nothrow()); + } + yield* shell(() => $`git -C ${clone} checkout -B ${branch} ${sha} --quiet`); + + // The clone only carries refs/heads/$BRANCH locally; seed the other + // configured branches from origin's tracking refs so semantic-release's + // branch validator sees them. A maintenance branch needs main present to compute its range. + for (const cfg of ["main", "develop", "next"]) { + if (cfg === branch) continue; + const refSha = yield* shell(() => + $`git -C ${clone} rev-parse --verify -q refs/remotes/origin/${cfg}`.nothrow().quiet(), + ); + if (refSha.exitCode === 0) { + yield* shell( + () => $`git -C ${clone} update-ref refs/heads/${cfg} ${refSha.text().trim()}`, + ); + } + } + + // semantic-release's notes reader returns channels=[null] for any tag + // missing an annotation, and the prerelease filter drops those tags + // entirely — walking lastRelease back far enough to drag unrelated commits + // into the changelog. Seed a channel note for every reachable tag that + // lacks one, inferring the channel from the tag name. + const mergedTagsOut = yield* shell(() => + $`git -C ${clone} tag --merged HEAD --sort=v:refname`.text(), + ); + const mergedTags = mergedTagsOut.split("\n").filter((t) => t && t !== tag); + for (const prevTag of mergedTags) { + const noteCheck = yield* shell(() => + $`git -C ${clone} notes --ref semantic-release show ${prevTag}`.nothrow().quiet(), + ); + if (noteCheck.exitCode === 0) continue; + const payload = yield* encodeJson({ channels: [channelForTag(prevTag)] }); + yield* shell(() => + $`git -C ${clone} notes --ref semantic-release add -f -m ${payload} ${prevTag}^{commit}`.quiet(), + ); + } + + // Apply the current release config to the historical checkout, so the notes + // come out in the right shape regardless of what release config shipped at the tag. + const clonePkgPath = path.join(clone, "apps/cli/package.json"); + const clonePkg = yield* decodeJson(utf8.decode(yield* fs.readFile(clonePkgPath))); + clonePkg.release = rootPkg.release; + yield* fs.writeFileString(clonePkgPath, `${yield* encodePackageJson(clonePkg)}\n`); + yield* copyLocalReleasePlugins(clone); + + // semantic-release exits with "behind remote" whenever the remote tip + // differs from HEAD, which it always does when backfilling an old tag. Use + // git's insteadOf to redirect the GitHub URL to the local clone for this + // run, while semantic-release still renders commit/PR links against the real URL. + yield* shell( + () => $`git -C ${clone} config --local url.file://${clone}.insteadOf ${repoUrl}`, + ); + + yield* Console.error(`==> Re-staged on ${branch} @ ${sha} (without tag ${tag})`); + yield* Console.error(`==> Running semantic-release --dry-run`); + + // env-ci detects the branch from GITHUB_REF (and friends) even with `noCi: + // true`, which only bypasses the "not in CI" guard. That can name a branch + // other than the one the clone's HEAD actually points at, so strip the + // GitHub Actions detection vars and let env-ci read HEAD directly. + const childEnv = { ...process.env }; + for (const key of [ + "GITHUB_ACTIONS", + "GITHUB_REF", + "GITHUB_REF_NAME", + "GITHUB_HEAD_REF", + "GITHUB_BASE_REF", + "GITHUB_EVENT_NAME", + "CI", + ]) { + delete childEnv[key]; + } + + const result = yield* Effect.tryPromise({ + try: () => + semanticRelease( + { dryRun: true, noCi: true, repositoryUrl: repoUrl }, + { + cwd: path.join(clone, "apps/cli"), + env: childEnv, + stdout: process.stderr, + stderr: process.stderr, + }, + ), + catch: (cause) => new SemanticReleaseFailed({ cause }), + }); + + if (!result || !result.nextRelease) { + yield* Console.error(`semantic-release did not compute a next release for ${tag}`); + return yield* Effect.sync(() => process.exit(1)); + } + + const expected = tag.replace(/^v/, ""); + if (result.nextRelease.version !== expected) { + yield* Console.error( + `semantic-release computed v${result.nextRelease.version} but expected ${tag}; ` + + `check channel notes and release config`, + ); + return yield* Effect.sync(() => process.exit(1)); + } + + const notes = result.nextRelease.notes ?? ""; + + if (apply) { + const notesFile = path.join(work, "notes.md"); + yield* fs.writeFileString(notesFile, notes); + yield* Console.error(`==> Updating GitHub Release body for ${tag}`); + yield* shell(() => $`gh release edit ${tag} --notes-file ${notesFile}`); + } else { + yield* Effect.sync(() => { + process.stdout.write(notes); + if (!notes.endsWith("\n")) process.stdout.write("\n"); + }); + } + }); + /* A failed removal replaces the run's outcome. */ + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const work = yield* fs.makeTempDirectory({ prefix: "backfill-release-notes." }); + const outcome = yield* Effect.exit(restore(backfill(work))); + yield* fs.remove(work, { recursive: true, force: true }); + return yield* outcome; + }), + ); +}); + +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + return error.cause !== undefined ? error.cause : new Error(error.message); } + +await Effect.runPromise( + main.pipe( + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + Effect.catch((error) => Effect.die(thrownValue(error))), + ), +); diff --git a/apps/cli/scripts/bench-cli.ts b/apps/cli/scripts/bench-cli.ts index 7e7530bb63..6e1df06c16 100644 --- a/apps/cli/scripts/bench-cli.ts +++ b/apps/cli/scripts/bench-cli.ts @@ -6,11 +6,10 @@ */ import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; -import { resolve as resolvePath } from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; -import { BunRuntime, BunServices } from "@effect/platform-bun"; -import { Clock, Console, Effect, FileSystem, Path, Ref, Stdio } from "effect"; +import { BunPath, BunRuntime, BunServices } from "@effect/platform-bun"; +import { Clock, Console, Effect, FileSystem, Path, Ref, Schema, Stdio } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { readSpans, type ReportSpan } from "./trace-report.ts"; @@ -35,6 +34,8 @@ export interface BenchOptions { readonly updateCheck: boolean; } +const ReportJson = Schema.fromJsonString(Schema.Unknown, { space: 2 }); + const DEFAULT_COMMANDS: ReadonlyArray> = [["--version"], ["--help"]]; /** Whether `--help`/`-h` is passed as a flag rather than as a `--command` value. */ @@ -90,11 +91,15 @@ export function parseBenchArgs(argv: ReadonlyArray): BenchOptions { throw new Error('--cwd-setup only supports "init"'); } + /* The host `Path` service (`node:path` itself), read synchronously for this sync parser. */ + const hostPath = Effect.runSync( + Path.Path.useSync((path) => path).pipe(Effect.provide(BunPath.layer)), + ); return { // Each launch spawns with a per-run `cwd`, which also resolves a relative command path; a // path given relative to the invocation directory must become absolute before that happens. - base: resolvePath(values.base), - branch: resolvePath(values.branch), + base: hostPath.resolve(values.base), + branch: hostPath.resolve(values.branch), runs, warmup, commands: @@ -590,10 +595,15 @@ const main = Effect.gen(function* () { if (options.out !== undefined) { const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(options.out, JSON.stringify({ report, samples }, null, 2)); + yield* fs.writeFileString( + options.out, + yield* Schema.encodeEffect(ReportJson)({ report, samples }), + ); } - yield* Console.log(options.json ? JSON.stringify(report, null, 2) : formatReport(report)); + yield* Console.log( + options.json ? yield* Schema.encodeEffect(ReportJson)(report) : formatReport(report), + ); }); if (import.meta.main) { diff --git a/apps/cli/scripts/build-binary.integration.test.ts b/apps/cli/scripts/build-binary.integration.test.ts index 8073bee193..f3ee9dad24 100644 --- a/apps/cli/scripts/build-binary.integration.test.ts +++ b/apps/cli/scripts/build-binary.integration.test.ts @@ -1,7 +1,6 @@ -import { afterEach, describe, expect, test } from "vitest"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; import { fileURLToPath } from "node:url"; const fixturePath = fileURLToPath( @@ -10,89 +9,111 @@ const fixturePath = fileURLToPath( const versionFixturePath = fileURLToPath( new URL("../tests/fixtures/compiled-cli-version.ts", import.meta.url), ); -const temporaryDirectories: string[] = []; +const platformLayer = Layer.mergeAll(BunFileSystem.layer, BunPath.layer); -afterEach(async () => { - await Promise.all( - temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), - ); -}); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); describe("compiled binary assets", () => { - test("embeds and loads libpg-query.wasm", async () => { - const directory = await mkdtemp(path.join(tmpdir(), "supabase-compiled-wasm-")); - temporaryDirectories.push(directory); - const executable = path.join(directory, "parser-probe"); - const bunExecutable = Bun.which("bun"); - if (!bunExecutable) { - throw new Error("Bun executable not found"); - } + it.live( + "embeds and loads libpg-query.wasm", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-compiled-wasm-" }); + const executable = path.join(directory, "parser-probe"); + const bunExecutable = Bun.which("bun"); + if (!bunExecutable) { + throw new Error("Bun executable not found"); + } - const build = Bun.spawn( - [bunExecutable, "build", fixturePath, "--compile", `--outfile=${executable}`], - { stdout: "pipe", stderr: "pipe" }, - ); - const [buildExitCode, buildStderr] = await Promise.all([ - build.exited, - new Response(build.stderr).text(), - ]); - expect(buildExitCode, buildStderr).toBe(0); + const build = Bun.spawn( + [bunExecutable, "build", fixturePath, "--compile", `--outfile=${executable}`], + { stdout: "pipe", stderr: "pipe" }, + ); + const [buildExitCode, buildStderr] = yield* Effect.all( + [ + Effect.promise(() => build.exited), + Effect.promise(() => new Response(build.stderr).text()), + ], + { concurrency: "unbounded" }, + ); + expect(buildExitCode, buildStderr).toBe(0); - const probe = Bun.spawn([executable], { - cwd: directory, - env: {}, - stdout: "pipe", - stderr: "pipe", - }); - const [probeExitCode, stdout, stderr] = await Promise.all([ - probe.exited, - new Response(probe.stdout).text(), - new Response(probe.stderr).text(), - ]); + const probe = Bun.spawn([executable], { + cwd: directory, + env: {}, + stdout: "pipe", + stderr: "pipe", + }); + const [probeExitCode, stdout, stderr] = yield* Effect.all( + [ + Effect.promise(() => probe.exited), + Effect.promise(() => new Response(probe.stdout).text()), + Effect.promise(() => new Response(probe.stderr).text()), + ], + { concurrency: "unbounded" }, + ); - expect(probeExitCode, stderr).toBe(0); - expect(stdout).toContain("libpg-query.wasm loaded"); - }, 20_000); + expect(probeExitCode, stderr).toBe(0); + expect(stdout).toContain("libpg-query.wasm loaded"); + }).pipe(Effect.provide(platformLayer)), + 20_000, + ); - test("embeds the build version independently of the runtime environment", async () => { - const directory = await mkdtemp(path.join(tmpdir(), "supabase-compiled-version-")); - temporaryDirectories.push(directory); - const executable = path.join(directory, "version-probe"); - const bunExecutable = Bun.which("bun"); - if (!bunExecutable) { - throw new Error("Bun executable not found"); - } + it.live( + "embeds the build version independently of the runtime environment", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-compiled-version-", + }); + const executable = path.join(directory, "version-probe"); + const bunExecutable = Bun.which("bun"); + if (!bunExecutable) { + throw new Error("Bun executable not found"); + } - const build = Bun.spawn( - [ - bunExecutable, - "build", - versionFixturePath, - "--compile", - `--define=SUPABASE_CLI_VERSION=${JSON.stringify("7.8.9-beta.1")}`, - `--outfile=${executable}`, - ], - { stdout: "pipe", stderr: "pipe" }, - ); - const [buildExitCode, buildStderr] = await Promise.all([ - build.exited, - new Response(build.stderr).text(), - ]); - expect(buildExitCode, buildStderr).toBe(0); + const build = Bun.spawn( + [ + bunExecutable, + "build", + versionFixturePath, + "--compile", + `--define=SUPABASE_CLI_VERSION=${yield* encodeJson("7.8.9-beta.1")}`, + `--outfile=${executable}`, + ], + { stdout: "pipe", stderr: "pipe" }, + ); + const [buildExitCode, buildStderr] = yield* Effect.all( + [ + Effect.promise(() => build.exited), + Effect.promise(() => new Response(build.stderr).text()), + ], + { concurrency: "unbounded" }, + ); + expect(buildExitCode, buildStderr).toBe(0); - const probe = Bun.spawn([executable], { - cwd: directory, - env: { SUPABASE_CLI_VERSION: "9.9.9" }, - stdout: "pipe", - stderr: "pipe", - }); - const [probeExitCode, stdout, stderr] = await Promise.all([ - probe.exited, - new Response(probe.stdout).text(), - new Response(probe.stderr).text(), - ]); + const probe = Bun.spawn([executable], { + cwd: directory, + env: { SUPABASE_CLI_VERSION: "9.9.9" }, + stdout: "pipe", + stderr: "pipe", + }); + const [probeExitCode, stdout, stderr] = yield* Effect.all( + [ + Effect.promise(() => probe.exited), + Effect.promise(() => new Response(probe.stdout).text()), + Effect.promise(() => new Response(probe.stderr).text()), + ], + { concurrency: "unbounded" }, + ); - expect(probeExitCode, stderr).toBe(0); - expect(stdout.trim()).toBe("7.8.9-beta.1"); - }, 20_000); + expect(probeExitCode, stderr).toBe(0); + expect(stdout.trim()).toBe("7.8.9-beta.1"); + }).pipe(Effect.provide(platformLayer)), + 20_000, + ); }); diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index 28ed04fee9..a92ca1d242 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Console, Effect } from "effect"; import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; import { compileOptions, stackReleaseDefine } from "./compile-options.ts"; @@ -32,5 +32,5 @@ const result = await Bun.build({ }, }); for (const log of result.logs) { - console.warn(log); + Effect.runSync(Console.warn(log)); } diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index 3d7f9db378..c911034494 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -1,10 +1,9 @@ import { $ } from "bun"; import { createHash } from "node:crypto"; -import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; -import { Effect } from "effect"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { Config, Console, Data, Effect, FileSystem, Layer, Path, Schema } from "effect"; import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; import { compileOptions, stackReleaseDefine } from "./compile-options.ts"; import { darwinBinaries, MACOS_IDENTIFIERS } from "./macos-signing.ts"; @@ -30,373 +29,431 @@ const { values } = parseArgs({ }, }); -const root = path.resolve(import.meta.dir, "../../.."); -const packageJsonPath = path.join(root, "apps/cli/package.json"); -const packageVersion = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: string }; -const version = values.version ?? packageVersion.version; -if (!version) { - console.error("Usage: bun apps/cli/scripts/build.ts [--version ]"); - process.exit(1); -} -if (values.version === undefined) { - console.warn( - `[build] --version not provided; falling back to package.json version "${version}". Pass --version explicitly in release builds.`, - ); -} - -const TARGETS = [ - { - bunTarget: "bun-darwin-arm64", - pkg: "cli-darwin-arm64", - archive: `supabase_${version}_darwin_arm64.tar.gz`, - ext: "", - }, - { - bunTarget: "bun-darwin-x64", - pkg: "cli-darwin-x64", - archive: `supabase_${version}_darwin_amd64.tar.gz`, - ext: "", - }, - { - bunTarget: "bun-linux-arm64", - pkg: "cli-linux-arm64", - archive: `supabase_${version}_linux_arm64.tar.gz`, - nfpmArch: "arm64", - ext: "", - }, - { - bunTarget: "bun-linux-x64-baseline", - pkg: "cli-linux-x64", - archive: `supabase_${version}_linux_amd64.tar.gz`, - nfpmArch: "amd64", - ext: "", - }, - { - bunTarget: "bun-windows-x64-baseline", - pkg: "cli-windows-x64", - archive: `supabase_${version}_windows_amd64.zip`, - ext: ".exe", - }, - { - bunTarget: "bun-windows-arm64", - pkg: "cli-windows-arm64", - archive: `supabase_${version}_windows_arm64.zip`, - ext: ".exe", - }, -] as const; - -const entrypoint = path.join(root, "apps/cli/src/main.ts"); -const distDir = path.join(root, "dist"); -const goSource = path.resolve(root, "apps/cli-go"); -const buildDefines = { - ...(await stackReleaseDefine()), - SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify( - await Effect.runPromise(bundleServeMainTemplate()), - ), - SUPABASE_CLI_POSTHOG_KEY: JSON.stringify(process.env.POSTHOG_API_KEY ?? ""), - SUPABASE_CLI_POSTHOG_HOST: JSON.stringify(process.env.POSTHOG_ENDPOINT ?? ""), - // Skips msgpackr's startup probe for its native addon at the build host's store path, which - // on macOS goes through the automounter and can hang every command (supabase/cli#6771). - "process.env.MSGPACKR_NATIVE_ACCELERATION_DISABLED": JSON.stringify("true"), -}; - -type BunTarget = (typeof TARGETS)[number]["bunTarget"]; - -const GO_TARGETS: Record = { - "bun-darwin-arm64": { goos: "darwin", goarch: "arm64" }, - "bun-darwin-x64": { goos: "darwin", goarch: "amd64" }, - "bun-linux-arm64": { goos: "linux", goarch: "arm64" }, - "bun-linux-x64-baseline": { goos: "linux", goarch: "amd64" }, - "bun-windows-x64-baseline": { goos: "windows", goarch: "amd64" }, - "bun-windows-arm64": { goos: "windows", goarch: "arm64" }, -}; - -type SignMode = "adhoc" | "off"; - -async function runBunBuild(config: Bun.BuildConfig) { - const result = await Bun.build({ - ...config, - ...compileOptions, - plugins: config.plugins ?? [], - }); - for (const log of result.logs) { - console.warn(log); - } -} +class BuildStepFailed extends Data.TaggedError("BuildStepFailed")<{ readonly cause: unknown }> {} -async function buildTarget(target: (typeof TARGETS)[number]) { - const binDir = path.join(root, "packages", target.pkg, "bin"); - await mkdir(binDir, { recursive: true }); +const step = (run: () => PromiseLike) => + Effect.tryPromise({ try: run, catch: (cause) => new BuildStepFailed({ cause }) }); - const outfile = path.join(binDir, `supabase${target.ext}`); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); - console.log(`[${target.pkg}] Compiling Bun CLI...`); - await runBunBuild({ - entrypoints: [entrypoint], - compile: { target: target.bunTarget, outfile }, - define: { - ...buildDefines, - SUPABASE_CLI_VERSION: JSON.stringify(version), - }, - }); - console.log(`[${target.pkg}] Done.`); -} +/* Native `JSON.parse`, so a malformed package.json fails with its own `SyntaxError`. */ +const parsePackageJson = (text: string) => JSON.parse(text) as { version?: string }; -async function buildGoTarget(target: (typeof TARGETS)[number]) { - const binDir = path.join(root, "packages", target.pkg, "bin"); - await mkdir(binDir, { recursive: true }); +const envOrEmpty = (name: string) => Config.String(name).pipe(Config.withDefault("")); - const { goos, goarch } = GO_TARGETS[target.bunTarget]; - const outfile = path.join(binDir, `supabase-go${target.ext}`); +const main = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + /* Concurrent jobs run as promises joined by `Promise.all`, so one failed job does not + interrupt the others. */ + const runPromise = Effect.runPromiseWith(yield* Effect.context()); + const runJob = ( + job: Effect.Effect, + ) => runPromise(job.pipe(Effect.catch((error) => Effect.die(thrownValue(error))))); - console.log(`[${target.pkg}] Compiling Go CLI (${goos}/${goarch})...`); - const ldflagParts = ["-s", "-w", `-X github.com/supabase/cli/internal/utils.Version=${version}`]; - const { SENTRY_DSN, POSTHOG_API_KEY, POSTHOG_ENDPOINT } = process.env; - if (SENTRY_DSN) { - ldflagParts.push(`-X github.com/supabase/cli/internal/utils.SentryDsn=${SENTRY_DSN}`); - } - if (POSTHOG_API_KEY) { - ldflagParts.push(`-X github.com/supabase/cli/internal/utils.PostHogAPIKey=${POSTHOG_API_KEY}`); - } - if (POSTHOG_ENDPOINT) { - ldflagParts.push( - `-X github.com/supabase/cli/internal/utils.PostHogEndpoint=${POSTHOG_ENDPOINT}`, - ); - } - const goLdflags = ldflagParts.join(" "); - await $`go build -trimpath -ldflags=${goLdflags} -o ${outfile} .`.cwd(goSource).env({ - ...process.env, - GOOS: goos, - GOARCH: goarch, - CGO_ENABLED: "0", + const root = path.resolve(import.meta.dir, "../../.."); + const packageJsonPath = path.join(root, "apps/cli/package.json"); + const packageJsonText = new TextDecoder("utf-8", { ignoreBOM: true }).decode( + yield* fs.readFile(packageJsonPath), + ); + const packageVersion = yield* Effect.try({ + try: () => parsePackageJson(packageJsonText), + catch: (cause) => new BuildStepFailed({ cause }), }); - console.log(`[${target.pkg}] Go binary done.`); -} - -/** - * Decides how to sign macOS binaries. `rcodesign` signs Mach-O binaries from Linux, so signing - * runs inline on this build runner; falls back to "off" with a warning unless - * SUPABASE_CLI_REQUIRE_SIGNING=1, which fails the build when the tool is missing. - */ -function resolveSignMode(): SignMode { - if (Bun.which("rcodesign")) { - return "adhoc"; + const version = values.version ?? packageVersion.version; + if (!version) { + yield* Console.error("Usage: bun apps/cli/scripts/build.ts [--version ]"); + process.exit(1); } - if (process.env.SUPABASE_CLI_REQUIRE_SIGNING === "1") { - console.error( - "rcodesign not found but SUPABASE_CLI_REQUIRE_SIGNING=1 is set. Release builds " + - "must sign the macOS binaries. Install rcodesign (https://github.com/indygreg/apple-platform-rs).", + if (values.version === undefined) { + yield* Console.warn( + `[build] --version not provided; falling back to package.json version "${version}". Pass --version explicitly in release builds.`, ); - process.exit(1); } - console.warn( - "WARNING: rcodesign not found — macOS binaries will keep Bun's linker-signed ad-hoc " + - "signature, which macOS 26+ SIGKILLs at launch (CLI-1621). Install rcodesign to " + - "produce runnable macOS binaries locally.", - ); - return "off"; -} -async function signDarwinBinaries(mode: SignMode) { - if (mode === "off") { - return; - } + const TARGETS = [ + { + bunTarget: "bun-darwin-arm64", + pkg: "cli-darwin-arm64", + archive: `supabase_${version}_darwin_arm64.tar.gz`, + ext: "", + }, + { + bunTarget: "bun-darwin-x64", + pkg: "cli-darwin-x64", + archive: `supabase_${version}_darwin_amd64.tar.gz`, + ext: "", + }, + { + bunTarget: "bun-linux-arm64", + pkg: "cli-linux-arm64", + archive: `supabase_${version}_linux_arm64.tar.gz`, + nfpmArch: "arm64", + ext: "", + }, + { + bunTarget: "bun-linux-x64-baseline", + pkg: "cli-linux-x64", + archive: `supabase_${version}_linux_amd64.tar.gz`, + nfpmArch: "amd64", + ext: "", + }, + { + bunTarget: "bun-windows-x64-baseline", + pkg: "cli-windows-x64", + archive: `supabase_${version}_windows_amd64.zip`, + ext: ".exe", + }, + { + bunTarget: "bun-windows-arm64", + pkg: "cli-windows-arm64", + archive: `supabase_${version}_windows_arm64.zip`, + ext: ".exe", + }, + ] as const; + + const entrypoint = path.join(root, "apps/cli/src/main.ts"); + const distDir = path.join(root, "dist"); + const goSource = path.resolve(root, "apps/cli-go"); + const buildDefines = { + ...(yield* step(() => stackReleaseDefine())), + SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: yield* encodeJson(yield* bundleServeMainTemplate()), + SUPABASE_CLI_POSTHOG_KEY: yield* encodeJson(yield* envOrEmpty("POSTHOG_API_KEY")), + SUPABASE_CLI_POSTHOG_HOST: yield* encodeJson(yield* envOrEmpty("POSTHOG_ENDPOINT")), + // Skips msgpackr's startup probe for its native addon at the build host's store path, which + // on macOS goes through the automounter and can hang every command (supabase/cli#6771). + "process.env.MSGPACKR_NATIVE_ACCELERATION_DISABLED": yield* encodeJson("true"), + }; + + type BunTarget = (typeof TARGETS)[number]["bunTarget"]; + + const GO_TARGETS: Record = { + "bun-darwin-arm64": { goos: "darwin", goarch: "arm64" }, + "bun-darwin-x64": { goos: "darwin", goarch: "amd64" }, + "bun-linux-arm64": { goos: "linux", goarch: "arm64" }, + "bun-linux-x64-baseline": { goos: "linux", goarch: "amd64" }, + "bun-windows-x64-baseline": { goos: "windows", goarch: "amd64" }, + "bun-windows-arm64": { goos: "windows", goarch: "arm64" }, + }; + + type SignMode = "adhoc" | "off"; + + const runBunBuild = Effect.fnUntraced(function* (config: Bun.BuildConfig) { + const result = yield* step(() => + Bun.build({ + ...config, + ...compileOptions, + plugins: config.plugins ?? [], + }), + ); + for (const log of result.logs) { + yield* Console.warn(log); + } + }); - const darwinTargets = TARGETS.filter((target) => target.bunTarget.startsWith("bun-darwin")); - const binaries = darwinBinaries(); + const buildTarget = Effect.fnUntraced(function* (target: (typeof TARGETS)[number]) { + const binDir = path.join(root, "packages", target.pkg, "bin"); + yield* fs.makeDirectory(binDir, { recursive: true }); + + const outfile = path.join(binDir, `supabase${target.ext}`); + + yield* Console.log(`[${target.pkg}] Compiling Bun CLI...`); + yield* runBunBuild({ + entrypoints: [entrypoint], + compile: { target: target.bunTarget, outfile }, + define: { + ...buildDefines, + SUPABASE_CLI_VERSION: yield* encodeJson(version), + }, + }); + yield* Console.log(`[${target.pkg}] Done.`); + }); - for (const target of darwinTargets) { + const buildGoTarget = Effect.fnUntraced(function* (target: (typeof TARGETS)[number]) { const binDir = path.join(root, "packages", target.pkg, "bin"); + yield* fs.makeDirectory(binDir, { recursive: true }); - for (const binary of binaries) { - const binPath = path.join(binDir, binary); - const identifier = MACOS_IDENTIFIERS[binary]; - - console.log(`[${target.pkg}] Ad-hoc signing ${binary} (${identifier})...`); - // No key material, so rcodesign produces an ad-hoc signature, equivalent to - // `codesign --sign -`, replacing Bun/Go's linker-signed one. - await $`rcodesign sign --binary-identifier ${identifier} ${binPath}`; - - // Matches the identifier's whole value, so the SFE's `com.supabase.cli` can't satisfy - // the sidecar's `com.supabase.cli-go`, and confirms the signature is no longer linker-signed. - const info = await $`rcodesign print-signature-info ${binPath}`.text(); - const signedIdentifier = info.match(/^\s*identifier:\s*(\S+)\s*$/m)?.[1]; - if (signedIdentifier !== identifier) { - throw new Error( - `Signing ${binPath} failed: expected identifier ${identifier}, got ${signedIdentifier ?? "none"}`, - ); - } - if (info.includes("LINKER_SIGNED")) { - throw new Error(`Signing ${binPath} failed: signature is still linker-signed`); - } + const { goos, goarch } = GO_TARGETS[target.bunTarget]; + const outfile = path.join(binDir, `supabase-go${target.ext}`); + + yield* Console.log(`[${target.pkg}] Compiling Go CLI (${goos}/${goarch})...`); + const ldflagParts = [ + "-s", + "-w", + `-X github.com/supabase/cli/internal/utils.Version=${version}`, + ]; + const { SENTRY_DSN, POSTHOG_API_KEY, POSTHOG_ENDPOINT } = process.env; + if (SENTRY_DSN) { + ldflagParts.push(`-X github.com/supabase/cli/internal/utils.SentryDsn=${SENTRY_DSN}`); } - } -} + if (POSTHOG_API_KEY) { + ldflagParts.push( + `-X github.com/supabase/cli/internal/utils.PostHogAPIKey=${POSTHOG_API_KEY}`, + ); + } + if (POSTHOG_ENDPOINT) { + ldflagParts.push( + `-X github.com/supabase/cli/internal/utils.PostHogEndpoint=${POSTHOG_ENDPOINT}`, + ); + } + const goLdflags = ldflagParts.join(" "); + yield* step(() => + $`go build -trimpath -ldflags=${goLdflags} -o ${outfile} .`.cwd(goSource).env({ + ...process.env, + GOOS: goos, + GOARCH: goarch, + CGO_ENABLED: "0", + }), + ); + yield* Console.log(`[${target.pkg}] Go binary done.`); + }); -async function archiveTarget(target: (typeof TARGETS)[number]) { - const binDir = path.join(root, "packages", target.pkg, "bin"); - const archivePath = path.join(distDir, target.archive); + /** + * Decides how to sign macOS binaries. `rcodesign` signs Mach-O binaries from Linux, so signing + * runs inline on this build runner; falls back to "off" with a warning unless + * SUPABASE_CLI_REQUIRE_SIGNING=1, which fails the build when the tool is missing. + */ + const resolveSignMode = Effect.fnUntraced(function* () { + if (Bun.which("rcodesign")) { + return "adhoc"; + } + if ((yield* envOrEmpty("SUPABASE_CLI_REQUIRE_SIGNING")) === "1") { + yield* Console.error( + "rcodesign not found but SUPABASE_CLI_REQUIRE_SIGNING=1 is set. Release builds " + + "must sign the macOS binaries. Install rcodesign (https://github.com/indygreg/apple-platform-rs).", + ); + process.exit(1); + } + yield* Console.warn( + "WARNING: rcodesign not found — macOS binaries will keep Bun's linker-signed ad-hoc " + + "signature, which macOS 26+ SIGKILLs at launch (CLI-1621). Install rcodesign to " + + "produce runnable macOS binaries locally.", + ); + return "off"; + }); - console.log(`[${target.pkg}] Creating archive ${target.archive}...`); + const signDarwinBinaries = Effect.fnUntraced(function* (mode: SignMode) { + if (mode === "off") { + return; + } - if (target.archive.endsWith(".zip")) { - const files = [ - path.join(binDir, `supabase${target.ext}`), - path.join(binDir, `supabase-go${target.ext}`), - ]; - await $`zip -j ${archivePath} ${files}`; - - // setup-cli and other download clients always fetch a .tar.gz, even on Windows, so - // publish one alongside the .zip. See #5257. - const tarArchive = target.archive.replace(/\.zip$/, ".tar.gz"); - const tarArchivePath = path.join(distDir, tarArchive); - const tarFiles = [`supabase${target.ext}`, `supabase-go${target.ext}`]; - console.log(`[${target.pkg}] Creating archive ${tarArchive}...`); - await $`tar -czf ${tarArchivePath} -C ${binDir} ${tarFiles}`; - } else { - const files = [`supabase${target.ext}`, `supabase-go${target.ext}`]; - await $`tar -czf ${archivePath} -C ${binDir} ${files}`; - } -} + const darwinTargets = TARGETS.filter((target) => target.bunTarget.startsWith("bun-darwin")); + const binaries = darwinBinaries(); -async function buildMuslBinaries() { - await Promise.all( - MUSL_TARGETS.map(async (target) => { + for (const target of darwinTargets) { const binDir = path.join(root, "packages", target.pkg, "bin"); - await mkdir(binDir, { recursive: true }); - - const outfile = path.join(binDir, "supabase"); - console.log(`[${target.pkg}] Compiling Bun CLI (musl)...`); - await runBunBuild({ - entrypoints: [entrypoint], - compile: { target: target.bunTarget, outfile }, - define: { - ...buildDefines, - SUPABASE_CLI_VERSION: JSON.stringify(version), - }, - }); - - // The Go binary is fully static (CGO_ENABLED=0), so the glibc build works on musl too; - // copy it into the musl package so GoProxy finds supabase-go there. - const glibcTarget = TARGETS.find( - (candidate) => "nfpmArch" in candidate && candidate.nfpmArch === target.nfpmArch, - ); - if (!glibcTarget) { - throw new Error(`No glibc Linux target found for musl arch ${target.nfpmArch}`); + + for (const binary of binaries) { + const binPath = path.join(binDir, binary); + const identifier = MACOS_IDENTIFIERS[binary]; + + yield* Console.log(`[${target.pkg}] Ad-hoc signing ${binary} (${identifier})...`); + // No key material, so rcodesign produces an ad-hoc signature, equivalent to + // `codesign --sign -`, replacing Bun/Go's linker-signed one. + yield* step(() => $`rcodesign sign --binary-identifier ${identifier} ${binPath}`); + + // Matches the identifier's whole value, so the SFE's `com.supabase.cli` can't satisfy + // the sidecar's `com.supabase.cli-go`, and confirms the signature is no longer linker-signed. + const info = yield* step(() => $`rcodesign print-signature-info ${binPath}`.text()); + const signedIdentifier = info.match(/^\s*identifier:\s*(\S+)\s*$/m)?.[1]; + if (signedIdentifier !== identifier) { + throw new Error( + `Signing ${binPath} failed: expected identifier ${identifier}, got ${signedIdentifier ?? "none"}`, + ); + } + if (info.includes("LINKER_SIGNED")) { + throw new Error(`Signing ${binPath} failed: signature is still linker-signed`); + } } - const src = path.join(root, "packages", glibcTarget.pkg, "bin", "supabase-go"); - const dst = path.join(binDir, "supabase-go"); - console.log(`[${target.pkg}] Copying Go binary from ${glibcTarget.pkg}...`); - await copyFile(src, dst); + } + }); - console.log(`[${target.pkg}] Done.`); - }), - ); -} + const archiveTarget = Effect.fnUntraced(function* (target: (typeof TARGETS)[number]) { + const binDir = path.join(root, "packages", target.pkg, "bin"); + const archivePath = path.join(distDir, target.archive); -async function buildLinuxPackages(version: string) { - const linuxTargets = TARGETS.filter((target) => "nfpmArch" in target); - const jobs: Promise[] = []; - - for (const target of linuxTargets) { - const glibcBinDir = path.join(root, "packages", target.pkg, "bin"); - const muslTarget = MUSL_TARGETS.find((candidate) => candidate.nfpmArch === target.nfpmArch)!; - const muslBinDir = path.join(root, "packages", muslTarget.pkg, "bin"); - - for (const fmt of LINUX_PKG_FORMATS) { - const outFile = `supabase_${version}_linux_${target.nfpmArch}.${fmt}`; - const outPath = path.join(distDir, outFile); - const binDir = fmt === "apk" ? muslBinDir : glibcBinDir; - - // The Go binary is fully static, so apk (musl) still references supabase-go - // from the glibc dir where it was built. - const contents: Array<{ src: string; dst: string }> = [ - { src: path.join(binDir, "supabase"), dst: "/usr/bin/supabase" }, - { src: path.join(glibcBinDir, "supabase-go"), dst: "/usr/bin/supabase-go" }, + yield* Console.log(`[${target.pkg}] Creating archive ${target.archive}...`); + + if (target.archive.endsWith(".zip")) { + const files = [ + path.join(binDir, `supabase${target.ext}`), + path.join(binDir, `supabase-go${target.ext}`), ]; + yield* step(() => $`zip -j ${archivePath} ${files}`); - const nfpmConfig: Record = { - name: "supabase", - arch: target.nfpmArch, - platform: "linux", - version, - maintainer: "Supabase ", - description: "Supabase CLI", - homepage: "https://supabase.com", - license: "MIT", - contents, - }; - - if (fmt === "apk") { - nfpmConfig.depends = ["libstdc++", "libgcc"]; - } + // setup-cli and other download clients always fetch a .tar.gz, even on Windows, so + // publish one alongside the .zip. See #5257. + const tarArchive = target.archive.replace(/\.zip$/, ".tar.gz"); + const tarArchivePath = path.join(distDir, tarArchive); + const tarFiles = [`supabase${target.ext}`, `supabase-go${target.ext}`]; + yield* Console.log(`[${target.pkg}] Creating archive ${tarArchive}...`); + yield* step(() => $`tar -czf ${tarArchivePath} -C ${binDir} ${tarFiles}`); + } else { + const files = [`supabase${target.ext}`, `supabase-go${target.ext}`]; + yield* step(() => $`tar -czf ${archivePath} -C ${binDir} ${files}`); + } + }); - const configPath = path.join(distDir, `nfpm-${target.nfpmArch}-${fmt}.yaml`); - await writeFile(configPath, JSON.stringify(nfpmConfig)); + const buildMuslBinaries = Effect.fnUntraced(function* () { + yield* step(() => + Promise.all( + MUSL_TARGETS.map((target) => + runJob( + Effect.gen(function* () { + const binDir = path.join(root, "packages", target.pkg, "bin"); + yield* fs.makeDirectory(binDir, { recursive: true }); + + const outfile = path.join(binDir, "supabase"); + yield* Console.log(`[${target.pkg}] Compiling Bun CLI (musl)...`); + yield* runBunBuild({ + entrypoints: [entrypoint], + compile: { target: target.bunTarget, outfile }, + define: { + ...buildDefines, + SUPABASE_CLI_VERSION: yield* encodeJson(version), + }, + }); + + // The Go binary is fully static (CGO_ENABLED=0), so the glibc build works on musl too; + // copy it into the musl package so GoProxy finds supabase-go there. + const glibcTarget = TARGETS.find( + (candidate) => "nfpmArch" in candidate && candidate.nfpmArch === target.nfpmArch, + ); + if (!glibcTarget) { + throw new Error(`No glibc Linux target found for musl arch ${target.nfpmArch}`); + } + const src = path.join(root, "packages", glibcTarget.pkg, "bin", "supabase-go"); + const dst = path.join(binDir, "supabase-go"); + yield* Console.log(`[${target.pkg}] Copying Go binary from ${glibcTarget.pkg}...`); + yield* fs.copyFile(src, dst); + + yield* Console.log(`[${target.pkg}] Done.`); + }), + ), + ), + ), + ); + }); - jobs.push( - (async () => { - console.log(`[${target.pkg}] Creating ${outFile}...`); - await $`nfpm package --config ${configPath} --packager ${fmt} --target ${outPath}`; - await rm(configPath); - })(), - ); + const buildLinuxPackages = Effect.fnUntraced(function* (version: string) { + const linuxTargets = TARGETS.filter((target) => "nfpmArch" in target); + const jobs: Promise[] = []; + + for (const target of linuxTargets) { + const glibcBinDir = path.join(root, "packages", target.pkg, "bin"); + const muslTarget = MUSL_TARGETS.find((candidate) => candidate.nfpmArch === target.nfpmArch)!; + const muslBinDir = path.join(root, "packages", muslTarget.pkg, "bin"); + + for (const fmt of LINUX_PKG_FORMATS) { + const outFile = `supabase_${version}_linux_${target.nfpmArch}.${fmt}`; + const outPath = path.join(distDir, outFile); + const binDir = fmt === "apk" ? muslBinDir : glibcBinDir; + + // The Go binary is fully static, so apk (musl) still references supabase-go + // from the glibc dir where it was built. + const contents: Array<{ src: string; dst: string }> = [ + { src: path.join(binDir, "supabase"), dst: "/usr/bin/supabase" }, + { src: path.join(glibcBinDir, "supabase-go"), dst: "/usr/bin/supabase-go" }, + ]; + + const nfpmConfig: Record = { + name: "supabase", + arch: target.nfpmArch, + platform: "linux", + version, + maintainer: "Supabase ", + description: "Supabase CLI", + homepage: "https://supabase.com", + license: "MIT", + contents, + }; + + if (fmt === "apk") { + nfpmConfig.depends = ["libstdc++", "libgcc"]; + } + + const configPath = path.join(distDir, `nfpm-${target.nfpmArch}-${fmt}.yaml`); + yield* fs.writeFileString(configPath, yield* encodeJson(nfpmConfig)); + + jobs.push( + runJob( + Effect.gen(function* () { + yield* Console.log(`[${target.pkg}] Creating ${outFile}...`); + yield* step( + () => $`nfpm package --config ${configPath} --packager ${fmt} --target ${outPath}`, + ); + yield* fs.remove(configPath); + }), + ), + ); + } } - } - await Promise.all(jobs); -} + yield* step(() => Promise.all(jobs)); + }); -async function generateChecksums() { - const lines: string[] = []; + const generateChecksums = Effect.fnUntraced(function* () { + const lines: string[] = []; - for (const target of TARGETS) { - const archivePath = path.join(distDir, target.archive); - const data = await readFile(archivePath); - const hash = createHash("sha256").update(data).digest("hex"); - lines.push(`${hash} ${target.archive}`); + for (const target of TARGETS) { + const archivePath = path.join(distDir, target.archive); + const data = yield* fs.readFile(archivePath); + const hash = createHash("sha256").update(data).digest("hex"); + lines.push(`${hash} ${target.archive}`); - if (target.archive.endsWith(".zip")) { - const tarArchive = target.archive.replace(/\.zip$/, ".tar.gz"); - const tarData = await readFile(path.join(distDir, tarArchive)); - const tarHash = createHash("sha256").update(tarData).digest("hex"); - lines.push(`${tarHash} ${tarArchive}`); + if (target.archive.endsWith(".zip")) { + const tarArchive = target.archive.replace(/\.zip$/, ".tar.gz"); + const tarData = yield* fs.readFile(path.join(distDir, tarArchive)); + const tarHash = createHash("sha256").update(tarData).digest("hex"); + lines.push(`${tarHash} ${tarArchive}`); + } } - } - const linuxTargets = TARGETS.filter((target) => "nfpmArch" in target); - for (const target of linuxTargets) { - for (const fmt of LINUX_PKG_FORMATS) { - const filename = `supabase_${version}_linux_${target.nfpmArch}.${fmt}`; - const data = await readFile(path.join(distDir, filename)); - const hash = createHash("sha256").update(data).digest("hex"); - lines.push(`${hash} ${filename}`); + const linuxTargets = TARGETS.filter((target) => "nfpmArch" in target); + for (const target of linuxTargets) { + for (const fmt of LINUX_PKG_FORMATS) { + const filename = `supabase_${version}_linux_${target.nfpmArch}.${fmt}`; + const data = yield* fs.readFile(path.join(distDir, filename)); + const hash = createHash("sha256").update(data).digest("hex"); + lines.push(`${hash} ${filename}`); + } } - } - const checksumsPath = path.join(distDir, "checksums.txt"); - await writeFile(checksumsPath, `${lines.join("\n")}\n`); - console.log("Checksums written to dist/checksums.txt"); -} + const checksumsPath = path.join(distDir, "checksums.txt"); + yield* fs.writeFileString(checksumsPath, `${lines.join("\n")}\n`); + yield* Console.log("Checksums written to dist/checksums.txt"); + }); + + yield* Console.log(`Building the CLI for ${TARGETS.length} targets...\n`); + + yield* step(() => Promise.all(TARGETS.map((target) => runJob(buildTarget(target))))); -console.log(`Building the CLI for ${TARGETS.length} targets...\n`); + yield* Console.log("\nCompiling Go CLI for all targets..."); + yield* step(() => Promise.all(TARGETS.map((target) => runJob(buildGoTarget(target))))); -await Promise.all(TARGETS.map(buildTarget)); + // Must run before archiveTarget / buildLinuxPackages / generateChecksums so every + // distribution channel ships the signed bytes. + const signMode = yield* resolveSignMode(); + yield* Console.log(`\nSigning macOS binaries (mode: ${signMode})...`); + yield* signDarwinBinaries(signMode); -console.log("\nCompiling Go CLI for all targets..."); -await Promise.all(TARGETS.map(buildGoTarget)); + yield* fs.makeDirectory(distDir, { recursive: true }); + yield* step(() => Promise.all(TARGETS.map((target) => runJob(archiveTarget(target))))); -// Must run before archiveTarget / buildLinuxPackages / generateChecksums so every -// distribution channel ships the signed bytes. -const signMode = resolveSignMode(); -console.log(`\nSigning macOS binaries (mode: ${signMode})...`); -await signDarwinBinaries(signMode); + yield* buildMuslBinaries(); + yield* buildLinuxPackages(version); + yield* generateChecksums(); -await mkdir(distDir, { recursive: true }); -await Promise.all(TARGETS.map(archiveTarget)); + yield* Console.log("\nAll targets built successfully."); +}); -await buildMuslBinaries(); -await buildLinuxPackages(version); -await generateChecksums(); +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + return error.cause !== undefined ? error.cause : new Error(error.message); +} -console.log("\nAll targets built successfully."); +await Effect.runPromise( + main.pipe( + Effect.catch((error) => Effect.die(thrownValue(error))), + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + ), +); diff --git a/apps/cli/scripts/compile-options.ts b/apps/cli/scripts/compile-options.ts index b18cf9a2e2..93797ca83c 100644 --- a/apps/cli/scripts/compile-options.ts +++ b/apps/cli/scripts/compile-options.ts @@ -20,8 +20,7 @@ export const compileOptions = { * Embeds the stack release of the sources being compiled, so the binary drives exactly the owners * started from the same stack sources, whether compiled or run from source. */ -export const stackReleaseDefine = async () => ({ - SUPABASE_STACK_BUILD_ID: JSON.stringify( - await Effect.runPromise(stackSourceDigest.pipe(Effect.provide(BunServices.layer))), - ), -}); +export const stackReleaseDefine = () => + Effect.runPromise(stackSourceDigest.pipe(Effect.provide(BunServices.layer))).then((digest) => ({ + SUPABASE_STACK_BUILD_ID: JSON.stringify(digest), + })); diff --git a/apps/cli/scripts/detect-unmirrored-images.ts b/apps/cli/scripts/detect-unmirrored-images.ts index 5bf3319f12..2b69f24c3c 100644 --- a/apps/cli/scripts/detect-unmirrored-images.ts +++ b/apps/cli/scripts/detect-unmirrored-images.ts @@ -1,9 +1,21 @@ // Detects images pinned in apps/cli/src/shared/services/Dockerfile that are missing from // any mirror registry and emits them as JSON, for the mirror-template-images workflow's backfill // matrix. -import { spawnSync } from "node:child_process"; -import { appendFileSync } from "node:fs"; -import process from "node:process"; +import { constants } from "node:os"; +import { BunServices } from "@effect/platform-bun"; +import { + Config, + Console, + Data, + Deferred, + Effect, + FileSystem, + Predicate, + Schema, + Semaphore, + Stream, +} from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { dockerfileServiceImages } from "../src/shared/services/dockerfile-images.ts"; /** @@ -43,26 +55,27 @@ export interface MirrorPartition { * any one registry lands in `missing`, since the backfill must re-push it everywhere; images * already mirrored everywhere land in `mirrored` and are skipped by a re-run. */ -export async function partitionUnmirroredImages( +export const partitionUnmirroredImages = ( images: Iterable, - isMirrored: (target: string) => Promise, + isMirrored: (target: string) => Effect.Effect, registries: ReadonlyArray = MIRROR_REGISTRIES, -): Promise { - const unique = [...new Set(images)]; - const results = await Promise.all( - unique.map(async (image) => { - const presence = await Promise.all( - mirrorImageTargets(image, registries).map((target) => isMirrored(target)), - ); - return { image, mirrored: presence.every(Boolean) }; - }), - ); +): Effect.Effect => + Effect.gen(function* () { + const unique = [...new Set(images)]; + const results = yield* Effect.forEach( + unique, + (image) => + Effect.forEach(mirrorImageTargets(image, registries), (target) => isMirrored(target), { + concurrency: "unbounded", + }).pipe(Effect.map((presence) => ({ image, mirrored: presence.every(Boolean) }))), + { concurrency: "unbounded" }, + ); - return { - mirrored: results.filter((result) => result.mirrored).map((result) => result.image), - missing: results.filter((result) => !result.mirrored).map((result) => result.image), - }; -} + return { + mirrored: results.filter((result) => result.mirrored).map((result) => result.image), + missing: results.filter((result) => !result.mirrored).map((result) => result.image), + }; + }); /** * Interprets a `docker buildx imagetools inspect` result. Only a registry "not found" means the @@ -91,66 +104,222 @@ export interface InspectResult { /** Backoff before each retry of a rate-limited (HTTP 429 or OCI `toomanyrequests`) registry probe. */ export const RATE_LIMIT_RETRY_DELAYS_MS: ReadonlyArray = [10_000, 30_000, 60_000]; +class MirrorInspectError extends Data.TaggedError("MirrorInspectError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + /** * Runs a registry probe, retrying with backoff while the registry rate-limits it (HTTP 429 or OCI * `toomanyrequests`); the anonymous public.ecr.aws quota is shared per runner IP and usually * recovers within a minute. */ -export async function inspectWithRateLimitRetry( +export const inspectWithRateLimitRetry = ( target: string, - inspect: () => InspectResult, - sleep: (ms: number) => Promise, + inspect: Effect.Effect, + sleep: (ms: number) => Effect.Effect, delaysMs: ReadonlyArray = RATE_LIMIT_RETRY_DELAYS_MS, -): Promise { - for (let attempt = 0; ; attempt++) { - const { exit, stderr } = inspect(); - const delay = delaysMs[attempt]; - if ( - exit !== 0 && - /429 Too Many Requests|toomanyrequests/i.test(stderr) && - delay !== undefined - ) { - console.error(`rate limited inspecting ${target}, retrying in ${delay}ms`); - await sleep(delay); - continue; +): Effect.Effect => + Effect.gen(function* () { + for (let attempt = 0; ; attempt++) { + const { exit, stderr } = yield* inspect; + const delay = delaysMs[attempt]; + if ( + exit !== 0 && + /429 Too Many Requests|toomanyrequests/i.test(stderr) && + delay !== undefined + ) { + yield* Console.error(`rate limited inspecting ${target}, retrying in ${delay}ms`); + yield* sleep(delay); + continue; + } + return yield* Effect.try({ + try: () => inspectResultIsMirrored(target, exit, stderr), + catch: (cause) => + new MirrorInspectError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); } - return inspectResultIsMirrored(target, exit, stderr); - } -} + }); -function imageExistsOnMirror(target: string): Promise { - return inspectWithRateLimitRetry( - target, - () => { - const result = spawnSync("docker", ["buildx", "imagetools", "inspect", target], { - encoding: "utf8", - stdio: ["ignore", "ignore", "pipe"], - }); - if (result.error) { - throw result.error; - } - return { exit: result.status ?? result.signal, stderr: result.stderr }; +const platformMessage = (error: { readonly message: string; readonly cause?: unknown }) => + error.cause instanceof Error ? error.cause.message : error.message; + +const isSignal = (name: string): name is NodeJS.Signals => Object.hasOwn(constants.signals, name); + +const receivedSignal = (error: { readonly message: string; readonly cause?: unknown }) => { + const name = /^Process interrupted due to receipt of signal: '(.+)'$/.exec( + platformMessage(error), + )?.[1]; + return name !== undefined && isSignal(name) ? name : undefined; +}; + +const MAX_STDERR_BYTES = 1024 * 1024; + +function stderrOverflowError(args: ReadonlyArray): Error { + return Object.assign( + new Error("spawnSync docker ENOBUFS (stdout or stderr buffer reached maxBuffer size limit)"), + { + path: "docker", + syscall: "spawnSync docker", + errno: -constants.errno.ENOBUFS, + code: "ENOBUFS", + spawnargs: [...args], }, - (ms) => new Promise((resolve) => setTimeout(resolve, ms)), ); } -if (import.meta.main) { +/* Decodes the whole stderr at once and keeps a leading byte order mark. */ +const utf8 = new TextDecoder("utf-8", { ignoreBOM: true }); + +const inspectMirrorTarget = (target: string) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const args = ["buildx", "imagetools", "inspect", target]; + const handle = yield* spawner + .spawn( + ChildProcess.make("docker", args, { + detached: false, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }), + ) + .pipe( + /* Spawn errors keep the `spawnSync docker` syscall label. */ + Effect.tapError((error) => + Effect.sync(() => { + if ( + Predicate.hasProperty(error.cause, "syscall") && + error.cause.syscall === "spawn docker" + ) { + error.cause.syscall = "spawnSync docker"; + } + }), + ), + Effect.catchDefect((defect) => + Effect.fail( + new MirrorInspectError({ + message: defect instanceof Error ? defect.message : String(defect), + cause: defect, + }), + ), + ), + ); + const chunks: Array = []; + let stderrBytes = 0; + const readStderr = Stream.runForEach(handle.stderr, (chunk) => + Effect.suspend(() => { + stderrBytes += chunk.byteLength; + if (stderrBytes > MAX_STDERR_BYTES) { + const cause = stderrOverflowError(args); + return Effect.fail(new MirrorInspectError({ message: cause.message, cause })); + } + chunks.push(chunk); + return Effect.void; + }), + ).pipe( + Effect.map(() => { + const bytes = new Uint8Array(stderrBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return utf8.decode(bytes); + }), + ); + const [stderr, exit] = yield* Effect.all( + [ + readStderr, + handle.exitCode.pipe( + Effect.map((code): number | NodeJS.Signals => Number(code)), + Effect.catch((error) => { + const signal = receivedSignal(error); + return signal === undefined ? Effect.fail(error) : Effect.succeed(signal); + }), + ), + ], + { concurrency: "unbounded" }, + ); + return { exit, stderr } satisfies InspectResult; + }).pipe( + Effect.scoped, + Effect.catchTag("PlatformError", (error) => + Effect.fail(new MirrorInspectError({ message: platformMessage(error), cause: error.cause })), + ), + ); + +const encodeMissing = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(Schema.String))); + +const main = Effect.gen(function* () { + /* Probes run one at a time, as the registries rate-limit bursts; a rate-limit backoff does not + hold the permit, so other probes continue meanwhile. */ + const probe = yield* Semaphore.make(1); const images = dockerfileServiceImages.map((spec) => spec.image); - const { mirrored, missing } = await partitionUnmirroredImages(images, imageExistsOnMirror); + + /* Every target gets its first probe before a probe failure aborts detection, and the earliest + failure is the one reported. */ + let firstProbesLeft = new Set(images).size * MIRROR_REGISTRIES.length; + const firstProbesDone = yield* Deferred.make(); + if (firstProbesLeft === 0) { + yield* Deferred.succeed(firstProbesDone, undefined); + } + const probeFailure = yield* Deferred.make(); + const imageExistsOnMirror = (target: string) => + Effect.suspend(() => { + let firstProbe = true; + const inspect = probe.withPermit(inspectMirrorTarget(target)).pipe( + Effect.ensuring( + Effect.suspend(() => { + if (!firstProbe) { + return Effect.void; + } + firstProbe = false; + firstProbesLeft -= 1; + return firstProbesLeft === 0 + ? Deferred.succeed(firstProbesDone, undefined) + : Effect.void; + }), + ), + ); + return inspectWithRateLimitRetry(target, inspect, (ms) => Effect.sleep(ms)).pipe( + Effect.catch((error) => + Deferred.fail(probeFailure, error).pipe(Effect.andThen(Effect.never)), + ), + ); + }); + + const { mirrored, missing } = yield* Effect.raceFirst( + partitionUnmirroredImages(images, imageExistsOnMirror), + Deferred.await(firstProbesDone).pipe(Effect.andThen(Deferred.await(probeFailure))), + ); for (const image of mirrored) { - console.error(`already mirrored: ${image}`); + yield* Console.error(`already mirrored: ${image}`); } for (const image of missing) { - console.error(`needs mirror: ${image} -> ${mirrorImageTargets(image).join(", ")}`); + yield* Console.error(`needs mirror: ${image} -> ${mirrorImageTargets(image).join(", ")}`); } - const json = JSON.stringify(missing); - console.log(json); + const json = yield* encodeMissing(missing); + yield* Console.log(json); // Expose the list to the workflow as a step output when running in CI. - if (process.env.GITHUB_OUTPUT) { - appendFileSync(process.env.GITHUB_OUTPUT, `missing=${json}\n`); + const githubOutput = yield* Config.String("GITHUB_OUTPUT").pipe(Config.withDefault("")); + if (githubOutput) { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(githubOutput, `missing=${json}\n`, { flag: "a" }); } +}); + +if (import.meta.main) { + await Effect.runPromise( + main.pipe( + Effect.mapError((error) => error.cause ?? error), + Effect.provide(BunServices.layer), + ), + ); } diff --git a/apps/cli/scripts/detect-unmirrored-images.unit.test.ts b/apps/cli/scripts/detect-unmirrored-images.unit.test.ts index 07b699f80a..00a43b0f15 100644 --- a/apps/cli/scripts/detect-unmirrored-images.unit.test.ts +++ b/apps/cli/scripts/detect-unmirrored-images.unit.test.ts @@ -1,3 +1,5 @@ +import { it } from "@effect/vitest"; +import { Effect } from "effect"; import { describe, expect, test } from "vitest"; import { inspectResultIsMirrored, @@ -26,41 +28,46 @@ describe("detect unmirrored images", () => { ]); }); - test("an image is mirrored only when present on ALL registries", async () => { - const present = new Set([ - // kong is on both registries -> mirrored. - "public.ecr.aws/supabase/kong:2.8.1", - "ghcr.io/supabase/kong:2.8.1", - // postgrest is only on ghcr.io -> partial mirror, must be re-pushed. - "ghcr.io/supabase/postgrest:v14.14", - ]); - const queried: string[] = []; - const isMirrored = (target: string) => { - queried.push(target); - return Promise.resolve(present.has(target)); - }; + it.effect("an image is mirrored only when present on ALL registries", () => + Effect.gen(function* () { + const present = new Set([ + // kong is on both registries -> mirrored. + "public.ecr.aws/supabase/kong:2.8.1", + "ghcr.io/supabase/kong:2.8.1", + // postgrest is only on ghcr.io -> partial mirror, must be re-pushed. + "ghcr.io/supabase/postgrest:v14.14", + ]); + const queried: string[] = []; + const isMirrored = (target: string) => + Effect.sync(() => { + queried.push(target); + return present.has(target); + }); - const { mirrored, missing } = await partitionUnmirroredImages( - // Duplicate kong to prove de-duplication. - ["library/kong:2.8.1", "postgrest/postgrest:v14.14", "library/kong:2.8.1"], - isMirrored, - ); + const { mirrored, missing } = yield* partitionUnmirroredImages( + // Duplicate kong to prove de-duplication. + ["library/kong:2.8.1", "postgrest/postgrest:v14.14", "library/kong:2.8.1"], + isMirrored, + ); - expect(mirrored).toEqual(["library/kong:2.8.1"]); - expect(missing).toEqual(["postgrest/postgrest:v14.14"]); - expect(queried).toHaveLength(4); - }); + expect(mirrored).toEqual(["library/kong:2.8.1"]); + expect(missing).toEqual(["postgrest/postgrest:v14.14"]); + expect(queried).toHaveLength(4); + }), + ); - test("is a no-op once everything is on every registry (idempotent re-run)", async () => { - const allMirrored = () => Promise.resolve(true); - const { mirrored, missing } = await partitionUnmirroredImages( - ["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"], - allMirrored, - ); + it.effect("is a no-op once everything is on every registry (idempotent re-run)", () => + Effect.gen(function* () { + const allMirrored = () => Effect.succeed(true); + const { mirrored, missing } = yield* partitionUnmirroredImages( + ["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"], + allMirrored, + ); - expect(missing).toEqual([]); - expect(mirrored).toEqual(["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"]); - }); + expect(missing).toEqual([]); + expect(mirrored).toEqual(["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"]); + }), + ); test("only a registry not-found marks an image as unmirrored", () => { const target = "public.ecr.aws/supabase/realtime:v2.144.2"; @@ -79,27 +86,37 @@ describe("detect unmirrored images", () => { ); }); - test("a rate-limited probe is retried with backoff and only aborts once retries run out", async () => { - const target = "public.ecr.aws/supabase/postgres:17.11.0.006"; - const rateLimited = { - exit: 1, - stderr: "ERROR: httpReadSeeker: failed open: unexpected status: 429 Too Many Requests\n", - }; - const slept: number[] = []; - const sleep = (ms: number) => { - slept.push(ms); - return Promise.resolve(); - }; + it.effect( + "a rate-limited probe is retried with backoff and only aborts once retries run out", + () => + Effect.gen(function* () { + const target = "public.ecr.aws/supabase/postgres:17.11.0.006"; + const rateLimited = { + exit: 1, + stderr: "ERROR: httpReadSeeker: failed open: unexpected status: 429 Too Many Requests\n", + }; + const slept: number[] = []; + const sleep = (ms: number) => + Effect.sync(() => { + slept.push(ms); + }); - const quotaExceeded = { exit: 1, stderr: "ERROR: toomanyrequests: Data limit exceeded\n" }; - const responses = [rateLimited, quotaExceeded, { exit: 0, stderr: "" }]; - await expect( - inspectWithRateLimitRetry(target, () => responses.shift()!, sleep, [1, 2, 3]), - ).resolves.toBe(true); - expect(slept).toEqual([1, 2]); + const quotaExceeded = { exit: 1, stderr: "ERROR: toomanyrequests: Data limit exceeded\n" }; + const responses = [rateLimited, quotaExceeded, { exit: 0, stderr: "" }]; + expect( + yield* inspectWithRateLimitRetry( + target, + Effect.sync(() => responses.shift()!), + sleep, + [1, 2, 3], + ), + ).toBe(true); + expect(slept).toEqual([1, 2]); - await expect(inspectWithRateLimitRetry(target, () => rateLimited, sleep, [1])).rejects.toThrow( - "429 Too Many Requests", - ); - }); + const error = yield* Effect.flip( + inspectWithRateLimitRetry(target, Effect.succeed(rateLimited), sleep, [1]), + ); + expect(error.message).toContain("429 Too Many Requests"); + }), + ); }); diff --git a/apps/cli/scripts/generate-docs-spec.ts b/apps/cli/scripts/generate-docs-spec.ts index 83530c0455..9f85e9c2b4 100644 --- a/apps/cli/scripts/generate-docs-spec.ts +++ b/apps/cli/scripts/generate-docs-spec.ts @@ -8,8 +8,7 @@ * version is a semantic-release placeholder and is never used here. */ import { BunServices } from "@effect/platform-bun"; -import { Effect } from "effect"; -import path from "node:path"; +import { Effect, Path } from "effect"; import process from "node:process"; import { readDocsContent } from "../src/docs/docs-spec.content.ts"; import { buildDocsSpec, stringifyDocsSpec } from "../src/docs/docs-spec.ts"; @@ -22,7 +21,8 @@ function resolveVersion(): string { } const content = await Effect.runPromise( - readDocsContent(path.resolve(import.meta.dir, "../docs")).pipe( + Path.Path.pipe( + Effect.flatMap((path) => readDocsContent(path.resolve(import.meta.dir, "../docs"))), Effect.provide(BunServices.layer), Effect.mapError((error) => error.cause ?? error), ), diff --git a/apps/cli/scripts/generate-docs.ts b/apps/cli/scripts/generate-docs.ts index e359d5ca00..886f0866ce 100644 --- a/apps/cli/scripts/generate-docs.ts +++ b/apps/cli/scripts/generate-docs.ts @@ -1,118 +1,144 @@ -import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"; -import path from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import process from "node:process"; import { CLI_CONFIG_SCHEMA_URL, PROJECT_CONFIG_SCHEMA_URL } from "@supabase/config"; import { Lock, LOCK_SCHEMA_URL } from "@supabase/stack/effect"; -import { Schema } from "effect"; +import { Console, Effect, FileSystem, Layer, Path, Schema } from "effect"; import { rootCommand } from "../src/cli/root.ts"; import { collectCommands, getHelpDoc } from "../src/shared/cli/command-docs.ts"; import { formatHelpDocAsMarkdown } from "../src/shared/cli/markdown-formatter.ts"; const BINARY_NAME = "supabase"; -const defaultContentDir = path.resolve(import.meta.dir, "../../../apps/docs/content/docs/commands"); -const defaultDocsPublicDir = path.resolve(import.meta.dir, "../../../apps/docs/public"); -const configPackageDistDir = path.resolve(import.meta.dir, "../../../packages/config/dist"); -const contentDir = process.argv[2] - ? path.resolve(process.cwd(), process.argv[2]) - : defaultContentDir; - -function generateCommandDocs() { - const leaves = collectCommands(rootCommand, [BINARY_NAME]).filter( - ({ command, commandPath }) => commandPath.length > 1 && command.subcommands.length === 0, +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 })); + +const main = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const defaultContentDir = path.resolve( + import.meta.dir, + "../../../apps/docs/content/docs/commands", ); - - const pages: Array<{ slug: string; title: string; description: string }> = []; - - for (const { command, commandPath } of leaves) { - const helpDoc = getHelpDoc(command, commandPath); - const body = formatHelpDocAsMarkdown(helpDoc); - - const title = commandPath.slice(1).join(" "); - const description = - (command as any).shortDescription ?? helpDoc.description?.split("\n")[0] ?? ""; - - const slug = commandPath.slice(1).join("/"); - const frontmatter = [ + const defaultDocsPublicDir = path.resolve(import.meta.dir, "../../../apps/docs/public"); + const configPackageDistDir = path.resolve(import.meta.dir, "../../../packages/config/dist"); + const contentDir = process.argv[2] + ? path.resolve(process.cwd(), process.argv[2]) + : defaultContentDir; + + const generateCommandDocs = Effect.fnUntraced(function* () { + const leaves = collectCommands(rootCommand, [BINARY_NAME]).filter( + ({ command, commandPath }) => commandPath.length > 1 && command.subcommands.length === 0, + ); + + const pages: Array<{ slug: string; title: string; description: string }> = []; + + for (const { command, commandPath } of leaves) { + const helpDoc = getHelpDoc(command, commandPath); + const body = formatHelpDocAsMarkdown(helpDoc); + + const title = commandPath.slice(1).join(" "); + const description = + (command as any).shortDescription ?? helpDoc.description?.split("\n")[0] ?? ""; + + const slug = commandPath.slice(1).join("/"); + const frontmatter = [ + "---", + `title: "${BINARY_NAME} ${title}"`, + `description: "${description.replace(/"/g, '\\"')}"`, + "---", + ].join("\n"); + + const mdxContent = `${frontmatter}\n\n${body}`; + + const filePath = path.join(contentDir, `${slug}.mdx`); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, mdxContent); + pages.push({ slug, title, description }); + + yield* Console.log(`Generated: commands/${slug}.mdx`); + } + + const indexFrontmatter = [ "---", - `title: "${BINARY_NAME} ${title}"`, - `description: "${description.replace(/"/g, '\\"')}"`, + "title: Command reference", + "description: Complete reference for all Supabase CLI commands", "---", ].join("\n"); - const mdxContent = `${frontmatter}\n\n${body}`; - - const filePath = path.join(contentDir, `${slug}.mdx`); - mkdirSync(path.dirname(filePath), { recursive: true }); - writeFileSync(filePath, mdxContent); - pages.push({ slug, title, description }); - - console.log(`Generated: commands/${slug}.mdx`); - } - - const indexFrontmatter = [ - "---", - "title: Command reference", - "description: Complete reference for all Supabase CLI commands", - "---", - ].join("\n"); - - const rows = pages.map( - (page) => - `| [\`${BINARY_NAME} ${page.title}\`](/docs/commands/${page.slug}) | ${page.description} |`, - ); - const table = `| Command | Description |\n| --- | --- |\n${rows.join("\n")}`; - const indexContent = `${indexFrontmatter}\n\n${table}\n`; - - writeFileSync(path.join(contentDir, "index.mdx"), indexContent); - console.log("Generated: commands/index.mdx"); - - const metaContent = { - title: "Commands", - pages: ["index", ...pages.map((page) => page.slug.split("/").pop())], - }; - writeFileSync(path.join(contentDir, "meta.json"), JSON.stringify(metaContent, null, 2)); - - console.log(`\nGenerated ${pages.length} command page(s)`); -} - -/** - * Copies `@supabase/config`'s post-processed `dist/*.json` schema artifact to its docs-site - * public path rather than re-rendering it here, since re-rendering would bypass - * `json-schema-postprocess.ts` and produce a mismatched `$id`. Requires - * `@supabase/config#build` to have already run (wired via `turbo.json`). - */ -function copyConfigSchemaAsset(schemaUrl: string, distFileName: string) { - const schemaPathname = new URL(schemaUrl).pathname.replace(/^\/docs/, ""); - const destPath = path.join(defaultDocsPublicDir, schemaPathname); - const sourcePath = path.join(configPackageDistDir, distFileName); - - mkdirSync(path.dirname(destPath), { recursive: true }); - copyFileSync(sourcePath, destPath); - - console.log(`Generated: ${path.relative(path.resolve(import.meta.dir, "../../.."), destPath)}`); -} - -/** Writes the `supabase.lock` v1 JSON Schema, generated from the stack's lock schema, to its docs-site public path. */ -function writeLockSchemaAsset() { - const { schema, definitions } = Schema.toJsonSchemaDocument(Lock); - const document = { - $schema: "https://json-schema.org/draft/2020-12/schema", - $id: LOCK_SCHEMA_URL, - title: "supabase.lock", - description: "The service versions that supabase start pins in supabase/supabase.lock.", - ...schema, - $defs: definitions, - }; - const schemaPathname = new URL(LOCK_SCHEMA_URL).pathname.replace(/^\/docs/, ""); - const destPath = path.join(defaultDocsPublicDir, schemaPathname); - - mkdirSync(path.dirname(destPath), { recursive: true }); - writeFileSync(destPath, `${JSON.stringify(document, null, 2)}\n`); - - console.log(`Generated: ${path.relative(path.resolve(import.meta.dir, "../../.."), destPath)}`); + const rows = pages.map( + (page) => + `| [\`${BINARY_NAME} ${page.title}\`](/docs/commands/${page.slug}) | ${page.description} |`, + ); + const table = `| Command | Description |\n| --- | --- |\n${rows.join("\n")}`; + const indexContent = `${indexFrontmatter}\n\n${table}\n`; + + yield* fs.writeFileString(path.join(contentDir, "index.mdx"), indexContent); + yield* Console.log("Generated: commands/index.mdx"); + + const metaContent = { + title: "Commands", + pages: ["index", ...pages.map((page) => page.slug.split("/").pop())], + }; + yield* fs.writeFileString(path.join(contentDir, "meta.json"), yield* encodeJson(metaContent)); + + yield* Console.log(`\nGenerated ${pages.length} command page(s)`); + }); + + /** + * Copies `@supabase/config`'s post-processed `dist/*.json` schema artifact to its docs-site + * public path rather than re-rendering it here, since re-rendering would bypass + * `json-schema-postprocess.ts` and produce a mismatched `$id`. Requires + * `@supabase/config#build` to have already run (wired via `turbo.json`). + */ + const copyConfigSchemaAsset = Effect.fnUntraced(function* ( + schemaUrl: string, + distFileName: string, + ) { + const schemaPathname = new URL(schemaUrl).pathname.replace(/^\/docs/, ""); + const destPath = path.join(defaultDocsPublicDir, schemaPathname); + const sourcePath = path.join(configPackageDistDir, distFileName); + + yield* fs.makeDirectory(path.dirname(destPath), { recursive: true }); + yield* fs.copyFile(sourcePath, destPath); + + yield* Console.log( + `Generated: ${path.relative(path.resolve(import.meta.dir, "../../.."), destPath)}`, + ); + }); + + /** Writes the `supabase.lock` v1 JSON Schema, generated from the stack's lock schema, to its docs-site public path. */ + const writeLockSchemaAsset = Effect.fnUntraced(function* () { + const { schema, definitions } = Schema.toJsonSchemaDocument(Lock); + const document = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: LOCK_SCHEMA_URL, + title: "supabase.lock", + description: "The service versions that supabase start pins in supabase/supabase.lock.", + ...schema, + $defs: definitions, + }; + const schemaPathname = new URL(LOCK_SCHEMA_URL).pathname.replace(/^\/docs/, ""); + const destPath = path.join(defaultDocsPublicDir, schemaPathname); + + yield* fs.makeDirectory(path.dirname(destPath), { recursive: true }); + yield* fs.writeFileString(destPath, `${yield* encodeJson(document)}\n`); + + yield* Console.log( + `Generated: ${path.relative(path.resolve(import.meta.dir, "../../.."), destPath)}`, + ); + }); + + yield* generateCommandDocs(); + yield* writeLockSchemaAsset(); + yield* copyConfigSchemaAsset(CLI_CONFIG_SCHEMA_URL, "schema.json"); + yield* copyConfigSchemaAsset(PROJECT_CONFIG_SCHEMA_URL, "project-schema.json"); +}); + +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + return error.cause !== undefined ? error.cause : new Error(error.message); } -generateCommandDocs(); -writeLockSchemaAsset(); -copyConfigSchemaAsset(CLI_CONFIG_SCHEMA_URL, "schema.json"); -copyConfigSchemaAsset(PROJECT_CONFIG_SCHEMA_URL, "project-schema.json"); +await Effect.runPromise( + main.pipe( + Effect.catch((error) => Effect.die(thrownValue(error))), + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + ), +); diff --git a/apps/cli/scripts/propose-release-notes.ts b/apps/cli/scripts/propose-release-notes.ts index c1ab14505e..225090a74a 100644 --- a/apps/cli/scripts/propose-release-notes.ts +++ b/apps/cli/scripts/propose-release-notes.ts @@ -7,14 +7,24 @@ // Usage: bun apps/cli/scripts/propose-release-notes.ts --tag v2.101.0 [--dry-run] [--apply] // [--render-only] [--model ] import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { $ } from "bun"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import path from "node:path"; +import { Console, Data, Effect, FileSystem, Layer, Path, Stream } from "effect"; import process from "node:process"; import { parseArgs } from "node:util"; import aiModels from "../../../.github/ai-models.json"; +class ShellCommandFailed extends Data.TaggedError("ShellCommandFailed")<{ + readonly cause: unknown; +}> {} + +class AgentQueryFailed extends Data.TaggedError("AgentQueryFailed")<{ readonly cause: unknown }> {} + +const shell = (command: () => Promise) => + Effect.tryPromise({ try: command, catch: (cause) => new ShellCommandFailed({ cause }) }); + +const utf8 = new TextDecoder("utf-8", { ignoreBOM: true }); + const { values } = parseArgs({ options: { tag: { type: "string" }, @@ -26,129 +36,147 @@ const { values } = parseArgs({ strict: true, }); -const tag = values.tag; -if (!tag) { - console.error("--tag is required (e.g. --tag v2.101.0)"); - process.exit(2); -} -const version = tag.replace(/^v/, ""); -const apply = values.apply === true && values["dry-run"] !== true; - -const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim(); -const promptPath = path.join(repoRoot, "tools/release/release-notes-prompt.md"); -const backfillScript = path.join(repoRoot, "apps/cli/scripts/backfill-release-notes.ts"); -const notesDir = path.join(repoRoot, "release-notes"); -const notesPath = path.join(notesDir, `v${version}.md`); - -console.error(`==> Re-deriving raw semantic-release notes for ${tag}`); -const rawNotes = (await $`bun ${backfillScript} --tag ${tag}`.cwd(repoRoot).text()).trim(); -if (!rawNotes) { - console.error(`backfill-release-notes produced no output for ${tag}`); - process.exit(1); -} +const main = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; -const promptTemplate = await readFile(promptPath, "utf8"); -const placeholder = "{{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}}"; -if (!promptTemplate.includes(placeholder)) { - console.error(`Prompt template at ${promptPath} is missing ${placeholder}`); - process.exit(1); -} -const rendered = promptTemplate.replace(placeholder, rawNotes); + const tag = values.tag; + if (!tag) { + yield* Console.error("--tag is required (e.g. --tag v2.101.0)"); + return yield* Effect.sync(() => process.exit(2)); + } + const version = tag.replace(/^v/, ""); + const apply = values.apply === true && values["dry-run"] !== true; + + const repoRoot = (yield* shell(() => $`git rev-parse --show-toplevel`.text())).trim(); + const promptPath = path.join(repoRoot, "tools/release/release-notes-prompt.md"); + const backfillScript = path.join(repoRoot, "apps/cli/scripts/backfill-release-notes.ts"); + const notesDir = path.join(repoRoot, "release-notes"); + const notesPath = path.join(notesDir, `v${version}.md`); + + yield* Console.error(`==> Re-deriving raw semantic-release notes for ${tag}`); + const rawNotes = (yield* shell(() => + $`bun ${backfillScript} --tag ${tag}`.cwd(repoRoot).text(), + )).trim(); + if (!rawNotes) { + yield* Console.error(`backfill-release-notes produced no output for ${tag}`); + return yield* Effect.sync(() => process.exit(1)); + } -if (values["render-only"]) { - process.stdout.write(rendered); - process.exit(0); -} + const promptTemplate = utf8.decode(yield* fs.readFile(promptPath)); + const placeholder = "{{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}}"; + if (!promptTemplate.includes(placeholder)) { + yield* Console.error(`Prompt template at ${promptPath} is missing ${placeholder}`); + return yield* Effect.sync(() => process.exit(1)); + } + const rendered = promptTemplate.replace(placeholder, rawNotes); -console.error(`==> Running Claude Agent SDK (model=${values.model})`); -const options: Options = { - model: values.model, - // WebFetch/WebSearch investigate PR bodies and linked issues; Bash uses `gh` for - // authenticated GitHub queries instead of HTML scraping. Edit/Write are excluded — the - // script owns the final file output. - allowedTools: ["WebFetch", "WebSearch", "Bash"], - // Skips the repo's CLAUDE.md and settings.json — the prompt is self-contained. - settingSources: [], - cwd: repoRoot, - effort: "low", -}; - -let finalText = ""; -let cost = 0; -const stream = query({ prompt: rendered, options }); -for await (const msg of stream) { - if (msg.type === "result") { - if (msg.subtype === "success") { - finalText = msg.result; - cost = msg.total_cost_usd; - } else { - console.error(`Agent failed: ${msg.subtype}`); - if (msg.errors?.length) console.error(msg.errors.join("\n")); - process.exit(1); - } + if (values["render-only"]) { + return yield* Effect.sync(() => { + process.stdout.write(rendered); + process.exit(0); + }); } -} -if (!finalText.trim()) { - console.error("Agent returned no result text"); - process.exit(1); -} + yield* Console.error(`==> Running Claude Agent SDK (model=${values.model})`); + const options: Options = { + model: values.model, + // WebFetch/WebSearch investigate PR bodies and linked issues; Bash uses `gh` for + // authenticated GitHub queries instead of HTML scraping. Edit/Write are excluded — the + // script owns the final file output. + allowedTools: ["WebFetch", "WebSearch", "Bash"], + // Skips the repo's CLAUDE.md and settings.json — the prompt is self-contained. + settingSources: [], + cwd: repoRoot, + effort: "low", + }; + + let finalText = ""; + let cost = 0; + yield* Stream.fromAsyncIterable( + query({ prompt: rendered, options }), + (cause) => new AgentQueryFailed({ cause }), + ).pipe( + Stream.runForEach((msg) => + Effect.gen(function* () { + if (msg.type === "result") { + if (msg.subtype === "success") { + finalText = msg.result; + cost = msg.total_cost_usd; + } else { + yield* Console.error(`Agent failed: ${msg.subtype}`); + if (msg.errors?.length) yield* Console.error(msg.errors.join("\n")); + return yield* Effect.sync(() => process.exit(1)); + } + } + }), + ), + ); + + if (!finalText.trim()) { + yield* Console.error("Agent returned no result text"); + return yield* Effect.sync(() => process.exit(1)); + } -const normalized = finalText.endsWith("\n") ? finalText : `${finalText}\n`; -console.error(`==> Agent finished (cost ~$${cost.toFixed(4)})`); + const normalized = finalText.endsWith("\n") ? finalText : `${finalText}\n`; + yield* Console.error(`==> Agent finished (cost ~$${cost.toFixed(4)})`); -if (!apply) { - process.stdout.write(normalized); - process.exit(0); -} + if (!apply) { + return yield* Effect.sync(() => { + process.stdout.write(normalized); + process.exit(0); + }); + } -await mkdir(notesDir, { recursive: true }); -if (existsSync(notesPath)) { - console.error( - `Refusing to overwrite existing ${path.relative(repoRoot, notesPath)}. ` + - `Delete it or rerun with --dry-run to preview.`, - ); - process.exit(1); -} -await writeFile(notesPath, normalized); -console.error(`==> Wrote ${path.relative(repoRoot, notesPath)}`); - -const branch = `release-notes/v${version}`; -// Branches from origin/develop, not the checked-out ref, so a feature branch that has diverged -// doesn't drag its commits into the PR; the notes file is untracked, so this leaves it in place. -// Targets develop rather than main so an accidental merge can't rewrite main's history. -await $`git fetch --no-tags origin develop`.cwd(repoRoot).nothrow(); -await $`git checkout -B ${branch} origin/develop`.cwd(repoRoot); -await $`git add ${notesPath}`.cwd(repoRoot); -const commitMessage = `docs(release): propose user-facing notes for ${tag}`; -await $`git commit -m ${commitMessage}`.cwd(repoRoot); - -console.error(`==> Pushing ${branch}`); -let pushed = false; -for (let attempt = 0; attempt < 4; attempt++) { - const result = await $`git push -u origin ${branch}`.cwd(repoRoot).nothrow(); - if (result.exitCode === 0) { - pushed = true; - break; + yield* fs.makeDirectory(notesDir, { recursive: true }); + if (yield* fs.exists(notesPath).pipe(Effect.orElseSucceed(() => false))) { + yield* Console.error( + `Refusing to overwrite existing ${path.relative(repoRoot, notesPath)}. ` + + `Delete it or rerun with --dry-run to preview.`, + ); + return yield* Effect.sync(() => process.exit(1)); + } + yield* fs.writeFileString(notesPath, normalized); + yield* Console.error(`==> Wrote ${path.relative(repoRoot, notesPath)}`); + + const branch = `release-notes/v${version}`; + // Branches from origin/develop, not the checked-out ref, so a feature branch that has diverged + // doesn't drag its commits into the PR; the notes file is untracked, so this leaves it in place. + // Targets develop rather than main so an accidental merge can't rewrite main's history. + yield* shell(() => $`git fetch --no-tags origin develop`.cwd(repoRoot).nothrow()); + yield* shell(() => $`git checkout -B ${branch} origin/develop`.cwd(repoRoot)); + yield* shell(() => $`git add ${notesPath}`.cwd(repoRoot)); + const commitMessage = `docs(release): propose user-facing notes for ${tag}`; + yield* shell(() => $`git commit -m ${commitMessage}`.cwd(repoRoot)); + + yield* Console.error(`==> Pushing ${branch}`); + let pushed = false; + for (let attempt = 0; attempt < 4; attempt++) { + const result = yield* shell(() => $`git push -u origin ${branch}`.cwd(repoRoot).nothrow()); + if (result.exitCode === 0) { + pushed = true; + break; + } + const wait = 2 ** (attempt + 1) * 1000; + yield* Console.error(`Push failed (attempt ${attempt + 1}/4); retrying in ${wait / 1000}s`); + yield* Effect.sleep(wait); + } + if (!pushed) { + yield* Console.error("git push failed after 4 attempts"); + return yield* Effect.sync(() => process.exit(1)); } - const wait = 2 ** (attempt + 1) * 1000; - console.error(`Push failed (attempt ${attempt + 1}/4); retrying in ${wait / 1000}s`); - await new Promise((r) => setTimeout(r, wait)); -} -if (!pushed) { - console.error("git push failed after 4 attempts"); - process.exit(1); -} -// Ensures the `do not merge` label exists before attaching it — a reminder for reviewers; the -// approval workflow never actually merges. -const labelName = "do not merge"; -await $`gh label create ${labelName} --color B60205 --description ${"Approve to apply; do not merge."} --force` - .cwd(repoRoot) - .nothrow(); + // Ensures the `do not merge` label exists before attaching it — a reminder for reviewers; the + // approval workflow never actually merges. + const labelName = "do not merge"; + yield* shell(() => + $`gh label create ${labelName} --color B60205 --description ${"Approve to apply; do not merge."} --force` + .cwd(repoRoot) + .nothrow(), + ); -const releaseUrl = `https://github.com/supabase/cli/releases/tag/${tag}`; -const prBody = `Proposed user-facing release notes for \`${tag}\`, generated by \`apps/cli/scripts/propose-release-notes.ts\` against \`tools/release/release-notes-prompt.md\`. + const releaseUrl = `https://github.com/supabase/cli/releases/tag/${tag}`; + const prBody = `Proposed user-facing release notes for \`${tag}\`, generated by \`apps/cli/scripts/propose-release-notes.ts\` against \`tools/release/release-notes-prompt.md\`. ## How to update the notes @@ -175,7 +203,21 @@ Close the PR without approving. The auto-generated semantic-release body for \`$ After this PR is closed, rerun the **Propose release notes** workflow from the Actions tab against \`${tag}\` to get a fresh proposal. `; -await $`gh pr create --title ${`docs(release): notes for ${tag}`} --body ${prBody} --base develop --head ${branch} --label ${labelName}`.cwd( - repoRoot, + yield* shell(() => + $`gh pr create --title ${`docs(release): notes for ${tag}`} --body ${prBody} --base develop --head ${branch} --label ${labelName}`.cwd( + repoRoot, + ), + ); + yield* Console.error(`==> PR opened for ${branch}`); +}); + +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + return error.cause !== undefined ? error.cause : new Error(error.message); +} + +await Effect.runPromise( + main.pipe( + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + Effect.catch((error) => Effect.die(thrownValue(error))), + ), ); -console.error(`==> PR opened for ${branch}`); diff --git a/apps/cli/scripts/publish-docs-spec.e2e.test.ts b/apps/cli/scripts/publish-docs-spec.e2e.test.ts index 819c6b09d4..f13b0a7340 100644 --- a/apps/cli/scripts/publish-docs-spec.e2e.test.ts +++ b/apps/cli/scripts/publish-docs-spec.e2e.test.ts @@ -1,8 +1,8 @@ -import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { stringify } from "yaml"; -const cliRoot = path.resolve(import.meta.dirname, ".."); +const cliRoot = fileURLToPath(new URL("..", import.meta.url)); function runScript( args: ReadonlyArray, diff --git a/apps/cli/scripts/publish-docs-spec.ts b/apps/cli/scripts/publish-docs-spec.ts index 310f2be9ce..a02343d4fd 100644 --- a/apps/cli/scripts/publish-docs-spec.ts +++ b/apps/cli/scripts/publish-docs-spec.ts @@ -7,14 +7,42 @@ * No-op (exit 0) when the spec is already published and no PR is missing; a branch already ahead * of base still gets its PR ensured, so a failed prior run is repaired by the next release. */ +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { $ } from "bun"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { Console, Data, Effect, FileSystem, Layer, Path } from "effect"; import process from "node:process"; import { parseArgs } from "node:util"; import { parse } from "yaml"; +class ShellCommandFailed extends Data.TaggedError("ShellCommandFailed")<{ + readonly cause: unknown; +}> {} + +class StdinReadFailed extends Data.TaggedError("StdinReadFailed")<{ readonly cause: unknown }> {} + +class SpecYamlInvalid extends Data.TaggedError("SpecYamlInvalid")<{ readonly cause: unknown }> {} + +class JsonFailed extends Data.TaggedError("JsonFailed")<{ readonly cause: unknown }> {} + +const shell = (command: () => Promise) => + Effect.tryPromise({ try: command, catch: (cause) => new ShellCommandFailed({ cause }) }); + +/* Plain `JSON` keeps the engine's own error for a cyclic spec value or malformed `gh` output, + which a `Schema` codec would replace with its issue text. */ +const json = (run: () => A) => + Effect.try({ try: run, catch: (cause) => new JsonFailed({ cause }) }); + +function jsonText(value: unknown): string { + return `${JSON.stringify(value)}`; +} + +function ownPullsOf(existing: string, repo: string) { + const openPulls: Array<{ headRepositoryOwner?: { login?: string } }> = JSON.parse(existing); + return openPulls.filter( + (pull) => pull.headRepositoryOwner?.login?.toLowerCase() === repo.split("/")[0]?.toLowerCase(), + ); +} + const { values } = parseArgs({ options: { version: { type: "string" }, @@ -26,130 +54,166 @@ const { values } = parseArgs({ }, }); -const versionArgument = values.version; -if (!versionArgument) { - console.error( - "Usage: bun scripts/generate-docs-spec.ts | bun scripts/publish-docs-spec.ts --version [--repo ] [--spec-path ] [--branch ] [--base ] [--dry-run]", - ); - process.exit(1); -} -const version = versionArgument.startsWith("v") ? versionArgument.slice(1) : versionArgument; - -const repo = values.repo!; -const specPath = values["spec-path"]!; -const branch = values.branch!; -const base = values.base!; -const dryRun = values["dry-run"]!; - -const spec = await Bun.stdin.text(); -if (spec.trim().length === 0) { - console.error("Refusing to publish an empty spec: nothing was piped in on stdin."); - process.exit(1); -} +const main = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; -let parsed: { clispec?: unknown; info?: { version?: unknown }; commands?: unknown }; -try { - parsed = parse(spec); -} catch (error) { - console.error(`Refusing to publish: stdin is not valid YAML (${error}).`); - process.exit(1); -} -if (parsed?.clispec !== "001") { - console.error( - `Refusing to publish: expected clispec "001", got ${JSON.stringify(parsed?.clispec)}.`, - ); - process.exit(1); -} -if (parsed.info?.version !== version) { - console.error( - `Refusing to publish: the spec's version ${JSON.stringify(parsed.info?.version)} does not match --version ${version}.`, - ); - process.exit(1); -} -const commands = parsed.commands; -if (!Array.isArray(commands) || commands.length < 144) { - console.error( - `Refusing to publish: the spec has ${Array.isArray(commands) ? commands.length : 0} commands, expected at least 144.`, - ); - process.exit(1); -} + const versionArgument = values.version; + if (!versionArgument) { + yield* Console.error( + "Usage: bun scripts/generate-docs-spec.ts | bun scripts/publish-docs-spec.ts --version [--repo ] [--spec-path ] [--branch ] [--base ] [--dry-run]", + ); + return yield* Effect.sync(() => process.exit(1)); + } + const version = versionArgument.startsWith("v") ? versionArgument.slice(1) : versionArgument; -if (dryRun) { - console.log(`Would publish ${spec.length} bytes to ${repo}:${specPath} on branch ${branch}`); - process.exit(0); -} + const repo = values.repo!; + const specPath = values["spec-path"]!; + const branch = values.branch!; + const base = values.base!; + const dryRun = values["dry-run"]!; -// `--depth 1` suffices: an existing `cli/ref-doc` branch is fetched at its tip and the new spec -// lands as one commit on top, so commits pushed onto an open PR survive later releases. -const tmpDir = await mkdtemp(path.join(tmpdir(), "supabase-docs-")); -try { - await $`git clone --quiet --depth 1 --branch ${base} https://github.com/${repo}.git ${tmpDir}`; - - const remoteBranch = await $`git -C ${tmpDir} fetch --quiet --depth 1 origin ${branch}` - .nothrow() - .quiet(); - if (remoteBranch.exitCode === 0) { - await $`git -C ${tmpDir} checkout --quiet -B ${branch} FETCH_HEAD`; - } else { - await $`git -C ${tmpDir} checkout --quiet -B ${branch}`; + const spec = yield* Effect.tryPromise({ + try: () => Bun.stdin.text(), + catch: (cause) => new StdinReadFailed({ cause }), + }); + if (spec.trim().length === 0) { + yield* Console.error("Refusing to publish an empty spec: nothing was piped in on stdin."); + return yield* Effect.sync(() => process.exit(1)); } - const target = path.join(tmpDir, specPath); - await writeFile(target, spec); - const prettier = path.resolve(import.meta.dir, "../node_modules/.bin/prettier"); - await $`${prettier} --no-config --single-quote --print-width 100 --log-level warn --write ${target}`; - - const message = "chore: update cli reference doc"; - await $`git -C ${tmpDir} add ${specPath}`; - const unchanged = await $`git -C ${tmpDir} diff --quiet --cached -- ${specPath}` - .nothrow() - .quiet(); - if (unchanged.exitCode === 0) { - console.log( - remoteBranch.exitCode === 0 - ? `${specPath} is already up to date on ${branch}` - : `${specPath} is already up to date in ${repo}`, + const parsed: { clispec?: unknown; info?: { version?: unknown }; commands?: unknown } = + yield* Effect.try({ + try: () => parse(spec), + catch: (cause) => new SpecYamlInvalid({ cause }), + }).pipe( + Effect.catch((error) => + Console.error(`Refusing to publish: stdin is not valid YAML (${error.cause}).`).pipe( + Effect.andThen(Effect.sync(() => process.exit(1))), + ), + ), + ); + if (parsed?.clispec !== "001") { + yield* Console.error( + `Refusing to publish: expected clispec "001", got ${yield* json(() => jsonText(parsed?.clispec))}.`, ); - } else { - await $`git -C ${tmpDir} -c ${"user.name=github-actions[bot]"} -c ${"user.email=41898282+github-actions[bot]@users.noreply.github.com"} commit --quiet -m ${message}`; - await $`git -C ${tmpDir} push --quiet origin ${branch}`; - console.log(`Pushed ${branch} to ${repo}`); + return yield* Effect.sync(() => process.exit(1)); + } + if (parsed.info?.version !== version) { + yield* Console.error( + `Refusing to publish: the spec's version ${yield* json(() => jsonText(parsed.info?.version))} does not match --version ${version}.`, + ); + return yield* Effect.sync(() => process.exit(1)); + } + const commands = parsed.commands; + if (!Array.isArray(commands) || commands.length < 144) { + yield* Console.error( + `Refusing to publish: the spec has ${Array.isArray(commands) ? commands.length : 0} commands, expected at least 144.`, + ); + return yield* Effect.sync(() => process.exit(1)); } - const unpublished = await $`git -C ${tmpDir} diff --quiet origin/${base} HEAD -- ${specPath}` - .nothrow() - .quiet(); - if (unpublished.exitCode === 1) { - const existing = - await $`gh pr list --repo ${repo} --head ${branch} --base ${base} --state open --json number,headRepositoryOwner` - .cwd(tmpDir) - .text(); - const openPulls: Array<{ headRepositoryOwner?: { login?: string } }> = JSON.parse(existing); - const ownPulls = openPulls.filter( - (pull) => - pull.headRepositoryOwner?.login?.toLowerCase() === repo.split("/")[0]?.toLowerCase(), + if (dryRun) { + yield* Console.log( + `Would publish ${spec.length} bytes to ${repo}:${specPath} on branch ${branch}`, ); - if (ownPulls.length > 0) { - console.log(`Reusing the open PR for ${branch}`); - } else { - const body = [ - "Updates the CLI reference from the supabase/cli release workflow.", - "", - "Generated by `scripts/generate-docs-spec.ts` in supabase/cli — edit the", - "command tree or the content under `apps/cli/docs/` there rather than this", - "file.", - "", - "New commands also need an entry in `apps/docs/spec/common-cli-sections.json` —", - "without one a command's page is silently dropped from the docs site.", - "Sections fixes can be committed directly onto this branch — later releases", - "add commits on top instead of rewriting it.", - ].join("\n"); - await $`gh pr create --repo ${repo} --title ${message} --body ${body} --base ${base} --head ${branch}`.cwd( - tmpDir, - ); - console.log(`Opened a pull request against ${repo}`); - } + return yield* Effect.sync(() => process.exit(0)); } -} finally { - await rm(tmpDir, { recursive: true, force: true }); -} + + // `--depth 1` suffices: an existing `cli/ref-doc` branch is fetched at its tip and the new spec + // lands as one commit on top, so commits pushed onto an open PR survive later releases. + const publishSpec = (tmpDir: string) => + Effect.gen(function* () { + yield* shell( + () => + $`git clone --quiet --depth 1 --branch ${base} https://github.com/${repo}.git ${tmpDir}`, + ); + + const remoteBranch = yield* shell(() => + $`git -C ${tmpDir} fetch --quiet --depth 1 origin ${branch}`.nothrow().quiet(), + ); + if (remoteBranch.exitCode === 0) { + yield* shell(() => $`git -C ${tmpDir} checkout --quiet -B ${branch} FETCH_HEAD`); + } else { + yield* shell(() => $`git -C ${tmpDir} checkout --quiet -B ${branch}`); + } + + const target = path.join(tmpDir, specPath); + yield* fs.writeFileString(target, spec); + const prettier = path.resolve(import.meta.dir, "../node_modules/.bin/prettier"); + yield* shell( + () => + $`${prettier} --no-config --single-quote --print-width 100 --log-level warn --write ${target}`, + ); + + const message = "chore: update cli reference doc"; + yield* shell(() => $`git -C ${tmpDir} add ${specPath}`); + const unchanged = yield* shell(() => + $`git -C ${tmpDir} diff --quiet --cached -- ${specPath}`.nothrow().quiet(), + ); + if (unchanged.exitCode === 0) { + yield* Console.log( + remoteBranch.exitCode === 0 + ? `${specPath} is already up to date on ${branch}` + : `${specPath} is already up to date in ${repo}`, + ); + } else { + yield* shell( + () => + $`git -C ${tmpDir} -c ${"user.name=github-actions[bot]"} -c ${"user.email=41898282+github-actions[bot]@users.noreply.github.com"} commit --quiet -m ${message}`, + ); + yield* shell(() => $`git -C ${tmpDir} push --quiet origin ${branch}`); + yield* Console.log(`Pushed ${branch} to ${repo}`); + } + + const unpublished = yield* shell(() => + $`git -C ${tmpDir} diff --quiet origin/${base} HEAD -- ${specPath}`.nothrow().quiet(), + ); + if (unpublished.exitCode === 1) { + const existing = yield* shell(() => + $`gh pr list --repo ${repo} --head ${branch} --base ${base} --state open --json number,headRepositoryOwner` + .cwd(tmpDir) + .text(), + ); + const ownPulls = yield* json(() => ownPullsOf(existing, repo)); + if (ownPulls.length > 0) { + yield* Console.log(`Reusing the open PR for ${branch}`); + } else { + const body = [ + "Updates the CLI reference from the supabase/cli release workflow.", + "", + "Generated by `scripts/generate-docs-spec.ts` in supabase/cli — edit the", + "command tree or the content under `apps/cli/docs/` there rather than this", + "file.", + "", + "New commands also need an entry in `apps/docs/spec/common-cli-sections.json` —", + "without one a command's page is silently dropped from the docs site.", + "Sections fixes can be committed directly onto this branch — later releases", + "add commits on top instead of rewriting it.", + ].join("\n"); + yield* shell(() => + $`gh pr create --repo ${repo} --title ${message} --body ${body} --base ${base} --head ${branch}`.cwd( + tmpDir, + ), + ); + yield* Console.log(`Opened a pull request against ${repo}`); + } + } + }); + /* A failed removal replaces the run's outcome. */ + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const tmpDir = yield* fs.makeTempDirectory({ prefix: "supabase-docs-" }); + const outcome = yield* Effect.exit(restore(publishSpec(tmpDir))); + yield* fs.remove(tmpDir, { recursive: true, force: true }); + return yield* outcome; + }), + ); +}); + +await Effect.runPromise( + main.pipe( + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + Effect.catch((error) => Effect.die(error.cause ?? error)), + ), +); diff --git a/apps/cli/scripts/publish.ts b/apps/cli/scripts/publish.ts index 226a1f6978..a40689739e 100644 --- a/apps/cli/scripts/publish.ts +++ b/apps/cli/scripts/publish.ts @@ -1,12 +1,11 @@ import { $ } from "bun"; -import { copyFile } from "node:fs/promises"; -import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { Console, Data, Effect, Fiber, FileSystem, Layer, Path, Predicate } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/http"; import { isPublishableNpmTag, PUBLISHABLE_NPM_TAGS_HINT } from "./release-channels.ts"; -const root = path.resolve(import.meta.dir, "../../.."); - const PLATFORM_PACKAGES = [ "cli-darwin-arm64", "cli-darwin-x64", @@ -27,133 +26,195 @@ const { values } = parseArgs({ const dryRun = values["dry-run"]; const tag = values.tag; -if (!isPublishableNpmTag(tag)) { - console.error( - `Invalid --tag value: ${String(tag)}. Expected one of: ${PUBLISHABLE_NPM_TAGS_HINT}.`, - ); - process.exit(1); -} - -const cliDir = path.join(root, "apps/cli"); -const cliPkgJson = await Bun.file(path.join(cliDir, "package.json")).json(); -const umbrellaName: string = cliPkgJson.name; -const umbrellaVersion: string = cliPkgJson.version; const dryRunFlag = dryRun ? ["--dry-run"] : []; const tagFlag = ["--tag", tag]; const provenanceFlag = ["--provenance"]; const noGitChecksFlag = ["--no-git-checks"]; -// Uses `npm config get registry` instead of hard-coding registry.npmjs.org, so the probe and -// publish target agree — needed for the local Verdaccio harness (`pnpm local-registry`). -const registryUrl = (await $`npm config get registry`.quiet().text()).trim().replace(/\/$/, ""); +class PublishStepFailed extends Data.TaggedError("PublishStepFailed")<{ + readonly cause: unknown; +}> {} + +class RegistryProbeFailed extends Data.TaggedError("RegistryProbeFailed")<{ + readonly message: string; +}> {} + +const step = (run: () => PromiseLike) => + Effect.tryPromise({ try: run, catch: (cause) => new PublishStepFailed({ cause }) }); // Used as both a pre-flight skip check and post-failure reconciliation, since a publish can // fail with E403 when the registry CDN cache lags behind an already-succeeded publish. -async function isAlreadyPublished(name: string, version: string): Promise { +const isAlreadyPublished = Effect.fnUntraced(function* ( + registryUrl: string, + name: string, + version: string, +) { const encodedName = name.replace("/", "%2F"); - const res = await fetch(`${registryUrl}/${encodedName}/${version}`, { method: "GET" }); + const res = yield* HttpClient.get(`${registryUrl}/${encodedName}/${version}`); if (res.status === 200) return true; if (res.status === 404) return false; - throw new Error(`npm registry probe for ${name}@${version} returned HTTP ${res.status}`); -} + return yield* new RegistryProbeFailed({ + message: `npm registry probe for ${name}@${version} returned HTTP ${res.status}`, + }); +}); type PublishResult = "published" | "skipped"; // Skips a version already on the registry — checked before publishing and again after a // publish-time conflict — and lets any other failure propagate. -async function publishPackage(opts: { +const publishPackage = Effect.fnUntraced(function* (opts: { + registryUrl: string; name: string; version: string; cwd: string; extraFlags?: string[]; -}): Promise { - const { name, version, cwd, extraFlags = [] } = opts; +}) { + const { registryUrl, name, version, cwd, extraFlags = [] } = opts; const label = `${name}@${version}`; - if (await isAlreadyPublished(name, version)) { - console.log(` [skip] ${label} already published.`); - return "skipped"; + if (yield* isAlreadyPublished(registryUrl, name, version)) { + yield* Console.log(` [skip] ${label} already published.`); + return "skipped" satisfies PublishResult; } - console.log(` Publishing ${label}...`); - try { - await $`pnpm publish ${extraFlags} ${provenanceFlag} ${tagFlag} ${noGitChecksFlag} ${dryRunFlag}`.cwd( + yield* Console.log(` Publishing ${label}...`); + return yield* step(() => + $`pnpm publish ${extraFlags} ${provenanceFlag} ${tagFlag} ${noGitChecksFlag} ${dryRunFlag}`.cwd( cwd, + ), + ).pipe( + Effect.andThen(Console.log(` ${label} published.`)), + Effect.as("published"), + Effect.catchTag("PublishStepFailed", (error) => + Effect.gen(function* () { + if (yield* isAlreadyPublished(registryUrl, name, version)) { + yield* Console.log( + ` [skip] ${label} reported a conflict but is now present on the registry; treating as success.`, + ); + return "skipped" satisfies PublishResult; + } + return yield* error; + }), + ), + ); +}); + +const main = Effect.gen(function* () { + if (!isPublishableNpmTag(tag)) { + yield* Console.error( + `Invalid --tag value: ${String(tag)}. Expected one of: ${PUBLISHABLE_NPM_TAGS_HINT}.`, ); - console.log(` ${label} published.`); - return "published"; - } catch (error) { - if (await isAlreadyPublished(name, version)) { - console.log( - ` [skip] ${label} reported a conflict but is now present on the registry; treating as success.`, - ); - return "skipped"; - } - throw error; + return yield* Effect.sync(() => process.exit(1)); } -} -console.log( - dryRun - ? `Publishing to npm with tag "${tag}" (dry run)...\n` - : `Publishing to npm with tag "${tag}"...\n`, -); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.resolve(import.meta.dir, "../../.."); + const cliDir = path.join(root, "apps/cli"); + const cliPkgJson = yield* step(() => Bun.file(path.join(cliDir, "package.json")).json()); + const umbrellaName: string = cliPkgJson.name; + const umbrellaVersion: string = cliPkgJson.version; + + // Uses `npm config get registry` instead of hard-coding registry.npmjs.org, so the probe and + // publish target agree — needed for the local Verdaccio harness (`pnpm local-registry`). + const registryUrl = (yield* step(() => $`npm config get registry`.quiet().text())) + .trim() + .replace(/\/$/, ""); + + yield* Console.log( + dryRun + ? `Publishing to npm with tag "${tag}" (dry run)...\n` + : `Publishing to npm with tag "${tag}"...\n`, + ); -// Every platform package must already be at the umbrella version; `sync-versions.ts` runs -// before this in the release workflow, so a mismatch means the script ran out of order. -for (const pkg of PLATFORM_PACKAGES) { - const pkgJson = await Bun.file(path.join(root, "packages", pkg, "package.json")).json(); - if (pkgJson.version !== umbrellaVersion) { - console.error( - `Version mismatch: @supabase/${pkg} is ${pkgJson.version}, expected ${umbrellaVersion}. Run sync-versions.ts first.`, + // Every platform package must already be at the umbrella version; `sync-versions.ts` runs + // before this in the release workflow, so a mismatch means the script ran out of order. + for (const pkg of PLATFORM_PACKAGES) { + const pkgJson = yield* step(() => + Bun.file(path.join(root, "packages", pkg, "package.json")).json(), ); - process.exit(1); + if (pkgJson.version !== umbrellaVersion) { + yield* Console.error( + `Version mismatch: @supabase/${pkg} is ${pkgJson.version}, expected ${umbrellaVersion}. Run sync-versions.ts first.`, + ); + return yield* Effect.sync(() => process.exit(1)); + } } -} - -console.log("Publishing platform packages..."); -const platformResults = await Promise.all( - PLATFORM_PACKAGES.map((pkg) => - publishPackage({ - name: `@supabase/${pkg}`, - version: umbrellaVersion, - cwd: path.join(root, "packages", pkg), - extraFlags: ["--access", "public"], - }), - ), -); -console.log("\nBuilding umbrella package shim..."); -await $`pnpm build:shim`.cwd(cliDir); + yield* Console.log("Publishing platform packages..."); + /* Detached so a failed job fails the run without cancelling its siblings; callers observe + completion with Fiber.joinAll. */ + const platformResults = yield* Fiber.joinAll( + yield* Effect.forEach(PLATFORM_PACKAGES, (pkg) => + publishPackage({ + registryUrl, + name: `@supabase/${pkg}`, + version: umbrellaVersion, + cwd: path.join(root, "packages", pkg), + extraFlags: ["--access", "public"], + }).pipe(Effect.forkDetach({ startImmediately: true })), + ), + ); -// npm renders the README from the package directory; apps/cli/README.md documents the source -// layout for contributors, not what npm users should see, so copy the root README over it. -console.log("\nStaging root README for umbrella package..."); -await copyFile(path.join(root, "README.md"), path.join(cliDir, "README.md")); + yield* Console.log("\nBuilding umbrella package shim..."); + yield* step(() => $`pnpm build:shim`.cwd(cliDir)); + + // npm renders the README from the package directory; apps/cli/README.md documents the source + // layout for contributors, not what npm users should see, so copy the root README over it. + yield* Console.log("\nStaging root README for umbrella package..."); + yield* fs.copyFile(path.join(root, "README.md"), path.join(cliDir, "README.md")); + + yield* Console.log(`Publishing umbrella package ${umbrellaName}...`); + const umbrellaResult = yield* publishPackage({ + registryUrl, + name: umbrellaName, + version: umbrellaVersion, + cwd: cliDir, + }); + + const results = [...platformResults, umbrellaResult]; + const publishedCount = results.filter((r) => r === "published").length; + const skippedCount = results.filter((r) => r === "skipped").length; + + yield* Console.log(`\nPublished: ${publishedCount}, Skipped: ${skippedCount}.`); + + // All-skipped is ambiguous: it may mean a downstream-only retry (bytes already on npm) or that + // today's commits silently didn't ship a new version. Warn loudly so a human can decide whether + // to re-cut via workflow_dispatch. + if (publishedCount === 0) { + yield* Console.warn( + `\n[warn] No packages were published — every package was already on the registry at ${umbrellaVersion}.\n` + + ` If today's commits were expected to ship, the version did not advance.\n` + + ` Re-cut as a fresh version via the Release workflow (workflow_dispatch).`, + ); + } -console.log(`Publishing umbrella package ${umbrellaName}...`); -const umbrellaResult = await publishPackage({ - name: umbrellaName, - version: umbrellaVersion, - cwd: cliDir, + yield* Console.log("\nAll packages published successfully."); }); -const results = [...platformResults, umbrellaResult]; -const publishedCount = results.filter((r) => r === "published").length; -const skippedCount = results.filter((r) => r === "skipped").length; - -console.log(`\nPublished: ${publishedCount}, Skipped: ${skippedCount}.`); - -// All-skipped is ambiguous: it may mean a downstream-only retry (bytes already on npm) or that -// today's commits silently didn't ship a new version. Warn loudly so a human can decide whether -// to re-cut via workflow_dispatch. -if (publishedCount === 0) { - console.warn( - `\n[warn] No packages were published — every package was already on the registry at ${umbrellaVersion}.\n` + - ` If today's commits were expected to ship, the version did not advance.\n` + - ` Re-cut as a fresh version via the Release workflow (workflow_dispatch).`, - ); +/* A registry URL that cannot be parsed fails as `fetch` reports an invalid URL. */ +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + if ( + Predicate.isTagged(error, "HttpClientError") && + Predicate.hasProperty(error, "reason") && + Predicate.isTagged(error.reason, "InvalidUrlError") + ) { + return Object.assign(new TypeError("fetch() URL is invalid"), { code: "ERR_INVALID_URL" }); + } + return error.cause !== undefined ? error.cause : new Error(error.message); } -console.log("\nAll packages published successfully."); +await Effect.runPromise( + main.pipe( + Effect.catch((error) => Effect.die(thrownValue(error))), + Effect.provide( + Layer.mergeAll( + BunFileSystem.layer, + BunPath.layer, + FetchHttpClient.layer, + Layer.succeed(HttpClient.TracerPropagationEnabled, false), + ), + ), + ), +); diff --git a/apps/cli/scripts/refresh-e2e-timings.ts b/apps/cli/scripts/refresh-e2e-timings.ts index 1fbf485426..366a50f17b 100644 --- a/apps/cli/scripts/refresh-e2e-timings.ts +++ b/apps/cli/scripts/refresh-e2e-timings.ts @@ -1,12 +1,12 @@ // Rewrites apps/cli/tests/e2e-timings.json from the Vitest results caches that the test-e2e // workflow job uploads as `e2e-timings-` artifacts. -import { spawnSync } from "node:child_process"; -import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { constants } from "node:os"; import process from "node:process"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; +import { BunServices } from "@effect/platform-bun"; +import { Console, Data, Effect, FileSystem, Path, Predicate, Schema } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { version as installedVitestVersion } from "vitest/node"; export interface VitestFileResult { @@ -96,75 +96,278 @@ function isResultsCache(value: unknown): value is VitestResultsCache { ); } -function readResultsCaches(dir: string): VitestResultsCache[] { - return readdirSync(dir, { recursive: true, withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name === "results.json") - .map((entry) => { - const file = join(entry.parentPath, entry.name); - const parsed: unknown = JSON.parse(readFileSync(file, "utf8")); - if (!isResultsCache(parsed)) { - throw new Error(`${file} is not a Vitest results cache`); +class RefreshTimingsError extends Data.TaggedError("RefreshTimingsError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const platformMessage = (error: { readonly message: string; readonly cause?: unknown }) => + error.cause instanceof Error ? error.cause.message : error.message; + +const toRefreshTimingsError = (error: { readonly message: string; readonly cause?: unknown }) => + new RefreshTimingsError({ message: platformMessage(error), cause: error }); + +const dieWithHostError = (error: { readonly cause?: unknown }) => Effect.die(error.cause ?? error); + +const encodeTimingsJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 })); + +const receivedSignal = (error: { readonly message: string; readonly cause?: unknown }) => + /^Process interrupted due to receipt of signal: '(.+)'$/.exec(platformMessage(error))?.[1]; + +const hostCode = (error: { readonly cause?: unknown }) => + Predicate.hasProperty(error.cause, "code") ? error.cause.code : undefined; + +/** + * Lists the regular files under `dir` breadth first, in directory order, following symlinked + * directories but never counting a symlink as a file. + * Only `fs.readLink`'s "not a symlink" answer (`EINVAL`) marks a plain entry; a link, or one + * `readLink` is denied on (a mode-000 link on macOS), is a symlink. A directory that cannot be + * listed fails the whole listing, reported against `dir`. + */ +const listRegularFiles = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const files: string[] = []; + const directories = [{ path: dir, viaSymlink: false }]; + for (let next = directories.shift(); next !== undefined; next = directories.shift()) { + const current = next; + const names = yield* fs.readDirectory(current.path).pipe( + Effect.catch((error) => { + const code = hostCode(error); + return current.viaSymlink && (code === "ENOTDIR" || code === "ENOENT") + ? Effect.succeed([]) + : Effect.fail( + new RefreshTimingsError({ + message: platformMessage(error).replace(`'${current.path}'`, `'${dir}'`), + cause: error, + }), + ); + }), + ); + for (const name of names) { + const entry = path.join(current.path, name); + const isSymlink = yield* fs.readLink(entry).pipe( + Effect.as(true), + Effect.catchTag("PlatformError", (error) => Effect.succeed(hostCode(error) !== "EINVAL")), + ); + if (isSymlink) { + directories.push({ path: entry, viaSymlink: true }); + continue; + } + const type = yield* fs.stat(entry).pipe( + Effect.map((info) => info.type), + Effect.orElseSucceed(() => undefined), + ); + if (type === "Directory") { + directories.push({ path: entry, viaSymlink: false }); + } else if (type === "File") { + files.push(entry); + } } - return parsed; - }); + } + return files; + }); + +/* Keeps a leading byte order mark, which the default decoder drops, so the JSON parse sees it. */ +const utf8 = new TextDecoder("utf-8", { ignoreBOM: true }); + +/* A plain parse, so malformed JSON reports the parser's own message. */ +function parseJson(text: string): unknown { + return JSON.parse(text); } +const readResultsCaches = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const caches: VitestResultsCache[] = []; + for (const file of yield* listRegularFiles(dir)) { + if (path.basename(file) !== "results.json") { + continue; + } + const text = utf8.decode(yield* fs.readFile(file)); + const parsed = yield* Effect.try({ + try: () => parseJson(text), + catch: (cause) => + new RefreshTimingsError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + if (!isResultsCache(parsed)) { + return yield* new RefreshTimingsError({ message: `${file} is not a Vitest results cache` }); + } + caches.push(parsed); + } + return caches; + }).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(toRefreshTimingsError(error)))); + const usage = `Usage: bun apps/cli/scripts/refresh-e2e-timings.ts --run [--repo supabase/cli] Downloads the e2e-timings-* artifacts of a test workflow run and rewrites apps/cli/tests/e2e-timings.json from them.`; +const refresh = (run: string, repo: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const downloadDir = yield* Effect.acquireRelease( + fs.makeTempDirectory({ prefix: "e2e-timings-" }).pipe(Effect.catch(dieWithHostError)), + (dir) => + fs.remove(dir, { recursive: true, force: true }).pipe(Effect.catch(dieWithHostError)), + ); + const program = Effect.gen(function* () { + const exitCode = yield* Effect.scoped( + Effect.gen(function* () { + const download = yield* spawner + .spawn( + ChildProcess.make( + "gh", + [ + "run", + "download", + run, + "--repo", + repo, + "--pattern", + "e2e-timings-*", + "--dir", + downloadDir, + ], + { detached: false, stdin: "inherit", stdout: "inherit", stderr: "inherit" }, + ), + ) + .pipe( + Effect.mapError( + (error) => + new RefreshTimingsError({ + message: `could not run gh: ${platformMessage(error)}`, + cause: error, + }), + ), + Effect.catchDefect((defect) => + Effect.fail( + new RefreshTimingsError({ + message: `could not run gh: ${defect instanceof Error ? defect.message : String(defect)}`, + cause: defect, + }), + ), + ), + ); + return yield* download.exitCode.pipe( + Effect.mapError((error) => { + const signal = receivedSignal(error); + return new RefreshTimingsError({ + message: + signal === undefined + ? `gh run download failed: ${platformMessage(error)}` + : `gh run download exited with signal ${signal}`, + cause: error, + }); + }), + ); + }), + ); + if (exitCode !== 0) { + return yield* new RefreshTimingsError({ + message: `gh run download exited with ${exitCode}`, + }); + } + + const caches = yield* readResultsCaches(downloadDir); + if (caches.length === 0) { + return yield* new RefreshTimingsError({ + message: `no results.json found in the e2e-timings-* artifacts of run ${run}`, + }); + } + const timings = yield* Console.consoleWith((out) => + Effect.try({ + try: () => + mergeTimings(caches, { + vitestVersion: installedVitestVersion, + warn: (message) => out.error(message), + }), + catch: (cause) => + new RefreshTimingsError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }), + ); + const target = fileURLToPath(new URL("../tests/e2e-timings.json", import.meta.url)); + const json = yield* encodeTimingsJson({ version: 1, files: timings }); + yield* fs.writeFileString(target, `${json}\n`).pipe(Effect.mapError(toRefreshTimingsError)); + yield* Console.error(`wrote ${Object.keys(timings).length} timings to ${target}`); + }); + yield* program.pipe( + Effect.catch((error) => + Console.error(`error: ${error.message}`).pipe( + Effect.andThen( + Effect.sync(() => { + process.exitCode = 1; + }), + ), + ), + ), + ); + }).pipe(Effect.scoped); + +const isSignal = (name: string): name is NodeJS.Signals => Object.hasOwn(constants.signals, name); + +/** + * Holds every signal that already has a handler (Vitest's exit hook) until the run, cleanup + * included, has finished, then raises each held signal again, in arrival order. + */ +const holdSignals = (run: Effect.Effect) => + Effect.gen(function* () { + const received: Array = []; + const hold = (signal: NodeJS.Signals) => { + received.push(signal); + }; + const signals = Object.keys(constants.signals) + .filter(isSignal) + .filter((signal) => process.listenerCount(signal) > 0); + const originalListeners = signals.map((signal) => ({ + signal, + listeners: process.rawListeners(signal), + })); + yield* Effect.acquireRelease( + Effect.sync(() => { + for (const { signal, listeners } of originalListeners) { + process.on(signal, hold); + for (const listener of listeners) { + process.off(signal, listener); + } + } + }), + () => + Effect.sync(() => { + for (const { signal, listeners } of originalListeners) { + for (const listener of listeners) { + process.on(signal, listener); + } + process.off(signal, hold); + } + for (const signal of received) { + process.kill(process.pid, signal); + } + }), + ); + return yield* run; + }).pipe(Effect.scoped); + if (import.meta.main) { const { values } = parseArgs({ args: process.argv.slice(2), options: { run: { type: "string" }, repo: { type: "string", default: "supabase/cli" } }, }); if (!values.run) { - console.error(usage); + await Effect.runPromise(Console.error(usage)); process.exit(2); } - const downloadDir = mkdtempSync(join(tmpdir(), "e2e-timings-")); - try { - const download = spawnSync( - "gh", - [ - "run", - "download", - values.run, - "--repo", - values.repo, - "--pattern", - "e2e-timings-*", - "--dir", - downloadDir, - ], - { stdio: "inherit" }, - ); - if (download.error !== undefined) { - throw new Error(`could not run gh: ${download.error.message}`); - } - if (download.status !== 0) { - throw new Error( - `gh run download exited with ${download.status ?? `signal ${download.signal}`}`, - ); - } - - const caches = readResultsCaches(downloadDir); - if (caches.length === 0) { - throw new Error(`no results.json found in the e2e-timings-* artifacts of run ${values.run}`); - } - const timings = mergeTimings(caches, { - vitestVersion: installedVitestVersion, - warn: (message) => console.error(message), - }); - const target = fileURLToPath(new URL("../tests/e2e-timings.json", import.meta.url)); - writeFileSync(target, `${JSON.stringify({ version: 1, files: timings }, null, 2)}\n`); - console.error(`wrote ${Object.keys(timings).length} timings to ${target}`); - } catch (cause) { - console.error(`error: ${cause instanceof Error ? cause.message : String(cause)}`); - process.exitCode = 1; - } finally { - rmSync(downloadDir, { recursive: true, force: true }); - } + await Effect.runPromise( + holdSignals(refresh(values.run, values.repo).pipe(Effect.provide(BunServices.layer))), + ); } diff --git a/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts b/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts index 7a3381fd86..3fb8e7bae2 100644 --- a/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts +++ b/apps/cli/scripts/render-service-dockerfile.rules.unit.test.ts @@ -6,7 +6,7 @@ import { renderDockerfile } from "./render-service-dockerfile.ts"; // one-line-per-alias check is satisfied by default; each test only mutates what it's // exercising. `vi.mock` factories are hoisted above every other top-level statement, so the // fixture pins are built inline here rather than imported from an outer module. -vi.mock("@supabase/stack/internal/artifacts", async (importOriginal) => { +vi.mock("@supabase/stack/internal/artifacts", (importOriginal) => { const nativePin = { archive: "a".repeat(64), manifest: "b".repeat(64) }; const natives = { "darwin-arm64": nativePin, "linux-amd64": nativePin, "linux-arm64": nativePin }; const pin = (sourceService: string, upstreamImage: string, isDefault: boolean) => ({ @@ -21,8 +21,8 @@ vi.mock("@supabase/stack/internal/artifacts", async (importOriginal) => { natives, }, }); - return { - ...(await importOriginal()), + return importOriginal().then((actual) => ({ + ...actual, catalogPins: () => [ pin("postgres", "supabase/postgres:17.0.0", true), pin("postgres", "supabase/postgres:15.0.0", false), @@ -40,7 +40,7 @@ vi.mock("@supabase/stack/internal/artifacts", async (importOriginal) => { pin("storage", "supabase/storage-api:v1.0.0", true), pin("analytics", "supabase/logflare:1.0.0", true), ], - }; + })); }); /** A minimal Dockerfile with every slim-capable alias, plus kong and pg14 (hand-managed). */ diff --git a/apps/cli/scripts/render-service-dockerfile.ts b/apps/cli/scripts/render-service-dockerfile.ts index 79327facf3..997c869d36 100644 --- a/apps/cli/scripts/render-service-dockerfile.ts +++ b/apps/cli/scripts/render-service-dockerfile.ts @@ -16,6 +16,7 @@ import { isOrioledbVersion, type ArtifactPin, } from "@supabase/stack/internal/artifacts"; +import { Console, Data, Effect } from "effect"; // Resolved from this module's own URL, so both the CLI invocation (cwd = repo root) and the // vitest unit test (cwd = apps/cli) read the same files. @@ -160,43 +161,58 @@ export function renderDockerfile(currentDockerfile: string): string { return lines.join("\n"); } -async function main(argv: ReadonlyArray): Promise { +class RenderStepFailed extends Data.TaggedError("RenderStepFailed")<{ readonly cause: unknown }> {} + +const step = (run: () => PromiseLike) => + Effect.tryPromise({ try: run, catch: (cause) => new RenderStepFailed({ cause }) }); + +const main = Effect.fnUntraced(function* (argv: ReadonlyArray) { const check = argv.includes("--check"); - const current = await Bun.file(TS_DOCKERFILE_PATH).text(); - const rendered = renderDockerfile(current); + const current = yield* step(() => Bun.file(TS_DOCKERFILE_PATH).text()); + const rendered = yield* Effect.try({ + try: () => renderDockerfile(current), + catch: (cause) => new RenderStepFailed({ cause }), + }); if (check) { - const go = await Bun.file(GO_DOCKERFILE_PATH).text(); + const go = yield* step(() => Bun.file(GO_DOCKERFILE_PATH).text()); let failed = false; if (current !== rendered) { - console.log( + yield* Console.log( `::error ::${TS_DOCKERFILE_PATH} has drifted from packages/stack/src/Artifacts.ts. Run ` + "`bun apps/cli/scripts/render-service-dockerfile.ts` and commit the result.", ); failed = true; } if (go !== rendered) { - console.log( + yield* Console.log( `::error ::${GO_DOCKERFILE_PATH} is not a byte copy of ${TS_DOCKERFILE_PATH}. Run ` + "`bun apps/cli/scripts/render-service-dockerfile.ts` and commit the result.", ); failed = true; } if (failed) { - process.exit(1); + return yield* Effect.sync(() => process.exit(1)); } - console.log("Both Dockerfiles match the catalog."); + yield* Console.log("Both Dockerfiles match the catalog."); return; } - await Bun.write(TS_DOCKERFILE_PATH, rendered); - await Bun.write(GO_DOCKERFILE_PATH, rendered); - console.log(`Regenerated ${TS_DOCKERFILE_PATH} and ${GO_DOCKERFILE_PATH} from the catalog.`); -} + yield* step(() => Bun.write(TS_DOCKERFILE_PATH, rendered)); + yield* step(() => Bun.write(GO_DOCKERFILE_PATH, rendered)); + yield* Console.log( + `Regenerated ${TS_DOCKERFILE_PATH} and ${GO_DOCKERFILE_PATH} from the catalog.`, + ); +}); if (import.meta.main) { - main(process.argv.slice(2)).catch((error: unknown) => { - console.log(`::error ::${error instanceof Error ? error.message : String(error)}`); - process.exit(1); - }); + await Effect.runPromise( + main(process.argv.slice(2)).pipe( + Effect.catch(({ cause }) => + Console.log(`::error ::${cause instanceof Error ? cause.message : String(cause)}`).pipe( + Effect.andThen(Effect.sync(() => process.exit(1))), + ), + ), + ), + ); } diff --git a/apps/cli/scripts/render-service-dockerfile.unit.test.ts b/apps/cli/scripts/render-service-dockerfile.unit.test.ts index 029a235af8..73f9a5b449 100644 --- a/apps/cli/scripts/render-service-dockerfile.unit.test.ts +++ b/apps/cli/scripts/render-service-dockerfile.unit.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from "node:fs"; import { describe, expect, test } from "vitest"; import { @@ -7,9 +6,15 @@ import { renderDockerfile, } from "./render-service-dockerfile.ts"; +/* Decoded as UTF-8 with a leading byte order mark kept (`.text()` would drop it). */ +const readDockerfile = (path: string) => + Bun.file(path) + .bytes() + .then((bytes) => new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes)); + // Both against the real, checked-in Dockerfile and the real catalog — no hardcoded version // literal, so a catalog bump never makes this drift-detection test itself go stale. -const currentTsDockerfile = readFileSync(TS_DOCKERFILE_PATH, "utf8"); +const currentTsDockerfile = await readDockerfile(TS_DOCKERFILE_PATH); describe("renderDockerfile against the real catalog and Dockerfile", () => { test("round-trips: rewriting the checked-in Dockerfile reproduces it byte for byte (no drift)", () => { @@ -22,10 +27,10 @@ describe("renderDockerfile against the real catalog and Dockerfile", () => { // The Go tree still `go:embed`s its own copy for a dependency that hasn't been removed yet; // this is the single place that keeps the two copies in sync until apps/cli-go is deleted. - test("the Go tree's embedded Dockerfile is a byte copy of the TS-owned one", () => { - const goDockerfile = readFileSync(GO_DOCKERFILE_PATH, "utf8"); - expect(goDockerfile).toBe(currentTsDockerfile); - }); + test("the Go tree's embedded Dockerfile is a byte copy of the TS-owned one", () => + readDockerfile(GO_DOCKERFILE_PATH).then((goDockerfile) => { + expect(goDockerfile).toBe(currentTsDockerfile); + })); test("detects drift when a generated line is hand-edited", () => { // A hand-edit to a generated tag (bumping gotrue without touching the catalog) must not diff --git a/apps/cli/scripts/sweep-live-projects.integration.test.ts b/apps/cli/scripts/sweep-live-projects.integration.test.ts index faaaac4c2b..5b4a084672 100644 --- a/apps/cli/scripts/sweep-live-projects.integration.test.ts +++ b/apps/cli/scripts/sweep-live-projects.integration.test.ts @@ -1,10 +1,17 @@ -import { afterEach, describe, expect, test } from "vitest"; -import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -const script = path.resolve(import.meta.dirname, "sweep-live-projects.sh"); -const directories: string[] = []; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { + Config, + ConfigProvider, + Data, + Effect, + Fiber, + FileSystem, + Layer, + Option, + Path, + PlatformError, +} from "effect"; // Per-sweep hang guard: a spawned run gets this long before the safety net kills // it. A test's own timeout must cover every sweep it runs plus report margin, so @@ -13,9 +20,26 @@ const SWEEP_GUARD_MS = 30_000; const REPORT_MARGIN_MS = 10_000; const sweepBudget = (sweeps: number) => sweeps * SWEEP_GUARD_MS + REPORT_MARGIN_MS; -afterEach(async () => { - await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); -}); +class SweepHarnessFailed extends Data.TaggedError("SweepHarnessFailed")<{ + readonly cause: unknown; +}> {} + +const sweepLayer = Layer.mergeAll( + BunFileSystem.layer, + BunPath.layer, + ConfigProvider.layer(ConfigProvider.fromEnvRecord(process.env, { preserveEmptyStrings: true })), +); + +const provideSweep = ( + sweep: Effect.Effect, +) => + sweep.pipe( + Effect.catchTags({ + PlatformError: (error) => Effect.die(error.cause ?? error), + SweepHarnessFailed: (error) => Effect.die(error.cause), + }), + Effect.provide(sweepLayer), + ); type Scenario = { lists: Array | ((deletes: ReadonlyArray) => unknown); @@ -23,84 +47,106 @@ type Scenario = { deleteStatuses?: Record>; }; -async function runSweep(scenario: Scenario) { - const directory = await mkdtemp(path.join(tmpdir(), "sweep-live-projects-")); - directories.push(directory); +const readText = (stream: ReadableStream) => + Effect.tryPromise({ + try: () => new Response(stream).text(), + catch: (cause) => new SweepHarnessFailed({ cause }), + }); + +const runSweep = Effect.fnUntraced(function* (scenario: Scenario) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const script = path.resolve(import.meta.dirname, "sweep-live-projects.sh"); + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "sweep-live-projects-" }); const fakeSleep = path.join(directory, "sleep"); - await writeFile(fakeSleep, "#!/bin/sh\nexit 0\n"); - await chmod(fakeSleep, 0o755); + yield* fs.writeFileString(fakeSleep, "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(fakeSleep, 0o755); let listIndex = 0; const deletes: string[] = []; - const server = Bun.serve({ - port: 0, - fetch(request) { - const url = new URL(request.url); - const ref = url.pathname.split("/").pop() ?? ""; - if (request.method === "GET" && url.pathname === "/v1/projects") { - const value = - typeof scenario.lists === "function" - ? scenario.lists(deletes) - : scenario.lists[Math.min(listIndex++, scenario.lists.length - 1)]; - return typeof value === "number" - ? new Response("temporary failure", { status: value }) - : Response.json(value); - } - if (request.method === "DELETE" && url.pathname.startsWith("/v1/projects/")) { - deletes.push(ref); - const statuses = scenario.deleteStatuses?.[ref]; - const result: { status: number; body?: unknown } = - statuses === undefined - ? (scenario.deletes[ref] ?? { status: 404 }) - : { - status: - statuses[ - Math.min( - deletes.filter((value) => value === ref).length - 1, - statuses.length - 1, - ) - ] ?? 500, - }; - return new Response(result.body === undefined ? null : JSON.stringify(result.body), { - status: result.status, - headers: { "x-request-id": `delete-${ref}` }, - }); - } - return new Response("not found", { status: 404 }); - }, - }); - try { - const child = Bun.spawn(["bash", script, "e2e-"], { - env: { - ...globalThis.process.env, - PATH: `${directory}:${globalThis.process.env.PATH}`, - SUPABASE_ACCESS_TOKEN: "test-token", - SUPABASE_LIVE_API_URL: `http://127.0.0.1:${server.port}`, + const serve = () => + Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + const ref = url.pathname.split("/").pop() ?? ""; + if (request.method === "GET" && url.pathname === "/v1/projects") { + const value = + typeof scenario.lists === "function" + ? scenario.lists(deletes) + : scenario.lists[Math.min(listIndex++, scenario.lists.length - 1)]; + return typeof value === "number" + ? new Response("temporary failure", { status: value }) + : Response.json(value); + } + if (request.method === "DELETE" && url.pathname.startsWith("/v1/projects/")) { + deletes.push(ref); + const statuses = scenario.deleteStatuses?.[ref]; + const result: { status: number; body?: unknown } = + statuses === undefined + ? (scenario.deletes[ref] ?? { status: 404 }) + : { + status: + statuses[ + Math.min( + deletes.filter((value) => value === ref).length - 1, + statuses.length - 1, + ) + ] ?? 500, + }; + return new Response(result.body === undefined ? null : JSON.stringify(result.body), { + status: result.status, + headers: { "x-request-id": `delete-${ref}` }, + }); + } + return new Response("not found", { status: 404 }); }, - stdout: "pipe", - stderr: "pipe", + }); + return yield* Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.try({ try: serve, catch: (cause) => new SweepHarnessFailed({ cause }) }), + (server) => Effect.promise(() => server.stop(true)), + ); + const inheritedPath = yield* Config.option(Config.String("PATH")); + const child = yield* Effect.try({ + try: () => + Bun.spawn(["bash", script, "e2e-"], { + env: { + ...globalThis.process.env, + PATH: `${directory}:${Option.getOrUndefined(inheritedPath)}`, + SUPABASE_ACCESS_TOKEN: "test-token", + SUPABASE_LIVE_API_URL: `http://127.0.0.1:${server.port}`, + }, + stdout: "pipe", + stderr: "pipe", + }), + catch: (cause) => new SweepHarnessFailed({ cause }), }); let hung = false; - const timeout = setTimeout(() => { - hung = true; - child.kill(); - }, SWEEP_GUARD_MS); - const [exitCode, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]); - clearTimeout(timeout); + const guard = yield* Effect.sleep(SWEEP_GUARD_MS).pipe( + Effect.andThen( + Effect.sync(() => { + hung = true; + child.kill(); + }), + ), + Effect.forkChild, + ); + const [exitCode, stdout, stderr] = yield* Effect.all( + [Effect.promise(() => child.exited), readText(child.stdout), readText(child.stderr)], + { concurrency: "unbounded" }, + ); + yield* Fiber.interrupt(guard); if (hung) { - throw new Error( - `sweep script hung: killed after ${SWEEP_GUARD_MS}ms without exiting (stdout: ${stdout}, stderr: ${stderr})`, + return yield* Effect.die( + new Error( + `sweep script hung: killed after ${SWEEP_GUARD_MS}ms without exiting (stdout: ${stdout}, stderr: ${stderr})`, + ), ); } return { exitCode, stdout, stderr, deletes }; - } finally { - await server.stop(true); - } -} + }).pipe(Effect.scoped); +}, provideSweep); const active = (ref: string, name = `e2e-${ref}`) => ({ ref, name, status: "ACTIVE" }); @@ -108,87 +154,101 @@ describe.skipIf(process.platform === "win32")( "sweep-live-projects.sh", { timeout: sweepBudget(1) }, () => { - test("accepts refused deletion when a fresh authenticated list shows absence", async () => { - const result = await runSweep({ - lists: [[active("gone")], []], - deletes: { gone: { status: 403, body: { message: "already removed" } } }, - }); - expect(result.exitCode).toBe(0); - expect(result.deletes).toEqual(["gone"]); - expect(`${result.stdout}\n${result.stderr}`).not.toContain("test-token"); - }); - - test("accepts a terminal status and converges after a stale list", async () => { - const result = await runSweep({ - lists: [[active("stale")], [active("stale")], [{ ...active("stale"), status: "REMOVED" }]], - deletes: { stale: { status: 202 } }, - }); - expect(result.exitCode).toBe(0); - }); + it.live("accepts refused deletion when a fresh authenticated list shows absence", () => + Effect.gen(function* () { + const result = yield* runSweep({ + lists: [[active("gone")], []], + deletes: { gone: { status: 403, body: { message: "already removed" } } }, + }); + expect(result.exitCode).toBe(0); + expect(result.deletes).toEqual(["gone"]); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("test-token"); + }), + ); - test( - "retries a transient read failure but rejects malformed evidence", - async () => { - const transient = await runSweep({ - lists: [503, [active("retry")], []], - deletes: { retry: { status: 202 } }, + it.live("accepts a terminal status and converges after a stale list", () => + Effect.gen(function* () { + const result = yield* runSweep({ + lists: [ + [active("stale")], + [active("stale")], + [{ ...active("stale"), status: "REMOVED" }], + ], + deletes: { stale: { status: 202 } }, }); - expect(transient.exitCode).toBe(0); + expect(result.exitCode).toBe(0); + }), + ); - const malformed = await runSweep({ lists: [{ projects: [] }], deletes: {} }); - expect(malformed.exitCode).not.toBe(0); - expect(malformed.deletes).toEqual([]); - }, + it.live( + "retries a transient read failure but rejects malformed evidence", + () => + Effect.gen(function* () { + const transient = yield* runSweep({ + lists: [503, [active("retry")], []], + deletes: { retry: { status: 202 } }, + }); + expect(transient.exitCode).toBe(0); + + const malformed = yield* runSweep({ lists: [{ projects: [] }], deletes: {} }); + expect(malformed.exitCode).not.toBe(0); + expect(malformed.deletes).toEqual([]); + }), sweepBudget(2), ); - test( + it.live( "fails terminal listing errors without retrying or deleting", - async () => { - const forbidden = await runSweep({ lists: [403, []], deletes: {} }); - expect(forbidden.exitCode).not.toBe(0); - expect(forbidden.deletes).toEqual([]); - - const unavailable = await runSweep({ lists: [503, 503, 503], deletes: {} }); - expect(unavailable.exitCode).not.toBe(0); - expect(unavailable.deletes).toEqual([]); - - const malformedReconciliation = await runSweep({ - lists: [[active("bad-evidence")], { projects: [] }], - deletes: { "bad-evidence": { status: 403 } }, - }); - expect(malformedReconciliation.exitCode).not.toBe(0); - }, + () => + Effect.gen(function* () { + const forbidden = yield* runSweep({ lists: [403, []], deletes: {} }); + expect(forbidden.exitCode).not.toBe(0); + expect(forbidden.deletes).toEqual([]); + + const unavailable = yield* runSweep({ lists: [503, 503, 503], deletes: {} }); + expect(unavailable.exitCode).not.toBe(0); + expect(unavailable.deletes).toEqual([]); + + const malformedReconciliation = yield* runSweep({ + lists: [[active("bad-evidence")], { projects: [] }], + deletes: { "bad-evidence": { status: 403 } }, + }); + expect(malformedReconciliation.exitCode).not.toBe(0); + }), sweepBudget(3), ); - test("attempts every owned project and fails if one remains active", async () => { - const result = await runSweep({ - lists: [ - [ - active("stuck"), - active("other"), - { ref: "foreign", name: "unrelated", status: "ACTIVE" }, + it.live("attempts every owned project and fails if one remains active", () => + Effect.gen(function* () { + const result = yield* runSweep({ + lists: [ + [ + active("stuck"), + active("other"), + { ref: "foreign", name: "unrelated", status: "ACTIVE" }, + ], + [active("stuck"), { ref: "foreign", name: "unrelated", status: "ACTIVE" }], ], - [active("stuck"), { ref: "foreign", name: "unrelated", status: "ACTIVE" }], - ], - deletes: { stuck: { status: 403 }, other: { status: 204 } }, - }); - expect(result.exitCode).not.toBe(0); - expect(result.deletes).toEqual(["stuck", "other"]); - }); + deletes: { stuck: { status: 403 }, other: { status: 204 } }, + }); + expect(result.exitCode).not.toBe(0); + expect(result.deletes).toEqual(["stuck", "other"]); + }), + ); - test("retries a transient deletion after fresh evidence still shows the project", async () => { - const result = await runSweep({ - lists: (deletes) => (deletes.length >= 2 ? [] : [active("flaky")]), - deletes: {}, - deleteStatuses: { flaky: [503, 204] }, - }); - expect(result.exitCode).toBe(0); - expect(result.deletes).toEqual(["flaky", "flaky"]); - expect(result.stderr).toContain( - "delete retry completed for project flaky (HTTP 204 request delete-flaky)", - ); - }); + it.live("retries a transient deletion after fresh evidence still shows the project", () => + Effect.gen(function* () { + const result = yield* runSweep({ + lists: (deletes) => (deletes.length >= 2 ? [] : [active("flaky")]), + deletes: {}, + deleteStatuses: { flaky: [503, 204] }, + }); + expect(result.exitCode).toBe(0); + expect(result.deletes).toEqual(["flaky", "flaky"]); + expect(result.stderr).toContain( + "delete retry completed for project flaky (HTTP 204 request delete-flaky)", + ); + }), + ); }, ); diff --git a/apps/cli/scripts/sync-versions.ts b/apps/cli/scripts/sync-versions.ts index 1f4cfe4fb5..e7d01434ac 100644 --- a/apps/cli/scripts/sync-versions.ts +++ b/apps/cli/scripts/sync-versions.ts @@ -1,6 +1,7 @@ import { parseArgs } from "node:util"; -import path from "node:path"; import process from "node:process"; +import { BunPath } from "@effect/platform-bun"; +import { Console, Data, Effect, Path, Schema } from "effect"; const PACKAGE_PATHS = { cli: ["apps", "cli"], @@ -22,22 +23,52 @@ const { values } = parseArgs({ }, }); +class PackageJsonFailed extends Data.TaggedError("PackageJsonFailed")<{ + readonly cause: unknown; +}> {} + +const encodePackageJson = Schema.encodeEffect( + Schema.fromJsonString(Schema.Unknown, { space: "\t" }), +); + const version = values.version; -if (!version) { - console.error("Usage: bun apps/cli/scripts/sync-versions.ts --version "); - process.exit(1); -} -const root = path.resolve(import.meta.dir, "../../.."); +const main = Effect.gen(function* () { + if (!version) { + yield* Console.error("Usage: bun apps/cli/scripts/sync-versions.ts --version "); + process.exit(1); + } + + const path = yield* Path.Path; + const root = path.resolve(import.meta.dir, "../../.."); -for (const pkg of ALL_PACKAGES) { - const pkgJsonPath = path.join(root, ...PACKAGE_PATHS[pkg], "package.json"); - const pkgJson = await Bun.file(pkgJsonPath).json(); + for (const pkg of ALL_PACKAGES) { + const pkgJsonPath = path.join(root, ...PACKAGE_PATHS[pkg], "package.json"); + const pkgJson = yield* Effect.tryPromise({ + try: () => Bun.file(pkgJsonPath).json(), + catch: (cause) => new PackageJsonFailed({ cause }), + }); - pkgJson.version = version; + pkgJson.version = version; + + const contents = `${yield* encodePackageJson(pkgJson)}\n`; + yield* Effect.tryPromise({ + try: () => Bun.write(pkgJsonPath, contents), + catch: (cause) => new PackageJsonFailed({ cause }), + }); + yield* Console.log(`Updated ${pkg} to v${version}`); + } + + yield* Console.log(`\nAll packages synced to v${version}.`); +}); - await Bun.write(pkgJsonPath, `${JSON.stringify(pkgJson, null, "\t")}\n`); - console.log(`Updated ${pkg} to v${version}`); +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + return error.cause !== undefined ? error.cause : new Error(error.message); } -console.log(`\nAll packages synced to v${version}.`); +await Effect.runPromise( + main.pipe( + Effect.catch((error) => Effect.die(thrownValue(error))), + Effect.provide(BunPath.layer), + ), +); diff --git a/apps/cli/scripts/trace-report.ts b/apps/cli/scripts/trace-report.ts index 29cb80c39d..a16430839c 100644 --- a/apps/cli/scripts/trace-report.ts +++ b/apps/cli/scripts/trace-report.ts @@ -27,7 +27,8 @@ const OtlpSpan = Schema.Struct({ name: Schema.String, startTimeUnixNano: Schema.String, endTimeUnixNano: Schema.String, - status: Schema.Struct({ code: Schema.Number }), + /* Any JSON number, including `1e309` (parses to `Infinity`), which `Schema.Finite` rejects. */ + status: Schema.Struct({ code: Schema.declare((u): u is number => typeof u === "number") }), attributes: Schema.optional(KeyValues), events: Schema.optional( Schema.Array(Schema.Struct({ name: Schema.String, attributes: KeyValues })), @@ -44,6 +45,8 @@ const TraceBatch = Schema.fromJsonString( }), ); +const ReportJson = Schema.fromJsonString(Schema.Unknown, { space: 2 }); + export interface ReportSpan { readonly traceId: string; readonly spanId: string; @@ -180,7 +183,7 @@ export const readSpans = Effect.fnUntraced(function* (file: string) { let skippedLines = 0; for (const line of text.split("\n")) { if (line.trim().length === 0) continue; - const decoded = yield* Schema.decodeUnknownEffect(TraceBatch)(line).pipe(Effect.option); + const decoded = yield* Schema.decodeEffect(TraceBatch)(line).pipe(Effect.option); if (decoded._tag === "None") { skippedLines += 1; continue; @@ -302,7 +305,11 @@ const main = Effect.gen(function* () { const header = `trace: ${trace.traceId}${trace.traceCount > 1 ? ` (latest of ${trace.traceCount}; choose one with --trace-id)` : ""}`; yield* Console.log( options.json - ? JSON.stringify({ traceId: trace.traceId, traceCount: trace.traceCount, ...report }, null, 2) + ? yield* Schema.encodeEffect(ReportJson)({ + traceId: trace.traceId, + traceCount: trace.traceCount, + ...report, + }) : `${header}\n${formatReport(report)}`, ); }); diff --git a/apps/cli/scripts/trace-report.unit.test.ts b/apps/cli/scripts/trace-report.unit.test.ts index 66e7bb5140..c2fff6938e 100644 --- a/apps/cli/scripts/trace-report.unit.test.ts +++ b/apps/cli/scripts/trace-report.unit.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { Effect, FileSystem } from "effect"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { analyzeTrace, parseTraceReportArgs, @@ -115,7 +115,7 @@ describe("reportSpanName", () => { }); describe("readSpans", () => { - it("keeps decodable batches and counts lines that do not decode", async () => { + it.effect("keeps decodable batches and counts lines that do not decode", () => { const batch = JSON.stringify({ resourceSpans: [ { @@ -137,19 +137,15 @@ describe("readSpans", () => { ], }); - const result = await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const file = yield* fs.makeTempFileScoped({ suffix: ".jsonl" }); - yield* fs.writeFileString(file, `${batch}\n{"resourceSpans": [\n`); - return yield* readSpans(file); - }), - ).pipe(Effect.provide(BunServices.layer)), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const file = yield* fs.makeTempFileScoped({ suffix: ".jsonl" }); + yield* fs.writeFileString(file, `${batch}\n{"resourceSpans": [\n`); + const result = yield* readSpans(file); - expect(result.spans.map((span) => span.name)).toEqual(["cli.run"]); - expect(result.skippedLines).toBe(1); + expect(result.spans.map((span) => span.name)).toEqual(["cli.run"]); + expect(result.skippedLines).toBe(1); + }).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/scripts/update-homebrew.ts b/apps/cli/scripts/update-homebrew.ts index 942cf61a0d..87915f99f6 100644 --- a/apps/cli/scripts/update-homebrew.ts +++ b/apps/cli/scripts/update-homebrew.ts @@ -1,9 +1,8 @@ import { $ } from "bun"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { Console, Data, Effect, FileSystem, Layer, Path } from "effect"; import { homebrewClassName, homebrewConflictsLine } from "./release-channels.ts"; const { values } = parseArgs({ @@ -17,57 +16,69 @@ const { values } = parseArgs({ }, }); -const version = values.version; -if (!version) { - console.error( - "Usage: bun run scripts/update-homebrew.ts --version [--repo ] [--tap ] [--name ] [--local] [--dry-run]", - ); - process.exit(1); -} +class ShellFailed extends Data.TaggedError("ShellFailed")<{ readonly cause: unknown }> {} -const repo = values.repo!; -const tap = values.tap!; -const name = values.name!; -const local = values.local!; -const dryRun = values["dry-run"]!; -const root = path.resolve(import.meta.dir, "../../.."); -const distDir = path.join(root, "dist"); - -// The class and filename vary by channel (e.g. "supabase-beta" -> "SupabaseBeta", "supabase@2" -> -// "SupabaseAT2"), but the installed binary is always `supabase`. -const className = homebrewClassName(name); - -// The Go sidecar is looked up by exact filename next to the running binary, so it must install -// under its original name; `if File.exist?` keeps the formula working when a build ships only -// the CLI binary. -const installBlock = [ - ` bin.install "supabase"`, - ` bin.install "supabase-go" if File.exist?("supabase-go")`, -].join("\n"); - -const conflictsBlock = homebrewConflictsLine(name); -const conflictsSection = conflictsBlock ? `${conflictsBlock}\n\n` : ""; - -const testInvocation = `#{bin}/supabase`; - -const checksums = new Map(); -const checksumsText = await readFile(path.join(distDir, "checksums.txt"), "utf-8"); -for (const line of checksumsText.trim().split("\n")) { - const [hash, file] = line.split(/\s+/) as [string, string]; - checksums.set(file, hash); -} +class FormulaError extends Data.TaggedError("FormulaError")<{ readonly message: string }> {} -function sha(file: string): string { - const hash = checksums.get(file); - if (!hash) throw new Error(`Checksum not found for ${file}`); - return hash; -} +const shell = (command: () => PromiseLike) => + Effect.tryPromise({ try: command, catch: (cause) => new ShellFailed({ cause }) }); + +const main = Effect.gen(function* () { + const version = values.version; + if (!version) { + yield* Console.error( + "Usage: bun run scripts/update-homebrew.ts --version [--repo ] [--tap ] [--name ] [--local] [--dry-run]", + ); + return yield* Effect.sync(() => process.exit(1)); + } + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repo = values.repo!; + const tap = values.tap!; + const name = values.name!; + const local = values.local!; + const dryRun = values["dry-run"]!; + const root = path.resolve(import.meta.dir, "../../.."); + const distDir = path.join(root, "dist"); + + // The class and filename vary by channel (e.g. "supabase-beta" -> "SupabaseBeta", "supabase@2" -> + // "SupabaseAT2"), but the installed binary is always `supabase`. + const className = homebrewClassName(name); + + // The Go sidecar is looked up by exact filename next to the running binary, so it must install + // under its original name; `if File.exist?` keeps the formula working when a build ships only + // the CLI binary. + const installBlock = [ + ` bin.install "supabase"`, + ` bin.install "supabase-go" if File.exist?("supabase-go")`, + ].join("\n"); + + const conflictsBlock = homebrewConflictsLine(name); + const conflictsSection = conflictsBlock ? `${conflictsBlock}\n\n` : ""; + + const testInvocation = `#{bin}/supabase`; + + const checksums = new Map(); + const checksumsText = new TextDecoder("utf-8", { ignoreBOM: true }).decode( + yield* fs.readFile(path.join(distDir, "checksums.txt")), + ); + for (const line of checksumsText.trim().split("\n")) { + const [hash, file] = line.split(/\s+/) as [string, string]; + checksums.set(file, hash); + } -const baseUrl = local - ? `file://${distDir}` - : `https://github.com/${repo}/releases/download/v${version}`; + const sha = Effect.fnUntraced(function* (file: string) { + const hash = checksums.get(file); + if (!hash) return yield* new FormulaError({ message: `Checksum not found for ${file}` }); + return hash; + }); -const formula = `class ${className} < Formula + const baseUrl = local + ? `file://${distDir}` + : `https://github.com/${repo}/releases/download/v${version}`; + + const formula = `class ${className} < Formula desc "Supabase CLI" homepage "https://supabase.com" version "${version}" @@ -76,20 +87,20 @@ const formula = `class ${className} < Formula on_macos do if Hardware::CPU.arm? url "${baseUrl}/supabase_${version}_darwin_arm64.tar.gz" - sha256 "${sha(`supabase_${version}_darwin_arm64.tar.gz`)}" + sha256 "${yield* sha(`supabase_${version}_darwin_arm64.tar.gz`)}" else url "${baseUrl}/supabase_${version}_darwin_amd64.tar.gz" - sha256 "${sha(`supabase_${version}_darwin_amd64.tar.gz`)}" + sha256 "${yield* sha(`supabase_${version}_darwin_amd64.tar.gz`)}" end end on_linux do if Hardware::CPU.arm? url "${baseUrl}/supabase_${version}_linux_arm64.tar.gz" - sha256 "${sha(`supabase_${version}_linux_arm64.tar.gz`)}" + sha256 "${yield* sha(`supabase_${version}_linux_arm64.tar.gz`)}" else url "${baseUrl}/supabase_${version}_linux_amd64.tar.gz" - sha256 "${sha(`supabase_${version}_linux_amd64.tar.gz`)}" + sha256 "${yield* sha(`supabase_${version}_linux_amd64.tar.gz`)}" end end @@ -103,43 +114,63 @@ ${installBlock} end `; -const formulaFileName = `${name}.rb`; -const formulaOut = path.join(distDir, formulaFileName); -await writeFile(formulaOut, formula); -console.log(`Formula written to ${formulaOut}`); + const formulaFileName = `${name}.rb`; + const formulaOut = path.join(distDir, formulaFileName); + yield* fs.writeFileString(formulaOut, formula); + yield* Console.log(`Formula written to ${formulaOut}`); -if (local || dryRun) { - console.log(formula); - process.exit(0); -} + if (local || dryRun) { + yield* Console.log(formula); + return yield* Effect.sync(() => process.exit(0)); + } -async function hasStagedChanges(repoDir: string, repoPath: string): Promise { - const diff = - await $`git -C ${repoDir} diff --cached --quiet --exit-code -- ${repoPath}`.nothrow(); - if (diff.exitCode === 0) return false; - if (diff.exitCode === 1) return true; - throw new Error(`Failed to inspect staged changes for ${repoPath}`); -} + const hasStagedChanges = Effect.fnUntraced(function* (repoDir: string, repoPath: string) { + const diff = yield* shell(() => + $`git -C ${repoDir} diff --cached --quiet --exit-code -- ${repoPath}`.nothrow(), + ); + if (diff.exitCode === 0) return false; + if (diff.exitCode === 1) return true; + return yield* new FormulaError({ message: `Failed to inspect staged changes for ${repoPath}` }); + }); + + const publishToTap = (tmpDir: string) => + Effect.gen(function* () { + const tapUrl = `https://github.com/${tap}.git`; + yield* shell(() => $`git clone ${tapUrl} ${tmpDir}`); + + const formulaDir = path.join(tmpDir, "Formula"); + yield* shell(() => $`mkdir -p ${formulaDir}`); + const tapFormulaPath = path.join(formulaDir, formulaFileName); + const tapFormulaRepoPath = `Formula/${formulaFileName}`; + yield* fs.writeFileString(tapFormulaPath, formula); + + yield* shell(() => $`git -C ${tmpDir} add ${tapFormulaRepoPath}`); + if (yield* hasStagedChanges(tmpDir, tapFormulaRepoPath)) { + yield* shell(() => $`git -C ${tmpDir} commit -m ${name + " " + version}`); + yield* shell(() => $`git -C ${tmpDir} push`); + yield* Console.log(`Pushed formula update to ${tap}`); + } else { + yield* Console.log(`Formula ${formulaFileName} is already up to date in ${tap}`); + } + }); + /* A failed removal replaces the run's outcome. */ + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const tmpDir = yield* fs.makeTempDirectory({ prefix: "homebrew-tap-" }); + const outcome = yield* Effect.exit(restore(publishToTap(tmpDir))); + yield* fs.remove(tmpDir, { recursive: true }); + return yield* outcome; + }), + ); +}); -const tmpDir = await mkdtemp(path.join(tmpdir(), "homebrew-tap-")); -try { - const tapUrl = `https://github.com/${tap}.git`; - await $`git clone ${tapUrl} ${tmpDir}`; - - const formulaDir = path.join(tmpDir, "Formula"); - await $`mkdir -p ${formulaDir}`; - const tapFormulaPath = path.join(formulaDir, formulaFileName); - const tapFormulaRepoPath = `Formula/${formulaFileName}`; - await writeFile(tapFormulaPath, formula); - - await $`git -C ${tmpDir} add ${tapFormulaRepoPath}`; - if (await hasStagedChanges(tmpDir, tapFormulaRepoPath)) { - await $`git -C ${tmpDir} commit -m ${name + " " + version}`; - await $`git -C ${tmpDir} push`; - console.log(`Pushed formula update to ${tap}`); - } else { - console.log(`Formula ${formulaFileName} is already up to date in ${tap}`); - } -} finally { - await rm(tmpDir, { recursive: true }); +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + return error.cause !== undefined ? error.cause : new Error(error.message); } + +await Effect.runPromise( + main.pipe( + Effect.catch((error) => Effect.die(thrownValue(error))), + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + ), +); diff --git a/apps/cli/scripts/update-scoop.ts b/apps/cli/scripts/update-scoop.ts index 596f6405f7..cadbd2bdb0 100644 --- a/apps/cli/scripts/update-scoop.ts +++ b/apps/cli/scripts/update-scoop.ts @@ -1,9 +1,8 @@ import { $ } from "bun"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { Console, Data, Effect, FileSystem, Layer, Path, Schema } from "effect"; const { values } = parseArgs({ options: { @@ -16,122 +15,158 @@ const { values } = parseArgs({ }, }); -const version = values.version; -if (!version) { - console.error( - "Usage: bun run scripts/update-scoop.ts --version [--repo ] [--bucket ] [--name ] [--local] [--dry-run]", +class ShellFailed extends Data.TaggedError("ShellFailed")<{ readonly cause: unknown }> {} + +class ManifestError extends Data.TaggedError("ManifestError")<{ readonly message: string }> {} + +const shell = (command: () => PromiseLike) => + Effect.tryPromise({ try: command, catch: (cause) => new ShellFailed({ cause }) }); + +const encodeManifest = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 4 })); + +const main = Effect.gen(function* () { + const version = values.version; + if (!version) { + yield* Console.error( + "Usage: bun run scripts/update-scoop.ts --version [--repo ] [--bucket ] [--name ] [--local] [--dry-run]", + ); + return yield* Effect.sync(() => process.exit(1)); + } + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repo = values.repo!; + const bucket = values.bucket!; + const name = values.name!; + const local = values.local!; + const dryRun = values["dry-run"]!; + + // The shipped binary is always `supabase.exe`; only the manifest filename differs (e.g. + // `supabase-beta.json`), so stable and beta can coexist in the same bucket. + const binEntry = "supabase.exe"; + const root = path.resolve(import.meta.dir, "../../.."); + const distDir = path.join(root, "dist"); + + const checksums = new Map(); + const checksumsText = new TextDecoder("utf-8", { ignoreBOM: true }).decode( + yield* fs.readFile(path.join(distDir, "checksums.txt")), ); - process.exit(1); -} + for (const line of checksumsText.trim().split("\n")) { + const [hash, file] = line.split(/\s+/) as [string, string]; + checksums.set(file, hash); + } -const repo = values.repo!; -const bucket = values.bucket!; -const name = values.name!; -const local = values.local!; -const dryRun = values["dry-run"]!; - -// The shipped binary is always `supabase.exe`; only the manifest filename differs (e.g. -// `supabase-beta.json`), so stable and beta can coexist in the same bucket. -const binEntry = "supabase.exe"; -const root = path.resolve(import.meta.dir, "../../.."); -const distDir = path.join(root, "dist"); - -const checksums = new Map(); -const checksumsText = await readFile(path.join(distDir, "checksums.txt"), "utf-8"); -for (const line of checksumsText.trim().split("\n")) { - const [hash, file] = line.split(/\s+/) as [string, string]; - checksums.set(file, hash); -} + const sha = Effect.fnUntraced(function* (file: string) { + const hash = checksums.get(file); + if (!hash) return yield* new ManifestError({ message: `Checksum not found for ${file}` }); + return hash; + }); -function sha(file: string): string { - const hash = checksums.get(file); - if (!hash) throw new Error(`Checksum not found for ${file}`); - return hash; -} + // Scoop supports file:// URLs for local testing + const baseUrl = local + ? `file:///${distDir.replace(/\\/g, "/")}` + : `https://github.com/${repo}/releases/download/v${version}`; -// Scoop supports file:// URLs for local testing -const baseUrl = local - ? `file:///${distDir.replace(/\\/g, "/")}` - : `https://github.com/${repo}/releases/download/v${version}`; - -// Published releases use unversioned Windows tarball names (release-shared.yml copies the -// versioned build); local builds only emit versioned archives, so --local keeps those names. -const amd64Tar = local - ? `supabase_${version}_windows_amd64.tar.gz` - : "supabase_windows_amd64.tar.gz"; -const arm64Tar = local - ? `supabase_${version}_windows_arm64.tar.gz` - : "supabase_windows_arm64.tar.gz"; - -const manifest = { - version, - description: "Supabase CLI", - homepage: "https://supabase.com/", - license: "MIT", - architecture: { - "64bit": { - url: `${baseUrl}/${amd64Tar}`, - hash: sha(`supabase_${version}_windows_amd64.tar.gz`), - }, - arm64: { - url: `${baseUrl}/${arm64Tar}`, - hash: sha(`supabase_${version}_windows_arm64.tar.gz`), - }, - }, - bin: binEntry, - checkver: { - github: `https://github.com/${repo}`, - }, - autoupdate: { + // Published releases use unversioned Windows tarball names (release-shared.yml copies the + // versioned build); local builds only emit versioned archives, so --local keeps those names. + const amd64Tar = local + ? `supabase_${version}_windows_amd64.tar.gz` + : "supabase_windows_amd64.tar.gz"; + const arm64Tar = local + ? `supabase_${version}_windows_arm64.tar.gz` + : "supabase_windows_arm64.tar.gz"; + + const manifest = { + version, + description: "Supabase CLI", + homepage: "https://supabase.com/", + license: "MIT", architecture: { "64bit": { - url: `https://github.com/${repo}/releases/download/v$version/supabase_windows_amd64.tar.gz`, + url: `${baseUrl}/${amd64Tar}`, + hash: yield* sha(`supabase_${version}_windows_amd64.tar.gz`), }, arm64: { - url: `https://github.com/${repo}/releases/download/v$version/supabase_windows_arm64.tar.gz`, + url: `${baseUrl}/${arm64Tar}`, + hash: yield* sha(`supabase_${version}_windows_arm64.tar.gz`), }, }, - hash: { - url: "$baseurl/supabase_$version_checksums.txt", + bin: binEntry, + checkver: { + github: `https://github.com/${repo}`, }, - }, -}; + autoupdate: { + architecture: { + "64bit": { + url: `https://github.com/${repo}/releases/download/v$version/supabase_windows_amd64.tar.gz`, + }, + arm64: { + url: `https://github.com/${repo}/releases/download/v$version/supabase_windows_arm64.tar.gz`, + }, + }, + hash: { + url: "$baseurl/supabase_$version_checksums.txt", + }, + }, + }; -const manifestFileName = `${name}.json`; -const manifestJson = `${JSON.stringify(manifest, null, 4)}\n`; -const manifestOut = path.join(distDir, manifestFileName); -await writeFile(manifestOut, manifestJson); -console.log(`Manifest written to ${manifestOut}`); + const manifestFileName = `${name}.json`; + const manifestJson = `${yield* encodeManifest(manifest)}\n`; + const manifestOut = path.join(distDir, manifestFileName); + yield* fs.writeFileString(manifestOut, manifestJson); + yield* Console.log(`Manifest written to ${manifestOut}`); -if (local || dryRun) { - console.log(manifestJson); - process.exit(0); -} + if (local || dryRun) { + yield* Console.log(manifestJson); + return yield* Effect.sync(() => process.exit(0)); + } -async function hasStagedChanges(repoDir: string, repoPath: string): Promise { - const diff = - await $`git -C ${repoDir} diff --cached --quiet --exit-code -- ${repoPath}`.nothrow(); - if (diff.exitCode === 0) return false; - if (diff.exitCode === 1) return true; - throw new Error(`Failed to inspect staged changes for ${repoPath}`); -} + const hasStagedChanges = Effect.fnUntraced(function* (repoDir: string, repoPath: string) { + const diff = yield* shell(() => + $`git -C ${repoDir} diff --cached --quiet --exit-code -- ${repoPath}`.nothrow(), + ); + if (diff.exitCode === 0) return false; + if (diff.exitCode === 1) return true; + return yield* new ManifestError({ + message: `Failed to inspect staged changes for ${repoPath}`, + }); + }); -const tmpDir = await mkdtemp(path.join(tmpdir(), "scoop-bucket-")); -try { - const bucketUrl = `https://github.com/${bucket}.git`; - await $`git clone ${bucketUrl} ${tmpDir}`; - - const bucketManifestPath = path.join(tmpDir, manifestFileName); - await writeFile(bucketManifestPath, manifestJson); - - await $`git -C ${tmpDir} add ${manifestFileName}`; - if (await hasStagedChanges(tmpDir, manifestFileName)) { - await $`git -C ${tmpDir} commit -m ${name + " " + version}`; - await $`git -C ${tmpDir} push`; - console.log(`Pushed manifest update to ${bucket}`); - } else { - console.log(`Manifest ${manifestFileName} is already up to date in ${bucket}`); - } -} finally { - await rm(tmpDir, { recursive: true }); + const publishToBucket = (tmpDir: string) => + Effect.gen(function* () { + const bucketUrl = `https://github.com/${bucket}.git`; + yield* shell(() => $`git clone ${bucketUrl} ${tmpDir}`); + + const bucketManifestPath = path.join(tmpDir, manifestFileName); + yield* fs.writeFileString(bucketManifestPath, manifestJson); + + yield* shell(() => $`git -C ${tmpDir} add ${manifestFileName}`); + if (yield* hasStagedChanges(tmpDir, manifestFileName)) { + yield* shell(() => $`git -C ${tmpDir} commit -m ${name + " " + version}`); + yield* shell(() => $`git -C ${tmpDir} push`); + yield* Console.log(`Pushed manifest update to ${bucket}`); + } else { + yield* Console.log(`Manifest ${manifestFileName} is already up to date in ${bucket}`); + } + }); + /* A failed removal replaces the run's outcome. */ + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const tmpDir = yield* fs.makeTempDirectory({ prefix: "scoop-bucket-" }); + const outcome = yield* Effect.exit(restore(publishToBucket(tmpDir))); + yield* fs.remove(tmpDir, { recursive: true }); + return yield* outcome; + }), + ); +}); + +function thrownValue(error: { readonly message: string; readonly cause?: unknown }): unknown { + return error.cause !== undefined ? error.cause : new Error(error.message); } + +await Effect.runPromise( + main.pipe( + Effect.catch((error) => Effect.die(thrownValue(error))), + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)), + ), +); diff --git a/apps/cli/scripts/upload-release-assets.integration.test.ts b/apps/cli/scripts/upload-release-assets.integration.test.ts index 699cae4bf1..879dd8986a 100644 --- a/apps/cli/scripts/upload-release-assets.integration.test.ts +++ b/apps/cli/scripts/upload-release-assets.integration.test.ts @@ -1,7 +1,16 @@ -import { afterEach, describe, expect, test } from "vitest"; -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { + Clock, + Config, + ConfigProvider, + Effect, + FileSystem, + Layer, + Option, + Path, + Schema, +} from "effect"; import { fileURLToPath } from "node:url"; import { createSpawnRun, @@ -17,15 +26,13 @@ const asset = { path: "dist/supabase_1.0.0_darwin_arm64.tar.gz", name: "supabase_1.0.0_darwin_arm64.tar.gz", }; -const temporaryDirectories: string[] = []; +const platformLayer = Layer.mergeAll( + BunFileSystem.layer, + BunPath.layer, + ConfigProvider.layer(ConfigProvider.fromEnvRecord(process.env, { preserveEmptyStrings: true })), +); -afterEach(async () => { - await Promise.all( - temporaryDirectories - .splice(0) - .map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); // A stand-in `gh` that logs every call, fails or hangs once per marker file, and serves // `release view` from view.json. @@ -54,18 +61,21 @@ fi exit 0 `; -async function fakeGhOnPath() { - const directory = await mkdtemp(path.join(tmpdir(), "upload-release-assets-")); - temporaryDirectories.push(directory); +const fakeGhOnPath = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "upload-release-assets-" }); const bin = path.join(directory, "bin"); - await mkdir(bin); - await writeFile(path.join(bin, "gh"), fakeGh); - await chmod(path.join(bin, "gh"), 0o755); - const env = { ...process.env, FAKE_GH_DIR: directory, PATH: `${bin}:${process.env.PATH}` }; - const calls = async () => - (await readFile(path.join(directory, "calls.log"), "utf8")).trimEnd().split("\n"); + yield* fs.makeDirectory(bin); + yield* fs.writeFileString(path.join(bin, "gh"), fakeGh); + yield* fs.chmod(path.join(bin, "gh"), 0o755); + const inheritedPath = Option.getOrUndefined(yield* Config.option(Config.String("PATH"))); + const env = { ...process.env, FAKE_GH_DIR: directory, PATH: `${bin}:${inheritedPath}` }; + const calls = fs + .readFileString(path.join(directory, "calls.log")) + .pipe(Effect.map((text) => text.trimEnd().split("\n"))); return { directory, env, calls }; -} +}); function recordingIo(run: ReleaseIo["run"]): { io: ReleaseIo; logs: string[] } { const logs: string[] = []; @@ -73,120 +83,153 @@ function recordingIo(run: ReleaseIo["run"]): { io: ReleaseIo; logs: string[] } { logs, io: { run, - fileExists: async () => true, - sleep: () => Promise.resolve(), - log: (line) => { - logs.push(line); - }, + fileExists: () => Effect.succeed(true), + sleep: () => Effect.void, + log: (line) => + Effect.sync(() => { + logs.push(line); + }), }, }; } describe("upload-release-assets against a fake gh", () => { - test("kills an upload that outlives the timeout and succeeds on the retry", async () => { - const { directory, env, calls } = await fakeGhOnPath(); - await writeFile(path.join(directory, `hang-once-${asset.name}`), ""); - const { io, logs } = recordingIo(createSpawnRun(env)); - const startedAt = Date.now(); - - await uploadAssets("v1.0.0", [asset], io, { - maxAttempts: 2, - timeoutMs: 5_000, - backoffMs: () => 0, - }); - - const elapsed = Date.now() - startedAt; - expect(elapsed).toBeGreaterThanOrEqual(5_000); - expect(elapsed).toBeLessThan(25_000); - expect(await calls()).toEqual([ - `release upload v1.0.0 ${asset.path} --clobber`, - `release upload v1.0.0 ${asset.path} --clobber`, - ]); - expect(logs).toEqual([ - `Upload of ${asset.name} failed on attempt 1 (timed out after 5s); retrying in 0s.`, - `Uploaded ${asset.name} (attempt 2).`, - ]); - }, 40_000); - - test("kills an upload that ignores SIGTERM once the grace period passes", async () => { - const { directory, env, calls } = await fakeGhOnPath(); - await writeFile(path.join(directory, `ignore-term-once-${asset.name}`), ""); - const { io, logs } = recordingIo(createSpawnRun(env)); - const startedAt = Date.now(); - - await uploadAssets("v1.0.0", [asset], io, { - maxAttempts: 2, - timeoutMs: 5_000, - backoffMs: () => 0, - }); - - const elapsed = Date.now() - startedAt; - expect(elapsed).toBeGreaterThanOrEqual(5_000 + KILL_GRACE_MS); - expect(elapsed).toBeLessThan(25_000); - expect(await calls()).toHaveLength(2); - expect(logs[0]).toBe( - `Upload of ${asset.name} failed on attempt 1 (timed out after 5s); retrying in 0s.`, - ); - }, 40_000); - - test("surfaces gh's stderr for a failed attempt and retries it", async () => { - const { directory, env, calls } = await fakeGhOnPath(); - await writeFile(path.join(directory, `fail-once-${asset.name}`), ""); - const { io, logs } = recordingIo(createSpawnRun(env)); - - await uploadAssets("v1.0.0", [asset], io, { - maxAttempts: 2, - timeoutMs: 5_000, - backoffMs: () => 0, - }); - - expect(await calls()).toHaveLength(2); - expect(logs[0]).toBe( - `Upload of ${asset.name} failed on attempt 1 (exit 1: HTTP 500: Error saving asset); retrying in 0s.`, - ); - }); - - test("reads uploaded asset names from gh release view", async () => { - const { directory, env } = await fakeGhOnPath(); - await writeFile( - path.join(directory, "view.json"), - JSON.stringify({ - assets: [ - { name: "install", state: "uploaded" }, - { name: "checksums.txt", state: "starter" }, - ], - }), - ); - const { io } = recordingIo(createSpawnRun(env)); - - await expect(uploadedAssetNames("v1.0.0", io)).resolves.toEqual(["install"]); - }); - - test("runs as a command and uploads all 22 assets from the working directory", async () => { - const { directory, env, calls } = await fakeGhOnPath(); - const workdir = path.join(directory, "work"); - await mkdir(path.join(workdir, "dist"), { recursive: true }); - for (const asset of releaseAssets("1.0.0")) { - await writeFile(path.join(workdir, asset.path), asset.name); - } - const bunExecutable = Bun.which("bun"); - if (!bunExecutable) throw new Error("Bun executable not found"); - - const child = Bun.spawn([bunExecutable, scriptPath, "upload", "--version", "1.0.0"], { - cwd: workdir, - env, - stdout: "pipe", - stderr: "pipe", - }); - const [exitCode, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]); - - expect(exitCode, stderr).toBe(0); - expect(await calls()).toHaveLength(22); - expect(stdout.trim().split("\n")).toHaveLength(22); - expect(stdout).toContain("Uploaded install (attempt 1)."); - }, 20_000); + it.live( + "kills an upload that outlives the timeout and succeeds on the retry", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { directory, env, calls } = yield* fakeGhOnPath; + yield* fs.writeFileString(path.join(directory, `hang-once-${asset.name}`), ""); + const { io, logs } = recordingIo(createSpawnRun(env)); + const startedAt = yield* Clock.currentTimeMillis; + + yield* uploadAssets("v1.0.0", [asset], io, { + maxAttempts: 2, + timeoutMs: 5_000, + backoffMs: () => 0, + }); + + const elapsed = (yield* Clock.currentTimeMillis) - startedAt; + expect(elapsed).toBeGreaterThanOrEqual(5_000); + expect(elapsed).toBeLessThan(25_000); + expect(yield* calls).toEqual([ + `release upload v1.0.0 ${asset.path} --clobber`, + `release upload v1.0.0 ${asset.path} --clobber`, + ]); + expect(logs).toEqual([ + `Upload of ${asset.name} failed on attempt 1 (timed out after 5s); retrying in 0s.`, + `Uploaded ${asset.name} (attempt 2).`, + ]); + }).pipe(Effect.provide(platformLayer)), + 40_000, + ); + + it.live( + "kills an upload that ignores SIGTERM once the grace period passes", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { directory, env, calls } = yield* fakeGhOnPath; + yield* fs.writeFileString(path.join(directory, `ignore-term-once-${asset.name}`), ""); + const { io, logs } = recordingIo(createSpawnRun(env)); + const startedAt = yield* Clock.currentTimeMillis; + + yield* uploadAssets("v1.0.0", [asset], io, { + maxAttempts: 2, + timeoutMs: 5_000, + backoffMs: () => 0, + }); + + const elapsed = (yield* Clock.currentTimeMillis) - startedAt; + expect(elapsed).toBeGreaterThanOrEqual(5_000 + KILL_GRACE_MS); + expect(elapsed).toBeLessThan(25_000); + expect(yield* calls).toHaveLength(2); + expect(logs[0]).toBe( + `Upload of ${asset.name} failed on attempt 1 (timed out after 5s); retrying in 0s.`, + ); + }).pipe(Effect.provide(platformLayer)), + 40_000, + ); + + it.live("surfaces gh's stderr for a failed attempt and retries it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { directory, env, calls } = yield* fakeGhOnPath; + yield* fs.writeFileString(path.join(directory, `fail-once-${asset.name}`), ""); + const { io, logs } = recordingIo(createSpawnRun(env)); + + yield* uploadAssets("v1.0.0", [asset], io, { + maxAttempts: 2, + timeoutMs: 5_000, + backoffMs: () => 0, + }); + + expect(yield* calls).toHaveLength(2); + expect(logs[0]).toBe( + `Upload of ${asset.name} failed on attempt 1 (exit 1: HTTP 500: Error saving asset); retrying in 0s.`, + ); + }).pipe(Effect.provide(platformLayer)), + ); + + it.live("reads uploaded asset names from gh release view", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { directory, env } = yield* fakeGhOnPath; + yield* fs.writeFileString( + path.join(directory, "view.json"), + yield* encodeJson({ + assets: [ + { name: "install", state: "uploaded" }, + { name: "checksums.txt", state: "starter" }, + ], + }), + ); + const { io } = recordingIo(createSpawnRun(env)); + + expect(yield* uploadedAssetNames("v1.0.0", io)).toEqual(["install"]); + }).pipe(Effect.provide(platformLayer)), + ); + + it.live( + "runs as a command and uploads all 22 assets from the working directory", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { directory, env, calls } = yield* fakeGhOnPath; + const workdir = path.join(directory, "work"); + yield* fs.makeDirectory(path.join(workdir, "dist"), { recursive: true }); + for (const asset of releaseAssets("1.0.0")) { + yield* fs.writeFileString(path.join(workdir, asset.path), asset.name); + } + const bunExecutable = Bun.which("bun"); + if (!bunExecutable) throw new Error("Bun executable not found"); + + const child = Bun.spawn([bunExecutable, scriptPath, "upload", "--version", "1.0.0"], { + cwd: workdir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + Effect.promise(() => child.exited), + Effect.promise(() => new Response(child.stdout).text()), + Effect.promise(() => new Response(child.stderr).text()), + ], + { concurrency: "unbounded" }, + ); + + expect(exitCode, stderr).toBe(0); + expect(yield* calls).toHaveLength(22); + expect(stdout.trim().split("\n")).toHaveLength(22); + expect(stdout).toContain("Uploaded install (attempt 1)."); + }).pipe(Effect.provide(platformLayer)), + 20_000, + ); }); diff --git a/apps/cli/scripts/upload-release-assets.ts b/apps/cli/scripts/upload-release-assets.ts index a0138e12ed..c9b16b98a4 100644 --- a/apps/cli/scripts/upload-release-assets.ts +++ b/apps/cli/scripts/upload-release-assets.ts @@ -1,5 +1,6 @@ import { parseArgs } from "node:util"; import process from "node:process"; +import { Console, Data, Effect, Fiber } from "effect"; export interface ReleaseAsset { path: string; @@ -42,11 +43,25 @@ export interface RunResult { timedOut: boolean; } +class ReleaseAssetsError extends Data.TaggedError("ReleaseAssetsError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const toReleaseAssetsError = (cause: unknown) => + new ReleaseAssetsError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }); + export interface ReleaseIo { - run: (argv: string[], options: { timeoutMs: number }) => Promise; - fileExists: (path: string) => Promise; - sleep: (ms: number) => Promise; - log: (line: string) => void; + run: ( + argv: string[], + options: { timeoutMs: number }, + ) => Effect.Effect; + fileExists: (path: string) => Effect.Effect; + sleep: (ms: number) => Effect.Effect; + log: (line: string) => Effect.Effect; } export interface RetryPolicy { @@ -71,38 +86,40 @@ function describeFailure(result: RunResult, timeoutMs: number): string { } /** Uploads each asset in turn, retrying per asset, and throws on the first asset that never lands. */ -export async function uploadAssets( +export const uploadAssets = Effect.fnUntraced(function* ( tag: string, assets: ReleaseAsset[], io: ReleaseIo, policy: RetryPolicy = defaultRetryPolicy, -): Promise { +) { for (const asset of assets) { - if (!(await io.fileExists(asset.path))) { - throw new Error(`Release asset ${asset.path} does not exist.`); + if (!(yield* io.fileExists(asset.path))) { + return yield* new ReleaseAssetsError({ + message: `Release asset ${asset.path} does not exist.`, + }); } for (let attempt = 1; ; attempt++) { - const result = await io.run(["gh", "release", "upload", tag, asset.path, "--clobber"], { + const result = yield* io.run(["gh", "release", "upload", tag, asset.path, "--clobber"], { timeoutMs: policy.timeoutMs, }); if (result.exitCode === 0) { - io.log(`Uploaded ${asset.name} (attempt ${attempt}).`); + yield* io.log(`Uploaded ${asset.name} (attempt ${attempt}).`); break; } const failure = describeFailure(result, policy.timeoutMs); if (attempt >= policy.maxAttempts) { - throw new Error( - `Upload of ${asset.name} to ${tag} failed after ${attempt} attempts (${failure}).`, - ); + return yield* new ReleaseAssetsError({ + message: `Upload of ${asset.name} to ${tag} failed after ${attempt} attempts (${failure}).`, + }); } const delayMs = policy.backoffMs(attempt); - io.log( + yield* io.log( `Upload of ${asset.name} failed on attempt ${attempt} (${failure}); retrying in ${delayMs / 1000}s.`, ); - await io.sleep(delayMs); + yield* io.sleep(delayMs); } } -} +}); interface ReleaseAssetView { name: string; @@ -110,17 +127,22 @@ interface ReleaseAssetView { } /** Names of the release's assets that GitHub reports as fully uploaded. */ -export async function uploadedAssetNames(tag: string, io: ReleaseIo): Promise { - const result = await io.run(["gh", "release", "view", tag, "--json", "assets"], { +export const uploadedAssetNames = Effect.fnUntraced(function* (tag: string, io: ReleaseIo) { + const result = yield* io.run(["gh", "release", "view", tag, "--json", "assets"], { timeoutMs: 60_000, }); if (result.exitCode !== 0) { - throw new Error(`Could not read assets of ${tag} (${describeFailure(result, 60_000)}).`); + return yield* new ReleaseAssetsError({ + message: `Could not read assets of ${tag} (${describeFailure(result, 60_000)}).`, + }); } - return parseAssetView(result.stdout, tag) + return (yield* Effect.try({ + try: () => parseAssetView(result.stdout, tag), + catch: toReleaseAssetsError, + })) .filter((asset) => asset.state === "uploaded") .map((asset) => asset.name); -} +}); function isAssetView(value: unknown): value is ReleaseAssetView { return ( @@ -158,37 +180,41 @@ const usage = `Usage: bun apps/cli/scripts/upload-release-assets.ts , one at a time with retries. verify Fail unless every expected asset is present on v and reported as uploaded.`; -export async function main(argv: string[], io: ReleaseIo): Promise { - const { values, positionals } = parseArgs({ - args: argv, - options: { version: { type: "string" } }, - allowPositionals: true, +export const main = Effect.fnUntraced(function* (argv: string[], io: ReleaseIo) { + const { values, positionals } = yield* Effect.try({ + try: () => + parseArgs({ + args: argv, + options: { version: { type: "string" } }, + allowPositionals: true, + }), + catch: toReleaseAssetsError, }); const [command] = positionals; if (!values.version || (command !== "upload" && command !== "verify")) { - io.log(usage); + yield* io.log(usage); return 2; } const tag = `v${values.version}`; const assets = releaseAssets(values.version); if (command === "upload") { - await uploadAssets(tag, assets, io); + yield* uploadAssets(tag, assets, io); return 0; } const missing = missingAssets( assets.map((asset) => asset.name), - await uploadedAssetNames(tag, io), + yield* uploadedAssetNames(tag, io), ); if (missing.length > 0) { - io.log(`::error::Release ${tag} is missing assets or has incomplete uploads:`); - for (const name of missing) io.log(` ${name}`); + yield* io.log(`::error::Release ${tag} is missing assets or has incomplete uploads:`); + for (const name of missing) yield* io.log(` ${name}`); return 1; } - io.log(`All ${assets.length} expected assets are present on ${tag}.`); + yield* io.log(`All ${assets.length} expected assets are present on ${tag}.`); return 0; -} +}); /** * Time a timed-out command gets to exit on SIGTERM before it is killed outright, and the bound on @@ -196,56 +222,67 @@ export async function main(argv: string[], io: ReleaseIo): Promise { */ export const KILL_GRACE_MS = 5_000; -function within(promise: Promise, ms: number, fallback: T): Promise { - let timer: ReturnType | undefined; - const expiry = new Promise((resolve) => { - timer = setTimeout(() => resolve(fallback), ms); - }); - return Promise.race([promise, expiry]).finally(() => clearTimeout(timer)); -} +const within = (fiber: Fiber.Fiber, ms: number, fallback: string) => + Fiber.join(fiber).pipe( + Effect.timeoutOrElse({ duration: ms, orElse: () => Effect.succeed(fallback) }), + ); /** Runs a command with the given environment and terminates it once the timeout elapses. */ export function createSpawnRun( env: Record = process.env, ): ReleaseIo["run"] { - return async (argv, { timeoutMs }) => { - const child = Bun.spawn(argv, { env, stdout: "pipe", stderr: "pipe" }); + return Effect.fnUntraced(function* (argv: string[], { timeoutMs }: { timeoutMs: number }) { + const child = yield* Effect.try({ + try: () => Bun.spawn(argv, { env, stdout: "pipe", stderr: "pipe" }), + catch: toReleaseAssetsError, + }); let timedOut = false; - let killTimer: ReturnType | undefined; - const timer = setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - killTimer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS); - }, timeoutMs); + yield* Effect.sleep(timeoutMs).pipe( + Effect.andThen( + Effect.sync(() => { + timedOut = true; + child.kill("SIGTERM"); + }), + ), + Effect.andThen(Effect.sleep(KILL_GRACE_MS)), + Effect.andThen(Effect.sync(() => child.kill("SIGKILL"))), + Effect.forkScoped({ startImmediately: true }), + ); // Start draining before the child exits so a chatty child never blocks on a full pipe. - const stdoutText = new Response(child.stdout).text(); - const stderrText = new Response(child.stderr).text(); - try { - const exitCode = await child.exited; - const [stdout, stderr] = await Promise.all([ - within(stdoutText, KILL_GRACE_MS, ""), - within(stderrText, KILL_GRACE_MS, ""), - ]); - return { exitCode, stdout, stderr, timedOut }; - } finally { - clearTimeout(timer); - clearTimeout(killTimer); - } - }; + const stdoutText = yield* Effect.tryPromise({ + try: () => new Response(child.stdout).text(), + catch: toReleaseAssetsError, + }).pipe(Effect.forkScoped({ startImmediately: true })); + const stderrText = yield* Effect.tryPromise({ + try: () => new Response(child.stderr).text(), + catch: toReleaseAssetsError, + }).pipe(Effect.forkScoped({ startImmediately: true })); + const exitCode = yield* Effect.tryPromise({ + try: () => child.exited, + catch: toReleaseAssetsError, + }); + const [stdout, stderr] = yield* Effect.all( + [within(stdoutText, KILL_GRACE_MS, ""), within(stderrText, KILL_GRACE_MS, "")], + { concurrency: "unbounded" }, + ); + return { exitCode, stdout, stderr, timedOut }; + }, Effect.scoped); } export const processIo: ReleaseIo = { run: createSpawnRun(), - fileExists: (path) => Bun.file(path).exists(), - sleep: (ms) => Bun.sleep(ms), - log: (line) => console.log(line), + fileExists: (path) => + Effect.tryPromise({ try: () => Bun.file(path).exists(), catch: toReleaseAssetsError }), + sleep: (ms) => Effect.sleep(ms), + log: (line) => Console.log(line), }; if (import.meta.main) { - try { - process.exit(await main(process.argv.slice(2), processIo)); - } catch (cause) { - console.error(`::error::${cause instanceof Error ? cause.message : String(cause)}`); - process.exit(1); - } + process.exit( + await Effect.runPromise( + main(process.argv.slice(2), processIo).pipe( + Effect.catch((error) => Console.error(`::error::${error.message}`).pipe(Effect.as(1))), + ), + ), + ); } diff --git a/apps/cli/scripts/upload-release-assets.unit.test.ts b/apps/cli/scripts/upload-release-assets.unit.test.ts index 8c4c99d88c..58f41fc537 100644 --- a/apps/cli/scripts/upload-release-assets.unit.test.ts +++ b/apps/cli/scripts/upload-release-assets.unit.test.ts @@ -1,3 +1,5 @@ +import { it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; import { describe, expect, test } from "vitest"; import { main, @@ -19,6 +21,8 @@ const failed: RunResult = { }; const timedOut: RunResult = { exitCode: null, stdout: "", stderr: "", timedOut: true }; +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + const fastPolicy: RetryPolicy = { maxAttempts: 3, timeoutMs: 1_000, @@ -32,18 +36,21 @@ function fakeIo( ) { const state = { runs: [] as string[][], sleeps: [] as number[], logs: [] as string[] }; const io: ReleaseIo = { - run: async (argv) => { - state.runs.push(argv); - const target = (argv[2] === "upload" ? argv[4] : argv[3]) ?? ""; - return script[target]?.shift() ?? ok; - }, - fileExists: async (path) => !options.missingFiles?.includes(path), - sleep: async (ms) => { - state.sleeps.push(ms); - }, - log: (line) => { - state.logs.push(line); - }, + run: (argv) => + Effect.sync(() => { + state.runs.push(argv); + const target = (argv[2] === "upload" ? argv[4] : argv[3]) ?? ""; + return script[target]?.shift() ?? ok; + }), + fileExists: (path) => Effect.sync(() => !options.missingFiles?.includes(path)), + sleep: (ms) => + Effect.sync(() => { + state.sleeps.push(ms); + }), + log: (line) => + Effect.sync(() => { + state.logs.push(line); + }), }; return { io, state }; } @@ -76,104 +83,123 @@ describe("uploadAssets", () => { const third = { path: "install", name: "install" }; const assets = [first, second, third]; - test("uploads every asset once with --clobber when nothing fails", async () => { - const { io, state } = fakeIo(); - - await uploadAssets("v1.0.0", assets, io, fastPolicy); - - expect(state.runs).toEqual( - assets.map((asset) => ["gh", "release", "upload", "v1.0.0", asset.path, "--clobber"]), - ); - expect(state.sleeps).toEqual([]); - expect(state.logs.filter((line) => line.startsWith("Uploaded "))).toHaveLength(3); - }); - - test("retries a failed asset with growing pauses and moves on once it lands", async () => { - const { io, state } = fakeIo({ [second.path]: [failed, failed] }); - - await uploadAssets("v1.0.0", assets, io, fastPolicy); - - const secondAttempts = state.runs.filter((argv) => argv[4] === second.path); - expect(secondAttempts).toHaveLength(3); - expect(state.sleeps).toEqual([10, 20]); - expect(state.logs).toContain( - `Upload of ${second.name} failed on attempt 1 (exit 1: HTTP 500: Error saving asset); retrying in 0.01s.`, - ); - expect(state.logs).toContain(`Uploaded ${first.name} (attempt 1).`); - expect(state.logs).toContain(`Uploaded ${second.name} (attempt 3).`); - }); - - test("gives up on an asset after the last attempt and does not touch later assets", async () => { - const { io, state } = fakeIo({ [second.path]: [failed, failed, failed] }); - - await expect(uploadAssets("v1.0.0", assets, io, fastPolicy)).rejects.toThrow( - `Upload of ${second.name} to v1.0.0 failed after 3 attempts (exit 1: HTTP 500: Error saving asset).`, - ); - - expect(state.runs.filter((argv) => argv[4] === third.path)).toHaveLength(0); - expect(state.sleeps).toEqual([10, 20]); - }); - - test("treats a timed-out upload as a failed attempt", async () => { - const { io, state } = fakeIo({ [first.path]: [timedOut] }); - - await uploadAssets("v1.0.0", [first], io, fastPolicy); - - expect(state.runs).toHaveLength(2); - expect(state.logs[0]).toBe( - `Upload of ${first.name} failed on attempt 1 (timed out after 1s); retrying in 0.01s.`, - ); - }); - - test("fails before calling gh when an asset file is missing", async () => { - const { io, state } = fakeIo({}, { missingFiles: [first.path] }); - - await expect(uploadAssets("v1.0.0", [first], io, fastPolicy)).rejects.toThrow( - `Release asset ${first.path} does not exist.`, - ); - expect(state.runs).toEqual([]); - }); + it.effect("uploads every asset once with --clobber when nothing fails", () => + Effect.gen(function* () { + const { io, state } = fakeIo(); + + yield* uploadAssets("v1.0.0", assets, io, fastPolicy); + + expect(state.runs).toEqual( + assets.map((asset) => ["gh", "release", "upload", "v1.0.0", asset.path, "--clobber"]), + ); + expect(state.sleeps).toEqual([]); + expect(state.logs.filter((line) => line.startsWith("Uploaded "))).toHaveLength(3); + }), + ); + + it.effect("retries a failed asset with growing pauses and moves on once it lands", () => + Effect.gen(function* () { + const { io, state } = fakeIo({ [second.path]: [failed, failed] }); + + yield* uploadAssets("v1.0.0", assets, io, fastPolicy); + + const secondAttempts = state.runs.filter((argv) => argv[4] === second.path); + expect(secondAttempts).toHaveLength(3); + expect(state.sleeps).toEqual([10, 20]); + expect(state.logs).toContain( + `Upload of ${second.name} failed on attempt 1 (exit 1: HTTP 500: Error saving asset); retrying in 0.01s.`, + ); + expect(state.logs).toContain(`Uploaded ${first.name} (attempt 1).`); + expect(state.logs).toContain(`Uploaded ${second.name} (attempt 3).`); + }), + ); + + it.effect("gives up on an asset after the last attempt and does not touch later assets", () => + Effect.gen(function* () { + const { io, state } = fakeIo({ [second.path]: [failed, failed, failed] }); + + const error = yield* Effect.flip(uploadAssets("v1.0.0", assets, io, fastPolicy)); + expect(error.message).toContain( + `Upload of ${second.name} to v1.0.0 failed after 3 attempts (exit 1: HTTP 500: Error saving asset).`, + ); + + expect(state.runs.filter((argv) => argv[4] === third.path)).toHaveLength(0); + expect(state.sleeps).toEqual([10, 20]); + }), + ); + + it.effect("treats a timed-out upload as a failed attempt", () => + Effect.gen(function* () { + const { io, state } = fakeIo({ [first.path]: [timedOut] }); + + yield* uploadAssets("v1.0.0", [first], io, fastPolicy); + + expect(state.runs).toHaveLength(2); + expect(state.logs[0]).toBe( + `Upload of ${first.name} failed on attempt 1 (timed out after 1s); retrying in 0.01s.`, + ); + }), + ); + + it.effect("fails before calling gh when an asset file is missing", () => + Effect.gen(function* () { + const { io, state } = fakeIo({}, { missingFiles: [first.path] }); + + const error = yield* Effect.flip(uploadAssets("v1.0.0", [first], io, fastPolicy)); + expect(error.message).toContain(`Release asset ${first.path} does not exist.`); + expect(state.runs).toEqual([]); + }), + ); }); describe("uploadedAssetNames", () => { - test("keeps only assets GitHub reports as uploaded", async () => { - const view = { - assets: [ - { name: "checksums.txt", state: "uploaded" }, - { name: "install", state: "starter" }, - ], - }; - const { io, state } = fakeIo({ "v1.0.0": [{ ...ok, stdout: JSON.stringify(view) }] }); - - await expect(uploadedAssetNames("v1.0.0", io)).resolves.toEqual(["checksums.txt"]); - expect(state.runs).toEqual([["gh", "release", "view", "v1.0.0", "--json", "assets"]]); - }); - - test("fails when the release cannot be read", async () => { - const { io } = fakeIo({ "v1.0.0": [{ ...failed, stderr: "release not found\n" }] }); - - await expect(uploadedAssetNames("v1.0.0", io)).rejects.toThrow( - "Could not read assets of v1.0.0 (exit 1: release not found).", - ); - }); - - test("names the release when gh prints something other than JSON", async () => { - const { io } = fakeIo({ "v1.0.0": [{ ...ok, stdout: "gh: rate limited\n" }] }); - - await expect(uploadedAssetNames("v1.0.0", io)).rejects.toThrow( - "Could not read assets of v1.0.0: gh returned invalid JSON.", - ); - }); - - test("names the release when the JSON has no usable assets array", async () => { - const { io } = fakeIo({ - "v1.0.0": [{ ...ok, stdout: JSON.stringify({ assets: [{ name: "install" }] }) }], - }); - - await expect(uploadedAssetNames("v1.0.0", io)).rejects.toThrow( - "Could not read assets of v1.0.0: gh output has no assets array with name and state.", - ); - }); + it.effect("keeps only assets GitHub reports as uploaded", () => + Effect.gen(function* () { + const view = { + assets: [ + { name: "checksums.txt", state: "uploaded" }, + { name: "install", state: "starter" }, + ], + }; + const { io, state } = fakeIo({ "v1.0.0": [{ ...ok, stdout: yield* encodeJson(view) }] }); + + expect(yield* uploadedAssetNames("v1.0.0", io)).toEqual(["checksums.txt"]); + expect(state.runs).toEqual([["gh", "release", "view", "v1.0.0", "--json", "assets"]]); + }), + ); + + it.effect("fails when the release cannot be read", () => + Effect.gen(function* () { + const { io } = fakeIo({ "v1.0.0": [{ ...failed, stderr: "release not found\n" }] }); + + const error = yield* Effect.flip(uploadedAssetNames("v1.0.0", io)); + expect(error.message).toContain( + "Could not read assets of v1.0.0 (exit 1: release not found).", + ); + }), + ); + + it.effect("names the release when gh prints something other than JSON", () => + Effect.gen(function* () { + const { io } = fakeIo({ "v1.0.0": [{ ...ok, stdout: "gh: rate limited\n" }] }); + + const error = yield* Effect.flip(uploadedAssetNames("v1.0.0", io)); + expect(error.message).toContain("Could not read assets of v1.0.0: gh returned invalid JSON."); + }), + ); + + it.effect("names the release when the JSON has no usable assets array", () => + Effect.gen(function* () { + const { io } = fakeIo({ + "v1.0.0": [{ ...ok, stdout: yield* encodeJson({ assets: [{ name: "install" }] }) }], + }); + + const error = yield* Effect.flip(uploadedAssetNames("v1.0.0", io)); + expect(error.message).toContain( + "Could not read assets of v1.0.0: gh output has no assets array with name and state.", + ); + }), + ); }); describe("missingAssets", () => { @@ -189,34 +215,40 @@ describe("main", () => { stdout: JSON.stringify({ assets: names.map((name) => ({ name, state: "uploaded" })) }), }); - test("prints usage for an unknown command or a missing version", async () => { - const { io, state } = fakeIo(); - - await expect(main(["publish", "--version", "1.0.0"], io)).resolves.toBe(2); - await expect(main(["upload"], io)).resolves.toBe(2); - expect(state.runs).toEqual([]); - expect(state.logs[0]).toContain("Usage:"); - }); - - test("verify succeeds when every expected asset is uploaded", async () => { - const names = releaseAssets("1.0.0").map((asset) => asset.name); - const { io, state } = fakeIo({ "v1.0.0": [view(names)] }); - - await expect(main(["verify", "--version", "1.0.0"], io)).resolves.toBe(0); - expect(state.logs).toEqual(["All 22 expected assets are present on v1.0.0."]); - }); - - test("verify fails and names each missing asset", async () => { - const names = releaseAssets("1.0.0") - .map((asset) => asset.name) - .filter((name) => name !== "checksums.txt" && name !== "install"); - const { io, state } = fakeIo({ "v1.0.0": [view(names)] }); - - await expect(main(["verify", "--version", "1.0.0"], io)).resolves.toBe(1); - expect(state.logs).toEqual([ - "::error::Release v1.0.0 is missing assets or has incomplete uploads:", - " checksums.txt", - " install", - ]); - }); + it.effect("prints usage for an unknown command or a missing version", () => + Effect.gen(function* () { + const { io, state } = fakeIo(); + + expect(yield* main(["publish", "--version", "1.0.0"], io)).toBe(2); + expect(yield* main(["upload"], io)).toBe(2); + expect(state.runs).toEqual([]); + expect(state.logs[0]).toContain("Usage:"); + }), + ); + + it.effect("verify succeeds when every expected asset is uploaded", () => + Effect.gen(function* () { + const names = releaseAssets("1.0.0").map((asset) => asset.name); + const { io, state } = fakeIo({ "v1.0.0": [view(names)] }); + + expect(yield* main(["verify", "--version", "1.0.0"], io)).toBe(0); + expect(state.logs).toEqual(["All 22 expected assets are present on v1.0.0."]); + }), + ); + + it.effect("verify fails and names each missing asset", () => + Effect.gen(function* () { + const names = releaseAssets("1.0.0") + .map((asset) => asset.name) + .filter((name) => name !== "checksums.txt" && name !== "install"); + const { io, state } = fakeIo({ "v1.0.0": [view(names)] }); + + expect(yield* main(["verify", "--version", "1.0.0"], io)).toBe(1); + expect(state.logs).toEqual([ + "::error::Release v1.0.0 is missing assets or has incomplete uploads:", + " checksums.txt", + " install", + ]); + }), + ); });