diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml new file mode 100644 index 0000000..329b4e7 --- /dev/null +++ b/.github/workflows/beta-release.yml @@ -0,0 +1,303 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +# Setup on @langfuse/cli: trusted publisher beta-release.yml with environment +# npm-publish-beta. Allow source branches and require release reviewers on this +# environment: dispatch executes workflow code from the selected branch. +# Run via Actions > Beta release > Run workflow, then select the source branch. +# Merge this workflow into the default branch to expose the manual run button. +name: Beta release + +on: + workflow_dispatch: + inputs: + dry_run: + description: Run all checks and show the version without publishing + type: boolean + default: true + +permissions: {} + +defaults: + run: + shell: bash + +# Serialize beta version allocation and publishing across manual runs. +concurrency: + group: npm-beta-publish + queue: max + cancel-in-progress: false + +jobs: + beta-version: + name: Select beta version + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.plan.outputs.version }} + skip: ${{ steps.plan.outputs.skip }} + steps: + - name: Checkout selected commit + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + no-cache: true + - name: Install dependencies + run: bun ci + - name: Allocate beta version or recognize a retry + id: plan + run: bun scripts/beta-release.ts >> "$GITHUB_OUTPUT" + + preparation: + name: Prepare beta release + needs: beta-version + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ needs.beta-version.outputs.version }} + skip: ${{ needs.beta-version.outputs.skip }} + dist-tag: ${{ steps.plan.outputs.dist-tag }} + artifact-id: ${{ steps.upload.outputs.artifact-id }} + env: + TAG_NAME: ${{ format('v{0}', needs.beta-version.outputs.version) }} + IS_PRERELEASE: "true" + RELEASE_SHA: ${{ github.sha }} + steps: + - name: Checkout selected commit + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + # This workflow publishes an artifact; never restore caches into it. + no-cache: true + + - name: Install dependencies + run: bun ci + + - name: Set beta package version in the runner + env: + BETA_VERSION: ${{ needs.beta-version.outputs.version }} + run: bun scripts/beta-release.ts --apply + + - name: Verify beta release policy and derive publish plan + id: plan + run: | + set -euo pipefail + RELEASE_PLAN="$(bun scripts/beta-release.ts --plan)" + DIST_TAG="$(jq -r '.distTag' <<< "$RELEASE_PLAN")" + echo "DIST_TAG=$DIST_TAG" >> "$GITHUB_ENV" + echo "FINAL_PLAN=$RELEASE_PLAN" >> "$GITHUB_ENV" + echo "dist-tag=$DIST_TAG" >> "$GITHUB_OUTPUT" + echo "Selected npm dist-tag $DIST_TAG" + jq '.packages' <<< "$RELEASE_PLAN" + + - name: Typecheck + run: bun run typecheck + + - name: Run tests + run: bun test + + - name: Build and fake-call every endpoint in every API snapshot + run: bun run conformance:all + + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + # This workflow publishes an artifact; never restore caches into it. + package-manager-cache: false + + - name: Verify npm supports trusted publishing (requires npm >= 11.5.1) + run: | + set -euo pipefail + NPM_VERSION="$(npm --version)" + MINIMUM="11.5.1" + if [ "$(printf '%s\n' "$MINIMUM" "$NPM_VERSION" | sort -V | head -n1)" != "$MINIMUM" ]; then + echo "::error::npm $NPM_VERSION is too old for trusted publishing (need >= $MINIMUM)" + exit 1 + fi + echo "npm $NPM_VERSION" + + # Stage both package roots from the one verified build. The canonical + # package receives README.md; the legacy package receives the migration + # README under that same filename on npm. + - name: Prepare canonical and legacy npm packages + run: | + set -euo pipefail + PACKAGE_ROOT="$GITHUB_WORKSPACE/.npm-packages" + bun scripts/npm-packages.ts "$PACKAGE_ROOT" "$RELEASE_SHA" + echo "PACKAGE_ROOT=$PACKAGE_ROOT" >> "$GITHUB_ENV" + + # npm publish does not verify tarball completeness; a files-array, + # README selection, or build-layout regression must fail here. + - name: Verify both packed tarballs + run: | + set -euo pipefail + for package in canonical legacy; do + FILES="$(npm pack --dry-run --json --ignore-scripts "$PACKAGE_ROOT/$package" | jq -r '.[0].files[].path')" + for required in LICENSE bin/langfuse.mjs dist/cli.js dist/contracts/catalog.json README.md; do + if ! grep -qx "$required" <<< "$FILES"; then + echo "::error::$required is missing from the $package npm tarball" + exit 1 + fi + done + done + test "$(jq -r '.name' "$PACKAGE_ROOT/canonical/package.json")" = "@langfuse/cli" + test "$(jq -r '.name' "$PACKAGE_ROOT/legacy/package.json")" = "langfuse-cli" + cmp README.md "$PACKAGE_ROOT/canonical/README.md" + cmp npm/legacy/README.md "$PACKAGE_ROOT/legacy/README.md" + diff -qr "$PACKAGE_ROOT/canonical/bin" "$PACKAGE_ROOT/legacy/bin" + diff -qr "$PACKAGE_ROOT/canonical/dist" "$PACKAGE_ROOT/legacy/dist" + echo "Both tarballs verified." + + - name: Smoke-test the built CLI under Node and Bun + run: | + set -euo pipefail + node bin/langfuse.mjs --version + bun bin/langfuse.mjs --help > /dev/null + echo "Smoke test passed." + + - name: Pack canonical package for publishing + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/beta-release" + TARBALL="$(npm pack "$PACKAGE_ROOT/canonical" --ignore-scripts --json --pack-destination "$RUNNER_TEMP/beta-release" | jq -r '.[0].filename')" + mv "$RUNNER_TEMP/beta-release/$TARBALL" "$RUNNER_TEMP/beta-release/package.tgz" + + - name: Upload prepared package + id: upload + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: beta-package-${{ github.run_attempt }} + path: ${{ runner.temp }}/beta-release/package.tgz + if-no-files-found: error + retention-days: 7 + + - name: Report release plan + run: | + set -euo pipefail + VERSION="$(jq -r '.version' <<< "$FINAL_PLAN")" + { + echo "### Release plan" + echo "" + echo "Version: $VERSION" + echo "" + echo "npm dist-tag: $DIST_TAG" + echo "" + jq -r '.packages[] | "- \(.name): \(.status)"' <<< "$FINAL_PLAN" + echo "- langfuse-cli: validated, publishing disabled" + } | tee -a "$GITHUB_STEP_SUMMARY" + + publish: + name: Publish beta to npm + needs: preparation + if: ${{ !inputs.dry_run && needs.preparation.outputs.skip == 'false' }} + runs-on: ubuntu-latest + environment: npm-publish-beta + permissions: + contents: read + id-token: write + env: + TAG_NAME: ${{ format('v{0}', needs.preparation.outputs.version) }} + IS_PRERELEASE: "true" + RELEASE_SHA: ${{ github.sha }} + DIST_TAG: ${{ needs.preparation.outputs.dist-tag }} + steps: + - name: Checkout selected commit + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + # This workflow publishes an artifact; never restore caches into it. + no-cache: true + + - name: Install dependencies + run: bun ci + + - name: Set beta package version in the runner + env: + BETA_VERSION: ${{ needs.preparation.outputs.version }} + run: bun scripts/beta-release.ts --apply + + - name: Set up Node (bundles the npm that performs the publish) + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + # This workflow publishes an artifact; never restore caches into it. + package-manager-cache: false + + - name: Verify npm supports trusted publishing (requires npm >= 11.5.1) + run: | + set -euo pipefail + NPM_VERSION="$(npm --version)" + MINIMUM="11.5.1" + if [ "$(printf '%s\n' "$MINIMUM" "$NPM_VERSION" | sort -V | head -n1)" != "$MINIMUM" ]; then + echo "::error::npm $NPM_VERSION is too old for trusted publishing (need >= $MINIMUM)" + exit 1 + fi + echo "npm $NPM_VERSION" + + - name: Download prepared package + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + artifact-ids: ${{ needs.preparation.outputs.artifact-id }} + path: ${{ runner.temp }}/beta-release + + # The early guard ran before ~5 minutes of gates; re-check against the + # registry state at publish time so changes made outside this workflow + # cannot slip a dist-tag backwards. + - name: Re-verify release policy immediately before publish + run: | + set -euo pipefail + FINAL_PLAN="$(bun scripts/beta-release.ts --plan)" + FINAL_TAG="$(jq -r '.distTag' <<< "$FINAL_PLAN")" + if [ "$FINAL_TAG" != "$DIST_TAG" ]; then + echo "::error::dist-tag changed between verification and publish ($DIST_TAG -> $FINAL_TAG)" + exit 1 + fi + echo "FINAL_PLAN=$FINAL_PLAN" >> "$GITHUB_ENV" + jq '.packages' <<< "$FINAL_PLAN" + + # Publish the package built and checked by preparation without rebuilding it. + - name: Publish @langfuse/cli to npm + run: | + set -euo pipefail + STATUS="$(jq -r '.packages.canonical.status' <<< "$FINAL_PLAN")" + VERSION="$(jq -r '.version' <<< "$FINAL_PLAN")" + if [ "$STATUS" = "already-published" ]; then + echo "@langfuse/cli@$VERSION was already published from this release; skipping." + else + npm publish "$RUNNER_TEMP/beta-release/package.tgz" --ignore-scripts --tag "$DIST_TAG" + fi + + - name: Verify beta publish reached npm + run: | + set -euo pipefail + EXPECTED_VERSION="$(jq -r '.version' <<< "$FINAL_PLAN")" + for attempt in 1 2 3 4 5; do + if PUBLISHED_PLAN="$(bun scripts/beta-release.ts --plan)" && + [ "$(jq -r '.packages.canonical.status' <<< "$PUBLISHED_PLAN")" = "already-published" ] && + [ "$(npm view "@langfuse/cli@$DIST_TAG" version)" = "$EXPECTED_VERSION" ]; then + jq '.packages' <<< "$PUBLISHED_PLAN" + echo "Beta npm package verified." + exit 0 + fi + sleep "$((attempt * 2))" + done + echo "::error::could not verify beta package from release commit $RELEASE_SHA" + exit 1 diff --git a/scripts/beta-release.test.ts b/scripts/beta-release.test.ts new file mode 100644 index 0000000..5a5832f --- /dev/null +++ b/scripts/beta-release.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, test } from "bun:test"; +import { cp, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { betaPublishPlan, betaReleasePlan, type BetaRegistryPackage } from "./beta-release"; + +const releaseSha = "a".repeat(40); +const previousSha = "b".repeat(40); + +function registry( + beta?: string, + versions: Record = {}, + latest = "1.2.4", +): BetaRegistryPackage { + return { distTags: { latest, ...(beta ? { beta } : {}) }, versions }; +} + +function plan( + metadata = registry(), + version = "1.2.4", +) { + return betaReleasePlan({ version, releaseSha, registry: metadata }); +} + +describe("beta releases", () => { + test("checks the selected beta version, source identity, tag, and monotonicity", () => { + const input = { version: "1.2.5-beta.0", releaseSha, registry: registry() }; + expect(betaPublishPlan(input).version).toBe(input.version); + expect(() => betaPublishPlan({ ...input, tagName: "v1.2.5-beta.1" })).toThrow("does not match"); + expect(() => betaPublishPlan({ ...input, version: "1.2.5" })).toThrow("requires a beta version"); + expect(() => betaPublishPlan({ + ...input, registry: registry("1.2.5-beta.1"), + })).toThrow("backwards"); + expect(() => betaPublishPlan({ + ...input, registry: registry(undefined, { [input.version]: { gitHead: previousSha } }), + })).toThrow("different or missing gitHead"); + expect(betaPublishPlan({ + ...input, registry: registry("1.2.5-beta.1", { [input.version]: { gitHead: releaseSha } }), + }).packages.canonical.status).toBe("already-published"); + }); + + test("starts the next minor beta without touching latest", () => { + const result = plan(); + expect(result.version).toBe("1.3.0-beta.0"); + expect(plan(registry(), "2.2.4").version).toBe("2.3.0-beta.0"); + expect(result.distTag).toBe("beta"); + expect(result.packages.canonical.status).toBe("publish"); + expect(Object.keys(result.packages)).toEqual(["canonical"]); + }); + + test("increments past all existing betas, including versions off the channel", () => { + const current = registry("1.3.0-beta.2", { + "1.3.0-beta.2": { gitHead: previousSha }, + "1.3.0-beta.4": { gitHead: "c".repeat(40) }, + }); + expect(plan(current).version).toBe("1.3.0-beta.5"); + }); + + test("advances beyond stable releases and allows an explicit beta base", () => { + const current = registry("1.2.5-beta.0", { + "1.2.5-beta.0": { gitHead: previousSha }, + }, "1.3.0"); + expect(plan(current).version).toBe("1.4.0-beta.0"); + expect(plan(current, "2.0.0-beta.0").version).toBe("2.0.0-beta.0"); + }); + + test("reuses the same version after a publish succeeds", () => { + const canonical = registry("1.2.5-beta.0", { + "1.2.5-beta.0": { gitHead: releaseSha }, + }); + const result = plan(canonical); + expect(result.version).toBe("1.2.5-beta.0"); + expect(result.packages.canonical.status).toBe("already-published"); + }); + + test("recognizes an old successful run after beta has advanced", () => { + const current = registry("1.2.5-beta.1", { + "1.2.5-beta.0": { gitHead: releaseSha }, + "1.2.5-beta.1": { gitHead: previousSha }, + }); + const result = plan(current); + expect(result.version).toBe("1.2.5-beta.0"); + expect(result.packages.canonical.status).toBe("already-published"); + }); + + test("refuses non-beta package bases and missing published commit metadata", () => { + expect(() => plan(registry(), "1.3.0-rc.0")).toThrow("stable or beta"); + expect(() => plan(registry("1.2.5-beta.0", { + "1.2.5-beta.0": {}, + }))).toThrow("gitHead"); + }); +}); + +async function withFixture(run: (fixture: { + root: string; + firstSha: string; + lastSha: string; + git: (...args: string[]) => string; + execute: (metadata: BetaRegistryPackage, sha: string, args?: string[], overrides?: Record) => Promise<{ + status: number; stdout: string; stderr: string; + }>; +}) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), "cli-beta-test-")); + try { + await cp(import.meta.dir, join(root, "scripts"), { recursive: true }); + await symlink(resolve(import.meta.dir, "../node_modules"), join(root, "node_modules")); + await writeFile(join(root, "package.json"), JSON.stringify({ name: "@langfuse/cli", version: "1.2.4" })); + await writeFile(join(root, ".gitignore"), "node_modules\nmetadata.json\nregistry.ts\n"); + const git = (...args: string[]) => { + const result = Bun.spawnSync(["git", ...args], { cwd: root }); + if (result.exitCode !== 0) throw new Error(result.stderr.toString()); + return result.stdout.toString().trim(); + }; + git("init", "-b", "beta"); + git("config", "user.name", "Beta Test"); + git("config", "user.email", "test@example.invalid"); + git("config", "commit.gpgsign", "false"); + git("add", "."); + git("commit", "-m", "fixture"); + const firstSha = git("rev-parse", "HEAD"); + git("commit", "--allow-empty", "-m", "next merge"); + const lastSha = git("rev-parse", "HEAD"); + const preload = join(root, "registry.ts"); + await writeFile(preload, ` + const metadata = await Bun.file(${JSON.stringify(join(root, "metadata.json"))}).json(); + globalThis.fetch = async (url) => { + if (String(url) !== "https://registry.npmjs.org/%40langfuse%2Fcli") { + throw new Error("Beta releases must only read the canonical npm registry package"); + } + return Response.json({ "dist-tags": metadata.distTags, versions: metadata.versions }); + }; + `); + const execute = async ( + metadata: BetaRegistryPackage, + sha: string, + args: string[] = [], + overrides: Record = {}, + ) => { + await writeFile(join(root, "metadata.json"), JSON.stringify(metadata)); + const proc = Bun.spawn([process.execPath, "--preload", preload, join(root, "scripts/beta-release.ts"), ...args], { + cwd: root, + env: { ...process.env, GITHUB_EVENT_NAME: "workflow_dispatch", GITHUB_REF: "refs/heads/feature/test", GITHUB_SHA: sha, ...overrides }, + stdout: "pipe", stderr: "pipe", + }); + const [status, stdout, stderr] = await Promise.all([ + proc.exited, new Response(proc.stdout).text(), new Response(proc.stderr).text(), + ]); + return { status, stdout, stderr }; + }; + await run({ root, firstSha, lastSha, git, execute }); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +describe("beta workflow entry point", () => { + test("allows manual beta releases from unrelated branches", async () => { + await withFixture(async ({ firstSha, lastSha, git, execute }) => { + const metadata = (sha: string) => registry("1.3.0-beta.0", { + "1.3.0-beta.0": { gitHead: sha }, + }); + const next = await execute(metadata(firstSha), lastSha); + expect(next.status).toBe(0); + expect(next.stdout).toBe("version=1.3.0-beta.1\nskip=false\n"); + git("checkout", "--orphan", "unrelated"); + git("commit", "-m", "unrelated history"); + const unrelated = await execute(metadata(firstSha), git("rev-parse", "HEAD")); + expect(unrelated.status).toBe(0); + expect(unrelated.stdout).toBe("version=1.3.0-beta.1\nskip=false\n"); + }); + }); + + test("enforces beta versions and leaves Git unchanged", async () => { + await withFixture(async ({ root, firstSha, lastSha, git, execute }) => { + const planned = await execute(registry(), lastSha); + expect(planned.status).toBe(0); + expect(planned.stdout).toBe("version=1.3.0-beta.0\nskip=false\n"); + const stable = await execute(registry(), lastSha, ["--apply"], { BETA_VERSION: "1.2.5" }); + expect(stable.status).toBe(1); + expect((await Bun.file(join(root, "package.json")).json()).version).toBe("1.2.4"); + const beta = await execute(registry(), lastSha, ["--apply"], { BETA_VERSION: "1.2.5-beta.0" }); + expect(beta.status).toBe(0); + expect((await Bun.file(join(root, "package.json")).json()).version).toBe("1.2.5-beta.0"); + const checked = await execute(registry(), lastSha, ["--plan"], { + TAG_NAME: "v1.2.5-beta.0", IS_PRERELEASE: "true", + }); + expect(checked.status).toBe(0); + expect(JSON.parse(checked.stdout)).toMatchObject({ + version: "1.2.5-beta.0", distTag: "beta", + packages: { canonical: { name: "@langfuse/cli", status: "publish" } }, + }); + const invalidFlag = await execute(registry(), lastSha, ["--plan"], { + TAG_NAME: "v1.2.5-beta.0", IS_PRERELEASE: "false", + }); + expect(invalidFlag.status).toBe(1); + expect(invalidFlag.stderr).toContain("IS_PRERELEASE=true"); + expect(git("rev-parse", "HEAD")).toBe(lastSha); + expect(git("rev-parse", "HEAD~1")).toBe(firstSha); + expect(git("tag", "--list")).toBe(""); + }); + }); +}); diff --git a/scripts/beta-release.ts b/scripts/beta-release.ts new file mode 100644 index 0000000..2150d58 --- /dev/null +++ b/scripts/beta-release.ts @@ -0,0 +1,128 @@ +import { gt, inc, prerelease, rcompare, valid } from "semver"; +import { assertReleasableVersion, releaseGuard } from "./release-config"; +import type { NpmReleasePlan } from "./npm-release-plan"; +import { CANONICAL_PACKAGE_NAME } from "./npm-packages"; + +export interface BetaRegistryPackage { + distTags: Record; + versions: Record; +} + +function isBeta(version: string): boolean { + return valid(version) !== null && prerelease(version)?.[0] === "beta"; +} + +type BetaReleasePlan = Omit & { + packages: Pick; +}; + +export function betaPublishPlan(input: { + version: string; + releaseSha: string; + registry: BetaRegistryPackage; + tagName?: string; +}): BetaReleasePlan { + if (!isBeta(input.version)) throw new Error("Beta publishing requires a beta version"); + const published = input.registry.versions[input.version]; + const distTag = releaseGuard({ + version: input.version, + tagName: input.tagName ?? `v${input.version}`, + isPrerelease: true, + currentDistTags: published ? null : input.registry.distTags, + }); + if (published && published.gitHead !== input.releaseSha) { + throw new Error(`${CANONICAL_PACKAGE_NAME}@${input.version} already exists from a different or missing gitHead`); + } + return { + version: input.version, + distTag, + packages: { + canonical: { name: CANONICAL_PACKAGE_NAME, status: published ? "already-published" : "publish" }, + }, + }; +} + +export function betaReleasePlan(input: { + version: string; + releaseSha: string; + registry: BetaRegistryPackage; +}): BetaReleasePlan { + const current = assertReleasableVersion(input.version); + if (prerelease(current) && !isBeta(current)) { + throw new Error("Beta releases require a stable or beta package.json version"); + } + let next = isBeta(current) ? current : inc(current, "preminor", "beta")!; + const registry = input.registry; + const betaVersions = Object.keys(registry.versions).filter(isBeta).sort(rcompare); + const retries = betaVersions.filter((version) => + registry.versions[version]?.gitHead === input.releaseSha, + ); + if (retries.length > 1) { + throw new Error("Multiple beta versions exist for this commit; inspect npm before retrying"); + } + const stable = registry.distTags.latest; + if (stable !== undefined) { + assertReleasableVersion(stable); + if (prerelease(stable)) throw new Error("npm latest must be a stable version"); + const seed = inc(stable, "preminor", "beta")!; + if (gt(seed, next)) next = seed; + } + const highest = betaVersions[0]; + if (highest && !gt(next, highest)) next = inc(highest, "prerelease", "beta")!; + const version = retries[0] ?? next; + + const beta = registry.distTags.beta; + if (beta !== undefined) { + if (!isBeta(beta)) throw new Error("npm beta must point to a beta version"); + const gitHead = registry.versions[beta]?.gitHead; + if (!gitHead || !/^[0-9a-f]{40}$/.test(gitHead)) { + throw new Error(`Published beta ${beta} is missing valid gitHead metadata`); + } + } + + return betaPublishPlan({ ...input, version }); +} + +async function registryPackage(packageName: string): Promise { + const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(15_000), + }); + if (response.status === 404) return { distTags: {}, versions: {} }; + if (!response.ok) { + throw new Error(`npm registry returned HTTP ${response.status} for ${packageName}`); + } + const body = await response.json() as { + "dist-tags"?: Record; + versions?: BetaRegistryPackage["versions"]; + }; + return { distTags: body["dist-tags"] ?? {}, versions: body.versions ?? {} }; +} + +if (import.meta.main) { + const path = `${import.meta.dir}/../package.json`; + const pkg = await Bun.file(path).json(); + if (pkg.name !== CANONICAL_PACKAGE_NAME) throw new Error("Unexpected npm package identity"); + + if (process.argv.includes("--apply")) { + const version = assertReleasableVersion(process.env.BETA_VERSION ?? ""); + if (!isBeta(version)) throw new Error("Beta publishing requires a beta version"); + await Bun.write(path, `${JSON.stringify({ ...pkg, version }, null, 2)}\n`); + } else { + const releaseSha = process.env.GITHUB_SHA ?? ""; + const registry = await registryPackage(CANONICAL_PACKAGE_NAME); + if (process.argv.includes("--plan")) { + if (!process.env.TAG_NAME || process.env.IS_PRERELEASE !== "true") { + throw new Error("Beta publish checks require TAG_NAME and IS_PRERELEASE=true"); + } + console.log(JSON.stringify(betaPublishPlan({ + version: pkg.version, releaseSha, registry, tagName: process.env.TAG_NAME, + }))); + } else { + const plan = betaReleasePlan({ version: pkg.version, releaseSha, registry }); + const skip = plan.packages.canonical.status === "already-published"; + console.log(`version=${plan.version}`); + console.log(`skip=${skip}`); + } + } +}