pnpm login refused to run whenever stdin or stdout was not a TTY, even though the registry web-auth flow only prints an authentication URL and polls the done endpoint until the browser approval completes - neither needs a terminal. Agent- and CI-adjacent tooling had to wrap pnpm in a pseudo-terminal (script -q /dev/null pnpm login) to use the web flow. Move the non-interactive guard from the top of the login command into the classic username/password fallback, the only path that prompts on the terminal. Without a TTY the web flow now prints the authentication URL and polls as before; the URL is printed without the QR code (a piped stdout cannot render the block art), and the press-ENTER browser prompt was already skipped for a non-TTY stdin. A registry without web login support still fails with ERR_PNPM_LOGIN_NON_INTERACTIVE. Harden the TypeScript web-login path to match pacquet while touching it: narrow the attacker-controlled response body at runtime (a missing, empty, or non-string loginUrl/doneUrl is an invalid response), and reject URLs containing Unicode control characters with pacquet's ERR_PNPM_AUTH_COMMANDS_LOGIN_UNSAFE_URL before anything is printed or polled. The shared error message now says "authentication URL" in both stacks, since the check covers loginUrl and doneUrl alike. Implemented in both stacks: the TypeScript CLI moves the guard into classicLogin and prints a URL-only message via the new formatAuthUrlOnlyMessage export of the web-auth package; pacquet moves the same guard into classic_login and selects AuthUrlMessage::UrlOnly when stdout is not a TTY. The pacquet CLI adapter unit test and the CLI-tier integration tests now drive the guard through a 404 web-login probe so it exercises the classic fallback, and a new integration test covers the headless web flow end-to-end against a mock registry. Also acknowledge a pre-existing zizmor ref-version-mismatch finding on the winget-releaser pin in update-latest.yml with the repository's usual inline ignore: the pinned commit is no longer reachable from any named ref upstream, so no version comment can describe it accurately.
272 lines
12 KiB
YAML
272 lines
12 KiB
YAML
name: Tag
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
version:
|
|
description: Version
|
|
required: true
|
|
tag:
|
|
description: Tag (use latest-<major> when tagging an older release line)
|
|
default: latest
|
|
required: true
|
|
skip_upgrade_check:
|
|
description: Skip the upgrade check (use only when the release line's current version is itself broken)
|
|
required: false
|
|
type: boolean
|
|
default: false
|
|
|
|
permissions: {}
|
|
|
|
env:
|
|
# Passed to each command as `--registry`, which is the only form pnpm reads;
|
|
# it ignores `npm_config_registry`.
|
|
REGISTRY: https://registry.npmjs.org/
|
|
# Keeps a runner-level .npmrc from redirecting any of this.
|
|
npm_config_userconfig: /dev/null
|
|
# The pnpm that operates this workflow, never the version being released:
|
|
# validate and verify-upgrade decide what the release is measured against, and
|
|
# tag-in-registry holds the publish token. Bump deliberately, to a version that
|
|
# has already proven itself as `latest`.
|
|
RELEASE_TOOL_PNPM_VERSION: 11.13.1
|
|
|
|
jobs:
|
|
validate:
|
|
name: Validate ${{ github.event.inputs.version }} and ${{ github.event.inputs.tag }}
|
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
permissions: {}
|
|
steps:
|
|
- name: Install pnpm and Node
|
|
uses: pnpm/setup@6523ce966bcf7a1061ce6401e5c6e0b60466bd31
|
|
with:
|
|
version: ${{ env.RELEASE_TOOL_PNPM_VERSION }}
|
|
runtime: node@26.5.0
|
|
install: false
|
|
token: ''
|
|
# The latest-<major> tag is derived from the version instead of being
|
|
# hardcoded per release branch, so dispatching this workflow from the wrong
|
|
# branch cannot point a newer line's tag at an older release
|
|
# (https://github.com/pnpm/pnpm/issues/12906).
|
|
- name: Validate version and tag
|
|
env:
|
|
VERSION: ${{ github.event.inputs.version }}
|
|
TAG: ${{ github.event.inputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
# Stable only: every dispatch moves latest-<major> whatever `tag` says,
|
|
# so a prerelease cannot be tagged here without reaching everyone.
|
|
if ! printf '%s\n' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
|
echo "::error::Version must be an exact, stable semver version like 11.2.0 (prereleases cannot be tagged). Got: ${VERSION}"
|
|
exit 1
|
|
fi
|
|
MAJOR="${VERSION%%.*}"
|
|
case "$TAG" in
|
|
latest-*)
|
|
if [ "$TAG" != "latest-${MAJOR}" ]; then
|
|
echo "::error::Tag ${TAG} does not match the major version of ${VERSION}. Expected latest-${MAJOR}."
|
|
exit 1
|
|
fi
|
|
;;
|
|
latest)
|
|
CURRENT_LATEST_MAJOR="$(pn view pnpm dist-tags.latest --registry="$REGISTRY" | cut -d . -f 1)"
|
|
# A dist-tag that could not be read must not pass the guard: an
|
|
# empty value makes the comparison below error out with a non-zero
|
|
# status, which `if` swallows, silently skipping the check.
|
|
if [ -z "$CURRENT_LATEST_MAJOR" ]; then
|
|
echo "::error::Could not read the current latest version of pnpm from the registry."
|
|
exit 1
|
|
fi
|
|
if [ "$MAJOR" -lt "$CURRENT_LATEST_MAJOR" ]; then
|
|
echo "::error::Refusing to move the latest tag back from v${CURRENT_LATEST_MAJOR} to ${VERSION}. Use latest-${MAJOR} to tag an older release line."
|
|
exit 1
|
|
fi
|
|
;;
|
|
esac
|
|
# Upgrading onto a version reads its published manifest and platform packages,
|
|
# so a release that is only broken as an upgrade target fails here and nowhere
|
|
# earlier — and the dist-tag move below is the last point at which it can still
|
|
# be held back.
|
|
#
|
|
# This job executes the release, so it holds no secrets: a lifecycle script
|
|
# could otherwise reach a later step through $GITHUB_PATH/$GITHUB_ENV and take
|
|
# the publish token with it.
|
|
verify-upgrade:
|
|
name: Verify upgrading onto ${{ github.event.inputs.version }}
|
|
needs: validate
|
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
permissions: {}
|
|
steps:
|
|
- name: Install pnpm and Node
|
|
uses: pnpm/setup@6523ce966bcf7a1061ce6401e5c6e0b60466bd31
|
|
with:
|
|
version: ${{ env.RELEASE_TOOL_PNPM_VERSION }}
|
|
runtime: node@26.5.0
|
|
install: false
|
|
token: ''
|
|
- name: Verify upgrading onto the new version
|
|
if: ${{ !inputs.skip_upgrade_check }}
|
|
env:
|
|
VERSION: ${{ github.event.inputs.version }}
|
|
run: |
|
|
set -eu
|
|
# pnpm enforces the devEngines of whatever manifest it finds by walking
|
|
# up from the working directory, so every command here runs from a
|
|
# scratch dir rather than the caller's cwd.
|
|
cd "$(mktemp -d)"
|
|
|
|
MAJOR="${VERSION%%.*}"
|
|
# Upgrade from what users on this release line are running today. A new
|
|
# major has no latest-<major> yet, so fall back to the current latest.
|
|
if ! FROM=$(pn view "pnpm@latest-${MAJOR}" version --registry="$REGISTRY" 2>/dev/null) || [ -z "$FROM" ]; then
|
|
FROM=$(pn view pnpm dist-tags.latest --registry="$REGISTRY")
|
|
fi
|
|
echo "Upgrading from v${FROM} to v${VERSION}"
|
|
|
|
# Both wrappers are exercised: self-update reinstalls the package it is
|
|
# running from, so `pnpm` and `@pnpm/exe` resolve different published
|
|
# manifests and a broken one surfaces on only that wrapper.
|
|
for package in pnpm @pnpm/exe; do
|
|
work=$(mktemp -d)
|
|
# Isolate the whole pnpm environment so the upgrade resolves and
|
|
# installs for real instead of reusing a warm store or global dir.
|
|
export PNPM_HOME="$work/home"
|
|
export XDG_DATA_HOME="$work/data"
|
|
export XDG_CACHE_HOME="$work/cache"
|
|
|
|
cd "$work"
|
|
mkdir -p "$work/from"
|
|
printf '{"name":"pnpm-upgrade-check","version":"0.0.0","private":true}\n' >"$work/from/package.json"
|
|
# `--allow-build` is what makes this install the starting point rather
|
|
# than a fake one: pnpm blocks dependency build scripts by default, and
|
|
# `@pnpm/exe`'s setup.js is what swaps its placeholder bin for the real
|
|
# native. Without it every run reproduces the exact breakage this gate
|
|
# exists to catch (the placeholder surviving) and blames the release.
|
|
pn add "${package}@${FROM}" \
|
|
--dir "$work/from" \
|
|
--allow-build=@pnpm/exe \
|
|
--registry="$REGISTRY"
|
|
# Drive through the bin shim rather than a path into the package: the
|
|
# entry point differs by major (pnpm.cjs in v10, pnpm.mjs in v11) and
|
|
# between the two wrappers, and the shim is what a user gets.
|
|
from_bin="$work/from/node_modules/.bin/pnpm"
|
|
|
|
# Check the starting point separately so a broken current release
|
|
# fails naming its own version rather than looking like a fault in
|
|
# $VERSION. When the current release is the broken one, it also blocks
|
|
# its own fix — dispatch with skip_upgrade_check to promote that.
|
|
test "$("$from_bin" --version)" = "$FROM"
|
|
|
|
"$from_bin" self-update "$VERSION"
|
|
|
|
# self-update installs the target using the layout of the version
|
|
# doing the installing: v11 links global bins into $PNPM_HOME/bin,
|
|
# v10 links them directly into $PNPM_HOME.
|
|
if [ -x "$PNPM_HOME/bin/pnpm" ]; then
|
|
upgraded="$PNPM_HOME/bin/pnpm"
|
|
else
|
|
upgraded="$PNPM_HOME/pnpm"
|
|
fi
|
|
# Run the upgraded binary rather than trust self-update's exit code:
|
|
# `@pnpm/exe`'s preinstall leaves its placeholder bin in place when the
|
|
# platform package ships no native, so that failure is invisible until
|
|
# the binary is actually invoked.
|
|
test "$("$upgraded" --version)" = "$VERSION"
|
|
|
|
# Hand the rest of the gate to the release itself, so what is gated is
|
|
# what ships rather than a reproduction of it maintained here. The
|
|
# command postdates the older lines this workflow can still tag, so
|
|
# skip it where it does not exist rather than fail a v10 promotion on
|
|
# a v11 command.
|
|
if "$upgraded" doctor --help >/dev/null 2>&1; then
|
|
"$upgraded" doctor --offline
|
|
else
|
|
echo "SKIP: ${package}@${VERSION} predates \`pnpm doctor\`; ran --version only"
|
|
fi
|
|
echo "OK: ${package}@${FROM} self-updated to v${VERSION}"
|
|
done
|
|
|
|
# Tagging with the promoted version reads as free verification and must not be
|
|
# done: this job is deciding whether that artifact is fit to ship, and pnpm is
|
|
# published by OIDC rather than by this token, so handing it the token turns a
|
|
# compromised dependency into org-wide publish access. verify-upgrade runs the
|
|
# release with no secrets so this job never has to.
|
|
tag-in-registry:
|
|
name: Tagging ${{ github.event.inputs.version }} as ${{ github.event.inputs.tag }}
|
|
needs: verify-upgrade
|
|
environment: release
|
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
steps:
|
|
- uses: garnet-org/action@3d47f4a9004f7356c980a0e8d420ef5984750e3c # v2.2.0
|
|
with:
|
|
api_token: ${{ secrets.GARNET_API_TOKEN }}
|
|
- name: Install pnpm and Node
|
|
uses: pnpm/setup@6523ce966bcf7a1061ce6401e5c6e0b60466bd31
|
|
with:
|
|
version: ${{ env.RELEASE_TOOL_PNPM_VERSION }}
|
|
runtime: node@26.5.0
|
|
install: false
|
|
token: ''
|
|
- name: Update tag
|
|
env:
|
|
# Written to pnpm's config below, not passed as
|
|
# `npm_config_//registry.npmjs.org/:_authToken`, which pnpm ignores.
|
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
VERSION: ${{ github.event.inputs.version }}
|
|
TAG: ${{ github.event.inputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
trap 'pn config delete "//registry.npmjs.org/:_authToken" || true' EXIT
|
|
pn config set "//registry.npmjs.org/:_authToken" "${NPM_TOKEN}"
|
|
|
|
MAJOR="${VERSION%%.*}"
|
|
pn dist-tag add "pnpm@${VERSION}" "latest-${MAJOR}" --registry="$REGISTRY"
|
|
pn dist-tag add "@pnpm/exe@${VERSION}" "latest-${MAJOR}" --registry="$REGISTRY"
|
|
if [ "$TAG" != "latest-${MAJOR}" ]; then
|
|
pn dist-tag add "pnpm@${VERSION}" "${TAG}" --registry="$REGISTRY"
|
|
pn dist-tag add "@pnpm/exe@${VERSION}" "${TAG}" --registry="$REGISTRY"
|
|
fi
|
|
|
|
publish-to-winget:
|
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
environment: release
|
|
needs: tag-in-registry
|
|
steps:
|
|
- uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 # 2026-03-15 pin, no longer on a named ref # zizmor: ignore[ref-version-mismatch]
|
|
with:
|
|
identifier: pnpm.pnpm
|
|
version: ${{ github.event.inputs.version }}
|
|
release-tag: v${{ github.event.inputs.version }}
|
|
token: ${{ secrets.WINGET_TOKEN }}
|
|
|
|
post-to-reddit:
|
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
environment: release
|
|
needs: tag-in-registry
|
|
steps:
|
|
- uses: bluwy/release-for-reddit-action@b4ee0e0d64da893e0428912aac5cda675082bd85 # v2
|
|
with:
|
|
username: ${{ secrets.REDDIT_USERNAME }}
|
|
password: ${{ secrets.REDDIT_PASSWORD }}
|
|
app-id: ${{ secrets.REDDIT_APP_ID }}
|
|
app-secret: ${{ secrets.REDDIT_APP_SECRET }}
|
|
subreddit: pnpm
|
|
title: pnpm@${{ github.event.inputs.version }} is out!
|
|
url: https://github.com/pnpm/pnpm/releases/tag/v${{ github.event.inputs.version }}
|
|
|
|
post-to-mastodon:
|
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
environment: release
|
|
needs: tag-in-registry
|
|
steps:
|
|
- name: Send toot to Mastodon
|
|
id: mastodon
|
|
uses: cbrgm/mastodon-github-action@ac2d8e8c9986a17b824dd12dd9df4ce5fcd813c1 # v2.2.2
|
|
with:
|
|
message: |
|
|
pnpm@${{ github.event.inputs.version }} is out!
|
|
https://github.com/pnpm/pnpm/releases/tag/v${{ github.event.inputs.version }}
|
|
visibility: "public"
|
|
env:
|
|
MASTODON_URL: "https://fosstodon.org/"
|
|
MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }} # access token
|