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) {