diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 693c06c..7a4dd4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,16 @@ on: # suppression). Doubles as the manual lever: Actions → release → Run # workflow. workflow_dispatch: + inputs: + # Recovery lever for the one state neither the driver nor the sweeper + # can reach: a release that was tagged but whose npm publish failed. + # release-please will not re-emit an already-tagged release, so a run + # with this input skips the driver, checks out the named tag, and + # publishes that package alone. One tag per run. + republish: + description: 'Existing tag to re-publish after a publish-side failure (e.g. byteguard-v0.2.0). Leave empty normally.' + required: false + default: '' # Sweeper: converges anything the dispatch handoff dropped — a release PR # merged without its follow-up run, or an open release PR whose drive # failed. A run that finds nothing to do exits in seconds. @@ -77,7 +87,7 @@ jobs: # dropped handoff leaves behind. A ci failure fails this run before any # merge — fix main, and the next push (or the sweeper) retries. - name: Drive the release PR to a tested merge - if: ${{ steps.release.outputs.releases_created != 'true' }} + if: ${{ steps.release.outputs.releases_created != 'true' && inputs.republish == '' }} env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} @@ -139,12 +149,15 @@ jobs: # Everything below runs only in the run that finds the merged release # PR — the follow-up run the driver dispatched, the push of a hand - # merge, or a sweeper run catching up. github.sha in those runs is the - # release commit every tag release-please just created points at, so - # the default checkout is the tagged tree, whichever subset of packages - # released. + # merge, or a sweeper run catching up — or in a republish run. In the + # normal case github.sha is the release commit every tag release-please + # just created points at, so the default checkout is the tagged tree, + # whichever subset of packages released; a republish run checks out the + # named tag instead (an empty ref means the default). - uses: actions/checkout@v4 - if: ${{ steps.release.outputs.releases_created == 'true' }} + if: ${{ steps.release.outputs.releases_created == 'true' || inputs.republish != '' }} + with: + ref: ${{ inputs.republish }} # No registry-url here, deliberately: setup-node's registry-url writes # an .npmrc whose _authToken (a placeholder when NODE_AUTH_TOKEN is @@ -153,19 +166,19 @@ jobs: # E404. With no auth configured at all, npm falls through to trusted # publishing. - uses: actions/setup-node@v4 - if: ${{ steps.release.outputs.releases_created == 'true' }} + if: ${{ steps.release.outputs.releases_created == 'true' || inputs.republish != '' }} with: node-version: 22 # Node 22 ships npm 10; OIDC exchange landed in npm 11.5.1. - name: Use an npm that speaks OIDC - if: ${{ steps.release.outputs.releases_created == 'true' }} + if: ${{ steps.release.outputs.releases_created == 'true' || inputs.republish != '' }} run: | npm install -g npm@latest npm --version - name: Install dependencies - if: ${{ steps.release.outputs.releases_created == 'true' }} + if: ${{ steps.release.outputs.releases_created == 'true' || inputs.republish != '' }} run: npm ci # Build every workspace up front, in workspace order (core first): @@ -174,16 +187,16 @@ jobs: # plugin is released. Each publish below re-runs its own build via # prepublishOnly; this step just guarantees the cross-package input. - name: Build workspaces - if: ${{ steps.release.outputs.releases_created == 'true' }} + if: ${{ steps.release.outputs.releases_created == 'true' || inputs.republish != '' }} run: npm run build # Core before plugin, so a release that includes both never publishes a # plugin whose dependency range names a version the registry has not # seen yet. - name: Publish byteguard to npm - if: ${{ steps.release.outputs['packages/byteguard--release_created'] == 'true' }} + if: ${{ steps.release.outputs['packages/byteguard--release_created'] == 'true' || startsWith(inputs.republish, 'byteguard-v') }} run: npm publish --provenance --access public --workspace packages/byteguard - name: Publish vite-plugin-byteguard to npm - if: ${{ steps.release.outputs['packages/vite-plugin-byteguard--release_created'] == 'true' }} + if: ${{ steps.release.outputs['packages/vite-plugin-byteguard--release_created'] == 'true' || startsWith(inputs.republish, 'vite-plugin-byteguard-v') }} run: npm publish --provenance --access public --workspace packages/vite-plugin-byteguard diff --git a/RELEASING.md b/RELEASING.md index 2597d3c..70865f6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -193,14 +193,25 @@ left behind — a merged release pull request whose dispatch never fired, an open one whose drive failed — is picked up and completed. A sweeper run with nothing to do exits in seconds. +**The one state the sweeper cannot reach** is a release that was tagged but +whose npm publish then failed: release-please will not re-emit an +already-tagged release, so no later run retries the publish on its own. For +that there is the `republish` input on `release.yml`'s manual trigger — +**Actions → release → Run workflow →** enter the existing tag (say +`byteguard-v0.5.0`; one tag per run). That run skips the driver, checks out +the tag, and publishes just that package, authenticating over OIDC as always. +Fix whatever failed the publish first; the tag and the GitHub release need no +touch. + **What a human can still do.** Everything, just none of it is required: dispatch `ci.yml` onto the release branch from the Actions tab; squash-merge the release pull request by hand (the resulting push tags and publishes as -always); dispatch `release.yml` on `main` to force a sweep right now. The one -rule: do not push your own commits to the release branch — release-please -owns it and will overwrite. The branch is named by release-please from its -config, so anything scripted reads it from the pull request rather than -hard-coding it: `gh pr view --json headRefName`. +always); dispatch `release.yml` on `main` to force a sweep right now, or with +`republish` set to retry a failed publish. The one rule: do not push your own +commits to the release branch — release-please owns it and will overwrite. +The branch is named by release-please from its config, so anything scripted +reads it from the pull request rather than hard-coding it: +`gh pr view --json headRefName`. **The PAT escape hatch** remains wired but unused: store a fine-grained personal access token scoped to this repository (**Contents: read and