diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab59ab1..9ccb38f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,6 @@ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json -# Publishes to npm when a GitHub release is published. +# Publishes the canonical and compatibility npm packages when a GitHub release +# is published. # # Flow: `bun run release` pushes a release commit + tag and opens a draft # GitHub release. A human edits the notes and publishes the release, which @@ -52,6 +53,9 @@ jobs: echo "::error::released commit is not on main" exit 1 fi + RELEASE_SHA="$(git rev-parse HEAD)" + echo "RELEASE_SHA=$RELEASE_SHA" >> "$GITHUB_ENV" + echo "Release commit: $RELEASE_SHA" - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 @@ -63,18 +67,20 @@ jobs: - name: Install dependencies run: bun ci - # Shared policy with scripts/release.ts (release-config.ts): validates + # Shared policy with scripts/release.ts validates both package identities, # tag <-> version binding, prerelease consistency, identifier whitelist, - # and latest-tag monotonicity; emits the npm dist-tag. - - name: Verify release policy and derive npm dist-tag + # channel monotonicity, and safe partial-publish recovery. + - name: Verify release policy and derive dual-publish plan env: TAG_NAME: ${{ github.event.release.tag_name }} IS_PRERELEASE: ${{ github.event.release.prerelease }} run: | set -euo pipefail - DIST_TAG="$(bun scripts/release-guard.ts)" + RELEASE_PLAN="$(bun scripts/release-guard.ts --json)" + DIST_TAG="$(jq -r '.distTag' <<< "$RELEASE_PLAN")" echo "DIST_TAG=$DIST_TAG" >> "$GITHUB_ENV" echo "Publishing with npm dist-tag $DIST_TAG" + jq '.packages' <<< "$RELEASE_PLAN" - name: Typecheck run: bun run typecheck @@ -103,19 +109,40 @@ jobs: fi echo "npm $NPM_VERSION" - # npm publish does not verify tarball completeness; a files-array or - # build-layout regression must fail here, not ship as latest. - - name: Verify packed tarball contents + # Stage two 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 - FILES="$(npm pack --dry-run --json --ignore-scripts | jq -r '.[0].files[].path')" - for required in bin/langfuse.mjs dist/cli.js dist/contracts/catalog.json README.md; do - if ! printf '%s\n' "$FILES" | grep -qx "$required"; then - echo "::error::$required is missing from the npm tarball" - exit 1 - fi + 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 - echo "Tarball contents verified." + test "$(jq -r '.name' "$PACKAGE_ROOT/canonical/package.json")" = "@langfuse/cli" + test "$(jq -r '.name' "$PACKAGE_ROOT/legacy/package.json")" = "langfuse-cli" + grep -Fq '# `langfuse-cli` is deprecated' "$PACKAGE_ROOT/legacy/README.md" + if grep -Fq '# `langfuse-cli` is deprecated' "$PACKAGE_ROOT/canonical/README.md"; then + echo "::error::canonical npm README is marked deprecated" + exit 1 + fi + 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: | @@ -134,13 +161,59 @@ jobs: IS_PRERELEASE: ${{ github.event.release.prerelease }} run: | set -euo pipefail - FINAL_TAG="$(bun scripts/release-guard.ts)" + FINAL_PLAN="$(bun scripts/release-guard.ts --json)" + 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" # conformance:all already built dist/; --ignore-scripts avoids a second # prepublishOnly build producing a different publish than was verified. - - name: Publish to npm - run: npm publish --ignore-scripts --tag "$DIST_TAG" + # Canonical publishes first so a partial failure never blocks old-name + # users from their previously working version. + - 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 "$PACKAGE_ROOT/canonical" --ignore-scripts --tag "$DIST_TAG" + fi + + - name: Publish langfuse-cli compatibility package to npm + run: | + set -euo pipefail + STATUS="$(jq -r '.packages.legacy.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 "$PACKAGE_ROOT/legacy" --ignore-scripts --tag "$DIST_TAG" + fi + + - name: Verify both publishes reached npm + env: + TAG_NAME: ${{ github.event.release.tag_name }} + IS_PRERELEASE: ${{ github.event.release.prerelease }} + 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/release-guard.ts --json)" && + [ "$(jq -r '.packages.canonical.status' <<< "$PUBLISHED_PLAN")" = "already-published" ] && + [ "$(jq -r '.packages.legacy.status' <<< "$PUBLISHED_PLAN")" = "already-published" ] && + [ "$(npm view "@langfuse/cli@$DIST_TAG" version)" = "$EXPECTED_VERSION" ] && + [ "$(npm view "langfuse-cli@$DIST_TAG" version)" = "$EXPECTED_VERSION" ]; then + jq '.packages' <<< "$PUBLISHED_PLAN" + echo "Both npm packages verified." + exit 0 + fi + sleep "$((attempt * 2))" + done + echo "::error::could not verify both npm packages from release commit $RELEASE_SHA" + exit 1 diff --git a/.gitignore b/.gitignore index 065923d..0d7d53a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ bun.lockb .env .env.local .DS_Store +.npm-packages/ diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 06cc47c..78aa4dd 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -81,9 +81,9 @@ bun run release have not started yet passes this silently, so wait for CI after pushing); asks for the next version (patch/minor/major, or alpha/beta/rc prereleases — other identifiers and build metadata are rejected, matching - the publish workflow's policy); verifies the version is not on npm and the - tag is free; runs typecheck, both test suites, and the full conformance - build; then pushes a `chore(release): vX.Y.Z` commit plus the `vX.Y.Z` tag + the publish workflow's policy); verifies the version is unused under both + npm package names and the tag is free; runs typecheck, both test suites, and + the full conformance build; then pushes a `chore(release): vX.Y.Z` commit plus the `vX.Y.Z` tag and opens a **draft GitHub release** with generated notes. 2. **Publish the GitHub release**: edit the notes on GitHub and click Publish. This is the release decision — nothing reaches npm before it. @@ -91,10 +91,19 @@ bun run release [`release.yml`](.github/workflows/release.yml), which re-verifies the release against the same policy module the cut script uses (`scripts/release-guard.ts`: tag == package.json version, commit on main, - prerelease consistency, identifier whitelist, and `latest` never moving to - an older version), verifies the packed tarball contents, re-runs all - gates, and publishes via **npm trusted publishing (OIDC)** with provenance - attestations. No npm token exists anywhere. + prerelease consistency, identifier whitelist, both package channels in + sync, and dist-tags never moving backwards), verifies both packed tarballs, + re-runs all gates, and publishes `@langfuse/cli` followed by the + `langfuse-cli` compatibility package via **npm trusted publishing (OIDC)** + with provenance attestations. No npm token exists anywhere. + +Both packages come from one build. `scripts/npm-packages.ts` stages two package +roots with identical `bin/`, `dist/`, version, and `langfuse` executable. The +canonical package receives `README.md`; the compatibility package receives +`npm/legacy/README.md` as its npm `README.md`. The scoped package publishes +first. If the second publish fails, rerun the workflow: the guard skips an +existing version only when its `gitHead` matches the release commit, then +publishes the missing package. npm dist-tags derive from the version: stable → `latest`, `-alpha.N` → `alpha`, `-beta.N` → `beta`, `-rc.N` → `rc`. Prerelease versions must be @@ -119,15 +128,24 @@ Only when Actions is unavailable, publish directly from a machine: bun run release -- --publish-local ``` -This runs the same gates plus `npm pack --dry-run` and an explicit publish -confirmation, and requires interactive npm authentication (with OTP if the -package disallows tokens). It does not commit or tag; do that manually after. +This runs the same gates plus `npm pack --dry-run` for both staged packages and +an explicit publish confirmation, and requires interactive npm authentication +(with OTP if either package disallows tokens). It publishes the canonical +package first and the compatibility package second. It does not commit or tag; +do that manually after. `--tag ` overrides the dist-tag in this mode only; the CI path always derives it from the version. ### One-time npm/GitHub configuration (required) -On npmjs.com → `langfuse-cli` → Settings: +The public `@langfuse/cli` package must exist before npm permits trusted- +publisher configuration. Bootstrap it once as a public prerelease from an +authenticated `@langfuse` organization owner, using 2FA and +`npm publish --access public`. Do not use the stable version intended for the +first automated dual publish. + +On npmjs.com → `@langfuse/cli` → Settings, and again on +`langfuse-cli` → Settings: 1. **Trusted Publisher** → GitHub: owner `langfuse`, repository `langfuse-cli`, workflow filename `release.yml`, environment `npm-publish`. @@ -148,3 +166,20 @@ closes this: allow only tags matching `v*`. 4. Additionally, add a repository **ruleset restricting who can create `v*` tags** (Settings → Rules → Rulesets) to maintainers. + +### Legacy npm deprecation notice + +The compatibility tarball is functional but its npm README directs users to +`@langfuse/cli`. After both packages for a version are verified, an +authenticated npm owner may also attach npm's install-time notice: + +```sh +npm deprecate 'langfuse-cli@' \ + 'Renamed to @langfuse/cli. v2 will only be published there. Migration: https://github.com/langfuse/langfuse-cli#readme' +``` + +The workflow does not run `npm deprecate`: trusted publishing authenticates +package publication, while registry metadata changes remain an explicit owner +operation. At v2, remove the compatibility staging/publish steps, publish only +`@langfuse/cli`, and deprecate the complete legacy version range. Never +unpublish the old artifacts. diff --git a/README.md b/README.md index 454145e..e6b89e9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ hero-b -# langfuse-cli +# Langfuse CLI Interact with the [Langfuse](https://langfuse.com) API from the command line. @@ -8,20 +8,24 @@ Interact with the [Langfuse](https://langfuse.com) API from the command line. ```sh # Run directly -npx langfuse-cli api +npx @langfuse/cli api # via bun: -bunx --bun langfuse-cli api +bunx --bun @langfuse/cli api # Or install globally -npm i -g langfuse-cli +npm i -g @langfuse/cli # via bun: -bun add --global langfuse-cli +bun add --global @langfuse/cli # then run langfuse api langfuse --env .env api ``` +Note: the package was previously published under `langfuse-cli`. That package +is identical to thise one and will be updated alongside until we release a new major version. +We recommend using `@langfuse/cli` from now on. + ## Authentication The CLI needs the following parameters to work: diff --git a/bun.lock b/bun.lock index 74f98f4..13fc674 100644 --- a/bun.lock +++ b/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "langfuse-cli", + "name": "@langfuse/cli", "devDependencies": { "@apidevtools/swagger-parser": "^12.1.0", "@types/bun": "^1.3.14", diff --git a/npm/legacy/README.md b/npm/legacy/README.md new file mode 100644 index 0000000..2d67292 --- /dev/null +++ b/npm/legacy/README.md @@ -0,0 +1,22 @@ +# Use `@langfuse/cli` instead of the package `langfuse-cli` from now on + +The npm package has moved to +[`@langfuse/cli`](https://www.npmjs.com/package/@langfuse/cli). The executable +remains `langfuse`. + +Run the current package directly: + +```sh +npx @langfuse/cli api +``` + +For a global installation, remove the legacy package first because both +packages provide the same `langfuse` executable: + +```sh +npm uninstall -g langfuse-cli +npm install -g @langfuse/cli +``` + +See the [Langfuse CLI documentation](https://github.com/langfuse/langfuse-cli#readme) +for authentication, usage, and other installation options. diff --git a/package.json b/package.json index 572f6fc..93585a1 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "langfuse-cli", + "name": "@langfuse/cli", "version": "1.2.0", "description": "Interact with Langfuse API from the command line", "author": "Langfuse", @@ -8,6 +8,9 @@ "type": "git", "url": "git+https://github.com/langfuse/langfuse-cli.git" }, + "publishConfig": { + "access": "public" + }, "keywords": [ "langfuse", "cli", diff --git a/scripts/npm-packages.test.ts b/scripts/npm-packages.test.ts new file mode 100644 index 0000000..adf7c7d --- /dev/null +++ b/scripts/npm-packages.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +import { + CANONICAL_PACKAGE_NAME, + LEGACY_PACKAGE_NAME, + prepareNpmPackages, +} from "./npm-packages"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => + rm(path, { force: true, recursive: true }), + ), + ); +}); + +async function fixture(): Promise<{ source: string; output: string }> { + const root = await mkdtemp(resolve(tmpdir(), "langfuse-npm-packages-test-")); + temporaryDirectories.push(root); + const source = resolve(root, "source"); + const output = resolve(root, "output"); + await Promise.all([ + mkdir(resolve(source, "bin"), { recursive: true }), + mkdir(resolve(source, "dist"), { recursive: true }), + mkdir(resolve(source, "npm/legacy"), { recursive: true }), + ]); + await Promise.all([ + writeFile( + resolve(source, "package.json"), + JSON.stringify({ + name: CANONICAL_PACKAGE_NAME, + version: "1.2.3", + bin: { langfuse: "bin/langfuse.mjs" }, + files: ["bin", "dist", "README.md"], + publishConfig: { access: "public" }, + }), + ), + writeFile(resolve(source, "README.md"), "canonical readme\n"), + writeFile(resolve(source, "npm/legacy/README.md"), "deprecated readme\n"), + writeFile(resolve(source, "LICENSE"), "MIT\n"), + writeFile(resolve(source, "bin/langfuse.mjs"), "runtime\n"), + writeFile(resolve(source, "dist/cli.js"), "runtime\n"), + ]); + return { source, output }; +} + +describe("npm package staging", () => { + test("creates canonical and deprecated legacy packages from one runtime", async () => { + const { source, output } = await fixture(); + const gitHead = "a".repeat(40); + const paths = await prepareNpmPackages(output, source, gitHead); + const canonical = JSON.parse( + await readFile(resolve(paths.canonical, "package.json"), "utf8"), + ); + const legacy = JSON.parse( + await readFile(resolve(paths.legacy, "package.json"), "utf8"), + ); + + expect(canonical.name).toBe(CANONICAL_PACKAGE_NAME); + expect(legacy.name).toBe(LEGACY_PACKAGE_NAME); + expect(legacy.version).toBe(canonical.version); + expect(legacy.gitHead).toBe(gitHead); + expect(canonical.gitHead).toBe(gitHead); + expect(legacy.bin).toEqual(canonical.bin); + expect(await readFile(resolve(paths.canonical, "README.md"), "utf8")).toBe( + "canonical readme\n", + ); + expect(await readFile(resolve(paths.legacy, "README.md"), "utf8")).toBe( + "deprecated readme\n", + ); + expect(await readFile(resolve(paths.legacy, "dist/cli.js"), "utf8")).toBe( + await readFile(resolve(paths.canonical, "dist/cli.js"), "utf8"), + ); + }); + + test("refuses a non-canonical source or an existing output directory", async () => { + const first = await fixture(); + const pkgPath = resolve(first.source, "package.json"); + const pkg = JSON.parse(await readFile(pkgPath, "utf8")); + await writeFile(pkgPath, JSON.stringify({ ...pkg, name: LEGACY_PACKAGE_NAME })); + await expect(prepareNpmPackages(first.output, first.source)).rejects.toThrow( + `package.json must use canonical name ${CANONICAL_PACKAGE_NAME}`, + ); + + const second = await fixture(); + await mkdir(second.output); + await expect(prepareNpmPackages(second.output, second.source)).rejects.toThrow( + "npm package output already exists", + ); + }); +}); diff --git a/scripts/npm-packages.ts b/scripts/npm-packages.ts new file mode 100644 index 0000000..340a0bf --- /dev/null +++ b/scripts/npm-packages.ts @@ -0,0 +1,108 @@ +import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +export const CANONICAL_PACKAGE_NAME = "@langfuse/cli"; +export const LEGACY_PACKAGE_NAME = "langfuse-cli"; + +export interface PreparedNpmPackages { + canonical: string; + legacy: string; +} + +type PackageJson = { + name: string; + version: string; + [key: string]: unknown; +}; + +async function assertDoesNotExist(path: string): Promise { + try { + await stat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + throw new Error(`npm package output already exists: ${path}`); +} + +async function copyPackageFiles( + sourceRoot: string, + destination: string, +): Promise { + await mkdir(destination, { recursive: true }); + await Promise.all([ + cp(resolve(sourceRoot, "bin"), resolve(destination, "bin"), { + recursive: true, + }), + cp(resolve(sourceRoot, "dist"), resolve(destination, "dist"), { + recursive: true, + }), + cp(resolve(sourceRoot, "LICENSE"), resolve(destination, "LICENSE")), + ]); +} + +async function writePackage( + sourceRoot: string, + destination: string, + pkg: PackageJson, + readme: string, +): Promise { + await copyPackageFiles(sourceRoot, destination); + await Promise.all([ + writeFile( + resolve(destination, "package.json"), + `${JSON.stringify(pkg, null, 2)}\n`, + ), + cp(resolve(sourceRoot, readme), resolve(destination, "README.md")), + ]); +} + +export async function prepareNpmPackages( + outputRoot: string, + sourceRoot = resolve(import.meta.dir, ".."), + gitHead?: string, +): Promise { + const resolvedOutput = resolve(outputRoot); + const resolvedSource = resolve(sourceRoot); + await assertDoesNotExist(resolvedOutput); + + const pkg = JSON.parse( + await readFile(resolve(resolvedSource, "package.json"), "utf8"), + ) as PackageJson; + if (pkg.name !== CANONICAL_PACKAGE_NAME) { + throw new Error( + `package.json must use canonical name ${CANONICAL_PACKAGE_NAME}; found ${pkg.name}`, + ); + } + if (gitHead && !/^[0-9a-f]{40}$/.test(gitHead)) { + throw new Error( + `git head must be a full lowercase commit SHA; found ${gitHead}`, + ); + } + const publishPackage = gitHead ? { ...pkg, gitHead } : pkg; + + const paths = { + canonical: resolve(resolvedOutput, "canonical"), + legacy: resolve(resolvedOutput, "legacy"), + }; + await mkdir(resolvedOutput, { recursive: true }); + await Promise.all([ + writePackage(resolvedSource, paths.canonical, publishPackage, "README.md"), + writePackage( + resolvedSource, + paths.legacy, + { ...publishPackage, name: LEGACY_PACKAGE_NAME }, + "npm/legacy/README.md", + ), + ]); + return paths; +} + +if (import.meta.main) { + const [outputRoot, gitHead] = process.argv.slice(2); + if (!outputRoot) { + throw new Error("Usage: bun scripts/npm-packages.ts "); + } + const paths = await prepareNpmPackages(outputRoot, undefined, gitHead); + console.log(JSON.stringify(paths)); +} diff --git a/scripts/npm-release-plan.test.ts b/scripts/npm-release-plan.test.ts new file mode 100644 index 0000000..4238cbb --- /dev/null +++ b/scripts/npm-release-plan.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; + +import { npmReleasePlan, type NpmRegistryPackageState } from "./npm-release-plan"; + +const releaseSha = "a".repeat(40); + +function state( + name: string, + currentDistTags: Record | null, + publishedGitHead: string | null = null, +): NpmRegistryPackageState { + return { name, currentDistTags, publishedGitHead }; +} + +function plan( + canonical: NpmRegistryPackageState, + legacy: NpmRegistryPackageState, +) { + return npmReleasePlan({ + version: "1.3.0", + tagName: "v1.3.0", + isPrerelease: false, + releaseSha, + packages: { canonical, legacy }, + }); +} + +describe("dual npm release plan", () => { + test("publishes both packages when the release is new", () => { + const result = plan( + state("@langfuse/cli", null), + state("langfuse-cli", { latest: "1.2.0" }), + ); + expect(result.distTag).toBe("latest"); + expect(result.packages.canonical.status).toBe("publish"); + expect(result.packages.legacy.status).toBe("publish"); + }); + + test("resumes a partial publish from the same release commit", () => { + const result = plan( + state("@langfuse/cli", { latest: "1.3.0" }, releaseSha), + state("langfuse-cli", { latest: "1.2.0" }), + ); + expect(result.packages.canonical.status).toBe("already-published"); + expect(result.packages.legacy.status).toBe("publish"); + }); + + test("refuses an existing version from another commit", () => { + expect(() => + plan( + state("@langfuse/cli", { latest: "1.3.0" }, "b".repeat(40)), + state("langfuse-cli", { latest: "1.2.0" }), + ), + ).toThrow("already exists from git head"); + }); + + test("refuses a new release while package dist-tags are divergent", () => { + expect(() => + plan( + state("@langfuse/cli", { latest: "1.2.0" }), + state("langfuse-cli", { latest: "1.1.0" }), + ), + ).toThrow("recover the previous dual publish"); + }); + + test("keeps monotonicity checks for either package", () => { + expect(() => + plan( + state("@langfuse/cli", { latest: "2.0.0" }), + state("langfuse-cli", { latest: "2.0.0" }), + ), + ).toThrow("backwards"); + }); +}); diff --git a/scripts/npm-release-plan.ts b/scripts/npm-release-plan.ts new file mode 100644 index 0000000..fbcac71 --- /dev/null +++ b/scripts/npm-release-plan.ts @@ -0,0 +1,83 @@ +import { releaseGuard } from "./release-config"; + +export type NpmPackageId = "canonical" | "legacy"; +export type NpmPackageStatus = "publish" | "already-published"; + +export interface NpmRegistryPackageState { + name: string; + currentDistTags: Record | null; + publishedGitHead: string | null; +} + +export interface NpmReleasePlan { + version: string; + distTag: string; + packages: Record< + NpmPackageId, + { name: string; status: NpmPackageStatus } + >; +} + +export function npmReleasePlan(input: { + version: string; + tagName: string; + isPrerelease: boolean; + releaseSha: string; + packages: Record; +}): NpmReleasePlan { + if (!/^[0-9a-f]{40}$/.test(input.releaseSha)) { + throw new Error("release SHA must be a full lowercase commit SHA"); + } + + const distTag = releaseGuard({ + version: input.version, + tagName: input.tagName, + isPrerelease: input.isPrerelease, + currentDistTags: null, + }); + const entries = Object.entries(input.packages) as [ + NpmPackageId, + NpmRegistryPackageState, + ][]; + const publishedEntries = entries.filter( + ([, state]) => state.publishedGitHead !== null, + ); + + for (const [, state] of publishedEntries) { + if (state.publishedGitHead !== input.releaseSha) { + throw new Error( + `${state.name}@${input.version} already exists from git head ${state.publishedGitHead}; expected ${input.releaseSha}`, + ); + } + } + + if (publishedEntries.length === 0) { + const currentVersions = entries + .map(([, state]) => state.currentDistTags?.[distTag]) + .filter((version): version is string => version !== undefined); + if (new Set(currentVersions).size > 1) { + throw new Error( + `npm dist-tag "${distTag}" differs between package names (${currentVersions.join( + " vs ", + )}); recover the previous dual publish before releasing ${input.version}`, + ); + } + } + + const packages = Object.fromEntries( + entries.map(([id, state]) => { + if (state.publishedGitHead !== null) { + return [id, { name: state.name, status: "already-published" as const }]; + } + releaseGuard({ + version: input.version, + tagName: input.tagName, + isPrerelease: input.isPrerelease, + currentDistTags: state.currentDistTags, + }); + return [id, { name: state.name, status: "publish" as const }]; + }), + ) as NpmReleasePlan["packages"]; + + return { version: input.version, distTag, packages }; +} diff --git a/scripts/release-guard.ts b/scripts/release-guard.ts index 72b9a7d..9d5abff 100644 --- a/scripts/release-guard.ts +++ b/scripts/release-guard.ts @@ -1,41 +1,101 @@ -// Publish-time guard for .github/workflows/release.yml. Reads the release -// context from the environment, validates it against the shared policy in -// release-config.ts, and prints the npm dist-tag as its only stdout line. -// The workflow runs it twice: once early to fail fast, and once immediately -// before `npm publish` so the monotonicity check reflects the registry state -// at publish time. -import { releaseGuard } from "./release-config"; +// Publish-time guard for .github/workflows/release.yml. It validates both npm +// package identities, refuses divergent channels, and permits recovery only +// when an existing package version came from this exact release commit. +import { + CANONICAL_PACKAGE_NAME, + LEGACY_PACKAGE_NAME, +} from "./npm-packages"; +import { + npmReleasePlan, + type NpmRegistryPackageState, +} from "./npm-release-plan"; const pkg = (await Bun.file(`${import.meta.dir}/../package.json`).json()) as { name: string; version: string; }; -async function currentDistTags(): Promise | null> { - const url = `https://registry.npmjs.org/${pkg.name}`; - const response = await fetch(url, { +async function registryPackageState( + packageName: string, + version: string, +): Promise { + const encodedName = encodeURIComponent(packageName); + const packageUrl = `https://registry.npmjs.org/${encodedName}`; + const packageResponse = await fetch(packageUrl, { headers: { accept: "application/vnd.npm.install-v1+json" }, signal: AbortSignal.timeout(15_000), }); - // 404 means the package has never been published (first release). - if (response.status === 404) return null; - if (!response.ok) { - throw new Error(`npm registry returned HTTP ${response.status} for ${url}`); + if (packageResponse.status === 404) { + return { name: packageName, currentDistTags: null, publishedGitHead: null }; } - const body = (await response.json()) as { "dist-tags"?: Record }; - return body["dist-tags"] ?? null; + if (!packageResponse.ok) { + throw new Error( + `npm registry returned HTTP ${packageResponse.status} for ${packageUrl}`, + ); + } + const packageBody = (await packageResponse.json()) as { + "dist-tags"?: Record; + }; + + const versionUrl = `${packageUrl}/${encodeURIComponent(version)}`; + const versionResponse = await fetch(versionUrl, { + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(15_000), + }); + if (versionResponse.status === 404) { + return { + name: packageName, + currentDistTags: packageBody["dist-tags"] ?? null, + publishedGitHead: null, + }; + } + if (!versionResponse.ok) { + throw new Error( + `npm registry returned HTTP ${versionResponse.status} for ${versionUrl}`, + ); + } + const versionBody = (await versionResponse.json()) as { gitHead?: unknown }; + if (typeof versionBody.gitHead !== "string") { + throw new Error( + `${packageName}@${version} exists without gitHead metadata; refusing to guess whether this release published it`, + ); + } + return { + name: packageName, + currentDistTags: packageBody["dist-tags"] ?? null, + publishedGitHead: versionBody.gitHead, + }; } const tagName = process.env.TAG_NAME; const isPrerelease = process.env.IS_PRERELEASE; -if (!tagName || (isPrerelease !== "true" && isPrerelease !== "false")) { - throw new Error("release-guard requires TAG_NAME and IS_PRERELEASE (true|false) in the environment"); +const releaseSha = process.env.RELEASE_SHA; +if ( + !tagName || + (isPrerelease !== "true" && isPrerelease !== "false") || + !releaseSha +) { + throw new Error( + "release-guard requires TAG_NAME, IS_PRERELEASE (true|false), and RELEASE_SHA in the environment", + ); +} +if (pkg.name !== CANONICAL_PACKAGE_NAME) { + throw new Error( + `package.json must use canonical name ${CANONICAL_PACKAGE_NAME}; found ${pkg.name}`, + ); } -const distTag = releaseGuard({ +const [canonical, legacy] = await Promise.all([ + registryPackageState(CANONICAL_PACKAGE_NAME, pkg.version), + registryPackageState(LEGACY_PACKAGE_NAME, pkg.version), +]); +const plan = npmReleasePlan({ version: pkg.version, tagName, isPrerelease: isPrerelease === "true", - currentDistTags: await currentDistTags(), + releaseSha, + packages: { canonical, legacy }, }); -console.log(distTag); + +if (process.argv.includes("--json")) console.log(JSON.stringify(plan)); +else console.log(plan.distTag); diff --git a/scripts/release.ts b/scripts/release.ts index 92bcc78..35ff22f 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,4 +1,7 @@ import { createInterface } from "node:readline/promises"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; import { prerelease, valid } from "semver"; import { @@ -12,6 +15,11 @@ import { type PrereleaseIdentifier, type ReleaseOptions, } from "./release-config"; +import { + CANONICAL_PACKAGE_NAME, + LEGACY_PACKAGE_NAME, + prepareNpmPackages, +} from "./npm-packages"; type PackageJson = { name: string; @@ -29,7 +37,13 @@ const exactReleaseFiles = new Set([ "bun.lock", "package.json", ]); -const releasePathPrefixes = ["bin/", "conformance/", "scripts/", "src/"]; +const releasePathPrefixes = [ + "bin/", + "conformance/", + "npm/", + "scripts/", + "src/", +]; let releaseOptions: ReleaseOptions; try { releaseOptions = parseReleaseArgs(process.argv.slice(2)); @@ -52,7 +66,8 @@ function printHelp(): void { Cuts a release: verifies main is clean, green, and in sync, runs all gates, bumps the version, pushes a release commit + tag, and opens a draft GitHub release. Publishing the GitHub release triggers the npm publish via GitHub -Actions (.github/workflows/release.yml, npm trusted publishing). +Actions (.github/workflows/release.yml, npm trusted publishing) for both +${CANONICAL_PACKAGE_NAME} and ${LEGACY_PACKAGE_NAME}. Options: --version Release an explicit version without the version prompt @@ -263,12 +278,18 @@ async function assertVersionNotPublished( version: string, ): Promise { console.log(`\nChecking npm for ${packageName}@${version}...`); - const { stdout } = await runCommand( + const result = await runCommand( "npm", ["view", packageName, "versions", "--json"], - { capture: true }, + { capture: true, throwOnError: false }, ); - const parsed = JSON.parse(stdout) as string | string[]; + if (result.exitCode !== 0) { + if (result.stderr.includes("E404")) return; + throw new Error( + `npm view ${packageName} versions --json failed with exit code ${result.exitCode}\n${result.stderr}`, + ); + } + const parsed = JSON.parse(result.stdout) as string | string[]; const versions = Array.isArray(parsed) ? parsed : [parsed]; if (versions.includes(version)) { @@ -331,6 +352,7 @@ async function printPostBuildReview(): Promise { "README.md", "bin", "conformance", + "npm", "package.json", "scripts", "src", @@ -481,7 +503,8 @@ async function cutRelease(): Promise { console.log(`Git tag: ${tagName}`); console.log(`npm dist-tag (applied by CI): ${distTag}`); - await assertVersionNotPublished(pkg.name, nextVersion); + await assertVersionNotPublished(CANONICAL_PACKAGE_NAME, nextVersion); + await assertVersionNotPublished(LEGACY_PACKAGE_NAME, nextVersion); if (!isDryRun) await assertTagAvailable(tagName); pkg.version = nextVersion; @@ -543,7 +566,7 @@ async function cutRelease(): Promise { console.log("Next steps:"); console.log(" 1. Edit the release notes on GitHub."); console.log( - ` 2. Publish the release — GitHub Actions then publishes ${pkg.name}@${nextVersion} to npm with dist-tag "${distTag}".`, + ` 2. Publish the release — GitHub Actions then publishes ${CANONICAL_PACKAGE_NAME}@${nextVersion} and ${LEGACY_PACKAGE_NAME}@${nextVersion} to npm with dist-tag "${distTag}".`, ); } @@ -570,7 +593,8 @@ async function publishLocal(): Promise { console.log(`Release version: ${nextVersion}`); console.log(`npm dist-tag: ${publishTag}`); - await assertVersionNotPublished(pkg.name, nextVersion); + await assertVersionNotPublished(CANONICAL_PACKAGE_NAME, nextVersion); + await assertVersionNotPublished(LEGACY_PACKAGE_NAME, nextVersion); await assertNpmPublishContext(); pkg.version = nextVersion; @@ -579,37 +603,59 @@ async function publishLocal(): Promise { await runGates(); - await runCommand("npm", ["pack", "--dry-run"]); - await printPostBuildReview(); + const stagingParent = await mkdtemp(resolve(tmpdir(), "langfuse-cli-release-")); + const releaseSha = ( + await runCommand("git", ["rev-parse", "HEAD"], { capture: true }) + ).stdout.trim(); + const packages = await prepareNpmPackages( + resolve(stagingParent, "packages"), + undefined, + releaseSha, + ); - if (isDryRun) { - console.log("\nDry run complete. Publish skipped."); - await restorePackageJsonIfNeeded(); - return; - } + try { + await runCommand("npm", ["pack", "--dry-run", packages.canonical]); + await runCommand("npm", ["pack", "--dry-run", packages.legacy]); + await printPostBuildReview(); + + if (isDryRun) { + console.log("\nDry run complete. Publish skipped."); + await restorePackageJsonIfNeeded(); + return; + } - const shouldPublish = await confirm( - rl, - `Publish ${pkg.name}@${nextVersion} to npm with dist-tag "${publishTag}" and the status above?`, - ); - if (!shouldPublish) { - console.log("Publish skipped."); - await restorePackageJsonIfNeeded(); - return; - } + const shouldPublish = await confirm( + rl, + `Publish ${CANONICAL_PACKAGE_NAME}@${nextVersion} and ${LEGACY_PACKAGE_NAME}@${nextVersion} to npm with dist-tag "${publishTag}" and the status above?`, + ); + if (!shouldPublish) { + console.log("Publish skipped."); + await restorePackageJsonIfNeeded(); + return; + } - // conformance:all already built above, and npm pack --dry-run showed the package - // contents. Avoid a second lifecycle run producing a different publish. - publishStarted = true; - await runCommand("npm", ["publish", "--ignore-scripts", "--tag", publishTag], { - suspendPrompt: true, - }); - console.log( - `Published ${pkg.name}@${nextVersion} with npm dist-tag "${publishTag}".`, - ); - console.log( - `Create a release commit/tag for ${pkg.name}@${nextVersion}; publish-local does not commit automatically.`, - ); + // conformance:all already built above, and npm pack --dry-run showed both + // package contents. Avoid lifecycle runs producing different artifacts. + publishStarted = true; + await runCommand( + "npm", + ["publish", packages.canonical, "--ignore-scripts", "--tag", publishTag], + { suspendPrompt: true }, + ); + await runCommand( + "npm", + ["publish", packages.legacy, "--ignore-scripts", "--tag", publishTag], + { suspendPrompt: true }, + ); + console.log( + `Published both npm packages at ${nextVersion} with dist-tag "${publishTag}".`, + ); + console.log( + `Create a release commit/tag for ${CANONICAL_PACKAGE_NAME}@${nextVersion}; publish-local does not commit automatically.`, + ); + } finally { + await rm(stagingParent, { force: true, recursive: true }); + } } try { diff --git a/src/cli.ts b/src/cli.ts index 7d0a4d6..785792d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -180,7 +180,7 @@ Original error: ${reason} } function printHelp(): void { - process.stdout.write(`langfuse-cli — Interact with Langfuse from the command line + process.stdout.write(`Langfuse CLI — Interact with Langfuse from the command line Usage: langfuse [options]