diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 26e72b1..46cceaf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,90 +1,86 @@ name: Publish on: - pull_request_target: - types: [closed] - -concurrency: - group: release-main - cancel-in-progress: false + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: Tag to publish (for example v0.7.1) + required: true + type: string jobs: publish: - if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' runs-on: ubuntu-latest permissions: contents: write id-token: write steps: - - name: Determine release bump from labels - id: release_meta - uses: actions/github-script@v7 - with: - script: | - const labels = (context.payload.pull_request.labels || []).map((label) => label.name); - const candidates = ["major", "minor", "patch"]; - const matched = candidates.filter((candidate) => labels.includes(candidate)); - - if (matched.length === 0) { - core.notice("No release label found (major|minor|patch). Skipping publish."); - core.setOutput("should_release", "false"); - return; - } - - if (matched.length > 1) { - core.setFailed(`Multiple release labels found: ${matched.join(", ")}. Keep exactly one of major|minor|patch.`); - return; - } - - core.info(`Selected release bump: ${matched[0]}`); - core.setOutput("should_release", "true"); - core.setOutput("bump", matched[0]); + - name: Resolve tag to publish + id: publish_meta + shell: bash + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ inputs.tag }}" + else + TAG="${GITHUB_REF_NAME}" + fi + case "$TAG" in + v*) ;; + *) + echo "Tag must start with v: $TAG" + exit 1 + ;; + esac + echo "tag=$TAG" >> "$GITHUB_OUTPUT" - uses: actions/checkout@v4 - if: steps.release_meta.outputs.should_release == 'true' with: - ref: main + ref: ${{ steps.publish_meta.outputs.tag }} fetch-depth: 0 - uses: actions/setup-node@v4 - if: steps.release_meta.outputs.should_release == 'true' with: node-version: 20 cache: npm registry-url: https://registry.npmjs.org - run: npm ci - if: steps.release_meta.outputs.should_release == 'true' - run: npm run check - if: steps.release_meta.outputs.should_release == 'true' env: ARENA_VCR_MODE: replay ARENA_API_URL: https://staging-api.are.na - - name: Configure git author - if: steps.release_meta.outputs.should_release == 'true' + - name: Read package version + id: package_meta + shell: bash run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + VERSION="$(node -p "require('./package.json').version")" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Bump version and create tag - id: version - if: steps.release_meta.outputs.should_release == 'true' + - name: Check whether version is already on npm + id: npm_state + shell: bash run: | - NEW_TAG="$(npm version "${{ steps.release_meta.outputs.bump }}" -m "release: %s")" - echo "tag=${NEW_TAG}" >> "$GITHUB_OUTPUT" - - - name: Push release commit and tags - if: steps.release_meta.outputs.should_release == 'true' - run: git push origin HEAD:main --follow-tags + if npm view "@aredotna/cli@${{ steps.package_meta.outputs.version }}" version >/dev/null 2>&1; then + echo "already_published=true" >> "$GITHUB_OUTPUT" + else + echo "already_published=false" >> "$GITHUB_OUTPUT" + fi - run: npm publish --provenance --access public - if: steps.release_meta.outputs.should_release == 'true' + if: steps.npm_state.outputs.already_published != 'true' - - name: Create GitHub Release - if: steps.release_meta.outputs.should_release == 'true' + - name: Ensure GitHub Release exists env: GH_TOKEN: ${{ github.token }} - run: gh release create "${{ steps.version.outputs.tag }}" --generate-notes + shell: bash + run: | + if gh release view "${{ steps.publish_meta.outputs.tag }}" >/dev/null 2>&1; then + echo "GitHub Release already exists for ${{ steps.publish_meta.outputs.tag }}" + else + gh release create "${{ steps.publish_meta.outputs.tag }}" --generate-notes + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..28fb2a8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,111 @@ +name: Release + +on: + pull_request_target: + types: [closed] + workflow_dispatch: + inputs: + bump: + description: Version bump type + required: true + type: choice + options: [patch, minor, major] + +concurrency: + group: release-main + cancel-in-progress: false + +jobs: + release: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + github.event.pull_request.base.ref == 'main') + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Determine release bump from labels or input + id: release_meta + uses: actions/github-script@v7 + with: + script: | + if (context.eventName === "workflow_dispatch") { + const bump = context.payload.inputs.bump; + core.info(`Manual dispatch: releasing with bump=${bump}`); + core.setOutput("should_release", "true"); + core.setOutput("bump", bump); + return; + } + + const labels = (context.payload.pull_request.labels || []).map((label) => label.name); + const candidates = ["major", "minor", "patch"]; + const matched = candidates.filter((candidate) => labels.includes(candidate)); + + if (matched.length === 0) { + core.notice("No release label found (major|minor|patch). Skipping release."); + core.setOutput("should_release", "false"); + return; + } + + if (matched.length > 1) { + core.setFailed(`Multiple release labels found: ${matched.join(", ")}. Keep exactly one of major|minor|patch.`); + return; + } + + core.info(`Selected release bump: ${matched[0]}`); + core.setOutput("should_release", "true"); + core.setOutput("bump", matched[0]); + + - uses: actions/checkout@v4 + if: steps.release_meta.outputs.should_release == 'true' + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN != '' && secrets.RELEASE_TOKEN || github.token }} + + - name: Ensure main has not advanced past merged PR + if: >- + steps.release_meta.outputs.should_release == 'true' && + github.event_name == 'pull_request_target' + run: | + CURRENT_HEAD="$(git rev-parse HEAD)" + EXPECTED_HEAD="${{ github.event.pull_request.merge_commit_sha }}" + if [ "$CURRENT_HEAD" != "$EXPECTED_HEAD" ]; then + echo "main advanced after this PR merged." + echo "Expected: $EXPECTED_HEAD" + echo "Current: $CURRENT_HEAD" + echo "Re-run the Release workflow manually after reviewing current main." + exit 1 + fi + + - uses: actions/setup-node@v4 + if: steps.release_meta.outputs.should_release == 'true' + with: + node-version: 20 + cache: npm + + - run: npm ci + if: steps.release_meta.outputs.should_release == 'true' + + - run: npm run check + if: steps.release_meta.outputs.should_release == 'true' + env: + ARENA_VCR_MODE: replay + ARENA_API_URL: https://staging-api.are.na + + - name: Configure git author + if: steps.release_meta.outputs.should_release == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Bump version and create tag + if: steps.release_meta.outputs.should_release == 'true' + run: | + npm version "${{ steps.release_meta.outputs.bump }}" -m "release: %s" + + - name: Push release commit and tags + if: steps.release_meta.outputs.should_release == 'true' + run: git push origin HEAD:main --follow-tags diff --git a/RELEASE.md b/RELEASE.md index 1f8d36e..9ab6a0b 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -4,7 +4,8 @@ Use this checklist to cut a new npm release for `@aredotna/cli`. Publishing is automated by GitHub Actions when a PR with a release label is merged to `main`. Use exactly one label on the PR: `major`, `minor`, or `patch`. -The workflow bumps the version, publishes to npm with trusted publishing (OIDC), pushes the release commit and tag, and creates a GitHub Release. +The `Release` workflow bumps the version and pushes the release commit and tag. +That tag then triggers the `Publish` workflow, which publishes to npm with trusted publishing (OIDC) and creates the GitHub Release. ## 1) Preflight @@ -26,11 +27,17 @@ npm run check - [ ] Merge the labeled PR. -This triggers `.github/workflows/publish.yml`. +This triggers `.github/workflows/release.yml`. ## 3) Confirm publish workflow success -- [ ] Wait for the "Publish" workflow on the pushed tag to complete successfully: +- [ ] Wait for the `Release` workflow to complete successfully: + +```bash +gh run list --workflow release.yml --limit 5 +``` + +- [ ] Wait for the `Publish` workflow on the created tag to complete successfully: ```bash gh run list --workflow publish.yml --limit 5 @@ -71,3 +78,5 @@ arena whoami --json - Publish job fails with OIDC/trusted publishing error: verify trusted publisher settings on npm exactly match `aredotna/cli` and workflow file `publish.yml`. - Workflow skips publishing: merged PR did not contain one of `major`, `minor`, or `patch` labels. - Workflow fails with multiple release labels: keep exactly one of `major|minor|patch` on the PR. +- Release job says `main` advanced after the PR merged: re-run the `Release` workflow manually after reviewing current `main`. +- Publish job fails after the tag already exists: re-run the `Publish` workflow with the existing tag instead of creating a new version bump.