Publish & Release #366
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |