Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 24 additions & 28 deletions .github/workflows/beta-release.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# 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.
# npm-publish-beta, allowing stage publish only. Allow source branches and
# require release reviewers on this environment: dispatch executes workflow
# code from the selected branch. The beta is only staged; it goes live once an
# npm maintainer approves it with npm 2FA.
# 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
Expand Down Expand Up @@ -117,13 +119,13 @@ jobs:
# 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)
- name: Verify npm supports staged publishing (requires npm >= 11.15.0)
run: |
set -euo pipefail
NPM_VERSION="$(npm --version)"
MINIMUM="11.5.1"
MINIMUM="11.15.0"
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)"
echo "::error::npm $NPM_VERSION is too old for npm stage publish (need >= $MINIMUM)"
exit 1
fi
echo "npm $NPM_VERSION"
Expand Down Expand Up @@ -241,13 +243,13 @@ jobs:
# 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)
- name: Verify npm supports staged publishing (requires npm >= 11.15.0)
run: |
set -euo pipefail
NPM_VERSION="$(npm --version)"
MINIMUM="11.5.1"
MINIMUM="11.15.0"
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)"
echo "::error::npm $NPM_VERSION is too old for npm stage publish (need >= $MINIMUM)"
exit 1
fi
echo "npm $NPM_VERSION"
Expand All @@ -273,31 +275,25 @@ jobs:
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
# Stage the package built and checked by preparation without rebuilding
# it. Staged versions are invisible to beta-release.ts, so another beta
# run before this one is approved or rejected computes the same version
# and fails at this step.
- name: Stage @langfuse/cli on 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"
RESULT="$(npm stage publish "$RUNNER_TEMP/beta-release/package.tgz" --ignore-scripts --tag "$DIST_TAG")"
echo "$RESULT"
{
echo "### npm beta staged, approval required"
echo ""
echo "Not installable until approved on npmjs.com or with \`npm stage approve <id>\`."
echo ""
grep '^+ ' <<< "$RESULT" | sed 's/^+ /- /'
} >> "$GITHUB_STEP_SUMMARY"
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
70 changes: 34 additions & 36 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
# Publishes the canonical and compatibility npm packages when a GitHub release
# Stages 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
# triggers this workflow. Publishing authenticates via npm trusted publishing
# triggers this workflow. Staging authenticates via npm trusted publishing
# (OIDC) — no npm token exists anywhere — and npm attaches provenance
# attestations automatically.
# attestations automatically. Staged versions are not installable until an
# npm maintainer approves them with npm 2FA, so neither this workflow nor any
# GitHub credential can put a version in front of users on its own.
#
# One-time npm/GitHub setup: see MAINTENANCE.md "One-time npm configuration".
# The npm-publish environment MUST carry protection rules (required
# reviewers + deployment tag policy): release events execute this workflow
# file as of the tagged commit, so environment protection is the platform
# gate between "has push access" and "can publish to npm".
# gate between "has push access" and "can stage on npm".
name: Release

on:
Expand Down Expand Up @@ -91,20 +93,20 @@ jobs:
- name: Build and fake-call every endpoint in every API snapshot
run: bun run conformance:all

- name: Set up Node (bundles the npm that performs the publish)
- name: Set up Node (bundles the npm that performs the staging)
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)
- name: Verify npm supports staged publishing (requires npm >= 11.15.0)
run: |
set -euo pipefail
NPM_VERSION="$(npm --version)"
MINIMUM="11.5.1"
MINIMUM="11.15.0"
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)"
echo "::error::npm $NPM_VERSION is too old for npm stage publish (need >= $MINIMUM)"
exit 1
fi
echo "npm $NPM_VERSION"
Expand Down Expand Up @@ -167,50 +169,46 @@ jobs:
echo "FINAL_PLAN=$FINAL_PLAN" >> "$GITHUB_ENV"
jq '.packages' <<< "$FINAL_PLAN"

- name: Start the approval summary
run: |
{
echo "### npm packages staged, approval required"
echo ""
echo "Nothing is installable yet. Approve on npmjs.com, or run"
echo "\`npm stage approve <id>\`, in the order listed."
echo ""
} >> "$GITHUB_STEP_SUMMARY"

# conformance:all already built dist/; --ignore-scripts avoids a second
# prepublishOnly build producing a different publish than was verified.
# Canonical publishes first so a partial failure never blocks old-name
# users from their previously working version.
- name: Publish @langfuse/cli to npm
# Canonical stages first and should be approved first, so a partial
# failure never blocks old-name users from their previously working
# version. Staged versions are invisible to release-guard.ts, so a rerun
# fails on the already-staged version until it is approved or rejected.
- name: Stage @langfuse/cli on 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."
echo "- @langfuse/cli@$VERSION already published from this release" >> "$GITHUB_STEP_SUMMARY"
else
npm publish "$PACKAGE_ROOT/canonical" --ignore-scripts --tag "$DIST_TAG"
RESULT="$(npm stage publish "$PACKAGE_ROOT/canonical" --ignore-scripts --tag "$DIST_TAG")"
echo "$RESULT"
grep '^+ ' <<< "$RESULT" | sed 's/^+ /- /' >> "$GITHUB_STEP_SUMMARY"
fi

