From 7e45752258d137d4b70f3a58543abdef692dca2d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 19:50:05 +0000 Subject: [PATCH] ci(release): stage npm packages for 2FA approval instead of publishing Stable and beta release workflows now run npm stage publish. Staged versions are not installable until an npm maintainer approves them with 2FA, so GitHub credentials alone can no longer ship a release. Co-authored-by: Hassieb Pakzad --- .github/workflows/beta-release.yml | 52 ++++++++++------------ .github/workflows/release.yml | 70 +++++++++++++++--------------- MAINTENANCE.md | 54 ++++++++++++++++++----- scripts/release.ts | 9 ++-- 4 files changed, 106 insertions(+), 79 deletions(-) diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 329b4e7..1ec2d95 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -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 @@ -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" @@ -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" @@ -273,8 +275,11 @@ 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")" @@ -282,22 +287,13 @@ jobs: 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 \`." + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 102df4e..5b52405 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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" @@ -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 \`, 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 diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 78aa4dd..7f612e1 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -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 @@ -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 # optional: inspect the exact tarball + npm stage approve # prompts for 2FA + ``` + + Approval also works on npmjs.com. Reject with `npm stage reject ` + 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 @@ -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 diff --git a/scripts/release.ts b/scripts/release.ts index 35ff22f..d704a9a 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -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 Release an explicit version without the version prompt @@ -470,7 +471,7 @@ async function runGates(): Promise { } // 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 { if (!isDryRun) {