Skip to content

Publish & Release

Publish & Release #366

Workflow file for this run

name: Publish & Release
on:
# dispatch only, and deliberately so. a `workflow_dispatch` needs a GitHub session or token
# carrying write access; a stolen git push credential carries neither, so it can no longer start a
# release by landing a commit. that is the shape of the 2026-09-21 nubjs/nub compromise, where a
# stolen push credential cut a tag and 18 packages published unattended. bumping the version in
# package.json is therefore inert until a maintainer runs `gh workflow run publish.yml -R
# pullfrog/pullfrog`, and the staged version then still needs their 2FA approval before npm serves
# it. the version is still read from package.json, so what ships is unchanged; only the trigger is.
workflow_dispatch:
permissions:
contents: write
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
# this job stages a version and then waits for a maintainer to approve it with
# 2FA, so the wait below has to outlast a human as well as npm's automated
# review (see its comment for the measurements). 360 is GitHub's hard cap for a
# hosted runner, not a tunable; the wait is set under it so the step, not the
# job, reports a stall. this repo is public, so an idle runner costs nothing.
timeout-minutes: 360
steps:
# a step, not a job-level `if:`: a skipped job is silent, and the run would go
# green having released nothing while looking like it had.
- name: Refuse to release from any ref but main
run: |
[ "$GITHUB_REF" = "refs/heads/main" ] || { echo "::error::publish.yml runs from main only, got $GITHUB_REF"; exit 1; }
# `ref: main` rather than `github.sha`, because the tag step below cannot
# push an arbitrary commit. GitHub refuses a GITHUB_TOKEN ref push whose
# `.github/workflows/` differs from the tip of every branch, and that token
# can never hold `workflows` scope. A second `action/**` commit mirroring in
# between the version bump and this job leaves the bump commit matching no
# branch tip, so the tag push is rejected and the release half-lands: npm
# published, `v0` unmoved (0.1.66, where the mirror raced ~5min behind).
# Tagging the branch tip satisfies the rule by construction, and it also
# keeps the npm tarball and the tag tree built from ONE checkout.
- name: Checkout code
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
# `npm stage publish` landed in npm 11.15, and the npm bundled with Node 24 lags it.
- name: Install an npm CLI that can stage
run: | # zizmor: ignore[adhoc-packages] the npm CLI itself, pinned to the major that carries `npm stage`
npm install -g npm@11
v="$(npm --version)"
# `sort -V | head -1` yields the smaller of the two; it must be the floor.
[ "$(printf '%s\n11.15.0\n' "$v" | sort -V | head -1)" = "11.15.0" ] \
|| { echo "::error::npm $v cannot stage (need >= 11.15)"; exit 1; }
echo "npm $v"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Get package version
id: version
run: |
VERSION=$(npm pkg get version | tr -d '"')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
# major version tag (e.g. "v0" from "0.1.59") — the moving tag consumers use
echo "major_tag=v$(echo $VERSION | cut -d. -f1)" >> $GITHUB_OUTPUT
echo "📦 package version: $VERSION"
# the guard asks npm, not git. gating on "does the tag exist" made a half-finished
# release unrecoverable: the tag is pushed by this same job, so any later failure
# left a re-run seeing its own tag and skipping the publish it still owed.
#
# every registry read here and below hits the registry URL directly rather than
# going through `npm view`, which revalidates a cached packument and so reports a
# live version as missing for minutes. That ambiguity is what made a slow read
# indistinguishable from a stalled publish while 0.1.66 and 0.1.72 were diagnosed.
- name: Check whether npm already serves this version
id: check_npm
run: |
if curl -sf "https://registry.npmjs.org/pullfrog/${{ steps.version.outputs.version }}" > /dev/null; then
echo "published=true" >> $GITHUB_OUTPUT
echo "ℹ️ npm already serves ${{ steps.version.outputs.version }} - skipping the staging step"
else
echo "published=false" >> $GITHUB_OUTPUT
echo "✅ ${{ steps.version.outputs.version }} is not on npm yet - will stage it"
fi
# this stages, it does not publish. the trusted publisher for `pullfrog` is
# stage-only, so a run can put a version in npm's staged queue and nothing else; a
# maintainer then approves it with 2FA, which is a proof of presence no stolen
# credential can supply. the wait below is what observes that approval.
#
# `continue-on-error` came off with the token-era publish it protected: staging must
# fail loudly. the one refusal it existed for is now handled in line — npm reserves
# the version number while it is staged, so a re-run before the approval is refused,
# and that refusal is success here (0.1.71 died on exactly it and left `v0` two
# versions behind for 19h). the wording is matched loosely because the first
# stage-only release is where it gets observed; anything else is fatal.
- name: Stage the release on npm
id: stage
if: steps.check_npm.outputs.published == 'false'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
out="$(mktemp)"
if npm stage publish --provenance --access public >"$out" 2>&1; then
cat "$out"
exit 0
fi
cat "$out"
if grep -qiE 'already (been )?staged|staged version|E409|EPUBLISHCONFLICT|previously published' "$out"; then
echo "ℹ️ $VERSION is already staged or published - the approval wait decides the rest"
exit 0
fi
echo "::error::staging $VERSION failed"
exit 1
# its own step, teed to the log: a step summary reaches the job view only when its step
# ends and the run page only when the job does, so written inside the wait below these
# commands stayed unreadable for the whole window they exist to serve.
- name: Post the approval commands
if: steps.check_npm.outputs.published == 'false'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
{
echo "## Staged on npm - waiting for approval"
echo
echo "\`pullfrog@$VERSION\` is staged and is not installable until a maintainer approves it with 2FA. No tag moves and no release is created until then."
echo
echo '```'
echo "npm stage list pullfrog"
echo "npm stage approve <id>"
echo "pnpm stage approve"
echo '```'
echo
echo "\`npm stage list\` prints the stage ids. \`npm stage approve\` takes one id per call; \`pnpm stage approve\` (pnpm 12+) approves a batch with one one-time password. The Staged Packages tab on npmjs.com does the same."
echo
echo "The next step waits 350 minutes. If the approval comes later, re-run the job: it skips the staging, sees the version served, and converges the tags and the release."
} | tee -a "$GITHUB_STEP_SUMMARY"
# the release invariant: the major tag must never advance past what npm actually
# serves, because the action bakes `pullfrog@^<own version>` into its npx spec, so
# a major tag ahead of npm is ETARGET on every consumer run. confirm rather than
# assume before any tag moves.
#
# this is the human gate, and it is deliberately the same loop that used to wait out
# npm's automated review: a staged version is not served either, so registry
# liveness answers both questions at once. everything below it — the tags, the
# release — therefore exists only for a version a maintainer approved.
#
# the old budget already had to outlast the review hold (measured here: 0.1.70 sat
# 3.5 minutes, 0.1.71 ~54, 0.1.72 236; zod sees 16, 25, 116). an approval is a human
# walking to a second factor, so the window goes to 350 minutes, the most that fits
# under the job's 360-minute cap. past it the step fails rather than the job timing
# out, so the log says what to do: approve, then re-run.
- name: Wait for the maintainer's approval
id: wait
env:
VERSION: ${{ steps.version.outputs.version }}
MAJOR_TAG: ${{ steps.version.outputs.major_tag }}
run: |
for i in $(seq 1 350); do
if curl -sf "https://registry.npmjs.org/pullfrog/$VERSION" > /dev/null; then
echo "✅ npm is serving $VERSION (after $i min)"
exit 0
fi
if [ $((i % 15)) -eq 0 ]; then
echo "waiting for the staged $VERSION to be approved and served ($i minutes)"
fi
sleep 60
done
echo "::error::npm never served $VERSION in 350min; refusing to move $MAJOR_TAG. approve the staged version and re-run this job, or see the staging step's log for why it never staged"
exit 1
- name: Create and push tags
run: |
if git rev-parse "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then
echo "🏷️ ${{ steps.version.outputs.tag }} already exists"
else
git tag ${{ steps.version.outputs.tag }}
git push origin ${{ steps.version.outputs.tag }}
echo "🏷️ created ${{ steps.version.outputs.tag }}"
fi
git tag -f ${{ steps.version.outputs.major_tag }}
git push origin ${{ steps.version.outputs.major_tag }} --force
echo "🏷️ moved ${{ steps.version.outputs.major_tag }} to ${{ steps.version.outputs.version }}"
# last, and deliberately so: this is the only cosmetic step, and it is the one that
# failed on 0.1.58. from here its failure costs a re-run, never a consumer outage.
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ steps.version.outputs.tag }}
MAJOR_TAG: ${{ steps.version.outputs.major_tag }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
echo "📝 release $TAG already exists"
exit 0
fi
{
echo "## 📦 pullfrog $VERSION"
echo
echo "### Usage in GitHub Actions"
echo
echo '```yaml'
echo "- uses: pullfrog/pullfrog@$MAJOR_TAG"
echo '```'
echo
echo "### Installation via npm"
echo
echo '```bash'
echo "npm install pullfrog@$VERSION"
echo '```'
} > "$RUNNER_TEMP/release-notes.md"
gh release create "$TAG" --title "$TAG" --notes-file "$RUNNER_TEMP/release-notes.md"
- name: Summary
if: always()
run: |
echo "## 📊 Publish Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ steps.check_npm.outputs.published }}" == "true" ]]; then
echo "ℹ️ Version ${{ steps.version.outputs.version }} was already on npm - re-converged tags and release" >> $GITHUB_STEP_SUMMARY
elif [[ "${{ steps.wait.outcome }}" == "success" ]]; then
echo "✅ A maintainer approved the staged ${{ steps.version.outputs.version }} and npm serves it" >> $GITHUB_STEP_SUMMARY
elif [[ "${{ steps.stage.outcome }}" == "success" ]]; then
echo "⏳ Staged ${{ steps.version.outputs.version }} on npm, not approved in time. Nothing below it exists until a maintainer approves it with 2FA - approve it, then re-run the job." >> $GITHUB_STEP_SUMMARY
else
echo "❌ Staging ${{ steps.version.outputs.version }} failed - see the staging step's log" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🏷️ Tags" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ steps.version.outputs.tag }}\` (specific version)" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ steps.version.outputs.major_tag }}\` (moving major tag, only ever advanced after npm serves the version)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Published to" >> $GITHUB_STEP_SUMMARY
echo "- GitHub Release: [View Release](https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }})" >> $GITHUB_STEP_SUMMARY
echo "- npm Registry: [pullfrog@${{ steps.version.outputs.version }}](https://www.npmjs.com/package/pullfrog/v/${{ steps.version.outputs.version }})" >> $GITHUB_STEP_SUMMARY