- name: Publish langfuse-cli compatibility package to npm
- name: Stage langfuse-cli compatibility package on 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."
echo "- langfuse-cli@$VERSION already published from this release" >> "$GITHUB_STEP_SUMMARY"
else
npm publish "$PACKAGE_ROOT/legacy" --ignore-scripts --tag "$DIST_TAG"
RESULT="$(npm stage publish "$PACKAGE_ROOT/legacy" --ignore-scripts --tag "$DIST_TAG")"
echo "$RESULT"
grep '^+ ' <<< "$RESULT" | sed 's/^+ /- /' >> "$GITHUB_STEP_SUMMARY"
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
54 changes: 43 additions & 11 deletions MAINTENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ an entry applied in no snapshot at all fails the build, tests, and

## Releases

Push to `main` freely; nothing publishes on push. Releases are a three-step
flow with a human gate in the middle:
Push to `main` freely; nothing publishes on push. Releases are a four-step
flow with two human gates, the last one outside GitHub:

```sh
bun run release
Expand All @@ -87,23 +87,44 @@ bun run release
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.
3. **npm publish** (automatic): publishing the release triggers
3. **npm stage** (automatic): publishing the release triggers
[`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, 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
re-runs all gates, and **stages** `@langfuse/cli` followed by the
`langfuse-cli` compatibility package via **npm trusted publishing (OIDC)**
with provenance attestations. No npm token exists anywhere.
4. **Approve on npm** (human, npm 2FA): staged versions are not installable.
The workflow run summary lists both stage ids. Approve `@langfuse/cli`
first, then `langfuse-cli`:

```sh
npm stage list # pending stage ids
npm stage download <stage-id> # optional: inspect the exact tarball
npm stage approve <stage-id> # prompts for 2FA
```

Approval also works on npmjs.com. Reject with `npm stage reject <stage-id>`
if anything looks wrong. This gate lives outside GitHub on purpose: stolen
GitHub credentials can trigger and approve the workflow, but cannot approve
an npm stage.

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/legacy/README.md` as its npm `README.md`. The scoped package stages
first. Staged versions are invisible to the guard, so if the second stage
fails, approve the already-staged canonical version and then rerun the
workflow: the guard skips an existing version only when its `gitHead` matches
the release commit, then stages the missing package. A rerun before that
approval fails on the already-staged version.

Beta releases ([`beta-release.yml`](.github/workflows/beta-release.yml)) are
staged the same way and need the same npm approval. Approve or reject a staged
beta before dispatching the next one; until then the next run computes the
same version and fails at the staging step.

npm dist-tags derive from the version: stable → `latest`, `-alpha.N` →
`alpha`, `-beta.N` → `beta`, `-rc.N` → `rc`. Prerelease versions must be
Expand Down Expand Up @@ -148,10 +169,21 @@ 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`.
workflow filename `release.yml`, environment `npm-publish`. Allow **stage
publish only**, not direct publish, so a workflow run can never make a
version installable by itself. On `@langfuse/cli`, add a second trusted
publisher for `beta-release.yml` with environment `npm-publish-beta`, also
stage-only. From the CLI:

```sh
npm trust github @langfuse/cli --file release.yml \
--repo langfuse/langfuse-cli --env npm-publish --allow-stage-publish
```

2. **Publishing access**: "Require two-factor authentication and disallow
tokens" — CI publishes via the trusted publisher, humans can still
`--publish-local` interactively with OTP, and no token can ever publish.
tokens" — CI stages via the trusted publisher, a maintainer approves with
2FA, humans can still `--publish-local` interactively with OTP, and no token
can ever publish.

In the GitHub repo, environment protection is **required, not optional**:
`release` events execute the workflow file **as of the tagged commit**, so
Expand Down
9 changes: 5 additions & 4 deletions scripts/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,10 @@ 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) for both
${CANONICAL_PACKAGE_NAME} and ${LEGACY_PACKAGE_NAME}.
release. Publishing the GitHub release stages both ${CANONICAL_PACKAGE_NAME}
and ${LEGACY_PACKAGE_NAME} on npm via GitHub Actions
(.github/workflows/release.yml, npm trusted publishing); an npm maintainer then
approves each staged version with npm 2FA.

Options:
--version <semver> Release an explicit version without the version prompt
Expand Down Expand Up @@ -470,7 +471,7 @@ async function runGates(): Promise<void> {
}

// Default mode: cut a release. Pushes a release commit + tag and opens a
// draft GitHub release; publishing that release triggers the npm publish in
// draft GitHub release; publishing that release stages the npm packages in
// GitHub Actions via npm trusted publishing. Never touches npm itself.
async function cutRelease(): Promise<void> {
if (!isDryRun) {
Expand Down
Loading