From f9201aff8c0aad3fa147924ac595824934c7c441 Mon Sep 17 00:00:00 2001 From: David Elner Date: Thu, 23 Jul 2026 17:32:33 +0000 Subject: [PATCH] Changed: Support multi-package releases --- .github/workflows/release-js-custom.yml | 86 ++---- .github/workflows/release-js.yml | 66 ++--- .github/workflows/release-py-custom.yml | 60 ++-- .github/workflows/release-py.yml | 65 ++--- .github/workflows/release-ruby-custom.yml | 61 ++-- .github/workflows/release-ruby.yml | 65 ++--- README.md | 33 ++- .../release/build-package-manifest/action.yml | 168 +++++++++++ .../release/lang/js/build-and-ship/action.yml | 44 ++- actions/release/lang/js/configure/action.yml | 204 ++++++++++++- actions/release/lang/js/pack-pnpm/action.yml | 29 +- .../release/lang/js/ship-package/action.yml | 39 ++- actions/release/lang/js/validate/action.yml | 93 ++---- .../release/lang/py/build-and-ship/action.yml | 39 ++- actions/release/lang/py/configure/action.yml | 196 ++++++++++++- .../release/lang/py/ship-package/action.yml | 43 ++- actions/release/lang/py/validate/action.yml | 88 ++---- .../lang/ruby/build-and-ship/action.yml | 41 ++- .../release/lang/ruby/configure/action.yml | 194 ++++++++++++- .../release/lang/ruby/ship-package/action.yml | 45 ++- actions/release/lang/ruby/validate/action.yml | 92 ++---- actions/release/prepare/action.yml | 69 ----- actions/release/request-approval/action.yml | 269 ++++++++++------- .../release/build-package-manifest.yml.erb | 112 +++++++ .../release/lang/js/build-and-ship.yml.erb | 9 +- .../actions/release/lang/js/configure.yml.erb | 88 +++++- .../actions/release/lang/js/pack-pnpm.yml.erb | 22 ++ .../release/lang/js/ship-package.yml.erb | 13 +- .../actions/release/lang/js/validate.yml.erb | 31 +- .../release/lang/py/build-and-ship.yml.erb | 9 +- .../actions/release/lang/py/configure.yml.erb | 80 ++++- .../release/lang/py/ship-package.yml.erb | 14 +- .../actions/release/lang/py/validate.yml.erb | 31 +- .../release/lang/ruby/build-and-ship.yml.erb | 6 +- .../release/lang/ruby/configure.yml.erb | 78 ++++- .../release/lang/ruby/ship-package.yml.erb | 11 +- .../release/lang/ruby/validate.yml.erb | 36 +-- templates/actions/release/prepare.yml | 67 ----- .../actions/release/request-approval.yml.erb | 273 +++++++++++------- .../steps/lang/js/npm/availability.yml.erb | 42 ++- .../steps/lang/js/npm/publish-package.yml.erb | 4 + templates/steps/lang/js/pnpm/pack.yml.erb | 10 +- templates/steps/lang/js/pnpm/sbom.yml.erb | 10 +- .../steps/lang/py/pypi/availability.yml.erb | 33 ++- templates/steps/lang/py/pypi/publish.yml.erb | 3 + .../lang/ruby/rubygems/availability.yml.erb | 36 ++- .../steps/lang/ruby/rubygems/push.yml.erb | 6 + .../steps/release/build-manifest.yml.erb | 79 +++++ templates/steps/release/checks.yml.erb | 13 + .../release/create-github-release.yml.erb | 16 +- .../release/lang/js/notify-published.yml.erb | 4 +- .../release/lang/py/notify-published.yml.erb | 4 +- .../lang/ruby/notify-published.yml.erb | 4 +- .../steps/release/notify-published.yml.erb | 16 + templates/steps/release/prepare-notes.yml.erb | 48 +++ templates/workflows/release/js/custom.yml.erb | 59 ++-- .../workflows/release/js/turnkey.yml.erb | 53 ++-- templates/workflows/release/py/custom.yml.erb | 53 ++-- .../workflows/release/py/turnkey.yml.erb | 51 ++-- .../workflows/release/ruby/custom.yml.erb | 54 ++-- .../workflows/release/ruby/turnkey.yml.erb | 51 ++-- .../js/.github/workflows/release-custom.yml | 59 ++-- test/release/js/.github/workflows/release.yml | 53 ++-- .../py/.github/workflows/release-custom.yml | 53 ++-- test/release/py/.github/workflows/release.yml | 51 ++-- .../ruby/.github/workflows/release-custom.yml | 54 ++-- .../ruby/.github/workflows/release.yml | 51 ++-- 67 files changed, 2464 insertions(+), 1475 deletions(-) create mode 100644 actions/release/build-package-manifest/action.yml delete mode 100644 actions/release/prepare/action.yml create mode 100644 templates/actions/release/build-package-manifest.yml.erb delete mode 100644 templates/actions/release/prepare.yml create mode 100644 templates/steps/release/build-manifest.yml.erb create mode 100644 templates/steps/release/prepare-notes.yml.erb diff --git a/.github/workflows/release-js-custom.yml b/.github/workflows/release-js-custom.yml index a65a545..fde9b32 100644 --- a/.github/workflows/release-js-custom.yml +++ b/.github/workflows/release-js-custom.yml @@ -2,20 +2,17 @@ # Reference implementation of the v2 JavaScript CUSTOM-BUILD ("build-ownership") shape, # using @braintrust/bt-publishing-test (pnpm). This does NOT release a real SDK. # -# The custom shape is the same pipeline as turnkey, cut at the build⟷publish seam into -# two jobs: -# configure → prepare → validate → pack (unprivileged build) → request-approval → [gate] → ship-package +# The custom shape is the same pipeline as turnkey, cut at the build⟷publish seam: +# configure (facts + manifest) → validate (gate) → pack (unprivileged build) → +# request-approval → [gate] → ship-package # -# • pack builds + packs + attests the artifact in a job with NO publish -# credentials, then uploads it. -# • ship-package (gated) downloads it, verifies its attestations against its own -# bytes, and publishes THAT exact artifact. -# Use this shape when the consumer owns a non-standard build; turnkey (release-js.yml) -# otherwise. +# • configure fact-finds (version/tag/notes/registry) and emits the package manifest. +# • pack builds + packs + attests in a job with NO publish credentials, uploads it. +# • ship-package (gated) downloads it, verifies attestations, publishes THAT exact artifact. +# Use this shape when the consumer owns a non-standard build; turnkey (release-js.yml) otherwise. # # ───────────────────────────────────────────────────────────────────────────── -# SETUP (same as release-js.yml, with one addition): -# npm Trusted Publishing must list THIS workflow filename (release-js-custom.yml) +# SETUP: npm Trusted Publishing must list THIS workflow filename (release-js-custom.yml) # + the `publish` environment. Only the ship-package job publishes. # ───────────────────────────────────────────────────────────────────────────── @@ -69,22 +66,23 @@ jobs: IFS='.' read -r major minor patch <<< "$CURRENT" echo "version=$major.$minor.$((patch + 1))" >> $GITHUB_OUTPUT - # DERIVE facts (read-only). + # FACT-FIND (version/tag/notes/registry) + emit the package manifest. contents: write for notes. configure: needs: bump runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} prev_release: ${{ steps.configure.outputs.prev_release }} branch: ${{ steps.configure.outputs.branch }} on_release_branch: ${{ steps.configure.outputs.on_release_branch }} - commit_message: ${{ steps.configure.outputs.commit_message }} channel: ${{ steps.configure.outputs.channel }} - github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Configure release @@ -95,30 +93,15 @@ jobs: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} working_directory: test/release/js release_type: ${{ inputs.release_type || 'stable' }} - channel: ${{ inputs.channel }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Prepare release - id: prepare - uses: ./actions/release/prepare - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha || needs.configure.outputs.prev_release }} + npm_channel: ${{ inputs.channel }} + npm_package_name: '@braintrust/bt-publishing-test' + package_label: '@braintrust/bt-publishing-test' + emoji: ':javascript:' + prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha }} # JUDGE (checks only — pack does the build, so build/sbom are off here). validate: - needs: [configure, prepare] + needs: [bump, configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -128,23 +111,22 @@ jobs: - name: Validate release uses: ./actions/release/lang/js/validate with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha || github.event.pull_request.head.sha }} dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} working_directory: test/release/js node_version: test/release/js/.tool-versions - package_name: '@braintrust/bt-publishing-test' release_tag: ${{ needs.configure.outputs.release_tag }} channel: ${{ needs.configure.outputs.channel }} allowed_channels: 'latest,rc,next,beta' on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} build: 'false' # the pack job owns the build in the custom shape sbom: 'false' # pack generates + attests the SBOM # BUILD side — unprivileged: NO publish credentials. Builds + packs + attests, then uploads. pack: - needs: [configure, prepare, validate] + needs: [bump, configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -177,9 +159,11 @@ jobs: # Stage the exact packed tarball + its SBOM for the gated ship job. - name: Stage artifact + env: + TARBALL: ${{ steps.pack.outputs.tarball }} run: | mkdir -p artifact - cp "${{ steps.pack.outputs.tarball }}" artifact/ + cp "$TARBALL" artifact/ cp test/release/js/sbom.json artifact/ 2>/dev/null || true - name: Upload artifact @@ -190,9 +174,9 @@ jobs: retention-days: 1 if-no-files-found: error - # NOTIFY — approval request (green when issued; the real gate is on ship-package). + # NOTIFY — approval request (gated behind validate; the real publish gate is on ship-package). request-approval: - needs: [configure, prepare, pack] + needs: [configure, validate, pack] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -201,23 +185,18 @@ jobs: - name: Request release approval uses: ./actions/release/request-approval with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - label: '@braintrust/bt-publishing-test' - emoji: ':javascript:' # PUBLISH side — gated. Downloads the prebuilt artifact, verifies, publishes THAT file. ship-package: - needs: [configure, prepare, pack, request-approval] + needs: [configure, validate, pack, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: >- @@ -229,9 +208,6 @@ jobs: id-token: write # OIDC trusted publishing + provenance attestations: read # gh attestation verify steps: - # Reference workflows exercise the in-repo actions via local `./actions/...` - # paths, so the repo must be checked out for the action files to resolve. (A - # real consumer pins a remote `...@` ref and needs no checkout here.) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -251,6 +227,7 @@ jobs: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} # ⚠️ TEST-BED EXCEPTION — do NOT copy. Real consumers wire the derived value: # github_release: ${{ needs.configure.outputs.github_release }} github_release: 'false' @@ -260,11 +237,10 @@ jobs: access: 'public' provenance: 'true' channel: ${{ needs.configure.outputs.channel }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} emoji: ':javascript:' diff --git a/.github/workflows/release-js.yml b/.github/workflows/release-js.yml index 86fd5bf..6b1f2b0 100644 --- a/.github/workflows/release-js.yml +++ b/.github/workflows/release-js.yml @@ -5,8 +5,8 @@ # # This workflow serves two purposes: # 1. End-to-end testing of the composite actions in actions/release/ -# 2. Reference implementation of the v2 JavaScript TURNKEY release shape -# (build-and-ship): configure → prepare → validate → request-approval → [gate] → build-and-ship +# 2. Reference implementation of the v2 JavaScript TURNKEY release shape (build-and-ship): +# configure (facts + manifest) → validate (gate) → request-approval → [gate] → build-and-ship # # Generic steps are provided by composite actions in actions/release/. # Only the sections marked with inline comments need to change for other packages. @@ -98,24 +98,22 @@ jobs: version: ${{ steps.bump.outputs.version }} steps: # bt-publishing-test: derives next version by querying npm and incrementing the patch. - # Falls back to 0.0.1 on first publish (package not found). Remove this job - # in real SDK workflows; version comes from the version bump PR. + # Remove this job in real SDK workflows; version comes from the version bump PR. - name: Bump version id: bump run: | - # Package may not exist yet (first release / new name); default to 0.0.0 - # so the first publish is 0.0.1. CURRENT=$(npm view @braintrust/bt-publishing-test version 2>/dev/null || echo "0.0.0") CURRENT=${CURRENT:-0.0.0} IFS='.' read -r major minor patch <<< "$CURRENT" echo "version=$major.$minor.$((patch + 1))" >> $GITHUB_OUTPUT + # FACT-FIND (version/tag/notes/registry) + emit the package manifest. contents: write for notes. configure: needs: bump runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} @@ -125,6 +123,9 @@ jobs: commit_message: ${{ steps.configure.outputs.commit_message }} channel: ${{ steps.configure.outputs.channel }} github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: # bt-publishing-test: checkout required to resolve local ./actions/... references. # Real SDK workflows use external braintrustdata/sdk-actions/...@sha references. @@ -140,33 +141,14 @@ jobs: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} working_directory: test/release/js release_type: ${{ inputs.release_type || 'stable' }} - channel: ${{ inputs.channel }} # explicit override; empty → derived from release_type - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # required for releases/generate-notes API - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Prepare release - id: prepare - uses: ./actions/release/prepare - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - # On PR auto dry-runs, anchor notes to the head SHA: prepare treats a - # full SHA as "notes unavailable" (no tag range), so the changelog - # doesn't accumulate in the step summary on every PR. - prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha || needs.configure.outputs.prev_release }} + npm_channel: ${{ inputs.channel }} # explicit override; empty → derived from release_type + npm_package_name: '@braintrust/bt-publishing-test' + package_label: '@braintrust/bt-publishing-test' + emoji: ':javascript:' + prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha }} validate: - needs: [configure, prepare] + needs: [bump, configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -177,17 +159,16 @@ jobs: - name: Validate release uses: ./actions/release/lang/js/validate with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha || github.event.pull_request.head.sha }} dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} working_directory: test/release/js node_version: test/release/js/.tool-versions # exercises version-file sourcing - package_name: '@braintrust/bt-publishing-test' # exercises the npm-availability check release_tag: ${{ needs.configure.outputs.release_tag }} channel: ${{ needs.configure.outputs.channel }} allowed_channels: 'latest,rc,next,beta' on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} # Guard the SBOM generator against silent content regressions (subject/purl correctness, # scope in group+purl, prod-only closure). Reads the sbom.json the dry-run just @@ -196,7 +177,7 @@ jobs: run: ruby scripts/sbom_verify.rb test/release/js/sbom.json --subject bt-publishing-test --purl-prefix "pkg:npm/%40braintrust/bt-publishing-test@" request-approval: - needs: [configure, prepare, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -206,23 +187,18 @@ jobs: - name: Request release approval uses: ./actions/release/request-approval with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} # Suppress Slack on PR auto dry-runs (still exercises message-building; just no real post). slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - label: '@braintrust/bt-publishing-test' - emoji: ':javascript:' build-and-ship: - needs: [configure, prepare, validate, request-approval] + needs: [configure, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 # No environment on PR dry-runs (avoids the approval gate AND the accumulating @@ -259,6 +235,7 @@ jobs: node_version: test/release/js/.tool-versions dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} # ⚠️ TEST-BED EXCEPTION — do NOT copy this into a real workflow. Creating a GitHub release # is the intended default for consumers, who should wire the derived value: # github_release: ${{ needs.configure.outputs.github_release }} @@ -271,11 +248,10 @@ jobs: access: 'public' provenance: 'true' channel: ${{ needs.configure.outputs.channel }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} emoji: ':javascript:' diff --git a/.github/workflows/release-py-custom.yml b/.github/workflows/release-py-custom.yml index aeb0bdf..1c82222 100644 --- a/.github/workflows/release-py-custom.yml +++ b/.github/workflows/release-py-custom.yml @@ -3,7 +3,8 @@ # using bt-publishing-test. This does NOT release a real SDK. # # Same pipeline as turnkey, cut at the build⟷publish seam: -# configure → prepare → validate → pack (unprivileged build) → request-approval → [gate] → ship-package +# configure (facts + manifest) → validate (gate) → pack (unprivileged build) → +# request-approval → [gate] → ship-package # pack builds + attests dist/* (whl + sdist) with NO publish credentials and uploads them; # ship-package (gated) verifies their attestations and publishes THOSE exact dists. # @@ -59,15 +60,16 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} prev_release: ${{ steps.configure.outputs.prev_release }} branch: ${{ steps.configure.outputs.branch }} on_release_branch: ${{ steps.configure.outputs.on_release_branch }} - commit_message: ${{ steps.configure.outputs.commit_message }} - github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Configure release @@ -78,28 +80,13 @@ jobs: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} working_directory: test/release/py release_type: ${{ inputs.release_type || 'stable' }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Prepare release - id: prepare - uses: ./actions/release/prepare - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha || needs.configure.outputs.prev_release }} + pypi_package_name: bt-publishing-test + package_label: 'bt-publishing-test' + emoji: ':python:' + prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha }} validate: - needs: [configure, prepare] + needs: [bump, configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -109,21 +96,20 @@ jobs: - name: Validate release uses: ./actions/release/lang/py/validate with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha || github.event.pull_request.head.sha }} dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} working_directory: test/release/py python_version: test/release/py/.python-version - package_name: bt-publishing-test release_tag: ${{ needs.configure.outputs.release_tag }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} build: 'false' # the pack job owns the build sbom: 'false' # pack generates + attests the SBOM # BUILD side — unprivileged: NO publish credentials. pack: - needs: [configure, prepare, validate] + needs: [bump, configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -168,7 +154,7 @@ jobs: if-no-files-found: error request-approval: - needs: [configure, prepare, pack] + needs: [configure, validate, pack] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -177,23 +163,18 @@ jobs: - name: Request release approval uses: ./actions/release/request-approval with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - label: 'bt-publishing-test' - emoji: ':python:' # PUBLISH side — gated. ship-package: - needs: [configure, prepare, pack, request-approval] + needs: [configure, validate, pack, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: >- @@ -205,9 +186,6 @@ jobs: id-token: write # OIDC trusted publishing + PEP 740 attestations: read # gh attestation verify steps: - # Reference workflows exercise the in-repo actions via local `./actions/...` - # paths, so the repo must be checked out for the action files to resolve. (A - # real consumer pins a remote `...@` ref and needs no checkout here.) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -222,17 +200,17 @@ jobs: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} # ⚠️ TEST-BED EXCEPTION — do NOT copy. Real consumers wire the derived value: # github_release: ${{ needs.configure.outputs.github_release }} github_release: 'false' version: ${{ needs.configure.outputs.version }} package_name: bt-publishing-test label: 'bt-publishing-test' - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} emoji: ':python:' diff --git a/.github/workflows/release-py.yml b/.github/workflows/release-py.yml index 5d3cda9..1cc9026 100644 --- a/.github/workflows/release-py.yml +++ b/.github/workflows/release-py.yml @@ -5,8 +5,8 @@ # # This workflow serves two purposes: # 1. End-to-end testing of the composite actions in actions/release/ -# 2. Reference implementation of the v2 Python TURNKEY release shape -# (build-and-ship): configure → prepare → validate → request-approval → [gate] → build-and-ship +# 2. Reference implementation of the v2 Python TURNKEY release shape (build-and-ship): +# configure (facts + manifest) → validate (gate) → request-approval → [gate] → build-and-ship # # Generic steps are provided by composite actions in actions/release/. # Only the sections marked with inline comments need to change for other packages. @@ -90,25 +90,22 @@ jobs: version: ${{ steps.bump.outputs.version }} steps: # bt-publishing-test: derives next version by querying PyPI and incrementing the patch. - # Falls back to 0.0.1 on first publish (project not found). Remove this job - # in real SDK workflows; version comes from the version bump PR. + # Remove this job in real SDK workflows; version comes from the version bump PR. - name: Bump version id: bump run: | - # Project may not exist yet (first release / new name); default to 0.0.0 - # so the first publish is 0.0.1. CURRENT=$(curl -sf https://pypi.org/pypi/bt-publishing-test/json 2>/dev/null | jq -r '.info.version // empty' || true) CURRENT=${CURRENT:-0.0.0} IFS='.' read -r major minor patch <<< "$CURRENT" echo "version=$major.$minor.$((patch + 1))" >> $GITHUB_OUTPUT - # DERIVE — read-only facts (version, tag, branch, …). No token; sets up a default Python to read the version. + # FACT-FIND (version/tag/notes/registry) + emit the package manifest. contents: write for notes. configure: needs: bump runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} @@ -117,6 +114,9 @@ jobs: on_release_branch: ${{ steps.configure.outputs.on_release_branch }} commit_message: ${{ steps.configure.outputs.commit_message }} github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -130,33 +130,14 @@ jobs: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} working_directory: test/release/py release_type: ${{ inputs.release_type || 'stable' }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # required for releases/generate-notes API - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Prepare release - id: prepare - uses: ./actions/release/prepare - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - # On PR auto dry-runs, anchor notes to the head SHA: prepare treats a - # full SHA as "notes unavailable" (no tag range), so the changelog - # doesn't accumulate in the step summary on every PR. - prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha || needs.configure.outputs.prev_release }} + pypi_package_name: bt-publishing-test + package_label: 'bt-publishing-test' + emoji: ':python:' + prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha }} # JUDGE — toggleable checks + a pre-gate build & SBOM, using the facts configure derived. validate: - needs: [configure, prepare] + needs: [bump, configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -167,15 +148,14 @@ jobs: - name: Validate release uses: ./actions/release/lang/py/validate with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha || github.event.pull_request.head.sha }} dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} working_directory: test/release/py python_version: test/release/py/.python-version # exercises version-file sourcing - package_name: bt-publishing-test # exercises the PyPI-availability check release_tag: ${{ needs.configure.outputs.release_tag }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} # Guard the SBOM generator against silent content regressions (subject/purl correctness, # dev tooling leaking in). Reads the sbom.json the dry-run just generated; @@ -185,7 +165,7 @@ jobs: # NOTIFY — issues the approval request (green the moment it posts; the real gate is on build-and-ship). request-approval: - needs: [configure, prepare, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -195,24 +175,19 @@ jobs: - name: Request release approval uses: ./actions/release/request-approval with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} # Suppress Slack on PR auto dry-runs (still exercises message-building; just no real post). slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - label: 'bt-publishing-test' - emoji: ':python:' # EXECUTE — build → attest → publish → release → announce, in one gated job. build-and-ship: - needs: [configure, prepare, validate, request-approval] + needs: [configure, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 # No environment on PR dry-runs (avoids the approval gate AND the accumulating @@ -249,6 +224,7 @@ jobs: python_version: test/release/py/.python-version dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} # ⚠️ TEST-BED EXCEPTION — do NOT copy this into a real workflow. Creating a GitHub release # is the intended default for consumers, who should wire the derived value: # github_release: ${{ needs.configure.outputs.github_release }} @@ -258,11 +234,10 @@ jobs: version: ${{ needs.configure.outputs.version }} package_name: bt-publishing-test label: 'bt-publishing-test' - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} emoji: ':python:' diff --git a/.github/workflows/release-ruby-custom.yml b/.github/workflows/release-ruby-custom.yml index d81db25..0c6b873 100644 --- a/.github/workflows/release-ruby-custom.yml +++ b/.github/workflows/release-ruby-custom.yml @@ -3,7 +3,8 @@ # bt-publishing-test. This does NOT release a real gem. # # Same pipeline as turnkey, cut at the build⟷publish seam: -# configure → prepare → validate → pack (unprivileged build) → request-approval → [gate] → ship-package +# configure (facts + manifest) → validate (gate) → pack (unprivileged build) → +# request-approval → [gate] → ship-package # pack builds + attests the gem with NO publish credentials and uploads it; ship-package # (gated) verifies its attestations and `gem push`es THAT exact gem. # @@ -59,15 +60,16 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} prev_release: ${{ steps.configure.outputs.prev_release }} branch: ${{ steps.configure.outputs.branch }} on_release_branch: ${{ steps.configure.outputs.on_release_branch }} - commit_message: ${{ steps.configure.outputs.commit_message }} - github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Configure release @@ -78,28 +80,13 @@ jobs: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} working_directory: test/release/ruby release_type: ${{ inputs.release_type || 'stable' }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Prepare release - id: prepare - uses: ./actions/release/prepare - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha || needs.configure.outputs.prev_release }} + rubygems_package_name: bt-publishing-test + package_label: 'bt-publishing-test' + emoji: ':ruby:' + prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha }} validate: - needs: [configure, prepare] + needs: [bump, configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -109,20 +96,19 @@ jobs: - name: Validate release uses: ./actions/release/lang/ruby/validate with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha || github.event.pull_request.head.sha }} dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} working_directory: test/release/ruby - package_name: bt-publishing-test release_tag: ${{ needs.configure.outputs.release_tag }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} build: 'false' # the pack job owns the build (lint still runs) sbom: 'false' # pack generates + attests the SBOM # BUILD side — unprivileged: NO publish credentials. pack: - needs: [configure, prepare, validate] + needs: [bump, configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -169,7 +155,7 @@ jobs: if-no-files-found: error request-approval: - needs: [configure, prepare, pack] + needs: [configure, validate, pack] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -178,23 +164,18 @@ jobs: - name: Request release approval uses: ./actions/release/request-approval with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - label: bt-publishing-test - emoji: ':ruby:' # PUBLISH side — gated. ship-package: - needs: [configure, prepare, pack, request-approval] + needs: [configure, validate, pack, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: >- @@ -206,9 +187,6 @@ jobs: id-token: write # OIDC trusted publishing attestations: read # gh attestation verify steps: - # Reference workflows exercise the in-repo actions via local `./actions/...` - # paths, so the repo must be checked out for the action files to resolve. (A - # real consumer pins a remote `...@` ref and needs no checkout here.) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -223,16 +201,17 @@ jobs: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} # ⚠️ TEST-BED EXCEPTION — do NOT copy. Real consumers wire the derived value: # github_release: ${{ needs.configure.outputs.github_release }} github_release: 'false' gem_name: bt-publishing-test + version: ${{ needs.configure.outputs.version }} label: bt-publishing-test - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} emoji: ':ruby:' diff --git a/.github/workflows/release-ruby.yml b/.github/workflows/release-ruby.yml index c02c194..4a46238 100644 --- a/.github/workflows/release-ruby.yml +++ b/.github/workflows/release-ruby.yml @@ -4,8 +4,8 @@ # # This workflow serves two purposes: # 1. End-to-end testing of the composite actions in actions/release/ -# 2. Reference implementation of the v2 Ruby TURNKEY release shape -# (build-and-ship): configure → prepare → validate → request-approval → [gate] → build-and-ship +# 2. Reference implementation of the v2 Ruby TURNKEY release shape (build-and-ship): +# configure (facts + manifest) → validate (gate) → request-approval → [gate] → build-and-ship # # Generic steps are provided by composite actions in actions/release/. # Only the sections marked with inline comments need to change for other Ruby gems. @@ -86,25 +86,22 @@ jobs: version: ${{ steps.bump.outputs.version }} steps: # bt-publishing-test: derives next version by querying RubyGems.org and incrementing the - # patch. Falls back to 0.0.1 on first publish (404). Remove this job in real - # SDK workflows; version comes from the version bump PR. + # patch. Remove this job in real SDK workflows; version comes from the version bump PR. - name: Bump version id: bump run: | - # Gem may not exist yet (first release / new name) → curl 404s and the - # pipeline yields no version; default to 0.0.0 so the first publish is 0.0.1. CURRENT=$(curl -sf https://rubygems.org/api/v1/gems/bt-publishing-test.json 2>/dev/null | jq -r '.version // empty' 2>/dev/null || true) CURRENT=${CURRENT:-0.0.0} IFS='.' read -r major minor patch <<< "$CURRENT" echo "version=$major.$minor.$((patch + 1))" >> $GITHUB_OUTPUT - # DERIVE — read-only facts (version, tag, branch, …). No token; sets up a default Ruby to read the version. + # FACT-FIND (version/tag/notes/registry) + emit the package manifest. contents: write for notes. configure: needs: bump runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} @@ -113,6 +110,9 @@ jobs: on_release_branch: ${{ steps.configure.outputs.on_release_branch }} commit_message: ${{ steps.configure.outputs.commit_message }} github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -128,33 +128,14 @@ jobs: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} working_directory: test/release/ruby release_type: ${{ inputs.release_type || 'stable' }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # required for releases/generate-notes API - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Prepare release - id: prepare - uses: ./actions/release/prepare - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - # On PR auto dry-runs, anchor notes to the head SHA: prepare treats a - # full SHA as "notes unavailable" (no tag range), so the changelog - # doesn't accumulate in the step summary on every PR. - prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha || needs.configure.outputs.prev_release }} + rubygems_package_name: bt-publishing-test + package_label: 'bt-publishing-test' + emoji: ':ruby:' + prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha }} # JUDGE — toggleable checks + lint + a pre-gate build & SBOM, using the facts configure derived. validate: - needs: [configure, prepare] + needs: [bump, configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -165,14 +146,13 @@ jobs: - name: Validate release uses: ./actions/release/lang/ruby/validate with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha || github.event.pull_request.head.sha }} dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} working_directory: test/release/ruby - package_name: bt-publishing-test # exercises the RubyGems-availability check release_tag: ${{ needs.configure.outputs.release_tag }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} # Guard the SBOM generator against silent content regressions (dev tooling leaking in, # runtime deps dropping). Reads the sbom.json the dry-run just generated; the expected @@ -182,7 +162,7 @@ jobs: # NOTIFY — issues the approval request (green the moment it posts; the real gate is on build-and-ship). request-approval: - needs: [configure, prepare, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -192,24 +172,19 @@ jobs: - name: Request release approval uses: ./actions/release/request-approval with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} # Suppress Slack on PR auto dry-runs (still exercises message-building; just no real post). slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - label: bt-publishing-test - emoji: ':ruby:' # EXECUTE — build → attest → publish → release → announce, in one gated job. build-and-ship: - needs: [configure, prepare, validate, request-approval] + needs: [configure, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 # No environment on PR dry-runs (avoids the approval gate AND the accumulating @@ -248,17 +223,17 @@ jobs: working_directory: test/release/ruby dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} # ⚠️ TEST-BED EXCEPTION — do NOT copy this into a real workflow. Creating a GitHub release # is the intended default for consumers, who should wire the derived value: # github_release: ${{ needs.configure.outputs.github_release }} # This workflow auto-runs on every PR and is ephemeral, so it hard-disables releases to # avoid tag/release pollution on the throwaway bt-publishing-test gem. github_release: 'false' - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} gem_name: bt-publishing-test diff --git a/README.md b/README.md index 16bd413..49c4de7 100644 --- a/README.md +++ b/README.md @@ -98,23 +98,30 @@ generation commonly uses `pnpm sbom`, which requires pnpm `>= 11.8`. The workflow is composed from these actions — `bin/workflow generate` wires them for you, but they're listed here for reference. Each is **self-contained** (calls no other action in this repo), so a single SHA pin pulls in everything it needs. -| Action | Purpose | -| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `release/lang//configure` | Derive release facts (tag, channel, rc suffix, `github_release`) from the version + `release_type` — read-only | -| `release/prepare` | Fetch the PR list and release notes | -| `release/lang//validate` | Validate the release (tag / channel / branch / metadata, registry availability) and run a pre-gate build + SBOM generation | -| `release/request-approval` | Post the pre-approval job summary and Slack notification | -| `release/lang//build-and-ship` | **Turnkey**: build → sign a CycloneDX SBOM (+ build provenance for Ruby) → publish (OIDC trusted publishing) → create the GitHub release (SBOM attached) → notify | -| `release/lang/js/pack-pnpm` · `pack-bun`, `release/lang/{py,ruby}/pack` | **Custom build**: build + pack + sign SBOM + build provenance in an unprivileged job (no publish credentials) | -| `release/lang//ship-package` | **Custom build**: verify the attestation against the downloaded artifact → publish that exact artifact → create the GitHub release → notify | -| `release/lang/js/publish-npm-tarballs` | Verify prebuilt npm tarballs by glob and publish the exact bytes to npmjs in manifest order | -| `release/create-package-github-releases` | Create package GitHub releases from existing manifest tags, titles, and bodies, attaching each `sbom_asset` from the manifest directory | -| `release/verify-package` | Verify downloaded artifact attestations without using a language-specific ship action | +The generated release shape is: + +- **Turnkey:** `configure → validate → request-approval → build-and-ship` +- **Custom build:** `configure → validate → pack → request-approval → ship-package` + +`configure` is the fact-finding step: it checks out the release SHA, resolves the package version, computes release metadata, checks the package registry, fetches release notes, and emits a `package` JSON object. `validate` consumes those facts and enforces the release gate. `request-approval` consumes one or more `package` objects through its `packages` input, so single-package and multi-package approvals use the same rendering path (including already-published / skipped packages). + +| Action | Purpose | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `release/lang//configure` | Resolve release facts and package metadata: version, tag, branch, channel / `github_release`, registry availability, notes, and the package JSON object for approval | +| `release/lang//validate` | Judge the configured facts (tag / channel / branch / notes / already-published state) and optionally run a pre-gate build + SBOM generation | +| `release/request-approval` | Post the pre-approval job summary and Slack notification from one or more package objects | +| `release/lang//build-and-ship` | **Turnkey**: build → sign a CycloneDX SBOM (+ build provenance for Ruby) → publish (OIDC trusted publishing) → create the GitHub release (SBOM attached) → notify | +| `release/lang/js/pack-pnpm` · `pack-bun`, `release/lang/{py,ruby}/pack` | **Custom build**: build + pack + sign SBOM + build provenance in an unprivileged job (no publish credentials) | +| `release/lang//ship-package` | **Custom build**: verify the attestation against the downloaded artifact → publish that exact artifact → create the GitHub release → notify | +| `release/build-package-manifest` | Advanced helper for constructing a package object when you are not using a language `configure` action | +| `release/lang/js/publish-npm-tarballs` | Verify prebuilt npm tarballs by glob and publish the exact bytes to npmjs in manifest order | +| `release/create-package-github-releases` | Create package GitHub releases from existing manifest tags, titles, and bodies, attaching each `sbom_asset` from the manifest directory | +| `release/verify-package` | Verify downloaded artifact attestations without using a language-specific ship action | Inputs and outputs are documented in each `action.yml`. Reference an action by commit SHA: ```yaml -- uses: braintrustdata/sdk-actions/actions/release/prepare@ +- uses: braintrustdata/sdk-actions/actions/release/lang/js/configure@ ``` ## Developing diff --git a/actions/release/build-package-manifest/action.yml b/actions/release/build-package-manifest/action.yml new file mode 100644 index 0000000..a7b235d --- /dev/null +++ b/actions/release/build-package-manifest/action.yml @@ -0,0 +1,168 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Build Package Manifest +description: > + Formats one package's release facts into a single, well-formed package manifest entry + (compact JSON) via the shared release/build-manifest step. Multi-package workflows call + this once per package (label/emoji at the call site) and splice the entries into + request-approval's `packages`. Package-level facts (label, emoji, tag, prev_release, + pr_list, notes, github_release) + per-registry facts under a prefix (npm_/pypi_/ + rubygems_) → nested `registries.`; one call may carry several registries. Empties + dropped. `pr_list` (x1f) and `notes` (base64) are JSON-escaped, so entries splice into + a JSON array via GitHub expression interpolation with no further shell/jq. + +inputs: + label: + description: "Display label for notifications (defaults, downstream, to a registry name / tag)" + required: false + default: '' + emoji: + description: "Presentation emoji for notifications" + required: false + default: '' + tag: + description: "Release tag (e.g. js-1.2.3)" + required: false + default: '' + prev_release: + description: "Previous release tag (for the diff link)" + required: false + default: '' + pr_list: + description: "x1f-delimited PR list (from configure)" + required: false + default: '' + notes: + description: "Base64-encoded release notes (from configure)" + required: false + default: '' + github_release: + description: "Whether a GitHub release should be created for this package" + required: false + default: '' + npm_name: + description: "npm: package name (presence adds an npm registry entry)" + required: false + default: '' + npm_version: + description: "npm: version" + required: false + default: '' + npm_channel: + description: "npm: dist-tag / channel" + required: false + default: '' + npm_version_url: + description: "npm: human URL for the version (from validate/configure)" + required: false + default: '' + npm_already_published: + description: "npm: 'true'/'false' — is this version already on npm" + required: false + default: '' + pypi_name: + description: "PyPI: package name (presence adds a pypi registry entry)" + required: false + default: '' + pypi_version: + description: "PyPI: version" + required: false + default: '' + pypi_version_url: + description: "PyPI: human URL for the version" + required: false + default: '' + pypi_already_published: + description: "PyPI: 'true'/'false' — is this version already on PyPI" + required: false + default: '' + rubygems_name: + description: "RubyGems: gem name (presence adds a rubygems registry entry)" + required: false + default: '' + rubygems_version: + description: "RubyGems: version" + required: false + default: '' + rubygems_version_url: + description: "RubyGems: human URL for the version" + required: false + default: '' + rubygems_already_published: + description: "RubyGems: 'true'/'false' — is this version already on RubyGems" + required: false + default: '' + +outputs: + package: + description: "One package manifest entry as compact JSON (only the provided fields)" + value: ${{ steps.build.outputs.package }} + +runs: + using: composite + steps: + - name: Build package manifest entry + id: build + shell: bash + env: + LABEL: "${{ inputs.label }}" + EMOJI: "${{ inputs.emoji }}" + TAG: "${{ inputs.tag }}" + PREV_RELEASE: "${{ inputs.prev_release }}" + PR_LIST: "${{ inputs.pr_list }}" + NOTES: "${{ inputs.notes }}" + GITHUB_RELEASE: "${{ inputs.github_release }}" + NPM_NAME: "${{ inputs.npm_name }}" + NPM_VERSION: "${{ inputs.npm_version }}" + NPM_CHANNEL: "${{ inputs.npm_channel }}" + NPM_VERSION_URL: "${{ inputs.npm_version_url }}" + NPM_ALREADY_PUBLISHED: "${{ inputs.npm_already_published }}" + PYPI_NAME: "${{ inputs.pypi_name }}" + PYPI_VERSION: "${{ inputs.pypi_version }}" + PYPI_VERSION_URL: "${{ inputs.pypi_version_url }}" + PYPI_ALREADY_PUBLISHED: "${{ inputs.pypi_already_published }}" + RUBYGEMS_NAME: "${{ inputs.rubygems_name }}" + RUBYGEMS_VERSION: "${{ inputs.rubygems_version }}" + RUBYGEMS_VERSION_URL: "${{ inputs.rubygems_version_url }}" + RUBYGEMS_ALREADY_PUBLISHED: "${{ inputs.rubygems_already_published }}" + run: | + set -euo pipefail + if ! command -v jq >/dev/null 2>&1; then + echo "::error title=jq not found::build-manifest requires jq." + exit 1 + fi + + REGISTRIES='{}' + # Add one registry entry (keyed by id) when its name/version are given. version_url is + # carried in (from the availability check); empties dropped; already_published → boolean. + add_registry() { + local reg="$1" name="$2" version="$3" channel="$4" ap="$5" url="$6" + { [ -z "$name" ] && [ -z "$version" ]; } && return 0 + local entry + entry="$(jq -nc \ + --arg name "$name" --arg version "$version" --arg url "$url" \ + --arg channel "$channel" --arg ap "$ap" \ + '{name:$name, version:$version, version_url:$url, channel:$channel} + | with_entries(select(.value != "")) + | if $ap=="true" then .already_published=true + elif $ap=="false" then .already_published=false else . end')" + REGISTRIES="$(jq -c --arg r "$reg" --argjson e "$entry" '. + {($r): $e}' <<<"$REGISTRIES")" + } + add_registry npm "$NPM_NAME" "$NPM_VERSION" "$NPM_CHANNEL" "$NPM_ALREADY_PUBLISHED" "$NPM_VERSION_URL" + add_registry pypi "$PYPI_NAME" "$PYPI_VERSION" "" "$PYPI_ALREADY_PUBLISHED" "$PYPI_VERSION_URL" + add_registry rubygems "$RUBYGEMS_NAME" "$RUBYGEMS_VERSION" "" "$RUBYGEMS_ALREADY_PUBLISHED" "$RUBYGEMS_VERSION_URL" + + # Package-level object; drop empties, then attach registries only when non-empty. + PACKAGE="$(jq -nc \ + --arg label "$LABEL" --arg emoji "$EMOJI" --arg tag "$TAG" \ + --arg prev_release "$PREV_RELEASE" --arg pr_list "$PR_LIST" --arg notes "$NOTES" \ + --arg github_release "$GITHUB_RELEASE" \ + '{label:$label, emoji:$emoji, tag:$tag, prev_release:$prev_release, + pr_list:$pr_list, notes:$notes, github_release:$github_release} + | with_entries(select(.value != ""))')" + if [ "$REGISTRIES" != "{}" ]; then + PACKAGE="$(jq -c --argjson r "$REGISTRIES" '. + {registries:$r}' <<<"$PACKAGE")" + fi + + echo "package=$PACKAGE" >> "$GITHUB_OUTPUT" + echo "Built package manifest entry: $(jq -c '{keys:keys, registries:(.registries // {} | keys)}' <<<"$PACKAGE")" diff --git a/actions/release/lang/js/build-and-ship/action.yml b/actions/release/lang/js/build-and-ship/action.yml index af67456..7f33435 100644 --- a/actions/release/lang/js/build-and-ship/action.yml +++ b/actions/release/lang/js/build-and-ship/action.yml @@ -102,13 +102,17 @@ inputs: description: "Whether the SHA is on an expected release branch" required: true pr_list: - description: "x1f-delimited PR list from prepare action" + description: "x1f-delimited PR list from configure" required: false default: '' package_name: description: "npm package name — used to build the npm link in notifications" required: false default: '' + already_published: + description: "Whether the version is already on npm (from configure). When 'true', the publish is skipped." + required: false + default: '' label: description: > Display name for release notifications (e.g. the package name). When set, it @@ -203,11 +207,12 @@ runs: shell: bash working-directory: ${{ inputs.working_directory }} run: | - if ! pnpm sbom --help >/dev/null 2>&1; then + PNPM="${PACK_PNPM:-pnpm}" + if ! $PNPM sbom --help >/dev/null 2>&1; then echo "::error title=pnpm sbom unavailable::'pnpm sbom' needs pnpm >= 11.8. Set the package's packageManager (or pnpm_version) to >= 11.8." exit 1 fi - pnpm sbom --sbom-format cyclonedx --prod --out sbom.json + $PNPM sbom --sbom-format cyclonedx --prod --out sbom.json echo "Wrote $(pwd)/sbom.json" - name: Pack tarball for SBOM attestation @@ -230,6 +235,7 @@ runs: sbom-path: ${{ inputs.working_directory }}/sbom.json - name: Publish to npm + if: ${{ inputs.already_published != 'true' }} shell: bash working-directory: ${{ inputs.working_directory }} env: @@ -286,11 +292,17 @@ runs: echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "(unavailable)" else echo "$NOTES_B64" | base64 -d 2>/dev/null > /tmp/release-notes.md - gh release create "$TAG" \ - --title "$TITLE" \ - --notes-file /tmp/release-notes.md \ - --target "$SHA" - # Attach the SBOM when one was generated for this release. + # Idempotent: a heal re-run of an already-shipped lane finds its tag/release already + # present — skip creation rather than hard-fail on the existing tag. + if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub release $TAG already exists; skipping creation." + else + gh release create "$TAG" \ + --title "$TITLE" \ + --notes-file /tmp/release-notes.md \ + --target "$SHA" + fi + # Attach the SBOM when one was generated for this release (--clobber → idempotent). if [ "$SBOM_ENABLED" = "true" ] && [ -f "$WORKING_DIRECTORY/sbom.json" ]; then gh release upload "$TAG" "$WORKING_DIRECTORY/sbom.json" --clobber fi @@ -319,6 +331,7 @@ runs: BRANCH: ${{ inputs.branch }} ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} DRY_RUN: ${{ inputs.dry_run }} + ALREADY_PUBLISHED: ${{ inputs.already_published }} LINKS_JSON: ${{ steps.links.outputs.links }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | @@ -333,6 +346,8 @@ runs: if [ "$DRY_RUN" = "true" ]; then echo "## $SUBJECT $TAG — dry run complete" >> $GITHUB_STEP_SUMMARY + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + echo "## $SUBJECT $TAG — already published, skipped ⏭️" >> $GITHUB_STEP_SUMMARY else echo "## $SUBJECT $TAG — published ✅" >> $GITHUB_STEP_SUMMARY fi @@ -342,6 +357,10 @@ runs: echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY echo "> Dry run: Nothing was tagged or published." >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY + echo "> This version was already published; this run published and tagged nothing." >> $GITHUB_STEP_SUMMARY + exit 0 fi if [ "$ON_RELEASE_BRANCH" = "false" ]; then @@ -380,6 +399,7 @@ runs: ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} PREV_RELEASE: ${{ inputs.prev_release }} DRY_RUN: ${{ inputs.dry_run }} + ALREADY_PUBLISHED: ${{ inputs.already_published }} SLACK_MENTION: ${{ inputs.slack_mention }} LINKS_JSON: ${{ steps.links.outputs.links }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -402,10 +422,18 @@ runs: if [ "$DRY_RUN" = "true" ]; then TEXT="$EMOJI *$SUBJECT $TAG* complete" TEXT="$TEXT\n> :information_source: _Dry run: nothing tagged or published._" + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + TEXT="$EMOJI *$SUBJECT $TAG* — already published, skipped" else TEXT="$EMOJI *$SUBJECT $TAG* published" fi + # A skip published/tagged nothing — keep the message to the one line, no branch/links/PRs. + if [ "$ALREADY_PUBLISHED" = "true" ]; then + { echo 'text<> $GITHUB_OUTPUT + exit 0 + fi + if [ "$ON_RELEASE_BRANCH" = "false" ]; then BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" TEXT="$TEXT\n> ⚠️ NOT on release branch: <$BRANCH_URL|$BRANCH>" diff --git a/actions/release/lang/js/configure/action.yml b/actions/release/lang/js/configure/action.yml index 09d27a1..6ee80f4 100644 --- a/actions/release/lang/js/configure/action.yml +++ b/actions/release/lang/js/configure/action.yml @@ -2,12 +2,13 @@ # sdk-actions: {"family":"release","version":"1.0.0"} name: Configure JavaScript Release description: > - Derives release facts for a JavaScript package — read-only, no token. Checks out the - SHA, sets up Node and pnpm, reads the package version, and computes the release tag, - the branch (and whether it's a release branch), the commit message, the previous - release tag, and the channel + github_release defaults from release_type (explicit - overrides win). Its outputs feed validate, request-approval, and build-and-ship. - Judging/checks live in the validate action; this action never fails on release state. + Produces the release FACTS for a JavaScript package — the fact-finding half of the + pipeline (validate judges these; it never fails on release state). Checks out the SHA, + sets up Node, reads the version, computes the release tag/branch/commit/channel/ + github_release, checks npm for whether the version is already published (→ + already_published + version_url), fetches release notes + PR list, and assembles the + package manifest. Requires contents: write on the calling job (for the generate-notes + API). Its outputs (incl. `package`) feed validate, request-approval, and ship. inputs: sha: @@ -34,7 +35,7 @@ inputs: inputs override this. required: false default: 'stable' - channel: + npm_channel: description: "Explicit npm channel (dist-tag) override. Empty → derived from release_type." required: false default: '' @@ -46,6 +47,27 @@ inputs: description: "Comma-separated allowed release branch patterns (globs)." required: false default: 'main' + npm_registry: + description: "npm registry URL (for the availability check)." + required: false + default: 'https://registry.npmjs.org' + npm_package_name: + description: "npm package name. Enables the availability check + the manifest's npm registry entry." + required: false + default: '' + package_label: + description: "Display label raw material for notifications (presentation-agnostic; the renderer formats it)." + required: false + default: '' + emoji: + description: "Presentation emoji raw material for notifications." + required: false + default: '' + prev_release: + description: "Release-notes anchor override (tag or SHA). Empty → configure's derived previous tag." + required: false + default: '' + outputs: version: description: "The resolved package version" @@ -71,6 +93,21 @@ outputs: github_release: description: "Whether a GitHub release should be created" value: ${{ steps.configure.outputs.github_release }} + already_published: + description: "Whether the version is already published to npm" + value: ${{ steps.version-check.outputs.already_published }} + version_url: + description: "Human URL for the version on npm" + value: ${{ steps.version-check.outputs.version_url }} + pr_list: + description: "Formatted PR list (x1f-delimited)" + value: ${{ steps.fetch.outputs.pr_list }} + notes: + description: "Base64-encoded release notes" + value: ${{ steps.fetch.outputs.notes }} + package: + description: "A JSON object describing the package to be published." + value: ${{ steps.build.outputs.package }} runs: using: composite @@ -80,11 +117,8 @@ runs: ref: ${{ inputs.sha }} fetch-depth: 0 - # configure reads package.json — so it sets up a default Node itself. The - # requirement is satisfied by the action (no node_version input, no consumer - # config): any Node reads the version, and the project's pinned toolchain is only - # needed to BUILD (validate / build-and-ship). A node-runtime read never touches - # engines/devEngines, so this is safe in strict-pnpm repos. No pnpm, no install. + # Read-only version read: any Node reads package.json; the project's pinned toolchain + # is only needed to BUILD (validate / ship). Safe in strict-pnpm repos. No pnpm/install. - name: Set up Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: @@ -128,7 +162,7 @@ runs: RELEASE_TYPE: ${{ inputs.release_type }} # Quoted: these overrides are legitimately empty (e.g. py/ruby have no channel), and an # unquoted empty value would parse as YAML null, which the action schema rejects. - CHANNEL_OVERRIDE: "${{ inputs.channel }}" + CHANNEL_OVERRIDE: "${{ inputs.npm_channel }}" GHR_OVERRIDE: "${{ inputs.github_release }}" run: | TAG="${TAG_FORMAT//\{version\}/$VERSION}" @@ -166,6 +200,150 @@ runs: } >> "$GITHUB_OUTPUT" echo "Configured $TAG @ $VERSION (channel=$CHANNEL, github_release=$GHR, branch=$BRANCH, on_release_branch=$ON_RELEASE_BRANCH)" + - name: Check npm version availability + id: version-check + shell: bash + env: + PACKAGE_NAME: ${{ inputs.npm_package_name }} + VERSION: ${{ steps.read-version.outputs.version }} + REGISTRY: ${{ inputs.npm_registry }} + SEVERITY: off + run: | + if [ -z "$PACKAGE_NAME" ]; then + echo "No package_name provided; skipping npm availability check." + exit 0 + fi + echo "version_url=https://www.npmjs.com/package/$PACKAGE_NAME/v/$VERSION" >> "$GITHUB_OUTPUT" + REGISTRY="${REGISTRY%/}" + CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ + --retry 3 --retry-connrefused --max-time 30 \ + "$REGISTRY/$PACKAGE_NAME/$VERSION") + case "$CODE" in + 200) + echo "already_published=true" >> "$GITHUB_OUTPUT" + if [ "$SEVERITY" = "off" ]; then + echo "$PACKAGE_NAME@$VERSION is already on npm." + elif [ "$SEVERITY" = "warn" ]; then + echo "::warning title=Already published::$PACKAGE_NAME@$VERSION is already on npm." + else + echo "::error title=Version already published::$PACKAGE_NAME@$VERSION already exists on npm — has the version been bumped?" + exit 1 + fi + ;; + 404) + echo "already_published=false" >> "$GITHUB_OUTPUT" + echo "$PACKAGE_NAME@$VERSION is available on npm." + ;; + *) + echo "::error title=Registry check failed::unexpected response from the npm registry (HTTP $CODE) checking $PACKAGE_NAME@$VERSION — cannot confirm the version is unpublished; aborting rather than risk a bad release." + exit 1 + ;; + esac + + - name: Fetch PR list and release notes + id: fetch + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.configure.outputs.release_tag }} + SHA: ${{ inputs.sha }} + PREV_RELEASE: ${{ inputs.prev_release || steps.configure.outputs.prev_release }} + run: | + # If prev_release is a full SHA, notes are unavailable. + if echo "$PREV_RELEASE" | grep -qE '^[0-9a-f]{40}$'; then + echo "SHA provided for prev_release — notes unavailable for SHA ranges." + echo "pr_list=" >> $GITHUB_OUTPUT + echo "notes=$(echo "" | base64 -w 0)" >> $GITHUB_OUTPUT + exit 0 + fi + + BODY=$(gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + --method POST \ + --field tag_name="$RELEASE_TAG" \ + --field target_commitish="$SHA" \ + ${PREV_RELEASE:+--field previous_tag_name="$PREV_RELEASE"} \ + --jq '.body' 2>/dev/null || echo "") + + PR_LIST=$(echo "$BODY" \ + | grep "^\* " \ + | grep -v "made their first contribution" \ + | grep -v "Full Changelog" \ + | head -10 \ + | sed 's|^\* ||' \ + | sed 's| by @[^ ]*||' \ + | sed 's@ in \(https://[^ ]*/pull/\([0-9]*\)\)@ (<\1|#\2>)@' \ + | sed 's/^/• /' \ + | tr '\n' $'\x1f' || echo "") + + echo "pr_list=$PR_LIST" >> $GITHUB_OUTPUT + echo "notes=$(echo "$BODY" | base64 -w 0)" >> $GITHUB_OUTPUT + + - name: Build package manifest entry + id: build + shell: bash + env: + LABEL: "${{ inputs.package_label }}" + EMOJI: "${{ inputs.emoji }}" + TAG: "${{ steps.configure.outputs.release_tag }}" + PREV_RELEASE: "${{ inputs.prev_release || steps.configure.outputs.prev_release }}" + PR_LIST: "${{ steps.fetch.outputs.pr_list }}" + NOTES: "${{ steps.fetch.outputs.notes }}" + GITHUB_RELEASE: "${{ steps.configure.outputs.github_release }}" + NPM_NAME: "${{ inputs.npm_package_name }}" + NPM_VERSION: "${{ steps.read-version.outputs.version }}" + NPM_CHANNEL: "${{ steps.configure.outputs.channel }}" + NPM_VERSION_URL: "${{ steps.version-check.outputs.version_url }}" + NPM_ALREADY_PUBLISHED: "${{ steps.version-check.outputs.already_published }}" + PYPI_NAME: "" + PYPI_VERSION: "" + PYPI_VERSION_URL: "" + PYPI_ALREADY_PUBLISHED: "" + RUBYGEMS_NAME: "" + RUBYGEMS_VERSION: "" + RUBYGEMS_VERSION_URL: "" + RUBYGEMS_ALREADY_PUBLISHED: "" + run: | + set -euo pipefail + if ! command -v jq >/dev/null 2>&1; then + echo "::error title=jq not found::build-manifest requires jq." + exit 1 + fi + + REGISTRIES='{}' + # Add one registry entry (keyed by id) when its name/version are given. version_url is + # carried in (from the availability check); empties dropped; already_published → boolean. + add_registry() { + local reg="$1" name="$2" version="$3" channel="$4" ap="$5" url="$6" + { [ -z "$name" ] && [ -z "$version" ]; } && return 0 + local entry + entry="$(jq -nc \ + --arg name "$name" --arg version "$version" --arg url "$url" \ + --arg channel "$channel" --arg ap "$ap" \ + '{name:$name, version:$version, version_url:$url, channel:$channel} + | with_entries(select(.value != "")) + | if $ap=="true" then .already_published=true + elif $ap=="false" then .already_published=false else . end')" + REGISTRIES="$(jq -c --arg r "$reg" --argjson e "$entry" '. + {($r): $e}' <<<"$REGISTRIES")" + } + add_registry npm "$NPM_NAME" "$NPM_VERSION" "$NPM_CHANNEL" "$NPM_ALREADY_PUBLISHED" "$NPM_VERSION_URL" + add_registry pypi "$PYPI_NAME" "$PYPI_VERSION" "" "$PYPI_ALREADY_PUBLISHED" "$PYPI_VERSION_URL" + add_registry rubygems "$RUBYGEMS_NAME" "$RUBYGEMS_VERSION" "" "$RUBYGEMS_ALREADY_PUBLISHED" "$RUBYGEMS_VERSION_URL" + + # Package-level object; drop empties, then attach registries only when non-empty. + PACKAGE="$(jq -nc \ + --arg label "$LABEL" --arg emoji "$EMOJI" --arg tag "$TAG" \ + --arg prev_release "$PREV_RELEASE" --arg pr_list "$PR_LIST" --arg notes "$NOTES" \ + --arg github_release "$GITHUB_RELEASE" \ + '{label:$label, emoji:$emoji, tag:$tag, prev_release:$prev_release, + pr_list:$pr_list, notes:$notes, github_release:$github_release} + | with_entries(select(.value != ""))')" + if [ "$REGISTRIES" != "{}" ]; then + PACKAGE="$(jq -c --argjson r "$REGISTRIES" '. + {registries:$r}' <<<"$PACKAGE")" + fi + + echo "package=$PACKAGE" >> "$GITHUB_OUTPUT" + echo "Built package manifest entry: $(jq -c '{keys:keys, registries:(.registries // {} | keys)}' <<<"$PACKAGE")" + - name: Dump JavaScript environment if: ${{ failure() }} shell: bash diff --git a/actions/release/lang/js/pack-pnpm/action.yml b/actions/release/lang/js/pack-pnpm/action.yml index 3b8cf22..8c9db83 100644 --- a/actions/release/lang/js/pack-pnpm/action.yml +++ b/actions/release/lang/js/pack-pnpm/action.yml @@ -119,6 +119,28 @@ runs: run: | pnpm run "$BUILD_COMMAND" + - name: Resolve packaging pnpm + # pack + sbom need pnpm >= 11.8, but install/build above use the project's own pnpm — which may + # pin an OLDER pnpm (packageManager) to honor its supply-chain config. Resolve the packaging + # pnpm here and export $PACK_PNPM for those two steps: the project's pnpm when it already + # satisfies >= 11.8, else a pinned one via corepack. --pm-on-fail=ignore only bypasses pnpm's + # packageManager version-mismatch refusal; the pack/sbom logic is the standard logic for that + # version, so the attested tarball bytes + SBOM are exactly what a same-version job produces. + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + PINNED: '11.9.0' # sdk-actions-tested pnpm for pack + sbom (>= 11.8); bump centrally as pnpm advances + COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' + run: | + CUR=$(pnpm --version 2>/dev/null || echo 0) + if printf '11.8.0\n%s\n' "$CUR" | sort -VC; then + echo "PACK_PNPM=pnpm" >> "$GITHUB_ENV" + echo "Packaging with the project's pnpm $CUR (>= 11.8)" + else + echo "PACK_PNPM=corepack pnpm@$PINNED --pm-on-fail=ignore" >> "$GITHUB_ENV" + echo "Project pnpm $CUR is < 11.8 — packaging with pinned pnpm $PINNED" + fi + - name: Pack id: pack shell: bash @@ -127,7 +149,7 @@ runs: PACK_DIR: ${{ runner.temp }}/pkgout run: | rm -rf "$PACK_DIR"; mkdir -p "$PACK_DIR" - pnpm pack --pack-destination "$PACK_DIR" >/dev/null + ${PACK_PNPM:-pnpm} pack --pack-destination "$PACK_DIR" >/dev/null TGZ=$(ls "$PACK_DIR"/*.tgz) echo "tarball=$TGZ" >> "$GITHUB_OUTPUT" echo "integrity=sha512-$(openssl dgst -sha512 -binary "$TGZ" | openssl base64 -A)" >> "$GITHUB_OUTPUT" @@ -164,11 +186,12 @@ runs: shell: bash working-directory: ${{ inputs.working_directory }} run: | - if ! pnpm sbom --help >/dev/null 2>&1; then + PNPM="${PACK_PNPM:-pnpm}" + if ! $PNPM sbom --help >/dev/null 2>&1; then echo "::error title=pnpm sbom unavailable::'pnpm sbom' needs pnpm >= 11.8. Set the package's packageManager (or pnpm_version) to >= 11.8." exit 1 fi - pnpm sbom --sbom-format cyclonedx --prod --out sbom.json + $PNPM sbom --sbom-format cyclonedx --prod --out sbom.json echo "Wrote $(pwd)/sbom.json" - name: Attest SBOM diff --git a/actions/release/lang/js/ship-package/action.yml b/actions/release/lang/js/ship-package/action.yml index 51585c4..7467e6a 100644 --- a/actions/release/lang/js/ship-package/action.yml +++ b/actions/release/lang/js/ship-package/action.yml @@ -73,6 +73,10 @@ inputs: description: "npm package name — used to build the npm link in notifications" required: false default: '' + already_published: + description: "Whether the version is already on npm (from configure). When 'true', the publish is skipped." + required: false + default: '' label: description: "Display name for release notifications (e.g. the package name)." required: false @@ -92,7 +96,7 @@ inputs: description: "Whether the SHA is on an expected release branch" required: true pr_list: - description: "x1f-delimited PR list from prepare action" + description: "x1f-delimited PR list from configure" required: false default: '' slack_token: @@ -160,6 +164,7 @@ runs: run: npm install -g "npm@$NPM_VERSION" - name: Publish prebuilt tarball to npm + if: ${{ inputs.already_published != 'true' }} shell: bash env: TGZ: ${{ inputs.tarball }} @@ -221,11 +226,17 @@ runs: echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "(unavailable)" else echo "$NOTES_B64" | base64 -d 2>/dev/null > /tmp/release-notes.md - gh release create "$TAG" \ - --title "$TITLE" \ - --notes-file /tmp/release-notes.md \ - --target "$SHA" - # Attach the SBOM when one was generated for this release. + # Idempotent: a heal re-run of an already-shipped lane finds its tag/release already + # present — skip creation rather than hard-fail on the existing tag. + if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub release $TAG already exists; skipping creation." + else + gh release create "$TAG" \ + --title "$TITLE" \ + --notes-file /tmp/release-notes.md \ + --target "$SHA" + fi + # Attach the SBOM when one was generated for this release (--clobber → idempotent). if [ "$SBOM_ENABLED" = "true" ] && [ -f "$WORKING_DIRECTORY/sbom.json" ]; then gh release upload "$TAG" "$WORKING_DIRECTORY/sbom.json" --clobber fi @@ -254,6 +265,7 @@ runs: BRANCH: ${{ inputs.branch }} ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} DRY_RUN: ${{ inputs.dry_run }} + ALREADY_PUBLISHED: ${{ inputs.already_published }} LINKS_JSON: ${{ steps.links.outputs.links }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | @@ -268,6 +280,8 @@ runs: if [ "$DRY_RUN" = "true" ]; then echo "## $SUBJECT $TAG — dry run complete" >> $GITHUB_STEP_SUMMARY + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + echo "## $SUBJECT $TAG — already published, skipped ⏭️" >> $GITHUB_STEP_SUMMARY else echo "## $SUBJECT $TAG — published ✅" >> $GITHUB_STEP_SUMMARY fi @@ -277,6 +291,10 @@ runs: echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY echo "> Dry run: Nothing was tagged or published." >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY + echo "> This version was already published; this run published and tagged nothing." >> $GITHUB_STEP_SUMMARY + exit 0 fi if [ "$ON_RELEASE_BRANCH" = "false" ]; then @@ -315,6 +333,7 @@ runs: ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} PREV_RELEASE: ${{ inputs.prev_release }} DRY_RUN: ${{ inputs.dry_run }} + ALREADY_PUBLISHED: ${{ inputs.already_published }} SLACK_MENTION: ${{ inputs.slack_mention }} LINKS_JSON: ${{ steps.links.outputs.links }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -337,10 +356,18 @@ runs: if [ "$DRY_RUN" = "true" ]; then TEXT="$EMOJI *$SUBJECT $TAG* complete" TEXT="$TEXT\n> :information_source: _Dry run: nothing tagged or published._" + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + TEXT="$EMOJI *$SUBJECT $TAG* — already published, skipped" else TEXT="$EMOJI *$SUBJECT $TAG* published" fi + # A skip published/tagged nothing — keep the message to the one line, no branch/links/PRs. + if [ "$ALREADY_PUBLISHED" = "true" ]; then + { echo 'text<> $GITHUB_OUTPUT + exit 0 + fi + if [ "$ON_RELEASE_BRANCH" = "false" ]; then BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" TEXT="$TEXT\n> ⚠️ NOT on release branch: <$BRANCH_URL|$BRANCH>" diff --git a/actions/release/lang/js/validate/action.yml b/actions/release/lang/js/validate/action.yml index b883461..8952871 100644 --- a/actions/release/lang/js/validate/action.yml +++ b/actions/release/lang/js/validate/action.yml @@ -7,20 +7,17 @@ description: > branch) — and runs a pre-gate build + CycloneDX SBOM generation so build/SBOM-tooling failures (e.g. pnpm < 11.8) surface BEFORE the approval gate. Read-only: nothing is signed or published here. Every check is a boolean toggle (default on) with an escape - hatch. + hatch. Consumes `configure`'s `already_published` fact (does not query the registry + itself — that fact is produced once, in configure). inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Warn instead of failing on an already-existing tag" + description: "Warn instead of failing on an already-existing tag / already-published version" required: false default: 'false' - version: - description: "Version override (from configure). If empty, read from package.json." - required: false - default: '' working_directory: description: "Path to the package root (where package.json lives)" required: false @@ -34,16 +31,6 @@ inputs: description: "pnpm version to install. When empty, read from package.json packageManager." required: false default: '' - registry: - description: "npm registry URL" - required: false - default: 'https://registry.npmjs.org' - package_name: - description: > - npm package name. Used by the version-unpublished check (covers prereleases, - which aren't git-tagged). Leave empty to skip that check. - required: false - default: '' release_tag: description: "The release tag (from configure) — checked for prior existence" required: true @@ -60,7 +47,11 @@ inputs: required: false default: 'true' notes: - description: "Base64-encoded release notes (from prepare) — checked non-blank" + description: "Base64-encoded release notes (from configure) — checked non-blank" + required: false + default: '' + already_published: + description: "Whether the version is already on the registry (from configure). Judged by check_version_unpublished." required: false default: '' build: @@ -85,7 +76,7 @@ inputs: required: false default: 'true' check_version_unpublished: - description: "Fail if the version is already published to the registry" + description: "Fail if the version is already published to the registry (judges configure's already_published)" required: false default: 'true' check_channel_allowed: @@ -152,54 +143,6 @@ runs: exit 1 } - - name: Read version - id: read-version - shell: bash - working-directory: ${{ inputs.working_directory }} - env: - VERSION_OVERRIDE: ${{ inputs.version }} - run: | - if [ -n "$VERSION_OVERRIDE" ]; then - VERSION="$VERSION_OVERRIDE" - else - VERSION=$(node -p "require('./package.json').version") - fi - if [ -z "$VERSION" ] || [ "$VERSION" = "undefined" ]; then - echo "::error title=Could not read version::resolved an empty version — check the \"version\" field in package.json (or pass a version override)." - exit 1 - fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Check npm version availability - if: ${{ inputs.check_version_unpublished == 'true' }} - shell: bash - env: - PACKAGE_NAME: ${{ inputs.package_name }} - VERSION: ${{ steps.read-version.outputs.version }} - REGISTRY: ${{ inputs.registry }} - run: | - if [ -z "$PACKAGE_NAME" ]; then - echo "No package_name provided; skipping npm availability check." - exit 0 - fi - REGISTRY="${REGISTRY%/}" - CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ - --retry 3 --retry-connrefused --max-time 30 \ - "$REGISTRY/$PACKAGE_NAME/$VERSION") - case "$CODE" in - 200) - echo "::error title=Version already published::$PACKAGE_NAME@$VERSION already exists on npm — has the version been bumped?" - exit 1 - ;; - 404) - echo "$PACKAGE_NAME@$VERSION is available on npm." - ;; - *) - echo "::error title=Registry check failed::unexpected response from the npm registry (HTTP $CODE) checking $PACKAGE_NAME@$VERSION — cannot confirm the version is unpublished; aborting rather than risk a bad release." - exit 1 - ;; - esac - - name: Validate release channel if: ${{ inputs.check_channel_allowed == 'true' }} shell: bash @@ -224,7 +167,20 @@ runs: CHECK_TAG_UNUSED: ${{ inputs.check_tag_unused }} CHECK_NOTES: ${{ inputs.check_notes_not_blank }} ENFORCE_RELEASE_BRANCH: ${{ inputs.enforce_release_branch }} + CHECK_VERSION_UNPUBLISHED: ${{ inputs.check_version_unpublished }} + ALREADY_PUBLISHED: ${{ inputs.already_published }} run: | + # Version not already published on the registry (fact from configure's availability check; + # covers prereleases, which aren't git-tagged). Warn (not fail) on a dry run. + if [ "$CHECK_VERSION_UNPUBLISHED" = "true" ] && [ "$ALREADY_PUBLISHED" = "true" ]; then + if [ "$DRY_RUN" = "true" ]; then + echo "::warning title=Version already published::the version is already on the registry — skipping in dry run." + else + echo "::error title=Version already published::the version is already on the registry — has the version been bumped?" + exit 1 + fi + fi + # Tag not already used (covers stable releases; prereleases are covered by the availability check) if [ "$CHECK_TAG_UNUSED" = "true" ] && git rev-parse "$TAG" >/dev/null 2>&1; then if [ "$DRY_RUN" = "true" ]; then @@ -267,11 +223,12 @@ runs: shell: bash working-directory: ${{ inputs.working_directory }} run: | - if ! pnpm sbom --help >/dev/null 2>&1; then + PNPM="${PACK_PNPM:-pnpm}" + if ! $PNPM sbom --help >/dev/null 2>&1; then echo "::error title=pnpm sbom unavailable::'pnpm sbom' needs pnpm >= 11.8. Set the package's packageManager (or pnpm_version) to >= 11.8." exit 1 fi - pnpm sbom --sbom-format cyclonedx --prod --out sbom.json + $PNPM sbom --sbom-format cyclonedx --prod --out sbom.json echo "Wrote $(pwd)/sbom.json" - name: Dump JavaScript environment diff --git a/actions/release/lang/py/build-and-ship/action.yml b/actions/release/lang/py/build-and-ship/action.yml index 5914cf4..97e01be 100644 --- a/actions/release/lang/py/build-and-ship/action.yml +++ b/actions/release/lang/py/build-and-ship/action.yml @@ -79,13 +79,17 @@ inputs: description: "Whether the SHA is on an expected release branch" required: true pr_list: - description: "x1f-delimited PR list from prepare action" + description: "x1f-delimited PR list from configure" required: false default: '' package_name: description: "PyPI package name — used to build the PyPI link in notifications" required: false default: '' + already_published: + description: "Whether the version is already on PyPI (from configure). When 'true', the publish is skipped." + required: false + default: '' label: description: > Display name for release notifications (e.g. the package name). When set, it @@ -211,6 +215,7 @@ runs: sbom-path: ${{ inputs.working_directory }}/sbom.json - name: Publish to PyPI + if: ${{ inputs.already_published != 'true' }} shell: bash working-directory: ${{ inputs.working_directory }} env: @@ -266,11 +271,17 @@ runs: echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "(unavailable)" else echo "$NOTES_B64" | base64 -d 2>/dev/null > /tmp/release-notes.md - gh release create "$TAG" \ - --title "$TITLE" \ - --notes-file /tmp/release-notes.md \ - --target "$SHA" - # Attach the SBOM when one was generated for this release. + # Idempotent: a heal re-run of an already-shipped lane finds its tag/release already + # present — skip creation rather than hard-fail on the existing tag. + if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub release $TAG already exists; skipping creation." + else + gh release create "$TAG" \ + --title "$TITLE" \ + --notes-file /tmp/release-notes.md \ + --target "$SHA" + fi + # Attach the SBOM when one was generated for this release (--clobber → idempotent). if [ "$SBOM_ENABLED" = "true" ] && [ -f "$WORKING_DIRECTORY/sbom.json" ]; then gh release upload "$TAG" "$WORKING_DIRECTORY/sbom.json" --clobber fi @@ -299,6 +310,7 @@ runs: BRANCH: ${{ inputs.branch }} ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} DRY_RUN: ${{ inputs.dry_run }} + ALREADY_PUBLISHED: ${{ inputs.already_published }} LINKS_JSON: ${{ steps.links.outputs.links }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | @@ -313,6 +325,8 @@ runs: if [ "$DRY_RUN" = "true" ]; then echo "## $SUBJECT $TAG — dry run complete" >> $GITHUB_STEP_SUMMARY + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + echo "## $SUBJECT $TAG — already published, skipped ⏭️" >> $GITHUB_STEP_SUMMARY else echo "## $SUBJECT $TAG — published ✅" >> $GITHUB_STEP_SUMMARY fi @@ -322,6 +336,10 @@ runs: echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY echo "> Dry run: Nothing was tagged or published." >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY + echo "> This version was already published; this run published and tagged nothing." >> $GITHUB_STEP_SUMMARY + exit 0 fi if [ "$ON_RELEASE_BRANCH" = "false" ]; then @@ -360,6 +378,7 @@ runs: ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} PREV_RELEASE: ${{ inputs.prev_release }} DRY_RUN: ${{ inputs.dry_run }} + ALREADY_PUBLISHED: ${{ inputs.already_published }} SLACK_MENTION: ${{ inputs.slack_mention }} LINKS_JSON: ${{ steps.links.outputs.links }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -382,10 +401,18 @@ runs: if [ "$DRY_RUN" = "true" ]; then TEXT="$EMOJI *$SUBJECT $TAG* complete" TEXT="$TEXT\n> :information_source: _Dry run: nothing tagged or published._" + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + TEXT="$EMOJI *$SUBJECT $TAG* — already published, skipped" else TEXT="$EMOJI *$SUBJECT $TAG* published" fi + # A skip published/tagged nothing — keep the message to the one line, no branch/links/PRs. + if [ "$ALREADY_PUBLISHED" = "true" ]; then + { echo 'text<> $GITHUB_OUTPUT + exit 0 + fi + if [ "$ON_RELEASE_BRANCH" = "false" ]; then BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" TEXT="$TEXT\n> ⚠️ NOT on release branch: <$BRANCH_URL|$BRANCH>" diff --git a/actions/release/lang/py/configure/action.yml b/actions/release/lang/py/configure/action.yml index 2cfcdcc..6331e09 100644 --- a/actions/release/lang/py/configure/action.yml +++ b/actions/release/lang/py/configure/action.yml @@ -2,13 +2,13 @@ # sdk-actions: {"family":"release","version":"1.0.0"} name: Configure Python Release description: > - Derives release facts for a Python package — read-only, no token. Checks out the - SHA, sets up uv (with a default Python the action guarantees itself — no - python_version input) to read the version, and computes the release tag, the branch - (and whether it's a release branch), the commit message, the previous release tag, - and the github_release default from release_type. Its outputs feed validate, - request-approval, and build-and-ship. Judging/checks live in the validate action; - this action never fails on release state. + Produces the release FACTS for a Python package — the fact-finding half of the pipeline + (validate judges these; it never fails on release state). Checks out the SHA, sets up uv, + reads the version, computes the release tag/branch/commit/github_release, checks PyPI for + whether the version is already published (→ already_published + version_url), fetches + release notes + PR list, and assembles the package manifest. Requires contents: write on + the calling job (for the generate-notes API). Its outputs (incl. `package`) feed + validate, request-approval, and ship. inputs: sha: @@ -48,6 +48,23 @@ inputs: description: "Comma-separated allowed release branch patterns (globs)." required: false default: 'main' + pypi_package_name: + description: "PyPI package name. Enables the availability check + the manifest's pypi registry entry." + required: false + default: '' + package_label: + description: "Display label raw material for notifications (presentation-agnostic; the renderer formats it)." + required: false + default: '' + emoji: + description: "Presentation emoji raw material for notifications." + required: false + default: '' + prev_release: + description: "Release-notes anchor override (tag or SHA). Empty → configure's derived previous tag." + required: false + default: '' + outputs: version: description: "The resolved package version" @@ -70,6 +87,21 @@ outputs: github_release: description: "Whether a GitHub release should be created" value: ${{ steps.configure.outputs.github_release }} + already_published: + description: "Whether the version is already published to PyPI" + value: ${{ steps.version-check.outputs.already_published }} + version_url: + description: "Human URL for the version on PyPI" + value: ${{ steps.version-check.outputs.version_url }} + pr_list: + description: "Formatted PR list (x1f-delimited)" + value: ${{ steps.fetch.outputs.pr_list }} + notes: + description: "Base64-encoded release notes" + value: ${{ steps.fetch.outputs.notes }} + package: + description: "A JSON object describing the package to be published." + value: ${{ steps.build.outputs.package }} runs: using: composite @@ -79,16 +111,12 @@ runs: ref: ${{ inputs.sha }} fetch-depth: 0 - # configure reads the version (version.py) — so it sets up uv with a default Python - # itself. The requirement is guaranteed by the action (no python_version input); any - # Python reads the version, and the project's pinned toolchain is only needed to - # BUILD (validate / build-and-ship). Read-only: no dependency install. + # Read-only version read: a default Python reads version_file; the project's pinned + # toolchain is only needed to BUILD (validate / ship). No dependency install. - name: Set up uv uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7.2.0 with: python-version: '3.13' - # No dependency install here, so leave the cache off — otherwise setup-uv's - # post-job save-cache errors on the empty cache and fails the job. enable-cache: "false" - name: Read version @@ -169,6 +197,148 @@ runs: } >> "$GITHUB_OUTPUT" echo "Configured $TAG @ $VERSION (channel=$CHANNEL, github_release=$GHR, branch=$BRANCH, on_release_branch=$ON_RELEASE_BRANCH)" + - name: Check PyPI version availability + id: version-check + shell: bash + env: + PACKAGE_NAME: ${{ inputs.pypi_package_name }} + VERSION: ${{ steps.read-version.outputs.version }} + SEVERITY: off + run: | + if [ -z "$PACKAGE_NAME" ]; then + echo "No package_name provided; skipping PyPI availability check." + exit 0 + fi + echo "version_url=https://pypi.org/project/$PACKAGE_NAME/$VERSION/" >> "$GITHUB_OUTPUT" + CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ + --retry 3 --retry-connrefused --max-time 30 \ + "https://pypi.org/pypi/$PACKAGE_NAME/$VERSION/json") + case "$CODE" in + 200) + echo "already_published=true" >> "$GITHUB_OUTPUT" + if [ "$SEVERITY" = "off" ]; then + echo "$PACKAGE_NAME $VERSION is already on PyPI." + elif [ "$SEVERITY" = "warn" ]; then + echo "::warning title=Already published::$PACKAGE_NAME $VERSION is already on PyPI." + else + echo "::error title=Version already published::$PACKAGE_NAME $VERSION already exists on PyPI — has the version been bumped?" + exit 1 + fi + ;; + 404) + echo "already_published=false" >> "$GITHUB_OUTPUT" + echo "$PACKAGE_NAME $VERSION is available on PyPI." + ;; + *) + echo "::error title=Registry check failed::unexpected response from PyPI (HTTP $CODE) checking $PACKAGE_NAME $VERSION — cannot confirm the version is unpublished; aborting rather than risk a bad release." + exit 1 + ;; + esac + + - name: Fetch PR list and release notes + id: fetch + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.configure.outputs.release_tag }} + SHA: ${{ inputs.sha }} + PREV_RELEASE: ${{ inputs.prev_release || steps.configure.outputs.prev_release }} + run: | + # If prev_release is a full SHA, notes are unavailable. + if echo "$PREV_RELEASE" | grep -qE '^[0-9a-f]{40}$'; then + echo "SHA provided for prev_release — notes unavailable for SHA ranges." + echo "pr_list=" >> $GITHUB_OUTPUT + echo "notes=$(echo "" | base64 -w 0)" >> $GITHUB_OUTPUT + exit 0 + fi + + BODY=$(gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + --method POST \ + --field tag_name="$RELEASE_TAG" \ + --field target_commitish="$SHA" \ + ${PREV_RELEASE:+--field previous_tag_name="$PREV_RELEASE"} \ + --jq '.body' 2>/dev/null || echo "") + + PR_LIST=$(echo "$BODY" \ + | grep "^\* " \ + | grep -v "made their first contribution" \ + | grep -v "Full Changelog" \ + | head -10 \ + | sed 's|^\* ||' \ + | sed 's| by @[^ ]*||' \ + | sed 's@ in \(https://[^ ]*/pull/\([0-9]*\)\)@ (<\1|#\2>)@' \ + | sed 's/^/• /' \ + | tr '\n' $'\x1f' || echo "") + + echo "pr_list=$PR_LIST" >> $GITHUB_OUTPUT + echo "notes=$(echo "$BODY" | base64 -w 0)" >> $GITHUB_OUTPUT + + - name: Build package manifest entry + id: build + shell: bash + env: + LABEL: "${{ inputs.package_label }}" + EMOJI: "${{ inputs.emoji }}" + TAG: "${{ steps.configure.outputs.release_tag }}" + PREV_RELEASE: "${{ inputs.prev_release || steps.configure.outputs.prev_release }}" + PR_LIST: "${{ steps.fetch.outputs.pr_list }}" + NOTES: "${{ steps.fetch.outputs.notes }}" + GITHUB_RELEASE: "${{ steps.configure.outputs.github_release }}" + NPM_NAME: "" + NPM_VERSION: "" + NPM_CHANNEL: "" + NPM_VERSION_URL: "" + NPM_ALREADY_PUBLISHED: "" + PYPI_NAME: "${{ inputs.pypi_package_name }}" + PYPI_VERSION: "${{ steps.read-version.outputs.version }}" + PYPI_VERSION_URL: "${{ steps.version-check.outputs.version_url }}" + PYPI_ALREADY_PUBLISHED: "${{ steps.version-check.outputs.already_published }}" + RUBYGEMS_NAME: "" + RUBYGEMS_VERSION: "" + RUBYGEMS_VERSION_URL: "" + RUBYGEMS_ALREADY_PUBLISHED: "" + run: | + set -euo pipefail + if ! command -v jq >/dev/null 2>&1; then + echo "::error title=jq not found::build-manifest requires jq." + exit 1 + fi + + REGISTRIES='{}' + # Add one registry entry (keyed by id) when its name/version are given. version_url is + # carried in (from the availability check); empties dropped; already_published → boolean. + add_registry() { + local reg="$1" name="$2" version="$3" channel="$4" ap="$5" url="$6" + { [ -z "$name" ] && [ -z "$version" ]; } && return 0 + local entry + entry="$(jq -nc \ + --arg name "$name" --arg version "$version" --arg url "$url" \ + --arg channel "$channel" --arg ap "$ap" \ + '{name:$name, version:$version, version_url:$url, channel:$channel} + | with_entries(select(.value != "")) + | if $ap=="true" then .already_published=true + elif $ap=="false" then .already_published=false else . end')" + REGISTRIES="$(jq -c --arg r "$reg" --argjson e "$entry" '. + {($r): $e}' <<<"$REGISTRIES")" + } + add_registry npm "$NPM_NAME" "$NPM_VERSION" "$NPM_CHANNEL" "$NPM_ALREADY_PUBLISHED" "$NPM_VERSION_URL" + add_registry pypi "$PYPI_NAME" "$PYPI_VERSION" "" "$PYPI_ALREADY_PUBLISHED" "$PYPI_VERSION_URL" + add_registry rubygems "$RUBYGEMS_NAME" "$RUBYGEMS_VERSION" "" "$RUBYGEMS_ALREADY_PUBLISHED" "$RUBYGEMS_VERSION_URL" + + # Package-level object; drop empties, then attach registries only when non-empty. + PACKAGE="$(jq -nc \ + --arg label "$LABEL" --arg emoji "$EMOJI" --arg tag "$TAG" \ + --arg prev_release "$PREV_RELEASE" --arg pr_list "$PR_LIST" --arg notes "$NOTES" \ + --arg github_release "$GITHUB_RELEASE" \ + '{label:$label, emoji:$emoji, tag:$tag, prev_release:$prev_release, + pr_list:$pr_list, notes:$notes, github_release:$github_release} + | with_entries(select(.value != ""))')" + if [ "$REGISTRIES" != "{}" ]; then + PACKAGE="$(jq -c --argjson r "$REGISTRIES" '. + {registries:$r}' <<<"$PACKAGE")" + fi + + echo "package=$PACKAGE" >> "$GITHUB_OUTPUT" + echo "Built package manifest entry: $(jq -c '{keys:keys, registries:(.registries // {} | keys)}' <<<"$PACKAGE")" + - name: Dump Python environment if: ${{ failure() }} shell: bash diff --git a/actions/release/lang/py/ship-package/action.yml b/actions/release/lang/py/ship-package/action.yml index c978293..0a5a203 100644 --- a/actions/release/lang/py/ship-package/action.yml +++ b/actions/release/lang/py/ship-package/action.yml @@ -51,6 +51,10 @@ inputs: description: "PyPI package name — used to build the PyPI link in notifications" required: false default: '' + already_published: + description: "Whether the version is already on PyPI (from configure). When 'true', the publish is skipped." + required: false + default: '' label: description: "Display name for release notifications (e.g. the package name)." required: false @@ -70,7 +74,7 @@ inputs: description: "Whether the SHA is on an expected release branch" required: true pr_list: - description: "x1f-delimited PR list from prepare action" + description: "x1f-delimited PR list from configure" required: false default: '' slack_token: @@ -102,6 +106,10 @@ runs: uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7.2.0 with: python-version: '3.13' + # No dependency install here (we only upload prebuilt dist/ artifacts), and this runs in the + # downloaded-artifact dir with no pyproject.toml/uv.lock — so leave the cache off, otherwise + # setup-uv's post-job save-cache errors on the missing cache path and fails the job. + enable-cache: "false" - name: Verify attestations if: ${{ inputs.dry_run != 'true' }} @@ -132,6 +140,7 @@ runs: echo "✓ attestations verified for: ${FILES[*]}" - name: Publish to PyPI + if: ${{ inputs.already_published != 'true' }} shell: bash working-directory: ${{ inputs.working_directory }} env: @@ -187,11 +196,17 @@ runs: echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "(unavailable)" else echo "$NOTES_B64" | base64 -d 2>/dev/null > /tmp/release-notes.md - gh release create "$TAG" \ - --title "$TITLE" \ - --notes-file /tmp/release-notes.md \ - --target "$SHA" - # Attach the SBOM when one was generated for this release. + # Idempotent: a heal re-run of an already-shipped lane finds its tag/release already + # present — skip creation rather than hard-fail on the existing tag. + if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub release $TAG already exists; skipping creation." + else + gh release create "$TAG" \ + --title "$TITLE" \ + --notes-file /tmp/release-notes.md \ + --target "$SHA" + fi + # Attach the SBOM when one was generated for this release (--clobber → idempotent). if [ "$SBOM_ENABLED" = "true" ] && [ -f "$WORKING_DIRECTORY/sbom.json" ]; then gh release upload "$TAG" "$WORKING_DIRECTORY/sbom.json" --clobber fi @@ -220,6 +235,7 @@ runs: BRANCH: ${{ inputs.branch }} ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} DRY_RUN: ${{ inputs.dry_run }} + ALREADY_PUBLISHED: ${{ inputs.already_published }} LINKS_JSON: ${{ steps.links.outputs.links }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | @@ -234,6 +250,8 @@ runs: if [ "$DRY_RUN" = "true" ]; then echo "## $SUBJECT $TAG — dry run complete" >> $GITHUB_STEP_SUMMARY + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + echo "## $SUBJECT $TAG — already published, skipped ⏭️" >> $GITHUB_STEP_SUMMARY else echo "## $SUBJECT $TAG — published ✅" >> $GITHUB_STEP_SUMMARY fi @@ -243,6 +261,10 @@ runs: echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY echo "> Dry run: Nothing was tagged or published." >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY + echo "> This version was already published; this run published and tagged nothing." >> $GITHUB_STEP_SUMMARY + exit 0 fi if [ "$ON_RELEASE_BRANCH" = "false" ]; then @@ -281,6 +303,7 @@ runs: ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} PREV_RELEASE: ${{ inputs.prev_release }} DRY_RUN: ${{ inputs.dry_run }} + ALREADY_PUBLISHED: ${{ inputs.already_published }} SLACK_MENTION: ${{ inputs.slack_mention }} LINKS_JSON: ${{ steps.links.outputs.links }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -303,10 +326,18 @@ runs: if [ "$DRY_RUN" = "true" ]; then TEXT="$EMOJI *$SUBJECT $TAG* complete" TEXT="$TEXT\n> :information_source: _Dry run: nothing tagged or published._" + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + TEXT="$EMOJI *$SUBJECT $TAG* — already published, skipped" else TEXT="$EMOJI *$SUBJECT $TAG* published" fi + # A skip published/tagged nothing — keep the message to the one line, no branch/links/PRs. + if [ "$ALREADY_PUBLISHED" = "true" ]; then + { echo 'text<> $GITHUB_OUTPUT + exit 0 + fi + if [ "$ON_RELEASE_BRANCH" = "false" ]; then BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" TEXT="$TEXT\n> ⚠️ NOT on release branch: <$BRANCH_URL|$BRANCH>" diff --git a/actions/release/lang/py/validate/action.yml b/actions/release/lang/py/validate/action.yml index c4a73c1..7d7706a 100644 --- a/actions/release/lang/py/validate/action.yml +++ b/actions/release/lang/py/validate/action.yml @@ -6,24 +6,17 @@ description: > (tag unused, version unpublished on PyPI, notes present, release branch) — and runs a pre-gate build + CycloneDX SBOM generation so build/SBOM-tooling failures surface BEFORE the approval gate. Read-only: nothing is signed or published here. Every check - is a boolean toggle (default on) with an escape hatch. + is a boolean toggle (default on) with an escape hatch. Consumes `configure`'s + `already_published` fact (does not query PyPI itself). inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Warn instead of failing on an already-existing tag" + description: "Warn instead of failing on an already-existing tag / already-published version" required: false default: 'false' - version: - description: "Version override (from configure). If empty, read from version_file." - required: false - default: '' - version_file: - description: "Path (repo-root-relative) to a Python module exposing VERSION." - required: false - default: '' working_directory: description: "Path to the package root (where pyproject.toml lives)" required: false @@ -33,12 +26,6 @@ inputs: Python version (e.g. 3.13), or a path to a version file (.python-version, .tool-versions) relative to the repo root. required: true - package_name: - description: > - PyPI package name. Used by the version-unpublished check (covers prereleases, - which aren't git-tagged). Leave empty to skip that check. - required: false - default: '' release_tag: description: "The release tag (from configure) — checked for prior existence" required: true @@ -47,7 +34,11 @@ inputs: required: false default: 'true' notes: - description: "Base64-encoded release notes (from prepare) — checked non-blank" + description: "Base64-encoded release notes (from configure) — checked non-blank" + required: false + default: '' + already_published: + description: "Whether the version is already on PyPI (from configure). Judged by check_version_unpublished." required: false default: '' build_command: @@ -72,7 +63,7 @@ inputs: required: false default: 'true' check_version_unpublished: - description: "Fail if the version is already published to PyPI" + description: "Fail if the version is already published to PyPI (judges configure's already_published)" required: false default: 'true' check_notes_not_blank: @@ -119,54 +110,6 @@ runs: # Don't fail the job when there's nothing to cache (jobs that install no deps). ignore-nothing-to-cache: "true" - - name: Read version - id: read-version - shell: bash - env: - VERSION_OVERRIDE: ${{ inputs.version }} - VERSION_FILE: ${{ inputs.version_file }} - run: | - if [ -n "$VERSION_OVERRIDE" ]; then - VERSION="$VERSION_OVERRIDE" - else - # exec the version file in an isolated namespace (no package import needed), - # matching how the SDKs expose VERSION in a standalone version.py. - VERSION=$(uv run --no-project python -c "import runpy,sys; print(runpy.run_path(sys.argv[1])['VERSION'])" "$VERSION_FILE") - fi - if [ -z "$VERSION" ] || [ "$VERSION" = "None" ]; then - echo "::error title=Could not read version::resolved an empty version — check VERSION in $VERSION_FILE (or pass a version override)." - exit 1 - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - - name: Check PyPI version availability - if: ${{ inputs.check_version_unpublished == 'true' }} - shell: bash - env: - PACKAGE_NAME: ${{ inputs.package_name }} - VERSION: ${{ steps.read-version.outputs.version }} - run: | - if [ -z "$PACKAGE_NAME" ]; then - echo "No package_name provided; skipping PyPI availability check." - exit 0 - fi - CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ - --retry 3 --retry-connrefused --max-time 30 \ - "https://pypi.org/pypi/$PACKAGE_NAME/$VERSION/json") - case "$CODE" in - 200) - echo "::error title=Version already published::$PACKAGE_NAME $VERSION already exists on PyPI — has the version been bumped?" - exit 1 - ;; - 404) - echo "$PACKAGE_NAME $VERSION is available on PyPI." - ;; - *) - echo "::error title=Registry check failed::unexpected response from PyPI (HTTP $CODE) checking $PACKAGE_NAME $VERSION — cannot confirm the version is unpublished; aborting rather than risk a bad release." - exit 1 - ;; - esac - - name: Check release shell: bash env: @@ -177,7 +120,20 @@ runs: CHECK_TAG_UNUSED: ${{ inputs.check_tag_unused }} CHECK_NOTES: ${{ inputs.check_notes_not_blank }} ENFORCE_RELEASE_BRANCH: ${{ inputs.enforce_release_branch }} + CHECK_VERSION_UNPUBLISHED: ${{ inputs.check_version_unpublished }} + ALREADY_PUBLISHED: ${{ inputs.already_published }} run: | + # Version not already published on the registry (fact from configure's availability check; + # covers prereleases, which aren't git-tagged). Warn (not fail) on a dry run. + if [ "$CHECK_VERSION_UNPUBLISHED" = "true" ] && [ "$ALREADY_PUBLISHED" = "true" ]; then + if [ "$DRY_RUN" = "true" ]; then + echo "::warning title=Version already published::the version is already on the registry — skipping in dry run." + else + echo "::error title=Version already published::the version is already on the registry — has the version been bumped?" + exit 1 + fi + fi + # Tag not already used (covers stable releases; prereleases are covered by the availability check) if [ "$CHECK_TAG_UNUSED" = "true" ] && git rev-parse "$TAG" >/dev/null 2>&1; then if [ "$DRY_RUN" = "true" ]; then diff --git a/actions/release/lang/ruby/build-and-ship/action.yml b/actions/release/lang/ruby/build-and-ship/action.yml index 9810bef..fbe8469 100644 --- a/actions/release/lang/ruby/build-and-ship/action.yml +++ b/actions/release/lang/ruby/build-and-ship/action.yml @@ -63,13 +63,17 @@ inputs: description: "Whether the SHA is on an expected release branch" required: true pr_list: - description: "x1f-delimited PR list from prepare action" + description: "x1f-delimited PR list from configure" required: false default: '' gem_name: description: "RubyGems gem name — used to build the RubyGems link in notifications" required: false default: '' + already_published: + description: "Whether the version is already on RubyGems (from configure). When 'true', the push is skipped." + required: false + default: '' label: description: > Display name for release notifications (e.g. the gem name). When set, it becomes @@ -215,6 +219,9 @@ runs: if: ${{ inputs.dry_run != 'true' }} uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0 - name: Push gem to RubyGems + # Idempotency: skipped when the caller's already_published fact is 'true' (from configure). + # Empty (turnkey, no such fact) → runs. + if: ${{ inputs.already_published != 'true' }} shell: bash working-directory: ${{ inputs.working_directory }} env: @@ -260,11 +267,17 @@ runs: echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "(unavailable)" else echo "$NOTES_B64" | base64 -d 2>/dev/null > /tmp/release-notes.md - gh release create "$TAG" \ - --title "$TITLE" \ - --notes-file /tmp/release-notes.md \ - --target "$SHA" - # Attach the SBOM when one was generated for this release. + # Idempotent: a heal re-run of an already-shipped lane finds its tag/release already + # present — skip creation rather than hard-fail on the existing tag. + if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub release $TAG already exists; skipping creation." + else + gh release create "$TAG" \ + --title "$TITLE" \ + --notes-file /tmp/release-notes.md \ + --target "$SHA" + fi + # Attach the SBOM when one was generated for this release (--clobber → idempotent). if [ "$SBOM_ENABLED" = "true" ] && [ -f "$WORKING_DIRECTORY/sbom.json" ]; then gh release upload "$TAG" "$WORKING_DIRECTORY/sbom.json" --clobber fi @@ -294,6 +307,7 @@ runs: BRANCH: ${{ inputs.branch }} ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} DRY_RUN: ${{ inputs.dry_run }} + ALREADY_PUBLISHED: ${{ inputs.already_published }} LINKS_JSON: ${{ steps.links.outputs.links }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | @@ -308,6 +322,8 @@ runs: if [ "$DRY_RUN" = "true" ]; then echo "## $SUBJECT $TAG — dry run complete" >> $GITHUB_STEP_SUMMARY + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + echo "## $SUBJECT $TAG — already published, skipped ⏭️" >> $GITHUB_STEP_SUMMARY else echo "## $SUBJECT $TAG — published ✅" >> $GITHUB_STEP_SUMMARY fi @@ -317,6 +333,10 @@ runs: echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY echo "> Dry run: Nothing was tagged or published." >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY + echo "> This version was already published; this run published and tagged nothing." >> $GITHUB_STEP_SUMMARY + exit 0 fi if [ "$ON_RELEASE_BRANCH" = "false" ]; then @@ -355,6 +375,7 @@ runs: ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} PREV_RELEASE: ${{ inputs.prev_release }} DRY_RUN: ${{ inputs.dry_run }} + ALREADY_PUBLISHED: ${{ inputs.already_published }} SLACK_MENTION: ${{ inputs.slack_mention }} LINKS_JSON: ${{ steps.links.outputs.links }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -377,10 +398,18 @@ runs: if [ "$DRY_RUN" = "true" ]; then TEXT="$EMOJI *$SUBJECT $TAG* complete" TEXT="$TEXT\n> :information_source: _Dry run: nothing tagged or published._" + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + TEXT="$EMOJI *$SUBJECT $TAG* — already published, skipped" else TEXT="$EMOJI *$SUBJECT $TAG* published" fi + # A skip published/tagged nothing — keep the message to the one line, no branch/links/PRs. + if [ "$ALREADY_PUBLISHED" = "true" ]; then + { echo 'text<> $GITHUB_OUTPUT + exit 0 + fi + if [ "$ON_RELEASE_BRANCH" = "false" ]; then BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" TEXT="$TEXT\n> ⚠️ NOT on release branch: <$BRANCH_URL|$BRANCH>" diff --git a/actions/release/lang/ruby/configure/action.yml b/actions/release/lang/ruby/configure/action.yml index 741e436..2316685 100644 --- a/actions/release/lang/ruby/configure/action.yml +++ b/actions/release/lang/ruby/configure/action.yml @@ -2,13 +2,13 @@ # sdk-actions: {"family":"release","version":"1.0.0"} name: Configure Ruby Release description: > - Derives release facts for a Ruby gem — read-only, no token. Checks out the SHA, sets - up Ruby (a default the action guarantees itself — no ruby_version input) to read the - version, and computes the release tag, the branch (and whether it's a release - branch), the commit message, the previous release tag, and the github_release - default from release_type. Its outputs feed validate, request-approval, and - build-and-ship. Judging/checks live in the validate action; this action never fails - on release state. + Produces the release FACTS for a Ruby gem — the fact-finding half of the pipeline + (validate judges these; it never fails on release state). Checks out the SHA, sets up + Ruby, reads the version, computes the release tag/branch/commit/github_release, checks + RubyGems for whether the version is already published (→ already_published + + version_url), fetches release notes + PR list, and assembles the package manifest. + Requires contents: write on the calling job (for the generate-notes API). Its outputs + (incl. `package`) feed validate, request-approval, and ship. inputs: sha: @@ -50,6 +50,23 @@ inputs: description: "Comma-separated allowed release branch patterns (globs)." required: false default: 'main' + rubygems_package_name: + description: "RubyGems gem name. Enables the availability check + the manifest's rubygems registry entry." + required: false + default: '' + package_label: + description: "Display label raw material for notifications (presentation-agnostic; the renderer formats it)." + required: false + default: '' + emoji: + description: "Presentation emoji raw material for notifications." + required: false + default: '' + prev_release: + description: "Release-notes anchor override (tag or SHA). Empty → configure's derived previous tag." + required: false + default: '' + outputs: version: description: "The resolved gem version" @@ -72,6 +89,21 @@ outputs: github_release: description: "Whether a GitHub release should be created" value: ${{ steps.configure.outputs.github_release }} + already_published: + description: "Whether the version is already published to RubyGems" + value: ${{ steps.version-check.outputs.already_published }} + version_url: + description: "Human URL for the version on RubyGems" + value: ${{ steps.version-check.outputs.version_url }} + pr_list: + description: "Formatted PR list (x1f-delimited)" + value: ${{ steps.fetch.outputs.pr_list }} + notes: + description: "Base64-encoded release notes" + value: ${{ steps.fetch.outputs.notes }} + package: + description: "A JSON object describing the package to be published." + value: ${{ steps.build.outputs.package }} runs: using: composite @@ -81,10 +113,8 @@ runs: ref: ${{ inputs.sha }} fetch-depth: 0 - # configure reads the version (version.rb) — so it sets up a default Ruby itself. - # The requirement is guaranteed by the action (no ruby_version input); any Ruby reads - # the version, and the project's pinned toolchain is only needed to BUILD (validate / - # build-and-ship). Read-only: no bundler-cache / no bundle install. + # Read-only version read: a default Ruby reads version.rb; the project's pinned + # toolchain is only needed to BUILD (validate / ship). No bundle install. - name: Set up Ruby uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 with: @@ -167,6 +197,148 @@ runs: } >> "$GITHUB_OUTPUT" echo "Configured $TAG @ $VERSION (channel=$CHANNEL, github_release=$GHR, branch=$BRANCH, on_release_branch=$ON_RELEASE_BRANCH)" + - name: Check RubyGems version availability + id: version-check + shell: bash + env: + PACKAGE_NAME: ${{ inputs.rubygems_package_name }} + VERSION: ${{ steps.read-version.outputs.version }} + SEVERITY: off + run: | + if [ -z "$PACKAGE_NAME" ]; then + echo "No package_name provided; skipping RubyGems availability check." + exit 0 + fi + echo "version_url=https://rubygems.org/gems/$PACKAGE_NAME/versions/$VERSION" >> "$GITHUB_OUTPUT" + CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ + --retry 3 --retry-connrefused --max-time 30 \ + "https://rubygems.org/api/v2/rubygems/$PACKAGE_NAME/versions/$VERSION.json") + case "$CODE" in + 200) + echo "already_published=true" >> "$GITHUB_OUTPUT" + if [ "$SEVERITY" = "off" ]; then + echo "$PACKAGE_NAME $VERSION is already on RubyGems." + elif [ "$SEVERITY" = "warn" ]; then + echo "::warning title=Already published::$PACKAGE_NAME $VERSION is already on RubyGems." + else + echo "::error title=Version already published::$PACKAGE_NAME $VERSION already exists on RubyGems — has the version been bumped?" + exit 1 + fi + ;; + 404) + echo "already_published=false" >> "$GITHUB_OUTPUT" + echo "$PACKAGE_NAME $VERSION is available on RubyGems." + ;; + *) + echo "::error title=Registry check failed::unexpected response from RubyGems (HTTP $CODE) checking $PACKAGE_NAME $VERSION — cannot confirm the version is unpublished; aborting rather than risk a bad release." + exit 1 + ;; + esac + + - name: Fetch PR list and release notes + id: fetch + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.configure.outputs.release_tag }} + SHA: ${{ inputs.sha }} + PREV_RELEASE: ${{ inputs.prev_release || steps.configure.outputs.prev_release }} + run: | + # If prev_release is a full SHA, notes are unavailable. + if echo "$PREV_RELEASE" | grep -qE '^[0-9a-f]{40}$'; then + echo "SHA provided for prev_release — notes unavailable for SHA ranges." + echo "pr_list=" >> $GITHUB_OUTPUT + echo "notes=$(echo "" | base64 -w 0)" >> $GITHUB_OUTPUT + exit 0 + fi + + BODY=$(gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + --method POST \ + --field tag_name="$RELEASE_TAG" \ + --field target_commitish="$SHA" \ + ${PREV_RELEASE:+--field previous_tag_name="$PREV_RELEASE"} \ + --jq '.body' 2>/dev/null || echo "") + + PR_LIST=$(echo "$BODY" \ + | grep "^\* " \ + | grep -v "made their first contribution" \ + | grep -v "Full Changelog" \ + | head -10 \ + | sed 's|^\* ||' \ + | sed 's| by @[^ ]*||' \ + | sed 's@ in \(https://[^ ]*/pull/\([0-9]*\)\)@ (<\1|#\2>)@' \ + | sed 's/^/• /' \ + | tr '\n' $'\x1f' || echo "") + + echo "pr_list=$PR_LIST" >> $GITHUB_OUTPUT + echo "notes=$(echo "$BODY" | base64 -w 0)" >> $GITHUB_OUTPUT + + - name: Build package manifest entry + id: build + shell: bash + env: + LABEL: "${{ inputs.package_label }}" + EMOJI: "${{ inputs.emoji }}" + TAG: "${{ steps.configure.outputs.release_tag }}" + PREV_RELEASE: "${{ inputs.prev_release || steps.configure.outputs.prev_release }}" + PR_LIST: "${{ steps.fetch.outputs.pr_list }}" + NOTES: "${{ steps.fetch.outputs.notes }}" + GITHUB_RELEASE: "${{ steps.configure.outputs.github_release }}" + NPM_NAME: "" + NPM_VERSION: "" + NPM_CHANNEL: "" + NPM_VERSION_URL: "" + NPM_ALREADY_PUBLISHED: "" + PYPI_NAME: "" + PYPI_VERSION: "" + PYPI_VERSION_URL: "" + PYPI_ALREADY_PUBLISHED: "" + RUBYGEMS_NAME: "${{ inputs.rubygems_package_name }}" + RUBYGEMS_VERSION: "${{ steps.read-version.outputs.version }}" + RUBYGEMS_VERSION_URL: "${{ steps.version-check.outputs.version_url }}" + RUBYGEMS_ALREADY_PUBLISHED: "${{ steps.version-check.outputs.already_published }}" + run: | + set -euo pipefail + if ! command -v jq >/dev/null 2>&1; then + echo "::error title=jq not found::build-manifest requires jq." + exit 1 + fi + + REGISTRIES='{}' + # Add one registry entry (keyed by id) when its name/version are given. version_url is + # carried in (from the availability check); empties dropped; already_published → boolean. + add_registry() { + local reg="$1" name="$2" version="$3" channel="$4" ap="$5" url="$6" + { [ -z "$name" ] && [ -z "$version" ]; } && return 0 + local entry + entry="$(jq -nc \ + --arg name "$name" --arg version "$version" --arg url "$url" \ + --arg channel "$channel" --arg ap "$ap" \ + '{name:$name, version:$version, version_url:$url, channel:$channel} + | with_entries(select(.value != "")) + | if $ap=="true" then .already_published=true + elif $ap=="false" then .already_published=false else . end')" + REGISTRIES="$(jq -c --arg r "$reg" --argjson e "$entry" '. + {($r): $e}' <<<"$REGISTRIES")" + } + add_registry npm "$NPM_NAME" "$NPM_VERSION" "$NPM_CHANNEL" "$NPM_ALREADY_PUBLISHED" "$NPM_VERSION_URL" + add_registry pypi "$PYPI_NAME" "$PYPI_VERSION" "" "$PYPI_ALREADY_PUBLISHED" "$PYPI_VERSION_URL" + add_registry rubygems "$RUBYGEMS_NAME" "$RUBYGEMS_VERSION" "" "$RUBYGEMS_ALREADY_PUBLISHED" "$RUBYGEMS_VERSION_URL" + + # Package-level object; drop empties, then attach registries only when non-empty. + PACKAGE="$(jq -nc \ + --arg label "$LABEL" --arg emoji "$EMOJI" --arg tag "$TAG" \ + --arg prev_release "$PREV_RELEASE" --arg pr_list "$PR_LIST" --arg notes "$NOTES" \ + --arg github_release "$GITHUB_RELEASE" \ + '{label:$label, emoji:$emoji, tag:$tag, prev_release:$prev_release, + pr_list:$pr_list, notes:$notes, github_release:$github_release} + | with_entries(select(.value != ""))')" + if [ "$REGISTRIES" != "{}" ]; then + PACKAGE="$(jq -c --argjson r "$REGISTRIES" '. + {registries:$r}' <<<"$PACKAGE")" + fi + + echo "package=$PACKAGE" >> "$GITHUB_OUTPUT" + echo "Built package manifest entry: $(jq -c '{keys:keys, registries:(.registries // {} | keys)}' <<<"$PACKAGE")" + - name: Dump Ruby environment if: ${{ failure() }} shell: bash diff --git a/actions/release/lang/ruby/ship-package/action.yml b/actions/release/lang/ruby/ship-package/action.yml index 2feab13..f68a0f2 100644 --- a/actions/release/lang/ruby/ship-package/action.yml +++ b/actions/release/lang/ruby/ship-package/action.yml @@ -39,6 +39,14 @@ inputs: description: "RubyGems gem name — used to build the RubyGems link in notifications" required: false default: '' + version: + description: "Gem version being released — used to build the RubyGems link in notifications." + required: false + default: '' + already_published: + description: "Whether the version is already on RubyGems (from configure). When 'true', the push is skipped." + required: false + default: '' label: description: "Display name for release notifications (e.g. the gem name)." required: false @@ -58,7 +66,7 @@ inputs: description: "Whether the SHA is on an expected release branch" required: true pr_list: - description: "x1f-delimited PR list from prepare action" + description: "x1f-delimited PR list from configure" required: false default: '' slack_token: @@ -124,6 +132,9 @@ runs: if: ${{ inputs.dry_run != 'true' }} uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0 - name: Push gem to RubyGems + # Idempotency: skipped when the caller's already_published fact is 'true' (from configure). + # Empty (turnkey, no such fact) → runs. + if: ${{ inputs.already_published != 'true' }} shell: bash working-directory: ${{ inputs.working_directory }} env: @@ -169,11 +180,17 @@ runs: echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "(unavailable)" else echo "$NOTES_B64" | base64 -d 2>/dev/null > /tmp/release-notes.md - gh release create "$TAG" \ - --title "$TITLE" \ - --notes-file /tmp/release-notes.md \ - --target "$SHA" - # Attach the SBOM when one was generated for this release. + # Idempotent: a heal re-run of an already-shipped lane finds its tag/release already + # present — skip creation rather than hard-fail on the existing tag. + if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub release $TAG already exists; skipping creation." + else + gh release create "$TAG" \ + --title "$TITLE" \ + --notes-file /tmp/release-notes.md \ + --target "$SHA" + fi + # Attach the SBOM when one was generated for this release (--clobber → idempotent). if [ "$SBOM_ENABLED" = "true" ] && [ -f "$WORKING_DIRECTORY/sbom.json" ]; then gh release upload "$TAG" "$WORKING_DIRECTORY/sbom.json" --clobber fi @@ -203,6 +220,7 @@ runs: BRANCH: ${{ inputs.branch }} ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} DRY_RUN: ${{ inputs.dry_run }} + ALREADY_PUBLISHED: ${{ inputs.already_published }} LINKS_JSON: ${{ steps.links.outputs.links }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | @@ -217,6 +235,8 @@ runs: if [ "$DRY_RUN" = "true" ]; then echo "## $SUBJECT $TAG — dry run complete" >> $GITHUB_STEP_SUMMARY + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + echo "## $SUBJECT $TAG — already published, skipped ⏭️" >> $GITHUB_STEP_SUMMARY else echo "## $SUBJECT $TAG — published ✅" >> $GITHUB_STEP_SUMMARY fi @@ -226,6 +246,10 @@ runs: echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY echo "> Dry run: Nothing was tagged or published." >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY + echo "> This version was already published; this run published and tagged nothing." >> $GITHUB_STEP_SUMMARY + exit 0 fi if [ "$ON_RELEASE_BRANCH" = "false" ]; then @@ -264,6 +288,7 @@ runs: ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} PREV_RELEASE: ${{ inputs.prev_release }} DRY_RUN: ${{ inputs.dry_run }} + ALREADY_PUBLISHED: ${{ inputs.already_published }} SLACK_MENTION: ${{ inputs.slack_mention }} LINKS_JSON: ${{ steps.links.outputs.links }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -286,10 +311,18 @@ runs: if [ "$DRY_RUN" = "true" ]; then TEXT="$EMOJI *$SUBJECT $TAG* complete" TEXT="$TEXT\n> :information_source: _Dry run: nothing tagged or published._" + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + TEXT="$EMOJI *$SUBJECT $TAG* — already published, skipped" else TEXT="$EMOJI *$SUBJECT $TAG* published" fi + # A skip published/tagged nothing — keep the message to the one line, no branch/links/PRs. + if [ "$ALREADY_PUBLISHED" = "true" ]; then + { echo 'text<> $GITHUB_OUTPUT + exit 0 + fi + if [ "$ON_RELEASE_BRANCH" = "false" ]; then BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" TEXT="$TEXT\n> ⚠️ NOT on release branch: <$BRANCH_URL|$BRANCH>" diff --git a/actions/release/lang/ruby/validate/action.yml b/actions/release/lang/ruby/validate/action.yml index 60248dd..e2b95a8 100644 --- a/actions/release/lang/ruby/validate/action.yml +++ b/actions/release/lang/ruby/validate/action.yml @@ -6,28 +6,17 @@ description: > (tag unused, version unpublished on RubyGems, notes present, release branch) — and runs lint + a pre-gate build + CycloneDX SBOM generation so failures surface BEFORE the approval gate. Read-only: nothing is signed or published here. Every check is a - boolean toggle (default on) with an escape hatch. + boolean toggle (default on) with an escape hatch. Consumes `configure`'s + `already_published` fact (does not query RubyGems itself). inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Warn instead of failing on an already-existing tag" + description: "Warn instead of failing on an already-existing tag / already-published version" required: false default: 'false' - version: - description: "Version override (from configure). If empty, read from version_file/version_module." - required: false - default: '' - version_file: - description: "Path to the Ruby version file (e.g. lib/gem_name/version.rb)" - required: false - default: '' - version_module: - description: "Ruby module holding the VERSION constant (e.g. GemName)" - required: false - default: '' working_directory: description: "Path to the gem root (where Gemfile and gemspec live)" required: false @@ -36,13 +25,6 @@ inputs: description: "Ruby version to use, or a version file path (.tool-versions, .ruby-version)" required: false default: '4.0' - package_name: - description: > - RubyGems gem name. Used by the version-unpublished check — the registry, not the - git tag, is the source of truth (covers prereleases and force-deleted tags). Leave - empty to skip that check. - required: false - default: '' release_tag: description: "The release tag (from configure) — checked for prior existence" required: true @@ -51,7 +33,11 @@ inputs: required: false default: 'true' notes: - description: "Base64-encoded release notes (from prepare) — checked non-blank" + description: "Base64-encoded release notes (from configure) — checked non-blank" + required: false + default: '' + already_published: + description: "Whether the version is already on RubyGems (from configure). Judged by check_version_unpublished." required: false default: '' build: @@ -72,7 +58,7 @@ inputs: required: false default: 'true' check_version_unpublished: - description: "Fail if the version is already published to RubyGems" + description: "Fail if the version is already published to RubyGems (judges configure's already_published)" required: false default: 'true' check_notes_not_blank: @@ -99,53 +85,6 @@ runs: bundler-cache: true working-directory: ${{ inputs.working_directory }} - - name: Read version - id: read-version - shell: bash - env: - VERSION_OVERRIDE: ${{ inputs.version }} - VERSION_FILE: ${{ inputs.version_file }} - VERSION_MODULE: ${{ inputs.version_module }} - run: | - if [ -n "$VERSION_OVERRIDE" ]; then - VERSION="$VERSION_OVERRIDE" - else - VERSION=$(ruby -r "./$VERSION_FILE" -e "puts Object.const_get(ENV.fetch('VERSION_MODULE')).const_get(:VERSION)") - fi - if [ -z "$VERSION" ]; then - echo "::error title=Could not read version::resolved an empty version — check VERSION in $VERSION_FILE (module $VERSION_MODULE), or pass a version override." - exit 1 - fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Check RubyGems version availability - if: ${{ inputs.check_version_unpublished == 'true' }} - shell: bash - env: - PACKAGE_NAME: ${{ inputs.package_name }} - VERSION: ${{ steps.read-version.outputs.version }} - run: | - if [ -z "$PACKAGE_NAME" ]; then - echo "No package_name provided; skipping RubyGems availability check." - exit 0 - fi - CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ - --retry 3 --retry-connrefused --max-time 30 \ - "https://rubygems.org/api/v2/rubygems/$PACKAGE_NAME/versions/$VERSION.json") - case "$CODE" in - 200) - echo "::error title=Version already published::$PACKAGE_NAME $VERSION already exists on RubyGems — has the version been bumped?" - exit 1 - ;; - 404) - echo "$PACKAGE_NAME $VERSION is available on RubyGems." - ;; - *) - echo "::error title=Registry check failed::unexpected response from RubyGems (HTTP $CODE) checking $PACKAGE_NAME $VERSION — cannot confirm the version is unpublished; aborting rather than risk a bad release." - exit 1 - ;; - esac - - name: Check release shell: bash env: @@ -156,7 +95,20 @@ runs: CHECK_TAG_UNUSED: ${{ inputs.check_tag_unused }} CHECK_NOTES: ${{ inputs.check_notes_not_blank }} ENFORCE_RELEASE_BRANCH: ${{ inputs.enforce_release_branch }} + CHECK_VERSION_UNPUBLISHED: ${{ inputs.check_version_unpublished }} + ALREADY_PUBLISHED: ${{ inputs.already_published }} run: | + # Version not already published on the registry (fact from configure's availability check; + # covers prereleases, which aren't git-tagged). Warn (not fail) on a dry run. + if [ "$CHECK_VERSION_UNPUBLISHED" = "true" ] && [ "$ALREADY_PUBLISHED" = "true" ]; then + if [ "$DRY_RUN" = "true" ]; then + echo "::warning title=Version already published::the version is already on the registry — skipping in dry run." + else + echo "::error title=Version already published::the version is already on the registry — has the version been bumped?" + exit 1 + fi + fi + # Tag not already used (covers stable releases; prereleases are covered by the availability check) if [ "$CHECK_TAG_UNUSED" = "true" ] && git rev-parse "$TAG" >/dev/null 2>&1; then if [ "$DRY_RUN" = "true" ]; then diff --git a/actions/release/prepare/action.yml b/actions/release/prepare/action.yml deleted file mode 100644 index 1d792df..0000000 --- a/actions/release/prepare/action.yml +++ /dev/null @@ -1,69 +0,0 @@ -# GENERATED by scripts/generate.rb — edit templates/, not this file. -# sdk-actions: {"family":"release","version":"1.0.0"} -name: Prepare Release -description: > - Fetches release notes and formats the PR list for notifications. - Requires contents:write permission on the calling job (for generate-notes API). - -inputs: - release_tag: - description: "The release tag (e.g. v0.3.2)" - required: true - sha: - description: "The commit SHA being released" - required: true - prev_release: - description: > - Anchor for release notes. Accepts a tag name or a full commit SHA. - If a tag: generates notes via the GitHub API using it as the previous release. - If a SHA: notes are unavailable (no reliable cross-strategy PR extraction); - outputs will be empty. If omitted, GitHub auto-detects the previous release. - required: false - default: '' - -outputs: - pr_list: - description: "Formatted PR list (x1f-delimited for safe job output passing)" - value: ${{ steps.fetch.outputs.pr_list }} - notes: - description: "Base64-encoded full release notes body" - value: ${{ steps.fetch.outputs.notes }} - -runs: - using: composite - steps: - - name: Fetch PR list and release notes - id: fetch - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PREV_RELEASE: ${{ inputs.prev_release }} - run: | - # If prev_release is a full SHA, notes are unavailable - if echo "$PREV_RELEASE" | grep -qE '^[0-9a-f]{40}$'; then - echo "SHA provided for prev_release — notes unavailable for SHA ranges." - echo "pr_list=" >> $GITHUB_OUTPUT - echo "notes=$(echo "" | base64 -w 0)" >> $GITHUB_OUTPUT - exit 0 - fi - - BODY=$(gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ - --method POST \ - --field tag_name="${{ inputs.release_tag }}" \ - --field target_commitish="${{ inputs.sha }}" \ - ${PREV_RELEASE:+--field previous_tag_name="$PREV_RELEASE"} \ - --jq '.body' 2>/dev/null || echo "") - - PR_LIST=$(echo "$BODY" \ - | grep "^\* " \ - | grep -v "made their first contribution" \ - | grep -v "Full Changelog" \ - | head -10 \ - | sed 's|^\* ||' \ - | sed 's| by @[^ ]*||' \ - | sed 's@ in \(https://[^ ]*/pull/\([0-9]*\)\)@ (<\1|#\2>)@' \ - | sed 's/^/• /' \ - | tr '\n' $'\x1f' || echo "") - - echo "pr_list=$PR_LIST" >> $GITHUB_OUTPUT - echo "notes=$(echo "$BODY" | base64 -w 0)" >> $GITHUB_OUTPUT diff --git a/actions/release/request-approval/action.yml b/actions/release/request-approval/action.yml index 27dbbff..5fe1f65 100644 --- a/actions/release/request-approval/action.yml +++ b/actions/release/request-approval/action.yml @@ -2,23 +2,25 @@ # sdk-actions: {"family":"release","version":"1.0.0"} name: Request Release Approval description: > - Issues the release approval request: posts the pre-approval job summary and Slack - notification. Runs before the environment gate on ship — reviewers see this and then - approve the gated ship/ship-package job. The job goes green the moment the request is - issued (the actual gate is the `environment:` on the ship job), so a green - request-approval is truthful and the ship job is visibly parked at the gate. + Issues the release approval request for one or more packages: posts the pre-approval job + summary and Slack notification, then the gated ship job(s) park at the environment gate for + reviewers. A green request-approval is truthful — the real gate is the `environment:` on the + ship job(s). + + One input surface: `packages`, a release manifest of the form {"packages": [ , … ]} + (the same envelope create-package-github-releases and publish-npm-tarballs consume). Build + entries with the build-package-manifest action — or by hand for a bare workflow that has no + configure step. Each entry describes one package: + label, emoji, tag, prev_release, pr_list (x1f), notes (base64) or release_body (plain), + github_release, and optional registries..{name, version, version_url, + channel, already_published}. + A package whose registries are ALL already_published renders as "will NOT be published". inputs: + # ── shared (whole release) ── sha: description: "Commit SHA being released" required: true - release_tag: - description: "The release tag (e.g. v0.3.2)" - required: true - prev_release: - description: "Previous release tag" - required: false - default: '' branch: description: "Branch containing the SHA" required: true @@ -28,18 +30,20 @@ inputs: commit_message: description: "Commit message at the SHA" required: true - pr_list: - description: "x1f-delimited PR list from prepare action" - required: false - default: '' - notes: - description: "Base64-encoded release notes from prepare action" - required: false - default: '' dry_run: description: "Whether this is a dry run" required: false default: 'false' + title: + description: > + Overall subject for the approval (e.g. the repo or a release name). Defaults to the + single package's label when there is one package, else the repository. + required: false + default: '' + emoji: + description: "Default emoji prefix (a package entry may override its own)" + required: false + default: ':package:' slack_token: description: "Slack bot token for posting messages" required: false @@ -49,100 +53,153 @@ inputs: required: false default: '' slack_mention: - description: "Optional Slack handle or group to @mention in the approval request (e.g. @sdk-eng)" + description: "Optional Slack handle or group to @mention in the approval request" required: false default: '' - emoji: - description: "Emoji prefix for Slack messages" - required: false - default: ':package:' - label: + packages: description: > - Display name for the thing being released (e.g. the package name). When set, - it becomes the notification subject instead of the repository — useful when a - repo publishes multiple packages. Defaults to the repository. - required: false - default: '' + Release manifest: a JSON object {"packages": [ , … ]}, one entry per package. + Build entries with the build-package-manifest action (or by hand). Each entry: + label, emoji, tag, prev_release, pr_list (x1f), notes (base64) or release_body (plain), + github_release, and optional registries..{name, version, version_url, + channel, already_published}. A bare array or any other shape is rejected. + required: true runs: using: composite steps: + # Validate the manifest and derive the approval subject. The sole input surface is a + # {"packages":[…]} object; anything else (a bare array, an object without .packages, a + # scalar, malformed JSON) is a caller error — fail loudly rather than release nothing. + - name: Normalize packages + id: manifest + shell: bash + env: + PACKAGES: ${{ inputs.packages }} + TITLE: ${{ inputs.title }} + run: | + set -euo pipefail + if ! M="$(jq -ce 'if (type=="object" and (.packages | type=="array")) then {packages: .packages} else empty end' <<<"$PACKAGES")"; then + echo "::error title=Invalid packages input::\`packages\` must be a JSON object of the form {\"packages\":[…]}. Got: $PACKAGES" + exit 1 + fi + + COUNT="$(jq '.packages | length' <<<"$M")" + if [ "$COUNT" = "0" ]; then + echo "::error title=Empty release manifest::\`packages\` contains no entries — there is nothing to approve." + exit 1 + fi + if [ -n "$TITLE" ]; then + SUBJECT="$TITLE" + elif [ "$COUNT" = "1" ]; then + SUBJECT="$(jq -r '.packages[0] | .label // .name // .tag // ""' <<<"$M")" + [ -z "$SUBJECT" ] && SUBJECT="$GITHUB_REPOSITORY" + else + SUBJECT="$GITHUB_REPOSITORY" + fi + # Job outputs are single-line; normalize any title/label newlines before writing. + SUBJECT="${SUBJECT//$'\n'/ }" + SUBJECT="${SUBJECT//$'\r'/ }" + { + echo "manifest=$M" + echo "subject=$SUBJECT" + } >> "$GITHUB_OUTPUT" + - name: Post release summary shell: bash env: - TAG: ${{ inputs.release_tag }} - LABEL: ${{ inputs.label }} + MANIFEST_JSON: ${{ steps.manifest.outputs.manifest }} + SUBJECT: ${{ steps.manifest.outputs.subject }} + DEFAULT_EMOJI: ${{ inputs.emoji }} COMMIT_MSG: ${{ inputs.commit_message }} - NOTES_B64: ${{ inputs.notes }} BRANCH: ${{ inputs.branch }} SHA: ${{ inputs.sha }} - PREV_RELEASE: ${{ inputs.prev_release }} ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} DRY_RUN: ${{ inputs.dry_run }} ACTOR: ${{ github.actor }} run: | - NOTES=$(echo "$NOTES_B64" | base64 -d 2>/dev/null) - if [ -z "$NOTES" ]; then NOTES="_Release notes unavailable._"; fi - - BRANCH_LABEL="[$BRANCH]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH)" - if [ "$ON_RELEASE_BRANCH" = "false" ]; then - BRANCH_LABEL="$BRANCH_LABEL ⚠️" - fi - - # Subject is the package label when set, else the repository. The repo - # remains in every link below, so it is never lost. - SUBJECT="$GITHUB_REPOSITORY" - [ -n "$LABEL" ] && SUBJECT="$LABEL" - - echo "## $SUBJECT $TAG" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ "$DRY_RUN" = "true" ]; then - echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY - echo "> Dry run: Nothing will be tagged or published." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - fi - - if [ "$ON_RELEASE_BRANCH" = "false" ]; then - echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY - echo "> Release SHA is not on an expected release branch. Is this intentional?" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - fi + set -euo pipefail + # A package is "skipped" when it has registries and ALL of them are already published. + SKIPPED_FILTER='(.registries // {}) as $r | (($r | length) > 0) and (all($r[]; .already_published == true))' + SKIPPED_LABELS="$(jq -r "[.packages[]? | select($SKIPPED_FILTER) | (.label // .tag // \"package\")] | join(\", \")" <<<"$MANIFEST_JSON")" + { + echo "## $SUBJECT" + echo "" + if [ "$DRY_RUN" = "true" ]; then + echo "> [!NOTE]" + echo "> Dry run: Nothing will be tagged or published." + echo "" + fi + if [ -n "$SKIPPED_LABELS" ]; then + echo "> [!WARNING]" + echo "> Already published — will be SKIPPED (not re-published): $SKIPPED_LABELS" + echo "" + fi + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + echo "> [!WARNING]" + echo "> Release SHA is not on an expected release branch. Is this intentional?" + echo "" + fi + BRANCH_LABEL="[$BRANCH]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH)" + [ "$ON_RELEASE_BRANCH" = "false" ] && BRANCH_LABEL="$BRANCH_LABEL ⚠️" + echo "**SHA:** [$SHA]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$SHA)" + echo "**Commit:** $COMMIT_MSG" + echo "**Branch:** $BRANCH_LABEL" + echo "**Initiated by:** [$ACTOR]($GITHUB_SERVER_URL/$ACTOR)" + echo "" + } >> "$GITHUB_STEP_SUMMARY" - echo "**SHA:** [$SHA]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$SHA)" >> $GITHUB_STEP_SUMMARY - echo "**Commit:** $COMMIT_MSG" >> $GITHUB_STEP_SUMMARY - echo "**Branch:** $BRANCH_LABEL" >> $GITHUB_STEP_SUMMARY - echo "**Initiated by:** [$ACTOR]($GITHUB_SERVER_URL/$ACTOR)" >> $GITHUB_STEP_SUMMARY - - if [ -n "$PREV_RELEASE" ]; then - DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...${SHA}" - echo "**Diff:** [${PREV_RELEASE}...$TAG]($DIFF_URL)" >> $GITHUB_STEP_SUMMARY - fi - - echo "" >> $GITHUB_STEP_SUMMARY - echo "$NOTES" >> $GITHUB_STEP_SUMMARY + while IFS= read -r encoded; do + p="$(printf '%s' "$encoded" | base64 -d)" + label="$(jq -r '.label // .tag // ""' <<<"$p")" + tag="$(jq -r '.tag // ""' <<<"$p")" + emoji="$(jq -r --arg d "$DEFAULT_EMOJI" '.emoji // $d' <<<"$p")" + skipped="$(jq -r "$SKIPPED_FILTER" <<<"$p")" + if [ "$skipped" = "true" ]; then + # Simplified: this version is already published, so it will NOT be published. + { + echo "### ⏭️ $label${tag:+ $tag} — already published, will NOT be published" + jq -r '.registries | to_entries[] + | "- **\(.key):** \(.value.name // "")\((.value.version // "") | if . != "" then " " + . else "" end)\((.value.version_url // "") | if . != "" then " — [published](" + . + ")" else "" end)"' <<<"$p" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + else + prev="$(jq -r '.prev_release // ""' <<<"$p")" + notes_b64="$(jq -r '.notes // ""' <<<"$p")" + if [ -n "$notes_b64" ]; then + NOTES="$(printf '%s' "$notes_b64" | base64 -d 2>/dev/null || true)" + else + NOTES="$(jq -r '.release_body // ""' <<<"$p")" + fi + { + echo "### $emoji $label${tag:+ $tag}" + if [ -n "$prev" ]; then + echo "**Diff:** [${prev}...${tag:-$SHA}]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${prev}...${SHA})" + fi + echo "" + echo "${NOTES:-_Release notes unavailable._}" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + fi + done < <(jq -r '.packages[]? | @base64' <<<"$MANIFEST_JSON") - name: Build pending message id: message-pending shell: bash env: - TAG: ${{ inputs.release_tag }} - LABEL: ${{ inputs.label }} - EMOJI: ${{ inputs.emoji }} + MANIFEST_JSON: ${{ steps.manifest.outputs.manifest }} + SUBJECT: ${{ steps.manifest.outputs.subject }} + DEFAULT_EMOJI: ${{ inputs.emoji }} BRANCH: ${{ inputs.branch }} ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} - PREV_RELEASE: ${{ inputs.prev_release }} SHA: ${{ inputs.sha }} DRY_RUN: ${{ inputs.dry_run }} SLACK_MENTION: ${{ inputs.slack_mention }} APPROVE_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} ACTOR_LINK: <${{ github.server_url }}/${{ github.actor }}|${{ github.actor }}> - PR_LIST_RAW: ${{ inputs.pr_list }} run: | - # Subject is the package label when set, else the repository. - SUBJECT="$GITHUB_REPOSITORY" - [ -n "$LABEL" ] && SUBJECT="$LABEL" - + set -euo pipefail + SKIPPED_FILTER='(.registries // {}) as $r | (($r | length) > 0) and (all($r[]; .already_published == true))' BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" BRANCH_LINK="<$BRANCH_URL|$BRANCH>" if [ "$ON_RELEASE_BRANCH" = "false" ]; then @@ -151,37 +208,49 @@ runs: BRANCH_INFO="$BRANCH_LINK" fi - DIFF_PART="" - if [ -n "$PREV_RELEASE" ]; then - DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...${SHA}" - DIFF_PART=" · <$DIFF_URL|${PREV_RELEASE}...$TAG>" - fi - - PR_LIST=$(echo "$PR_LIST_RAW" | tr $'\x1f' '\n' | sed '/^$/d') - - TEXT="$EMOJI *$SUBJECT $TAG* awaiting approval" + TEXT="$DEFAULT_EMOJI *$SUBJECT* awaiting approval" TEXT="$TEXT\nInitiated by: $ACTOR_LINK" - if [ "$DRY_RUN" = "true" ]; then TEXT="$TEXT\n> :information_source: _Dry run: nothing will be tagged or published._" fi + SKIPPED_LABELS="$(jq -r "[.packages[]? | select($SKIPPED_FILTER) | (.label // .tag // \"package\")] | join(\", \")" <<<"$MANIFEST_JSON")" + [ -n "$SKIPPED_LABELS" ] && TEXT="$TEXT\n> ⚠️ Already published, will be SKIPPED: $SKIPPED_LABELS" + TEXT="$TEXT\n${BRANCH_INFO}" - TEXT="$TEXT\n${BRANCH_INFO}${DIFF_PART}" - - if [ -n "$PR_LIST" ]; then - TEXT="$TEXT\n$PR_LIST" - fi + while IFS= read -r encoded; do + p="$(printf '%s' "$encoded" | base64 -d)" + label="$(jq -r '.label // .tag // ""' <<<"$p")" + tag="$(jq -r '.tag // ""' <<<"$p")" + emoji="$(jq -r --arg d "$DEFAULT_EMOJI" '.emoji // $d' <<<"$p")" + skipped="$(jq -r "$SKIPPED_FILTER" <<<"$p")" + if [ "$skipped" = "true" ]; then + LINE="⏭️ *$label${tag:+ $tag}* — already published, will NOT be published" + REG="$(jq -r '[.registries | to_entries[] | if (.value.version_url // "") != "" then "<\(.value.version_url)|\(.key)>" else .key end] | join(", ")' <<<"$p")" + [ -n "$REG" ] && LINE="$LINE · $REG" + TEXT="$TEXT\n$LINE" + else + prev="$(jq -r '.prev_release // ""' <<<"$p")" + pr_raw="$(jq -r '.pr_list // ""' <<<"$p")" + LINE="$emoji *$label${tag:+ $tag}*" + if [ -n "$prev" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${prev}...${SHA}" + LINE="$LINE · <$DIFF_URL|${prev}...${tag:-$SHA}>" + fi + TEXT="$TEXT\n$LINE" + PR_LIST="$(printf '%s' "$pr_raw" | tr $'\x1f' '\n' | sed '/^$/d')" + [ -n "$PR_LIST" ] && TEXT="$TEXT\n$PR_LIST" + fi + done < <(jq -r '.packages[]? | @base64' <<<"$MANIFEST_JSON") if [ -n "$SLACK_MENTION" ]; then TEXT="$TEXT\n$SLACK_MENTION please review and approve." fi - TEXT="$TEXT\n<$APPROVE_URL|View changes & approve>" { echo 'text<> $GITHUB_OUTPUT + } >> "$GITHUB_OUTPUT" - name: Send Slack message shell: bash diff --git a/templates/actions/release/build-package-manifest.yml.erb b/templates/actions/release/build-package-manifest.yml.erb new file mode 100644 index 0000000..0b35af1 --- /dev/null +++ b/templates/actions/release/build-package-manifest.yml.erb @@ -0,0 +1,112 @@ +name: Build Package Manifest +description: > + Formats one package's release facts into a single, well-formed package manifest entry + (compact JSON) via the shared release/build-manifest step. Multi-package workflows call + this once per package (label/emoji at the call site) and splice the entries into + request-approval's `packages`. Package-level facts (label, emoji, tag, prev_release, + pr_list, notes, github_release) + per-registry facts under a prefix (npm_/pypi_/ + rubygems_) → nested `registries.`; one call may carry several registries. Empties + dropped. `pr_list` (x1f) and `notes` (base64) are JSON-escaped, so entries splice into + a JSON array via GitHub expression interpolation with no further shell/jq. + +inputs: + label: + description: "Display label for notifications (defaults, downstream, to a registry name / tag)" + required: false + default: '' + emoji: + description: "Presentation emoji for notifications" + required: false + default: '' + tag: + description: "Release tag (e.g. js-1.2.3)" + required: false + default: '' + prev_release: + description: "Previous release tag (for the diff link)" + required: false + default: '' + pr_list: + description: "x1f-delimited PR list (from configure)" + required: false + default: '' + notes: + description: "Base64-encoded release notes (from configure)" + required: false + default: '' + github_release: + description: "Whether a GitHub release should be created for this package" + required: false + default: '' + npm_name: + description: "npm: package name (presence adds an npm registry entry)" + required: false + default: '' + npm_version: + description: "npm: version" + required: false + default: '' + npm_channel: + description: "npm: dist-tag / channel" + required: false + default: '' + npm_version_url: + description: "npm: human URL for the version (from validate/configure)" + required: false + default: '' + npm_already_published: + description: "npm: 'true'/'false' — is this version already on npm" + required: false + default: '' + pypi_name: + description: "PyPI: package name (presence adds a pypi registry entry)" + required: false + default: '' + pypi_version: + description: "PyPI: version" + required: false + default: '' + pypi_version_url: + description: "PyPI: human URL for the version" + required: false + default: '' + pypi_already_published: + description: "PyPI: 'true'/'false' — is this version already on PyPI" + required: false + default: '' + rubygems_name: + description: "RubyGems: gem name (presence adds a rubygems registry entry)" + required: false + default: '' + rubygems_version: + description: "RubyGems: version" + required: false + default: '' + rubygems_version_url: + description: "RubyGems: human URL for the version" + required: false + default: '' + rubygems_already_published: + description: "RubyGems: 'true'/'false' — is this version already on RubyGems" + required: false + default: '' + +outputs: + package: + description: "One package manifest entry as compact JSON (only the provided fields)" + value: ${{ steps.build.outputs.package }} + +runs: + using: composite + steps: +<%= render_step('release/build-manifest', + label: "${{ inputs.label }}", emoji: "${{ inputs.emoji }}", tag: "${{ inputs.tag }}", + prev_release: "${{ inputs.prev_release }}", pr_list: "${{ inputs.pr_list }}", + notes: "${{ inputs.notes }}", github_release: "${{ inputs.github_release }}", + npm_name: "${{ inputs.npm_name }}", npm_version: "${{ inputs.npm_version }}", + npm_channel: "${{ inputs.npm_channel }}", npm_version_url: "${{ inputs.npm_version_url }}", + npm_already_published: "${{ inputs.npm_already_published }}", + pypi_name: "${{ inputs.pypi_name }}", pypi_version: "${{ inputs.pypi_version }}", + pypi_version_url: "${{ inputs.pypi_version_url }}", pypi_already_published: "${{ inputs.pypi_already_published }}", + rubygems_name: "${{ inputs.rubygems_name }}", rubygems_version: "${{ inputs.rubygems_version }}", + rubygems_version_url: "${{ inputs.rubygems_version_url }}", rubygems_already_published: "${{ inputs.rubygems_already_published }}") %> diff --git a/templates/actions/release/lang/js/build-and-ship.yml.erb b/templates/actions/release/lang/js/build-and-ship.yml.erb index 862a650..244f4b0 100644 --- a/templates/actions/release/lang/js/build-and-ship.yml.erb +++ b/templates/actions/release/lang/js/build-and-ship.yml.erb @@ -100,13 +100,17 @@ inputs: description: "Whether the SHA is on an expected release branch" required: true pr_list: - description: "x1f-delimited PR list from prepare action" + description: "x1f-delimited PR list from configure" required: false default: '' package_name: description: "npm package name — used to build the npm link in notifications" required: false default: '' + already_published: + description: "Whether the version is already on npm (from configure). When 'true', the publish is skipped." + required: false + default: '' label: description: > Display name for release notifications (e.g. the package name). When set, it @@ -152,7 +156,8 @@ runs: <%= render_step('lang/js/pnpm/attest-sbom', if: "${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}") %> -<%= render_step('lang/js/npm/publish') %> +<%# Idempotent: skip the publish when configure already saw this version on npm. -%> +<%= render_step('lang/js/npm/publish', if: "${{ inputs.already_published != 'true' }}") %> <%= render_step('release/create-github-release') %> diff --git a/templates/actions/release/lang/js/configure.yml.erb b/templates/actions/release/lang/js/configure.yml.erb index 1e48eb3..68464a8 100644 --- a/templates/actions/release/lang/js/configure.yml.erb +++ b/templates/actions/release/lang/js/configure.yml.erb @@ -1,11 +1,12 @@ name: Configure JavaScript Release description: > - Derives release facts for a JavaScript package — read-only, no token. Checks out the - SHA, sets up Node and pnpm, reads the package version, and computes the release tag, - the branch (and whether it's a release branch), the commit message, the previous - release tag, and the channel + github_release defaults from release_type (explicit - overrides win). Its outputs feed validate, request-approval, and build-and-ship. - Judging/checks live in the validate action; this action never fails on release state. + Produces the release FACTS for a JavaScript package — the fact-finding half of the + pipeline (validate judges these; it never fails on release state). Checks out the SHA, + sets up Node, reads the version, computes the release tag/branch/commit/channel/ + github_release, checks npm for whether the version is already published (→ + already_published + version_url), fetches release notes + PR list, and assembles the + package manifest. Requires contents: write on the calling job (for the generate-notes + API). Its outputs (incl. `package`) feed validate, request-approval, and ship. inputs: sha: @@ -32,7 +33,7 @@ inputs: inputs override this. required: false default: 'stable' - channel: + npm_channel: description: "Explicit npm channel (dist-tag) override. Empty → derived from release_type." required: false default: '' @@ -44,6 +45,27 @@ inputs: description: "Comma-separated allowed release branch patterns (globs)." required: false default: 'main' + npm_registry: + description: "npm registry URL (for the availability check)." + required: false + default: 'https://registry.npmjs.org' + npm_package_name: + description: "npm package name. Enables the availability check + the manifest's npm registry entry." + required: false + default: '' + package_label: + description: "Display label raw material for notifications (presentation-agnostic; the renderer formats it)." + required: false + default: '' + emoji: + description: "Presentation emoji raw material for notifications." + required: false + default: '' + prev_release: + description: "Release-notes anchor override (tag or SHA). Empty → configure's derived previous tag." + required: false + default: '' + outputs: version: description: "The resolved package version" @@ -69,6 +91,21 @@ outputs: github_release: description: "Whether a GitHub release should be created" value: ${{ steps.configure.outputs.github_release }} + already_published: + description: "Whether the version is already published to npm" + value: ${{ steps.version-check.outputs.already_published }} + version_url: + description: "Human URL for the version on npm" + value: ${{ steps.version-check.outputs.version_url }} + pr_list: + description: "Formatted PR list (x1f-delimited)" + value: ${{ steps.fetch.outputs.pr_list }} + notes: + description: "Base64-encoded release notes" + value: ${{ steps.fetch.outputs.notes }} + package: + description: "A JSON object describing the package to be published." + value: ${{ steps.build.outputs.package }} runs: using: composite @@ -78,11 +115,8 @@ runs: ref: ${{ inputs.sha }} fetch-depth: 0 - # configure reads package.json — so it sets up a default Node itself. The - # requirement is satisfied by the action (no node_version input, no consumer - # config): any Node reads the version, and the project's pinned toolchain is only - # needed to BUILD (validate / build-and-ship). A node-runtime read never touches - # engines/devEngines, so this is safe in strict-pnpm repos. No pnpm, no install. + # Read-only version read: any Node reads package.json; the project's pinned toolchain + # is only needed to BUILD (validate / ship). Safe in strict-pnpm repos. No pnpm/install. - name: Set up Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: @@ -96,7 +130,35 @@ runs: tag_format: "${{ inputs.tag_format }}", release_branch: "${{ inputs.release_branch }}", release_type: "${{ inputs.release_type }}", - channel: "${{ inputs.channel }}", + channel: "${{ inputs.npm_channel }}", github_release: "${{ inputs.github_release }}") %> +<%# Fact: is this version already on npm? (severity off — validate judges it, this never fails). -%> +<%= render_step('lang/js/npm/availability', + package_name: "${{ inputs.npm_package_name }}", + version: "${{ steps.read-version.outputs.version }}", + registry: "${{ inputs.npm_registry }}", + severity: 'off') %> + +<%# Fact: release notes + PR list (needs contents: write on the job). -%> +<%= render_step('release/prepare-notes', + release_tag: "${{ steps.configure.outputs.release_tag }}", + sha: "${{ inputs.sha }}", + prev_release: "${{ inputs.prev_release || steps.configure.outputs.prev_release }}") %> + +<%# Assemble the package object (single home of the schema). -%> +<%= render_step('release/build-manifest', + label: "${{ inputs.package_label }}", + emoji: "${{ inputs.emoji }}", + tag: "${{ steps.configure.outputs.release_tag }}", + prev_release: "${{ inputs.prev_release || steps.configure.outputs.prev_release }}", + pr_list: "${{ steps.fetch.outputs.pr_list }}", + notes: "${{ steps.fetch.outputs.notes }}", + github_release: "${{ steps.configure.outputs.github_release }}", + npm_name: "${{ inputs.npm_package_name }}", + npm_version: "${{ steps.read-version.outputs.version }}", + npm_channel: "${{ steps.configure.outputs.channel }}", + npm_version_url: "${{ steps.version-check.outputs.version_url }}", + npm_already_published: "${{ steps.version-check.outputs.already_published }}") %> + <%= render_step('lang/js/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/js/pack-pnpm.yml.erb b/templates/actions/release/lang/js/pack-pnpm.yml.erb index aa1cd3c..806e499 100644 --- a/templates/actions/release/lang/js/pack-pnpm.yml.erb +++ b/templates/actions/release/lang/js/pack-pnpm.yml.erb @@ -69,6 +69,28 @@ runs: <%= render_step('lang/js/pnpm/build', if: "${{ inputs.build == 'true' }}") %> + - name: Resolve packaging pnpm + # pack + sbom need pnpm >= 11.8, but install/build above use the project's own pnpm — which may + # pin an OLDER pnpm (packageManager) to honor its supply-chain config. Resolve the packaging + # pnpm here and export $PACK_PNPM for those two steps: the project's pnpm when it already + # satisfies >= 11.8, else a pinned one via corepack. --pm-on-fail=ignore only bypasses pnpm's + # packageManager version-mismatch refusal; the pack/sbom logic is the standard logic for that + # version, so the attested tarball bytes + SBOM are exactly what a same-version job produces. + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + PINNED: '11.9.0' # sdk-actions-tested pnpm for pack + sbom (>= 11.8); bump centrally as pnpm advances + COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' + run: | + CUR=$(pnpm --version 2>/dev/null || echo 0) + if printf '11.8.0\n%s\n' "$CUR" | sort -VC; then + echo "PACK_PNPM=pnpm" >> "$GITHUB_ENV" + echo "Packaging with the project's pnpm $CUR (>= 11.8)" + else + echo "PACK_PNPM=corepack pnpm@$PINNED --pm-on-fail=ignore" >> "$GITHUB_ENV" + echo "Project pnpm $CUR is < 11.8 — packaging with pinned pnpm $PINNED" + fi + <%= render_step('lang/js/pnpm/pack') %> <%= render_step('release/lang/js/validate-package') %> diff --git a/templates/actions/release/lang/js/ship-package.yml.erb b/templates/actions/release/lang/js/ship-package.yml.erb index 8776104..f7aeec9 100644 --- a/templates/actions/release/lang/js/ship-package.yml.erb +++ b/templates/actions/release/lang/js/ship-package.yml.erb @@ -71,6 +71,10 @@ inputs: description: "npm package name — used to build the npm link in notifications" required: false default: '' + already_published: + description: "Whether the version is already on npm (from configure). When 'true', the publish is skipped." + required: false + default: '' label: description: "Display name for release notifications (e.g. the package name)." required: false @@ -90,7 +94,7 @@ inputs: description: "Whether the SHA is on an expected release branch" required: true pr_list: - description: "x1f-delimited PR list from prepare action" + description: "x1f-delimited PR list from configure" required: false default: '' slack_token: @@ -125,7 +129,12 @@ runs: <%= render_step('lang/js/npm/setup') %> -<%= render_step('lang/js/npm/publish-package', tarball: "${{ inputs.tarball }}") %> +<%# Idempotency: gate the publish on the already_published FACT produced by configure (one + registry check, upstream — ship never re-queries, so it can't drift from validate/approval). + An already-published version is skipped (the step shows as skipped). -%> +<%= render_step('lang/js/npm/publish-package', + if: "${{ inputs.already_published != 'true' }}", + tarball: "${{ inputs.tarball }}") %> <%= render_step('release/create-github-release') %> diff --git a/templates/actions/release/lang/js/validate.yml.erb b/templates/actions/release/lang/js/validate.yml.erb index 0579a2a..ee8e6d9 100644 --- a/templates/actions/release/lang/js/validate.yml.erb +++ b/templates/actions/release/lang/js/validate.yml.erb @@ -5,20 +5,17 @@ description: > branch) — and runs a pre-gate build + CycloneDX SBOM generation so build/SBOM-tooling failures (e.g. pnpm < 11.8) surface BEFORE the approval gate. Read-only: nothing is signed or published here. Every check is a boolean toggle (default on) with an escape - hatch. + hatch. Consumes `configure`'s `already_published` fact (does not query the registry + itself — that fact is produced once, in configure). inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Warn instead of failing on an already-existing tag" + description: "Warn instead of failing on an already-existing tag / already-published version" required: false default: 'false' - version: - description: "Version override (from configure). If empty, read from package.json." - required: false - default: '' working_directory: description: "Path to the package root (where package.json lives)" required: false @@ -32,16 +29,6 @@ inputs: description: "pnpm version to install. When empty, read from package.json packageManager." required: false default: '' - registry: - description: "npm registry URL" - required: false - default: 'https://registry.npmjs.org' - package_name: - description: > - npm package name. Used by the version-unpublished check (covers prereleases, - which aren't git-tagged). Leave empty to skip that check. - required: false - default: '' release_tag: description: "The release tag (from configure) — checked for prior existence" required: true @@ -58,7 +45,11 @@ inputs: required: false default: 'true' notes: - description: "Base64-encoded release notes (from prepare) — checked non-blank" + description: "Base64-encoded release notes (from configure) — checked non-blank" + required: false + default: '' + already_published: + description: "Whether the version is already on the registry (from configure). Judged by check_version_unpublished." required: false default: '' build: @@ -83,7 +74,7 @@ inputs: required: false default: 'true' check_version_unpublished: - description: "Fail if the version is already published to the registry" + description: "Fail if the version is already published to the registry (judges configure's already_published)" required: false default: 'true' check_channel_allowed: @@ -109,10 +100,6 @@ runs: <%= render_step('lang/js/pnpm/setup') %> -<%= render_step('lang/js/read-version', version: "${{ inputs.version }}") %> - -<%= render_step('lang/js/npm/availability', if: "${{ inputs.check_version_unpublished == 'true' }}") %> - <%= render_step('release/lang/js/validate-channel', if: "${{ inputs.check_channel_allowed == 'true' }}") %> <%= render_step('release/checks') %> diff --git a/templates/actions/release/lang/py/build-and-ship.yml.erb b/templates/actions/release/lang/py/build-and-ship.yml.erb index 7f8d7b0..9528035 100644 --- a/templates/actions/release/lang/py/build-and-ship.yml.erb +++ b/templates/actions/release/lang/py/build-and-ship.yml.erb @@ -77,13 +77,17 @@ inputs: description: "Whether the SHA is on an expected release branch" required: true pr_list: - description: "x1f-delimited PR list from prepare action" + description: "x1f-delimited PR list from configure" required: false default: '' package_name: description: "PyPI package name — used to build the PyPI link in notifications" required: false default: '' + already_published: + description: "Whether the version is already on PyPI (from configure). When 'true', the publish is skipped." + required: false + default: '' label: description: > Display name for release notifications (e.g. the package name). When set, it @@ -134,7 +138,8 @@ runs: subject_path: ["${{ inputs.working_directory }}/dist/*.whl", "${{ inputs.working_directory }}/dist/*.tar.gz"], sbom_path: "${{ inputs.working_directory }}/sbom.json") %> -<%= render_step('lang/py/pypi/publish') %> +<%# Idempotent: skip the publish when configure already saw this version on PyPI. -%> +<%= render_step('lang/py/pypi/publish', if: "${{ inputs.already_published != 'true' }}") %> <%= render_step('release/create-github-release') %> diff --git a/templates/actions/release/lang/py/configure.yml.erb b/templates/actions/release/lang/py/configure.yml.erb index 3ef4ca7..069890b 100644 --- a/templates/actions/release/lang/py/configure.yml.erb +++ b/templates/actions/release/lang/py/configure.yml.erb @@ -1,12 +1,12 @@ name: Configure Python Release description: > - Derives release facts for a Python package — read-only, no token. Checks out the - SHA, sets up uv (with a default Python the action guarantees itself — no - python_version input) to read the version, and computes the release tag, the branch - (and whether it's a release branch), the commit message, the previous release tag, - and the github_release default from release_type. Its outputs feed validate, - request-approval, and build-and-ship. Judging/checks live in the validate action; - this action never fails on release state. + Produces the release FACTS for a Python package — the fact-finding half of the pipeline + (validate judges these; it never fails on release state). Checks out the SHA, sets up uv, + reads the version, computes the release tag/branch/commit/github_release, checks PyPI for + whether the version is already published (→ already_published + version_url), fetches + release notes + PR list, and assembles the package manifest. Requires contents: write on + the calling job (for the generate-notes API). Its outputs (incl. `package`) feed + validate, request-approval, and ship. inputs: sha: @@ -46,6 +46,23 @@ inputs: description: "Comma-separated allowed release branch patterns (globs)." required: false default: 'main' + pypi_package_name: + description: "PyPI package name. Enables the availability check + the manifest's pypi registry entry." + required: false + default: '' + package_label: + description: "Display label raw material for notifications (presentation-agnostic; the renderer formats it)." + required: false + default: '' + emoji: + description: "Presentation emoji raw material for notifications." + required: false + default: '' + prev_release: + description: "Release-notes anchor override (tag or SHA). Empty → configure's derived previous tag." + required: false + default: '' + outputs: version: description: "The resolved package version" @@ -68,6 +85,21 @@ outputs: github_release: description: "Whether a GitHub release should be created" value: ${{ steps.configure.outputs.github_release }} + already_published: + description: "Whether the version is already published to PyPI" + value: ${{ steps.version-check.outputs.already_published }} + version_url: + description: "Human URL for the version on PyPI" + value: ${{ steps.version-check.outputs.version_url }} + pr_list: + description: "Formatted PR list (x1f-delimited)" + value: ${{ steps.fetch.outputs.pr_list }} + notes: + description: "Base64-encoded release notes" + value: ${{ steps.fetch.outputs.notes }} + package: + description: "A JSON object describing the package to be published." + value: ${{ steps.build.outputs.package }} runs: using: composite @@ -77,16 +109,12 @@ runs: ref: ${{ inputs.sha }} fetch-depth: 0 - # configure reads the version (version.py) — so it sets up uv with a default Python - # itself. The requirement is guaranteed by the action (no python_version input); any - # Python reads the version, and the project's pinned toolchain is only needed to - # BUILD (validate / build-and-ship). Read-only: no dependency install. + # Read-only version read: a default Python reads version_file; the project's pinned + # toolchain is only needed to BUILD (validate / ship). No dependency install. - name: Set up uv uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7.2.0 with: python-version: '3.13' - # No dependency install here, so leave the cache off — otherwise setup-uv's - # post-job save-cache errors on the empty cache and fails the job. enable-cache: "false" <%= render_step('lang/py/read-version') %> @@ -100,4 +128,30 @@ runs: channel: "", github_release: "${{ inputs.github_release }}") %> +<%# Fact: is this version already on PyPI? (severity off — validate judges it, this never fails). -%> +<%= render_step('lang/py/pypi/availability', + package_name: "${{ inputs.pypi_package_name }}", + version: "${{ steps.read-version.outputs.version }}", + severity: 'off') %> + +<%# Fact: release notes + PR list (needs contents: write on the job). -%> +<%= render_step('release/prepare-notes', + release_tag: "${{ steps.configure.outputs.release_tag }}", + sha: "${{ inputs.sha }}", + prev_release: "${{ inputs.prev_release || steps.configure.outputs.prev_release }}") %> + +<%# Assemble the package object (single home of the schema). -%> +<%= render_step('release/build-manifest', + label: "${{ inputs.package_label }}", + emoji: "${{ inputs.emoji }}", + tag: "${{ steps.configure.outputs.release_tag }}", + prev_release: "${{ inputs.prev_release || steps.configure.outputs.prev_release }}", + pr_list: "${{ steps.fetch.outputs.pr_list }}", + notes: "${{ steps.fetch.outputs.notes }}", + github_release: "${{ steps.configure.outputs.github_release }}", + pypi_name: "${{ inputs.pypi_package_name }}", + pypi_version: "${{ steps.read-version.outputs.version }}", + pypi_version_url: "${{ steps.version-check.outputs.version_url }}", + pypi_already_published: "${{ steps.version-check.outputs.already_published }}") %> + <%= render_step('lang/py/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/py/ship-package.yml.erb b/templates/actions/release/lang/py/ship-package.yml.erb index f3b30c5..6367c3f 100644 --- a/templates/actions/release/lang/py/ship-package.yml.erb +++ b/templates/actions/release/lang/py/ship-package.yml.erb @@ -49,6 +49,10 @@ inputs: description: "PyPI package name — used to build the PyPI link in notifications" required: false default: '' + already_published: + description: "Whether the version is already on PyPI (from configure). When 'true', the publish is skipped." + required: false + default: '' label: description: "Display name for release notifications (e.g. the package name)." required: false @@ -68,7 +72,7 @@ inputs: description: "Whether the SHA is on an expected release branch" required: true pr_list: - description: "x1f-delimited PR list from prepare action" + description: "x1f-delimited PR list from configure" required: false default: '' slack_token: @@ -100,12 +104,18 @@ runs: uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7.2.0 with: python-version: '3.13' + # No dependency install here (we only upload prebuilt dist/ artifacts), and this runs in the + # downloaded-artifact dir with no pyproject.toml/uv.lock — so leave the cache off, otherwise + # setup-uv's post-job save-cache errors on the missing cache path and fails the job. + enable-cache: "false" <%= render_step('release/verify-package', if: "${{ inputs.dry_run != 'true' }}", artifacts: "${{ inputs.working_directory }}/dist/*.whl ${{ inputs.working_directory }}/dist/*.tar.gz") %> -<%= render_step('lang/py/pypi/publish') %> +<%# Idempotency: gate the publish on configure's already_published fact (no re-query in ship). -%> +<%= render_step('lang/py/pypi/publish', + if: "${{ inputs.already_published != 'true' }}") %> <%= render_step('release/create-github-release') %> diff --git a/templates/actions/release/lang/py/validate.yml.erb b/templates/actions/release/lang/py/validate.yml.erb index e3f3f99..90a19e2 100644 --- a/templates/actions/release/lang/py/validate.yml.erb +++ b/templates/actions/release/lang/py/validate.yml.erb @@ -4,24 +4,17 @@ description: > (tag unused, version unpublished on PyPI, notes present, release branch) — and runs a pre-gate build + CycloneDX SBOM generation so build/SBOM-tooling failures surface BEFORE the approval gate. Read-only: nothing is signed or published here. Every check - is a boolean toggle (default on) with an escape hatch. + is a boolean toggle (default on) with an escape hatch. Consumes `configure`'s + `already_published` fact (does not query PyPI itself). inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Warn instead of failing on an already-existing tag" + description: "Warn instead of failing on an already-existing tag / already-published version" required: false default: 'false' - version: - description: "Version override (from configure). If empty, read from version_file." - required: false - default: '' - version_file: - description: "Path (repo-root-relative) to a Python module exposing VERSION." - required: false - default: '' working_directory: description: "Path to the package root (where pyproject.toml lives)" required: false @@ -31,12 +24,6 @@ inputs: Python version (e.g. 3.13), or a path to a version file (.python-version, .tool-versions) relative to the repo root. required: true - package_name: - description: > - PyPI package name. Used by the version-unpublished check (covers prereleases, - which aren't git-tagged). Leave empty to skip that check. - required: false - default: '' release_tag: description: "The release tag (from configure) — checked for prior existence" required: true @@ -45,7 +32,11 @@ inputs: required: false default: 'true' notes: - description: "Base64-encoded release notes (from prepare) — checked non-blank" + description: "Base64-encoded release notes (from configure) — checked non-blank" + required: false + default: '' + already_published: + description: "Whether the version is already on PyPI (from configure). Judged by check_version_unpublished." required: false default: '' build_command: @@ -70,7 +61,7 @@ inputs: required: false default: 'true' check_version_unpublished: - description: "Fail if the version is already published to PyPI" + description: "Fail if the version is already published to PyPI (judges configure's already_published)" required: false default: 'true' check_notes_not_blank: @@ -92,10 +83,6 @@ runs: <%= render_step('lang/py/setup') %> -<%= render_step('lang/py/read-version') %> - -<%= render_step('lang/py/pypi/availability', if: "${{ inputs.check_version_unpublished == 'true' }}") %> - <%= render_step('release/checks') %> <%= render_step('lang/py/build', if: "${{ inputs.build == 'true' }}") %> diff --git a/templates/actions/release/lang/ruby/build-and-ship.yml.erb b/templates/actions/release/lang/ruby/build-and-ship.yml.erb index d06d4e3..3479233 100644 --- a/templates/actions/release/lang/ruby/build-and-ship.yml.erb +++ b/templates/actions/release/lang/ruby/build-and-ship.yml.erb @@ -61,13 +61,17 @@ inputs: description: "Whether the SHA is on an expected release branch" required: true pr_list: - description: "x1f-delimited PR list from prepare action" + description: "x1f-delimited PR list from configure" required: false default: '' gem_name: description: "RubyGems gem name — used to build the RubyGems link in notifications" required: false default: '' + already_published: + description: "Whether the version is already on RubyGems (from configure). When 'true', the push is skipped." + required: false + default: '' label: description: > Display name for release notifications (e.g. the gem name). When set, it becomes diff --git a/templates/actions/release/lang/ruby/configure.yml.erb b/templates/actions/release/lang/ruby/configure.yml.erb index 3e2f650..d5c8627 100644 --- a/templates/actions/release/lang/ruby/configure.yml.erb +++ b/templates/actions/release/lang/ruby/configure.yml.erb @@ -1,12 +1,12 @@ name: Configure Ruby Release description: > - Derives release facts for a Ruby gem — read-only, no token. Checks out the SHA, sets - up Ruby (a default the action guarantees itself — no ruby_version input) to read the - version, and computes the release tag, the branch (and whether it's a release - branch), the commit message, the previous release tag, and the github_release - default from release_type. Its outputs feed validate, request-approval, and - build-and-ship. Judging/checks live in the validate action; this action never fails - on release state. + Produces the release FACTS for a Ruby gem — the fact-finding half of the pipeline + (validate judges these; it never fails on release state). Checks out the SHA, sets up + Ruby, reads the version, computes the release tag/branch/commit/github_release, checks + RubyGems for whether the version is already published (→ already_published + + version_url), fetches release notes + PR list, and assembles the package manifest. + Requires contents: write on the calling job (for the generate-notes API). Its outputs + (incl. `package`) feed validate, request-approval, and ship. inputs: sha: @@ -48,6 +48,23 @@ inputs: description: "Comma-separated allowed release branch patterns (globs)." required: false default: 'main' + rubygems_package_name: + description: "RubyGems gem name. Enables the availability check + the manifest's rubygems registry entry." + required: false + default: '' + package_label: + description: "Display label raw material for notifications (presentation-agnostic; the renderer formats it)." + required: false + default: '' + emoji: + description: "Presentation emoji raw material for notifications." + required: false + default: '' + prev_release: + description: "Release-notes anchor override (tag or SHA). Empty → configure's derived previous tag." + required: false + default: '' + outputs: version: description: "The resolved gem version" @@ -70,6 +87,21 @@ outputs: github_release: description: "Whether a GitHub release should be created" value: ${{ steps.configure.outputs.github_release }} + already_published: + description: "Whether the version is already published to RubyGems" + value: ${{ steps.version-check.outputs.already_published }} + version_url: + description: "Human URL for the version on RubyGems" + value: ${{ steps.version-check.outputs.version_url }} + pr_list: + description: "Formatted PR list (x1f-delimited)" + value: ${{ steps.fetch.outputs.pr_list }} + notes: + description: "Base64-encoded release notes" + value: ${{ steps.fetch.outputs.notes }} + package: + description: "A JSON object describing the package to be published." + value: ${{ steps.build.outputs.package }} runs: using: composite @@ -79,10 +111,8 @@ runs: ref: ${{ inputs.sha }} fetch-depth: 0 - # configure reads the version (version.rb) — so it sets up a default Ruby itself. - # The requirement is guaranteed by the action (no ruby_version input); any Ruby reads - # the version, and the project's pinned toolchain is only needed to BUILD (validate / - # build-and-ship). Read-only: no bundler-cache / no bundle install. + # Read-only version read: a default Ruby reads version.rb; the project's pinned + # toolchain is only needed to BUILD (validate / ship). No bundle install. - name: Set up Ruby uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 with: @@ -99,4 +129,30 @@ runs: channel: "", github_release: "${{ inputs.github_release }}") %> +<%# Fact: is this version already on RubyGems? (severity off — validate judges it, this never fails). -%> +<%= render_step('lang/ruby/rubygems/availability', + package_name: "${{ inputs.rubygems_package_name }}", + version: "${{ steps.read-version.outputs.version }}", + severity: 'off') %> + +<%# Fact: release notes + PR list (needs contents: write on the job). -%> +<%= render_step('release/prepare-notes', + release_tag: "${{ steps.configure.outputs.release_tag }}", + sha: "${{ inputs.sha }}", + prev_release: "${{ inputs.prev_release || steps.configure.outputs.prev_release }}") %> + +<%# Assemble the package object (single home of the schema). -%> +<%= render_step('release/build-manifest', + label: "${{ inputs.package_label }}", + emoji: "${{ inputs.emoji }}", + tag: "${{ steps.configure.outputs.release_tag }}", + prev_release: "${{ inputs.prev_release || steps.configure.outputs.prev_release }}", + pr_list: "${{ steps.fetch.outputs.pr_list }}", + notes: "${{ steps.fetch.outputs.notes }}", + github_release: "${{ steps.configure.outputs.github_release }}", + rubygems_name: "${{ inputs.rubygems_package_name }}", + rubygems_version: "${{ steps.read-version.outputs.version }}", + rubygems_version_url: "${{ steps.version-check.outputs.version_url }}", + rubygems_already_published: "${{ steps.version-check.outputs.already_published }}") %> + <%= render_step('lang/ruby/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/ruby/ship-package.yml.erb b/templates/actions/release/lang/ruby/ship-package.yml.erb index 9fd6dd5..57933f8 100644 --- a/templates/actions/release/lang/ruby/ship-package.yml.erb +++ b/templates/actions/release/lang/ruby/ship-package.yml.erb @@ -37,6 +37,14 @@ inputs: description: "RubyGems gem name — used to build the RubyGems link in notifications" required: false default: '' + version: + description: "Gem version being released — used to build the RubyGems link in notifications." + required: false + default: '' + already_published: + description: "Whether the version is already on RubyGems (from configure). When 'true', the push is skipped." + required: false + default: '' label: description: "Display name for release notifications (e.g. the gem name)." required: false @@ -56,7 +64,7 @@ inputs: description: "Whether the SHA is on an expected release branch" required: true pr_list: - description: "x1f-delimited PR list from prepare action" + description: "x1f-delimited PR list from configure" required: false default: '' slack_token: @@ -94,6 +102,7 @@ runs: if: "${{ inputs.dry_run != 'true' }}", artifacts: "${{ inputs.working_directory }}/*.gem") %> +<%# Idempotency: the push step self-gates on configure's already_published fact (no re-query). -%> <%= render_step('lang/ruby/rubygems/push', gem: "*.gem") %> <%= render_step('release/create-github-release') %> diff --git a/templates/actions/release/lang/ruby/validate.yml.erb b/templates/actions/release/lang/ruby/validate.yml.erb index 1cf0539..8111278 100644 --- a/templates/actions/release/lang/ruby/validate.yml.erb +++ b/templates/actions/release/lang/ruby/validate.yml.erb @@ -4,28 +4,17 @@ description: > (tag unused, version unpublished on RubyGems, notes present, release branch) — and runs lint + a pre-gate build + CycloneDX SBOM generation so failures surface BEFORE the approval gate. Read-only: nothing is signed or published here. Every check is a - boolean toggle (default on) with an escape hatch. + boolean toggle (default on) with an escape hatch. Consumes `configure`'s + `already_published` fact (does not query RubyGems itself). inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Warn instead of failing on an already-existing tag" + description: "Warn instead of failing on an already-existing tag / already-published version" required: false default: 'false' - version: - description: "Version override (from configure). If empty, read from version_file/version_module." - required: false - default: '' - version_file: - description: "Path to the Ruby version file (e.g. lib/gem_name/version.rb)" - required: false - default: '' - version_module: - description: "Ruby module holding the VERSION constant (e.g. GemName)" - required: false - default: '' working_directory: description: "Path to the gem root (where Gemfile and gemspec live)" required: false @@ -34,13 +23,6 @@ inputs: description: "Ruby version to use, or a version file path (.tool-versions, .ruby-version)" required: false default: '4.0' - package_name: - description: > - RubyGems gem name. Used by the version-unpublished check — the registry, not the - git tag, is the source of truth (covers prereleases and force-deleted tags). Leave - empty to skip that check. - required: false - default: '' release_tag: description: "The release tag (from configure) — checked for prior existence" required: true @@ -49,7 +31,11 @@ inputs: required: false default: 'true' notes: - description: "Base64-encoded release notes (from prepare) — checked non-blank" + description: "Base64-encoded release notes (from configure) — checked non-blank" + required: false + default: '' + already_published: + description: "Whether the version is already on RubyGems (from configure). Judged by check_version_unpublished." required: false default: '' build: @@ -70,7 +56,7 @@ inputs: required: false default: 'true' check_version_unpublished: - description: "Fail if the version is already published to RubyGems" + description: "Fail if the version is already published to RubyGems (judges configure's already_published)" required: false default: 'true' check_notes_not_blank: @@ -92,10 +78,6 @@ runs: <%= render_step('lang/ruby/setup') %> -<%= render_step('lang/ruby/read-version') %> - -<%= render_step('lang/ruby/rubygems/availability', if: "${{ inputs.check_version_unpublished == 'true' }}") %> - <%= render_step('release/checks') %> - name: Lint diff --git a/templates/actions/release/prepare.yml b/templates/actions/release/prepare.yml deleted file mode 100644 index 6382830..0000000 --- a/templates/actions/release/prepare.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Prepare Release -description: > - Fetches release notes and formats the PR list for notifications. - Requires contents:write permission on the calling job (for generate-notes API). - -inputs: - release_tag: - description: "The release tag (e.g. v0.3.2)" - required: true - sha: - description: "The commit SHA being released" - required: true - prev_release: - description: > - Anchor for release notes. Accepts a tag name or a full commit SHA. - If a tag: generates notes via the GitHub API using it as the previous release. - If a SHA: notes are unavailable (no reliable cross-strategy PR extraction); - outputs will be empty. If omitted, GitHub auto-detects the previous release. - required: false - default: '' - -outputs: - pr_list: - description: "Formatted PR list (x1f-delimited for safe job output passing)" - value: ${{ steps.fetch.outputs.pr_list }} - notes: - description: "Base64-encoded full release notes body" - value: ${{ steps.fetch.outputs.notes }} - -runs: - using: composite - steps: - - name: Fetch PR list and release notes - id: fetch - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PREV_RELEASE: ${{ inputs.prev_release }} - run: | - # If prev_release is a full SHA, notes are unavailable - if echo "$PREV_RELEASE" | grep -qE '^[0-9a-f]{40}$'; then - echo "SHA provided for prev_release — notes unavailable for SHA ranges." - echo "pr_list=" >> $GITHUB_OUTPUT - echo "notes=$(echo "" | base64 -w 0)" >> $GITHUB_OUTPUT - exit 0 - fi - - BODY=$(gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ - --method POST \ - --field tag_name="${{ inputs.release_tag }}" \ - --field target_commitish="${{ inputs.sha }}" \ - ${PREV_RELEASE:+--field previous_tag_name="$PREV_RELEASE"} \ - --jq '.body' 2>/dev/null || echo "") - - PR_LIST=$(echo "$BODY" \ - | grep "^\* " \ - | grep -v "made their first contribution" \ - | grep -v "Full Changelog" \ - | head -10 \ - | sed 's|^\* ||' \ - | sed 's| by @[^ ]*||' \ - | sed 's@ in \(https://[^ ]*/pull/\([0-9]*\)\)@ (<\1|#\2>)@' \ - | sed 's/^/• /' \ - | tr '\n' $'\x1f' || echo "") - - echo "pr_list=$PR_LIST" >> $GITHUB_OUTPUT - echo "notes=$(echo "$BODY" | base64 -w 0)" >> $GITHUB_OUTPUT diff --git a/templates/actions/release/request-approval.yml.erb b/templates/actions/release/request-approval.yml.erb index 3cc7902..6b08452 100644 --- a/templates/actions/release/request-approval.yml.erb +++ b/templates/actions/release/request-approval.yml.erb @@ -1,22 +1,24 @@ name: Request Release Approval description: > - Issues the release approval request: posts the pre-approval job summary and Slack - notification. Runs before the environment gate on ship — reviewers see this and then - approve the gated ship/ship-package job. The job goes green the moment the request is - issued (the actual gate is the `environment:` on the ship job), so a green - request-approval is truthful and the ship job is visibly parked at the gate. + Issues the release approval request for one or more packages: posts the pre-approval job + summary and Slack notification, then the gated ship job(s) park at the environment gate for + reviewers. A green request-approval is truthful — the real gate is the `environment:` on the + ship job(s). + + One input surface: `packages`, a release manifest of the form {"packages": [ , … ]} + (the same envelope create-package-github-releases and publish-npm-tarballs consume). Build + entries with the build-package-manifest action — or by hand for a bare workflow that has no + configure step. Each entry describes one package: + label, emoji, tag, prev_release, pr_list (x1f), notes (base64) or release_body (plain), + github_release, and optional registries..{name, version, version_url, + channel, already_published}. + A package whose registries are ALL already_published renders as "will NOT be published". inputs: + # ── shared (whole release) ── sha: description: "Commit SHA being released" required: true - release_tag: - description: "The release tag (e.g. v0.3.2)" - required: true - prev_release: - description: "Previous release tag" - required: false - default: '' branch: description: "Branch containing the SHA" required: true @@ -26,18 +28,20 @@ inputs: commit_message: description: "Commit message at the SHA" required: true - pr_list: - description: "x1f-delimited PR list from prepare action" - required: false - default: '' - notes: - description: "Base64-encoded release notes from prepare action" - required: false - default: '' dry_run: description: "Whether this is a dry run" required: false default: 'false' + title: + description: > + Overall subject for the approval (e.g. the repo or a release name). Defaults to the + single package's label when there is one package, else the repository. + required: false + default: '' + emoji: + description: "Default emoji prefix (a package entry may override its own)" + required: false + default: ':package:' slack_token: description: "Slack bot token for posting messages" required: false @@ -47,100 +51,153 @@ inputs: required: false default: '' slack_mention: - description: "Optional Slack handle or group to @mention in the approval request (e.g. @sdk-eng)" + description: "Optional Slack handle or group to @mention in the approval request" required: false default: '' - emoji: - description: "Emoji prefix for Slack messages" - required: false - default: ':package:' - label: + packages: description: > - Display name for the thing being released (e.g. the package name). When set, - it becomes the notification subject instead of the repository — useful when a - repo publishes multiple packages. Defaults to the repository. - required: false - default: '' + Release manifest: a JSON object {"packages": [ , … ]}, one entry per package. + Build entries with the build-package-manifest action (or by hand). Each entry: + label, emoji, tag, prev_release, pr_list (x1f), notes (base64) or release_body (plain), + github_release, and optional registries..{name, version, version_url, + channel, already_published}. A bare array or any other shape is rejected. + required: true runs: using: composite steps: + # Validate the manifest and derive the approval subject. The sole input surface is a + # {"packages":[…]} object; anything else (a bare array, an object without .packages, a + # scalar, malformed JSON) is a caller error — fail loudly rather than release nothing. + - name: Normalize packages + id: manifest + shell: bash + env: + PACKAGES: ${{ inputs.packages }} + TITLE: ${{ inputs.title }} + run: | + set -euo pipefail + if ! M="$(jq -ce 'if (type=="object" and (.packages | type=="array")) then {packages: .packages} else empty end' <<<"$PACKAGES")"; then + echo "::error title=Invalid packages input::\`packages\` must be a JSON object of the form {\"packages\":[…]}. Got: $PACKAGES" + exit 1 + fi + + COUNT="$(jq '.packages | length' <<<"$M")" + if [ "$COUNT" = "0" ]; then + echo "::error title=Empty release manifest::\`packages\` contains no entries — there is nothing to approve." + exit 1 + fi + if [ -n "$TITLE" ]; then + SUBJECT="$TITLE" + elif [ "$COUNT" = "1" ]; then + SUBJECT="$(jq -r '.packages[0] | .label // .name // .tag // ""' <<<"$M")" + [ -z "$SUBJECT" ] && SUBJECT="$GITHUB_REPOSITORY" + else + SUBJECT="$GITHUB_REPOSITORY" + fi + # Job outputs are single-line; normalize any title/label newlines before writing. + SUBJECT="${SUBJECT//$'\n'/ }" + SUBJECT="${SUBJECT//$'\r'/ }" + { + echo "manifest=$M" + echo "subject=$SUBJECT" + } >> "$GITHUB_OUTPUT" + - name: Post release summary shell: bash env: - TAG: ${{ inputs.release_tag }} - LABEL: ${{ inputs.label }} + MANIFEST_JSON: ${{ steps.manifest.outputs.manifest }} + SUBJECT: ${{ steps.manifest.outputs.subject }} + DEFAULT_EMOJI: ${{ inputs.emoji }} COMMIT_MSG: ${{ inputs.commit_message }} - NOTES_B64: ${{ inputs.notes }} BRANCH: ${{ inputs.branch }} SHA: ${{ inputs.sha }} - PREV_RELEASE: ${{ inputs.prev_release }} ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} DRY_RUN: ${{ inputs.dry_run }} ACTOR: ${{ github.actor }} run: | - NOTES=$(echo "$NOTES_B64" | base64 -d 2>/dev/null) - if [ -z "$NOTES" ]; then NOTES="_Release notes unavailable._"; fi - - BRANCH_LABEL="[$BRANCH]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH)" - if [ "$ON_RELEASE_BRANCH" = "false" ]; then - BRANCH_LABEL="$BRANCH_LABEL ⚠️" - fi - - # Subject is the package label when set, else the repository. The repo - # remains in every link below, so it is never lost. - SUBJECT="$GITHUB_REPOSITORY" - [ -n "$LABEL" ] && SUBJECT="$LABEL" - - echo "## $SUBJECT $TAG" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ "$DRY_RUN" = "true" ]; then - echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY - echo "> Dry run: Nothing will be tagged or published." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - fi - - if [ "$ON_RELEASE_BRANCH" = "false" ]; then - echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY - echo "> Release SHA is not on an expected release branch. Is this intentional?" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - fi - - echo "**SHA:** [$SHA]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$SHA)" >> $GITHUB_STEP_SUMMARY - echo "**Commit:** $COMMIT_MSG" >> $GITHUB_STEP_SUMMARY - echo "**Branch:** $BRANCH_LABEL" >> $GITHUB_STEP_SUMMARY - echo "**Initiated by:** [$ACTOR]($GITHUB_SERVER_URL/$ACTOR)" >> $GITHUB_STEP_SUMMARY - - if [ -n "$PREV_RELEASE" ]; then - DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...${SHA}" - echo "**Diff:** [${PREV_RELEASE}...$TAG]($DIFF_URL)" >> $GITHUB_STEP_SUMMARY - fi - - echo "" >> $GITHUB_STEP_SUMMARY - echo "$NOTES" >> $GITHUB_STEP_SUMMARY + set -euo pipefail + # A package is "skipped" when it has registries and ALL of them are already published. + SKIPPED_FILTER='(.registries // {}) as $r | (($r | length) > 0) and (all($r[]; .already_published == true))' + SKIPPED_LABELS="$(jq -r "[.packages[]? | select($SKIPPED_FILTER) | (.label // .tag // \"package\")] | join(\", \")" <<<"$MANIFEST_JSON")" + { + echo "## $SUBJECT" + echo "" + if [ "$DRY_RUN" = "true" ]; then + echo "> [!NOTE]" + echo "> Dry run: Nothing will be tagged or published." + echo "" + fi + if [ -n "$SKIPPED_LABELS" ]; then + echo "> [!WARNING]" + echo "> Already published — will be SKIPPED (not re-published): $SKIPPED_LABELS" + echo "" + fi + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + echo "> [!WARNING]" + echo "> Release SHA is not on an expected release branch. Is this intentional?" + echo "" + fi + BRANCH_LABEL="[$BRANCH]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH)" + [ "$ON_RELEASE_BRANCH" = "false" ] && BRANCH_LABEL="$BRANCH_LABEL ⚠️" + echo "**SHA:** [$SHA]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$SHA)" + echo "**Commit:** $COMMIT_MSG" + echo "**Branch:** $BRANCH_LABEL" + echo "**Initiated by:** [$ACTOR]($GITHUB_SERVER_URL/$ACTOR)" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + while IFS= read -r encoded; do + p="$(printf '%s' "$encoded" | base64 -d)" + label="$(jq -r '.label // .tag // ""' <<<"$p")" + tag="$(jq -r '.tag // ""' <<<"$p")" + emoji="$(jq -r --arg d "$DEFAULT_EMOJI" '.emoji // $d' <<<"$p")" + skipped="$(jq -r "$SKIPPED_FILTER" <<<"$p")" + if [ "$skipped" = "true" ]; then + # Simplified: this version is already published, so it will NOT be published. + { + echo "### ⏭️ $label${tag:+ $tag} — already published, will NOT be published" + jq -r '.registries | to_entries[] + | "- **\(.key):** \(.value.name // "")\((.value.version // "") | if . != "" then " " + . else "" end)\((.value.version_url // "") | if . != "" then " — [published](" + . + ")" else "" end)"' <<<"$p" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + else + prev="$(jq -r '.prev_release // ""' <<<"$p")" + notes_b64="$(jq -r '.notes // ""' <<<"$p")" + if [ -n "$notes_b64" ]; then + NOTES="$(printf '%s' "$notes_b64" | base64 -d 2>/dev/null || true)" + else + NOTES="$(jq -r '.release_body // ""' <<<"$p")" + fi + { + echo "### $emoji $label${tag:+ $tag}" + if [ -n "$prev" ]; then + echo "**Diff:** [${prev}...${tag:-$SHA}]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${prev}...${SHA})" + fi + echo "" + echo "${NOTES:-_Release notes unavailable._}" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + fi + done < <(jq -r '.packages[]? | @base64' <<<"$MANIFEST_JSON") - name: Build pending message id: message-pending shell: bash env: - TAG: ${{ inputs.release_tag }} - LABEL: ${{ inputs.label }} - EMOJI: ${{ inputs.emoji }} + MANIFEST_JSON: ${{ steps.manifest.outputs.manifest }} + SUBJECT: ${{ steps.manifest.outputs.subject }} + DEFAULT_EMOJI: ${{ inputs.emoji }} BRANCH: ${{ inputs.branch }} ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} - PREV_RELEASE: ${{ inputs.prev_release }} SHA: ${{ inputs.sha }} DRY_RUN: ${{ inputs.dry_run }} SLACK_MENTION: ${{ inputs.slack_mention }} APPROVE_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} ACTOR_LINK: <${{ github.server_url }}/${{ github.actor }}|${{ github.actor }}> - PR_LIST_RAW: ${{ inputs.pr_list }} run: | - # Subject is the package label when set, else the repository. - SUBJECT="$GITHUB_REPOSITORY" - [ -n "$LABEL" ] && SUBJECT="$LABEL" - + set -euo pipefail + SKIPPED_FILTER='(.registries // {}) as $r | (($r | length) > 0) and (all($r[]; .already_published == true))' BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" BRANCH_LINK="<$BRANCH_URL|$BRANCH>" if [ "$ON_RELEASE_BRANCH" = "false" ]; then @@ -149,36 +206,48 @@ runs: BRANCH_INFO="$BRANCH_LINK" fi - DIFF_PART="" - if [ -n "$PREV_RELEASE" ]; then - DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...${SHA}" - DIFF_PART=" · <$DIFF_URL|${PREV_RELEASE}...$TAG>" - fi - - PR_LIST=$(echo "$PR_LIST_RAW" | tr $'\x1f' '\n' | sed '/^$/d') - - TEXT="$EMOJI *$SUBJECT $TAG* awaiting approval" + TEXT="$DEFAULT_EMOJI *$SUBJECT* awaiting approval" TEXT="$TEXT\nInitiated by: $ACTOR_LINK" - if [ "$DRY_RUN" = "true" ]; then TEXT="$TEXT\n> :information_source: _Dry run: nothing will be tagged or published._" fi - - TEXT="$TEXT\n${BRANCH_INFO}${DIFF_PART}" - - if [ -n "$PR_LIST" ]; then - TEXT="$TEXT\n$PR_LIST" - fi + SKIPPED_LABELS="$(jq -r "[.packages[]? | select($SKIPPED_FILTER) | (.label // .tag // \"package\")] | join(\", \")" <<<"$MANIFEST_JSON")" + [ -n "$SKIPPED_LABELS" ] && TEXT="$TEXT\n> ⚠️ Already published, will be SKIPPED: $SKIPPED_LABELS" + TEXT="$TEXT\n${BRANCH_INFO}" + + while IFS= read -r encoded; do + p="$(printf '%s' "$encoded" | base64 -d)" + label="$(jq -r '.label // .tag // ""' <<<"$p")" + tag="$(jq -r '.tag // ""' <<<"$p")" + emoji="$(jq -r --arg d "$DEFAULT_EMOJI" '.emoji // $d' <<<"$p")" + skipped="$(jq -r "$SKIPPED_FILTER" <<<"$p")" + if [ "$skipped" = "true" ]; then + LINE="⏭️ *$label${tag:+ $tag}* — already published, will NOT be published" + REG="$(jq -r '[.registries | to_entries[] | if (.value.version_url // "") != "" then "<\(.value.version_url)|\(.key)>" else .key end] | join(", ")' <<<"$p")" + [ -n "$REG" ] && LINE="$LINE · $REG" + TEXT="$TEXT\n$LINE" + else + prev="$(jq -r '.prev_release // ""' <<<"$p")" + pr_raw="$(jq -r '.pr_list // ""' <<<"$p")" + LINE="$emoji *$label${tag:+ $tag}*" + if [ -n "$prev" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${prev}...${SHA}" + LINE="$LINE · <$DIFF_URL|${prev}...${tag:-$SHA}>" + fi + TEXT="$TEXT\n$LINE" + PR_LIST="$(printf '%s' "$pr_raw" | tr $'\x1f' '\n' | sed '/^$/d')" + [ -n "$PR_LIST" ] && TEXT="$TEXT\n$PR_LIST" + fi + done < <(jq -r '.packages[]? | @base64' <<<"$MANIFEST_JSON") if [ -n "$SLACK_MENTION" ]; then TEXT="$TEXT\n$SLACK_MENTION please review and approve." fi - TEXT="$TEXT\n<$APPROVE_URL|View changes & approve>" { echo 'text<> $GITHUB_OUTPUT + } >> "$GITHUB_OUTPUT" <%= render_step('slack/send', text: "${{ steps.message-pending.outputs.text }}") %> diff --git a/templates/steps/lang/js/npm/availability.yml.erb b/templates/steps/lang/js/npm/availability.yml.erb index 6700df1..1b4c76b 100644 --- a/templates/steps/lang/js/npm/availability.yml.erb +++ b/templates/steps/lang/js/npm/availability.yml.erb @@ -1,36 +1,54 @@ <%# - Fails fast if the package version is already published to the npm registry. - Read-only registry HTTP query — no auth, and no package-manager CLI, so it works - in pnpm-strict repos (a `npm view` here trips devEngines/engines with - EBADDEVENGINES, and swallowing that error silently passes the check). Self-guards: - skips when package_name is empty. Complements release/validate's git-tag check by - covering prereleases, which aren't tagged. Assumes the read-version step - (id: read-version) has run. + Checks whether the package version is already published to the npm registry and + exports `already_published` (true|false). Read-only registry HTTP query — no auth, + and no package-manager CLI, so it works in pnpm-strict repos (a `npm view` here trips + devEngines/engines with EBADDEVENGINES, and swallowing that error silently passes the + check). This is the SINGLE registry-existence check shared by validate (pre-gate) and + ship (at publish time), so the two never drift. Self-guards: skips when package_name + is empty. An unexpected HTTP status aborts — we never guess. - @param package_name [String] expr for the npm package name. Default: ${{ inputs.package_name }} - @param registry [String] expr for the npm registry URL. Default: ${{ inputs.registry }} + `severity` controls what happens when the version IS already published: + fail (default) → error + abort · warn → warning + continue · off → just report. + `already_published` is set regardless (unless self-guarded/aborted), for the caller + to consume (validate exposes it; ship gates the publish step on it). + + @param package_name [String] expr for the npm package name. Default: ${{ inputs.package_name }} + @param version [String] expr for the version to check. Default: ${{ steps.read-version.outputs.version }} + @param registry [String] expr for the npm registry URL. Default: ${{ inputs.registry }} + @param severity [String] fail | warn | off. Default: fail -%> - name: Check npm version availability + id: version-check shell: bash env: PACKAGE_NAME: <%= locals.fetch(:package_name) { "${{ inputs.package_name }}" } %> - VERSION: ${{ steps.read-version.outputs.version }} + VERSION: <%= locals.fetch(:version) { "${{ steps.read-version.outputs.version }}" } %> REGISTRY: <%= locals.fetch(:registry) { "${{ inputs.registry }}" } %> + SEVERITY: <%= locals.fetch(:severity) { "fail" } %> run: | if [ -z "$PACKAGE_NAME" ]; then echo "No package_name provided; skipping npm availability check." exit 0 fi + echo "version_url=https://www.npmjs.com/package/$PACKAGE_NAME/v/$VERSION" >> "$GITHUB_OUTPUT" REGISTRY="${REGISTRY%/}" CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ --retry 3 --retry-connrefused --max-time 30 \ "$REGISTRY/$PACKAGE_NAME/$VERSION") case "$CODE" in 200) - echo "::error title=Version already published::$PACKAGE_NAME@$VERSION already exists on npm — has the version been bumped?" - exit 1 + echo "already_published=true" >> "$GITHUB_OUTPUT" + if [ "$SEVERITY" = "off" ]; then + echo "$PACKAGE_NAME@$VERSION is already on npm." + elif [ "$SEVERITY" = "warn" ]; then + echo "::warning title=Already published::$PACKAGE_NAME@$VERSION is already on npm." + else + echo "::error title=Version already published::$PACKAGE_NAME@$VERSION already exists on npm — has the version been bumped?" + exit 1 + fi ;; 404) + echo "already_published=false" >> "$GITHUB_OUTPUT" echo "$PACKAGE_NAME@$VERSION is available on npm." ;; *) diff --git a/templates/steps/lang/js/npm/publish-package.yml.erb b/templates/steps/lang/js/npm/publish-package.yml.erb index 99a6931..45c83f8 100644 --- a/templates/steps/lang/js/npm/publish-package.yml.erb +++ b/templates/steps/lang/js/npm/publish-package.yml.erb @@ -6,6 +6,10 @@ manifest — verified locally). Publishes the exact attested bytes, so published == attested. Self-guards on the `npm` toggle and dry_run. + Idempotency (present → skip) is handled by the caller: ship renders the shared + lang/js/npm/availability check and gates this step with `if: !already_published`, so + this step never needs its own registry check. + @param tarball [String] expr for the path to the downloaded tarball. @param npm [String] publish to npm or not. Default: ${{ inputs.npm }} @param channel [String] dist-tag. Default: ${{ inputs.channel }} diff --git a/templates/steps/lang/js/pnpm/pack.yml.erb b/templates/steps/lang/js/pnpm/pack.yml.erb index 6103ed9..a30c17f 100644 --- a/templates/steps/lang/js/pnpm/pack.yml.erb +++ b/templates/steps/lang/js/pnpm/pack.yml.erb @@ -6,8 +6,12 @@ artifact we attest AND the artifact ship-package publishes. Sets id: pack with a `tarball` (absolute path) and `integrity` (sha512) output. - @param working_directory [String] expr for the package root. Default: ${{ inputs.working_directory }} - @param pack_dir [String] expr for the (clean) output dir. Default: ${{ runner.temp }}/pkgout + Runs via ${PACK_PNPM:-pnpm}: the "Resolve packaging pnpm" step sets PACK_PNPM to a + pnpm ≥ 11.8 (the project's own, or a pinned fallback via corepack when it is older). + When unset, plain `pnpm` is used, so callers that don't resolve are unaffected. + + @param working_directory [String] expr for the package root. Default: ${{ inputs.working_directory }} + @param pack_dir [String] expr for the (clean) output dir. Default: ${{ runner.temp }}/pkgout -%> - name: Pack id: pack @@ -17,7 +21,7 @@ PACK_DIR: <%= locals.fetch(:pack_dir) { "${{ runner.temp }}/pkgout" } %> run: | rm -rf "$PACK_DIR"; mkdir -p "$PACK_DIR" - pnpm pack --pack-destination "$PACK_DIR" >/dev/null + ${PACK_PNPM:-pnpm} pack --pack-destination "$PACK_DIR" >/dev/null TGZ=$(ls "$PACK_DIR"/*.tgz) echo "tarball=$TGZ" >> "$GITHUB_OUTPUT" echo "integrity=sha512-$(openssl dgst -sha512 -binary "$TGZ" | openssl base64 -A)" >> "$GITHUB_OUTPUT" diff --git a/templates/steps/lang/js/pnpm/sbom.yml.erb b/templates/steps/lang/js/pnpm/sbom.yml.erb index 38ce92f..ccb8175 100644 --- a/templates/steps/lang/js/pnpm/sbom.yml.erb +++ b/templates/steps/lang/js/pnpm/sbom.yml.erb @@ -4,8 +4,9 @@ `--prod` restricts it to the production dependency closure — the deps a consumer of the published package actually gets — so the dev toolchain isn't included. The composing action gates this step with `if: inputs.sbom == 'true'`. Assumes - lang/js/setup installed dependencies. Fails fast with a clear message when pnpm - is too old to provide the native `sbom` command. + lang/js/setup installed dependencies. Runs via ${PACK_PNPM:-pnpm} (see the + "Resolve packaging pnpm" step, which guarantees a pnpm ≥ 11.8). Fails fast with a + clear message when the resolved pnpm is too old to provide the native `sbom` command. @param working_directory [String] expr for the package root. Default: ${{ inputs.working_directory }} -%> @@ -13,9 +14,10 @@ shell: bash working-directory: <%= locals.fetch(:working_directory) { "${{ inputs.working_directory }}" } %> run: | - if ! pnpm sbom --help >/dev/null 2>&1; then + PNPM="${PACK_PNPM:-pnpm}" + if ! $PNPM sbom --help >/dev/null 2>&1; then echo "::error title=pnpm sbom unavailable::'pnpm sbom' needs pnpm >= 11.8. Set the package's packageManager (or pnpm_version) to >= 11.8." exit 1 fi - pnpm sbom --sbom-format cyclonedx --prod --out sbom.json + $PNPM sbom --sbom-format cyclonedx --prod --out sbom.json echo "Wrote $(pwd)/sbom.json" diff --git a/templates/steps/lang/py/pypi/availability.yml.erb b/templates/steps/lang/py/pypi/availability.yml.erb index 72fef1f..84eaad2 100644 --- a/templates/steps/lang/py/pypi/availability.yml.erb +++ b/templates/steps/lang/py/pypi/availability.yml.erb @@ -1,30 +1,47 @@ <%# - Fails fast if the package version is already published to PyPI. Read-only - registry query (no auth). Self-guards: skips when package_name is empty. - Complements release/validate's git-tag check by covering prereleases, which - aren't tagged. Assumes the read-version step (id: read-version) has run. + Checks whether the package version is already published to PyPI and exports + `already_published` (true|false). Read-only registry query (no auth). This is the + SINGLE registry-existence check shared by validate (pre-gate) and ship (at publish + time), so the two never drift. Self-guards: skips when package_name is empty. An + unexpected HTTP status aborts — we never guess. - @param package_name [String] expr for the PyPI package name. Default: ${{ inputs.package_name }} + `severity` controls what happens when the version IS already published: + fail (default) → error + abort · warn → warning + continue · off → just report. + + @param package_name [String] expr for the PyPI package name. Default: ${{ inputs.package_name }} + @param version [String] expr for the version to check. Default: ${{ steps.read-version.outputs.version }} + @param severity [String] fail | warn | off. Default: fail -%> - name: Check PyPI version availability + id: version-check shell: bash env: PACKAGE_NAME: <%= locals.fetch(:package_name) { "${{ inputs.package_name }}" } %> - VERSION: ${{ steps.read-version.outputs.version }} + VERSION: <%= locals.fetch(:version) { "${{ steps.read-version.outputs.version }}" } %> + SEVERITY: <%= locals.fetch(:severity) { "fail" } %> run: | if [ -z "$PACKAGE_NAME" ]; then echo "No package_name provided; skipping PyPI availability check." exit 0 fi + echo "version_url=https://pypi.org/project/$PACKAGE_NAME/$VERSION/" >> "$GITHUB_OUTPUT" CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ --retry 3 --retry-connrefused --max-time 30 \ "https://pypi.org/pypi/$PACKAGE_NAME/$VERSION/json") case "$CODE" in 200) - echo "::error title=Version already published::$PACKAGE_NAME $VERSION already exists on PyPI — has the version been bumped?" - exit 1 + echo "already_published=true" >> "$GITHUB_OUTPUT" + if [ "$SEVERITY" = "off" ]; then + echo "$PACKAGE_NAME $VERSION is already on PyPI." + elif [ "$SEVERITY" = "warn" ]; then + echo "::warning title=Already published::$PACKAGE_NAME $VERSION is already on PyPI." + else + echo "::error title=Version already published::$PACKAGE_NAME $VERSION already exists on PyPI — has the version been bumped?" + exit 1 + fi ;; 404) + echo "already_published=false" >> "$GITHUB_OUTPUT" echo "$PACKAGE_NAME $VERSION is available on PyPI." ;; *) diff --git a/templates/steps/lang/py/pypi/publish.yml.erb b/templates/steps/lang/py/pypi/publish.yml.erb index 4ed445d..012909d 100644 --- a/templates/steps/lang/py/pypi/publish.yml.erb +++ b/templates/steps/lang/py/pypi/publish.yml.erb @@ -7,6 +7,9 @@ validated the artifacts). Self-guards on the `pypi` toggle and `dry_run` in shell, so the composing action needs no `if:`. + Idempotency (present → skip) is handled by the caller: ship renders the shared + lang/py/pypi/availability check and gates this step with `if: !already_published`. + Shell-based on purpose: `uv publish` (not the pypa Docker action) so the publish composes cleanly inside this composite action — a Docker container action nested in a composite fails image resolution. Trusted Publishing + attestations need diff --git a/templates/steps/lang/ruby/rubygems/availability.yml.erb b/templates/steps/lang/ruby/rubygems/availability.yml.erb index 91fb05f..319fd0b 100644 --- a/templates/steps/lang/ruby/rubygems/availability.yml.erb +++ b/templates/steps/lang/ruby/rubygems/availability.yml.erb @@ -1,31 +1,49 @@ <%# - Fails fast if the gem version is already published to RubyGems. Read-only - registry HTTP query (no auth). The registry — not the git tag — is the source of - truth, so this catches a duplicate even if the tag was force-deleted or never - pushed, and it covers prereleases (which aren't tagged). Self-guards: skips when - package_name is empty. Assumes the read-version step (id: read-version) has run. + Checks whether the gem version is already published to RubyGems and exports + `already_published` (true|false). Read-only registry HTTP query (no auth). The + registry — not the git tag — is the source of truth, so this catches a duplicate even + if the tag was force-deleted or never pushed, and it covers prereleases. This is the + SINGLE registry-existence check shared by validate (pre-gate) and ship (at push time), + so the two never drift. Self-guards: skips when package_name is empty. An unexpected + HTTP status aborts — we never guess. - @param package_name [String] expr for the RubyGems gem name. Default: ${{ inputs.package_name }} + `severity` controls what happens when the version IS already published: + fail (default) → error + abort · warn → warning + continue · off → just report. + + @param package_name [String] expr for the RubyGems gem name. Default: ${{ inputs.package_name }} + @param version [String] expr for the version to check. Default: ${{ steps.read-version.outputs.version }} + @param severity [String] fail | warn | off. Default: fail -%> - name: Check RubyGems version availability + id: version-check shell: bash env: PACKAGE_NAME: <%= locals.fetch(:package_name) { "${{ inputs.package_name }}" } %> - VERSION: ${{ steps.read-version.outputs.version }} + VERSION: <%= locals.fetch(:version) { "${{ steps.read-version.outputs.version }}" } %> + SEVERITY: <%= locals.fetch(:severity) { "fail" } %> run: | if [ -z "$PACKAGE_NAME" ]; then echo "No package_name provided; skipping RubyGems availability check." exit 0 fi + echo "version_url=https://rubygems.org/gems/$PACKAGE_NAME/versions/$VERSION" >> "$GITHUB_OUTPUT" CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ --retry 3 --retry-connrefused --max-time 30 \ "https://rubygems.org/api/v2/rubygems/$PACKAGE_NAME/versions/$VERSION.json") case "$CODE" in 200) - echo "::error title=Version already published::$PACKAGE_NAME $VERSION already exists on RubyGems — has the version been bumped?" - exit 1 + echo "already_published=true" >> "$GITHUB_OUTPUT" + if [ "$SEVERITY" = "off" ]; then + echo "$PACKAGE_NAME $VERSION is already on RubyGems." + elif [ "$SEVERITY" = "warn" ]; then + echo "::warning title=Already published::$PACKAGE_NAME $VERSION is already on RubyGems." + else + echo "::error title=Version already published::$PACKAGE_NAME $VERSION already exists on RubyGems — has the version been bumped?" + exit 1 + fi ;; 404) + echo "already_published=false" >> "$GITHUB_OUTPUT" echo "$PACKAGE_NAME $VERSION is available on RubyGems." ;; *) diff --git a/templates/steps/lang/ruby/rubygems/push.yml.erb b/templates/steps/lang/ruby/rubygems/push.yml.erb index 85c3777..ccb62bf 100644 --- a/templates/steps/lang/ruby/rubygems/push.yml.erb +++ b/templates/steps/lang/ruby/rubygems/push.yml.erb @@ -5,12 +5,18 @@ environment if gated). Shared by turnkey build-and-ship and custom ship-package; only the gem's location differs. + Idempotency (present → skip) is handled by the caller: ship renders the shared + lang/ruby/rubygems/availability check and gates this step with `if: !already_published`. + @param gem [String] gem glob, relative to working_directory. Default: pkg/*.gem -%> - name: Configure RubyGems credentials (OIDC trusted publishing) if: ${{ inputs.dry_run != 'true' }} uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0 - name: Push gem to RubyGems + # Idempotency: skipped when the caller's already_published fact is 'true' (from configure). + # Empty (turnkey, no such fact) → runs. + if: ${{ inputs.already_published != 'true' }} shell: bash working-directory: ${{ inputs.working_directory }} env: diff --git a/templates/steps/release/build-manifest.yml.erb b/templates/steps/release/build-manifest.yml.erb new file mode 100644 index 0000000..e582c31 --- /dev/null +++ b/templates/steps/release/build-manifest.yml.erb @@ -0,0 +1,79 @@ +<%# + Assembles ONE package manifest entry (compact JSON) from package-level facts + + per-registry facts (npm_/pypi_/rubygems_ prefixes). The single home of the package/ + registry schema — rendered by build-package-manifest (standalone) AND by configure + (which emits its lane's manifest). Empties dropped; already_published → boolean; + version_url is carried in (the availability step is the single URL home). Sets id: + build with a `package` output. + + All params default to empty — callers pass only what they have. + @param label,emoji,tag,prev_release,pr_list,notes,github_release package-level + @param npm_name,npm_version,npm_channel,npm_version_url,npm_already_published npm registry + @param pypi_name,pypi_version,pypi_version_url,pypi_already_published pypi registry + @param rubygems_name,rubygems_version,rubygems_version_url,rubygems_already_published rubygems registry +-%> +- name: Build package manifest entry + id: build + shell: bash + env: + LABEL: "<%= locals.fetch(:label) { "" } %>" + EMOJI: "<%= locals.fetch(:emoji) { "" } %>" + TAG: "<%= locals.fetch(:tag) { "" } %>" + PREV_RELEASE: "<%= locals.fetch(:prev_release) { "" } %>" + PR_LIST: "<%= locals.fetch(:pr_list) { "" } %>" + NOTES: "<%= locals.fetch(:notes) { "" } %>" + GITHUB_RELEASE: "<%= locals.fetch(:github_release) { "" } %>" + NPM_NAME: "<%= locals.fetch(:npm_name) { "" } %>" + NPM_VERSION: "<%= locals.fetch(:npm_version) { "" } %>" + NPM_CHANNEL: "<%= locals.fetch(:npm_channel) { "" } %>" + NPM_VERSION_URL: "<%= locals.fetch(:npm_version_url) { "" } %>" + NPM_ALREADY_PUBLISHED: "<%= locals.fetch(:npm_already_published) { "" } %>" + PYPI_NAME: "<%= locals.fetch(:pypi_name) { "" } %>" + PYPI_VERSION: "<%= locals.fetch(:pypi_version) { "" } %>" + PYPI_VERSION_URL: "<%= locals.fetch(:pypi_version_url) { "" } %>" + PYPI_ALREADY_PUBLISHED: "<%= locals.fetch(:pypi_already_published) { "" } %>" + RUBYGEMS_NAME: "<%= locals.fetch(:rubygems_name) { "" } %>" + RUBYGEMS_VERSION: "<%= locals.fetch(:rubygems_version) { "" } %>" + RUBYGEMS_VERSION_URL: "<%= locals.fetch(:rubygems_version_url) { "" } %>" + RUBYGEMS_ALREADY_PUBLISHED: "<%= locals.fetch(:rubygems_already_published) { "" } %>" + run: | + set -euo pipefail + if ! command -v jq >/dev/null 2>&1; then + echo "::error title=jq not found::build-manifest requires jq." + exit 1 + fi + + REGISTRIES='{}' + # Add one registry entry (keyed by id) when its name/version are given. version_url is + # carried in (from the availability check); empties dropped; already_published → boolean. + add_registry() { + local reg="$1" name="$2" version="$3" channel="$4" ap="$5" url="$6" + { [ -z "$name" ] && [ -z "$version" ]; } && return 0 + local entry + entry="$(jq -nc \ + --arg name "$name" --arg version "$version" --arg url "$url" \ + --arg channel "$channel" --arg ap "$ap" \ + '{name:$name, version:$version, version_url:$url, channel:$channel} + | with_entries(select(.value != "")) + | if $ap=="true" then .already_published=true + elif $ap=="false" then .already_published=false else . end')" + REGISTRIES="$(jq -c --arg r "$reg" --argjson e "$entry" '. + {($r): $e}' <<<"$REGISTRIES")" + } + add_registry npm "$NPM_NAME" "$NPM_VERSION" "$NPM_CHANNEL" "$NPM_ALREADY_PUBLISHED" "$NPM_VERSION_URL" + add_registry pypi "$PYPI_NAME" "$PYPI_VERSION" "" "$PYPI_ALREADY_PUBLISHED" "$PYPI_VERSION_URL" + add_registry rubygems "$RUBYGEMS_NAME" "$RUBYGEMS_VERSION" "" "$RUBYGEMS_ALREADY_PUBLISHED" "$RUBYGEMS_VERSION_URL" + + # Package-level object; drop empties, then attach registries only when non-empty. + PACKAGE="$(jq -nc \ + --arg label "$LABEL" --arg emoji "$EMOJI" --arg tag "$TAG" \ + --arg prev_release "$PREV_RELEASE" --arg pr_list "$PR_LIST" --arg notes "$NOTES" \ + --arg github_release "$GITHUB_RELEASE" \ + '{label:$label, emoji:$emoji, tag:$tag, prev_release:$prev_release, + pr_list:$pr_list, notes:$notes, github_release:$github_release} + | with_entries(select(.value != ""))')" + if [ "$REGISTRIES" != "{}" ]; then + PACKAGE="$(jq -c --argjson r "$REGISTRIES" '. + {registries:$r}' <<<"$PACKAGE")" + fi + + echo "package=$PACKAGE" >> "$GITHUB_OUTPUT" + echo "Built package manifest entry: $(jq -c '{keys:keys, registries:(.registries // {} | keys)}' <<<"$PACKAGE")" diff --git a/templates/steps/release/checks.yml.erb b/templates/steps/release/checks.yml.erb index ada9bb9..0188b09 100644 --- a/templates/steps/release/checks.yml.erb +++ b/templates/steps/release/checks.yml.erb @@ -25,7 +25,20 @@ CHECK_TAG_UNUSED: <%= locals.fetch(:check_tag_unused) { "${{ inputs.check_tag_unused }}" } %> CHECK_NOTES: <%= locals.fetch(:check_notes_not_blank) { "${{ inputs.check_notes_not_blank }}" } %> ENFORCE_RELEASE_BRANCH: <%= locals.fetch(:enforce_release_branch) { "${{ inputs.enforce_release_branch }}" } %> + CHECK_VERSION_UNPUBLISHED: <%= locals.fetch(:check_version_unpublished) { "${{ inputs.check_version_unpublished }}" } %> + ALREADY_PUBLISHED: <%= locals.fetch(:already_published) { "${{ inputs.already_published }}" } %> run: | + # Version not already published on the registry (fact from configure's availability check; + # covers prereleases, which aren't git-tagged). Warn (not fail) on a dry run. + if [ "$CHECK_VERSION_UNPUBLISHED" = "true" ] && [ "$ALREADY_PUBLISHED" = "true" ]; then + if [ "$DRY_RUN" = "true" ]; then + echo "::warning title=Version already published::the version is already on the registry — skipping in dry run." + else + echo "::error title=Version already published::the version is already on the registry — has the version been bumped?" + exit 1 + fi + fi + # Tag not already used (covers stable releases; prereleases are covered by the availability check) if [ "$CHECK_TAG_UNUSED" = "true" ] && git rev-parse "$TAG" >/dev/null 2>&1; then if [ "$DRY_RUN" = "true" ]; then diff --git a/templates/steps/release/create-github-release.yml.erb b/templates/steps/release/create-github-release.yml.erb index 183261d..1e6b1de 100644 --- a/templates/steps/release/create-github-release.yml.erb +++ b/templates/steps/release/create-github-release.yml.erb @@ -32,11 +32,17 @@ echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "(unavailable)" else echo "$NOTES_B64" | base64 -d 2>/dev/null > /tmp/release-notes.md - gh release create "$TAG" \ - --title "$TITLE" \ - --notes-file /tmp/release-notes.md \ - --target "$SHA" - # Attach the SBOM when one was generated for this release. + # Idempotent: a heal re-run of an already-shipped lane finds its tag/release already + # present — skip creation rather than hard-fail on the existing tag. + if gh release view "$TAG" >/dev/null 2>&1; then + echo "GitHub release $TAG already exists; skipping creation." + else + gh release create "$TAG" \ + --title "$TITLE" \ + --notes-file /tmp/release-notes.md \ + --target "$SHA" + fi + # Attach the SBOM when one was generated for this release (--clobber → idempotent). if [ "$SBOM_ENABLED" = "true" ] && [ -f "$WORKING_DIRECTORY/sbom.json" ]; then gh release upload "$TAG" "$WORKING_DIRECTORY/sbom.json" --clobber fi diff --git a/templates/steps/release/lang/js/notify-published.yml.erb b/templates/steps/release/lang/js/notify-published.yml.erb index 09a9f98..dd510ac 100644 --- a/templates/steps/release/lang/js/notify-published.yml.erb +++ b/templates/steps/release/lang/js/notify-published.yml.erb @@ -18,4 +18,6 @@ fi echo "links=$LINKS" >> $GITHUB_OUTPUT -<%= render_step('release/notify-published', indent: 0, links: "${{ steps.links.outputs.links }}") %> +<%= render_step('release/notify-published', indent: 0, + links: "${{ steps.links.outputs.links }}", + already_published: "${{ inputs.already_published }}") %> diff --git a/templates/steps/release/lang/py/notify-published.yml.erb b/templates/steps/release/lang/py/notify-published.yml.erb index 7d7d115..7d558f5 100644 --- a/templates/steps/release/lang/py/notify-published.yml.erb +++ b/templates/steps/release/lang/py/notify-published.yml.erb @@ -18,4 +18,6 @@ fi echo "links=$LINKS" >> "$GITHUB_OUTPUT" -<%= render_step('release/notify-published', indent: 0, links: "${{ steps.links.outputs.links }}") %> +<%= render_step('release/notify-published', indent: 0, + links: "${{ steps.links.outputs.links }}", + already_published: "${{ inputs.already_published }}") %> diff --git a/templates/steps/release/lang/ruby/notify-published.yml.erb b/templates/steps/release/lang/ruby/notify-published.yml.erb index e1a811f..720a554 100644 --- a/templates/steps/release/lang/ruby/notify-published.yml.erb +++ b/templates/steps/release/lang/ruby/notify-published.yml.erb @@ -17,4 +17,6 @@ fi echo "links=$LINKS" >> $GITHUB_OUTPUT -<%= render_step('release/notify-published', indent: 0, links: "${{ steps.links.outputs.links }}") %> +<%= render_step('release/notify-published', indent: 0, + links: "${{ steps.links.outputs.links }}", + already_published: "${{ inputs.already_published }}") %> diff --git a/templates/steps/release/notify-published.yml.erb b/templates/steps/release/notify-published.yml.erb index 3857e3d..875ab05 100644 --- a/templates/steps/release/notify-published.yml.erb +++ b/templates/steps/release/notify-published.yml.erb @@ -15,6 +15,7 @@ BRANCH: ${{ inputs.branch }} ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} DRY_RUN: ${{ inputs.dry_run }} + ALREADY_PUBLISHED: <%= locals.fetch(:already_published) { "${{ steps.version-check.outputs.already_published }}" } %> LINKS_JSON: <%= locals.fetch(:links) { "${{ inputs.links }}" } %> RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | @@ -29,6 +30,8 @@ if [ "$DRY_RUN" = "true" ]; then echo "## $SUBJECT $TAG — dry run complete" >> $GITHUB_STEP_SUMMARY + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + echo "## $SUBJECT $TAG — already published, skipped ⏭️" >> $GITHUB_STEP_SUMMARY else echo "## $SUBJECT $TAG — published ✅" >> $GITHUB_STEP_SUMMARY fi @@ -38,6 +41,10 @@ echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY echo "> Dry run: Nothing was tagged or published." >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY + echo "> This version was already published; this run published and tagged nothing." >> $GITHUB_STEP_SUMMARY + exit 0 fi if [ "$ON_RELEASE_BRANCH" = "false" ]; then @@ -76,6 +83,7 @@ ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} PREV_RELEASE: ${{ inputs.prev_release }} DRY_RUN: ${{ inputs.dry_run }} + ALREADY_PUBLISHED: <%= locals.fetch(:already_published) { "${{ steps.version-check.outputs.already_published }}" } %> SLACK_MENTION: ${{ inputs.slack_mention }} LINKS_JSON: <%= locals.fetch(:links) { "${{ inputs.links }}" } %> RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -98,10 +106,18 @@ if [ "$DRY_RUN" = "true" ]; then TEXT="$EMOJI *$SUBJECT $TAG* complete" TEXT="$TEXT\n> :information_source: _Dry run: nothing tagged or published._" + elif [ "$ALREADY_PUBLISHED" = "true" ]; then + TEXT="$EMOJI *$SUBJECT $TAG* — already published, skipped" else TEXT="$EMOJI *$SUBJECT $TAG* published" fi + # A skip published/tagged nothing — keep the message to the one line, no branch/links/PRs. + if [ "$ALREADY_PUBLISHED" = "true" ]; then + { echo 'text<> $GITHUB_OUTPUT + exit 0 + fi + if [ "$ON_RELEASE_BRANCH" = "false" ]; then BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" TEXT="$TEXT\n> ⚠️ NOT on release branch: <$BRANCH_URL|$BRANCH>" diff --git a/templates/steps/release/prepare-notes.yml.erb b/templates/steps/release/prepare-notes.yml.erb new file mode 100644 index 0000000..0f93148 --- /dev/null +++ b/templates/steps/release/prepare-notes.yml.erb @@ -0,0 +1,48 @@ +<%# + Fetches release notes + formats the PR list via the GitHub `generate-notes` API. + Requires contents: write on the calling job. Sets id: fetch with `pr_list` + (x1f-delimited, safe for job outputs) and `notes` (base64) outputs. When prev_release + is a full SHA, notes are unavailable (no reliable cross-strategy PR extraction) and the + outputs are empty. + + @param release_tag [String] expr for the release tag. Default: ${{ inputs.release_tag }} + @param sha [String] expr for the commit SHA. Default: ${{ inputs.sha }} + @param prev_release [String] expr for the notes anchor. Default: ${{ inputs.prev_release }} +-%> +- name: Fetch PR list and release notes + id: fetch + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: <%= locals.fetch(:release_tag) { "${{ inputs.release_tag }}" } %> + SHA: <%= locals.fetch(:sha) { "${{ inputs.sha }}" } %> + PREV_RELEASE: <%= locals.fetch(:prev_release) { "${{ inputs.prev_release }}" } %> + run: | + # If prev_release is a full SHA, notes are unavailable. + if echo "$PREV_RELEASE" | grep -qE '^[0-9a-f]{40}$'; then + echo "SHA provided for prev_release — notes unavailable for SHA ranges." + echo "pr_list=" >> $GITHUB_OUTPUT + echo "notes=$(echo "" | base64 -w 0)" >> $GITHUB_OUTPUT + exit 0 + fi + + BODY=$(gh api "repos/$GITHUB_REPOSITORY/releases/generate-notes" \ + --method POST \ + --field tag_name="$RELEASE_TAG" \ + --field target_commitish="$SHA" \ + ${PREV_RELEASE:+--field previous_tag_name="$PREV_RELEASE"} \ + --jq '.body' 2>/dev/null || echo "") + + PR_LIST=$(echo "$BODY" \ + | grep "^\* " \ + | grep -v "made their first contribution" \ + | grep -v "Full Changelog" \ + | head -10 \ + | sed 's|^\* ||' \ + | sed 's| by @[^ ]*||' \ + | sed 's@ in \(https://[^ ]*/pull/\([0-9]*\)\)@ (<\1|#\2>)@' \ + | sed 's/^/• /' \ + | tr '\n' $'\x1f' || echo "") + + echo "pr_list=$PR_LIST" >> $GITHUB_OUTPUT + echo "notes=$(echo "$BODY" | base64 -w 0)" >> $GITHUB_OUTPUT diff --git a/templates/workflows/release/js/custom.yml.erb b/templates/workflows/release/js/custom.yml.erb index e0ea7f5..e47258b 100644 --- a/templates/workflows/release/js/custom.yml.erb +++ b/templates/workflows/release/js/custom.yml.erb @@ -66,11 +66,13 @@ on: default: false jobs: + # FACT-FIND — derive version/tag/notes, check the registry, and emit the package manifest. + # contents: write is for the releases/generate-notes API. configure: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} @@ -80,6 +82,9 @@ jobs: commit_message: ${{ steps.configure.outputs.commit_message }} channel: ${{ steps.configure.outputs.channel }} github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - name: Configure release id: configure @@ -88,28 +93,14 @@ jobs: sha: ${{ inputs.sha }} working_directory: <%= working_directory %> release_type: ${{ inputs.release_type }} - channel: ${{ inputs.channel }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # releases/generate-notes API - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - name: Prepare release - id: prepare - uses: braintrustdata/sdk-actions/actions/release/prepare@<%= ref %> - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha }} - prev_release: ${{ inputs.prev_release || needs.configure.outputs.prev_release }} + npm_channel: ${{ inputs.channel }} + npm_package_name: '<%= package_name %>' + package_label: '<%= package_name %>' + emoji: '<%= emoji %>' + prev_release: ${{ inputs.prev_release }} validate: - needs: [configure, prepare] + needs: [configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -118,23 +109,22 @@ jobs: - name: Validate release uses: braintrustdata/sdk-actions/actions/release/lang/js/validate@<%= ref %> with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} working_directory: <%= working_directory %> node_version: <%= node_version %> - package_name: '<%= package_name %>' release_tag: ${{ needs.configure.outputs.release_tag }} channel: ${{ needs.configure.outputs.channel }} allowed_channels: '<%= allowed_channels %>' on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} build: 'false' # the pack job owns the build sbom: 'false' # the pack job generates + attests the SBOM # BUILD side — unprivileged: builds + packs + attests the tarball, NO publish credentials. pack: - needs: [configure, prepare, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -152,9 +142,11 @@ jobs: dry_run: ${{ inputs.dry_run }} - name: Stage artifact + env: + TARBALL: ${{ steps.pack.outputs.tarball }} run: | mkdir -p artifact - cp "${{ steps.pack.outputs.tarball }}" artifact/ + cp "$TARBALL" artifact/ cp <%= working_directory %>/sbom.json artifact/ 2>/dev/null || true - name: Upload artifact @@ -166,7 +158,7 @@ jobs: if-no-files-found: error request-approval: - needs: [configure, prepare, pack] + needs: [configure, validate, pack] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -174,23 +166,18 @@ jobs: - name: Request release approval uses: braintrustdata/sdk-actions/actions/release/request-approval@<%= ref %> with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run }} slack_token: ${{ secrets.<%= slack_token_secret %> }} slack_channel: ${{ vars.<%= slack_channel_var %> }} - label: '<%= package_name %>' - emoji: '<%= emoji %>' # PUBLISH side — gated: verifies the attestation against the downloaded tarball, then publishes it. ship-package: - needs: [configure, prepare, pack, request-approval] + needs: [configure, validate, pack, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: ${{ inputs.dry_run && '<%= dry_run_environment %>' || '<%= publish_environment %>' }} @@ -217,6 +204,7 @@ jobs: sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} github_release: ${{ needs.configure.outputs.github_release }} version: ${{ needs.configure.outputs.version }} access: '<%= access %>' @@ -224,11 +212,10 @@ jobs: channel: ${{ needs.configure.outputs.channel }} package_name: '<%= package_name %>' label: '<%= package_name %>' - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ secrets.<%= slack_token_secret %> }} slack_channel: ${{ vars.<%= slack_channel_var %> }} emoji: '<%= emoji %>' diff --git a/templates/workflows/release/js/turnkey.yml.erb b/templates/workflows/release/js/turnkey.yml.erb index 376cb6f..cfc45d2 100644 --- a/templates/workflows/release/js/turnkey.yml.erb +++ b/templates/workflows/release/js/turnkey.yml.erb @@ -64,11 +64,13 @@ on: default: false jobs: + # FACT-FIND — derive version/tag/notes, check the registry, and emit the package manifest. + # contents: write is for the releases/generate-notes API. configure: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} @@ -78,6 +80,9 @@ jobs: commit_message: ${{ steps.configure.outputs.commit_message }} channel: ${{ steps.configure.outputs.channel }} github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - name: Configure release id: configure @@ -86,28 +91,14 @@ jobs: sha: ${{ inputs.sha }} working_directory: <%= working_directory %> release_type: ${{ inputs.release_type }} - channel: ${{ inputs.channel }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # releases/generate-notes API - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - name: Prepare release - id: prepare - uses: braintrustdata/sdk-actions/actions/release/prepare@<%= ref %> - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha }} - prev_release: ${{ inputs.prev_release || needs.configure.outputs.prev_release }} + npm_channel: ${{ inputs.channel }} + npm_package_name: '<%= package_name %>' + package_label: '<%= package_name %>' + emoji: '<%= emoji %>' + prev_release: ${{ inputs.prev_release }} validate: - needs: [configure, prepare] + needs: [configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -116,20 +107,19 @@ jobs: - name: Validate release uses: braintrustdata/sdk-actions/actions/release/lang/js/validate@<%= ref %> with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} working_directory: <%= working_directory %> node_version: <%= node_version %> - package_name: '<%= package_name %>' release_tag: ${{ needs.configure.outputs.release_tag }} channel: ${{ needs.configure.outputs.channel }} allowed_channels: '<%= allowed_channels %>' on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} request-approval: - needs: [configure, prepare, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -137,22 +127,17 @@ jobs: - name: Request release approval uses: braintrustdata/sdk-actions/actions/release/request-approval@<%= ref %> with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run }} slack_token: ${{ secrets.<%= slack_token_secret %> }} slack_channel: ${{ vars.<%= slack_channel_var %> }} - label: '<%= package_name %>' - emoji: '<%= emoji %>' build-and-ship: - needs: [configure, prepare, validate, request-approval] + needs: [configure, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: ${{ inputs.dry_run && '<%= dry_run_environment %>' || '<%= publish_environment %>' }} @@ -169,6 +154,7 @@ jobs: node_version: <%= node_version %> dry_run: ${{ inputs.dry_run }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} github_release: ${{ needs.configure.outputs.github_release }} version: ${{ needs.configure.outputs.version }} access: '<%= access %>' @@ -176,11 +162,10 @@ jobs: channel: ${{ needs.configure.outputs.channel }} package_name: '<%= package_name %>' label: '<%= package_name %>' - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ secrets.<%= slack_token_secret %> }} slack_channel: ${{ vars.<%= slack_channel_var %> }} emoji: '<%= emoji %>' diff --git a/templates/workflows/release/py/custom.yml.erb b/templates/workflows/release/py/custom.yml.erb index 1338d38..5d1a329 100644 --- a/templates/workflows/release/py/custom.yml.erb +++ b/templates/workflows/release/py/custom.yml.erb @@ -58,11 +58,13 @@ on: default: false jobs: + # FACT-FIND — derive version/tag/notes, check the registry, and emit the package manifest. + # contents: write is for the releases/generate-notes API. configure: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} @@ -71,6 +73,9 @@ jobs: on_release_branch: ${{ steps.configure.outputs.on_release_branch }} commit_message: ${{ steps.configure.outputs.commit_message }} github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - name: Configure release id: configure @@ -80,27 +85,13 @@ jobs: sha: ${{ inputs.sha }} working_directory: <%= working_directory %> release_type: ${{ inputs.release_type }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # releases/generate-notes API - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - name: Prepare release - id: prepare - uses: braintrustdata/sdk-actions/actions/release/prepare@<%= ref %> - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha }} - prev_release: ${{ inputs.prev_release || needs.configure.outputs.prev_release }} + pypi_package_name: <%= package_name %> + package_label: <%= package_name %> + emoji: '<%= emoji %>' + prev_release: ${{ inputs.prev_release }} validate: - needs: [configure, prepare] + needs: [configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -109,21 +100,20 @@ jobs: - name: Validate release uses: braintrustdata/sdk-actions/actions/release/lang/py/validate@<%= ref %> with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} working_directory: <%= working_directory %> python_version: <%= python_version %> - package_name: <%= package_name %> release_tag: ${{ needs.configure.outputs.release_tag }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} build: 'false' # the pack job owns the build sbom: 'false' # the pack job generates + attests the SBOM # BUILD side — unprivileged: builds + attests dist/, NO publish credentials. pack: - needs: [configure, prepare, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -154,7 +144,7 @@ jobs: if-no-files-found: error request-approval: - needs: [configure, prepare, pack] + needs: [configure, validate, pack] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -162,23 +152,18 @@ jobs: - name: Request release approval uses: braintrustdata/sdk-actions/actions/release/request-approval@<%= ref %> with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run }} slack_token: ${{ secrets.<%= slack_token_secret %> }} slack_channel: ${{ vars.<%= slack_channel_var %> }} - label: <%= package_name %> - emoji: '<%= emoji %>' # PUBLISH side — gated: verifies the attestation against the downloaded dists, then publishes them. ship-package: - needs: [configure, prepare, pack, request-approval] + needs: [configure, validate, pack, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: ${{ inputs.dry_run && '<%= dry_run_environment %>' || '<%= publish_environment %>' }} @@ -200,15 +185,15 @@ jobs: sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} github_release: ${{ needs.configure.outputs.github_release }} version: ${{ needs.configure.outputs.version }} package_name: <%= package_name %> label: <%= package_name %> - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ secrets.<%= slack_token_secret %> }} slack_channel: ${{ vars.<%= slack_channel_var %> }} emoji: '<%= emoji %>' diff --git a/templates/workflows/release/py/turnkey.yml.erb b/templates/workflows/release/py/turnkey.yml.erb index 6c38332..04d2410 100644 --- a/templates/workflows/release/py/turnkey.yml.erb +++ b/templates/workflows/release/py/turnkey.yml.erb @@ -57,11 +57,13 @@ on: default: false jobs: + # FACT-FIND — derive version/tag/notes, check the registry, and emit the package manifest. + # contents: write is for the releases/generate-notes API. configure: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} @@ -70,6 +72,9 @@ jobs: on_release_branch: ${{ steps.configure.outputs.on_release_branch }} commit_message: ${{ steps.configure.outputs.commit_message }} github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - name: Configure release id: configure @@ -79,27 +84,13 @@ jobs: sha: ${{ inputs.sha }} working_directory: <%= working_directory %> release_type: ${{ inputs.release_type }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # releases/generate-notes API - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - name: Prepare release - id: prepare - uses: braintrustdata/sdk-actions/actions/release/prepare@<%= ref %> - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha }} - prev_release: ${{ inputs.prev_release || needs.configure.outputs.prev_release }} + pypi_package_name: <%= package_name %> + package_label: <%= package_name %> + emoji: '<%= emoji %>' + prev_release: ${{ inputs.prev_release }} validate: - needs: [configure, prepare] + needs: [configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -108,18 +99,17 @@ jobs: - name: Validate release uses: braintrustdata/sdk-actions/actions/release/lang/py/validate@<%= ref %> with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} working_directory: <%= working_directory %> python_version: <%= python_version %> - package_name: <%= package_name %> release_tag: ${{ needs.configure.outputs.release_tag }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} request-approval: - needs: [configure, prepare, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -127,22 +117,17 @@ jobs: - name: Request release approval uses: braintrustdata/sdk-actions/actions/release/request-approval@<%= ref %> with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run }} slack_token: ${{ secrets.<%= slack_token_secret %> }} slack_channel: ${{ vars.<%= slack_channel_var %> }} - label: <%= package_name %> - emoji: '<%= emoji %>' build-and-ship: - needs: [configure, prepare, validate, request-approval] + needs: [configure, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: ${{ inputs.dry_run && '<%= dry_run_environment %>' || '<%= publish_environment %>' }} @@ -159,15 +144,15 @@ jobs: python_version: <%= python_version %> dry_run: ${{ inputs.dry_run }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} github_release: ${{ needs.configure.outputs.github_release }} version: ${{ needs.configure.outputs.version }} package_name: <%= package_name %> label: <%= package_name %> - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ secrets.<%= slack_token_secret %> }} slack_channel: ${{ vars.<%= slack_channel_var %> }} emoji: '<%= emoji %>' diff --git a/templates/workflows/release/ruby/custom.yml.erb b/templates/workflows/release/ruby/custom.yml.erb index 0501226..678dd76 100644 --- a/templates/workflows/release/ruby/custom.yml.erb +++ b/templates/workflows/release/ruby/custom.yml.erb @@ -60,11 +60,13 @@ on: default: false jobs: + # FACT-FIND — derive version/tag/notes, check the registry, and emit the package manifest. + # contents: write is for the releases/generate-notes API. configure: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} @@ -73,6 +75,9 @@ jobs: on_release_branch: ${{ steps.configure.outputs.on_release_branch }} commit_message: ${{ steps.configure.outputs.commit_message }} github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - name: Configure release id: configure @@ -83,27 +88,13 @@ jobs: sha: ${{ inputs.sha }} working_directory: <%= working_directory %> release_type: ${{ inputs.release_type }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # releases/generate-notes API - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - name: Prepare release - id: prepare - uses: braintrustdata/sdk-actions/actions/release/prepare@<%= ref %> - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha }} - prev_release: ${{ inputs.prev_release || needs.configure.outputs.prev_release }} + rubygems_package_name: <%= gem_name %> + package_label: <%= gem_name %> + emoji: '<%= emoji %>' + prev_release: ${{ inputs.prev_release }} validate: - needs: [configure, prepare] + needs: [configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -112,14 +103,13 @@ jobs: - name: Validate release uses: braintrustdata/sdk-actions/actions/release/lang/ruby/validate@<%= ref %> with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} working_directory: <%= working_directory %> - package_name: <%= gem_name %> release_tag: ${{ needs.configure.outputs.release_tag }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} <%- unless ruby_version.to_s.empty? -%> ruby_version: <%= ruby_version %> <%- end -%> @@ -128,7 +118,7 @@ jobs: # BUILD side — unprivileged: builds + attests the gem, NO publish credentials. pack: - needs: [configure, prepare, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -161,7 +151,7 @@ jobs: if-no-files-found: error request-approval: - needs: [configure, prepare, pack] + needs: [configure, validate, pack] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -169,23 +159,18 @@ jobs: - name: Request release approval uses: braintrustdata/sdk-actions/actions/release/request-approval@<%= ref %> with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run }} slack_token: ${{ secrets.<%= slack_token_secret %> }} slack_channel: ${{ vars.<%= slack_channel_var %> }} - label: <%= gem_name %> - emoji: '<%= emoji %>' # PUBLISH side — gated: verifies the attestation against the downloaded gem, then publishes it. ship-package: - needs: [configure, prepare, pack, request-approval] + needs: [configure, validate, pack, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: ${{ inputs.dry_run && '<%= dry_run_environment %>' || '<%= publish_environment %>' }} @@ -207,14 +192,15 @@ jobs: sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} github_release: ${{ needs.configure.outputs.github_release }} gem_name: <%= gem_name %> + version: ${{ needs.configure.outputs.version }} label: <%= gem_name %> - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ secrets.<%= slack_token_secret %> }} slack_channel: ${{ vars.<%= slack_channel_var %> }} emoji: '<%= emoji %>' diff --git a/templates/workflows/release/ruby/turnkey.yml.erb b/templates/workflows/release/ruby/turnkey.yml.erb index 8292860..c0aae5e 100644 --- a/templates/workflows/release/ruby/turnkey.yml.erb +++ b/templates/workflows/release/ruby/turnkey.yml.erb @@ -59,11 +59,13 @@ on: default: false jobs: + # FACT-FIND — derive version/tag/notes, check the registry, and emit the package manifest. + # contents: write is for the releases/generate-notes API. configure: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} @@ -72,6 +74,9 @@ jobs: on_release_branch: ${{ steps.configure.outputs.on_release_branch }} commit_message: ${{ steps.configure.outputs.commit_message }} github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - name: Configure release id: configure @@ -82,27 +87,13 @@ jobs: sha: ${{ inputs.sha }} working_directory: <%= working_directory %> release_type: ${{ inputs.release_type }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # releases/generate-notes API - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - name: Prepare release - id: prepare - uses: braintrustdata/sdk-actions/actions/release/prepare@<%= ref %> - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha }} - prev_release: ${{ inputs.prev_release || needs.configure.outputs.prev_release }} + rubygems_package_name: <%= gem_name %> + package_label: <%= gem_name %> + emoji: '<%= emoji %>' + prev_release: ${{ inputs.prev_release }} validate: - needs: [configure, prepare] + needs: [configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -111,20 +102,19 @@ jobs: - name: Validate release uses: braintrustdata/sdk-actions/actions/release/lang/ruby/validate@<%= ref %> with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} working_directory: <%= working_directory %> - package_name: <%= gem_name %> release_tag: ${{ needs.configure.outputs.release_tag }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} <%- unless ruby_version.to_s.empty? -%> ruby_version: <%= ruby_version %> <%- end -%> request-approval: - needs: [configure, prepare, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -132,22 +122,17 @@ jobs: - name: Request release approval uses: braintrustdata/sdk-actions/actions/release/request-approval@<%= ref %> with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run }} slack_token: ${{ secrets.<%= slack_token_secret %> }} slack_channel: ${{ vars.<%= slack_channel_var %> }} - label: <%= gem_name %> - emoji: '<%= emoji %>' build-and-ship: - needs: [configure, prepare, validate, request-approval] + needs: [configure, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: ${{ inputs.dry_run && '<%= dry_run_environment %>' || '<%= publish_environment %>' }} @@ -166,12 +151,12 @@ jobs: <%- end -%> dry_run: ${{ inputs.dry_run }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} github_release: ${{ needs.configure.outputs.github_release }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ secrets.<%= slack_token_secret %> }} slack_channel: ${{ vars.<%= slack_channel_var %> }} gem_name: <%= gem_name %> diff --git a/test/release/js/.github/workflows/release-custom.yml b/test/release/js/.github/workflows/release-custom.yml index 59da200..6cbf607 100644 --- a/test/release/js/.github/workflows/release-custom.yml +++ b/test/release/js/.github/workflows/release-custom.yml @@ -34,11 +34,13 @@ on: default: false jobs: + # FACT-FIND — derive version/tag/notes, check the registry, and emit the package manifest. + # contents: write is for the releases/generate-notes API. configure: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} @@ -48,6 +50,9 @@ jobs: commit_message: ${{ steps.configure.outputs.commit_message }} channel: ${{ steps.configure.outputs.channel }} github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - name: Configure release id: configure @@ -56,28 +61,14 @@ jobs: sha: ${{ inputs.sha }} working_directory: . release_type: ${{ inputs.release_type }} - channel: ${{ inputs.channel }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # releases/generate-notes API - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - name: Prepare release - id: prepare - uses: braintrustdata/sdk-actions/actions/release/prepare@38dcec863c910f6802c03a94ab9aeb79931a0a3a - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha }} - prev_release: ${{ inputs.prev_release || needs.configure.outputs.prev_release }} + npm_channel: ${{ inputs.channel }} + npm_package_name: '@braintrust/bt-publishing-test' + package_label: '@braintrust/bt-publishing-test' + emoji: ':javascript:' + prev_release: ${{ inputs.prev_release }} validate: - needs: [configure, prepare] + needs: [configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -86,23 +77,22 @@ jobs: - name: Validate release uses: braintrustdata/sdk-actions/actions/release/lang/js/validate@38dcec863c910f6802c03a94ab9aeb79931a0a3a with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} working_directory: . node_version: .nvmrc - package_name: '@braintrust/bt-publishing-test' release_tag: ${{ needs.configure.outputs.release_tag }} channel: ${{ needs.configure.outputs.channel }} allowed_channels: 'latest,rc,next,beta' on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} build: 'false' # the pack job owns the build sbom: 'false' # the pack job generates + attests the SBOM # BUILD side — unprivileged: builds + packs + attests the tarball, NO publish credentials. pack: - needs: [configure, prepare, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -120,9 +110,11 @@ jobs: dry_run: ${{ inputs.dry_run }} - name: Stage artifact + env: + TARBALL: ${{ steps.pack.outputs.tarball }} run: | mkdir -p artifact - cp "${{ steps.pack.outputs.tarball }}" artifact/ + cp "$TARBALL" artifact/ cp ./sbom.json artifact/ 2>/dev/null || true - name: Upload artifact @@ -134,7 +126,7 @@ jobs: if-no-files-found: error request-approval: - needs: [configure, prepare, pack] + needs: [configure, validate, pack] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -142,23 +134,18 @@ jobs: - name: Request release approval uses: braintrustdata/sdk-actions/actions/release/request-approval@38dcec863c910f6802c03a94ab9aeb79931a0a3a with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run }} slack_token: ${{ secrets.SLACK_BOT_TOKEN }} slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} - label: '@braintrust/bt-publishing-test' - emoji: ':javascript:' # PUBLISH side — gated: verifies the attestation against the downloaded tarball, then publishes it. ship-package: - needs: [configure, prepare, pack, request-approval] + needs: [configure, validate, pack, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: ${{ inputs.dry_run && 'publish-dry-run' || 'publish' }} @@ -185,6 +172,7 @@ jobs: sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} github_release: ${{ needs.configure.outputs.github_release }} version: ${{ needs.configure.outputs.version }} access: 'public' @@ -192,11 +180,10 @@ jobs: channel: ${{ needs.configure.outputs.channel }} package_name: '@braintrust/bt-publishing-test' label: '@braintrust/bt-publishing-test' - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ secrets.SLACK_BOT_TOKEN }} slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} emoji: ':javascript:' diff --git a/test/release/js/.github/workflows/release.yml b/test/release/js/.github/workflows/release.yml index 15d819b..71c84c7 100644 --- a/test/release/js/.github/workflows/release.yml +++ b/test/release/js/.github/workflows/release.yml @@ -32,11 +32,13 @@ on: default: false jobs: + # FACT-FIND — derive version/tag/notes, check the registry, and emit the package manifest. + # contents: write is for the releases/generate-notes API. configure: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} @@ -46,6 +48,9 @@ jobs: commit_message: ${{ steps.configure.outputs.commit_message }} channel: ${{ steps.configure.outputs.channel }} github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - name: Configure release id: configure @@ -54,28 +59,14 @@ jobs: sha: ${{ inputs.sha }} working_directory: . release_type: ${{ inputs.release_type }} - channel: ${{ inputs.channel }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # releases/generate-notes API - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - name: Prepare release - id: prepare - uses: braintrustdata/sdk-actions/actions/release/prepare@38dcec863c910f6802c03a94ab9aeb79931a0a3a - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha }} - prev_release: ${{ inputs.prev_release || needs.configure.outputs.prev_release }} + npm_channel: ${{ inputs.channel }} + npm_package_name: '@braintrust/bt-publishing-test' + package_label: '@braintrust/bt-publishing-test' + emoji: ':javascript:' + prev_release: ${{ inputs.prev_release }} validate: - needs: [configure, prepare] + needs: [configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -84,20 +75,19 @@ jobs: - name: Validate release uses: braintrustdata/sdk-actions/actions/release/lang/js/validate@38dcec863c910f6802c03a94ab9aeb79931a0a3a with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} working_directory: . node_version: .nvmrc - package_name: '@braintrust/bt-publishing-test' release_tag: ${{ needs.configure.outputs.release_tag }} channel: ${{ needs.configure.outputs.channel }} allowed_channels: 'latest,rc,next,beta' on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} request-approval: - needs: [configure, prepare, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -105,22 +95,17 @@ jobs: - name: Request release approval uses: braintrustdata/sdk-actions/actions/release/request-approval@38dcec863c910f6802c03a94ab9aeb79931a0a3a with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run }} slack_token: ${{ secrets.SLACK_BOT_TOKEN }} slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} - label: '@braintrust/bt-publishing-test' - emoji: ':javascript:' build-and-ship: - needs: [configure, prepare, validate, request-approval] + needs: [configure, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: ${{ inputs.dry_run && 'publish-dry-run' || 'publish' }} @@ -137,6 +122,7 @@ jobs: node_version: .nvmrc dry_run: ${{ inputs.dry_run }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} github_release: ${{ needs.configure.outputs.github_release }} version: ${{ needs.configure.outputs.version }} access: 'public' @@ -144,11 +130,10 @@ jobs: channel: ${{ needs.configure.outputs.channel }} package_name: '@braintrust/bt-publishing-test' label: '@braintrust/bt-publishing-test' - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ secrets.SLACK_BOT_TOKEN }} slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} emoji: ':javascript:' diff --git a/test/release/py/.github/workflows/release-custom.yml b/test/release/py/.github/workflows/release-custom.yml index 275aba7..070cb65 100644 --- a/test/release/py/.github/workflows/release-custom.yml +++ b/test/release/py/.github/workflows/release-custom.yml @@ -29,11 +29,13 @@ on: default: false jobs: + # FACT-FIND — derive version/tag/notes, check the registry, and emit the package manifest. + # contents: write is for the releases/generate-notes API. configure: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} @@ -42,6 +44,9 @@ jobs: on_release_branch: ${{ steps.configure.outputs.on_release_branch }} commit_message: ${{ steps.configure.outputs.commit_message }} github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - name: Configure release id: configure @@ -51,27 +56,13 @@ jobs: sha: ${{ inputs.sha }} working_directory: . release_type: ${{ inputs.release_type }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # releases/generate-notes API - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - name: Prepare release - id: prepare - uses: braintrustdata/sdk-actions/actions/release/prepare@38dcec863c910f6802c03a94ab9aeb79931a0a3a - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha }} - prev_release: ${{ inputs.prev_release || needs.configure.outputs.prev_release }} + pypi_package_name: bt-publishing-test + package_label: bt-publishing-test + emoji: ':python:' + prev_release: ${{ inputs.prev_release }} validate: - needs: [configure, prepare] + needs: [configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -80,21 +71,20 @@ jobs: - name: Validate release uses: braintrustdata/sdk-actions/actions/release/lang/py/validate@38dcec863c910f6802c03a94ab9aeb79931a0a3a with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} working_directory: . python_version: .python-version - package_name: bt-publishing-test release_tag: ${{ needs.configure.outputs.release_tag }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} build: 'false' # the pack job owns the build sbom: 'false' # the pack job generates + attests the SBOM # BUILD side — unprivileged: builds + attests dist/, NO publish credentials. pack: - needs: [configure, prepare, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -125,7 +115,7 @@ jobs: if-no-files-found: error request-approval: - needs: [configure, prepare, pack] + needs: [configure, validate, pack] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -133,23 +123,18 @@ jobs: - name: Request release approval uses: braintrustdata/sdk-actions/actions/release/request-approval@38dcec863c910f6802c03a94ab9aeb79931a0a3a with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run }} slack_token: ${{ secrets.SLACK_BOT_TOKEN }} slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} - label: bt-publishing-test - emoji: ':python:' # PUBLISH side — gated: verifies the attestation against the downloaded dists, then publishes them. ship-package: - needs: [configure, prepare, pack, request-approval] + needs: [configure, validate, pack, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: ${{ inputs.dry_run && 'publish-dry-run' || 'publish' }} @@ -171,15 +156,15 @@ jobs: sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} github_release: ${{ needs.configure.outputs.github_release }} version: ${{ needs.configure.outputs.version }} package_name: bt-publishing-test label: bt-publishing-test - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ secrets.SLACK_BOT_TOKEN }} slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} emoji: ':python:' diff --git a/test/release/py/.github/workflows/release.yml b/test/release/py/.github/workflows/release.yml index 35393d4..e5cb369 100644 --- a/test/release/py/.github/workflows/release.yml +++ b/test/release/py/.github/workflows/release.yml @@ -27,11 +27,13 @@ on: default: false jobs: + # FACT-FIND — derive version/tag/notes, check the registry, and emit the package manifest. + # contents: write is for the releases/generate-notes API. configure: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} @@ -40,6 +42,9 @@ jobs: on_release_branch: ${{ steps.configure.outputs.on_release_branch }} commit_message: ${{ steps.configure.outputs.commit_message }} github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - name: Configure release id: configure @@ -49,27 +54,13 @@ jobs: sha: ${{ inputs.sha }} working_directory: . release_type: ${{ inputs.release_type }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # releases/generate-notes API - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - name: Prepare release - id: prepare - uses: braintrustdata/sdk-actions/actions/release/prepare@38dcec863c910f6802c03a94ab9aeb79931a0a3a - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha }} - prev_release: ${{ inputs.prev_release || needs.configure.outputs.prev_release }} + pypi_package_name: bt-publishing-test + package_label: bt-publishing-test + emoji: ':python:' + prev_release: ${{ inputs.prev_release }} validate: - needs: [configure, prepare] + needs: [configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -78,18 +69,17 @@ jobs: - name: Validate release uses: braintrustdata/sdk-actions/actions/release/lang/py/validate@38dcec863c910f6802c03a94ab9aeb79931a0a3a with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} working_directory: . python_version: .python-version - package_name: bt-publishing-test release_tag: ${{ needs.configure.outputs.release_tag }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} request-approval: - needs: [configure, prepare, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -97,22 +87,17 @@ jobs: - name: Request release approval uses: braintrustdata/sdk-actions/actions/release/request-approval@38dcec863c910f6802c03a94ab9aeb79931a0a3a with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run }} slack_token: ${{ secrets.SLACK_BOT_TOKEN }} slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} - label: bt-publishing-test - emoji: ':python:' build-and-ship: - needs: [configure, prepare, validate, request-approval] + needs: [configure, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: ${{ inputs.dry_run && 'publish-dry-run' || 'publish' }} @@ -129,15 +114,15 @@ jobs: python_version: .python-version dry_run: ${{ inputs.dry_run }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} github_release: ${{ needs.configure.outputs.github_release }} version: ${{ needs.configure.outputs.version }} package_name: bt-publishing-test label: bt-publishing-test - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ secrets.SLACK_BOT_TOKEN }} slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} emoji: ':python:' diff --git a/test/release/ruby/.github/workflows/release-custom.yml b/test/release/ruby/.github/workflows/release-custom.yml index b4e96f1..df0919b 100644 --- a/test/release/ruby/.github/workflows/release-custom.yml +++ b/test/release/ruby/.github/workflows/release-custom.yml @@ -29,11 +29,13 @@ on: default: false jobs: + # FACT-FIND — derive version/tag/notes, check the registry, and emit the package manifest. + # contents: write is for the releases/generate-notes API. configure: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} @@ -42,6 +44,9 @@ jobs: on_release_branch: ${{ steps.configure.outputs.on_release_branch }} commit_message: ${{ steps.configure.outputs.commit_message }} github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - name: Configure release id: configure @@ -52,27 +57,13 @@ jobs: sha: ${{ inputs.sha }} working_directory: . release_type: ${{ inputs.release_type }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # releases/generate-notes API - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - name: Prepare release - id: prepare - uses: braintrustdata/sdk-actions/actions/release/prepare@38dcec863c910f6802c03a94ab9aeb79931a0a3a - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha }} - prev_release: ${{ inputs.prev_release || needs.configure.outputs.prev_release }} + rubygems_package_name: bt-publishing-test + package_label: bt-publishing-test + emoji: ':gem:' + prev_release: ${{ inputs.prev_release }} validate: - needs: [configure, prepare] + needs: [configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -81,20 +72,19 @@ jobs: - name: Validate release uses: braintrustdata/sdk-actions/actions/release/lang/ruby/validate@38dcec863c910f6802c03a94ab9aeb79931a0a3a with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} working_directory: . - package_name: bt-publishing-test release_tag: ${{ needs.configure.outputs.release_tag }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} build: 'false' # the pack job owns the build (lint still runs) sbom: 'false' # the pack job generates + attests the SBOM # BUILD side — unprivileged: builds + attests the gem, NO publish credentials. pack: - needs: [configure, prepare, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -124,7 +114,7 @@ jobs: if-no-files-found: error request-approval: - needs: [configure, prepare, pack] + needs: [configure, validate, pack] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -132,23 +122,18 @@ jobs: - name: Request release approval uses: braintrustdata/sdk-actions/actions/release/request-approval@38dcec863c910f6802c03a94ab9aeb79931a0a3a with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run }} slack_token: ${{ secrets.SLACK_BOT_TOKEN }} slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} - label: bt-publishing-test - emoji: ':gem:' # PUBLISH side — gated: verifies the attestation against the downloaded gem, then publishes it. ship-package: - needs: [configure, prepare, pack, request-approval] + needs: [configure, validate, pack, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: ${{ inputs.dry_run && 'publish-dry-run' || 'publish' }} @@ -170,14 +155,15 @@ jobs: sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} github_release: ${{ needs.configure.outputs.github_release }} gem_name: bt-publishing-test + version: ${{ needs.configure.outputs.version }} label: bt-publishing-test - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ secrets.SLACK_BOT_TOKEN }} slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} emoji: ':gem:' diff --git a/test/release/ruby/.github/workflows/release.yml b/test/release/ruby/.github/workflows/release.yml index 30a27a3..4279d6e 100644 --- a/test/release/ruby/.github/workflows/release.yml +++ b/test/release/ruby/.github/workflows/release.yml @@ -27,11 +27,13 @@ on: default: false jobs: + # FACT-FIND — derive version/tag/notes, check the registry, and emit the package manifest. + # contents: write is for the releases/generate-notes API. configure: runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: - contents: read + contents: write outputs: version: ${{ steps.configure.outputs.version }} release_tag: ${{ steps.configure.outputs.release_tag }} @@ -40,6 +42,9 @@ jobs: on_release_branch: ${{ steps.configure.outputs.on_release_branch }} commit_message: ${{ steps.configure.outputs.commit_message }} github_release: ${{ steps.configure.outputs.github_release }} + already_published: ${{ steps.configure.outputs.already_published }} + notes: ${{ steps.configure.outputs.notes }} + package: ${{ steps.configure.outputs.package }} steps: - name: Configure release id: configure @@ -50,27 +55,13 @@ jobs: sha: ${{ inputs.sha }} working_directory: . release_type: ${{ inputs.release_type }} - - prepare: - needs: configure - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: write # releases/generate-notes API - outputs: - pr_list: ${{ steps.prepare.outputs.pr_list }} - notes: ${{ steps.prepare.outputs.notes }} - steps: - - name: Prepare release - id: prepare - uses: braintrustdata/sdk-actions/actions/release/prepare@38dcec863c910f6802c03a94ab9aeb79931a0a3a - with: - release_tag: ${{ needs.configure.outputs.release_tag }} - sha: ${{ inputs.sha }} - prev_release: ${{ inputs.prev_release || needs.configure.outputs.prev_release }} + rubygems_package_name: bt-publishing-test + package_label: bt-publishing-test + emoji: ':gem:' + prev_release: ${{ inputs.prev_release }} validate: - needs: [configure, prepare] + needs: [configure] runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -79,17 +70,16 @@ jobs: - name: Validate release uses: braintrustdata/sdk-actions/actions/release/lang/ruby/validate@38dcec863c910f6802c03a94ab9aeb79931a0a3a with: - version: ${{ needs.configure.outputs.version }} sha: ${{ inputs.sha }} dry_run: ${{ inputs.dry_run }} working_directory: . - package_name: bt-publishing-test release_tag: ${{ needs.configure.outputs.release_tag }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} + already_published: ${{ needs.configure.outputs.already_published }} request-approval: - needs: [configure, prepare, validate] + needs: [configure, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} @@ -97,22 +87,17 @@ jobs: - name: Request release approval uses: braintrustdata/sdk-actions/actions/release/request-approval@38dcec863c910f6802c03a94ab9aeb79931a0a3a with: + packages: '{"packages":[${{ needs.configure.outputs.package }}]}' sha: ${{ inputs.sha }} - release_tag: ${{ needs.configure.outputs.release_tag }} - prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} commit_message: ${{ needs.configure.outputs.commit_message }} - pr_list: ${{ needs.prepare.outputs.pr_list }} - notes: ${{ needs.prepare.outputs.notes }} dry_run: ${{ inputs.dry_run }} slack_token: ${{ secrets.SLACK_BOT_TOKEN }} slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} - label: bt-publishing-test - emoji: ':gem:' build-and-ship: - needs: [configure, prepare, validate, request-approval] + needs: [configure, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 environment: ${{ inputs.dry_run && 'publish-dry-run' || 'publish' }} @@ -128,12 +113,12 @@ jobs: working_directory: . dry_run: ${{ inputs.dry_run }} release_tag: ${{ needs.configure.outputs.release_tag }} + already_published: ${{ needs.configure.outputs.already_published }} github_release: ${{ needs.configure.outputs.github_release }} - notes: ${{ needs.prepare.outputs.notes }} + notes: ${{ needs.configure.outputs.notes }} prev_release: ${{ needs.configure.outputs.prev_release }} branch: ${{ needs.configure.outputs.branch }} on_release_branch: ${{ needs.configure.outputs.on_release_branch }} - pr_list: ${{ needs.prepare.outputs.pr_list }} slack_token: ${{ secrets.SLACK_BOT_TOKEN }} slack_channel: ${{ vars.SLACK_SDK_RELEASE_CHANNEL }} gem_name: bt-publishing-test