diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 1f5644536..14c471a70 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -5,6 +5,16 @@ on: types: [closed] workflow_dispatch: inputs: + release_mode: + description: 'Publish a prerelease or recover an existing stable GitHub release' + type: choice + options: + - prerelease + - recover-stable + default: prerelease + release_version: + description: 'Existing stable tag to recover (for example, 4.1.2); leave blank for prereleases' + type: string npm_tag: description: 'NPM tag for prerelease' default: 'next' @@ -14,6 +24,9 @@ jobs: name: Publish to npm runs-on: ubuntu-latest environment: npm-publish + concurrency: + group: npm-publish-stable + cancel-in-progress: false if: github.event_name == 'pull_request' && github.event.pull_request.merged == true && contains( github.event.pull_request.labels.*.name, '[ Type ] NPM version update' ) && startsWith( github.head_ref, 'release/') permissions: contents: write @@ -33,13 +46,91 @@ jobs: PROVENANCE: 'true' CONVENTIONAL_COMMITS: 'true' + recover-stable: + name: Recover stable release + if: github.event_name == 'workflow_dispatch' && inputs.release_mode == 'recover-stable' + runs-on: ubuntu-latest + environment: npm-publish + concurrency: + group: npm-publish-stable + cancel-in-progress: false + permissions: + contents: read + id-token: write + env: + RELEASE_VERSION: ${{ inputs.release_version }} + NPM_CONFIG_LOGLEVEL: error + steps: + - name: Validate recovery target + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [[ "$GITHUB_REF" != refs/heads/trunk || ! "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo '::error::Run recovery from trunk with an existing stable tag, such as 4.1.2.' + exit 1 + fi + gh release view "$RELEASE_VERSION" --repo "$GITHUB_REPOSITORY" \ + --json isDraft,isPrerelease,tagName > "$RUNNER_TEMP/recovery-release.json" + jq -e --arg version "$RELEASE_VERSION" \ + '.tagName == $version and .isDraft == false and .isPrerelease == false' \ + "$RUNNER_TEMP/recovery-release.json" + + - uses: actions/checkout@v7 + with: + ref: refs/tags/${{ inputs.release_version }} + persist-credentials: false + + - name: Validate package and prepare local release branch + run: | + node -e 'const p = require("./package.json"); if (p.name !== "@automattic/vip" || p.version !== process.env.RELEASE_VERSION) process.exit(1);' + # prepublishOnly requires a branch named trunk for the latest npm tag. + # Keep HEAD at the existing release tag; do not fetch current trunk content. + git switch -C trunk + + - uses: actions/setup-node@v7 + with: + node-version: 'lts/*' + registry-url: https://registry.npmjs.org/ + + - name: Install npm and dependencies + run: | + npm install --global npm@11 + npm ci --ignore-scripts + + - name: Refuse already published versions and downgrades + run: | + npm view @automattic/vip versions dist-tags --json > "$RUNNER_TEMP/recovery-registry.json" + node <<'NODE' + const fs = require('node:fs'); + const semver = require('semver'); + const registry = JSON.parse(fs.readFileSync(`${process.env.RUNNER_TEMP}/recovery-registry.json`, 'utf8')); + const version = process.env.RELEASE_VERSION; + if (registry.versions.includes(version) || !semver.gt(version, registry['dist-tags'].latest)) { + console.error(`Refusing recovery: ${version} is already published or does not exceed latest (${registry['dist-tags'].latest}).`); + process.exit(1); + } + NODE + + - name: Build and test release + env: + NODE_AUTH_TOKEN: '' + run: | + npm rebuild + npm run prepare --if-present + npm test + npm publish --access public --tag latest --dry-run + + - name: Publish existing release to npm + run: npm publish --access public --tag latest --provenance --loglevel error + changelog: name: Publish docs changelog permissions: contents: read pull-requests: read uses: ./.github/workflows/changelog.yml - needs: publish + needs: [publish, recover-stable] + if: always() && (needs.publish.result == 'success' || needs.recover-stable.result == 'success') secrets: CHANGELOG_DOCS_POST_TOKEN: ${{ secrets.CHANGELOG_DOCS_POST_TOKEN }} @@ -48,7 +139,8 @@ jobs: permissions: contents: read uses: ./.github/workflows/publish-docs.yml - needs: publish + needs: [publish, recover-stable] + if: always() && (needs.publish.result == 'success' || needs.recover-stable.result == 'success') secrets: DOCS_SECRET_TOKEN: ${{ secrets.DOCS_SECRET_TOKEN }} @@ -56,7 +148,7 @@ jobs: name: Publish prerelease runs-on: ubuntu-latest environment: npm-publish - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && inputs.release_mode != 'recover-stable' permissions: contents: write id-token: write diff --git a/docs/NPM-RELEASE-RECOVERY.md b/docs/NPM-RELEASE-RECOVERY.md new file mode 100644 index 000000000..18179b15e --- /dev/null +++ b/docs/NPM-RELEASE-RECOVERY.md @@ -0,0 +1,26 @@ +# Recover an interrupted stable npm release + +Use this when the stable publishing workflow created a GitHub release and tag, +but failed before publishing that version to npm. + +1. Ensure the recovery workflow is merged into `trunk`. +2. Open **Actions → Publish to npm (if applicable) → Run workflow**. +3. Select branch **trunk**, set **release_mode** to **recover-stable**, and enter + the existing tag in **release_version** (for example, `4.1.2`). +4. Leave **npm_tag** unchanged; stable recovery always publishes to `latest`. +5. Select **Run workflow** and inspect the **Recover stable release** job. + +Do not rerun the original failed run to pick up workflow changes: reruns retain +the original workflow. The original action also tries to recreate the existing +GitHub release before it reaches npm publishing. + +Recovery builds and tests the existing tag, preserves the GitHub release, and +uses the same workflow identity and `npm-publish` environment for npm trusted +publishing. It enables npm error logging and refuses versions already published +or older than the current `latest`. Successful publication starts the changelog +and command-reference documentation jobs. + +Recovery does not create the next development-version PR that the regular +publishing action normally opens after publication. Handle that version bump +separately once recovery succeeds. If npm publication succeeds but a downstream +documentation job fails, rerun only the failed jobs.