From ec2291bce0f92bea1776e0f2280a30ad8efa4e38 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:45:49 -0400 Subject: [PATCH 1/2] build(cli): gate release approval on publish job only Split the single `release` job into three purpose-driven jobs: - `mode`: Detects pending changesets and whether a publish is needed. - `version`: Creates/updates the Version Packages PR (runs only if changesets are pending). - `publish`: Publishes to npm (requires approval, runs only if needs_publish is true). The `needs_publish` logic skips publish when changesets are pending (version PR not yet merged), and when the current version is already tagged (docs-only merges). This ensures docs/CI-only merges never leave a run stuck awaiting approval, and approval gates only the final publish step. Release approval is now environment-scoped to the `publish` job, not the entire workflow. --- .github/workflows/release.yml | 103 +++++++++++++++++++++++++++++----- docs/releasing.md | 8 ++- 2 files changed, 93 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cea289bbf..7b614b5d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,18 +4,92 @@ on: push: branches: [main] -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false - jobs: - release: - name: Release - environment: release + mode: + name: Detect release mode + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + has_changesets: ${{ steps.detect.outputs.has_changesets }} + needs_publish: ${{ steps.detect.outputs.needs_publish }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Detect pending changesets and unpublished version + id: detect + run: | + shopt -s nullglob + has_changesets=false + for f in .changeset/*.md; do + [[ "$(basename "$f")" == "README.md" ]] && continue + has_changesets=true + break + done + + # Only publish when the current version has never been tagged, so + # docs-only merges don't leave a run stuck awaiting approval. + needs_publish=false + if [[ "$has_changesets" == "false" ]]; then + version=$(jq -r .version package.json) + if [[ -z "$(git ls-remote --tags origin "refs/tags/v${version}")" ]]; then + needs_publish=true + fi + fi + + echo "has_changesets=$has_changesets" >> "$GITHUB_OUTPUT" + echo "needs_publish=$needs_publish" >> "$GITHUB_OUTPUT" + + version: + name: Version PR + needs: mode + if: needs.mode.outputs.has_changesets == 'true' runs-on: ubuntu-latest + concurrency: + group: release-version-${{ github.ref }} + cancel-in-progress: false permissions: contents: write pull-requests: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version-file: package.json + + # No build needed: version-packages only runs `changeset version` and oxfmt. + - run: bun install --frozen-lockfile + + # GITHUB_TOKEN can't create PRs under org policy, and PRs it opens don't + # trigger CI; the App token does both. + - name: Generate qa-wolf-ops token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.QA_WOLF_OPS_CLIENT_ID }} + private-key: ${{ secrets.QA_WOLF_OPS_PRIVATE_KEY }} + + - name: Create or update Version Packages PR + uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1.8.0 + with: + version: bun run version-packages + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + + publish: + name: Publish + needs: mode + if: needs.mode.outputs.needs_publish == 'true' + environment: release + runs-on: ubuntu-latest + concurrency: + group: release-publish-${{ github.ref }} + cancel-in-progress: false + permissions: + contents: write id-token: write outputs: published: ${{ steps.changesets.outputs.published }} @@ -33,8 +107,9 @@ jobs: - run: bun run build - # GITHUB_TOKEN can't create PRs under org policy, and PRs it opens don't - # trigger CI; the App token does both. + # The App token creates the GitHub Release, so the release:published + # trigger in release-binaries fires (default-token releases don't + # trigger workflows). - name: Generate qa-wolf-ops token id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 @@ -42,18 +117,16 @@ jobs: client-id: ${{ secrets.QA_WOLF_OPS_CLIENT_ID }} private-key: ${{ secrets.QA_WOLF_OPS_PRIVATE_KEY }} - - name: Create Release PR or Publish + - name: Publish to npm id: changesets uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1.8.0 with: - version: bun run version-packages publish: bunx changeset publish env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: true - # Only set when a publish actually happened; safe on version-mode runs. - name: Resolve release tag id: tag if: steps.changesets.outputs.published == 'true' @@ -65,10 +138,10 @@ jobs: binaries: name: Binaries - needs: release - if: needs.release.outputs.published == 'true' + needs: publish + if: needs.publish.outputs.published == 'true' permissions: contents: write uses: ./.github/workflows/release-binaries.yml with: - tag: ${{ needs.release.outputs.tag }} + tag: ${{ needs.publish.outputs.tag }} diff --git a/docs/releasing.md b/docs/releasing.md index 44c665050..b9c15de8c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -23,9 +23,9 @@ PRs that do not affect the published package (CI config, internal tooling, docs) ## Release flow 1. One or more PRs with changeset files merge to `main`. -2. The [Release workflow](../.github/workflows/release.yml) detects the pending changesets and creates (or updates) a **"Version Packages"** PR. This PR bumps `package.json` version and updates `CHANGELOG.md`. +2. The [Release workflow](../.github/workflows/release.yml) detects the pending changesets and creates (or updates) a **"Version Packages"** PR. This PR bumps `package.json` version and updates `CHANGELOG.md`. It runs automatically, with no approval required. 3. Review the Version Packages PR and merge it when ready to ship. -4. The Release workflow runs again. It detects no pending changesets, builds the package, and runs: +4. Merging the Version Packages PR triggers the workflow's `Publish` job, which pauses for approval of the `release` environment (Actions → the run → "Review deployments"). Once approved, it builds the package and runs: ```bash bunx changeset publish @@ -33,6 +33,8 @@ PRs that do not affect the published package (CI config, internal tooling, docs) `changeset publish` publishes to npm and creates a git tag for the new version. The changesets action pushes that tag and creates a GitHub Release from it, which triggers the binary build pipeline. npm [provenance](https://docs.npmjs.com/generating-provenance-statements) is enabled via the `NPM_CONFIG_PROVENANCE: true` env var on the publish step. +Merges to `main` with no pending changesets and an already-tagged version (docs/CI-only merges) skip both the version and publish jobs, so they never leave a run stuck awaiting approval. + ## After publish Verify the release: @@ -44,4 +46,4 @@ qawolf --version # should match package.json version ## Re-running a failed publish -If the publish step fails (e.g. network issue after the version PR was merged), re-run the Release workflow from the GitHub Actions UI (Actions → Release → Re-run jobs). If the version was already partially published, npm will return an error indicating the version exists — in that case, cut a patch release with a new changeset rather than attempting to re-publish the same version. +If the publish step fails (e.g. network issue after the version PR was merged), re-run the Release workflow from the GitHub Actions UI (Actions → Release → Re-run jobs). This requests approval of the `release` environment again. "Re-run failed jobs" preserves the `mode` job's outputs, so the `Publish` job re-runs without re-evaluating whether a publish is needed. If the version was already partially published, npm will return an error indicating the version exists — in that case, cut a patch release with a new changeset rather than attempting to re-publish the same version. From 0ee782e16c45101b7df2abb403fc931d39c81250 Mon Sep 17 00:00:00 2001 From: Michael Price <1845029+michael-pr@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:15:09 -0400 Subject: [PATCH 2/2] build(cli): harden release workflow per review --- .github/workflows/release.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b614b5d9..2cf962b2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,12 +9,14 @@ jobs: name: Detect release mode runs-on: ubuntu-latest permissions: - contents: read + contents: read # read-only detection (changeset files + remote tag lookup) outputs: has_changesets: ${{ steps.detect.outputs.has_changesets }} needs_publish: ${{ steps.detect.outputs.needs_publish }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false - name: Detect pending changesets and unpublished version id: detect @@ -49,12 +51,13 @@ jobs: group: release-version-${{ github.ref }} cancel-in-progress: false permissions: - contents: write - pull-requests: write + contents: write # push the changeset-release branch + pull-requests: write # open/update the Version Packages PR steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 + persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: @@ -71,6 +74,8 @@ jobs: with: client-id: ${{ secrets.QA_WOLF_OPS_CLIENT_ID }} private-key: ${{ secrets.QA_WOLF_OPS_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write - name: Create or update Version Packages PR uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1.8.0 @@ -89,8 +94,8 @@ jobs: group: release-publish-${{ github.ref }} cancel-in-progress: false permissions: - contents: write - id-token: write + contents: write # push the release tag and create the GitHub Release + id-token: write # OIDC for npm provenance outputs: published: ${{ steps.changesets.outputs.published }} tag: ${{ steps.tag.outputs.tag }} @@ -116,6 +121,7 @@ jobs: with: client-id: ${{ secrets.QA_WOLF_OPS_CLIENT_ID }} private-key: ${{ secrets.QA_WOLF_OPS_PRIVATE_KEY }} + permission-contents: write - name: Publish to npm id: changesets @@ -141,7 +147,7 @@ jobs: needs: publish if: needs.publish.outputs.published == 'true' permissions: - contents: write + contents: write # upload binaries to the GitHub Release (reusable workflow) uses: ./.github/workflows/release-binaries.yml with: tag: ${{ needs.publish.outputs.tag }}