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",
+ ]);
+ }),
+ );
});