From 38dcec863c910f6802c03a94ab9aeb79931a0a3a Mon Sep 17 00:00:00 2001 From: David Elner Date: Mon, 6 Jul 2026 20:52:06 +0000 Subject: [PATCH 1/9] Added: Custom build release actions --- .github/workflows/release-js-custom.yml | 270 ++++++++++ .github/workflows/release-js.yml | 155 +++--- .github/workflows/release-py-custom.yml | 238 +++++++++ .github/workflows/release-py.yml | 148 +++--- .github/workflows/release-ruby-custom.yml | 238 +++++++++ .github/workflows/release-ruby.yml | 144 +++--- CONTRIBUTING.md | 21 + README.md | 58 ++- .../js/{publish => build-and-ship}/action.yml | 15 +- actions/release/lang/js/configure/action.yml | 196 +++++++ actions/release/lang/js/pack-bun/action.yml | 225 ++++++++ actions/release/lang/js/pack-pnpm/action.yml | 216 ++++++++ .../release/lang/js/ship-package/action.yml | 482 ++++++++++++++++++ actions/release/lang/js/validate/action.yml | 154 +++--- .../py/{publish => build-and-ship}/action.yml | 17 +- actions/release/lang/py/configure/action.yml | 197 +++++++ actions/release/lang/py/pack/action.yml | 151 ++++++ .../release/lang/py/ship-package/action.yml | 453 ++++++++++++++++ actions/release/lang/py/validate/action.yml | 153 +++--- .../{publish => build-and-ship}/action.yml | 25 +- .../release/lang/ruby/configure/action.yml | 181 +++++++ actions/release/lang/ruby/pack/action.yml | 150 ++++++ .../release/lang/ruby/ship-package/action.yml | 422 +++++++++++++++ actions/release/lang/ruby/validate/action.yml | 148 +++--- actions/release/prepare/action.yml | 1 + .../action.yml | 10 +- scripts/generate.rb | 35 +- templates/actions/release/VERSION | 1 + ...publish.yml.erb => build-and-ship.yml.erb} | 27 +- .../actions/release/lang/js/configure.yml.erb | 102 ++++ .../actions/release/lang/js/pack-bun.yml.erb | 92 ++++ .../actions/release/lang/js/pack-pnpm.yml.erb | 89 ++++ .../release/lang/js/ship-package.yml.erb | 137 +++++ .../actions/release/lang/js/validate.yml.erb | 103 ++-- ...publish.yml.erb => build-and-ship.yml.erb} | 18 +- .../actions/release/lang/py/configure.yml.erb | 103 ++++ .../actions/release/lang/py/pack.yml.erb | 68 +++ .../release/lang/py/ship-package.yml.erb | 114 +++++ .../actions/release/lang/py/validate.yml.erb | 94 ++-- ...publish.yml.erb => build-and-ship.yml.erb} | 20 +- .../release/lang/ruby/configure.yml.erb | 102 ++++ .../actions/release/lang/ruby/pack.yml.erb | 57 +++ .../release/lang/ruby/ship-package.yml.erb | 103 ++++ .../release/lang/ruby/validate.yml.erb | 93 ++-- ...nding.yml.erb => request-approval.yml.erb} | 9 +- templates/steps/lang/js/bun/build.yml.erb | 24 + templates/steps/lang/js/bun/pack.yml.erb | 22 + templates/steps/lang/js/bun/sbom.yml.erb | 59 +++ templates/steps/lang/js/bun/setup.yml.erb | 20 + .../js/npm/availability.yml.erb} | 0 .../steps/lang/js/npm/publish-package.yml.erb | 52 ++ .../js/npm/publish.yml.erb} | 0 .../js => lang/js/pnpm}/attest-sbom.yml.erb | 0 .../steps/lang/js/{ => pnpm}/build.yml.erb | 0 templates/steps/lang/js/pnpm/pack.yml.erb | 24 + .../lang/js => lang/js/pnpm}/sbom.yml.erb | 0 .../steps/lang/js/{ => pnpm}/setup.yml.erb | 0 .../py/pypi/availability.yml.erb} | 0 .../py/pypi/publish.yml.erb} | 0 .../steps/{release => }/lang/py/sbom.yml.erb | 0 templates/steps/lang/py/setup.yml.erb | 2 + .../{release => }/lang/ruby/build.yml.erb | 0 .../ruby/rubygems/availability.yml.erb} | 0 .../ruby/rubygems/push.yml.erb} | 21 +- .../{release => }/lang/ruby/sbom.yml.erb | 0 .../steps/release/attest-provenance.yml.erb | 20 +- templates/steps/release/checks.yml.erb | 55 ++ templates/steps/release/configure.yml.erb | 74 +++ .../steps/release/lang/js/on-failure.yml.erb | 2 +- .../release/lang/js/validate-package.yml.erb | 35 ++ templates/steps/release/validate.yml.erb | 66 --- .../steps/release/verify-package.yml.erb | 38 ++ 72 files changed, 5630 insertions(+), 719 deletions(-) create mode 100644 .github/workflows/release-js-custom.yml create mode 100644 .github/workflows/release-py-custom.yml create mode 100644 .github/workflows/release-ruby-custom.yml rename actions/release/lang/js/{publish => build-and-ship}/action.yml (96%) create mode 100644 actions/release/lang/js/configure/action.yml create mode 100644 actions/release/lang/js/pack-bun/action.yml create mode 100644 actions/release/lang/js/pack-pnpm/action.yml create mode 100644 actions/release/lang/js/ship-package/action.yml rename actions/release/lang/py/{publish => build-and-ship}/action.yml (96%) create mode 100644 actions/release/lang/py/configure/action.yml create mode 100644 actions/release/lang/py/pack/action.yml create mode 100644 actions/release/lang/py/ship-package/action.yml rename actions/release/lang/ruby/{publish => build-and-ship}/action.yml (95%) create mode 100644 actions/release/lang/ruby/configure/action.yml create mode 100644 actions/release/lang/ruby/pack/action.yml create mode 100644 actions/release/lang/ruby/ship-package/action.yml rename actions/release/{notify-pending => request-approval}/action.yml (93%) create mode 100644 templates/actions/release/VERSION rename templates/actions/release/lang/js/{publish.yml.erb => build-and-ship.yml.erb} (81%) create mode 100644 templates/actions/release/lang/js/configure.yml.erb create mode 100644 templates/actions/release/lang/js/pack-bun.yml.erb create mode 100644 templates/actions/release/lang/js/pack-pnpm.yml.erb create mode 100644 templates/actions/release/lang/js/ship-package.yml.erb rename templates/actions/release/lang/py/{publish.yml.erb => build-and-ship.yml.erb} (87%) create mode 100644 templates/actions/release/lang/py/configure.yml.erb create mode 100644 templates/actions/release/lang/py/pack.yml.erb create mode 100644 templates/actions/release/lang/py/ship-package.yml.erb rename templates/actions/release/lang/ruby/{publish.yml.erb => build-and-ship.yml.erb} (84%) create mode 100644 templates/actions/release/lang/ruby/configure.yml.erb create mode 100644 templates/actions/release/lang/ruby/pack.yml.erb create mode 100644 templates/actions/release/lang/ruby/ship-package.yml.erb rename templates/actions/release/{notify-pending.yml.erb => request-approval.yml.erb} (93%) create mode 100644 templates/steps/lang/js/bun/build.yml.erb create mode 100644 templates/steps/lang/js/bun/pack.yml.erb create mode 100644 templates/steps/lang/js/bun/sbom.yml.erb create mode 100644 templates/steps/lang/js/bun/setup.yml.erb rename templates/steps/{release/lang/js/npm-availability.yml.erb => lang/js/npm/availability.yml.erb} (100%) create mode 100644 templates/steps/lang/js/npm/publish-package.yml.erb rename templates/steps/{release/lang/js/npm-publish.yml.erb => lang/js/npm/publish.yml.erb} (100%) rename templates/steps/{release/lang/js => lang/js/pnpm}/attest-sbom.yml.erb (100%) rename templates/steps/lang/js/{ => pnpm}/build.yml.erb (100%) create mode 100644 templates/steps/lang/js/pnpm/pack.yml.erb rename templates/steps/{release/lang/js => lang/js/pnpm}/sbom.yml.erb (100%) rename templates/steps/lang/js/{ => pnpm}/setup.yml.erb (100%) rename templates/steps/{release/lang/py/pypi-availability.yml.erb => lang/py/pypi/availability.yml.erb} (100%) rename templates/steps/{release/lang/py/pypi-publish.yml.erb => lang/py/pypi/publish.yml.erb} (100%) rename templates/steps/{release => }/lang/py/sbom.yml.erb (100%) rename templates/steps/{release => }/lang/ruby/build.yml.erb (100%) rename templates/steps/{release/lang/ruby/rubygems-availability.yml.erb => lang/ruby/rubygems/availability.yml.erb} (100%) rename templates/steps/{release/lang/ruby/rubygems-push.yml.erb => lang/ruby/rubygems/push.yml.erb} (52%) rename templates/steps/{release => }/lang/ruby/sbom.yml.erb (100%) create mode 100644 templates/steps/release/checks.yml.erb create mode 100644 templates/steps/release/configure.yml.erb create mode 100644 templates/steps/release/lang/js/validate-package.yml.erb delete mode 100644 templates/steps/release/validate.yml.erb create mode 100644 templates/steps/release/verify-package.yml.erb diff --git a/.github/workflows/release-js-custom.yml b/.github/workflows/release-js-custom.yml new file mode 100644 index 0000000..a65a545 --- /dev/null +++ b/.github/workflows/release-js-custom.yml @@ -0,0 +1,270 @@ +# +# 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 +# +# • 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. +# +# ───────────────────────────────────────────────────────────────────────────── +# SETUP (same as release-js.yml, with one addition): +# npm Trusted Publishing must list THIS workflow filename (release-js-custom.yml) +# + the `publish` environment. Only the ship-package job publishes. +# ───────────────────────────────────────────────────────────────────────────── + +name: Release JavaScript custom-build (@braintrust/bt-publishing-test) + +on: + pull_request: + paths: + - 'actions/**' + - '.github/workflows/**' + workflow_dispatch: + inputs: + release_type: + description: "Release type — maps to channel + github_release defaults" + type: choice + default: stable + options: [stable, prerelease] + channel: + description: "Explicit npm channel / dist-tag override (empty → derived from release_type)" + type: choice + default: latest + options: [latest, rc, next, beta] + sha: + description: "Commit SHA (of the version bump) to release" + required: true + type: string + prev_release: + description: "Anchor for release notes: a tag name or commit SHA." + type: string + required: false + default: '58a397193105516446c3042361913655bcece509' + dry_run: + description: "Dry run: build + pack (no attest, no publish)" + type: boolean + default: false + +jobs: + bump: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + outputs: + version: ${{ steps.bump.outputs.version }} + steps: + # bt-publishing-test: derive next version from npm. Real SDKs drop this job. + - name: Bump version + id: bump + run: | + 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 + + # DERIVE facts (read-only). + configure: + needs: bump + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + 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 }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Configure release + id: configure + uses: ./actions/release/lang/js/configure + with: + version: ${{ needs.bump.outputs.version }} + 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 }} + + # JUDGE (checks only — pack does the build, so build/sbom are off here). + validate: + needs: [configure, prepare] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - 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 }} + 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] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write # sign attestations + attestations: write # write attestations + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.sha || github.event.pull_request.head.sha }} + fetch-depth: 0 + + # bt-publishing-test: write the resolved version into package.json before packing. + # Real SDK workflows omit this — the version is committed in the bump PR. + - name: Apply version to package.json + env: + VERSION: ${{ needs.configure.outputs.version }} + run: | + node -e 'const fs=require("fs");const f="test/release/js/package.json";const p=JSON.parse(fs.readFileSync(f,"utf8"));p.version=process.env.VERSION;fs.writeFileSync(f,JSON.stringify(p,null,2)+"\n")' + + - name: Pack + attest + id: pack + uses: ./actions/release/lang/js/pack-pnpm + with: + sha: ${{ inputs.sha || github.event.pull_request.head.sha }} + checkout: 'false' # version patch above must survive + working_directory: test/release/js + node_version: test/release/js/.tool-versions + dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} + + # Stage the exact packed tarball + its SBOM for the gated ship job. + - name: Stage artifact + run: | + mkdir -p artifact + cp "${{ steps.pack.outputs.tarball }}" artifact/ + cp test/release/js/sbom.json artifact/ 2>/dev/null || true + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-package + path: artifact/ + retention-days: 1 + if-no-files-found: error + + # NOTIFY — approval request (green when issued; the real gate is on ship-package). + request-approval: + needs: [configure, prepare, pack] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Request release approval + uses: ./actions/release/request-approval + with: + 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] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + environment: >- + ${{ github.event_name != 'pull_request' + && (inputs.dry_run && 'publish-dry-run' || 'publish') + || '' }} + permissions: + contents: write + 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 + with: + name: release-package + path: dl + + - name: Resolve tarball + id: art + run: echo "tarball=$(ls dl/*.tgz)" >> "$GITHUB_OUTPUT" + + - name: Ship package + uses: ./actions/release/lang/js/ship-package + with: + tarball: ${{ steps.art.outputs.tarball }} + working_directory: dl # holds the tarball + sbom.json (attached to the release) + 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 }} + # ⚠️ 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: '@braintrust/bt-publishing-test' + label: '@braintrust/bt-publishing-test' + access: 'public' + provenance: 'true' + channel: ${{ needs.configure.outputs.channel }} + notes: ${{ needs.prepare.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 8181222..3b34c19 100644 --- a/.github/workflows/release-js.yml +++ b/.github/workflows/release-js.yml @@ -5,7 +5,8 @@ # # This workflow serves two purposes: # 1. End-to-end testing of the composite actions in actions/release/ -# 2. Reference implementation of the JavaScript-specific release template +# 2. Reference implementation of the v2 JavaScript TURNKEY release shape +# (build-and-ship): configure → prepare → validate → 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. @@ -18,11 +19,9 @@ # publish # - Required reviewers: sdk-eng team or named maintainers # - Prevent self-review: enabled -# - Deployment branches: main only # publish-dry-run # - Required reviewers: at least yourself (to test the approval gate) # - Allow administrators to bypass: enabled (for testing flexibility) -# - Deployment branches: no restriction # # Repository Variables (Settings → Secrets and variables → Variables): # SLACK_SDK_RELEASE_CHANNEL Slack channel ID for release notifications @@ -37,7 +36,7 @@ # Repository: braintrustdata/sdk-actions (or your SDK repo) # Workflow: release-js.yml (or your workflow filename) # Environment: publish -# Provenance requires a PUBLIC package; the publish job needs id-token: write. +# Provenance requires a PUBLIC package; the build-and-ship job needs id-token: write. # # ───────────────────────────────────────────────────────────────────────────── @@ -58,8 +57,15 @@ on: type: string default: "I have merged a version bump PR" required: false + release_type: + description: "Release type — maps to channel + github_release defaults (overridden by 'channel' below)" + type: choice + default: stable + options: + - stable + - prerelease channel: - description: "npm release channel / dist-tag (default: latest)" + description: "Explicit npm channel / dist-tag override (empty → derived from release_type)" type: choice default: latest options: @@ -104,41 +110,40 @@ jobs: IFS='.' read -r major minor patch <<< "$CURRENT" echo "version=$major.$minor.$((patch + 1))" >> $GITHUB_OUTPUT - validate: + configure: needs: bump runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 5 permissions: contents: read outputs: - release_tag: ${{ steps.validate-release.outputs.release_tag }} - prev_release: ${{ steps.validate-release.outputs.prev_release }} - branch: ${{ steps.validate-release.outputs.branch }} - on_release_branch: ${{ steps.validate-release.outputs.on_release_branch }} - commit_message: ${{ steps.validate-release.outputs.commit_message }} + 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 }} steps: # bt-publishing-test: checkout required to resolve local ./actions/... references. - # Real SDK workflows use external braintrustdata/sdk-actions/...@sha references - # and do not need this step. + # Real SDK workflows use external braintrustdata/sdk-actions/...@sha references. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Validate release - id: validate-release - uses: ./actions/release/lang/js/validate + - name: Configure release + id: configure + uses: ./actions/release/lang/js/configure with: - # bt-publishing-test: version passed directly from bump job — no package.json read needed. + # bt-publishing-test: version passed directly from the bump job. # In a real JS project, drop `version`; it is read from package.json. version: ${{ needs.bump.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 (vs explicit version) - package_name: '@braintrust/bt-publishing-test' # exercises the npm-availability fail-fast check - channel: ${{ inputs.channel || 'latest' }} - allowed_channels: 'latest,rc,next,beta' + release_type: ${{ inputs.release_type || 'stable' }} + channel: ${{ inputs.channel }} # explicit override; empty → derived from release_type prepare: - needs: validate + needs: configure runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: @@ -147,60 +152,75 @@ jobs: pr_list: ${{ steps.prepare.outputs.pr_list }} notes: ${{ steps.prepare.outputs.notes }} steps: - # bt-publishing-test: checkout required because actions are referenced locally (./actions/...). - # Real SDK workflows reference actions externally (braintrustdata/sdk-actions/...@sha) - # and do not need this step. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Prepare release id: prepare uses: ./actions/release/prepare with: - release_tag: ${{ needs.validate.outputs.release_tag }} + 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.validate.outputs.prev_release }} + prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha || needs.configure.outputs.prev_release }} + + validate: + needs: [configure, prepare] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - 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 }} - notify-pending: - needs: [validate, prepare] + request-approval: + needs: [configure, prepare, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} steps: - # bt-publishing-test: checkout required for local action resolution (see prepare job comment) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Notify release pending - uses: ./actions/release/notify-pending + - name: Request release approval + uses: ./actions/release/request-approval with: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - release_tag: ${{ needs.validate.outputs.release_tag }} - prev_release: ${{ needs.validate.outputs.prev_release }} - branch: ${{ needs.validate.outputs.branch }} - on_release_branch: ${{ needs.validate.outputs.on_release_branch }} - commit_message: ${{ needs.validate.outputs.commit_message }} + 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). - # Gate on != pull_request: GHA's `A && '' || B` returns B (the '' is falsy), so suppression - # must be the trailing '' branch, not the "true" value. slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - # slack_mention: '@sdk-eng' - label: '@braintrust/bt-publishing-test' # distinguishes this from other packages in this repo + label: '@braintrust/bt-publishing-test' emoji: ':javascript:' - publish: - needs: [bump, validate, prepare, notify-pending] + build-and-ship: + needs: [configure, prepare, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 # No environment on PR dry-runs (avoids the approval gate AND the accumulating # deployment records); the gated environments apply only to manual dispatch. - # Note the structure: empty must be the trailing `|| ''` branch, since GHA's - # `cond && '' || X` would fall through to X (the '' is falsy). environment: >- ${{ github.event_name != 'pull_request' && (inputs.dry_run && 'publish-dry-run' || 'publish') @@ -208,49 +228,48 @@ jobs: permissions: contents: write id-token: write # OIDC trusted publishing + provenance - attestations: write # signed SBOM attestation (actions/attest-sbom) + attestations: write # signed SBOM attestation steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.sha || github.event.pull_request.head.sha }} fetch-depth: 0 - # bt-publishing-test: write the run-number version into package.json. The publish job - # checks out fresh, so the file still reflects the placeholder version. + # bt-publishing-test: write the resolved version (from configure) into package.json. + # The job checks out fresh, so the file still reflects the committed placeholder version. # Real SDK workflows omit this — the version is committed in the bump PR. - # (No lockfile patch needed: pnpm-lock.yaml doesn't record the package's own version.) - name: Apply version to package.json env: - VERSION: ${{ needs.bump.outputs.version }} + VERSION: ${{ needs.configure.outputs.version }} run: | node -e 'const fs=require("fs");const f="test/release/js/package.json";const p=JSON.parse(fs.readFileSync(f,"utf8"));p.version=process.env.VERSION;fs.writeFileSync(f,JSON.stringify(p,null,2)+"\n")' - - name: Publish - uses: ./actions/release/lang/js/publish + - name: Build and ship + uses: ./actions/release/lang/js/build-and-ship with: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - checkout: 'false' # bt-publishing-test: skip internal checkout — version patching above must survive into the build + checkout: 'false' # bt-publishing-test: skip internal checkout — version patch above must survive working_directory: test/release/js node_version: test/release/js/.tool-versions dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} - release_tag: ${{ needs.validate.outputs.release_tag }} - github_release: 'false' # bt-publishing-test: omit GitHub release to avoid tag/release pollution from repeated test runs - version: ${{ needs.bump.outputs.version }} + release_tag: ${{ needs.configure.outputs.release_tag }} + # ⚠️ 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 package. + github_release: 'false' + version: ${{ needs.configure.outputs.version }} package_name: '@braintrust/bt-publishing-test' - label: '@braintrust/bt-publishing-test' # distinguishes this from other packages in this repo + label: '@braintrust/bt-publishing-test' access: 'public' provenance: 'true' - channel: ${{ inputs.channel || 'latest' }} + channel: ${{ needs.configure.outputs.channel }} notes: ${{ needs.prepare.outputs.notes }} - prev_release: ${{ needs.validate.outputs.prev_release }} - branch: ${{ needs.validate.outputs.branch }} - on_release_branch: ${{ needs.validate.outputs.on_release_branch }} + 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 }} - # Suppress Slack on PR auto dry-runs (still exercises message-building; just no real post). - # Gate on != pull_request: GHA's `A && '' || B` returns B (the '' is falsy), so suppression - # must be the trailing '' branch, not the "true" value. slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - # slack_mention: '@sdk-eng' - # failure_slack_mention: '@sdk-eng' emoji: ':javascript:' diff --git a/.github/workflows/release-py-custom.yml b/.github/workflows/release-py-custom.yml new file mode 100644 index 0000000..aeb0bdf --- /dev/null +++ b/.github/workflows/release-py-custom.yml @@ -0,0 +1,238 @@ +# +# Reference implementation of the v2 Python CUSTOM-BUILD ("build-ownership") shape, +# 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 +# pack builds + attests dist/* (whl + sdist) with NO publish credentials and uploads them; +# ship-package (gated) verifies their attestations and publishes THOSE exact dists. +# +# SETUP: PyPI Trusted Publishing must list THIS workflow filename (release-py-custom.yml) +# + the `publish` environment. Only the ship-package job publishes. + +name: Release Python custom-build (bt-publishing-test) + +on: + pull_request: + paths: + - 'actions/**' + - '.github/workflows/**' + workflow_dispatch: + inputs: + release_type: + description: "Release type — maps to the github_release default" + type: choice + default: stable + options: [stable, prerelease] + sha: + description: "Commit SHA (of the version bump) to release" + required: true + type: string + prev_release: + description: "Anchor for release notes: a tag name or commit SHA." + type: string + required: false + default: '58a397193105516446c3042361913655bcece509' + dry_run: + description: "Dry run: build + pack (no attest, no publish)" + type: boolean + default: false + +jobs: + bump: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + outputs: + version: ${{ steps.bump.outputs.version }} + steps: + - name: Bump version + id: bump + run: | + 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 + + configure: + needs: bump + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + 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 }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Configure release + id: configure + uses: ./actions/release/lang/py/configure + with: + version: ${{ needs.bump.outputs.version }} + 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 }} + + validate: + needs: [configure, prepare] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - 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 }} + 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] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.sha || github.event.pull_request.head.sha }} + fetch-depth: 0 + + # bt-publishing-test: write the resolved version into version.py before building. + - name: Apply version to version.py + env: + VERSION: ${{ needs.configure.outputs.version }} + run: | + printf 'VERSION = "%s"\n' "$VERSION" > test/release/py/src/bt_publishing_test/version.py + + - name: Pack + attest + uses: ./actions/release/lang/py/pack + with: + sha: ${{ inputs.sha || github.event.pull_request.head.sha }} + checkout: 'false' + working_directory: test/release/py + python_version: test/release/py/.python-version + dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} + + # Stage the exact built dists (whl + sdist) + SBOM, preserving dist/ layout. + - name: Stage artifact + run: | + mkdir -p artifact/dist + cp test/release/py/dist/*.whl test/release/py/dist/*.tar.gz artifact/dist/ + cp test/release/py/sbom.json artifact/ 2>/dev/null || true + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-package + path: artifact/ + retention-days: 1 + if-no-files-found: error + + request-approval: + needs: [configure, prepare, pack] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Request release approval + uses: ./actions/release/request-approval + with: + 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] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + environment: >- + ${{ github.event_name != 'pull_request' + && (inputs.dry_run && 'publish-dry-run' || 'publish') + || '' }} + permissions: + contents: write + 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 + with: + name: release-package + path: dl + + - name: Ship package + uses: ./actions/release/lang/py/ship-package + with: + working_directory: dl # holds dist/ (whl + sdist) + sbom.json + 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 }} + # ⚠️ 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 }} + 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 06413c2..3109e0d 100644 --- a/.github/workflows/release-py.yml +++ b/.github/workflows/release-py.yml @@ -5,7 +5,8 @@ # # This workflow serves two purposes: # 1. End-to-end testing of the composite actions in actions/release/ -# 2. Reference implementation of the Python-specific release template +# 2. Reference implementation of the v2 Python TURNKEY release shape +# (build-and-ship): configure → prepare → validate → 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. @@ -18,11 +19,9 @@ # publish # - Required reviewers: sdk-eng team or named maintainers # - Prevent self-review: enabled -# - Deployment branches: main only # publish-dry-run # - Required reviewers: at least yourself (to test the approval gate) # - Allow administrators to bypass: enabled (for testing flexibility) -# - Deployment branches: no restriction # # Repository Variables (Settings → Secrets and variables → Variables): # SLACK_SDK_RELEASE_CHANNEL Slack channel ID for release notifications @@ -38,7 +37,7 @@ # Repository: sdk-actions (or your SDK repo) # Workflow: release-py.yml (or your workflow filename) # Environment: publish -# The publish job needs id-token: write for OIDC + PEP 740 attestations. +# The build-and-ship job needs id-token: write for OIDC + PEP 740 attestations. # # ───────────────────────────────────────────────────────────────────────────── @@ -59,6 +58,13 @@ on: type: string default: "I have merged a version bump PR" required: false + release_type: + description: "Release type — maps to the github_release default (prerelease → no GitHub release)" + type: choice + default: stable + options: + - stable + - prerelease sha: description: "Commit SHA (of the version bump) to release" required: true @@ -96,39 +102,37 @@ jobs: IFS='.' read -r major minor patch <<< "$CURRENT" echo "version=$major.$minor.$((patch + 1))" >> $GITHUB_OUTPUT - validate: + # DERIVE — read-only facts (version, tag, branch, …). No token; sets up a default Python to read the version. + configure: needs: bump runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 5 permissions: contents: read outputs: - release_tag: ${{ steps.validate-release.outputs.release_tag }} - prev_release: ${{ steps.validate-release.outputs.prev_release }} - branch: ${{ steps.validate-release.outputs.branch }} - on_release_branch: ${{ steps.validate-release.outputs.on_release_branch }} - commit_message: ${{ steps.validate-release.outputs.commit_message }} + 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 }} steps: - # bt-publishing-test: checkout required to resolve local ./actions/... references. - # Real SDK workflows use external braintrustdata/sdk-actions/...@sha references - # and do not need this step. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Validate release - id: validate-release - uses: ./actions/release/lang/py/validate + - name: Configure release + id: configure + uses: ./actions/release/lang/py/configure with: - # bt-publishing-test: version passed directly from bump job — no version_file read needed. + # bt-publishing-test: version passed directly from the bump job. # In a real Python project, drop `version`; it is read from version_file. version: ${{ needs.bump.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 (vs explicit version) - package_name: bt-publishing-test # exercises the PyPI-availability fail-fast check + release_type: ${{ inputs.release_type || 'stable' }} prepare: - needs: validate + needs: configure runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: @@ -137,60 +141,76 @@ jobs: pr_list: ${{ steps.prepare.outputs.pr_list }} notes: ${{ steps.prepare.outputs.notes }} steps: - # bt-publishing-test: checkout required because actions are referenced locally (./actions/...). - # Real SDK workflows reference actions externally (braintrustdata/sdk-actions/...@sha) - # and do not need this step. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Prepare release id: prepare uses: ./actions/release/prepare with: - release_tag: ${{ needs.validate.outputs.release_tag }} + 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.validate.outputs.prev_release }} + prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha || needs.configure.outputs.prev_release }} + + # JUDGE — toggleable checks + a pre-gate build & SBOM, using the facts configure derived. + validate: + needs: [configure, prepare] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - 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 }} - notify-pending: - needs: [validate, prepare] + # NOTIFY — issues the approval request (green the moment it posts; the real gate is on build-and-ship). + request-approval: + needs: [configure, prepare, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} steps: - # bt-publishing-test: checkout required for local action resolution (see prepare job comment) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Notify release pending - uses: ./actions/release/notify-pending + - name: Request release approval + uses: ./actions/release/request-approval with: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - release_tag: ${{ needs.validate.outputs.release_tag }} - prev_release: ${{ needs.validate.outputs.prev_release }} - branch: ${{ needs.validate.outputs.branch }} - on_release_branch: ${{ needs.validate.outputs.on_release_branch }} - commit_message: ${{ needs.validate.outputs.commit_message }} + 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). - # Gate on != pull_request: GHA's `A && '' || B` returns B (the '' is falsy), so suppression - # must be the trailing '' branch, not the "true" value. slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - # slack_mention: '@sdk-eng' - label: 'bt-publishing-test' # distinguishes this from other packages in this repo + label: 'bt-publishing-test' emoji: ':python:' - publish: - needs: [bump, validate, prepare, notify-pending] + # EXECUTE — build → attest → publish → release → announce, in one gated job. + build-and-ship: + needs: [configure, prepare, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 # No environment on PR dry-runs (avoids the approval gate AND the accumulating # deployment records); the gated environments apply only to manual dispatch. - # Note the structure: empty must be the trailing `|| ''` branch, since GHA's - # `cond && '' || X` would fall through to X (the '' is falsy). environment: >- ${{ github.event_name != 'pull_request' && (inputs.dry_run && 'publish-dry-run' || 'publish') @@ -198,45 +218,45 @@ jobs: permissions: contents: write id-token: write # OIDC trusted publishing + PEP 740 attestations - attestations: write # signed SBOM attestation (actions/attest-sbom) + attestations: write # signed SBOM attestation steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.sha || github.event.pull_request.head.sha }} fetch-depth: 0 - # bt-publishing-test: write the run-number version into version.py. The publish job - # checks out fresh, so the file still reflects the placeholder version. + # bt-publishing-test: write the resolved version (from configure) into version.py. + # The job checks out fresh, so the file still reflects the committed placeholder version. # Real SDK workflows omit this — the version is committed in the bump PR. - name: Apply version to version.py env: - VERSION: ${{ needs.bump.outputs.version }} + VERSION: ${{ needs.configure.outputs.version }} run: | printf 'VERSION = "%s"\n' "$VERSION" > test/release/py/src/bt_publishing_test/version.py - - name: Publish - uses: ./actions/release/lang/py/publish + - name: Build and ship + uses: ./actions/release/lang/py/build-and-ship with: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - checkout: 'false' # bt-publishing-test: skip internal checkout — version patching above must survive into the build + checkout: 'false' # bt-publishing-test: skip internal checkout — version patch above must survive working_directory: test/release/py python_version: test/release/py/.python-version dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} - release_tag: ${{ needs.validate.outputs.release_tag }} - github_release: 'false' # bt-publishing-test: omit GitHub release to avoid tag/release pollution from repeated test runs - version: ${{ needs.bump.outputs.version }} + release_tag: ${{ needs.configure.outputs.release_tag }} + # ⚠️ 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 package. + github_release: 'false' + version: ${{ needs.configure.outputs.version }} package_name: bt-publishing-test - label: 'bt-publishing-test' # distinguishes this from other packages in this repo + label: 'bt-publishing-test' notes: ${{ needs.prepare.outputs.notes }} - prev_release: ${{ needs.validate.outputs.prev_release }} - branch: ${{ needs.validate.outputs.branch }} - on_release_branch: ${{ needs.validate.outputs.on_release_branch }} + 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 }} - # Suppress Slack on PR auto dry-runs (still exercises message-building; just no real post). - # Gate on != pull_request: GHA's `A && '' || B` returns B (the '' is falsy), so suppression - # must be the trailing '' branch, not the "true" value. slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - # slack_mention: '@sdk-eng' - # failure_slack_mention: '@sdk-eng' emoji: ':python:' diff --git a/.github/workflows/release-ruby-custom.yml b/.github/workflows/release-ruby-custom.yml new file mode 100644 index 0000000..d81db25 --- /dev/null +++ b/.github/workflows/release-ruby-custom.yml @@ -0,0 +1,238 @@ +# +# Reference implementation of the v2 Ruby CUSTOM-BUILD ("build-ownership") shape, using +# 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 +# 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. +# +# SETUP: RubyGems Trusted Publishing must list THIS workflow filename +# (release-ruby-custom.yml) + the `publish` environment. Only ship-package publishes. + +name: Release Ruby custom-build (bt-publishing-test) + +on: + pull_request: + paths: + - 'actions/**' + - '.github/workflows/**' + workflow_dispatch: + inputs: + release_type: + description: "Release type — maps to the github_release default" + type: choice + default: stable + options: [stable, prerelease] + sha: + description: "Commit SHA (of the version bump) to release" + required: true + type: string + prev_release: + description: "Anchor for release notes: a tag name or commit SHA." + type: string + required: false + default: '58a397193105516446c3042361913655bcece509' + dry_run: + description: "Dry run: build + pack (no attest, no publish)" + type: boolean + default: false + +jobs: + bump: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + outputs: + version: ${{ steps.bump.outputs.version }} + steps: + - name: Bump version + id: bump + run: | + 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 + + configure: + needs: bump + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + 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 }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Configure release + id: configure + uses: ./actions/release/lang/ruby/configure + with: + version: ${{ needs.bump.outputs.version }} + 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 }} + + validate: + needs: [configure, prepare] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - 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 }} + 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] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.sha || github.event.pull_request.head.sha }} + fetch-depth: 0 + + # bt-publishing-test: write the resolved version into version.rb + Gemfile.lock. + - name: Apply version to version.rb and Gemfile.lock + env: + VERSION: ${{ needs.configure.outputs.version }} + run: | + sed -i "s|VERSION = \".*\"|VERSION = \"$VERSION\"|" \ + test/release/ruby/lib/bt_publishing_test/version.rb + sed -i "s|bt-publishing-test ([0-9]*\.[0-9]*\.[0-9]*)|bt-publishing-test ($VERSION)|" \ + test/release/ruby/Gemfile.lock + + - name: Pack + attest + uses: ./actions/release/lang/ruby/pack + with: + sha: ${{ inputs.sha || github.event.pull_request.head.sha }} + checkout: 'false' + working_directory: test/release/ruby + dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} + + # Stage the exact built gem + SBOM for the gated ship job. + - name: Stage artifact + run: | + mkdir -p artifact + cp test/release/ruby/pkg/*.gem artifact/ + cp test/release/ruby/sbom.json artifact/ 2>/dev/null || true + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-package + path: artifact/ + retention-days: 1 + if-no-files-found: error + + request-approval: + needs: [configure, prepare, pack] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Request release approval + uses: ./actions/release/request-approval + with: + 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] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + environment: >- + ${{ github.event_name != 'pull_request' + && (inputs.dry_run && 'publish-dry-run' || 'publish') + || '' }} + permissions: + contents: write + 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 + with: + name: release-package + path: dl + + - name: Ship package + uses: ./actions/release/lang/ruby/ship-package + with: + working_directory: dl # holds the .gem + sbom.json + 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 }} + # ⚠️ 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 + label: bt-publishing-test + notes: ${{ needs.prepare.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 0c8882f..49fddea 100644 --- a/.github/workflows/release-ruby.yml +++ b/.github/workflows/release-ruby.yml @@ -4,7 +4,8 @@ # # This workflow serves two purposes: # 1. End-to-end testing of the composite actions in actions/release/ -# 2. Reference implementation of the Ruby-specific release template +# 2. Reference implementation of the v2 Ruby TURNKEY release shape +# (build-and-ship): configure → prepare → validate → 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. @@ -17,11 +18,9 @@ # publish # - Required reviewers: sdk-eng team or named maintainers # - Prevent self-review: enabled -# - Deployment branches: main only # publish-dry-run # - Required reviewers: at least yourself (to test the approval gate) # - Allow administrators to bypass: enabled (for testing flexibility) -# - Deployment branches: no restriction # # Repository Variables (Settings → Secrets and variables → Variables): # SLACK_SDK_RELEASE_CHANNEL Slack channel ID for release notifications @@ -55,6 +54,13 @@ on: type: string default: "I have merged a version bump PR" required: false + release_type: + description: "Release type — maps to the github_release default (prerelease → no GitHub release)" + type: choice + default: stable + options: + - stable + - prerelease sha: description: "Commit SHA (of the version bump) to release" required: true @@ -87,46 +93,44 @@ jobs: 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. - # (Robust whether or not the shell has pipefail set.) 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 - validate: + # DERIVE — read-only facts (version, tag, branch, …). No token; sets up a default Ruby to read the version. + configure: needs: bump runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 5 permissions: contents: read outputs: - release_tag: ${{ steps.validate-release.outputs.release_tag }} - prev_release: ${{ steps.validate-release.outputs.prev_release }} - branch: ${{ steps.validate-release.outputs.branch }} - on_release_branch: ${{ steps.validate-release.outputs.on_release_branch }} - commit_message: ${{ steps.validate-release.outputs.commit_message }} + 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 }} steps: - # bt-publishing-test: checkout required to resolve local ./actions/... references. - # Real SDK workflows use external braintrustdata/sdk-actions/...@sha references - # and do not need this step. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Validate release - id: validate-release - uses: ./actions/release/lang/ruby/validate + - name: Configure release + id: configure + uses: ./actions/release/lang/ruby/configure with: - # bt-publishing-test: version passed directly from bump job — no version file needed. + # bt-publishing-test: version passed directly from the bump job. # In a real Ruby project, replace `version` with: # version_file: lib/your_gem/version.rb # version_module: YourGem version: ${{ needs.bump.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 fail-fast check + release_type: ${{ inputs.release_type || 'stable' }} prepare: - needs: validate + needs: configure runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: @@ -135,60 +139,75 @@ jobs: pr_list: ${{ steps.prepare.outputs.pr_list }} notes: ${{ steps.prepare.outputs.notes }} steps: - # bt-publishing-test: checkout required because actions are referenced locally (./actions/...). - # Real SDK workflows reference actions externally (braintrustdata/sdk-actions/...@sha) - # and do not need this step. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Prepare release id: prepare uses: ./actions/release/prepare with: - release_tag: ${{ needs.validate.outputs.release_tag }} + 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.validate.outputs.prev_release }} + prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha || needs.configure.outputs.prev_release }} + + # JUDGE — toggleable checks + lint + a pre-gate build & SBOM, using the facts configure derived. + validate: + needs: [configure, prepare] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - 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 }} - notify-pending: - needs: [validate, prepare] + # NOTIFY — issues the approval request (green the moment it posts; the real gate is on build-and-ship). + request-approval: + needs: [configure, prepare, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} steps: - # bt-publishing-test: checkout required for local action resolution (see prepare job comment) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Notify release pending - uses: ./actions/release/notify-pending + - name: Request release approval + uses: ./actions/release/request-approval with: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - release_tag: ${{ needs.validate.outputs.release_tag }} - prev_release: ${{ needs.validate.outputs.prev_release }} - branch: ${{ needs.validate.outputs.branch }} - on_release_branch: ${{ needs.validate.outputs.on_release_branch }} - commit_message: ${{ needs.validate.outputs.commit_message }} + 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). - # Gate on != pull_request: GHA's `A && '' || B` returns B (the '' is falsy), so suppression - # must be the trailing '' branch, not the "true" value. slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - # slack_mention: '@sdk-eng' - label: bt-publishing-test # distinguishes this from other packages in this repo + label: bt-publishing-test emoji: ':ruby:' - publish: - needs: [bump, validate, prepare, notify-pending] + # EXECUTE — build → attest → publish → release → announce, in one gated job. + build-and-ship: + needs: [configure, prepare, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 # No environment on PR dry-runs (avoids the approval gate AND the accumulating # deployment records); the gated environments apply only to manual dispatch. - # Note the structure: empty must be the trailing `|| ''` branch, since GHA's - # `cond && '' || X` would fall through to X (the '' is falsy). environment: >- ${{ github.event_name != 'pull_request' && (inputs.dry_run && 'publish-dry-run' || 'publish') @@ -196,45 +215,46 @@ jobs: permissions: contents: write id-token: write - attestations: write # signed SBOM attestation (actions/attest-sbom) + attestations: write # signed SBOM + SLSA build provenance steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.sha || github.event.pull_request.head.sha }} fetch-depth: 0 - # bt-publishing-test: update both version.rb and Gemfile.lock to the run-number version. - # The publish job checks out fresh so both files still reflect the placeholder. + # bt-publishing-test: update version.rb and Gemfile.lock to the resolved version (from configure). + # The job checks out fresh, so both files still reflect the committed placeholder. # Real SDK workflows omit this — both files are committed together in the bump PR. - name: Apply version to version.rb and Gemfile.lock env: - VERSION: ${{ needs.bump.outputs.version }} + VERSION: ${{ needs.configure.outputs.version }} run: | sed -i "s|VERSION = \".*\"|VERSION = \"$VERSION\"|" \ test/release/ruby/lib/bt_publishing_test/version.rb sed -i "s|bt-publishing-test ([0-9]*\.[0-9]*\.[0-9]*)|bt-publishing-test ($VERSION)|" \ test/release/ruby/Gemfile.lock - - name: Publish - uses: ./actions/release/lang/ruby/publish + - name: Build and ship + uses: ./actions/release/lang/ruby/build-and-ship with: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - checkout: 'false' # bt-publishing-test: skip internal checkout — version patching above must survive into the gem build + checkout: 'false' # bt-publishing-test: skip internal checkout — version patch above must survive working_directory: test/release/ruby dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} - release_tag: ${{ needs.validate.outputs.release_tag }} - github_release: 'false' # bt-publishing-test: omit GitHub release to avoid tag/release pollution from repeated test runs + release_tag: ${{ needs.configure.outputs.release_tag }} + # ⚠️ 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 }} - prev_release: ${{ needs.validate.outputs.prev_release }} - branch: ${{ needs.validate.outputs.branch }} - on_release_branch: ${{ needs.validate.outputs.on_release_branch }} + 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 }} - # Suppress Slack on PR auto dry-runs (still exercises message-building; just no real post). - # Gate on != pull_request: GHA's `A && '' || B` returns B (the '' is falsy), so suppression - # must be the trailing '' branch, not the "true" value. slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - # slack_mention: '@sdk-eng' - # failure_slack_mention: '@sdk-eng' gem_name: bt-publishing-test - label: bt-publishing-test # distinguishes this from other packages in this repo + label: bt-publishing-test + emoji: ':ruby:' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1527391..fcaf4f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,27 @@ and regenerate: After any change, run `rake generate` and commit `templates/` and the regenerated `actions/` **together**. CI fails if they drift. +### Versioning + +Each action family (the top directory under `templates/actions/`, e.g. `release`) +carries a soft-semver in `templates/actions//VERSION`. `rake generate` +stamps it into every generated action's header as a machine-readable comment: + +``` +# sdk-actions: {"family":"release","version":"1.0.0"} +``` + +Consumers pin by SHA and diff this stamp across SHAs to judge upgrade risk, so bump +`VERSION` when a family's public interface changes and regenerate: + +- **major** — breaking (an input removed/renamed, a behavior contract changed) +- **minor** — additive (new action, new optional input) +- **patch** — fix + +We publish **no git tags** — SHA pinning is the contract. The version is a comment, +not a YAML key (`action.yml` has no native version field), so it never affects schema +validation. + ### Commands | command | what it does | diff --git a/README.md b/README.md index 997eee1..130a246 100644 --- a/README.md +++ b/README.md @@ -4,24 +4,47 @@ Shared GitHub actions and workflows for Braintrust SDK repositories. ## Releases -**Copy the canonical release workflow template** from `.github/workflows/release-.yml` and adapt it for your repository. Using it as an upstream source will enable the implemented release process to receive improvements in the future. +The release actions provide building blocks for implementing release workflows in your own repository. They contain features for security (e.g. trusted publishing, gating, provenance, SBOM, etc) and convenience (e.g. Slack notifications, summaries, automated GitHub release, etc) + +These actions can be used in combination to bootstrap quickly or individually to build out a customized release workflow. ### Pre-requisites -- **JS**: needs `packageManager` (or `pnpm_version`) **≥ pnpm 11.8** (for `pnpm sbom`) -- **Ruby**: needs a `rake build` that emits exactly one `pkg/*.gem`. +- **JS**: needs `packageManager` (or `pnpm_version`) **≥ pnpm 11.8** (for `pnpm sbom`). Bun (custom builds) needs a `bun build … --metafile` build. +- **Python**: a `uv`-buildable project; Python pinned via `.python-version` / `.tool-versions`. +- **Ruby**: needs a `rake build` that emits exactly one `pkg/*.gem`, and `rake lint`. ### Adopting the release workflow -1. **Copy the canonical template** for your language into your repo's +1. **Pick a shape**: + - **Turnkey** builds and publishes for you in one gated job. Recommended for most applications. + - **Custom build** lets you build your own artifact and hands it to a publish-only job that verifies and ships it, keeping the same security guarantees. + +2. **Copy the canonical template** for your language + shape into your repo's `.github/workflows/`: - - [`release-js.yml`](.github/workflows/release-js.yml) - - [`release-py.yml`](.github/workflows/release-py.yml) - - [`release-ruby.yml`](.github/workflows/release-ruby.yml) -2. **Configure an OIDC trusted publisher** on your registry (npm / PyPI / RubyGems) for this repo + workflow (and environment, if gated) — publishing and attestation use it, no long-lived tokens. (Adapting an existing workflow rather than copying? Also grant the publish job `attestations: write`.) -3. **Adapt the marked sections** — version source, package/gem name, working directory, and so on; the template's comments flag exactly what changes. -4. **Record the upstream version you based it on** — the sdk-actions ref (commit SHA or tag) your copy was adapted from, e.g. in a header comment. Tracking it lets you (or an agent) diff your copy against a newer upstream template and sync changes deliberately as this repo evolves. -5. **Bump the pinned SHA** to pick up action changes. Because each action is self-contained, one SHA bump pulls in the whole updated chain. + + | Language | Turnkey | Custom build | + |---|---|---| + | JavaScript | [`release-js.yml`](.github/workflows/release-js.yml) | [`release-js-custom.yml`](.github/workflows/release-js-custom.yml) | + | Python | [`release-py.yml`](.github/workflows/release-py.yml) | [`release-py-custom.yml`](.github/workflows/release-py-custom.yml) | + | Ruby | [`release-ruby.yml`](.github/workflows/release-ruby.yml) | [`release-ruby-custom.yml`](.github/workflows/release-ruby-custom.yml) | + +3. **Configure an OIDC trusted publisher** on your registry (npm / PyPI / RubyGems) for this repo + workflow filename (and environment, if gated) — publishing and attestation use it, no long-lived tokens. +4. **Adapt the template to your package.** The template demonstrates an actual package deploy and includes glue to make that function: other applications will want to remove that glue, so review the whole file before adapting. The comments flag what typically changes (version source, package/gem name, working directory), but they aren't exhaustive. +5. **Record the upstream SHA you based it on** Copy the sdk-actions commit your copy of the workflow was adapted from, e.g. in a header comment. Tracking it lets you (or an agent) diff your copy against a newer upstream template and sync changes deliberately as this repo evolves. + +### Updating a release workflow + +Actions are pinned by commit SHA. Bump the SHA to pick up changes. To judge whether a bump is safe, compare the version stamped into each `action.yml` header between the old and new SHA: + +```yaml +# sdk-actions: {"family":"release","version":"1.0.0"} + +# Tip: this is machine-readable: +# sed -n 's/^# sdk-actions: //p' action.yml | jq -r .version # → 1.0.0 +``` + +These actions follow semantic versioning: expect breaking changes when the major version updates. ### Available release actions @@ -32,14 +55,13 @@ pulls in everything it needs. | Action | Purpose | |---|---| -| `release/lang/ruby/publish` | Build the gem, generate + sign a CycloneDX SBOM and SLSA build provenance, push to RubyGems (OIDC trusted publishing), create the GitHub release (SBOM attached), and notify | -| `release/lang/ruby/validate` | Check out the SHA, set up Ruby, read the version, validate the release (tag/branch/metadata), lint + build + generate SBOM (pre-gate check) | -| `release/lang/js/publish` | Build, generate + sign a CycloneDX SBOM, publish to npm (OIDC trusted publishing + provenance), create the GitHub release (SBOM attached), and notify | -| `release/lang/js/validate` | Check out the SHA, set up Node + package manager, read the version, validate the release (channel/tag/branch/metadata), build + generate SBOM (pre-gate check) | -| `release/lang/py/publish` | Build, generate + sign a CycloneDX SBOM, publish to PyPI (OIDC trusted publishing + PEP 740 attestations), create the GitHub release (SBOM attached), and notify | -| `release/lang/py/validate` | Check out the SHA, set up uv + Python, read the version, validate the release (tag/branch/metadata, PyPI availability), build + generate SBOM (pre-gate check) | -| `release/notify-pending` | Post the pre-approval job summary and Slack notification | +| `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 | Inputs and outputs are documented in each `action.yml`. Reference an action by commit SHA: diff --git a/actions/release/lang/js/publish/action.yml b/actions/release/lang/js/build-and-ship/action.yml similarity index 96% rename from actions/release/lang/js/publish/action.yml rename to actions/release/lang/js/build-and-ship/action.yml index 3846b9f..af67456 100644 --- a/actions/release/lang/js/publish/action.yml +++ b/actions/release/lang/js/build-and-ship/action.yml @@ -1,11 +1,14 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. -name: Publish JavaScript Package +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Build and Ship JavaScript Package description: > - Full JavaScript package release: checks out the SHA, sets up Node and pnpm, - builds, generates and signs a CycloneDX SBOM (bound to the published tarball), - publishes to npm (OIDC trusted publishing + provenance), creates a GitHub release - (with the SBOM attached), and sends a Slack completion notification. Covers the - standard publish sequence for Braintrust npm packages. + Turnkey JavaScript release — the whole pipeline in one gated job: checks out the + SHA, sets up Node and pnpm, builds, generates and signs a CycloneDX SBOM (bound to + the published tarball via `pnpm pack` == `pnpm publish` bytes), publishes to npm + (OIDC trusted publishing + provenance), creates a GitHub release (with the SBOM + attached), and announces on Slack. This is the `build → attest → publish → release → + announce` pipeline; the custom-build path (pack + ship-package) is the same pipeline + split across two jobs at the build⟷publish seam. inputs: sha: diff --git a/actions/release/lang/js/configure/action.yml b/actions/release/lang/js/configure/action.yml new file mode 100644 index 0000000..09d27a1 --- /dev/null +++ b/actions/release/lang/js/configure/action.yml @@ -0,0 +1,196 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# 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. + +inputs: + sha: + description: "Commit SHA being released" + required: true + version: + description: > + Explicit version override. If provided, the version is not read from package.json. + Use when the version is computed externally (e.g. a run-number auto-bump). + required: false + default: '' + working_directory: + description: "Path to the package root (where package.json lives)" + required: false + default: '.' + tag_format: + description: "Git tag format, with a {version} placeholder (e.g. js-{version})" + required: false + default: 'v{version}' + release_type: + description: > + 'stable' | 'prerelease'. Maps to the channel + github_release defaults + (prerelease → channel 'rc', no GitHub release). Explicit channel/github_release + inputs override this. + required: false + default: 'stable' + channel: + description: "Explicit npm channel (dist-tag) override. Empty → derived from release_type." + required: false + default: '' + github_release: + description: "Explicit github_release override ('true'/'false'). Empty → derived from release_type." + required: false + default: '' + release_branch: + description: "Comma-separated allowed release branch patterns (globs)." + required: false + default: 'main' +outputs: + version: + description: "The resolved package version" + value: ${{ steps.read-version.outputs.version }} + release_tag: + description: "The release tag (e.g. v0.3.2)" + value: ${{ steps.configure.outputs.release_tag }} + prev_release: + description: "The previous release tag" + value: ${{ steps.configure.outputs.prev_release }} + branch: + description: "The branch containing the SHA" + value: ${{ steps.configure.outputs.branch }} + on_release_branch: + description: "Whether the SHA is on an allowed release branch" + value: ${{ steps.configure.outputs.on_release_branch }} + commit_message: + description: "The commit message at the SHA" + value: ${{ steps.configure.outputs.commit_message }} + channel: + description: "The resolved npm channel (dist-tag)" + value: ${{ steps.configure.outputs.channel }} + github_release: + description: "Whether a GitHub release should be created" + value: ${{ steps.configure.outputs.github_release }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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. + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 'lts/*' + + - 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: Validate SHA format + shell: bash + env: + SHA: ${{ inputs.sha }} + run: | + if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." + exit 1 + fi + + - name: Configure release + id: configure + shell: bash + env: + VERSION: ${{ steps.read-version.outputs.version }} + TAG_FORMAT: ${{ inputs.tag_format }} + RELEASE_BRANCH: ${{ inputs.release_branch }} + 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 }}" + GHR_OVERRIDE: "${{ inputs.github_release }}" + run: | + TAG="${TAG_FORMAT//\{version\}/$VERSION}" + COMMIT_MSG=$(git log -1 --format="%s" HEAD) + BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) + + ON_RELEASE_BRANCH=false + IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" + for pattern in "${PATTERNS[@]}"; do + pattern=$(echo "$pattern" | xargs) + if [[ "$BRANCH" == $pattern ]]; then ON_RELEASE_BRANCH=true; break; fi + done + + PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" + PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") + + # channel: explicit override wins; else map from release_type (prerelease→rc, stable→latest) + if [ -n "$CHANNEL_OVERRIDE" ]; then CHANNEL="$CHANNEL_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then CHANNEL="rc" + else CHANNEL="latest"; fi + + # github_release: explicit override wins; else prerelease→false, stable→true + if [ -n "$GHR_OVERRIDE" ]; then GHR="$GHR_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then GHR="false" + else GHR="true"; fi + + { + echo "release_tag=$TAG" + echo "commit_message=$COMMIT_MSG" + echo "branch=$BRANCH" + echo "on_release_branch=$ON_RELEASE_BRANCH" + echo "prev_release=$PREV_RELEASE" + echo "channel=$CHANNEL" + echo "github_release=$GHR" + } >> "$GITHUB_OUTPUT" + echo "Configured $TAG @ $VERSION (channel=$CHANNEL, github_release=$GHR, branch=$BRANCH, on_release_branch=$ON_RELEASE_BRANCH)" + + - name: Dump JavaScript environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== Node environment ===" + node --version + npm --version + echo "=== pnpm ===" + pnpm --version || echo "(unavailable)" + echo "=== package.json ===" + node -e ' + const p = require("./package.json"); + console.log(JSON.stringify({ + name: p.name, + version: p.version, + private: p.private || false, + packageManager: p.packageManager, + engines: p.engines, + devEngines: p.devEngines, + publishConfig: p.publishConfig, + lifecycle: Object.fromEntries( + ["prepublishOnly", "prepack", "prepare", "postpack"] + .filter((k) => p.scripts && p.scripts[k]) + .map((k) => [k, p.scripts[k]]), + ), + }, null, 2)); + ' 2>/dev/null || echo "(package.json unavailable)" diff --git a/actions/release/lang/js/pack-bun/action.yml b/actions/release/lang/js/pack-bun/action.yml new file mode 100644 index 0000000..6fb55f5 --- /dev/null +++ b/actions/release/lang/js/pack-bun/action.yml @@ -0,0 +1,225 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Pack JavaScript Package (custom build, bun) +description: > + Build side of the custom-build ("build-ownership") path for a Bun project — runs in an + UNPRIVILEGED job with NO publish credentials. Sets up Bun, runs `bun build --metafile` + (bundling deps into the artifact), packs the npm tarball (`bun pm pack`), generates a + CycloneDX SBOM FROM the build metafile (so it matches the bundled artifact), and attests + the SBOM + SLSA build provenance to that tarball. The workflow uploads the tarball + SBOM + for the gated ship-package job (shared with pnpm). Requires id-token: write + + attestations: write. pnpm projects use pack-pnpm instead. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before building. Set to false when the caller already checked out." + required: false + default: 'true' + working_directory: + description: "Path to the package root (where package.json lives)" + required: false + default: '.' + bun_version: + description: "Bun version. Empty → auto-detect from the project (.tool-versions / package.json) or latest." + required: false + default: '' + entry: + description: "bun build entrypoint" + required: false + default: 'src/index.ts' + outdir: + description: "bun build output directory" + required: false + default: 'dist' + target: + description: "bun build --target (bun|node|browser)" + required: false + default: 'bun' + format: + description: "bun build --format (esm|cjs|iife)" + required: false + default: 'esm' + sbom: + description: "Generate + attest a CycloneDX SBOM (from the build metafile). Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' +outputs: + tarball: + description: "Absolute path to the packed tarball (the exact bytes ship-package will publish)" + value: ${{ steps.pack.outputs.tarball }} + integrity: + description: "sha512 integrity of the packed tarball" + value: ${{ steps.pack.outputs.integrity }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: ${{ inputs.bun_version }} + - name: Install dependencies + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + bun install --frozen-lockfile || { + echo "::error title=Dependency install failed::'bun install --frozen-lockfile' failed — the lockfile is likely out of sync with package.json (run 'bun install' and commit bun.lock)." + exit 1 + } + + - name: Build (bun) + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + ENTRY: ${{ inputs.entry }} + OUTDIR: ${{ inputs.outdir }} + TARGET: ${{ inputs.target }} + FORMAT: ${{ inputs.format }} + METAFILE: meta.json + run: | + bun build "$ENTRY" --outdir "$OUTDIR" --target "$TARGET" --format "$FORMAT" --metafile="$METAFILE" + echo "Built $OUTDIR (metafile: $METAFILE)" + + - name: Pack (bun pm pack) + id: pack + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + PACK_DIR: ${{ runner.temp }}/pkgout + run: | + rm -rf "$PACK_DIR"; mkdir -p "$PACK_DIR" + bun pm 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" + echo "Packed $TGZ" + + - name: Validate packed tarball + shell: bash + env: + TGZ: ${{ steps.pack.outputs.tarball }} + run: | + if [ ! -f "$TGZ" ]; then + echo "::error title=Package not found::packed tarball does not exist: $TGZ"; exit 1 + fi + PKGJSON=$(tar -xzO -f "$TGZ" package/package.json) + NAME=$(printf '%s' "$PKGJSON" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).name || ""') + VER=$(printf '%s' "$PKGJSON" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).version || ""') + # Count shipped files EXCLUDING package.json — a tarball with only package.json means + # "files"/.npmignore excluded everything that should ship. + CONTENT=$(tar -tzf "$TGZ" | grep '^package/' | grep -vc '^package/package.json$' || true) + + if [ -z "$NAME" ]; then + echo "::error title=Invalid package::the packed tarball has no name."; exit 1 + fi + if [ -z "$VER" ]; then + echo "::error title=Invalid package::the packed tarball has no version."; exit 1 + fi + if [ "$CONTENT" -lt 1 ]; then + echo "::error title=Empty package::the tarball ships only package.json — check the package's \"files\"/.npmignore."; exit 1 + fi + echo "✓ $NAME@$VER — $CONTENT shipped file(s), tarball looks sane" + + - name: Generate SBOM (CycloneDX, from Bun bundle metafile) + if: ${{ inputs.sbom == 'true' }} + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + METAFILE: meta.json + OUTPUT: sbom.json + run: | + # .cjs so it runs as CommonJS even when the package is "type": "module". + cat > "$RUNNER_TEMP/bun-sbom.cjs" <<'CJS' + const fs = require("fs"), crypto = require("crypto"); + const meta = JSON.parse(fs.readFileSync(process.env.METAFILE, "utf8")); + const root = JSON.parse(fs.readFileSync("package.json", "utf8")); + // Collect every node_modules module the bundle actually included. The last + // node_modules segment is the installed package (handles nesting + @scope/name). + const seen = new Map(); + for (const out of Object.values(meta.outputs || {})) { + for (const input of Object.keys(out.inputs || {})) { + const m = input.match(/^(.*node_modules\/(?:@[^/]+\/[^/]+|[^/]+))\//); + if (!m || seen.has(m[1])) continue; + try { + const pj = JSON.parse(fs.readFileSync(m[1] + "/package.json", "utf8")); + if (pj.name && pj.version) seen.set(m[1], { name: pj.name, version: pj.version }); + } catch { /* not a package root; skip */ } + } + } + const purl = (n, v) => `pkg:npm/${n[0] === "@" ? "%40" + n.slice(1) : n}@${v}`; + const components = [...seen.values()] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((c) => ({ type: "library", name: c.name, version: c.version, purl: purl(c.name, c.version) })); + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.7", + // Required: actions/attest recognizes CycloneDX only with bomFormat + specVersion + serialNumber. + serialNumber: `urn:uuid:${crypto.randomUUID()}`, + version: 1, + metadata: { + tools: { components: [{ type: "application", group: "braintrust", name: "sdk-actions bun sbom" }] }, + component: { type: "application", name: root.name, version: root.version, purl: purl(root.name, root.version) }, + }, + components, + }; + fs.writeFileSync(process.env.OUTPUT, JSON.stringify(bom, null, 2)); + console.log(`Wrote ${process.env.OUTPUT}: ${components.length} bundled component(s)`); + CJS + node "$RUNNER_TEMP/bun-sbom.cjs" + + - name: Attest SBOM + if: ${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }} + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: |- + ${{ steps.pack.outputs.tarball }} + sbom-path: ${{ inputs.working_directory }}/sbom.json + + - name: Attest build provenance + if: ${{ inputs.dry_run != 'true' }} + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: |- + ${{ steps.pack.outputs.tarball }} + + - name: Dump JavaScript environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== Node environment ===" + node --version + npm --version + echo "=== pnpm ===" + pnpm --version || echo "(unavailable)" + echo "=== package.json ===" + node -e ' + const p = require("./package.json"); + console.log(JSON.stringify({ + name: p.name, + version: p.version, + private: p.private || false, + packageManager: p.packageManager, + engines: p.engines, + devEngines: p.devEngines, + publishConfig: p.publishConfig, + lifecycle: Object.fromEntries( + ["prepublishOnly", "prepack", "prepare", "postpack"] + .filter((k) => p.scripts && p.scripts[k]) + .map((k) => [k, p.scripts[k]]), + ), + }, null, 2)); + ' 2>/dev/null || echo "(package.json unavailable)" diff --git a/actions/release/lang/js/pack-pnpm/action.yml b/actions/release/lang/js/pack-pnpm/action.yml new file mode 100644 index 0000000..3b8cf22 --- /dev/null +++ b/actions/release/lang/js/pack-pnpm/action.yml @@ -0,0 +1,216 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Pack JavaScript Package (custom build, pnpm) +description: > + Build side of the custom-build ("build-ownership") path for a pnpm project — runs in + an UNPRIVILEGED job with NO publish credentials. Checks out the SHA, builds, packs the + exact tarball (`pnpm pack`), generates a CycloneDX SBOM (`pnpm sbom`), and attests both + the SBOM and SLSA build provenance to that tarball. The workflow uploads the tarball + + SBOM for the gated ship-package job to verify and publish. This is the + `build → pack → attest` prefix of the pipeline; ship-package is the + `publish → release → announce` suffix. Requires id-token: write + attestations: write + (but NOT publish credentials). Bun projects use pack-bun instead. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before packing. Set to false when the caller has already checked out and built." + required: false + default: 'true' + working_directory: + description: "Path to the package root (where package.json lives)" + required: false + default: '.' + node_version: + description: > + Node version (e.g. 24), or a path to a version file + (.tool-versions, .nvmrc, .node-version) relative to the repo root. + required: true + pnpm_version: + description: "pnpm version to install. When empty, read from package.json packageManager." + required: false + default: '' + build: + description: > + Whether to run the build step. Set to 'false' when the consumer's own build + already ran (the common monorepo case) — pack then just packages the output. + required: false + default: 'true' + build_command: + description: "npm script to run for the build (when build is 'true')" + required: false + default: 'build' + sbom: + description: "Generate + attest a CycloneDX SBOM bound to the packed tarball. Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' +outputs: + tarball: + description: "Absolute path to the packed tarball (the exact bytes ship-package will publish)" + value: ${{ steps.pack.outputs.tarball }} + integrity: + description: "sha512 integrity of the packed tarball" + value: ${{ steps.pack.outputs.integrity }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + + - name: Set up pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + # Empty version → action-setup reads the pnpm version from this package.json's + # "packageManager" field (one source of truth). A non-empty value overrides it. + version: ${{ inputs.pnpm_version }} + package_json_file: ${{ inputs.working_directory }}/package.json + + - name: Resolve Node version source + id: node-src + shell: bash + env: + NODE_VERSION: ${{ inputs.node_version }} + run: | + # node_version is either an explicit version (e.g. 24) or a path to a version + # file (.tool-versions / .nvmrc / .node-version). Route by file existence so a + # single input covers both; paths are relative to the repo root (as setup-node expects). + if [ -n "$NODE_VERSION" ] && [ -f "$NODE_VERSION" ]; then + echo "file=$NODE_VERSION" >> "$GITHUB_OUTPUT" + echo "spec=" >> "$GITHUB_OUTPUT" + else + echo "file=" >> "$GITHUB_OUTPUT" + echo "spec=$NODE_VERSION" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ steps.node-src.outputs.spec }} + node-version-file: ${{ steps.node-src.outputs.file }} + registry-url: ${{ inputs.registry }} + cache: pnpm + cache-dependency-path: ${{ inputs.working_directory }}/pnpm-lock.yaml + + - name: Install dependencies + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + pnpm install --frozen-lockfile || { + echo "::error title=Dependency install failed::'pnpm install --frozen-lockfile' failed — the lockfile is likely out of sync with package.json (run 'pnpm install' and commit pnpm-lock.yaml)." + exit 1 + } + + - name: Build + if: ${{ inputs.build == 'true' }} + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + BUILD_COMMAND: ${{ inputs.build_command }} + run: | + pnpm run "$BUILD_COMMAND" + + - name: Pack + id: pack + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + PACK_DIR: ${{ runner.temp }}/pkgout + run: | + rm -rf "$PACK_DIR"; mkdir -p "$PACK_DIR" + 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" + echo "Packed $TGZ" + + - name: Validate packed tarball + shell: bash + env: + TGZ: ${{ steps.pack.outputs.tarball }} + run: | + if [ ! -f "$TGZ" ]; then + echo "::error title=Package not found::packed tarball does not exist: $TGZ"; exit 1 + fi + PKGJSON=$(tar -xzO -f "$TGZ" package/package.json) + NAME=$(printf '%s' "$PKGJSON" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).name || ""') + VER=$(printf '%s' "$PKGJSON" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).version || ""') + # Count shipped files EXCLUDING package.json — a tarball with only package.json means + # "files"/.npmignore excluded everything that should ship. + CONTENT=$(tar -tzf "$TGZ" | grep '^package/' | grep -vc '^package/package.json$' || true) + + if [ -z "$NAME" ]; then + echo "::error title=Invalid package::the packed tarball has no name."; exit 1 + fi + if [ -z "$VER" ]; then + echo "::error title=Invalid package::the packed tarball has no version."; exit 1 + fi + if [ "$CONTENT" -lt 1 ]; then + echo "::error title=Empty package::the tarball ships only package.json — check the package's \"files\"/.npmignore."; exit 1 + fi + echo "✓ $NAME@$VER — $CONTENT shipped file(s), tarball looks sane" + + - name: Generate SBOM (CycloneDX) + if: ${{ inputs.sbom == 'true' }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + 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 + echo "Wrote $(pwd)/sbom.json" + + - name: Attest SBOM + if: ${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }} + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: |- + ${{ steps.pack.outputs.tarball }} + sbom-path: ${{ inputs.working_directory }}/sbom.json + + - name: Attest build provenance + if: ${{ inputs.dry_run != 'true' }} + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: |- + ${{ steps.pack.outputs.tarball }} + + - name: Dump JavaScript environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== Node environment ===" + node --version + npm --version + echo "=== pnpm ===" + pnpm --version || echo "(unavailable)" + echo "=== package.json ===" + node -e ' + const p = require("./package.json"); + console.log(JSON.stringify({ + name: p.name, + version: p.version, + private: p.private || false, + packageManager: p.packageManager, + engines: p.engines, + devEngines: p.devEngines, + publishConfig: p.publishConfig, + lifecycle: Object.fromEntries( + ["prepublishOnly", "prepack", "prepare", "postpack"] + .filter((k) => p.scripts && p.scripts[k]) + .map((k) => [k, p.scripts[k]]), + ), + }, null, 2)); + ' 2>/dev/null || echo "(package.json unavailable)" diff --git a/actions/release/lang/js/ship-package/action.yml b/actions/release/lang/js/ship-package/action.yml new file mode 100644 index 0000000..1cf5c5b --- /dev/null +++ b/actions/release/lang/js/ship-package/action.yml @@ -0,0 +1,482 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Ship JavaScript Package (custom build) +description: > + Publish side of the custom-build ("build-ownership") path — runs in the GATED job. + Verifies the downloaded artifact's attestations (SLSA build provenance + CycloneDX + SBOM) against its own bytes, then publishes THAT exact tarball to npm (OIDC trusted + publishing + provenance), creates a GitHub release (attaching the SBOM), and + announces on Slack. No build, no attest — it ships a prebuilt, attested artifact. + Tamper-evident: any change to the tarball in transit fails verification and blocks + the publish. Requires id-token: write + contents: write. The workflow downloads the + artifact (tarball + sbom.json) into working_directory before calling this action. + +inputs: + tarball: + description: "Path to the downloaded tarball to verify + publish (the exact bytes pack attested)" + required: true + working_directory: + description: "Directory holding the downloaded tarball + sbom.json (used to attach the SBOM to the release)" + required: false + default: '.' + sha: + description: "Commit SHA being released (release target)" + required: true + npm: + description: "Whether to publish to the npm registry" + required: false + default: 'true' + registry: + description: "npm registry URL" + required: false + default: 'https://registry.npmjs.org' + access: + description: "npm publish access: public | restricted (provenance requires public)" + required: false + default: 'public' + provenance: + description: "Publish with npm-native provenance (requires a public package)" + required: false + default: 'true' + channel: + description: "npm release channel (dist-tag) to publish under" + required: false + default: 'latest' + dry_run: + description: "Skip verification, npm publish, tag push, and GitHub release creation" + required: false + default: 'false' + release_tag: + description: "The release tag (e.g. v0.3.2)" + required: true + github_release: + description: "Whether to create a GitHub release" + required: false + default: 'true' + release_title: + description: "GitHub release name/title. Defaults to the release tag when empty." + required: false + default: '' + sbom: + description: "Attach the downloaded sbom.json to the GitHub release" + required: false + default: 'true' + version: + description: "The package version — used to build the npm link in notifications" + required: false + default: '' + package_name: + description: "npm package name — used to build the npm link in notifications" + required: false + default: '' + label: + description: "Display name for release notifications (e.g. the package name)." + required: false + default: '' + notes: + description: "Base64-encoded release notes from the prepare action" + required: false + default: '' + prev_release: + description: "Previous release tag — used in completion notification" + required: false + default: '' + branch: + description: "Branch the release was made from" + required: true + on_release_branch: + description: "Whether the SHA is on an expected release branch" + required: true + pr_list: + description: "x1f-delimited PR list from prepare action" + required: false + default: '' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if the publish fails" + required: false + default: '' + emoji: + description: "Emoji prefix for Slack messages" + required: false + default: ':package:' + +runs: + using: composite + steps: + - name: Verify attestations + if: ${{ inputs.dry_run != 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACTS: ${{ inputs.tarball }} + REPO: ${{ github.repository }} + run: | + shopt -s nullglob + FILES=( $ARTIFACTS ) + if [ ${#FILES[@]} -eq 0 ]; then + echo "::error title=Nothing to verify::no artifact files matched '$ARTIFACTS'." + exit 1 + fi + for f in "${FILES[@]}"; do + echo "Verifying $(basename "$f") — build provenance…" + gh attestation verify "$f" --repo "$REPO" || { + echo "::error title=Provenance verification failed::no build-provenance attestation matches $(basename "$f") — the artifact was not produced by a trusted build, or was tampered with in transit." + exit 1 + } + echo "Verifying $(basename "$f") — SBOM…" + gh attestation verify "$f" --repo "$REPO" --predicate-type https://cyclonedx.org/bom || { + echo "::error title=SBOM verification failed::no CycloneDX SBOM attestation matches $(basename "$f")." + exit 1 + } + done + echo "✓ attestations verified for: ${FILES[*]}" + + # Publishing a prebuilt tarball goes through the npm CLI, so this needs Node with a + # user-writable global prefix (to install a modern npm for OIDC) and the registry + # wired for trusted publishing — but no pnpm and no dependency install. + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 'lts/*' + registry-url: ${{ inputs.registry }} + + - name: Publish prebuilt tarball to npm + shell: bash + env: + TGZ: ${{ inputs.tarball }} + NPM_ENABLED: ${{ inputs.npm }} + CHANNEL: ${{ inputs.channel }} + ACCESS: ${{ inputs.access }} + PROVENANCE: ${{ inputs.provenance }} + REGISTRY: ${{ inputs.registry }} + DRY_RUN: ${{ inputs.dry_run }} + # Force OIDC: clear any inherited token so publish can't fall back to token auth. + NODE_AUTH_TOKEN: '' + NPM_TOKEN: '' + run: | + if [ "$NPM_ENABLED" != "true" ]; then + echo "npm registry publishing disabled; skipping." + exit 0 + fi + # pnpm can't publish a prebuilt tarball (it re-packs from a dir), so ship uses the + # npm CLI directly; pin >= 11.5.1 for OIDC (newer than setup-node's). + npm install -g "npm@11.6.2" + # Publish from the tarball's OWN directory — no package.json there, so a strict-pnpm + # consumer's devEngines onFail:error can't trip (it checks the CWD manifest). A bare + # path with a "/" is also read by npm as a GitHub shorthand, so we cd + use ./. + DIR=$(dirname "$TGZ"); FILE=$(basename "$TGZ") + cd "$DIR" + ARGS=(--tag "$CHANNEL" --access "$ACCESS" --registry "$REGISTRY") + [ "$PROVENANCE" = "true" ] && ARGS+=(--provenance) + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: npm publish ./$FILE ${ARGS[*]} --dry-run" + ARGS+=(--dry-run) + fi + npm publish "./$FILE" "${ARGS[@]}" || { + echo "::error title=npm publish failed::ENEEDAUTH → the npm trusted publisher must list this repo, workflow, and (if gated) environment; 'cannot publish over ' → the version already exists on npm (bump it)." + exit 1 + } + + - name: Create GitHub release + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + ENABLED: ${{ inputs.github_release }} + DRY_RUN: ${{ inputs.dry_run }} + TAG: ${{ inputs.release_tag }} + RELEASE_TITLE: ${{ inputs.release_title }} + NOTES_B64: ${{ inputs.notes }} + SHA: ${{ inputs.sha }} + SBOM_ENABLED: ${{ inputs.sbom }} + WORKING_DIRECTORY: ${{ inputs.working_directory }} + run: | + if [ "$ENABLED" != "true" ]; then + echo "GitHub release disabled; skipping." + exit 0 + fi + # Release name defaults to the tag when no explicit title is provided. + TITLE="${RELEASE_TITLE:-$TAG}" + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would create GitHub release $TAG (title: $TITLE)" + echo "--- Release notes preview ---" + 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. + if [ "$SBOM_ENABLED" = "true" ] && [ -f "$WORKING_DIRECTORY/sbom.json" ]; then + gh release upload "$TAG" "$WORKING_DIRECTORY/sbom.json" --clobber + fi + fi + + - name: Build npm links + id: links + shell: bash + env: + PACKAGE_NAME: ${{ inputs.package_name }} + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + LINKS='[]' + if [ "$DRY_RUN" != "true" ] && [ -n "$PACKAGE_NAME" ] && [ -n "$VERSION" ]; then + LINKS="[{\"label\":\"npm\",\"url\":\"https://www.npmjs.com/package/$PACKAGE_NAME/v/$VERSION\"}]" + fi + echo "links=$LINKS" >> $GITHUB_OUTPUT + + - name: Post release summary + shell: bash + env: + TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + PREV_RELEASE: ${{ inputs.prev_release }} + BRANCH: ${{ inputs.branch }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + DRY_RUN: ${{ inputs.dry_run }} + LINKS_JSON: ${{ steps.links.outputs.links }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" + + # Subject is the package label when set (disambiguates repos that publish + # multiple packages); otherwise the repository. The repo is still present in + # every link below, so it is never lost. + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + if [ "$DRY_RUN" = "true" ]; then + echo "## $SUBJECT $TAG — dry run complete" >> $GITHUB_STEP_SUMMARY + else + echo "## $SUBJECT $TAG — published ✅" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "$DRY_RUN" = "true" ]; then + echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY + echo "> Dry run: Nothing was tagged or published." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY + echo "> Released from non-release branch: [$BRANCH]($BRANCH_URL)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + echo "**Branch:** [$BRANCH]($BRANCH_URL)" >> $GITHUB_STEP_SUMMARY + + if [ -n "$PREV_RELEASE" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...$TAG" + echo "**Diff:** [$PREV_RELEASE...$TAG]($DIFF_URL)" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Published to" >> $GITHUB_STEP_SUMMARY + + if [ "$DRY_RUN" = "true" ]; then + echo "- [View dry run]($RUN_URL)" >> $GITHUB_STEP_SUMMARY + else + echo "- [GitHub Release]($RELEASE_URL)" >> $GITHUB_STEP_SUMMARY + if [ -n "$LINKS_JSON" ] && [ "$LINKS_JSON" != "[]" ]; then + echo "$LINKS_JSON" | jq -r '.[] | "- [\(.label)](\(.url))"' >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Build published message + id: message-published + shell: bash + env: + TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + EMOJI: ${{ inputs.emoji }} + BRANCH: ${{ inputs.branch }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + PREV_RELEASE: ${{ inputs.prev_release }} + DRY_RUN: ${{ inputs.dry_run }} + SLACK_MENTION: ${{ inputs.slack_mention }} + LINKS_JSON: ${{ steps.links.outputs.links }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + PR_LIST_RAW: ${{ inputs.pr_list }} + run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + + # Subject is the package label when set, else the repository (see summary step). + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + DIFF_PART="" + if [ -n "$PREV_RELEASE" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...$TAG" + DIFF_PART="<$DIFF_URL|${PREV_RELEASE}...$TAG> · " + fi + + PR_LIST=$(echo "$PR_LIST_RAW" | tr $'\x1f' '\n' | sed '/^$/d') + + if [ "$DRY_RUN" = "true" ]; then + TEXT="$EMOJI *$SUBJECT $TAG* complete" + TEXT="$TEXT\n> :information_source: _Dry run: nothing tagged or published._" + else + TEXT="$EMOJI *$SUBJECT $TAG* published" + 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>" + fi + + if [ "$DRY_RUN" = "true" ]; then + TEXT="$TEXT\n${DIFF_PART}<$RUN_URL|View dry run>" + else + SLACK_LINKS="${DIFF_PART}<$RELEASE_URL|View release>" + if [ -n "$LINKS_JSON" ] && [ "$LINKS_JSON" != "[]" ]; then + EXTRA=$(echo "$LINKS_JSON" | jq -r '[.[] | "<\(.url)|\(.label)>"] | join(" · ")') + SLACK_LINKS="$SLACK_LINKS · $EXTRA" + fi + TEXT="$TEXT\n$SLACK_LINKS" + fi + + if [ -n "$PR_LIST" ]; then + TEXT="$TEXT\n$PR_LIST" + fi + + if [ -n "$SLACK_MENTION" ]; then + TEXT="$TEXT\n$SLACK_MENTION" + fi + + { echo 'text<> $GITHUB_OUTPUT + + - name: Send Slack message + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-published.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi + + - name: Dump JavaScript environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== Node environment ===" + node --version + npm --version + echo "=== pnpm ===" + pnpm --version || echo "(unavailable)" + echo "=== package.json ===" + node -e ' + const p = require("./package.json"); + console.log(JSON.stringify({ + name: p.name, + version: p.version, + private: p.private || false, + packageManager: p.packageManager, + engines: p.engines, + devEngines: p.devEngines, + publishConfig: p.publishConfig, + lifecycle: Object.fromEntries( + ["prepublishOnly", "prepack", "prepare", "postpack"] + .filter((k) => p.scripts && p.scripts[k]) + .map((k) => [k, p.scripts[k]]), + ), + }, null, 2)); + ' 2>/dev/null || echo "(package.json unavailable)" + + - name: Build failure message + if: ${{ failure() }} + id: message-failure + shell: bash + env: + RELEASE_TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + STEP: publish + SLACK_MENTION: ${{ inputs.failure_slack_mention }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ACTOR_LINK: <${{ github.server_url }}/${{ github.actor }}|${{ github.actor }}> + run: | + # Subject is the package label when set, else the repository. + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + TEXT=":x: *$SUBJECT $RELEASE_TAG* failed at $STEP" + TEXT="$TEXT\nInitiated by: $ACTOR_LINK" + + if [ -n "$SLACK_MENTION" ]; then + TEXT="$TEXT $SLACK_MENTION" + fi + + TEXT="$TEXT\n<$RUN_URL|View failed run>" + + { echo 'text<> $GITHUB_OUTPUT + + - name: Send Slack message + if: ${{ failure() }} + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-failure.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi diff --git a/actions/release/lang/js/validate/action.yml b/actions/release/lang/js/validate/action.yml index df16d4c..b883461 100644 --- a/actions/release/lang/js/validate/action.yml +++ b/actions/release/lang/js/validate/action.yml @@ -1,25 +1,24 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} name: Validate JavaScript Release description: > - Checks out the release SHA, sets up Node and pnpm, reads the - package version, validates the release channel and metadata (tag existence, - branch, commit metadata), builds, and generates a CycloneDX SBOM — all to - confirm the package is publishable (and the SBOM tooling works) before the - approval gate. + Judges a JavaScript release against the facts `configure` derived — the toggleable + checks (tag unused, version unpublished, channel allowed, notes present, release + 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. inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Skip tag existence failure in dry runs" + description: "Warn instead of failing on an already-existing tag" required: false default: 'false' version: - description: > - Explicit version override. If provided, the version is not read from - package.json. Use when the version is computed externally (e.g. bt-publishing-test - auto-bump from run number). + description: "Version override (from configure). If empty, read from package.json." required: false default: '' working_directory: @@ -41,27 +40,33 @@ inputs: default: 'https://registry.npmjs.org' package_name: description: > - npm package name (matches the publish action's input). When set, validate fails - fast if this version is already published (covers prereleases, which aren't - git-tagged). Leave empty to skip. + 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: '' - tag_format: - description: "Git tag format, with a {version} placeholder (e.g. js-{version})" - required: false - default: 'v{version}' + release_tag: + description: "The release tag (from configure) — checked for prior existence" + required: true channel: - description: "npm release channel (dist-tag) this release targets" + description: "The npm channel/dist-tag (from configure) — checked against allowed_channels" required: false default: 'latest' allowed_channels: description: "Comma-separated allowlist of accepted release channels" required: false default: 'latest' + on_release_branch: + description: "Whether the SHA is on an allowed release branch (from configure)" + required: false + default: 'true' + notes: + description: "Base64-encoded release notes (from prepare) — checked non-blank" + required: false + default: '' build: description: > - Whether to run the build step. Set to 'false' for packages that publish - their sources directly (no build) — the build step then no-ops. + Whether to run the build step. Set to 'false' for packages that publish their + sources directly (no build) — the build step then no-ops. required: false default: 'true' build_command: @@ -70,30 +75,31 @@ inputs: default: 'build' sbom: description: > - Generate a CycloneDX SBOM as a pre-gate check (same generator as publish), so - SBOM-tooling failures (e.g. pnpm < 11.8) surface BEFORE the approval gate. The - SBOM is not signed or attached here — that happens in publish. + Generate a CycloneDX SBOM as a pre-gate check (same generator as build-and-ship), + so SBOM-tooling failures surface BEFORE the approval gate. Not signed or attached + here — that happens in build-and-ship. required: false default: 'true' -outputs: - release_tag: - description: "The release tag (e.g. v0.3.2)" - value: ${{ steps.validate.outputs.release_tag }} - prev_release: - description: "The previous release tag" - value: ${{ steps.validate.outputs.prev_release }} - branch: - description: "The branch containing the SHA" - value: ${{ steps.validate.outputs.branch }} - on_release_branch: - description: "Whether the SHA is on an expected release branch" - value: ${{ steps.validate.outputs.on_release_branch }} - commit_message: - description: "The commit message at the SHA" - value: ${{ steps.validate.outputs.commit_message }} - version: - description: "The resolved package version" - value: ${{ steps.read-version.outputs.version }} + check_tag_unused: + description: "Fail if the release tag already exists" + required: false + default: 'true' + check_version_unpublished: + description: "Fail if the version is already published to the registry" + required: false + default: 'true' + check_channel_allowed: + description: "Fail if channel is not in allowed_channels" + required: false + default: 'true' + check_notes_not_blank: + description: "Warn if release notes are blank" + required: false + default: 'true' + enforce_release_branch: + description: "Hard-fail (instead of warn) when the SHA is off the release branch" + required: false + default: 'false' runs: using: composite @@ -165,6 +171,7 @@ runs: echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Check npm version availability + if: ${{ inputs.check_version_unpublished == 'true' }} shell: bash env: PACKAGE_NAME: ${{ inputs.package_name }} @@ -194,6 +201,7 @@ runs: esac - name: Validate release channel + if: ${{ inputs.check_channel_allowed == 'true' }} shell: bash env: CHANNEL: ${{ inputs.channel }} @@ -206,31 +214,19 @@ runs: echo "::error title=Release channel not allowed::release channel '$CHANNEL' is not in the allowed set: $ALLOWED" exit 1 - - name: Validate SHA format - shell: bash - env: - SHA: ${{ inputs.sha }} - run: | - if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then - echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." - exit 1 - fi - - - name: Validate release - id: validate + - name: Check release shell: bash env: - VERSION: ${{ steps.read-version.outputs.version }} - TAG_FORMAT: ${{ inputs.tag_format }} + TAG: ${{ inputs.release_tag }} DRY_RUN: ${{ inputs.dry_run }} - RELEASE_BRANCH: 'main' - SHA: ${{ inputs.sha }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + NOTES_B64: ${{ inputs.notes }} + CHECK_TAG_UNUSED: ${{ inputs.check_tag_unused }} + CHECK_NOTES: ${{ inputs.check_notes_not_blank }} + ENFORCE_RELEASE_BRANCH: ${{ inputs.enforce_release_branch }} run: | - TAG="${TAG_FORMAT//\{version\}/$VERSION}" - COMMIT_MSG=$(git log -1 --format="%s" HEAD) - BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) - - if git rev-parse "$TAG" >/dev/null 2>&1; then + # 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 echo "::warning title=Release tag already exists::Tag $TAG already exists — skipping in dry run." else @@ -239,25 +235,23 @@ runs: fi fi - ON_RELEASE_BRANCH=false - IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" - for pattern in "${PATTERNS[@]}"; do - pattern=$(echo "$pattern" | xargs) - if [[ "$BRANCH" == $pattern ]]; then - ON_RELEASE_BRANCH=true - break + # On a release branch: warn by default, hard-fail only when enforced. + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + if [ "$ENFORCE_RELEASE_BRANCH" = "true" ]; then + echo "::error title=Off release branch::the release SHA is not on an allowed release branch (enforce_release_branch=true)." + exit 1 + else + echo "::warning title=Off release branch::the release SHA is not on an allowed release branch — releasing anyway." fi - done - - PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" - PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") + fi - echo "release_tag=$TAG" >> $GITHUB_OUTPUT - echo "commit_message=$COMMIT_MSG" >> $GITHUB_OUTPUT - echo "branch=$BRANCH" >> $GITHUB_OUTPUT - echo "on_release_branch=$ON_RELEASE_BRANCH" >> $GITHUB_OUTPUT - echo "prev_release=$PREV_RELEASE" >> $GITHUB_OUTPUT - echo "Validated $TAG @ $SHA ($COMMIT_MSG)" + # Release notes present (warn only — a genuine first release, or no PRs since the last tag, is legitimate). + if [ "$CHECK_NOTES" = "true" ]; then + NOTES=$(echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "") + if [ -z "$NOTES" ]; then + echo "::warning title=Empty release notes::no release notes were generated (first release, or no PRs since the last tag?)." + fi + fi - name: Build if: ${{ inputs.build == 'true' }} diff --git a/actions/release/lang/py/publish/action.yml b/actions/release/lang/py/build-and-ship/action.yml similarity index 96% rename from actions/release/lang/py/publish/action.yml rename to actions/release/lang/py/build-and-ship/action.yml index 79890f7..6890027 100644 --- a/actions/release/lang/py/publish/action.yml +++ b/actions/release/lang/py/build-and-ship/action.yml @@ -1,12 +1,13 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. -name: Publish Python Package +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Build and Ship Python Package description: > - Full Python package release: checks out the SHA, sets up uv and Python, - builds the sdist + wheel, generates and signs a CycloneDX SBOM (bound to the - built dists), publishes to PyPI (OIDC Trusted Publishing with PEP 740 - attestations), creates a GitHub release (with the SBOM attached), and sends a - Slack completion notification. Covers the standard publish sequence for - Braintrust PyPI packages. + Turnkey Python release — the whole pipeline in one gated job: checks out the SHA, + sets up uv and Python, builds the sdist + wheel, generates and signs a CycloneDX SBOM + (bound to the built dists), publishes to PyPI (OIDC Trusted Publishing with PEP 740 + attestations), creates a GitHub release (with the SBOM attached), and announces on + Slack. This is the `build → attest → publish → release → announce` pipeline; the + custom-build path (pack + ship-package) is the same pipeline split across two jobs. inputs: sha: @@ -146,6 +147,8 @@ runs: # Installs this Python and exports UV_PYTHON for the job, so subsequent uv # commands (build, read-version) use it. python-version: ${{ steps.py-src.outputs.spec }} + # Don't fail the job when there's nothing to cache (jobs that install no deps). + ignore-nothing-to-cache: "true" - name: Build distribution shell: bash diff --git a/actions/release/lang/py/configure/action.yml b/actions/release/lang/py/configure/action.yml new file mode 100644 index 0000000..2cfcdcc --- /dev/null +++ b/actions/release/lang/py/configure/action.yml @@ -0,0 +1,197 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# 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. + +inputs: + sha: + description: "Commit SHA being released" + required: true + version: + description: > + Explicit version override. If provided, the version is not read from version_file. + Use when the version is computed externally (e.g. a run-number auto-bump). + required: false + default: '' + version_file: + description: > + Path (repo-root-relative) to a Python module exposing VERSION (e.g. + py/pkg/version.py). Read when version is not overridden. + required: false + default: '' + working_directory: + description: "Path to the package root (where pyproject.toml lives)" + required: false + default: '.' + tag_format: + description: "Git tag format, with a {version} placeholder (e.g. py-{version})" + required: false + default: 'py-{version}' + release_type: + description: > + 'stable' | 'prerelease'. Maps to the github_release default (prerelease → no + GitHub release). An explicit github_release input overrides this. + required: false + default: 'stable' + github_release: + description: "Explicit github_release override ('true'/'false'). Empty → derived from release_type." + required: false + default: '' + release_branch: + description: "Comma-separated allowed release branch patterns (globs)." + required: false + default: 'main' +outputs: + version: + description: "The resolved package version" + value: ${{ steps.read-version.outputs.version }} + release_tag: + description: "The release tag (e.g. py-0.3.2)" + value: ${{ steps.configure.outputs.release_tag }} + prev_release: + description: "The previous release tag" + value: ${{ steps.configure.outputs.prev_release }} + branch: + description: "The branch containing the SHA" + value: ${{ steps.configure.outputs.branch }} + on_release_branch: + description: "Whether the SHA is on an allowed release branch" + value: ${{ steps.configure.outputs.on_release_branch }} + commit_message: + description: "The commit message at the SHA" + value: ${{ steps.configure.outputs.commit_message }} + github_release: + description: "Whether a GitHub release should be created" + value: ${{ steps.configure.outputs.github_release }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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. + - 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 + 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: Validate SHA format + shell: bash + env: + SHA: ${{ inputs.sha }} + run: | + if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." + exit 1 + fi + + - name: Configure release + id: configure + shell: bash + env: + VERSION: ${{ steps.read-version.outputs.version }} + TAG_FORMAT: ${{ inputs.tag_format }} + RELEASE_BRANCH: ${{ inputs.release_branch }} + 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: "" + GHR_OVERRIDE: "${{ inputs.github_release }}" + run: | + TAG="${TAG_FORMAT//\{version\}/$VERSION}" + COMMIT_MSG=$(git log -1 --format="%s" HEAD) + BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) + + ON_RELEASE_BRANCH=false + IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" + for pattern in "${PATTERNS[@]}"; do + pattern=$(echo "$pattern" | xargs) + if [[ "$BRANCH" == $pattern ]]; then ON_RELEASE_BRANCH=true; break; fi + done + + PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" + PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") + + # channel: explicit override wins; else map from release_type (prerelease→rc, stable→latest) + if [ -n "$CHANNEL_OVERRIDE" ]; then CHANNEL="$CHANNEL_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then CHANNEL="rc" + else CHANNEL="latest"; fi + + # github_release: explicit override wins; else prerelease→false, stable→true + if [ -n "$GHR_OVERRIDE" ]; then GHR="$GHR_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then GHR="false" + else GHR="true"; fi + + { + echo "release_tag=$TAG" + echo "commit_message=$COMMIT_MSG" + echo "branch=$BRANCH" + echo "on_release_branch=$ON_RELEASE_BRANCH" + echo "prev_release=$PREV_RELEASE" + echo "channel=$CHANNEL" + echo "github_release=$GHR" + } >> "$GITHUB_OUTPUT" + echo "Configured $TAG @ $VERSION (channel=$CHANNEL, github_release=$GHR, branch=$BRANCH, on_release_branch=$ON_RELEASE_BRANCH)" + + - name: Dump Python environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== uv / Python ===" + uv --version || echo "(uv unavailable)" + uv run --no-project python --version || echo "(python unavailable)" + echo "=== UV_PYTHON ===" + echo "${UV_PYTHON:-(unset)}" + echo "=== Built artifacts ===" + ls -la dist 2>/dev/null || echo "(no dist/)" + echo "=== pyproject.toml (release-relevant) ===" + uv run --no-project python -c ' + import json, tomllib, pathlib + t = tomllib.loads(pathlib.Path("pyproject.toml").read_text()) + proj = t.get("project", {}) + print(json.dumps({ + "name": proj.get("name"), + "version": proj.get("version") or ("(dynamic)" if "version" in proj.get("dynamic", []) else None), + "requires-python": proj.get("requires-python"), + "dependencies": proj.get("dependencies"), + "optional-dependencies": sorted((proj.get("optional-dependencies") or {}).keys()), + "build-system": t.get("build-system"), + }, indent=2)) + ' 2>/dev/null || echo "(pyproject.toml unavailable)" diff --git a/actions/release/lang/py/pack/action.yml b/actions/release/lang/py/pack/action.yml new file mode 100644 index 0000000..a561082 --- /dev/null +++ b/actions/release/lang/py/pack/action.yml @@ -0,0 +1,151 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Pack Python Package (custom build) +description: > + Custom-build side (unprivileged — no publish credentials): checks out the SHA, builds + the sdist + wheel, generates a CycloneDX SBOM, and attests the SBOM + SLSA build + provenance to those dists. The workflow uploads dist/* + sbom.json for the gated + ship-package job. Requires id-token: write + attestations: write. + + Note: unlike turnkey py (which relies on PyPI's native PEP 740 provenance), the custom + path also emits our own build provenance — ship-package verifies it against the + downloaded dists before publishing. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before building. Set to false when the caller already checked out." + required: false + default: 'true' + working_directory: + description: "Path to the package root (where pyproject.toml lives)" + required: false + default: '.' + python_version: + description: > + Python version (e.g. 3.13), or a path to a version file + (.python-version, .tool-versions) relative to the repo root. + required: true + build_command: + description: "Override build command. When empty, `uv build` is used." + required: false + default: '' + sbom: + description: "Generate + attest a CycloneDX SBOM bound to the dists. Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + + - name: Resolve Python version source + id: py-src + shell: bash + env: + PYTHON_VERSION: ${{ inputs.python_version }} + run: | + # python_version is either an explicit version (e.g. 3.13) or a path to a + # version file (.python-version / .tool-versions). Route by file existence. + if [ -n "$PYTHON_VERSION" ] && [ -f "$PYTHON_VERSION" ]; then + case "$PYTHON_VERSION" in + *.tool-versions) SPEC=$(awk '/^python /{print $2; exit}' "$PYTHON_VERSION") ;; + *) SPEC=$(head -1 "$PYTHON_VERSION" | tr -d '[:space:]') ;; + esac + else + SPEC="$PYTHON_VERSION" + fi + echo "spec=$SPEC" >> "$GITHUB_OUTPUT" + + - name: Set up uv + uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7.2.0 + with: + # Installs this Python and exports UV_PYTHON for the job, so subsequent uv + # commands (build, read-version) use it. + python-version: ${{ steps.py-src.outputs.spec }} + # Don't fail the job when there's nothing to cache (jobs that install no deps). + ignore-nothing-to-cache: "true" + + - name: Build distribution + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + BUILD_COMMAND: ${{ inputs.build_command }} + run: | + rm -rf dist + if [ -n "$BUILD_COMMAND" ]; then + echo "Running custom build: $BUILD_COMMAND" + eval "$BUILD_COMMAND" + else + uv build + fi + uvx twine check dist/* + + - name: Generate SBOM (CycloneDX) + if: ${{ inputs.sbom == 'true' }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + # cyclonedx-py can't read uv lockfiles, so scan the wheel's installed closure: + # install the built wheel into a throwaway venv, then generate from that env. + SBOM_VENV="$(mktemp -d)/venv" + uv venv "$SBOM_VENV" + uv pip install --python "$SBOM_VENV/bin/python" dist/*.whl + uvx --from "cyclonedx-bom==7.3.0" cyclonedx-py environment "$SBOM_VENV" \ + --spec-version 1.7 --output-format JSON --output-file sbom.json + echo "Wrote $(pwd)/sbom.json" + + - name: Attest SBOM + if: ${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }} + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: |- + ${{ inputs.working_directory }}/dist/*.whl + ${{ inputs.working_directory }}/dist/*.tar.gz + sbom-path: ${{ inputs.working_directory }}/sbom.json + + - name: Attest build provenance + if: ${{ inputs.dry_run != 'true' }} + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: |- + ${{ inputs.working_directory }}/dist/*.whl + ${{ inputs.working_directory }}/dist/*.tar.gz + + - name: Dump Python environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== uv / Python ===" + uv --version || echo "(uv unavailable)" + uv run --no-project python --version || echo "(python unavailable)" + echo "=== UV_PYTHON ===" + echo "${UV_PYTHON:-(unset)}" + echo "=== Built artifacts ===" + ls -la dist 2>/dev/null || echo "(no dist/)" + echo "=== pyproject.toml (release-relevant) ===" + uv run --no-project python -c ' + import json, tomllib, pathlib + t = tomllib.loads(pathlib.Path("pyproject.toml").read_text()) + proj = t.get("project", {}) + print(json.dumps({ + "name": proj.get("name"), + "version": proj.get("version") or ("(dynamic)" if "version" in proj.get("dynamic", []) else None), + "requires-python": proj.get("requires-python"), + "dependencies": proj.get("dependencies"), + "optional-dependencies": sorted((proj.get("optional-dependencies") or {}).keys()), + "build-system": t.get("build-system"), + }, indent=2)) + ' 2>/dev/null || echo "(pyproject.toml unavailable)" diff --git a/actions/release/lang/py/ship-package/action.yml b/actions/release/lang/py/ship-package/action.yml new file mode 100644 index 0000000..c978293 --- /dev/null +++ b/actions/release/lang/py/ship-package/action.yml @@ -0,0 +1,453 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Ship Python Package (custom build) +description: > + Custom-build gated publish side: verifies the downloaded dists' attestations against + their own bytes, publishes them to PyPI (OIDC Trusted Publishing + PEP 740), creates a + GitHub release (attaching the SBOM), and announces. No build, no attest — it ships + prebuilt, attested dists. Requires id-token: write + contents: write. The workflow + downloads dist/* (+ sbom.json) into working_directory first. + +inputs: + working_directory: + description: "Directory holding the downloaded dist/ (whl + sdist) and sbom.json" + required: false + default: '.' + sha: + description: "Commit SHA being released (release target)" + required: true + pypi: + description: "Whether to publish to PyPI" + required: false + default: 'true' + attestations: + description: "Publish PEP 740 attestations (Sigstore-signed provenance). Requires id-token: write." + required: false + default: 'true' + dry_run: + description: "Skip verification, PyPI upload, tag push, and GitHub release creation" + required: false + default: 'false' + release_tag: + description: "The release tag (e.g. py-0.3.2)" + required: true + github_release: + description: "Whether to create a GitHub release" + required: false + default: 'true' + release_title: + description: "GitHub release name/title. Defaults to the release tag when empty." + required: false + default: '' + sbom: + description: "Attach the downloaded sbom.json to the GitHub release" + required: false + default: 'true' + version: + description: "The package version — used to build the PyPI link in notifications" + required: false + default: '' + package_name: + description: "PyPI package name — used to build the PyPI link in notifications" + required: false + default: '' + label: + description: "Display name for release notifications (e.g. the package name)." + required: false + default: '' + notes: + description: "Base64-encoded release notes from the prepare action" + required: false + default: '' + prev_release: + description: "Previous release tag — used in completion notification" + required: false + default: '' + branch: + description: "Branch the release was made from" + required: true + on_release_branch: + description: "Whether the SHA is on an expected release branch" + required: true + pr_list: + description: "x1f-delimited PR list from prepare action" + required: false + default: '' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if the publish fails" + required: false + default: '' + emoji: + description: "Emoji prefix for Slack messages" + required: false + default: ':python:' + +runs: + using: composite + steps: + # Set up a default uv/Python so `uv publish` + PEP 740 signing work; no build here. + - name: Set up uv + uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7.2.0 + with: + python-version: '3.13' + + - name: Verify attestations + if: ${{ inputs.dry_run != 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACTS: ${{ inputs.working_directory }}/dist/*.whl ${{ inputs.working_directory }}/dist/*.tar.gz + REPO: ${{ github.repository }} + run: | + shopt -s nullglob + FILES=( $ARTIFACTS ) + if [ ${#FILES[@]} -eq 0 ]; then + echo "::error title=Nothing to verify::no artifact files matched '$ARTIFACTS'." + exit 1 + fi + for f in "${FILES[@]}"; do + echo "Verifying $(basename "$f") — build provenance…" + gh attestation verify "$f" --repo "$REPO" || { + echo "::error title=Provenance verification failed::no build-provenance attestation matches $(basename "$f") — the artifact was not produced by a trusted build, or was tampered with in transit." + exit 1 + } + echo "Verifying $(basename "$f") — SBOM…" + gh attestation verify "$f" --repo "$REPO" --predicate-type https://cyclonedx.org/bom || { + echo "::error title=SBOM verification failed::no CycloneDX SBOM attestation matches $(basename "$f")." + exit 1 + } + done + echo "✓ attestations verified for: ${FILES[*]}" + + - name: Publish to PyPI + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + PYPI_ENABLED: ${{ inputs.pypi }} + DRY_RUN: ${{ inputs.dry_run }} + ATTESTATIONS: ${{ inputs.attestations }} + run: | + if [ "$PYPI_ENABLED" != "true" ]; then + echo "PyPI publishing disabled; skipping." + exit 0 + fi + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: artifacts built and twine-checked; not uploaded to PyPI." + exit 0 + fi + # --trusted-publishing always: require OIDC (fail loudly rather than fall back + # to token auth). + ARGS=(--trusted-publishing always) + if [ "$ATTESTATIONS" = "true" ]; then + # uv uploads attestations but does not generate them; sign first (Sigstore, + # ambient GitHub OIDC) so the *.publish.attestation files exist for upload. + uvx pypi-attestations sign dist/*.whl dist/*.tar.gz + else + ARGS+=(--no-attestations) + fi + uv publish "${ARGS[@]}" || { + echo "::error title=PyPI publish failed::auth/OIDC error → the PyPI trusted publisher must list this repo, workflow, and (if gated) environment; a 400 'File already exists' → the version is already on PyPI (bump it)." + exit 1 + } + + - name: Create GitHub release + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + ENABLED: ${{ inputs.github_release }} + DRY_RUN: ${{ inputs.dry_run }} + TAG: ${{ inputs.release_tag }} + RELEASE_TITLE: ${{ inputs.release_title }} + NOTES_B64: ${{ inputs.notes }} + SHA: ${{ inputs.sha }} + SBOM_ENABLED: ${{ inputs.sbom }} + WORKING_DIRECTORY: ${{ inputs.working_directory }} + run: | + if [ "$ENABLED" != "true" ]; then + echo "GitHub release disabled; skipping." + exit 0 + fi + # Release name defaults to the tag when no explicit title is provided. + TITLE="${RELEASE_TITLE:-$TAG}" + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would create GitHub release $TAG (title: $TITLE)" + echo "--- Release notes preview ---" + 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. + if [ "$SBOM_ENABLED" = "true" ] && [ -f "$WORKING_DIRECTORY/sbom.json" ]; then + gh release upload "$TAG" "$WORKING_DIRECTORY/sbom.json" --clobber + fi + fi + + - name: Build PyPI links + id: links + shell: bash + env: + PACKAGE_NAME: ${{ inputs.package_name }} + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + LINKS='[]' + if [ "$DRY_RUN" != "true" ] && [ -n "$PACKAGE_NAME" ] && [ -n "$VERSION" ]; then + LINKS="[{\"label\":\"PyPI\",\"url\":\"https://pypi.org/project/$PACKAGE_NAME/$VERSION/\"}]" + fi + echo "links=$LINKS" >> "$GITHUB_OUTPUT" + + - name: Post release summary + shell: bash + env: + TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + PREV_RELEASE: ${{ inputs.prev_release }} + BRANCH: ${{ inputs.branch }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + DRY_RUN: ${{ inputs.dry_run }} + LINKS_JSON: ${{ steps.links.outputs.links }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" + + # Subject is the package label when set (disambiguates repos that publish + # multiple packages); otherwise the repository. The repo is still present in + # every link below, so it is never lost. + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + if [ "$DRY_RUN" = "true" ]; then + echo "## $SUBJECT $TAG — dry run complete" >> $GITHUB_STEP_SUMMARY + else + echo "## $SUBJECT $TAG — published ✅" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "$DRY_RUN" = "true" ]; then + echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY + echo "> Dry run: Nothing was tagged or published." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY + echo "> Released from non-release branch: [$BRANCH]($BRANCH_URL)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + echo "**Branch:** [$BRANCH]($BRANCH_URL)" >> $GITHUB_STEP_SUMMARY + + if [ -n "$PREV_RELEASE" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...$TAG" + echo "**Diff:** [$PREV_RELEASE...$TAG]($DIFF_URL)" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Published to" >> $GITHUB_STEP_SUMMARY + + if [ "$DRY_RUN" = "true" ]; then + echo "- [View dry run]($RUN_URL)" >> $GITHUB_STEP_SUMMARY + else + echo "- [GitHub Release]($RELEASE_URL)" >> $GITHUB_STEP_SUMMARY + if [ -n "$LINKS_JSON" ] && [ "$LINKS_JSON" != "[]" ]; then + echo "$LINKS_JSON" | jq -r '.[] | "- [\(.label)](\(.url))"' >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Build published message + id: message-published + shell: bash + env: + TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + EMOJI: ${{ inputs.emoji }} + BRANCH: ${{ inputs.branch }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + PREV_RELEASE: ${{ inputs.prev_release }} + DRY_RUN: ${{ inputs.dry_run }} + SLACK_MENTION: ${{ inputs.slack_mention }} + LINKS_JSON: ${{ steps.links.outputs.links }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + PR_LIST_RAW: ${{ inputs.pr_list }} + run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + + # Subject is the package label when set, else the repository (see summary step). + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + DIFF_PART="" + if [ -n "$PREV_RELEASE" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...$TAG" + DIFF_PART="<$DIFF_URL|${PREV_RELEASE}...$TAG> · " + fi + + PR_LIST=$(echo "$PR_LIST_RAW" | tr $'\x1f' '\n' | sed '/^$/d') + + if [ "$DRY_RUN" = "true" ]; then + TEXT="$EMOJI *$SUBJECT $TAG* complete" + TEXT="$TEXT\n> :information_source: _Dry run: nothing tagged or published._" + else + TEXT="$EMOJI *$SUBJECT $TAG* published" + 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>" + fi + + if [ "$DRY_RUN" = "true" ]; then + TEXT="$TEXT\n${DIFF_PART}<$RUN_URL|View dry run>" + else + SLACK_LINKS="${DIFF_PART}<$RELEASE_URL|View release>" + if [ -n "$LINKS_JSON" ] && [ "$LINKS_JSON" != "[]" ]; then + EXTRA=$(echo "$LINKS_JSON" | jq -r '[.[] | "<\(.url)|\(.label)>"] | join(" · ")') + SLACK_LINKS="$SLACK_LINKS · $EXTRA" + fi + TEXT="$TEXT\n$SLACK_LINKS" + fi + + if [ -n "$PR_LIST" ]; then + TEXT="$TEXT\n$PR_LIST" + fi + + if [ -n "$SLACK_MENTION" ]; then + TEXT="$TEXT\n$SLACK_MENTION" + fi + + { echo 'text<> $GITHUB_OUTPUT + + - name: Send Slack message + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-published.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi + + - name: Dump Python environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== uv / Python ===" + uv --version || echo "(uv unavailable)" + uv run --no-project python --version || echo "(python unavailable)" + echo "=== UV_PYTHON ===" + echo "${UV_PYTHON:-(unset)}" + echo "=== Built artifacts ===" + ls -la dist 2>/dev/null || echo "(no dist/)" + echo "=== pyproject.toml (release-relevant) ===" + uv run --no-project python -c ' + import json, tomllib, pathlib + t = tomllib.loads(pathlib.Path("pyproject.toml").read_text()) + proj = t.get("project", {}) + print(json.dumps({ + "name": proj.get("name"), + "version": proj.get("version") or ("(dynamic)" if "version" in proj.get("dynamic", []) else None), + "requires-python": proj.get("requires-python"), + "dependencies": proj.get("dependencies"), + "optional-dependencies": sorted((proj.get("optional-dependencies") or {}).keys()), + "build-system": t.get("build-system"), + }, indent=2)) + ' 2>/dev/null || echo "(pyproject.toml unavailable)" + + - name: Build failure message + if: ${{ failure() }} + id: message-failure + shell: bash + env: + RELEASE_TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + STEP: publish + SLACK_MENTION: ${{ inputs.failure_slack_mention }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ACTOR_LINK: <${{ github.server_url }}/${{ github.actor }}|${{ github.actor }}> + run: | + # Subject is the package label when set, else the repository. + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + TEXT=":x: *$SUBJECT $RELEASE_TAG* failed at $STEP" + TEXT="$TEXT\nInitiated by: $ACTOR_LINK" + + if [ -n "$SLACK_MENTION" ]; then + TEXT="$TEXT $SLACK_MENTION" + fi + + TEXT="$TEXT\n<$RUN_URL|View failed run>" + + { echo 'text<> $GITHUB_OUTPUT + + - name: Send Slack message + if: ${{ failure() }} + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-failure.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi diff --git a/actions/release/lang/py/validate/action.yml b/actions/release/lang/py/validate/action.yml index a2a5887..ce11327 100644 --- a/actions/release/lang/py/validate/action.yml +++ b/actions/release/lang/py/validate/action.yml @@ -1,31 +1,27 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} name: Validate Python Release description: > - Checks out the release SHA, sets up uv and Python, reads the package - version, validates the release metadata (tag existence, branch, commit - metadata) and that the version is not already on PyPI, then builds and generates - a CycloneDX SBOM — confirming the package is publishable (and the SBOM tooling - works) before the approval gate. + Judges a Python release against the facts `configure` derived — the toggleable checks + (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. inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Skip tag existence failure in dry runs" + description: "Warn instead of failing on an already-existing tag" required: false default: 'false' version: - description: > - Explicit version override. If provided, the version is not read from - version_file. Use when the version is computed externally (e.g. bt-publishing-test - auto-bump, or an ephemeral prerelease). + 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 (e.g. - py/pkg/version.py). Read when version is not overridden. + description: "Path (repo-root-relative) to a Python module exposing VERSION." required: false default: '' working_directory: @@ -39,45 +35,54 @@ inputs: required: true package_name: description: > - PyPI package name (matches the publish action's input). When set, validate - fails fast if this version is already published (covers prereleases, which - aren't git-tagged). Leave empty to skip. + 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: '' - tag_format: - description: "Git tag format, with a {version} placeholder (e.g. py-{version})" + release_tag: + description: "The release tag (from configure) — checked for prior existence" + required: true + on_release_branch: + description: "Whether the SHA is on an allowed release branch (from configure)" required: false - default: 'py-{version}' + default: 'true' + notes: + description: "Base64-encoded release notes (from prepare) — checked non-blank" + required: false + default: '' build_command: description: "Override build command. When empty, `uv build` is used." required: false default: '' + build: + description: > + Build the distribution as a pre-gate check. Set to 'false' in the custom-build + shape, where the pack job owns the build. + required: false + default: 'true' sbom: description: > - Generate a CycloneDX SBOM as a pre-gate check (same generator as publish), so - SBOM-tooling failures surface BEFORE the approval gate. Not signed or attached - here — that happens in publish. + Generate a CycloneDX SBOM as a pre-gate check (same generator as build-and-ship), + so SBOM-tooling failures surface BEFORE the approval gate. Not signed or attached + here — that happens in build-and-ship. required: false default: 'true' -outputs: - release_tag: - description: "The release tag (e.g. py-0.3.2)" - value: ${{ steps.validate.outputs.release_tag }} - prev_release: - description: "The previous release tag" - value: ${{ steps.validate.outputs.prev_release }} - branch: - description: "The branch containing the SHA" - value: ${{ steps.validate.outputs.branch }} - on_release_branch: - description: "Whether the SHA is on an expected release branch" - value: ${{ steps.validate.outputs.on_release_branch }} - commit_message: - description: "The commit message at the SHA" - value: ${{ steps.validate.outputs.commit_message }} - version: - description: "The resolved package version" - value: ${{ steps.read-version.outputs.version }} + check_tag_unused: + description: "Fail if the release tag already exists" + required: false + default: 'true' + check_version_unpublished: + description: "Fail if the version is already published to PyPI" + required: false + default: 'true' + check_notes_not_blank: + description: "Warn if release notes are blank" + required: false + default: 'true' + enforce_release_branch: + description: "Hard-fail (instead of warn) when the SHA is off the release branch" + required: false + default: 'false' runs: using: composite @@ -111,6 +116,8 @@ runs: # Installs this Python and exports UV_PYTHON for the job, so subsequent uv # commands (build, read-version) use it. python-version: ${{ steps.py-src.outputs.spec }} + # 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 @@ -133,6 +140,7 @@ runs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Check PyPI version availability + if: ${{ inputs.check_version_unpublished == 'true' }} shell: bash env: PACKAGE_NAME: ${{ inputs.package_name }} @@ -159,31 +167,19 @@ runs: ;; esac - - name: Validate SHA format - shell: bash - env: - SHA: ${{ inputs.sha }} - run: | - if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then - echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." - exit 1 - fi - - - name: Validate release - id: validate + - name: Check release shell: bash env: - VERSION: ${{ steps.read-version.outputs.version }} - TAG_FORMAT: ${{ inputs.tag_format }} + TAG: ${{ inputs.release_tag }} DRY_RUN: ${{ inputs.dry_run }} - RELEASE_BRANCH: 'main' - SHA: ${{ inputs.sha }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + NOTES_B64: ${{ inputs.notes }} + CHECK_TAG_UNUSED: ${{ inputs.check_tag_unused }} + CHECK_NOTES: ${{ inputs.check_notes_not_blank }} + ENFORCE_RELEASE_BRANCH: ${{ inputs.enforce_release_branch }} run: | - TAG="${TAG_FORMAT//\{version\}/$VERSION}" - COMMIT_MSG=$(git log -1 --format="%s" HEAD) - BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) - - if git rev-parse "$TAG" >/dev/null 2>&1; then + # 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 echo "::warning title=Release tag already exists::Tag $TAG already exists — skipping in dry run." else @@ -192,27 +188,26 @@ runs: fi fi - ON_RELEASE_BRANCH=false - IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" - for pattern in "${PATTERNS[@]}"; do - pattern=$(echo "$pattern" | xargs) - if [[ "$BRANCH" == $pattern ]]; then - ON_RELEASE_BRANCH=true - break + # On a release branch: warn by default, hard-fail only when enforced. + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + if [ "$ENFORCE_RELEASE_BRANCH" = "true" ]; then + echo "::error title=Off release branch::the release SHA is not on an allowed release branch (enforce_release_branch=true)." + exit 1 + else + echo "::warning title=Off release branch::the release SHA is not on an allowed release branch — releasing anyway." fi - done - - PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" - PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") + fi - echo "release_tag=$TAG" >> $GITHUB_OUTPUT - echo "commit_message=$COMMIT_MSG" >> $GITHUB_OUTPUT - echo "branch=$BRANCH" >> $GITHUB_OUTPUT - echo "on_release_branch=$ON_RELEASE_BRANCH" >> $GITHUB_OUTPUT - echo "prev_release=$PREV_RELEASE" >> $GITHUB_OUTPUT - echo "Validated $TAG @ $SHA ($COMMIT_MSG)" + # Release notes present (warn only — a genuine first release, or no PRs since the last tag, is legitimate). + if [ "$CHECK_NOTES" = "true" ]; then + NOTES=$(echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "") + if [ -z "$NOTES" ]; then + echo "::warning title=Empty release notes::no release notes were generated (first release, or no PRs since the last tag?)." + fi + fi - name: Build distribution + if: ${{ inputs.build == 'true' }} shell: bash working-directory: ${{ inputs.working_directory }} env: diff --git a/actions/release/lang/ruby/publish/action.yml b/actions/release/lang/ruby/build-and-ship/action.yml similarity index 95% rename from actions/release/lang/ruby/publish/action.yml rename to actions/release/lang/ruby/build-and-ship/action.yml index 320c099..9a072fa 100644 --- a/actions/release/lang/ruby/publish/action.yml +++ b/actions/release/lang/ruby/build-and-ship/action.yml @@ -1,11 +1,14 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. -name: Publish Ruby Package +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Build and Ship Ruby Package description: > - Full Ruby gem release: checks out the SHA, builds the gem, generates and signs a - CycloneDX SBOM plus SLSA build provenance (bound to the exact gem), publishes to - RubyGems via OIDC trusted publishing (pushing that same gem — no rebuild), creates - a GitHub release (with the SBOM attached), and sends a Slack completion - notification. Covers the standard publish sequence for all Braintrust Ruby gems. + Turnkey Ruby release — the whole pipeline in one gated job: checks out the SHA, + builds the gem, generates and signs a CycloneDX SBOM plus SLSA build provenance + (bound to the exact gem), publishes to RubyGems via OIDC trusted publishing (pushing + that same gem — no rebuild), creates a GitHub release (with the SBOM attached), and + announces on Slack. This is the `build → attest → publish → release → announce` + pipeline; Ruby's Path C (build once, attest, push that exact gem) is already the + custom-build shape un-split. inputs: sha: @@ -196,7 +199,8 @@ runs: if: ${{ inputs.dry_run != 'true' }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: - subject-path: ${{ inputs.working_directory }}/pkg/*.gem + subject-path: |- + ${{ inputs.working_directory }}/pkg/*.gem - name: Configure RubyGems credentials (OIDC trusted publishing) if: ${{ inputs.dry_run != 'true' }} @@ -205,9 +209,14 @@ runs: shell: bash working-directory: ${{ inputs.working_directory }} env: + GEM_GLOB: "pkg/*.gem" DRY_RUN: ${{ inputs.dry_run }} run: | - GEM=$(ls pkg/*.gem) + GEM=$(ls $GEM_GLOB 2>/dev/null | head -1) + if [ -z "$GEM" ]; then + echo "::error title=Gem not found::no gem matched '$GEM_GLOB' in $(pwd)." + exit 1 + fi if [ "$DRY_RUN" = "true" ]; then echo "DRY RUN: would gem push $GEM" exit 0 diff --git a/actions/release/lang/ruby/configure/action.yml b/actions/release/lang/ruby/configure/action.yml new file mode 100644 index 0000000..741e436 --- /dev/null +++ b/actions/release/lang/ruby/configure/action.yml @@ -0,0 +1,181 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# 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. + +inputs: + sha: + description: "Commit SHA being released" + required: true + version: + description: > + Explicit version override. If provided, version_file/version_module are ignored. + Use when the version is computed externally (e.g. a run-number auto-bump). + 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 + default: '.' + tag_format: + description: "Git tag format, with a {version} placeholder (e.g. v{version})" + required: false + default: 'v{version}' + release_type: + description: > + 'stable' | 'prerelease'. Maps to the github_release default (prerelease → no + GitHub release). An explicit github_release input overrides this. + required: false + default: 'stable' + github_release: + description: "Explicit github_release override ('true'/'false'). Empty → derived from release_type." + required: false + default: '' + release_branch: + description: "Comma-separated allowed release branch patterns (globs)." + required: false + default: 'main' +outputs: + version: + description: "The resolved gem version" + value: ${{ steps.read-version.outputs.version }} + release_tag: + description: "The release tag (e.g. v0.3.2)" + value: ${{ steps.configure.outputs.release_tag }} + prev_release: + description: "The previous release tag" + value: ${{ steps.configure.outputs.prev_release }} + branch: + description: "The branch containing the SHA" + value: ${{ steps.configure.outputs.branch }} + on_release_branch: + description: "Whether the SHA is on an allowed release branch" + value: ${{ steps.configure.outputs.on_release_branch }} + commit_message: + description: "The commit message at the SHA" + value: ${{ steps.configure.outputs.commit_message }} + github_release: + description: "Whether a GitHub release should be created" + value: ${{ steps.configure.outputs.github_release }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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. + - name: Set up Ruby + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 + with: + ruby-version: '3.3' + + - 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: Validate SHA format + shell: bash + env: + SHA: ${{ inputs.sha }} + run: | + if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." + exit 1 + fi + + - name: Configure release + id: configure + shell: bash + env: + VERSION: ${{ steps.read-version.outputs.version }} + TAG_FORMAT: ${{ inputs.tag_format }} + RELEASE_BRANCH: ${{ inputs.release_branch }} + 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: "" + GHR_OVERRIDE: "${{ inputs.github_release }}" + run: | + TAG="${TAG_FORMAT//\{version\}/$VERSION}" + COMMIT_MSG=$(git log -1 --format="%s" HEAD) + BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) + + ON_RELEASE_BRANCH=false + IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" + for pattern in "${PATTERNS[@]}"; do + pattern=$(echo "$pattern" | xargs) + if [[ "$BRANCH" == $pattern ]]; then ON_RELEASE_BRANCH=true; break; fi + done + + PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" + PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") + + # channel: explicit override wins; else map from release_type (prerelease→rc, stable→latest) + if [ -n "$CHANNEL_OVERRIDE" ]; then CHANNEL="$CHANNEL_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then CHANNEL="rc" + else CHANNEL="latest"; fi + + # github_release: explicit override wins; else prerelease→false, stable→true + if [ -n "$GHR_OVERRIDE" ]; then GHR="$GHR_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then GHR="false" + else GHR="true"; fi + + { + echo "release_tag=$TAG" + echo "commit_message=$COMMIT_MSG" + echo "branch=$BRANCH" + echo "on_release_branch=$ON_RELEASE_BRANCH" + echo "prev_release=$PREV_RELEASE" + echo "channel=$CHANNEL" + echo "github_release=$GHR" + } >> "$GITHUB_OUTPUT" + echo "Configured $TAG @ $VERSION (channel=$CHANNEL, github_release=$GHR, branch=$BRANCH, on_release_branch=$ON_RELEASE_BRANCH)" + + - name: Dump Ruby environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== Ruby environment ===" + ruby --version + bundle --version + echo "=== Default gems (version conflicts may cause activation errors) ===" + ruby -e "Gem::Specification.default_stubs.map { |s| \"#{s.name} #{s.version}\" }.sort.each { |g| puts g }" + echo "=== Gemfile.lock BUNDLED WITH ===" + grep -A1 "^BUNDLED WITH" Gemfile.lock 2>/dev/null || echo "(not found)" diff --git a/actions/release/lang/ruby/pack/action.yml b/actions/release/lang/ruby/pack/action.yml new file mode 100644 index 0000000..9e5fd60 --- /dev/null +++ b/actions/release/lang/ruby/pack/action.yml @@ -0,0 +1,150 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Pack Ruby Package (custom build) +description: > + Custom-build side (unprivileged — no publish credentials): checks out the SHA, builds + the gem, generates a CycloneDX SBOM, and attests the SBOM + SLSA build provenance to + that exact gem. The workflow uploads pkg/*.gem + sbom.json for the gated ship-package + job. Requires id-token: write + attestations: write. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before building. Set to false when the caller already checked out." + required: false + default: 'true' + working_directory: + description: "Path to the gem root (where Gemfile and gemspec live)" + required: false + default: '.' + ruby_version: + description: "Ruby version to build with, or a version file path (.tool-versions, .ruby-version)" + required: false + default: '4.0' + sbom: + description: "Generate + attest a CycloneDX SBOM bound to the gem. Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + + - name: Set up Ruby + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 + with: + ruby-version: ${{ inputs.ruby_version }} + bundler-cache: true + working-directory: ${{ inputs.working_directory }} + + - name: Build gem + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + bundle exec rake build + if [ "$(ls pkg/*.gem 2>/dev/null | wc -l)" -ne 1 ]; then + echo "::error title=Build failed::expected exactly one gem at pkg/*.gem after 'rake build' — found: $(ls pkg/*.gem 2>/dev/null || echo none)" + exit 1 + fi + echo "Built $(ls pkg/*.gem)" + + - name: Generate SBOM (CycloneDX) + if: ${{ inputs.sbom == 'true' }} + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + GEM_NAME: ${{ inputs.gem_name }} + run: | + if [ ! -f Gemfile.lock ]; then + echo "::error title=SBOM failed::Gemfile.lock not found in $(pwd) — bundle install must run before SBOM generation." + exit 1 + fi + # Map Gemfile.lock → CycloneDX 1.6 JSON with Bundler's own lockfile parser. + # Only the gem's RUNTIME closure is included: we walk the dependency graph + # from the gem itself, so the dev/test toolchain (rubocop, rake, …) that lives + # in Gemfile.lock but is not a runtime dependency of the published gem is + # excluded. The gem is the SBOM subject (metadata.component), its runtime + # dependency closure are the components. + ruby <<'RUBY' + require 'bundler' + require 'json' + require 'securerandom' + lock = Bundler::LockfileParser.new(File.read('Gemfile.lock')) + by_name = lock.specs.each_with_object({}) { |s, h| h[s.name] = s } + # The gem being released: prefer the explicit gem_name, else the local + # path/gemspec source spec (the gem defined in this repo). + root_name = ENV['GEM_NAME'].to_s.empty? ? nil : ENV['GEM_NAME'] + root = (by_name[root_name] if root_name) || + lock.specs.find { |s| s.source.is_a?(Bundler::Source::Path) } + abort("::error title=SBOM failed::could not identify the gem in Gemfile.lock (set gem_name).") unless root + # Breadth-first over each spec's runtime dependencies (as recorded per-gem in + # the GEM section of the lockfile), starting from the released gem. + seen = {} + queue = root.dependencies.map(&:name) + until queue.empty? + name = queue.shift + next if seen[name] || !by_name[name] + spec = by_name[name] + seen[name] = spec + queue.concat(spec.dependencies.map(&:name)) + end + component = ->(s) do + { 'type' => 'library', 'name' => s.name, 'version' => s.version.to_s, + 'purl' => "pkg:gem/#{s.name}@#{s.version}" } + end + bom = { + 'bomFormat' => 'CycloneDX', + 'specVersion' => '1.7', + # Required: actions/attest recognizes CycloneDX only when bomFormat + specVersion + # + serialNumber are all present (see actions/attest src/sbom.ts). Don't remove. + 'serialNumber' => "urn:uuid:#{SecureRandom.uuid}", + 'version' => 1, + 'metadata' => { + # CycloneDX ≥1.5 tools shape (the array-of-tools form was deprecated in 1.5). + 'tools' => { 'components' => [{ 'type' => 'application', 'group' => 'braintrust', 'name' => 'sdk-actions ruby sbom' }] }, + 'component' => component.call(root).merge('type' => 'application'), + }, + 'components' => seen.values.sort_by(&:name).map { |s| component.call(s) }, + } + File.write('sbom.json', JSON.pretty_generate(bom)) + puts "Wrote #{Dir.pwd}/sbom.json (subject: #{root.name}, #{seen.size} runtime components)" + RUBY + + - name: Attest SBOM + if: ${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }} + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: |- + ${{ inputs.working_directory }}/pkg/*.gem + sbom-path: ${{ inputs.working_directory }}/sbom.json + + - name: Attest build provenance + if: ${{ inputs.dry_run != 'true' }} + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: |- + ${{ inputs.working_directory }}/pkg/*.gem + + - name: Dump Ruby environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== Ruby environment ===" + ruby --version + bundle --version + echo "=== Default gems (version conflicts may cause activation errors) ===" + ruby -e "Gem::Specification.default_stubs.map { |s| \"#{s.name} #{s.version}\" }.sort.each { |g| puts g }" + echo "=== Gemfile.lock BUNDLED WITH ===" + grep -A1 "^BUNDLED WITH" Gemfile.lock 2>/dev/null || echo "(not found)" diff --git a/actions/release/lang/ruby/ship-package/action.yml b/actions/release/lang/ruby/ship-package/action.yml new file mode 100644 index 0000000..2feab13 --- /dev/null +++ b/actions/release/lang/ruby/ship-package/action.yml @@ -0,0 +1,422 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Ship Ruby Package (custom build) +description: > + Custom-build gated publish side: verifies the downloaded gem's attestations against + its own bytes, pushes that exact gem to RubyGems (OIDC trusted publishing), creates a + GitHub release (attaching the SBOM), and announces. No build, no attest — it ships a + prebuilt, attested gem. Requires id-token: write + contents: write. The workflow + downloads the gem + sbom.json into working_directory first. + +inputs: + working_directory: + description: "Directory holding the downloaded gem + sbom.json" + required: false + default: '.' + sha: + description: "Commit SHA being released (release target)" + required: true + dry_run: + description: "Skip verification, gem push, tag push, and GitHub release creation" + required: false + default: 'false' + release_tag: + description: "The release tag (e.g. v0.3.2)" + required: true + github_release: + description: "Whether to create a GitHub release" + required: false + default: 'true' + release_title: + description: "GitHub release name/title. Defaults to the release tag when empty." + required: false + default: '' + sbom: + description: "Attach the downloaded sbom.json to the GitHub release" + required: false + default: 'true' + gem_name: + description: "RubyGems gem name — used to build the RubyGems link in notifications" + required: false + default: '' + label: + description: "Display name for release notifications (e.g. the gem name)." + required: false + default: '' + notes: + description: "Base64-encoded release notes from the prepare action" + required: false + default: '' + prev_release: + description: "Previous release tag — used in completion notification" + required: false + default: '' + branch: + description: "Branch the release was made from" + required: true + on_release_branch: + description: "Whether the SHA is on an expected release branch" + required: true + pr_list: + description: "x1f-delimited PR list from prepare action" + required: false + default: '' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if the publish fails" + required: false + default: '' + emoji: + description: "Emoji prefix for Slack messages" + required: false + default: ':ruby:' + +runs: + using: composite + steps: + # Set up a default Ruby so `gem push` has a modern rubygems (OIDC trusted publishing); + # no bundle install — this job doesn't build. + - name: Set up Ruby + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 + with: + ruby-version: '3.3' + + - name: Verify attestations + if: ${{ inputs.dry_run != 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACTS: ${{ inputs.working_directory }}/*.gem + REPO: ${{ github.repository }} + run: | + shopt -s nullglob + FILES=( $ARTIFACTS ) + if [ ${#FILES[@]} -eq 0 ]; then + echo "::error title=Nothing to verify::no artifact files matched '$ARTIFACTS'." + exit 1 + fi + for f in "${FILES[@]}"; do + echo "Verifying $(basename "$f") — build provenance…" + gh attestation verify "$f" --repo "$REPO" || { + echo "::error title=Provenance verification failed::no build-provenance attestation matches $(basename "$f") — the artifact was not produced by a trusted build, or was tampered with in transit." + exit 1 + } + echo "Verifying $(basename "$f") — SBOM…" + gh attestation verify "$f" --repo "$REPO" --predicate-type https://cyclonedx.org/bom || { + echo "::error title=SBOM verification failed::no CycloneDX SBOM attestation matches $(basename "$f")." + exit 1 + } + done + echo "✓ attestations verified for: ${FILES[*]}" + + - 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 + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + GEM_GLOB: "*.gem" + DRY_RUN: ${{ inputs.dry_run }} + run: | + GEM=$(ls $GEM_GLOB 2>/dev/null | head -1) + if [ -z "$GEM" ]; then + echo "::error title=Gem not found::no gem matched '$GEM_GLOB' in $(pwd)." + exit 1 + fi + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would gem push $GEM" + exit 0 + fi + gem push "$GEM" || { + echo "::error title=gem push failed::OIDC/trusted-publisher error → RubyGems must list a trusted publisher for this gem with this repo + workflow (and environment if gated); 'Repushing of gem versions is not allowed' → the version already exists (bump it)." + exit 1 + } + + - name: Create GitHub release + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + ENABLED: ${{ inputs.github_release }} + DRY_RUN: ${{ inputs.dry_run }} + TAG: ${{ inputs.release_tag }} + RELEASE_TITLE: ${{ inputs.release_title }} + NOTES_B64: ${{ inputs.notes }} + SHA: ${{ inputs.sha }} + SBOM_ENABLED: ${{ inputs.sbom }} + WORKING_DIRECTORY: ${{ inputs.working_directory }} + run: | + if [ "$ENABLED" != "true" ]; then + echo "GitHub release disabled; skipping." + exit 0 + fi + # Release name defaults to the tag when no explicit title is provided. + TITLE="${RELEASE_TITLE:-$TAG}" + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would create GitHub release $TAG (title: $TITLE)" + echo "--- Release notes preview ---" + 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. + if [ "$SBOM_ENABLED" = "true" ] && [ -f "$WORKING_DIRECTORY/sbom.json" ]; then + gh release upload "$TAG" "$WORKING_DIRECTORY/sbom.json" --clobber + fi + fi + + - name: Build RubyGems links + id: links + shell: bash + env: + GEM_NAME: ${{ inputs.gem_name }} + RELEASE_TAG: ${{ inputs.release_tag }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + LINKS='[]' + if [ "$DRY_RUN" != "true" ] && [ -n "$GEM_NAME" ]; then + VERSION="${RELEASE_TAG#v}" + LINKS="[{\"label\":\"RubyGems\",\"url\":\"https://rubygems.org/gems/$GEM_NAME/versions/$VERSION\"}]" + fi + echo "links=$LINKS" >> $GITHUB_OUTPUT + + - name: Post release summary + shell: bash + env: + TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + PREV_RELEASE: ${{ inputs.prev_release }} + BRANCH: ${{ inputs.branch }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + DRY_RUN: ${{ inputs.dry_run }} + LINKS_JSON: ${{ steps.links.outputs.links }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" + + # Subject is the package label when set (disambiguates repos that publish + # multiple packages); otherwise the repository. The repo is still present in + # every link below, so it is never lost. + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + if [ "$DRY_RUN" = "true" ]; then + echo "## $SUBJECT $TAG — dry run complete" >> $GITHUB_STEP_SUMMARY + else + echo "## $SUBJECT $TAG — published ✅" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "$DRY_RUN" = "true" ]; then + echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY + echo "> Dry run: Nothing was tagged or published." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY + echo "> Released from non-release branch: [$BRANCH]($BRANCH_URL)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + echo "**Branch:** [$BRANCH]($BRANCH_URL)" >> $GITHUB_STEP_SUMMARY + + if [ -n "$PREV_RELEASE" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...$TAG" + echo "**Diff:** [$PREV_RELEASE...$TAG]($DIFF_URL)" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Published to" >> $GITHUB_STEP_SUMMARY + + if [ "$DRY_RUN" = "true" ]; then + echo "- [View dry run]($RUN_URL)" >> $GITHUB_STEP_SUMMARY + else + echo "- [GitHub Release]($RELEASE_URL)" >> $GITHUB_STEP_SUMMARY + if [ -n "$LINKS_JSON" ] && [ "$LINKS_JSON" != "[]" ]; then + echo "$LINKS_JSON" | jq -r '.[] | "- [\(.label)](\(.url))"' >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Build published message + id: message-published + shell: bash + env: + TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + EMOJI: ${{ inputs.emoji }} + BRANCH: ${{ inputs.branch }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + PREV_RELEASE: ${{ inputs.prev_release }} + DRY_RUN: ${{ inputs.dry_run }} + SLACK_MENTION: ${{ inputs.slack_mention }} + LINKS_JSON: ${{ steps.links.outputs.links }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + PR_LIST_RAW: ${{ inputs.pr_list }} + run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + + # Subject is the package label when set, else the repository (see summary step). + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + DIFF_PART="" + if [ -n "$PREV_RELEASE" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...$TAG" + DIFF_PART="<$DIFF_URL|${PREV_RELEASE}...$TAG> · " + fi + + PR_LIST=$(echo "$PR_LIST_RAW" | tr $'\x1f' '\n' | sed '/^$/d') + + if [ "$DRY_RUN" = "true" ]; then + TEXT="$EMOJI *$SUBJECT $TAG* complete" + TEXT="$TEXT\n> :information_source: _Dry run: nothing tagged or published._" + else + TEXT="$EMOJI *$SUBJECT $TAG* published" + 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>" + fi + + if [ "$DRY_RUN" = "true" ]; then + TEXT="$TEXT\n${DIFF_PART}<$RUN_URL|View dry run>" + else + SLACK_LINKS="${DIFF_PART}<$RELEASE_URL|View release>" + if [ -n "$LINKS_JSON" ] && [ "$LINKS_JSON" != "[]" ]; then + EXTRA=$(echo "$LINKS_JSON" | jq -r '[.[] | "<\(.url)|\(.label)>"] | join(" · ")') + SLACK_LINKS="$SLACK_LINKS · $EXTRA" + fi + TEXT="$TEXT\n$SLACK_LINKS" + fi + + if [ -n "$PR_LIST" ]; then + TEXT="$TEXT\n$PR_LIST" + fi + + if [ -n "$SLACK_MENTION" ]; then + TEXT="$TEXT\n$SLACK_MENTION" + fi + + { echo 'text<> $GITHUB_OUTPUT + + - name: Send Slack message + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-published.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi + + - name: Dump Ruby environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== Ruby environment ===" + ruby --version + bundle --version + echo "=== Default gems (version conflicts may cause activation errors) ===" + ruby -e "Gem::Specification.default_stubs.map { |s| \"#{s.name} #{s.version}\" }.sort.each { |g| puts g }" + echo "=== Gemfile.lock BUNDLED WITH ===" + grep -A1 "^BUNDLED WITH" Gemfile.lock 2>/dev/null || echo "(not found)" + + - name: Build failure message + if: ${{ failure() }} + id: message-failure + shell: bash + env: + RELEASE_TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + STEP: publish + SLACK_MENTION: ${{ inputs.failure_slack_mention }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ACTOR_LINK: <${{ github.server_url }}/${{ github.actor }}|${{ github.actor }}> + run: | + # Subject is the package label when set, else the repository. + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + TEXT=":x: *$SUBJECT $RELEASE_TAG* failed at $STEP" + TEXT="$TEXT\nInitiated by: $ACTOR_LINK" + + if [ -n "$SLACK_MENTION" ]; then + TEXT="$TEXT $SLACK_MENTION" + fi + + TEXT="$TEXT\n<$RUN_URL|View failed run>" + + { echo 'text<> $GITHUB_OUTPUT + + - name: Send Slack message + if: ${{ failure() }} + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-failure.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi diff --git a/actions/release/lang/ruby/validate/action.yml b/actions/release/lang/ruby/validate/action.yml index a89fb51..4d6f92a 100644 --- a/actions/release/lang/ruby/validate/action.yml +++ b/actions/release/lang/ruby/validate/action.yml @@ -1,24 +1,23 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} name: Validate Ruby Release description: > - Checks out the release SHA, sets up Ruby, reads the gem version, validates - the release (RubyGems availability, tag existence, branch, commit metadata), - and runs lint, build, and SBOM generation to confirm the gem is publishable - (and the SBOM tooling works) before the approval gate. + Judges a Ruby release against the facts `configure` derived — the toggleable checks + (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. inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Skip tag existence failure in dry runs" + description: "Warn instead of failing on an already-existing tag" required: false default: 'false' version: - description: > - Explicit version override. If provided, version_file and version_module - are ignored. Use when the version is computed externally (e.g. bt-publishing-test - auto-bump from run number). + description: "Version override (from configure). If empty, read from version_file/version_module." required: false default: '' version_file: @@ -33,40 +32,57 @@ inputs: description: "Path to the gem root (where Gemfile and gemspec live)" required: false default: '.' + ruby_version: + 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. When set, validate fails fast if this version is already - published — the registry, not the git tag, is the source of truth (covers - prereleases and force-deleted tags). Leave empty to skip. + 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: '' - ruby_version: - description: "Ruby version to use, or a version file path (.tool-versions, .ruby-version)" + release_tag: + description: "The release tag (from configure) — checked for prior existence" + required: true + on_release_branch: + description: "Whether the SHA is on an allowed release branch (from configure)" required: false - default: '4.0' + default: 'true' + notes: + description: "Base64-encoded release notes (from prepare) — checked non-blank" + required: false + default: '' + build: + description: > + Build the gem as a pre-gate check. Set to 'false' in the custom-build shape, where + the pack job owns the build (lint still runs). + required: false + default: 'true' sbom: description: > - Generate a CycloneDX SBOM as a pre-gate check (same generator as publish), so - SBOM-generation failures surface BEFORE the approval gate. Not signed or - attached here — that happens in publish. + Generate a CycloneDX SBOM as a pre-gate check (same generator as build-and-ship), + so SBOM-generation failures surface BEFORE the approval gate. Not signed or + attached here — that happens in build-and-ship. required: false default: 'true' -outputs: - release_tag: - description: "The release tag (e.g. v0.3.2)" - value: ${{ steps.validate.outputs.release_tag }} - prev_release: - description: "The previous release tag" - value: ${{ steps.validate.outputs.prev_release }} - branch: - description: "The branch containing the SHA" - value: ${{ steps.validate.outputs.branch }} - on_release_branch: - description: "Whether the SHA is on an expected release branch" - value: ${{ steps.validate.outputs.on_release_branch }} - commit_message: - description: "The commit message at the SHA" - value: ${{ steps.validate.outputs.commit_message }} + check_tag_unused: + description: "Fail if the release tag already exists" + required: false + default: 'true' + check_version_unpublished: + description: "Fail if the version is already published to RubyGems" + required: false + default: 'true' + check_notes_not_blank: + description: "Warn if release notes are blank" + required: false + default: 'true' + enforce_release_branch: + description: "Hard-fail (instead of warn) when the SHA is off the release branch" + required: false + default: 'false' runs: using: composite @@ -103,6 +119,7 @@ runs: echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Check RubyGems version availability + if: ${{ inputs.check_version_unpublished == 'true' }} shell: bash env: PACKAGE_NAME: ${{ inputs.package_name }} @@ -129,31 +146,19 @@ runs: ;; esac - - name: Validate SHA format - shell: bash - env: - SHA: ${{ inputs.sha }} - run: | - if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then - echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." - exit 1 - fi - - - name: Validate release - id: validate + - name: Check release shell: bash env: - VERSION: ${{ steps.read-version.outputs.version }} - TAG_FORMAT: 'v{version}' + TAG: ${{ inputs.release_tag }} DRY_RUN: ${{ inputs.dry_run }} - RELEASE_BRANCH: 'main' - SHA: ${{ inputs.sha }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + NOTES_B64: ${{ inputs.notes }} + CHECK_TAG_UNUSED: ${{ inputs.check_tag_unused }} + CHECK_NOTES: ${{ inputs.check_notes_not_blank }} + ENFORCE_RELEASE_BRANCH: ${{ inputs.enforce_release_branch }} run: | - TAG="${TAG_FORMAT//\{version\}/$VERSION}" - COMMIT_MSG=$(git log -1 --format="%s" HEAD) - BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) - - if git rev-parse "$TAG" >/dev/null 2>&1; then + # 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 echo "::warning title=Release tag already exists::Tag $TAG already exists — skipping in dry run." else @@ -162,25 +167,23 @@ runs: fi fi - ON_RELEASE_BRANCH=false - IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" - for pattern in "${PATTERNS[@]}"; do - pattern=$(echo "$pattern" | xargs) - if [[ "$BRANCH" == $pattern ]]; then - ON_RELEASE_BRANCH=true - break + # On a release branch: warn by default, hard-fail only when enforced. + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + if [ "$ENFORCE_RELEASE_BRANCH" = "true" ]; then + echo "::error title=Off release branch::the release SHA is not on an allowed release branch (enforce_release_branch=true)." + exit 1 + else + echo "::warning title=Off release branch::the release SHA is not on an allowed release branch — releasing anyway." fi - done - - PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" - PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") + fi - echo "release_tag=$TAG" >> $GITHUB_OUTPUT - echo "commit_message=$COMMIT_MSG" >> $GITHUB_OUTPUT - echo "branch=$BRANCH" >> $GITHUB_OUTPUT - echo "on_release_branch=$ON_RELEASE_BRANCH" >> $GITHUB_OUTPUT - echo "prev_release=$PREV_RELEASE" >> $GITHUB_OUTPUT - echo "Validated $TAG @ $SHA ($COMMIT_MSG)" + # Release notes present (warn only — a genuine first release, or no PRs since the last tag, is legitimate). + if [ "$CHECK_NOTES" = "true" ]; then + NOTES=$(echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "") + if [ -z "$NOTES" ]; then + echo "::warning title=Empty release notes::no release notes were generated (first release, or no PRs since the last tag?)." + fi + fi - name: Lint shell: bash @@ -188,6 +191,7 @@ runs: run: bundle exec rake lint - name: Build gem + if: ${{ inputs.build == 'true' }} shell: bash working-directory: ${{ inputs.working_directory }} run: | diff --git a/actions/release/prepare/action.yml b/actions/release/prepare/action.yml index 6014f73..1d792df 100644 --- a/actions/release/prepare/action.yml +++ b/actions/release/prepare/action.yml @@ -1,4 +1,5 @@ # 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. diff --git a/actions/release/notify-pending/action.yml b/actions/release/request-approval/action.yml similarity index 93% rename from actions/release/notify-pending/action.yml rename to actions/release/request-approval/action.yml index c15936f..27dbbff 100644 --- a/actions/release/notify-pending/action.yml +++ b/actions/release/request-approval/action.yml @@ -1,8 +1,12 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. -name: Notify Release Pending +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Request Release Approval description: > - Posts the pre-approval job summary and Slack notification. - Called before the environment gate — reviewer sees this before approving. + 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. inputs: sha: diff --git a/scripts/generate.rb b/scripts/generate.rb index 6eb8cc3..68d8e16 100644 --- a/scripts/generate.rb +++ b/scripts/generate.rb @@ -12,6 +12,7 @@ require 'erb' require 'fileutils' +require 'json' module ActionGenerator REPO_ROOT = File.expand_path('..', __dir__) @@ -21,6 +22,13 @@ module ActionGenerator GENERATED_HEADER = "# GENERATED by scripts/generate.rb — edit templates/, not this file.\n" + # Soft-semver, scoped per action FAMILY (the top dir under templates/actions/, e.g. + # `release`). The version lives in that family's VERSION file and is stamped into every + # action it generates. Consumers pin our actions by SHA — this number is NOT a git tag; + # it's a risk signal for diffing two pinned SHAs: a major jump = breaking change (inputs + # removed/renamed, behavior contract changed), minor = additive, patch = fix. Scoped per + # family so one family's breaking change never forces a major bump on the others. + # The rendering scope for a template. An instance's `binding` is handed to ERB, # so the ONLY methods a template (.yml.erb) can call are this class's PUBLIC # methods — currently just render_step. Internal helpers stay private. @@ -89,10 +97,35 @@ def template_paths def generate(template_path) out_path = output_path_for(template_path) FileUtils.mkdir_p(File.dirname(out_path)) - File.write(out_path, GENERATED_HEADER + render_action(template_path)) + File.write(out_path, header_for(template_path) + render_action(template_path)) puts " #{erb?(template_path) ? 'rendered' : 'copied '} → #{out_path.sub("#{REPO_ROOT}/", '')}" end + # The generated header plus, when the action belongs to a versioned family, a + # machine-parseable JSON metadata comment stamped from that family's VERSION file: + # # sdk-actions: {"family":"release","version":"1.0.0"} + # (a comment, not a YAML key — action.yml has no native version field, and this + # survives regeneration + can't trip the action schema). + def header_for(template_path) + family, version = family_version(template_path) + return GENERATED_HEADER unless version + "#{GENERATED_HEADER}# sdk-actions: #{JSON.generate('family' => family, 'version' => version)}\n" + end + + # Nearest VERSION file walking up from the template toward templates/actions/. + # Returns [family, version] (family = that dir relative to templates/actions/, + # e.g. "release"), or nil when no ancestor carries a VERSION. + def family_version(template_path) + dir = File.dirname(template_path) + while dir.start_with?(TEMPLATES_DIR) + version_file = File.join(dir, 'VERSION') + return [dir.sub("#{TEMPLATES_DIR}/", ''), File.read(version_file).strip] if File.exist?(version_file) + break if dir == TEMPLATES_DIR + dir = File.dirname(dir) + end + nil + end + def render_action(template_path) source = File.read(template_path) erb?(template_path) ? Template.new(source).render : source diff --git a/templates/actions/release/VERSION b/templates/actions/release/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/templates/actions/release/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/templates/actions/release/lang/js/publish.yml.erb b/templates/actions/release/lang/js/build-and-ship.yml.erb similarity index 81% rename from templates/actions/release/lang/js/publish.yml.erb rename to templates/actions/release/lang/js/build-and-ship.yml.erb index ac1d551..862a650 100644 --- a/templates/actions/release/lang/js/publish.yml.erb +++ b/templates/actions/release/lang/js/build-and-ship.yml.erb @@ -1,10 +1,12 @@ -name: Publish JavaScript Package +name: Build and Ship JavaScript Package description: > - Full JavaScript package release: checks out the SHA, sets up Node and pnpm, - builds, generates and signs a CycloneDX SBOM (bound to the published tarball), - publishes to npm (OIDC trusted publishing + provenance), creates a GitHub release - (with the SBOM attached), and sends a Slack completion notification. Covers the - standard publish sequence for Braintrust npm packages. + Turnkey JavaScript release — the whole pipeline in one gated job: checks out the + SHA, sets up Node and pnpm, builds, generates and signs a CycloneDX SBOM (bound to + the published tarball via `pnpm pack` == `pnpm publish` bytes), publishes to npm + (OIDC trusted publishing + provenance), creates a GitHub release (with the SBOM + attached), and announces on Slack. This is the `build → attest → publish → release → + announce` pipeline; the custom-build path (pack + ship-package) is the same pipeline + split across two jobs at the build⟷publish seam. inputs: sha: @@ -142,18 +144,15 @@ runs: ref: ${{ inputs.sha }} fetch-depth: 0 -<%= render_step('lang/js/setup') %> +<%= render_step('lang/js/pnpm/setup') %> -<%= render_step('lang/js/build', if: "${{ inputs.build == 'true' }}") %> +<%= render_step('lang/js/pnpm/build', if: "${{ inputs.build == 'true' }}") %> -<%= render_step('release/lang/js/sbom', if: "${{ inputs.sbom == 'true' }}") %> +<%= render_step('lang/js/pnpm/sbom', if: "${{ inputs.sbom == 'true' }}") %> -<%# Attest BEFORE publish: `pnpm pack` == the bytes `pnpm publish` uploads (pnpm ≥ 11.8), - so the SBOM binds to the published digest with no refetch, and a failed attestation - never leaves a published-but-unsigned package. -%> -<%= render_step('release/lang/js/attest-sbom', if: "${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}") %> +<%= render_step('lang/js/pnpm/attest-sbom', if: "${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}") %> -<%= render_step('release/lang/js/npm-publish') %> +<%= render_step('lang/js/npm/publish') %> <%= 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 new file mode 100644 index 0000000..1e48eb3 --- /dev/null +++ b/templates/actions/release/lang/js/configure.yml.erb @@ -0,0 +1,102 @@ +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. + +inputs: + sha: + description: "Commit SHA being released" + required: true + version: + description: > + Explicit version override. If provided, the version is not read from package.json. + Use when the version is computed externally (e.g. a run-number auto-bump). + required: false + default: '' + working_directory: + description: "Path to the package root (where package.json lives)" + required: false + default: '.' + tag_format: + description: "Git tag format, with a {version} placeholder (e.g. js-{version})" + required: false + default: 'v{version}' + release_type: + description: > + 'stable' | 'prerelease'. Maps to the channel + github_release defaults + (prerelease → channel 'rc', no GitHub release). Explicit channel/github_release + inputs override this. + required: false + default: 'stable' + channel: + description: "Explicit npm channel (dist-tag) override. Empty → derived from release_type." + required: false + default: '' + github_release: + description: "Explicit github_release override ('true'/'false'). Empty → derived from release_type." + required: false + default: '' + release_branch: + description: "Comma-separated allowed release branch patterns (globs)." + required: false + default: 'main' +outputs: + version: + description: "The resolved package version" + value: ${{ steps.read-version.outputs.version }} + release_tag: + description: "The release tag (e.g. v0.3.2)" + value: ${{ steps.configure.outputs.release_tag }} + prev_release: + description: "The previous release tag" + value: ${{ steps.configure.outputs.prev_release }} + branch: + description: "The branch containing the SHA" + value: ${{ steps.configure.outputs.branch }} + on_release_branch: + description: "Whether the SHA is on an allowed release branch" + value: ${{ steps.configure.outputs.on_release_branch }} + commit_message: + description: "The commit message at the SHA" + value: ${{ steps.configure.outputs.commit_message }} + channel: + description: "The resolved npm channel (dist-tag)" + value: ${{ steps.configure.outputs.channel }} + github_release: + description: "Whether a GitHub release should be created" + value: ${{ steps.configure.outputs.github_release }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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. + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 'lts/*' + +<%= render_step('lang/js/read-version') %> + +<%= render_step('release/configure', + version: "${{ steps.read-version.outputs.version }}", + sha: "${{ inputs.sha }}", + tag_format: "${{ inputs.tag_format }}", + release_branch: "${{ inputs.release_branch }}", + release_type: "${{ inputs.release_type }}", + channel: "${{ inputs.channel }}", + github_release: "${{ inputs.github_release }}") %> + +<%= render_step('lang/js/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/js/pack-bun.yml.erb b/templates/actions/release/lang/js/pack-bun.yml.erb new file mode 100644 index 0000000..ed8b1e8 --- /dev/null +++ b/templates/actions/release/lang/js/pack-bun.yml.erb @@ -0,0 +1,92 @@ +name: Pack JavaScript Package (custom build, bun) +description: > + Build side of the custom-build ("build-ownership") path for a Bun project — runs in an + UNPRIVILEGED job with NO publish credentials. Sets up Bun, runs `bun build --metafile` + (bundling deps into the artifact), packs the npm tarball (`bun pm pack`), generates a + CycloneDX SBOM FROM the build metafile (so it matches the bundled artifact), and attests + the SBOM + SLSA build provenance to that tarball. The workflow uploads the tarball + SBOM + for the gated ship-package job (shared with pnpm). Requires id-token: write + + attestations: write. pnpm projects use pack-pnpm instead. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before building. Set to false when the caller already checked out." + required: false + default: 'true' + working_directory: + description: "Path to the package root (where package.json lives)" + required: false + default: '.' + bun_version: + description: "Bun version. Empty → auto-detect from the project (.tool-versions / package.json) or latest." + required: false + default: '' + entry: + description: "bun build entrypoint" + required: false + default: 'src/index.ts' + outdir: + description: "bun build output directory" + required: false + default: 'dist' + target: + description: "bun build --target (bun|node|browser)" + required: false + default: 'bun' + format: + description: "bun build --format (esm|cjs|iife)" + required: false + default: 'esm' + sbom: + description: "Generate + attest a CycloneDX SBOM (from the build metafile). Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' +outputs: + tarball: + description: "Absolute path to the packed tarball (the exact bytes ship-package will publish)" + value: ${{ steps.pack.outputs.tarball }} + integrity: + description: "sha512 integrity of the packed tarball" + value: ${{ steps.pack.outputs.integrity }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + +<%= render_step('lang/js/bun/setup') %> + +<%= render_step('lang/js/bun/build', + entry: "${{ inputs.entry }}", + outdir: "${{ inputs.outdir }}", + target: "${{ inputs.target }}", + format: "${{ inputs.format }}") %> + +<%= render_step('lang/js/bun/pack') %> + +<%= render_step('release/lang/js/validate-package') %> + +<%= render_step('lang/js/bun/sbom', if: "${{ inputs.sbom == 'true' }}") %> + +<%# Attest the packed tarball BEFORE it leaves this job. Skipped on dry runs. -%> +<%= render_step('release/attest-sbom', + if: "${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}", + subject_path: "${{ steps.pack.outputs.tarball }}", + sbom_path: "${{ inputs.working_directory }}/sbom.json") %> + +<%= render_step('release/attest-provenance', + if: "${{ inputs.dry_run != 'true' }}", + subject_path: "${{ steps.pack.outputs.tarball }}") %> + +<%= 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 new file mode 100644 index 0000000..aa1cd3c --- /dev/null +++ b/templates/actions/release/lang/js/pack-pnpm.yml.erb @@ -0,0 +1,89 @@ +name: Pack JavaScript Package (custom build, pnpm) +description: > + Build side of the custom-build ("build-ownership") path for a pnpm project — runs in + an UNPRIVILEGED job with NO publish credentials. Checks out the SHA, builds, packs the + exact tarball (`pnpm pack`), generates a CycloneDX SBOM (`pnpm sbom`), and attests both + the SBOM and SLSA build provenance to that tarball. The workflow uploads the tarball + + SBOM for the gated ship-package job to verify and publish. This is the + `build → pack → attest` prefix of the pipeline; ship-package is the + `publish → release → announce` suffix. Requires id-token: write + attestations: write + (but NOT publish credentials). Bun projects use pack-bun instead. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before packing. Set to false when the caller has already checked out and built." + required: false + default: 'true' + working_directory: + description: "Path to the package root (where package.json lives)" + required: false + default: '.' + node_version: + description: > + Node version (e.g. 24), or a path to a version file + (.tool-versions, .nvmrc, .node-version) relative to the repo root. + required: true + pnpm_version: + description: "pnpm version to install. When empty, read from package.json packageManager." + required: false + default: '' + build: + description: > + Whether to run the build step. Set to 'false' when the consumer's own build + already ran (the common monorepo case) — pack then just packages the output. + required: false + default: 'true' + build_command: + description: "npm script to run for the build (when build is 'true')" + required: false + default: 'build' + sbom: + description: "Generate + attest a CycloneDX SBOM bound to the packed tarball. Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' +outputs: + tarball: + description: "Absolute path to the packed tarball (the exact bytes ship-package will publish)" + value: ${{ steps.pack.outputs.tarball }} + integrity: + description: "sha512 integrity of the packed tarball" + value: ${{ steps.pack.outputs.integrity }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + +<%= render_step('lang/js/pnpm/setup') %> + +<%= render_step('lang/js/pnpm/build', if: "${{ inputs.build == 'true' }}") %> + +<%= render_step('lang/js/pnpm/pack') %> + +<%= render_step('release/lang/js/validate-package') %> + +<%= render_step('lang/js/pnpm/sbom', if: "${{ inputs.sbom == 'true' }}") %> + +<%# Attest the packed tarball BEFORE it leaves this job. Skipped on dry runs (nothing + should hit the transparency log for a build that won't be published). -%> +<%= render_step('release/attest-sbom', + if: "${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}", + subject_path: "${{ steps.pack.outputs.tarball }}", + sbom_path: "${{ inputs.working_directory }}/sbom.json") %> + +<%= render_step('release/attest-provenance', + if: "${{ inputs.dry_run != 'true' }}", + subject_path: "${{ steps.pack.outputs.tarball }}") %> + +<%= render_step('lang/js/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/js/ship-package.yml.erb b/templates/actions/release/lang/js/ship-package.yml.erb new file mode 100644 index 0000000..ac73be2 --- /dev/null +++ b/templates/actions/release/lang/js/ship-package.yml.erb @@ -0,0 +1,137 @@ +name: Ship JavaScript Package (custom build) +description: > + Publish side of the custom-build ("build-ownership") path — runs in the GATED job. + Verifies the downloaded artifact's attestations (SLSA build provenance + CycloneDX + SBOM) against its own bytes, then publishes THAT exact tarball to npm (OIDC trusted + publishing + provenance), creates a GitHub release (attaching the SBOM), and + announces on Slack. No build, no attest — it ships a prebuilt, attested artifact. + Tamper-evident: any change to the tarball in transit fails verification and blocks + the publish. Requires id-token: write + contents: write. The workflow downloads the + artifact (tarball + sbom.json) into working_directory before calling this action. + +inputs: + tarball: + description: "Path to the downloaded tarball to verify + publish (the exact bytes pack attested)" + required: true + working_directory: + description: "Directory holding the downloaded tarball + sbom.json (used to attach the SBOM to the release)" + required: false + default: '.' + sha: + description: "Commit SHA being released (release target)" + required: true + npm: + description: "Whether to publish to the npm registry" + required: false + default: 'true' + registry: + description: "npm registry URL" + required: false + default: 'https://registry.npmjs.org' + access: + description: "npm publish access: public | restricted (provenance requires public)" + required: false + default: 'public' + provenance: + description: "Publish with npm-native provenance (requires a public package)" + required: false + default: 'true' + channel: + description: "npm release channel (dist-tag) to publish under" + required: false + default: 'latest' + dry_run: + description: "Skip verification, npm publish, tag push, and GitHub release creation" + required: false + default: 'false' + release_tag: + description: "The release tag (e.g. v0.3.2)" + required: true + github_release: + description: "Whether to create a GitHub release" + required: false + default: 'true' + release_title: + description: "GitHub release name/title. Defaults to the release tag when empty." + required: false + default: '' + sbom: + description: "Attach the downloaded sbom.json to the GitHub release" + required: false + default: 'true' + version: + description: "The package version — used to build the npm link in notifications" + required: false + default: '' + package_name: + description: "npm package name — used to build the npm link in notifications" + required: false + default: '' + label: + description: "Display name for release notifications (e.g. the package name)." + required: false + default: '' + notes: + description: "Base64-encoded release notes from the prepare action" + required: false + default: '' + prev_release: + description: "Previous release tag — used in completion notification" + required: false + default: '' + branch: + description: "Branch the release was made from" + required: true + on_release_branch: + description: "Whether the SHA is on an expected release branch" + required: true + pr_list: + description: "x1f-delimited PR list from prepare action" + required: false + default: '' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if the publish fails" + required: false + default: '' + emoji: + description: "Emoji prefix for Slack messages" + required: false + default: ':package:' + +runs: + using: composite + steps: +<%# Verify the attestations against the downloaded bytes BEFORE publishing (tamper-evident). + Skipped on dry runs, where pack didn't attest. -%> +<%= render_step('release/verify-package', + if: "${{ inputs.dry_run != 'true' }}", + artifacts: "${{ inputs.tarball }}") %> + + # Publishing a prebuilt tarball goes through the npm CLI, so this needs Node with a + # user-writable global prefix (to install a modern npm for OIDC) and the registry + # wired for trusted publishing — but no pnpm and no dependency install. + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 'lts/*' + registry-url: ${{ inputs.registry }} + +<%= render_step('lang/js/npm/publish-package', tarball: "${{ inputs.tarball }}") %> + +<%= render_step('release/create-github-release') %> + +<%= render_step('release/lang/js/notify-published') %> + +<%= render_step('release/lang/js/on-failure', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/js/validate.yml.erb b/templates/actions/release/lang/js/validate.yml.erb index f6924d0..0579a2a 100644 --- a/templates/actions/release/lang/js/validate.yml.erb +++ b/templates/actions/release/lang/js/validate.yml.erb @@ -1,24 +1,22 @@ name: Validate JavaScript Release description: > - Checks out the release SHA, sets up Node and pnpm, reads the - package version, validates the release channel and metadata (tag existence, - branch, commit metadata), builds, and generates a CycloneDX SBOM — all to - confirm the package is publishable (and the SBOM tooling works) before the - approval gate. + Judges a JavaScript release against the facts `configure` derived — the toggleable + checks (tag unused, version unpublished, channel allowed, notes present, release + 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. inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Skip tag existence failure in dry runs" + description: "Warn instead of failing on an already-existing tag" required: false default: 'false' version: - description: > - Explicit version override. If provided, the version is not read from - package.json. Use when the version is computed externally (e.g. bt-publishing-test - auto-bump from run number). + description: "Version override (from configure). If empty, read from package.json." required: false default: '' working_directory: @@ -40,27 +38,33 @@ inputs: default: 'https://registry.npmjs.org' package_name: description: > - npm package name (matches the publish action's input). When set, validate fails - fast if this version is already published (covers prereleases, which aren't - git-tagged). Leave empty to skip. + 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: '' - tag_format: - description: "Git tag format, with a {version} placeholder (e.g. js-{version})" - required: false - default: 'v{version}' + release_tag: + description: "The release tag (from configure) — checked for prior existence" + required: true channel: - description: "npm release channel (dist-tag) this release targets" + description: "The npm channel/dist-tag (from configure) — checked against allowed_channels" required: false default: 'latest' allowed_channels: description: "Comma-separated allowlist of accepted release channels" required: false default: 'latest' + on_release_branch: + description: "Whether the SHA is on an allowed release branch (from configure)" + required: false + default: 'true' + notes: + description: "Base64-encoded release notes (from prepare) — checked non-blank" + required: false + default: '' build: description: > - Whether to run the build step. Set to 'false' for packages that publish - their sources directly (no build) — the build step then no-ops. + Whether to run the build step. Set to 'false' for packages that publish their + sources directly (no build) — the build step then no-ops. required: false default: 'true' build_command: @@ -69,30 +73,31 @@ inputs: default: 'build' sbom: description: > - Generate a CycloneDX SBOM as a pre-gate check (same generator as publish), so - SBOM-tooling failures (e.g. pnpm < 11.8) surface BEFORE the approval gate. The - SBOM is not signed or attached here — that happens in publish. + Generate a CycloneDX SBOM as a pre-gate check (same generator as build-and-ship), + so SBOM-tooling failures surface BEFORE the approval gate. Not signed or attached + here — that happens in build-and-ship. required: false default: 'true' -outputs: - release_tag: - description: "The release tag (e.g. v0.3.2)" - value: ${{ steps.validate.outputs.release_tag }} - prev_release: - description: "The previous release tag" - value: ${{ steps.validate.outputs.prev_release }} - branch: - description: "The branch containing the SHA" - value: ${{ steps.validate.outputs.branch }} - on_release_branch: - description: "Whether the SHA is on an expected release branch" - value: ${{ steps.validate.outputs.on_release_branch }} - commit_message: - description: "The commit message at the SHA" - value: ${{ steps.validate.outputs.commit_message }} - version: - description: "The resolved package version" - value: ${{ steps.read-version.outputs.version }} + check_tag_unused: + description: "Fail if the release tag already exists" + required: false + default: 'true' + check_version_unpublished: + description: "Fail if the version is already published to the registry" + required: false + default: 'true' + check_channel_allowed: + description: "Fail if channel is not in allowed_channels" + required: false + default: 'true' + check_notes_not_blank: + description: "Warn if release notes are blank" + required: false + default: 'true' + enforce_release_branch: + description: "Hard-fail (instead of warn) when the SHA is off the release branch" + required: false + default: 'false' runs: using: composite @@ -102,19 +107,19 @@ runs: ref: ${{ inputs.sha }} fetch-depth: 0 -<%= render_step('lang/js/setup') %> +<%= render_step('lang/js/pnpm/setup') %> -<%= render_step('lang/js/read-version') %> +<%= render_step('lang/js/read-version', version: "${{ inputs.version }}") %> -<%= render_step('release/lang/js/npm-availability') %> +<%= render_step('lang/js/npm/availability', if: "${{ inputs.check_version_unpublished == 'true' }}") %> -<%= render_step('release/lang/js/validate-channel') %> +<%= render_step('release/lang/js/validate-channel', if: "${{ inputs.check_channel_allowed == 'true' }}") %> -<%= render_step('release/validate', version: "${{ steps.read-version.outputs.version }}", tag_format: "${{ inputs.tag_format }}") %> +<%= render_step('release/checks') %> -<%= render_step('lang/js/build', if: "${{ inputs.build == 'true' }}") %> +<%= render_step('lang/js/pnpm/build', if: "${{ inputs.build == 'true' }}") %> <%# Pre-gate check: confirm SBOM generation works before the approval gate (not signed here). -%> -<%= render_step('release/lang/js/sbom', if: "${{ inputs.sbom == 'true' }}") %> +<%= render_step('lang/js/pnpm/sbom', if: "${{ inputs.sbom == 'true' }}") %> <%= render_step('lang/js/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/py/publish.yml.erb b/templates/actions/release/lang/py/build-and-ship.yml.erb similarity index 87% rename from templates/actions/release/lang/py/publish.yml.erb rename to templates/actions/release/lang/py/build-and-ship.yml.erb index c12bbc8..7f8d7b0 100644 --- a/templates/actions/release/lang/py/publish.yml.erb +++ b/templates/actions/release/lang/py/build-and-ship.yml.erb @@ -1,11 +1,11 @@ -name: Publish Python Package +name: Build and Ship Python Package description: > - Full Python package release: checks out the SHA, sets up uv and Python, - builds the sdist + wheel, generates and signs a CycloneDX SBOM (bound to the - built dists), publishes to PyPI (OIDC Trusted Publishing with PEP 740 - attestations), creates a GitHub release (with the SBOM attached), and sends a - Slack completion notification. Covers the standard publish sequence for - Braintrust PyPI packages. + Turnkey Python release — the whole pipeline in one gated job: checks out the SHA, + sets up uv and Python, builds the sdist + wheel, generates and signs a CycloneDX SBOM + (bound to the built dists), publishes to PyPI (OIDC Trusted Publishing with PEP 740 + attestations), creates a GitHub release (with the SBOM attached), and announces on + Slack. This is the `build → attest → publish → release → announce` pipeline; the + custom-build path (pack + ship-package) is the same pipeline split across two jobs. inputs: sha: @@ -125,7 +125,7 @@ runs: <%= render_step('lang/py/build') %> -<%= render_step('release/lang/py/sbom', if: "${{ inputs.sbom == 'true' }}") %> +<%= render_step('lang/py/sbom', if: "${{ inputs.sbom == 'true' }}") %> <%# Attest only the distributions. `uv build` drops a `dist/.gitignore`, and actions/attest's glob matches dotfiles (with no brace support), so target the two dist extensions explicitly. -%> @@ -134,7 +134,7 @@ runs: subject_path: ["${{ inputs.working_directory }}/dist/*.whl", "${{ inputs.working_directory }}/dist/*.tar.gz"], sbom_path: "${{ inputs.working_directory }}/sbom.json") %> -<%= render_step('release/lang/py/pypi-publish') %> +<%= render_step('lang/py/pypi/publish') %> <%= 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 new file mode 100644 index 0000000..3ef4ca7 --- /dev/null +++ b/templates/actions/release/lang/py/configure.yml.erb @@ -0,0 +1,103 @@ +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. + +inputs: + sha: + description: "Commit SHA being released" + required: true + version: + description: > + Explicit version override. If provided, the version is not read from version_file. + Use when the version is computed externally (e.g. a run-number auto-bump). + required: false + default: '' + version_file: + description: > + Path (repo-root-relative) to a Python module exposing VERSION (e.g. + py/pkg/version.py). Read when version is not overridden. + required: false + default: '' + working_directory: + description: "Path to the package root (where pyproject.toml lives)" + required: false + default: '.' + tag_format: + description: "Git tag format, with a {version} placeholder (e.g. py-{version})" + required: false + default: 'py-{version}' + release_type: + description: > + 'stable' | 'prerelease'. Maps to the github_release default (prerelease → no + GitHub release). An explicit github_release input overrides this. + required: false + default: 'stable' + github_release: + description: "Explicit github_release override ('true'/'false'). Empty → derived from release_type." + required: false + default: '' + release_branch: + description: "Comma-separated allowed release branch patterns (globs)." + required: false + default: 'main' +outputs: + version: + description: "The resolved package version" + value: ${{ steps.read-version.outputs.version }} + release_tag: + description: "The release tag (e.g. py-0.3.2)" + value: ${{ steps.configure.outputs.release_tag }} + prev_release: + description: "The previous release tag" + value: ${{ steps.configure.outputs.prev_release }} + branch: + description: "The branch containing the SHA" + value: ${{ steps.configure.outputs.branch }} + on_release_branch: + description: "Whether the SHA is on an allowed release branch" + value: ${{ steps.configure.outputs.on_release_branch }} + commit_message: + description: "The commit message at the SHA" + value: ${{ steps.configure.outputs.commit_message }} + github_release: + description: "Whether a GitHub release should be created" + value: ${{ steps.configure.outputs.github_release }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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. + - 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') %> + +<%= render_step('release/configure', + version: "${{ steps.read-version.outputs.version }}", + sha: "${{ inputs.sha }}", + tag_format: "${{ inputs.tag_format }}", + release_branch: "${{ inputs.release_branch }}", + release_type: "${{ inputs.release_type }}", + channel: "", + github_release: "${{ inputs.github_release }}") %> + +<%= render_step('lang/py/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/py/pack.yml.erb b/templates/actions/release/lang/py/pack.yml.erb new file mode 100644 index 0000000..159862c --- /dev/null +++ b/templates/actions/release/lang/py/pack.yml.erb @@ -0,0 +1,68 @@ +name: Pack Python Package (custom build) +description: > + Custom-build side (unprivileged — no publish credentials): checks out the SHA, builds + the sdist + wheel, generates a CycloneDX SBOM, and attests the SBOM + SLSA build + provenance to those dists. The workflow uploads dist/* + sbom.json for the gated + ship-package job. Requires id-token: write + attestations: write. + + Note: unlike turnkey py (which relies on PyPI's native PEP 740 provenance), the custom + path also emits our own build provenance — ship-package verifies it against the + downloaded dists before publishing. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before building. Set to false when the caller already checked out." + required: false + default: 'true' + working_directory: + description: "Path to the package root (where pyproject.toml lives)" + required: false + default: '.' + python_version: + description: > + Python version (e.g. 3.13), or a path to a version file + (.python-version, .tool-versions) relative to the repo root. + required: true + build_command: + description: "Override build command. When empty, `uv build` is used." + required: false + default: '' + sbom: + description: "Generate + attest a CycloneDX SBOM bound to the dists. Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + +<%= render_step('lang/py/setup') %> + +<%= render_step('lang/py/build') %> + +<%= render_step('lang/py/sbom', if: "${{ inputs.sbom == 'true' }}") %> + +<%# uv build drops a dist/.gitignore, and actions/attest's glob matches dotfiles with no + brace support, so target the two dist extensions explicitly. -%> +<%= render_step('release/attest-sbom', + if: "${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}", + subject_path: ["${{ inputs.working_directory }}/dist/*.whl", "${{ inputs.working_directory }}/dist/*.tar.gz"], + sbom_path: "${{ inputs.working_directory }}/sbom.json") %> + +<%= render_step('release/attest-provenance', + if: "${{ inputs.dry_run != 'true' }}", + subject_path: ["${{ inputs.working_directory }}/dist/*.whl", "${{ inputs.working_directory }}/dist/*.tar.gz"]) %> + +<%= 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 new file mode 100644 index 0000000..f3b30c5 --- /dev/null +++ b/templates/actions/release/lang/py/ship-package.yml.erb @@ -0,0 +1,114 @@ +name: Ship Python Package (custom build) +description: > + Custom-build gated publish side: verifies the downloaded dists' attestations against + their own bytes, publishes them to PyPI (OIDC Trusted Publishing + PEP 740), creates a + GitHub release (attaching the SBOM), and announces. No build, no attest — it ships + prebuilt, attested dists. Requires id-token: write + contents: write. The workflow + downloads dist/* (+ sbom.json) into working_directory first. + +inputs: + working_directory: + description: "Directory holding the downloaded dist/ (whl + sdist) and sbom.json" + required: false + default: '.' + sha: + description: "Commit SHA being released (release target)" + required: true + pypi: + description: "Whether to publish to PyPI" + required: false + default: 'true' + attestations: + description: "Publish PEP 740 attestations (Sigstore-signed provenance). Requires id-token: write." + required: false + default: 'true' + dry_run: + description: "Skip verification, PyPI upload, tag push, and GitHub release creation" + required: false + default: 'false' + release_tag: + description: "The release tag (e.g. py-0.3.2)" + required: true + github_release: + description: "Whether to create a GitHub release" + required: false + default: 'true' + release_title: + description: "GitHub release name/title. Defaults to the release tag when empty." + required: false + default: '' + sbom: + description: "Attach the downloaded sbom.json to the GitHub release" + required: false + default: 'true' + version: + description: "The package version — used to build the PyPI link in notifications" + required: false + default: '' + package_name: + description: "PyPI package name — used to build the PyPI link in notifications" + required: false + default: '' + label: + description: "Display name for release notifications (e.g. the package name)." + required: false + default: '' + notes: + description: "Base64-encoded release notes from the prepare action" + required: false + default: '' + prev_release: + description: "Previous release tag — used in completion notification" + required: false + default: '' + branch: + description: "Branch the release was made from" + required: true + on_release_branch: + description: "Whether the SHA is on an expected release branch" + required: true + pr_list: + description: "x1f-delimited PR list from prepare action" + required: false + default: '' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if the publish fails" + required: false + default: '' + emoji: + description: "Emoji prefix for Slack messages" + required: false + default: ':python:' + +runs: + using: composite + steps: + # Set up a default uv/Python so `uv publish` + PEP 740 signing work; no build here. + - name: Set up uv + uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7.2.0 + with: + python-version: '3.13' + +<%= 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') %> + +<%= render_step('release/create-github-release') %> + +<%= render_step('release/lang/py/notify-published') %> + +<%= render_step('release/lang/py/on-failure', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/py/validate.yml.erb b/templates/actions/release/lang/py/validate.yml.erb index b6143ac..e3f3f99 100644 --- a/templates/actions/release/lang/py/validate.yml.erb +++ b/templates/actions/release/lang/py/validate.yml.erb @@ -1,30 +1,25 @@ name: Validate Python Release description: > - Checks out the release SHA, sets up uv and Python, reads the package - version, validates the release metadata (tag existence, branch, commit - metadata) and that the version is not already on PyPI, then builds and generates - a CycloneDX SBOM — confirming the package is publishable (and the SBOM tooling - works) before the approval gate. + Judges a Python release against the facts `configure` derived — the toggleable checks + (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. inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Skip tag existence failure in dry runs" + description: "Warn instead of failing on an already-existing tag" required: false default: 'false' version: - description: > - Explicit version override. If provided, the version is not read from - version_file. Use when the version is computed externally (e.g. bt-publishing-test - auto-bump, or an ephemeral prerelease). + 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 (e.g. - py/pkg/version.py). Read when version is not overridden. + description: "Path (repo-root-relative) to a Python module exposing VERSION." required: false default: '' working_directory: @@ -38,45 +33,54 @@ inputs: required: true package_name: description: > - PyPI package name (matches the publish action's input). When set, validate - fails fast if this version is already published (covers prereleases, which - aren't git-tagged). Leave empty to skip. + 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: '' - tag_format: - description: "Git tag format, with a {version} placeholder (e.g. py-{version})" + release_tag: + description: "The release tag (from configure) — checked for prior existence" + required: true + on_release_branch: + description: "Whether the SHA is on an allowed release branch (from configure)" required: false - default: 'py-{version}' + default: 'true' + notes: + description: "Base64-encoded release notes (from prepare) — checked non-blank" + required: false + default: '' build_command: description: "Override build command. When empty, `uv build` is used." required: false default: '' + build: + description: > + Build the distribution as a pre-gate check. Set to 'false' in the custom-build + shape, where the pack job owns the build. + required: false + default: 'true' sbom: description: > - Generate a CycloneDX SBOM as a pre-gate check (same generator as publish), so - SBOM-tooling failures surface BEFORE the approval gate. Not signed or attached - here — that happens in publish. + Generate a CycloneDX SBOM as a pre-gate check (same generator as build-and-ship), + so SBOM-tooling failures surface BEFORE the approval gate. Not signed or attached + here — that happens in build-and-ship. required: false default: 'true' -outputs: - release_tag: - description: "The release tag (e.g. py-0.3.2)" - value: ${{ steps.validate.outputs.release_tag }} - prev_release: - description: "The previous release tag" - value: ${{ steps.validate.outputs.prev_release }} - branch: - description: "The branch containing the SHA" - value: ${{ steps.validate.outputs.branch }} - on_release_branch: - description: "Whether the SHA is on an expected release branch" - value: ${{ steps.validate.outputs.on_release_branch }} - commit_message: - description: "The commit message at the SHA" - value: ${{ steps.validate.outputs.commit_message }} - version: - description: "The resolved package version" - value: ${{ steps.read-version.outputs.version }} + check_tag_unused: + description: "Fail if the release tag already exists" + required: false + default: 'true' + check_version_unpublished: + description: "Fail if the version is already published to PyPI" + required: false + default: 'true' + check_notes_not_blank: + description: "Warn if release notes are blank" + required: false + default: 'true' + enforce_release_branch: + description: "Hard-fail (instead of warn) when the SHA is off the release branch" + required: false + default: 'false' runs: using: composite @@ -90,13 +94,13 @@ runs: <%= render_step('lang/py/read-version') %> -<%= render_step('release/lang/py/pypi-availability') %> +<%= render_step('lang/py/pypi/availability', if: "${{ inputs.check_version_unpublished == 'true' }}") %> -<%= render_step('release/validate', version: "${{ steps.read-version.outputs.version }}", tag_format: "${{ inputs.tag_format }}") %> +<%= render_step('release/checks') %> -<%= render_step('lang/py/build') %> +<%= render_step('lang/py/build', if: "${{ inputs.build == 'true' }}") %> <%# Pre-gate check: confirm SBOM generation works before the approval gate (not signed here). -%> -<%= render_step('release/lang/py/sbom', if: "${{ inputs.sbom == 'true' }}") %> +<%= render_step('lang/py/sbom', if: "${{ inputs.sbom == 'true' }}") %> <%= render_step('lang/py/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/ruby/publish.yml.erb b/templates/actions/release/lang/ruby/build-and-ship.yml.erb similarity index 84% rename from templates/actions/release/lang/ruby/publish.yml.erb rename to templates/actions/release/lang/ruby/build-and-ship.yml.erb index 42c8e0c..d06d4e3 100644 --- a/templates/actions/release/lang/ruby/publish.yml.erb +++ b/templates/actions/release/lang/ruby/build-and-ship.yml.erb @@ -1,10 +1,12 @@ -name: Publish Ruby Package +name: Build and Ship Ruby Package description: > - Full Ruby gem release: checks out the SHA, builds the gem, generates and signs a - CycloneDX SBOM plus SLSA build provenance (bound to the exact gem), publishes to - RubyGems via OIDC trusted publishing (pushing that same gem — no rebuild), creates - a GitHub release (with the SBOM attached), and sends a Slack completion - notification. Covers the standard publish sequence for all Braintrust Ruby gems. + Turnkey Ruby release — the whole pipeline in one gated job: checks out the SHA, + builds the gem, generates and signs a CycloneDX SBOM plus SLSA build provenance + (bound to the exact gem), publishes to RubyGems via OIDC trusted publishing (pushing + that same gem — no rebuild), creates a GitHub release (with the SBOM attached), and + announces on Slack. This is the `build → attest → publish → release → announce` + pipeline; Ruby's Path C (build once, attest, push that exact gem) is already the + custom-build shape un-split. inputs: sha: @@ -107,9 +109,9 @@ runs: <%# Single build: the pkg/*.gem produced here is the exact file we attest AND push (no rebuild), so the attested digest is guaranteed to be the published digest. -%> -<%= render_step('release/lang/ruby/build') %> +<%= render_step('lang/ruby/build') %> -<%= render_step('release/lang/ruby/sbom', if: "${{ inputs.sbom == 'true' }}") %> +<%= render_step('lang/ruby/sbom', if: "${{ inputs.sbom == 'true' }}") %> <%# Attest the exact gem BEFORE it is pushed, so a failed attestation never leaves a published gem unsigned. -%> @@ -124,7 +126,7 @@ runs: if: "${{ inputs.dry_run != 'true' }}", subject_path: "${{ inputs.working_directory }}/pkg/*.gem") %> -<%= render_step('release/lang/ruby/rubygems-push') %> +<%= render_step('lang/ruby/rubygems/push') %> <%= render_step('release/create-github-release') %> diff --git a/templates/actions/release/lang/ruby/configure.yml.erb b/templates/actions/release/lang/ruby/configure.yml.erb new file mode 100644 index 0000000..3e2f650 --- /dev/null +++ b/templates/actions/release/lang/ruby/configure.yml.erb @@ -0,0 +1,102 @@ +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. + +inputs: + sha: + description: "Commit SHA being released" + required: true + version: + description: > + Explicit version override. If provided, version_file/version_module are ignored. + Use when the version is computed externally (e.g. a run-number auto-bump). + 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 + default: '.' + tag_format: + description: "Git tag format, with a {version} placeholder (e.g. v{version})" + required: false + default: 'v{version}' + release_type: + description: > + 'stable' | 'prerelease'. Maps to the github_release default (prerelease → no + GitHub release). An explicit github_release input overrides this. + required: false + default: 'stable' + github_release: + description: "Explicit github_release override ('true'/'false'). Empty → derived from release_type." + required: false + default: '' + release_branch: + description: "Comma-separated allowed release branch patterns (globs)." + required: false + default: 'main' +outputs: + version: + description: "The resolved gem version" + value: ${{ steps.read-version.outputs.version }} + release_tag: + description: "The release tag (e.g. v0.3.2)" + value: ${{ steps.configure.outputs.release_tag }} + prev_release: + description: "The previous release tag" + value: ${{ steps.configure.outputs.prev_release }} + branch: + description: "The branch containing the SHA" + value: ${{ steps.configure.outputs.branch }} + on_release_branch: + description: "Whether the SHA is on an allowed release branch" + value: ${{ steps.configure.outputs.on_release_branch }} + commit_message: + description: "The commit message at the SHA" + value: ${{ steps.configure.outputs.commit_message }} + github_release: + description: "Whether a GitHub release should be created" + value: ${{ steps.configure.outputs.github_release }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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. + - name: Set up Ruby + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 + with: + ruby-version: '3.3' + +<%= render_step('lang/ruby/read-version') %> + +<%= render_step('release/configure', + version: "${{ steps.read-version.outputs.version }}", + sha: "${{ inputs.sha }}", + tag_format: "${{ inputs.tag_format }}", + release_branch: "${{ inputs.release_branch }}", + release_type: "${{ inputs.release_type }}", + channel: "", + github_release: "${{ inputs.github_release }}") %> + +<%= render_step('lang/ruby/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/ruby/pack.yml.erb b/templates/actions/release/lang/ruby/pack.yml.erb new file mode 100644 index 0000000..73ffa20 --- /dev/null +++ b/templates/actions/release/lang/ruby/pack.yml.erb @@ -0,0 +1,57 @@ +name: Pack Ruby Package (custom build) +description: > + Custom-build side (unprivileged — no publish credentials): checks out the SHA, builds + the gem, generates a CycloneDX SBOM, and attests the SBOM + SLSA build provenance to + that exact gem. The workflow uploads pkg/*.gem + sbom.json for the gated ship-package + job. Requires id-token: write + attestations: write. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before building. Set to false when the caller already checked out." + required: false + default: 'true' + working_directory: + description: "Path to the gem root (where Gemfile and gemspec live)" + required: false + default: '.' + ruby_version: + description: "Ruby version to build with, or a version file path (.tool-versions, .ruby-version)" + required: false + default: '4.0' + sbom: + description: "Generate + attest a CycloneDX SBOM bound to the gem. Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + +<%= render_step('lang/ruby/setup') %> + +<%= render_step('lang/ruby/build') %> + +<%= render_step('lang/ruby/sbom', if: "${{ inputs.sbom == 'true' }}") %> + +<%= render_step('release/attest-sbom', + if: "${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}", + subject_path: "${{ inputs.working_directory }}/pkg/*.gem", + sbom_path: "${{ inputs.working_directory }}/sbom.json") %> + +<%= render_step('release/attest-provenance', + if: "${{ inputs.dry_run != 'true' }}", + subject_path: "${{ inputs.working_directory }}/pkg/*.gem") %> + +<%= 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 new file mode 100644 index 0000000..9fd6dd5 --- /dev/null +++ b/templates/actions/release/lang/ruby/ship-package.yml.erb @@ -0,0 +1,103 @@ +name: Ship Ruby Package (custom build) +description: > + Custom-build gated publish side: verifies the downloaded gem's attestations against + its own bytes, pushes that exact gem to RubyGems (OIDC trusted publishing), creates a + GitHub release (attaching the SBOM), and announces. No build, no attest — it ships a + prebuilt, attested gem. Requires id-token: write + contents: write. The workflow + downloads the gem + sbom.json into working_directory first. + +inputs: + working_directory: + description: "Directory holding the downloaded gem + sbom.json" + required: false + default: '.' + sha: + description: "Commit SHA being released (release target)" + required: true + dry_run: + description: "Skip verification, gem push, tag push, and GitHub release creation" + required: false + default: 'false' + release_tag: + description: "The release tag (e.g. v0.3.2)" + required: true + github_release: + description: "Whether to create a GitHub release" + required: false + default: 'true' + release_title: + description: "GitHub release name/title. Defaults to the release tag when empty." + required: false + default: '' + sbom: + description: "Attach the downloaded sbom.json to the GitHub release" + required: false + default: 'true' + gem_name: + description: "RubyGems gem name — used to build the RubyGems link in notifications" + required: false + default: '' + label: + description: "Display name for release notifications (e.g. the gem name)." + required: false + default: '' + notes: + description: "Base64-encoded release notes from the prepare action" + required: false + default: '' + prev_release: + description: "Previous release tag — used in completion notification" + required: false + default: '' + branch: + description: "Branch the release was made from" + required: true + on_release_branch: + description: "Whether the SHA is on an expected release branch" + required: true + pr_list: + description: "x1f-delimited PR list from prepare action" + required: false + default: '' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if the publish fails" + required: false + default: '' + emoji: + description: "Emoji prefix for Slack messages" + required: false + default: ':ruby:' + +runs: + using: composite + steps: + # Set up a default Ruby so `gem push` has a modern rubygems (OIDC trusted publishing); + # no bundle install — this job doesn't build. + - name: Set up Ruby + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 + with: + ruby-version: '3.3' + +<%= render_step('release/verify-package', + if: "${{ inputs.dry_run != 'true' }}", + artifacts: "${{ inputs.working_directory }}/*.gem") %> + +<%= render_step('lang/ruby/rubygems/push', gem: "*.gem") %> + +<%= render_step('release/create-github-release') %> + +<%= render_step('release/lang/ruby/notify-published') %> + +<%= render_step('release/lang/ruby/on-failure', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/ruby/validate.yml.erb b/templates/actions/release/lang/ruby/validate.yml.erb index d94dd52..1cf0539 100644 --- a/templates/actions/release/lang/ruby/validate.yml.erb +++ b/templates/actions/release/lang/ruby/validate.yml.erb @@ -1,23 +1,21 @@ name: Validate Ruby Release description: > - Checks out the release SHA, sets up Ruby, reads the gem version, validates - the release (RubyGems availability, tag existence, branch, commit metadata), - and runs lint, build, and SBOM generation to confirm the gem is publishable - (and the SBOM tooling works) before the approval gate. + Judges a Ruby release against the facts `configure` derived — the toggleable checks + (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. inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Skip tag existence failure in dry runs" + description: "Warn instead of failing on an already-existing tag" required: false default: 'false' version: - description: > - Explicit version override. If provided, version_file and version_module - are ignored. Use when the version is computed externally (e.g. bt-publishing-test - auto-bump from run number). + description: "Version override (from configure). If empty, read from version_file/version_module." required: false default: '' version_file: @@ -32,40 +30,57 @@ inputs: description: "Path to the gem root (where Gemfile and gemspec live)" required: false default: '.' + ruby_version: + 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. When set, validate fails fast if this version is already - published — the registry, not the git tag, is the source of truth (covers - prereleases and force-deleted tags). Leave empty to skip. + 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: '' - ruby_version: - description: "Ruby version to use, or a version file path (.tool-versions, .ruby-version)" + release_tag: + description: "The release tag (from configure) — checked for prior existence" + required: true + on_release_branch: + description: "Whether the SHA is on an allowed release branch (from configure)" required: false - default: '4.0' + default: 'true' + notes: + description: "Base64-encoded release notes (from prepare) — checked non-blank" + required: false + default: '' + build: + description: > + Build the gem as a pre-gate check. Set to 'false' in the custom-build shape, where + the pack job owns the build (lint still runs). + required: false + default: 'true' sbom: description: > - Generate a CycloneDX SBOM as a pre-gate check (same generator as publish), so - SBOM-generation failures surface BEFORE the approval gate. Not signed or - attached here — that happens in publish. + Generate a CycloneDX SBOM as a pre-gate check (same generator as build-and-ship), + so SBOM-generation failures surface BEFORE the approval gate. Not signed or + attached here — that happens in build-and-ship. required: false default: 'true' -outputs: - release_tag: - description: "The release tag (e.g. v0.3.2)" - value: ${{ steps.validate.outputs.release_tag }} - prev_release: - description: "The previous release tag" - value: ${{ steps.validate.outputs.prev_release }} - branch: - description: "The branch containing the SHA" - value: ${{ steps.validate.outputs.branch }} - on_release_branch: - description: "Whether the SHA is on an expected release branch" - value: ${{ steps.validate.outputs.on_release_branch }} - commit_message: - description: "The commit message at the SHA" - value: ${{ steps.validate.outputs.commit_message }} + check_tag_unused: + description: "Fail if the release tag already exists" + required: false + default: 'true' + check_version_unpublished: + description: "Fail if the version is already published to RubyGems" + required: false + default: 'true' + check_notes_not_blank: + description: "Warn if release notes are blank" + required: false + default: 'true' + enforce_release_branch: + description: "Hard-fail (instead of warn) when the SHA is off the release branch" + required: false + default: 'false' runs: using: composite @@ -79,19 +94,19 @@ runs: <%= render_step('lang/ruby/read-version') %> -<%= render_step('release/lang/ruby/rubygems-availability') %> +<%= render_step('lang/ruby/rubygems/availability', if: "${{ inputs.check_version_unpublished == 'true' }}") %> -<%= render_step('release/validate', version: "${{ steps.read-version.outputs.version }}") %> +<%= render_step('release/checks') %> - name: Lint shell: bash working-directory: ${{ inputs.working_directory }} run: bundle exec rake lint -<%# Build the gem (same partial as publish) to confirm it builds before the gate. -%> -<%= render_step('release/lang/ruby/build') %> +<%# Build the gem (same partial as build-and-ship) to confirm it builds before the gate. -%> +<%= render_step('lang/ruby/build', if: "${{ inputs.build == 'true' }}") %> <%# Pre-gate check: confirm SBOM generation works before the approval gate (not signed here). -%> -<%= render_step('release/lang/ruby/sbom', if: "${{ inputs.sbom == 'true' }}") %> +<%= render_step('lang/ruby/sbom', if: "${{ inputs.sbom == 'true' }}") %> <%= render_step('lang/ruby/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/notify-pending.yml.erb b/templates/actions/release/request-approval.yml.erb similarity index 93% rename from templates/actions/release/notify-pending.yml.erb rename to templates/actions/release/request-approval.yml.erb index dd619dd..3cc7902 100644 --- a/templates/actions/release/notify-pending.yml.erb +++ b/templates/actions/release/request-approval.yml.erb @@ -1,7 +1,10 @@ -name: Notify Release Pending +name: Request Release Approval description: > - Posts the pre-approval job summary and Slack notification. - Called before the environment gate — reviewer sees this before approving. + 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. inputs: sha: diff --git a/templates/steps/lang/js/bun/build.yml.erb b/templates/steps/lang/js/bun/build.yml.erb new file mode 100644 index 0000000..1ed8b11 --- /dev/null +++ b/templates/steps/lang/js/bun/build.yml.erb @@ -0,0 +1,24 @@ +<%# + Builds a Bun project into a bundle and emits a metafile — the module graph of what + `bun build` actually inlined — which sbom-bun turns into an SBOM faithful to the + shipped bundle. Parametrized by the standard bun build knobs. + + @param entry [String] build entrypoint. Default: src/index.ts + @param outdir [String] output directory. Default: dist + @param target [String] bun build --target (bun|node|browser). Default: bun + @param format [String] bun build --format (esm|cjs|iife). Default: esm + @param metafile [String] metafile path, relative to working_directory. Default: meta.json + @param working_directory [String] package root. Default: ${{ inputs.working_directory }} +-%> +- name: Build (bun) + shell: bash + working-directory: <%= locals.fetch(:working_directory) { "${{ inputs.working_directory }}" } %> + env: + ENTRY: <%= locals.fetch(:entry) { "src/index.ts" } %> + OUTDIR: <%= locals.fetch(:outdir) { "dist" } %> + TARGET: <%= locals.fetch(:target) { "bun" } %> + FORMAT: <%= locals.fetch(:format) { "esm" } %> + METAFILE: <%= locals.fetch(:metafile) { "meta.json" } %> + run: | + bun build "$ENTRY" --outdir "$OUTDIR" --target "$TARGET" --format "$FORMAT" --metafile="$METAFILE" + echo "Built $OUTDIR (metafile: $METAFILE)" diff --git a/templates/steps/lang/js/bun/pack.yml.erb b/templates/steps/lang/js/bun/pack.yml.erb new file mode 100644 index 0000000..1af03f0 --- /dev/null +++ b/templates/steps/lang/js/bun/pack.yml.erb @@ -0,0 +1,22 @@ +<%# + Packs the Bun package into the exact npm tarball via `bun pm pack`, to a clean + directory. Sets id: pack with `tarball` (absolute path) + `integrity` (sha512) — + matching lang/js/pack-pnpm, so the composing action treats both toolchains + identically (validate/attest/ship steps are shared). + + @param working_directory [String] package root. Default: ${{ inputs.working_directory }} + @param pack_dir [String] clean output dir. Default: ${{ runner.temp }}/pkgout +-%> +- name: Pack (bun pm pack) + id: pack + shell: bash + working-directory: <%= locals.fetch(:working_directory) { "${{ inputs.working_directory }}" } %> + env: + PACK_DIR: <%= locals.fetch(:pack_dir) { "${{ runner.temp }}/pkgout" } %> + run: | + rm -rf "$PACK_DIR"; mkdir -p "$PACK_DIR" + bun pm 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" + echo "Packed $TGZ" diff --git a/templates/steps/lang/js/bun/sbom.yml.erb b/templates/steps/lang/js/bun/sbom.yml.erb new file mode 100644 index 0000000..6458730 --- /dev/null +++ b/templates/steps/lang/js/bun/sbom.yml.erb @@ -0,0 +1,59 @@ +<%# + Generates a CycloneDX SBOM for a BUNDLED Bun artifact from the `bun build` metafile — + the module graph of what was actually inlined — so the SBOM matches the shipped + bundle rather than a re-resolution of package.json. Maps each bundled node_modules + module to its package@version and emits CycloneDX 1.7 (with serialNumber, so + actions/attest recognizes it). Bun has no native SBOM, and cyclonedx-npm/cdxgen + describe declared deps, not the bundle — hence this metafile-based generator. + + Prerequisite: the build must have run `bun build … --metafile=`. + + @param working_directory [String] package root (holds package.json + the metafile). Default: ${{ inputs.working_directory }} + @param metafile [String] bun build metafile path, relative to working_directory. Default: meta.json + @param output [String] SBOM output path, relative to working_directory. Default: sbom.json +-%> +- name: Generate SBOM (CycloneDX, from Bun bundle metafile) + shell: bash + working-directory: <%= locals.fetch(:working_directory) { "${{ inputs.working_directory }}" } %> + env: + METAFILE: <%= locals.fetch(:metafile) { "meta.json" } %> + OUTPUT: <%= locals.fetch(:output) { "sbom.json" } %> + run: | + # .cjs so it runs as CommonJS even when the package is "type": "module". + cat > "$RUNNER_TEMP/bun-sbom.cjs" <<'CJS' + const fs = require("fs"), crypto = require("crypto"); + const meta = JSON.parse(fs.readFileSync(process.env.METAFILE, "utf8")); + const root = JSON.parse(fs.readFileSync("package.json", "utf8")); + // Collect every node_modules module the bundle actually included. The last + // node_modules segment is the installed package (handles nesting + @scope/name). + const seen = new Map(); + for (const out of Object.values(meta.outputs || {})) { + for (const input of Object.keys(out.inputs || {})) { + const m = input.match(/^(.*node_modules\/(?:@[^/]+\/[^/]+|[^/]+))\//); + if (!m || seen.has(m[1])) continue; + try { + const pj = JSON.parse(fs.readFileSync(m[1] + "/package.json", "utf8")); + if (pj.name && pj.version) seen.set(m[1], { name: pj.name, version: pj.version }); + } catch { /* not a package root; skip */ } + } + } + const purl = (n, v) => `pkg:npm/${n[0] === "@" ? "%40" + n.slice(1) : n}@${v}`; + const components = [...seen.values()] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((c) => ({ type: "library", name: c.name, version: c.version, purl: purl(c.name, c.version) })); + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.7", + // Required: actions/attest recognizes CycloneDX only with bomFormat + specVersion + serialNumber. + serialNumber: `urn:uuid:${crypto.randomUUID()}`, + version: 1, + metadata: { + tools: { components: [{ type: "application", group: "braintrust", name: "sdk-actions bun sbom" }] }, + component: { type: "application", name: root.name, version: root.version, purl: purl(root.name, root.version) }, + }, + components, + }; + fs.writeFileSync(process.env.OUTPUT, JSON.stringify(bom, null, 2)); + console.log(`Wrote ${process.env.OUTPUT}: ${components.length} bundled component(s)`); + CJS + node "$RUNNER_TEMP/bun-sbom.cjs" diff --git a/templates/steps/lang/js/bun/setup.yml.erb b/templates/steps/lang/js/bun/setup.yml.erb new file mode 100644 index 0000000..47f7083 --- /dev/null +++ b/templates/steps/lang/js/bun/setup.yml.erb @@ -0,0 +1,20 @@ +<%# + Sets up Bun and installs dependencies (frozen). The Bun analogue of lang/js/setup. + When bun_version is empty, setup-bun resolves it from the project (.tool-versions / + package.json) or falls back to latest. + + @param bun_version [String] Bun version. Empty → auto-detect. Default: ${{ inputs.bun_version }} + @param working_directory [String] package root. Default: ${{ inputs.working_directory }} +-%> +- name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: <%= locals.fetch(:bun_version) { "${{ inputs.bun_version }}" } %> +- name: Install dependencies + shell: bash + working-directory: <%= locals.fetch(:working_directory) { "${{ inputs.working_directory }}" } %> + run: | + bun install --frozen-lockfile || { + echo "::error title=Dependency install failed::'bun install --frozen-lockfile' failed — the lockfile is likely out of sync with package.json (run 'bun install' and commit bun.lock)." + exit 1 + } diff --git a/templates/steps/release/lang/js/npm-availability.yml.erb b/templates/steps/lang/js/npm/availability.yml.erb similarity index 100% rename from templates/steps/release/lang/js/npm-availability.yml.erb rename to templates/steps/lang/js/npm/availability.yml.erb diff --git a/templates/steps/lang/js/npm/publish-package.yml.erb b/templates/steps/lang/js/npm/publish-package.yml.erb new file mode 100644 index 0000000..99a6931 --- /dev/null +++ b/templates/steps/lang/js/npm/publish-package.yml.erb @@ -0,0 +1,52 @@ +<%# + Publishes the EXACT prebuilt tarball to npm with OIDC trusted publishing (no token) + — the publish half of the custom-build ship. Runs `npm publish ` from the + tarball's OWN directory (never a source tree): that dir has no package.json, so a + strict-pnpm consumer's devEngines/onFail:error can't fire (the check reads the CWD + manifest — verified locally). Publishes the exact attested bytes, so + published == attested. Self-guards on the `npm` toggle and dry_run. + + @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 }} + @param access [String] public | restricted. Default: ${{ inputs.access }} + @param provenance [String] emit npm provenance. Default: ${{ inputs.provenance }} + @param registry [String] registry URL. Default: ${{ inputs.registry }} + @param dry_run [String] skip the upload. Default: ${{ inputs.dry_run }} +-%> +- name: Publish prebuilt tarball to npm + shell: bash + env: + TGZ: <%= locals.fetch(:tarball) %> + NPM_ENABLED: <%= locals.fetch(:npm) { "${{ inputs.npm }}" } %> + CHANNEL: <%= locals.fetch(:channel) { "${{ inputs.channel }}" } %> + ACCESS: <%= locals.fetch(:access) { "${{ inputs.access }}" } %> + PROVENANCE: <%= locals.fetch(:provenance) { "${{ inputs.provenance }}" } %> + REGISTRY: <%= locals.fetch(:registry) { "${{ inputs.registry }}" } %> + DRY_RUN: <%= locals.fetch(:dry_run) { "${{ inputs.dry_run }}" } %> + # Force OIDC: clear any inherited token so publish can't fall back to token auth. + NODE_AUTH_TOKEN: '' + NPM_TOKEN: '' + run: | + if [ "$NPM_ENABLED" != "true" ]; then + echo "npm registry publishing disabled; skipping." + exit 0 + fi + # pnpm can't publish a prebuilt tarball (it re-packs from a dir), so ship uses the + # npm CLI directly; pin >= 11.5.1 for OIDC (newer than setup-node's). + npm install -g "npm@11.6.2" + # Publish from the tarball's OWN directory — no package.json there, so a strict-pnpm + # consumer's devEngines onFail:error can't trip (it checks the CWD manifest). A bare + # path with a "/" is also read by npm as a GitHub shorthand, so we cd + use ./. + DIR=$(dirname "$TGZ"); FILE=$(basename "$TGZ") + cd "$DIR" + ARGS=(--tag "$CHANNEL" --access "$ACCESS" --registry "$REGISTRY") + [ "$PROVENANCE" = "true" ] && ARGS+=(--provenance) + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: npm publish ./$FILE ${ARGS[*]} --dry-run" + ARGS+=(--dry-run) + fi + npm publish "./$FILE" "${ARGS[@]}" || { + echo "::error title=npm publish failed::ENEEDAUTH → the npm trusted publisher must list this repo, workflow, and (if gated) environment; 'cannot publish over ' → the version already exists on npm (bump it)." + exit 1 + } diff --git a/templates/steps/release/lang/js/npm-publish.yml.erb b/templates/steps/lang/js/npm/publish.yml.erb similarity index 100% rename from templates/steps/release/lang/js/npm-publish.yml.erb rename to templates/steps/lang/js/npm/publish.yml.erb diff --git a/templates/steps/release/lang/js/attest-sbom.yml.erb b/templates/steps/lang/js/pnpm/attest-sbom.yml.erb similarity index 100% rename from templates/steps/release/lang/js/attest-sbom.yml.erb rename to templates/steps/lang/js/pnpm/attest-sbom.yml.erb diff --git a/templates/steps/lang/js/build.yml.erb b/templates/steps/lang/js/pnpm/build.yml.erb similarity index 100% rename from templates/steps/lang/js/build.yml.erb rename to templates/steps/lang/js/pnpm/build.yml.erb diff --git a/templates/steps/lang/js/pnpm/pack.yml.erb b/templates/steps/lang/js/pnpm/pack.yml.erb new file mode 100644 index 0000000..6103ed9 --- /dev/null +++ b/templates/steps/lang/js/pnpm/pack.yml.erb @@ -0,0 +1,24 @@ +<%# + Packs the built package into the exact tarball that will be published, to a clean + directory (a stray .tgz left in the package dir would be packed into the next + tarball — spike-observed). `pnpm pack` emits the exact bytes `pnpm publish` uploads + (pnpm ≥ 11.8, including workspace: rewriting — spike-verified), so this is the + 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 +-%> +- name: Pack + id: pack + shell: bash + working-directory: <%= locals.fetch(:working_directory) { "${{ inputs.working_directory }}" } %> + env: + 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 + TGZ=$(ls "$PACK_DIR"/*.tgz) + echo "tarball=$TGZ" >> "$GITHUB_OUTPUT" + echo "integrity=sha512-$(openssl dgst -sha512 -binary "$TGZ" | openssl base64 -A)" >> "$GITHUB_OUTPUT" + echo "Packed $TGZ" diff --git a/templates/steps/release/lang/js/sbom.yml.erb b/templates/steps/lang/js/pnpm/sbom.yml.erb similarity index 100% rename from templates/steps/release/lang/js/sbom.yml.erb rename to templates/steps/lang/js/pnpm/sbom.yml.erb diff --git a/templates/steps/lang/js/setup.yml.erb b/templates/steps/lang/js/pnpm/setup.yml.erb similarity index 100% rename from templates/steps/lang/js/setup.yml.erb rename to templates/steps/lang/js/pnpm/setup.yml.erb diff --git a/templates/steps/release/lang/py/pypi-availability.yml.erb b/templates/steps/lang/py/pypi/availability.yml.erb similarity index 100% rename from templates/steps/release/lang/py/pypi-availability.yml.erb rename to templates/steps/lang/py/pypi/availability.yml.erb diff --git a/templates/steps/release/lang/py/pypi-publish.yml.erb b/templates/steps/lang/py/pypi/publish.yml.erb similarity index 100% rename from templates/steps/release/lang/py/pypi-publish.yml.erb rename to templates/steps/lang/py/pypi/publish.yml.erb diff --git a/templates/steps/release/lang/py/sbom.yml.erb b/templates/steps/lang/py/sbom.yml.erb similarity index 100% rename from templates/steps/release/lang/py/sbom.yml.erb rename to templates/steps/lang/py/sbom.yml.erb diff --git a/templates/steps/lang/py/setup.yml.erb b/templates/steps/lang/py/setup.yml.erb index be3fdd7..f2a0d40 100644 --- a/templates/steps/lang/py/setup.yml.erb +++ b/templates/steps/lang/py/setup.yml.erb @@ -32,3 +32,5 @@ # Installs this Python and exports UV_PYTHON for the job, so subsequent uv # commands (build, read-version) use it. python-version: ${{ steps.py-src.outputs.spec }} + # Don't fail the job when there's nothing to cache (jobs that install no deps). + ignore-nothing-to-cache: "true" diff --git a/templates/steps/release/lang/ruby/build.yml.erb b/templates/steps/lang/ruby/build.yml.erb similarity index 100% rename from templates/steps/release/lang/ruby/build.yml.erb rename to templates/steps/lang/ruby/build.yml.erb diff --git a/templates/steps/release/lang/ruby/rubygems-availability.yml.erb b/templates/steps/lang/ruby/rubygems/availability.yml.erb similarity index 100% rename from templates/steps/release/lang/ruby/rubygems-availability.yml.erb rename to templates/steps/lang/ruby/rubygems/availability.yml.erb diff --git a/templates/steps/release/lang/ruby/rubygems-push.yml.erb b/templates/steps/lang/ruby/rubygems/push.yml.erb similarity index 52% rename from templates/steps/release/lang/ruby/rubygems-push.yml.erb rename to templates/steps/lang/ruby/rubygems/push.yml.erb index d457bbf..85c3777 100644 --- a/templates/steps/release/lang/ruby/rubygems-push.yml.erb +++ b/templates/steps/lang/ruby/rubygems/push.yml.erb @@ -1,11 +1,11 @@ <%# - Publishes the PRE-BUILT gem to RubyGems via OIDC trusted publishing — pushing the - exact gem built (and attested) earlier, with NO rebuild, so the attested digest is - the published digest. Uses rubygems/configure-rubygems-credentials (the official - RubyGems OIDC action, the same one rubygems/release-gem uses internally) to obtain - a short-lived trusted-publishing token, then `gem push`. On a dry run, skips both - credential setup and the push. Requires id-token: write and a RubyGems trusted - publisher configured for this gem (repo + workflow, and environment if gated). + Pushes a pre-built gem to RubyGems via OIDC trusted publishing (no token) — the exact + file, no rebuild, so attested digest == published digest. Skipped on dry runs. + Requires id-token: write + a RubyGems trusted publisher (repo + workflow, and + environment if gated). Shared by turnkey build-and-ship and custom ship-package; only + the gem's location differs. + + @param gem [String] gem glob, relative to working_directory. Default: pkg/*.gem -%> - name: Configure RubyGems credentials (OIDC trusted publishing) if: ${{ inputs.dry_run != 'true' }} @@ -14,9 +14,14 @@ shell: bash working-directory: ${{ inputs.working_directory }} env: + GEM_GLOB: "<%= locals.fetch(:gem) { "pkg/*.gem" } %>" DRY_RUN: ${{ inputs.dry_run }} run: | - GEM=$(ls pkg/*.gem) + GEM=$(ls $GEM_GLOB 2>/dev/null | head -1) + if [ -z "$GEM" ]; then + echo "::error title=Gem not found::no gem matched '$GEM_GLOB' in $(pwd)." + exit 1 + fi if [ "$DRY_RUN" = "true" ]; then echo "DRY RUN: would gem push $GEM" exit 0 diff --git a/templates/steps/release/lang/ruby/sbom.yml.erb b/templates/steps/lang/ruby/sbom.yml.erb similarity index 100% rename from templates/steps/release/lang/ruby/sbom.yml.erb rename to templates/steps/lang/ruby/sbom.yml.erb diff --git a/templates/steps/release/attest-provenance.yml.erb b/templates/steps/release/attest-provenance.yml.erb index 82cf4e2..174bae2 100644 --- a/templates/steps/release/attest-provenance.yml.erb +++ b/templates/steps/release/attest-provenance.yml.erb @@ -1,15 +1,19 @@ <%# - Generates SLSA build provenance for an artifact via GitHub's official + Signs SLSA build provenance for an artifact via GitHub's official actions/attest-build-provenance (Sigstore → Rekor), verifiable with - `gh attestation verify`. Used where the package registry doesn't mint its own - provenance (Ruby); npm (`--provenance`) and PyPI (PEP 740) already do. - Requires id-token: write + attestations: write. The caller stamps the `if:` - (not a dry run) and points subject_path at the built artifact, attested BEFORE - publish so a failed attestation never leaves a published artifact without provenance. + `gh attestation verify`. Used where the registry mints no native provenance (Ruby, + and every custom build — ship-package verifies it); npm (--provenance) and PyPI + (PEP 740) cover the turnkey js/py case. Requires id-token: write + attestations: + write. The caller stamps the `if:` (not a dry run) and points subject_path at the + artifact(s), attested BEFORE publish. - @param subject_path [String] path/glob to the artifact to attest. + @param subject_path [String, Array] path/glob(s) to attest. Pass an array for + multiple globs (e.g. ['dist/*.whl', 'dist/*.tar.gz']). -%> - name: Attest build provenance uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: - subject-path: <%= locals.fetch(:subject_path) %> + subject-path: |- +<%- Array(locals.fetch(:subject_path)).each do |path| -%> + <%= path %> +<%- end -%> diff --git a/templates/steps/release/checks.yml.erb b/templates/steps/release/checks.yml.erb new file mode 100644 index 0000000..ada9bb9 --- /dev/null +++ b/templates/steps/release/checks.yml.erb @@ -0,0 +1,55 @@ +<%# + Judges a release against the facts configure derived — the toggleable guards that can + fail on repository/registry state. Each is a boolean input defaulting to 'true' + (enforce_release_branch defaults 'false'), so a repo owner can opt out of any check. + Assumes the repo is checked out at the SHA (for the tag-existence check). Channel + membership and version availability are separate steps (validate-channel, the + availability check). Failures here are pre-gate — a bad release is caught before the + approval gate. + + @param release_tag [String] expr for the derived tag. Default: ${{ inputs.release_tag }} + @param dry_run [String] expr; warn instead of fail on an existing tag. Default: ${{ inputs.dry_run }} + @param on_release_branch [String] expr. Default: ${{ inputs.on_release_branch }} + @param notes [String] expr; base64 release notes. Default: ${{ inputs.notes }} + @param check_tag_unused [String] toggle. Default: ${{ inputs.check_tag_unused }} + @param check_notes_not_blank [String] toggle. Default: ${{ inputs.check_notes_not_blank }} + @param enforce_release_branch [String] toggle. Default: ${{ inputs.enforce_release_branch }} +-%> +- name: Check release + shell: bash + env: + TAG: <%= locals.fetch(:release_tag) { "${{ inputs.release_tag }}" } %> + DRY_RUN: <%= locals.fetch(:dry_run) { "${{ inputs.dry_run }}" } %> + ON_RELEASE_BRANCH: <%= locals.fetch(:on_release_branch) { "${{ inputs.on_release_branch }}" } %> + NOTES_B64: <%= locals.fetch(:notes) { "${{ inputs.notes }}" } %> + 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 }}" } %> + run: | + # 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 + echo "::warning title=Release tag already exists::Tag $TAG already exists — skipping in dry run." + else + echo "::error title=Release tag already exists::Tag $TAG already exists — has the version been bumped?" + exit 1 + fi + fi + + # On a release branch: warn by default, hard-fail only when enforced. + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + if [ "$ENFORCE_RELEASE_BRANCH" = "true" ]; then + echo "::error title=Off release branch::the release SHA is not on an allowed release branch (enforce_release_branch=true)." + exit 1 + else + echo "::warning title=Off release branch::the release SHA is not on an allowed release branch — releasing anyway." + fi + fi + + # Release notes present (warn only — a genuine first release, or no PRs since the last tag, is legitimate). + if [ "$CHECK_NOTES" = "true" ]; then + NOTES=$(echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "") + if [ -z "$NOTES" ]; then + echo "::warning title=Empty release notes::no release notes were generated (first release, or no PRs since the last tag?)." + fi + fi diff --git a/templates/steps/release/configure.yml.erb b/templates/steps/release/configure.yml.erb new file mode 100644 index 0000000..db52e24 --- /dev/null +++ b/templates/steps/release/configure.yml.erb @@ -0,0 +1,74 @@ +<%# + Derives release FACTS (no judging): the release tag, the branch containing the SHA + and whether it's a release branch, the commit message, the previous release tag, and + the channel + github_release defaults mapped from release_type (explicit overrides + win). Read-only — the "derive" half of the old release/validate step; the checks that + can fail on state moved to release/checks. Assumes the repo is checked out at the + target SHA and the version is resolved (read-version, id: read-version). Writes its + outputs on the `configure` step. + + @param version [String] expr for the version. Default: ${{ steps.read-version.outputs.version }} + @param sha [String] expr for the commit SHA. Default: ${{ inputs.sha }} + @param tag_format [String] git tag format, {version} placeholder. Default: v{version} + @param release_branch [String] comma-separated branch/glob patterns. Default: main + @param release_type [String] 'stable' | 'prerelease' — channel/github_release defaults. Default: ${{ inputs.release_type }} + @param channel [String] explicit channel override (wins over release_type). Default: ${{ inputs.channel }} + @param github_release [String] explicit github_release override (wins over release_type). Default: ${{ inputs.github_release }} +-%> +- name: Validate SHA format + shell: bash + env: + SHA: <%= locals.fetch(:sha) { "${{ inputs.sha }}" } %> + run: | + if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." + exit 1 + fi + +- name: Configure release + id: configure + shell: bash + env: + VERSION: <%= locals.fetch(:version) { "${{ steps.read-version.outputs.version }}" } %> + TAG_FORMAT: <%= locals.fetch(:tag_format) { "${{ inputs.tag_format }}" } %> + RELEASE_BRANCH: <%= locals.fetch(:release_branch) { "${{ inputs.release_branch }}" } %> + RELEASE_TYPE: <%= locals.fetch(: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: "<%= locals.fetch(:channel) { "${{ inputs.channel }}" } %>" + GHR_OVERRIDE: "<%= locals.fetch(:github_release) { "${{ inputs.github_release }}" } %>" + run: | + TAG="${TAG_FORMAT//\{version\}/$VERSION}" + COMMIT_MSG=$(git log -1 --format="%s" HEAD) + BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) + + ON_RELEASE_BRANCH=false + IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" + for pattern in "${PATTERNS[@]}"; do + pattern=$(echo "$pattern" | xargs) + if [[ "$BRANCH" == $pattern ]]; then ON_RELEASE_BRANCH=true; break; fi + done + + PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" + PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") + + # channel: explicit override wins; else map from release_type (prerelease→rc, stable→latest) + if [ -n "$CHANNEL_OVERRIDE" ]; then CHANNEL="$CHANNEL_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then CHANNEL="rc" + else CHANNEL="latest"; fi + + # github_release: explicit override wins; else prerelease→false, stable→true + if [ -n "$GHR_OVERRIDE" ]; then GHR="$GHR_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then GHR="false" + else GHR="true"; fi + + { + echo "release_tag=$TAG" + echo "commit_message=$COMMIT_MSG" + echo "branch=$BRANCH" + echo "on_release_branch=$ON_RELEASE_BRANCH" + echo "prev_release=$PREV_RELEASE" + echo "channel=$CHANNEL" + echo "github_release=$GHR" + } >> "$GITHUB_OUTPUT" + echo "Configured $TAG @ $VERSION (channel=$CHANNEL, github_release=$GHR, branch=$BRANCH, on_release_branch=$ON_RELEASE_BRANCH)" diff --git a/templates/steps/release/lang/js/on-failure.yml.erb b/templates/steps/release/lang/js/on-failure.yml.erb index 89d72eb..e2e0639 100644 --- a/templates/steps/release/lang/js/on-failure.yml.erb +++ b/templates/steps/release/lang/js/on-failure.yml.erb @@ -1,7 +1,7 @@ <%# The publish failure handler — dumps the JavaScript environment for diagnostics and posts a Slack failure notification. Composed as the terminal - step of js/publish under `if: failure()`; the composing action stamps that + step of js/build-and-ship under `if: failure()`; the composing action stamps that guard onto each step here, and slack/send self-guards when Slack isn't set up. -%> <%= render_step('lang/js/env-dump', indent: 0) %> diff --git a/templates/steps/release/lang/js/validate-package.yml.erb b/templates/steps/release/lang/js/validate-package.yml.erb new file mode 100644 index 0000000..910c0df --- /dev/null +++ b/templates/steps/release/lang/js/validate-package.yml.erb @@ -0,0 +1,35 @@ +<%# + Sanity-checks the just-packed tarball before it's attested/uploaded — intrinsic + checks only (nothing the consumer has to assert): it exists, its package.json has a + name and a version, and it ships actual files (not just package.json — the real + footgun is a mis-configured "files"/.npmignore that publishes an empty package). + Runs inside pack (same job, before attest): neither independently runnable nor across + a security boundary, so it's a step, not an action. + + @param tarball [String] expr for the packed tarball path. Default: ${{ steps.pack.outputs.tarball }} +-%> +- name: Validate packed tarball + shell: bash + env: + TGZ: <%= locals.fetch(:tarball) { "${{ steps.pack.outputs.tarball }}" } %> + run: | + if [ ! -f "$TGZ" ]; then + echo "::error title=Package not found::packed tarball does not exist: $TGZ"; exit 1 + fi + PKGJSON=$(tar -xzO -f "$TGZ" package/package.json) + NAME=$(printf '%s' "$PKGJSON" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).name || ""') + VER=$(printf '%s' "$PKGJSON" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).version || ""') + # Count shipped files EXCLUDING package.json — a tarball with only package.json means + # "files"/.npmignore excluded everything that should ship. + CONTENT=$(tar -tzf "$TGZ" | grep '^package/' | grep -vc '^package/package.json$' || true) + + if [ -z "$NAME" ]; then + echo "::error title=Invalid package::the packed tarball has no name."; exit 1 + fi + if [ -z "$VER" ]; then + echo "::error title=Invalid package::the packed tarball has no version."; exit 1 + fi + if [ "$CONTENT" -lt 1 ]; then + echo "::error title=Empty package::the tarball ships only package.json — check the package's \"files\"/.npmignore."; exit 1 + fi + echo "✓ $NAME@$VER — $CONTENT shipped file(s), tarball looks sane" diff --git a/templates/steps/release/validate.yml.erb b/templates/steps/release/validate.yml.erb deleted file mode 100644 index 20b08e4..0000000 --- a/templates/steps/release/validate.yml.erb +++ /dev/null @@ -1,66 +0,0 @@ -<%# - Validates a release SHA and computes release metadata. Composed - into every language's validate action. Assumes the repo is checked - out at the target SHA. - - @param version [String] expr for the version string. Default: ${{ inputs.version }} - @param sha [String] expr for the commit SHA. Default: ${{ inputs.sha }} - @param dry_run [String] expr; warn instead of fail on an existing tag. Default: ${{ inputs.dry_run }} - @param tag_format [String] git tag format, {version} placeholder. Default: v{version} - @param release_branch [String] comma-separated branch/glob patterns. Default: main - - Writes outputs on the `validate` step: release_tag, prev_release, branch, - on_release_branch, commit_message. --%> -- name: Validate SHA format - shell: bash - env: - SHA: <%= locals.fetch(:sha) { "${{ inputs.sha }}" } %> - run: | - if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then - echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." - exit 1 - fi - -- name: Validate release - id: validate - shell: bash - env: - VERSION: <%= locals.fetch(:version) { "${{ inputs.version }}" } %> - TAG_FORMAT: <%= locals.fetch(:tag_format) { "'v{version}'" } %> - DRY_RUN: <%= locals.fetch(:dry_run) { "${{ inputs.dry_run }}" } %> - RELEASE_BRANCH: <%= locals.fetch(:release_branch) { "'main'" } %> - SHA: <%= locals.fetch(:sha) { "${{ inputs.sha }}" } %> - run: | - TAG="${TAG_FORMAT//\{version\}/$VERSION}" - COMMIT_MSG=$(git log -1 --format="%s" HEAD) - BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) - - if git rev-parse "$TAG" >/dev/null 2>&1; then - if [ "$DRY_RUN" = "true" ]; then - echo "::warning title=Release tag already exists::Tag $TAG already exists — skipping in dry run." - else - echo "::error title=Release tag already exists::Tag $TAG already exists — has the version been bumped?" - exit 1 - fi - fi - - ON_RELEASE_BRANCH=false - IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" - for pattern in "${PATTERNS[@]}"; do - pattern=$(echo "$pattern" | xargs) - if [[ "$BRANCH" == $pattern ]]; then - ON_RELEASE_BRANCH=true - break - fi - done - - PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" - PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") - - echo "release_tag=$TAG" >> $GITHUB_OUTPUT - echo "commit_message=$COMMIT_MSG" >> $GITHUB_OUTPUT - echo "branch=$BRANCH" >> $GITHUB_OUTPUT - echo "on_release_branch=$ON_RELEASE_BRANCH" >> $GITHUB_OUTPUT - echo "prev_release=$PREV_RELEASE" >> $GITHUB_OUTPUT - echo "Validated $TAG @ $SHA ($COMMIT_MSG)" diff --git a/templates/steps/release/verify-package.yml.erb b/templates/steps/release/verify-package.yml.erb new file mode 100644 index 0000000..5ba8e27 --- /dev/null +++ b/templates/steps/release/verify-package.yml.erb @@ -0,0 +1,38 @@ +<%# + Verifies the downloaded artifact(s)' attestations — SLSA build provenance + CycloneDX + SBOM — against their own bytes, BEFORE publishing. The tamper-evident half of the + custom-build handoff: `gh attestation verify` recomputes each file's digest and + requires a matching attestation; any mutation in transit → 404 → fails closed + (spike-verified). Iterates over every file matching `artifacts`, so it covers a single + tarball/gem or a Python dist set (whl + sdist). Needs a GH token with attestations: + read. Agnostic — used by every language's ship-package. + + @param artifacts [String] expr; space-separated file glob(s) to verify (e.g. dl/*.tgz, dl/*.whl dl/*.tar.gz). + @param repo [String] expr; owner/repo the attestations live under. Default: ${{ github.repository }} +-%> +- name: Verify attestations + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACTS: <%= locals.fetch(:artifacts) %> + REPO: <%= locals.fetch(:repo) { "${{ github.repository }}" } %> + run: | + shopt -s nullglob + FILES=( $ARTIFACTS ) + if [ ${#FILES[@]} -eq 0 ]; then + echo "::error title=Nothing to verify::no artifact files matched '$ARTIFACTS'." + exit 1 + fi + for f in "${FILES[@]}"; do + echo "Verifying $(basename "$f") — build provenance…" + gh attestation verify "$f" --repo "$REPO" || { + echo "::error title=Provenance verification failed::no build-provenance attestation matches $(basename "$f") — the artifact was not produced by a trusted build, or was tampered with in transit." + exit 1 + } + echo "Verifying $(basename "$f") — SBOM…" + gh attestation verify "$f" --repo "$REPO" --predicate-type https://cyclonedx.org/bom || { + echo "::error title=SBOM verification failed::no CycloneDX SBOM attestation matches $(basename "$f")." + exit 1 + } + done + echo "✓ attestations verified for: ${FILES[*]}" From f1144ecd94fa8600887aa3c86c297387cc406750 Mon Sep 17 00:00:00 2001 From: David Elner Date: Mon, 6 Jul 2026 20:52:06 +0000 Subject: [PATCH 2/9] Added: Custom build release actions --- .github/workflows/release-js-custom.yml | 270 ++++++++++ .github/workflows/release-js.yml | 155 +++--- .github/workflows/release-py-custom.yml | 238 +++++++++ .github/workflows/release-py.yml | 148 +++--- .github/workflows/release-ruby-custom.yml | 238 +++++++++ .github/workflows/release-ruby.yml | 144 +++--- CONTRIBUTING.md | 21 + README.md | 58 ++- .../js/{publish => build-and-ship}/action.yml | 15 +- actions/release/lang/js/configure/action.yml | 196 +++++++ actions/release/lang/js/pack-bun/action.yml | 225 ++++++++ actions/release/lang/js/pack-pnpm/action.yml | 216 ++++++++ .../release/lang/js/ship-package/action.yml | 482 ++++++++++++++++++ actions/release/lang/js/validate/action.yml | 154 +++--- .../py/{publish => build-and-ship}/action.yml | 17 +- actions/release/lang/py/configure/action.yml | 197 +++++++ actions/release/lang/py/pack/action.yml | 151 ++++++ .../release/lang/py/ship-package/action.yml | 453 ++++++++++++++++ actions/release/lang/py/validate/action.yml | 153 +++--- .../{publish => build-and-ship}/action.yml | 25 +- .../release/lang/ruby/configure/action.yml | 181 +++++++ actions/release/lang/ruby/pack/action.yml | 150 ++++++ .../release/lang/ruby/ship-package/action.yml | 422 +++++++++++++++ actions/release/lang/ruby/validate/action.yml | 148 +++--- actions/release/prepare/action.yml | 1 + .../action.yml | 10 +- scripts/generate.rb | 35 +- templates/actions/release/VERSION | 1 + ...publish.yml.erb => build-and-ship.yml.erb} | 27 +- .../actions/release/lang/js/configure.yml.erb | 102 ++++ .../actions/release/lang/js/pack-bun.yml.erb | 92 ++++ .../actions/release/lang/js/pack-pnpm.yml.erb | 89 ++++ .../release/lang/js/ship-package.yml.erb | 137 +++++ .../actions/release/lang/js/validate.yml.erb | 103 ++-- ...publish.yml.erb => build-and-ship.yml.erb} | 18 +- .../actions/release/lang/py/configure.yml.erb | 103 ++++ .../actions/release/lang/py/pack.yml.erb | 68 +++ .../release/lang/py/ship-package.yml.erb | 114 +++++ .../actions/release/lang/py/validate.yml.erb | 94 ++-- ...publish.yml.erb => build-and-ship.yml.erb} | 20 +- .../release/lang/ruby/configure.yml.erb | 102 ++++ .../actions/release/lang/ruby/pack.yml.erb | 57 +++ .../release/lang/ruby/ship-package.yml.erb | 103 ++++ .../release/lang/ruby/validate.yml.erb | 93 ++-- ...nding.yml.erb => request-approval.yml.erb} | 9 +- templates/steps/lang/js/bun/build.yml.erb | 24 + templates/steps/lang/js/bun/pack.yml.erb | 22 + templates/steps/lang/js/bun/sbom.yml.erb | 59 +++ templates/steps/lang/js/bun/setup.yml.erb | 20 + .../js/npm/availability.yml.erb} | 0 .../steps/lang/js/npm/publish-package.yml.erb | 52 ++ .../js/npm/publish.yml.erb} | 0 .../js => lang/js/pnpm}/attest-sbom.yml.erb | 0 .../steps/lang/js/{ => pnpm}/build.yml.erb | 0 templates/steps/lang/js/pnpm/pack.yml.erb | 24 + .../lang/js => lang/js/pnpm}/sbom.yml.erb | 0 .../steps/lang/js/{ => pnpm}/setup.yml.erb | 0 .../py/pypi/availability.yml.erb} | 0 .../py/pypi/publish.yml.erb} | 0 .../steps/{release => }/lang/py/sbom.yml.erb | 0 templates/steps/lang/py/setup.yml.erb | 2 + .../{release => }/lang/ruby/build.yml.erb | 0 .../ruby/rubygems/availability.yml.erb} | 0 .../ruby/rubygems/push.yml.erb} | 21 +- .../{release => }/lang/ruby/sbom.yml.erb | 0 .../steps/release/attest-provenance.yml.erb | 20 +- templates/steps/release/checks.yml.erb | 55 ++ templates/steps/release/configure.yml.erb | 74 +++ .../steps/release/lang/js/on-failure.yml.erb | 2 +- .../release/lang/js/validate-package.yml.erb | 35 ++ templates/steps/release/validate.yml.erb | 66 --- .../steps/release/verify-package.yml.erb | 38 ++ 72 files changed, 5630 insertions(+), 719 deletions(-) create mode 100644 .github/workflows/release-js-custom.yml create mode 100644 .github/workflows/release-py-custom.yml create mode 100644 .github/workflows/release-ruby-custom.yml rename actions/release/lang/js/{publish => build-and-ship}/action.yml (96%) create mode 100644 actions/release/lang/js/configure/action.yml create mode 100644 actions/release/lang/js/pack-bun/action.yml create mode 100644 actions/release/lang/js/pack-pnpm/action.yml create mode 100644 actions/release/lang/js/ship-package/action.yml rename actions/release/lang/py/{publish => build-and-ship}/action.yml (96%) create mode 100644 actions/release/lang/py/configure/action.yml create mode 100644 actions/release/lang/py/pack/action.yml create mode 100644 actions/release/lang/py/ship-package/action.yml rename actions/release/lang/ruby/{publish => build-and-ship}/action.yml (95%) create mode 100644 actions/release/lang/ruby/configure/action.yml create mode 100644 actions/release/lang/ruby/pack/action.yml create mode 100644 actions/release/lang/ruby/ship-package/action.yml rename actions/release/{notify-pending => request-approval}/action.yml (93%) create mode 100644 templates/actions/release/VERSION rename templates/actions/release/lang/js/{publish.yml.erb => build-and-ship.yml.erb} (81%) create mode 100644 templates/actions/release/lang/js/configure.yml.erb create mode 100644 templates/actions/release/lang/js/pack-bun.yml.erb create mode 100644 templates/actions/release/lang/js/pack-pnpm.yml.erb create mode 100644 templates/actions/release/lang/js/ship-package.yml.erb rename templates/actions/release/lang/py/{publish.yml.erb => build-and-ship.yml.erb} (87%) create mode 100644 templates/actions/release/lang/py/configure.yml.erb create mode 100644 templates/actions/release/lang/py/pack.yml.erb create mode 100644 templates/actions/release/lang/py/ship-package.yml.erb rename templates/actions/release/lang/ruby/{publish.yml.erb => build-and-ship.yml.erb} (84%) create mode 100644 templates/actions/release/lang/ruby/configure.yml.erb create mode 100644 templates/actions/release/lang/ruby/pack.yml.erb create mode 100644 templates/actions/release/lang/ruby/ship-package.yml.erb rename templates/actions/release/{notify-pending.yml.erb => request-approval.yml.erb} (93%) create mode 100644 templates/steps/lang/js/bun/build.yml.erb create mode 100644 templates/steps/lang/js/bun/pack.yml.erb create mode 100644 templates/steps/lang/js/bun/sbom.yml.erb create mode 100644 templates/steps/lang/js/bun/setup.yml.erb rename templates/steps/{release/lang/js/npm-availability.yml.erb => lang/js/npm/availability.yml.erb} (100%) create mode 100644 templates/steps/lang/js/npm/publish-package.yml.erb rename templates/steps/{release/lang/js/npm-publish.yml.erb => lang/js/npm/publish.yml.erb} (100%) rename templates/steps/{release/lang/js => lang/js/pnpm}/attest-sbom.yml.erb (100%) rename templates/steps/lang/js/{ => pnpm}/build.yml.erb (100%) create mode 100644 templates/steps/lang/js/pnpm/pack.yml.erb rename templates/steps/{release/lang/js => lang/js/pnpm}/sbom.yml.erb (100%) rename templates/steps/lang/js/{ => pnpm}/setup.yml.erb (100%) rename templates/steps/{release/lang/py/pypi-availability.yml.erb => lang/py/pypi/availability.yml.erb} (100%) rename templates/steps/{release/lang/py/pypi-publish.yml.erb => lang/py/pypi/publish.yml.erb} (100%) rename templates/steps/{release => }/lang/py/sbom.yml.erb (100%) rename templates/steps/{release => }/lang/ruby/build.yml.erb (100%) rename templates/steps/{release/lang/ruby/rubygems-availability.yml.erb => lang/ruby/rubygems/availability.yml.erb} (100%) rename templates/steps/{release/lang/ruby/rubygems-push.yml.erb => lang/ruby/rubygems/push.yml.erb} (52%) rename templates/steps/{release => }/lang/ruby/sbom.yml.erb (100%) create mode 100644 templates/steps/release/checks.yml.erb create mode 100644 templates/steps/release/configure.yml.erb create mode 100644 templates/steps/release/lang/js/validate-package.yml.erb delete mode 100644 templates/steps/release/validate.yml.erb create mode 100644 templates/steps/release/verify-package.yml.erb diff --git a/.github/workflows/release-js-custom.yml b/.github/workflows/release-js-custom.yml new file mode 100644 index 0000000..a65a545 --- /dev/null +++ b/.github/workflows/release-js-custom.yml @@ -0,0 +1,270 @@ +# +# 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 +# +# • 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. +# +# ───────────────────────────────────────────────────────────────────────────── +# SETUP (same as release-js.yml, with one addition): +# npm Trusted Publishing must list THIS workflow filename (release-js-custom.yml) +# + the `publish` environment. Only the ship-package job publishes. +# ───────────────────────────────────────────────────────────────────────────── + +name: Release JavaScript custom-build (@braintrust/bt-publishing-test) + +on: + pull_request: + paths: + - 'actions/**' + - '.github/workflows/**' + workflow_dispatch: + inputs: + release_type: + description: "Release type — maps to channel + github_release defaults" + type: choice + default: stable + options: [stable, prerelease] + channel: + description: "Explicit npm channel / dist-tag override (empty → derived from release_type)" + type: choice + default: latest + options: [latest, rc, next, beta] + sha: + description: "Commit SHA (of the version bump) to release" + required: true + type: string + prev_release: + description: "Anchor for release notes: a tag name or commit SHA." + type: string + required: false + default: '58a397193105516446c3042361913655bcece509' + dry_run: + description: "Dry run: build + pack (no attest, no publish)" + type: boolean + default: false + +jobs: + bump: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + outputs: + version: ${{ steps.bump.outputs.version }} + steps: + # bt-publishing-test: derive next version from npm. Real SDKs drop this job. + - name: Bump version + id: bump + run: | + 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 + + # DERIVE facts (read-only). + configure: + needs: bump + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + 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 }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Configure release + id: configure + uses: ./actions/release/lang/js/configure + with: + version: ${{ needs.bump.outputs.version }} + 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 }} + + # JUDGE (checks only — pack does the build, so build/sbom are off here). + validate: + needs: [configure, prepare] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - 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 }} + 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] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write # sign attestations + attestations: write # write attestations + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.sha || github.event.pull_request.head.sha }} + fetch-depth: 0 + + # bt-publishing-test: write the resolved version into package.json before packing. + # Real SDK workflows omit this — the version is committed in the bump PR. + - name: Apply version to package.json + env: + VERSION: ${{ needs.configure.outputs.version }} + run: | + node -e 'const fs=require("fs");const f="test/release/js/package.json";const p=JSON.parse(fs.readFileSync(f,"utf8"));p.version=process.env.VERSION;fs.writeFileSync(f,JSON.stringify(p,null,2)+"\n")' + + - name: Pack + attest + id: pack + uses: ./actions/release/lang/js/pack-pnpm + with: + sha: ${{ inputs.sha || github.event.pull_request.head.sha }} + checkout: 'false' # version patch above must survive + working_directory: test/release/js + node_version: test/release/js/.tool-versions + dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} + + # Stage the exact packed tarball + its SBOM for the gated ship job. + - name: Stage artifact + run: | + mkdir -p artifact + cp "${{ steps.pack.outputs.tarball }}" artifact/ + cp test/release/js/sbom.json artifact/ 2>/dev/null || true + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-package + path: artifact/ + retention-days: 1 + if-no-files-found: error + + # NOTIFY — approval request (green when issued; the real gate is on ship-package). + request-approval: + needs: [configure, prepare, pack] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Request release approval + uses: ./actions/release/request-approval + with: + 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] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + environment: >- + ${{ github.event_name != 'pull_request' + && (inputs.dry_run && 'publish-dry-run' || 'publish') + || '' }} + permissions: + contents: write + 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 + with: + name: release-package + path: dl + + - name: Resolve tarball + id: art + run: echo "tarball=$(ls dl/*.tgz)" >> "$GITHUB_OUTPUT" + + - name: Ship package + uses: ./actions/release/lang/js/ship-package + with: + tarball: ${{ steps.art.outputs.tarball }} + working_directory: dl # holds the tarball + sbom.json (attached to the release) + 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 }} + # ⚠️ 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: '@braintrust/bt-publishing-test' + label: '@braintrust/bt-publishing-test' + access: 'public' + provenance: 'true' + channel: ${{ needs.configure.outputs.channel }} + notes: ${{ needs.prepare.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 8181222..3b34c19 100644 --- a/.github/workflows/release-js.yml +++ b/.github/workflows/release-js.yml @@ -5,7 +5,8 @@ # # This workflow serves two purposes: # 1. End-to-end testing of the composite actions in actions/release/ -# 2. Reference implementation of the JavaScript-specific release template +# 2. Reference implementation of the v2 JavaScript TURNKEY release shape +# (build-and-ship): configure → prepare → validate → 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. @@ -18,11 +19,9 @@ # publish # - Required reviewers: sdk-eng team or named maintainers # - Prevent self-review: enabled -# - Deployment branches: main only # publish-dry-run # - Required reviewers: at least yourself (to test the approval gate) # - Allow administrators to bypass: enabled (for testing flexibility) -# - Deployment branches: no restriction # # Repository Variables (Settings → Secrets and variables → Variables): # SLACK_SDK_RELEASE_CHANNEL Slack channel ID for release notifications @@ -37,7 +36,7 @@ # Repository: braintrustdata/sdk-actions (or your SDK repo) # Workflow: release-js.yml (or your workflow filename) # Environment: publish -# Provenance requires a PUBLIC package; the publish job needs id-token: write. +# Provenance requires a PUBLIC package; the build-and-ship job needs id-token: write. # # ───────────────────────────────────────────────────────────────────────────── @@ -58,8 +57,15 @@ on: type: string default: "I have merged a version bump PR" required: false + release_type: + description: "Release type — maps to channel + github_release defaults (overridden by 'channel' below)" + type: choice + default: stable + options: + - stable + - prerelease channel: - description: "npm release channel / dist-tag (default: latest)" + description: "Explicit npm channel / dist-tag override (empty → derived from release_type)" type: choice default: latest options: @@ -104,41 +110,40 @@ jobs: IFS='.' read -r major minor patch <<< "$CURRENT" echo "version=$major.$minor.$((patch + 1))" >> $GITHUB_OUTPUT - validate: + configure: needs: bump runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 5 permissions: contents: read outputs: - release_tag: ${{ steps.validate-release.outputs.release_tag }} - prev_release: ${{ steps.validate-release.outputs.prev_release }} - branch: ${{ steps.validate-release.outputs.branch }} - on_release_branch: ${{ steps.validate-release.outputs.on_release_branch }} - commit_message: ${{ steps.validate-release.outputs.commit_message }} + 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 }} steps: # bt-publishing-test: checkout required to resolve local ./actions/... references. - # Real SDK workflows use external braintrustdata/sdk-actions/...@sha references - # and do not need this step. + # Real SDK workflows use external braintrustdata/sdk-actions/...@sha references. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Validate release - id: validate-release - uses: ./actions/release/lang/js/validate + - name: Configure release + id: configure + uses: ./actions/release/lang/js/configure with: - # bt-publishing-test: version passed directly from bump job — no package.json read needed. + # bt-publishing-test: version passed directly from the bump job. # In a real JS project, drop `version`; it is read from package.json. version: ${{ needs.bump.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 (vs explicit version) - package_name: '@braintrust/bt-publishing-test' # exercises the npm-availability fail-fast check - channel: ${{ inputs.channel || 'latest' }} - allowed_channels: 'latest,rc,next,beta' + release_type: ${{ inputs.release_type || 'stable' }} + channel: ${{ inputs.channel }} # explicit override; empty → derived from release_type prepare: - needs: validate + needs: configure runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: @@ -147,60 +152,75 @@ jobs: pr_list: ${{ steps.prepare.outputs.pr_list }} notes: ${{ steps.prepare.outputs.notes }} steps: - # bt-publishing-test: checkout required because actions are referenced locally (./actions/...). - # Real SDK workflows reference actions externally (braintrustdata/sdk-actions/...@sha) - # and do not need this step. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Prepare release id: prepare uses: ./actions/release/prepare with: - release_tag: ${{ needs.validate.outputs.release_tag }} + 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.validate.outputs.prev_release }} + prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha || needs.configure.outputs.prev_release }} + + validate: + needs: [configure, prepare] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - 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 }} - notify-pending: - needs: [validate, prepare] + request-approval: + needs: [configure, prepare, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} steps: - # bt-publishing-test: checkout required for local action resolution (see prepare job comment) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Notify release pending - uses: ./actions/release/notify-pending + - name: Request release approval + uses: ./actions/release/request-approval with: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - release_tag: ${{ needs.validate.outputs.release_tag }} - prev_release: ${{ needs.validate.outputs.prev_release }} - branch: ${{ needs.validate.outputs.branch }} - on_release_branch: ${{ needs.validate.outputs.on_release_branch }} - commit_message: ${{ needs.validate.outputs.commit_message }} + 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). - # Gate on != pull_request: GHA's `A && '' || B` returns B (the '' is falsy), so suppression - # must be the trailing '' branch, not the "true" value. slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - # slack_mention: '@sdk-eng' - label: '@braintrust/bt-publishing-test' # distinguishes this from other packages in this repo + label: '@braintrust/bt-publishing-test' emoji: ':javascript:' - publish: - needs: [bump, validate, prepare, notify-pending] + build-and-ship: + needs: [configure, prepare, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 # No environment on PR dry-runs (avoids the approval gate AND the accumulating # deployment records); the gated environments apply only to manual dispatch. - # Note the structure: empty must be the trailing `|| ''` branch, since GHA's - # `cond && '' || X` would fall through to X (the '' is falsy). environment: >- ${{ github.event_name != 'pull_request' && (inputs.dry_run && 'publish-dry-run' || 'publish') @@ -208,49 +228,48 @@ jobs: permissions: contents: write id-token: write # OIDC trusted publishing + provenance - attestations: write # signed SBOM attestation (actions/attest-sbom) + attestations: write # signed SBOM attestation steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.sha || github.event.pull_request.head.sha }} fetch-depth: 0 - # bt-publishing-test: write the run-number version into package.json. The publish job - # checks out fresh, so the file still reflects the placeholder version. + # bt-publishing-test: write the resolved version (from configure) into package.json. + # The job checks out fresh, so the file still reflects the committed placeholder version. # Real SDK workflows omit this — the version is committed in the bump PR. - # (No lockfile patch needed: pnpm-lock.yaml doesn't record the package's own version.) - name: Apply version to package.json env: - VERSION: ${{ needs.bump.outputs.version }} + VERSION: ${{ needs.configure.outputs.version }} run: | node -e 'const fs=require("fs");const f="test/release/js/package.json";const p=JSON.parse(fs.readFileSync(f,"utf8"));p.version=process.env.VERSION;fs.writeFileSync(f,JSON.stringify(p,null,2)+"\n")' - - name: Publish - uses: ./actions/release/lang/js/publish + - name: Build and ship + uses: ./actions/release/lang/js/build-and-ship with: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - checkout: 'false' # bt-publishing-test: skip internal checkout — version patching above must survive into the build + checkout: 'false' # bt-publishing-test: skip internal checkout — version patch above must survive working_directory: test/release/js node_version: test/release/js/.tool-versions dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} - release_tag: ${{ needs.validate.outputs.release_tag }} - github_release: 'false' # bt-publishing-test: omit GitHub release to avoid tag/release pollution from repeated test runs - version: ${{ needs.bump.outputs.version }} + release_tag: ${{ needs.configure.outputs.release_tag }} + # ⚠️ 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 package. + github_release: 'false' + version: ${{ needs.configure.outputs.version }} package_name: '@braintrust/bt-publishing-test' - label: '@braintrust/bt-publishing-test' # distinguishes this from other packages in this repo + label: '@braintrust/bt-publishing-test' access: 'public' provenance: 'true' - channel: ${{ inputs.channel || 'latest' }} + channel: ${{ needs.configure.outputs.channel }} notes: ${{ needs.prepare.outputs.notes }} - prev_release: ${{ needs.validate.outputs.prev_release }} - branch: ${{ needs.validate.outputs.branch }} - on_release_branch: ${{ needs.validate.outputs.on_release_branch }} + 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 }} - # Suppress Slack on PR auto dry-runs (still exercises message-building; just no real post). - # Gate on != pull_request: GHA's `A && '' || B` returns B (the '' is falsy), so suppression - # must be the trailing '' branch, not the "true" value. slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - # slack_mention: '@sdk-eng' - # failure_slack_mention: '@sdk-eng' emoji: ':javascript:' diff --git a/.github/workflows/release-py-custom.yml b/.github/workflows/release-py-custom.yml new file mode 100644 index 0000000..aeb0bdf --- /dev/null +++ b/.github/workflows/release-py-custom.yml @@ -0,0 +1,238 @@ +# +# Reference implementation of the v2 Python CUSTOM-BUILD ("build-ownership") shape, +# 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 +# pack builds + attests dist/* (whl + sdist) with NO publish credentials and uploads them; +# ship-package (gated) verifies their attestations and publishes THOSE exact dists. +# +# SETUP: PyPI Trusted Publishing must list THIS workflow filename (release-py-custom.yml) +# + the `publish` environment. Only the ship-package job publishes. + +name: Release Python custom-build (bt-publishing-test) + +on: + pull_request: + paths: + - 'actions/**' + - '.github/workflows/**' + workflow_dispatch: + inputs: + release_type: + description: "Release type — maps to the github_release default" + type: choice + default: stable + options: [stable, prerelease] + sha: + description: "Commit SHA (of the version bump) to release" + required: true + type: string + prev_release: + description: "Anchor for release notes: a tag name or commit SHA." + type: string + required: false + default: '58a397193105516446c3042361913655bcece509' + dry_run: + description: "Dry run: build + pack (no attest, no publish)" + type: boolean + default: false + +jobs: + bump: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + outputs: + version: ${{ steps.bump.outputs.version }} + steps: + - name: Bump version + id: bump + run: | + 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 + + configure: + needs: bump + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + 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 }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Configure release + id: configure + uses: ./actions/release/lang/py/configure + with: + version: ${{ needs.bump.outputs.version }} + 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 }} + + validate: + needs: [configure, prepare] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - 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 }} + 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] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.sha || github.event.pull_request.head.sha }} + fetch-depth: 0 + + # bt-publishing-test: write the resolved version into version.py before building. + - name: Apply version to version.py + env: + VERSION: ${{ needs.configure.outputs.version }} + run: | + printf 'VERSION = "%s"\n' "$VERSION" > test/release/py/src/bt_publishing_test/version.py + + - name: Pack + attest + uses: ./actions/release/lang/py/pack + with: + sha: ${{ inputs.sha || github.event.pull_request.head.sha }} + checkout: 'false' + working_directory: test/release/py + python_version: test/release/py/.python-version + dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} + + # Stage the exact built dists (whl + sdist) + SBOM, preserving dist/ layout. + - name: Stage artifact + run: | + mkdir -p artifact/dist + cp test/release/py/dist/*.whl test/release/py/dist/*.tar.gz artifact/dist/ + cp test/release/py/sbom.json artifact/ 2>/dev/null || true + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-package + path: artifact/ + retention-days: 1 + if-no-files-found: error + + request-approval: + needs: [configure, prepare, pack] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Request release approval + uses: ./actions/release/request-approval + with: + 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] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + environment: >- + ${{ github.event_name != 'pull_request' + && (inputs.dry_run && 'publish-dry-run' || 'publish') + || '' }} + permissions: + contents: write + 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 + with: + name: release-package + path: dl + + - name: Ship package + uses: ./actions/release/lang/py/ship-package + with: + working_directory: dl # holds dist/ (whl + sdist) + sbom.json + 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 }} + # ⚠️ 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 }} + 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 06413c2..3109e0d 100644 --- a/.github/workflows/release-py.yml +++ b/.github/workflows/release-py.yml @@ -5,7 +5,8 @@ # # This workflow serves two purposes: # 1. End-to-end testing of the composite actions in actions/release/ -# 2. Reference implementation of the Python-specific release template +# 2. Reference implementation of the v2 Python TURNKEY release shape +# (build-and-ship): configure → prepare → validate → 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. @@ -18,11 +19,9 @@ # publish # - Required reviewers: sdk-eng team or named maintainers # - Prevent self-review: enabled -# - Deployment branches: main only # publish-dry-run # - Required reviewers: at least yourself (to test the approval gate) # - Allow administrators to bypass: enabled (for testing flexibility) -# - Deployment branches: no restriction # # Repository Variables (Settings → Secrets and variables → Variables): # SLACK_SDK_RELEASE_CHANNEL Slack channel ID for release notifications @@ -38,7 +37,7 @@ # Repository: sdk-actions (or your SDK repo) # Workflow: release-py.yml (or your workflow filename) # Environment: publish -# The publish job needs id-token: write for OIDC + PEP 740 attestations. +# The build-and-ship job needs id-token: write for OIDC + PEP 740 attestations. # # ───────────────────────────────────────────────────────────────────────────── @@ -59,6 +58,13 @@ on: type: string default: "I have merged a version bump PR" required: false + release_type: + description: "Release type — maps to the github_release default (prerelease → no GitHub release)" + type: choice + default: stable + options: + - stable + - prerelease sha: description: "Commit SHA (of the version bump) to release" required: true @@ -96,39 +102,37 @@ jobs: IFS='.' read -r major minor patch <<< "$CURRENT" echo "version=$major.$minor.$((patch + 1))" >> $GITHUB_OUTPUT - validate: + # DERIVE — read-only facts (version, tag, branch, …). No token; sets up a default Python to read the version. + configure: needs: bump runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 5 permissions: contents: read outputs: - release_tag: ${{ steps.validate-release.outputs.release_tag }} - prev_release: ${{ steps.validate-release.outputs.prev_release }} - branch: ${{ steps.validate-release.outputs.branch }} - on_release_branch: ${{ steps.validate-release.outputs.on_release_branch }} - commit_message: ${{ steps.validate-release.outputs.commit_message }} + 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 }} steps: - # bt-publishing-test: checkout required to resolve local ./actions/... references. - # Real SDK workflows use external braintrustdata/sdk-actions/...@sha references - # and do not need this step. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Validate release - id: validate-release - uses: ./actions/release/lang/py/validate + - name: Configure release + id: configure + uses: ./actions/release/lang/py/configure with: - # bt-publishing-test: version passed directly from bump job — no version_file read needed. + # bt-publishing-test: version passed directly from the bump job. # In a real Python project, drop `version`; it is read from version_file. version: ${{ needs.bump.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 (vs explicit version) - package_name: bt-publishing-test # exercises the PyPI-availability fail-fast check + release_type: ${{ inputs.release_type || 'stable' }} prepare: - needs: validate + needs: configure runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: @@ -137,60 +141,76 @@ jobs: pr_list: ${{ steps.prepare.outputs.pr_list }} notes: ${{ steps.prepare.outputs.notes }} steps: - # bt-publishing-test: checkout required because actions are referenced locally (./actions/...). - # Real SDK workflows reference actions externally (braintrustdata/sdk-actions/...@sha) - # and do not need this step. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Prepare release id: prepare uses: ./actions/release/prepare with: - release_tag: ${{ needs.validate.outputs.release_tag }} + 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.validate.outputs.prev_release }} + prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha || needs.configure.outputs.prev_release }} + + # JUDGE — toggleable checks + a pre-gate build & SBOM, using the facts configure derived. + validate: + needs: [configure, prepare] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - 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 }} - notify-pending: - needs: [validate, prepare] + # NOTIFY — issues the approval request (green the moment it posts; the real gate is on build-and-ship). + request-approval: + needs: [configure, prepare, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} steps: - # bt-publishing-test: checkout required for local action resolution (see prepare job comment) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Notify release pending - uses: ./actions/release/notify-pending + - name: Request release approval + uses: ./actions/release/request-approval with: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - release_tag: ${{ needs.validate.outputs.release_tag }} - prev_release: ${{ needs.validate.outputs.prev_release }} - branch: ${{ needs.validate.outputs.branch }} - on_release_branch: ${{ needs.validate.outputs.on_release_branch }} - commit_message: ${{ needs.validate.outputs.commit_message }} + 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). - # Gate on != pull_request: GHA's `A && '' || B` returns B (the '' is falsy), so suppression - # must be the trailing '' branch, not the "true" value. slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - # slack_mention: '@sdk-eng' - label: 'bt-publishing-test' # distinguishes this from other packages in this repo + label: 'bt-publishing-test' emoji: ':python:' - publish: - needs: [bump, validate, prepare, notify-pending] + # EXECUTE — build → attest → publish → release → announce, in one gated job. + build-and-ship: + needs: [configure, prepare, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 # No environment on PR dry-runs (avoids the approval gate AND the accumulating # deployment records); the gated environments apply only to manual dispatch. - # Note the structure: empty must be the trailing `|| ''` branch, since GHA's - # `cond && '' || X` would fall through to X (the '' is falsy). environment: >- ${{ github.event_name != 'pull_request' && (inputs.dry_run && 'publish-dry-run' || 'publish') @@ -198,45 +218,45 @@ jobs: permissions: contents: write id-token: write # OIDC trusted publishing + PEP 740 attestations - attestations: write # signed SBOM attestation (actions/attest-sbom) + attestations: write # signed SBOM attestation steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.sha || github.event.pull_request.head.sha }} fetch-depth: 0 - # bt-publishing-test: write the run-number version into version.py. The publish job - # checks out fresh, so the file still reflects the placeholder version. + # bt-publishing-test: write the resolved version (from configure) into version.py. + # The job checks out fresh, so the file still reflects the committed placeholder version. # Real SDK workflows omit this — the version is committed in the bump PR. - name: Apply version to version.py env: - VERSION: ${{ needs.bump.outputs.version }} + VERSION: ${{ needs.configure.outputs.version }} run: | printf 'VERSION = "%s"\n' "$VERSION" > test/release/py/src/bt_publishing_test/version.py - - name: Publish - uses: ./actions/release/lang/py/publish + - name: Build and ship + uses: ./actions/release/lang/py/build-and-ship with: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - checkout: 'false' # bt-publishing-test: skip internal checkout — version patching above must survive into the build + checkout: 'false' # bt-publishing-test: skip internal checkout — version patch above must survive working_directory: test/release/py python_version: test/release/py/.python-version dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} - release_tag: ${{ needs.validate.outputs.release_tag }} - github_release: 'false' # bt-publishing-test: omit GitHub release to avoid tag/release pollution from repeated test runs - version: ${{ needs.bump.outputs.version }} + release_tag: ${{ needs.configure.outputs.release_tag }} + # ⚠️ 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 package. + github_release: 'false' + version: ${{ needs.configure.outputs.version }} package_name: bt-publishing-test - label: 'bt-publishing-test' # distinguishes this from other packages in this repo + label: 'bt-publishing-test' notes: ${{ needs.prepare.outputs.notes }} - prev_release: ${{ needs.validate.outputs.prev_release }} - branch: ${{ needs.validate.outputs.branch }} - on_release_branch: ${{ needs.validate.outputs.on_release_branch }} + 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 }} - # Suppress Slack on PR auto dry-runs (still exercises message-building; just no real post). - # Gate on != pull_request: GHA's `A && '' || B` returns B (the '' is falsy), so suppression - # must be the trailing '' branch, not the "true" value. slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - # slack_mention: '@sdk-eng' - # failure_slack_mention: '@sdk-eng' emoji: ':python:' diff --git a/.github/workflows/release-ruby-custom.yml b/.github/workflows/release-ruby-custom.yml new file mode 100644 index 0000000..d81db25 --- /dev/null +++ b/.github/workflows/release-ruby-custom.yml @@ -0,0 +1,238 @@ +# +# Reference implementation of the v2 Ruby CUSTOM-BUILD ("build-ownership") shape, using +# 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 +# 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. +# +# SETUP: RubyGems Trusted Publishing must list THIS workflow filename +# (release-ruby-custom.yml) + the `publish` environment. Only ship-package publishes. + +name: Release Ruby custom-build (bt-publishing-test) + +on: + pull_request: + paths: + - 'actions/**' + - '.github/workflows/**' + workflow_dispatch: + inputs: + release_type: + description: "Release type — maps to the github_release default" + type: choice + default: stable + options: [stable, prerelease] + sha: + description: "Commit SHA (of the version bump) to release" + required: true + type: string + prev_release: + description: "Anchor for release notes: a tag name or commit SHA." + type: string + required: false + default: '58a397193105516446c3042361913655bcece509' + dry_run: + description: "Dry run: build + pack (no attest, no publish)" + type: boolean + default: false + +jobs: + bump: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + outputs: + version: ${{ steps.bump.outputs.version }} + steps: + - name: Bump version + id: bump + run: | + 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 + + configure: + needs: bump + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + 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 }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Configure release + id: configure + uses: ./actions/release/lang/ruby/configure + with: + version: ${{ needs.bump.outputs.version }} + 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 }} + + validate: + needs: [configure, prepare] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - 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 }} + 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] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.sha || github.event.pull_request.head.sha }} + fetch-depth: 0 + + # bt-publishing-test: write the resolved version into version.rb + Gemfile.lock. + - name: Apply version to version.rb and Gemfile.lock + env: + VERSION: ${{ needs.configure.outputs.version }} + run: | + sed -i "s|VERSION = \".*\"|VERSION = \"$VERSION\"|" \ + test/release/ruby/lib/bt_publishing_test/version.rb + sed -i "s|bt-publishing-test ([0-9]*\.[0-9]*\.[0-9]*)|bt-publishing-test ($VERSION)|" \ + test/release/ruby/Gemfile.lock + + - name: Pack + attest + uses: ./actions/release/lang/ruby/pack + with: + sha: ${{ inputs.sha || github.event.pull_request.head.sha }} + checkout: 'false' + working_directory: test/release/ruby + dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} + + # Stage the exact built gem + SBOM for the gated ship job. + - name: Stage artifact + run: | + mkdir -p artifact + cp test/release/ruby/pkg/*.gem artifact/ + cp test/release/ruby/sbom.json artifact/ 2>/dev/null || true + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-package + path: artifact/ + retention-days: 1 + if-no-files-found: error + + request-approval: + needs: [configure, prepare, pack] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: {} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Request release approval + uses: ./actions/release/request-approval + with: + 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] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + environment: >- + ${{ github.event_name != 'pull_request' + && (inputs.dry_run && 'publish-dry-run' || 'publish') + || '' }} + permissions: + contents: write + 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 + with: + name: release-package + path: dl + + - name: Ship package + uses: ./actions/release/lang/ruby/ship-package + with: + working_directory: dl # holds the .gem + sbom.json + 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 }} + # ⚠️ 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 + label: bt-publishing-test + notes: ${{ needs.prepare.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 0c8882f..49fddea 100644 --- a/.github/workflows/release-ruby.yml +++ b/.github/workflows/release-ruby.yml @@ -4,7 +4,8 @@ # # This workflow serves two purposes: # 1. End-to-end testing of the composite actions in actions/release/ -# 2. Reference implementation of the Ruby-specific release template +# 2. Reference implementation of the v2 Ruby TURNKEY release shape +# (build-and-ship): configure → prepare → validate → 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. @@ -17,11 +18,9 @@ # publish # - Required reviewers: sdk-eng team or named maintainers # - Prevent self-review: enabled -# - Deployment branches: main only # publish-dry-run # - Required reviewers: at least yourself (to test the approval gate) # - Allow administrators to bypass: enabled (for testing flexibility) -# - Deployment branches: no restriction # # Repository Variables (Settings → Secrets and variables → Variables): # SLACK_SDK_RELEASE_CHANNEL Slack channel ID for release notifications @@ -55,6 +54,13 @@ on: type: string default: "I have merged a version bump PR" required: false + release_type: + description: "Release type — maps to the github_release default (prerelease → no GitHub release)" + type: choice + default: stable + options: + - stable + - prerelease sha: description: "Commit SHA (of the version bump) to release" required: true @@ -87,46 +93,44 @@ jobs: 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. - # (Robust whether or not the shell has pipefail set.) 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 - validate: + # DERIVE — read-only facts (version, tag, branch, …). No token; sets up a default Ruby to read the version. + configure: needs: bump runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 5 permissions: contents: read outputs: - release_tag: ${{ steps.validate-release.outputs.release_tag }} - prev_release: ${{ steps.validate-release.outputs.prev_release }} - branch: ${{ steps.validate-release.outputs.branch }} - on_release_branch: ${{ steps.validate-release.outputs.on_release_branch }} - commit_message: ${{ steps.validate-release.outputs.commit_message }} + 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 }} steps: - # bt-publishing-test: checkout required to resolve local ./actions/... references. - # Real SDK workflows use external braintrustdata/sdk-actions/...@sha references - # and do not need this step. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Validate release - id: validate-release - uses: ./actions/release/lang/ruby/validate + - name: Configure release + id: configure + uses: ./actions/release/lang/ruby/configure with: - # bt-publishing-test: version passed directly from bump job — no version file needed. + # bt-publishing-test: version passed directly from the bump job. # In a real Ruby project, replace `version` with: # version_file: lib/your_gem/version.rb # version_module: YourGem version: ${{ needs.bump.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 fail-fast check + release_type: ${{ inputs.release_type || 'stable' }} prepare: - needs: validate + needs: configure runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: @@ -135,60 +139,75 @@ jobs: pr_list: ${{ steps.prepare.outputs.pr_list }} notes: ${{ steps.prepare.outputs.notes }} steps: - # bt-publishing-test: checkout required because actions are referenced locally (./actions/...). - # Real SDK workflows reference actions externally (braintrustdata/sdk-actions/...@sha) - # and do not need this step. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Prepare release id: prepare uses: ./actions/release/prepare with: - release_tag: ${{ needs.validate.outputs.release_tag }} + 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.validate.outputs.prev_release }} + prev_release: ${{ inputs.prev_release || github.event.pull_request.head.sha || needs.configure.outputs.prev_release }} + + # JUDGE — toggleable checks + lint + a pre-gate build & SBOM, using the facts configure derived. + validate: + needs: [configure, prepare] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - 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 }} - notify-pending: - needs: [validate, prepare] + # NOTIFY — issues the approval request (green the moment it posts; the real gate is on build-and-ship). + request-approval: + needs: [configure, prepare, validate] runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: {} steps: - # bt-publishing-test: checkout required for local action resolution (see prepare job comment) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Notify release pending - uses: ./actions/release/notify-pending + - name: Request release approval + uses: ./actions/release/request-approval with: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - release_tag: ${{ needs.validate.outputs.release_tag }} - prev_release: ${{ needs.validate.outputs.prev_release }} - branch: ${{ needs.validate.outputs.branch }} - on_release_branch: ${{ needs.validate.outputs.on_release_branch }} - commit_message: ${{ needs.validate.outputs.commit_message }} + 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). - # Gate on != pull_request: GHA's `A && '' || B` returns B (the '' is falsy), so suppression - # must be the trailing '' branch, not the "true" value. slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - # slack_mention: '@sdk-eng' - label: bt-publishing-test # distinguishes this from other packages in this repo + label: bt-publishing-test emoji: ':ruby:' - publish: - needs: [bump, validate, prepare, notify-pending] + # EXECUTE — build → attest → publish → release → announce, in one gated job. + build-and-ship: + needs: [configure, prepare, validate, request-approval] runs-on: ubuntu-24.04 timeout-minutes: 15 # No environment on PR dry-runs (avoids the approval gate AND the accumulating # deployment records); the gated environments apply only to manual dispatch. - # Note the structure: empty must be the trailing `|| ''` branch, since GHA's - # `cond && '' || X` would fall through to X (the '' is falsy). environment: >- ${{ github.event_name != 'pull_request' && (inputs.dry_run && 'publish-dry-run' || 'publish') @@ -196,45 +215,46 @@ jobs: permissions: contents: write id-token: write - attestations: write # signed SBOM attestation (actions/attest-sbom) + attestations: write # signed SBOM + SLSA build provenance steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.sha || github.event.pull_request.head.sha }} fetch-depth: 0 - # bt-publishing-test: update both version.rb and Gemfile.lock to the run-number version. - # The publish job checks out fresh so both files still reflect the placeholder. + # bt-publishing-test: update version.rb and Gemfile.lock to the resolved version (from configure). + # The job checks out fresh, so both files still reflect the committed placeholder. # Real SDK workflows omit this — both files are committed together in the bump PR. - name: Apply version to version.rb and Gemfile.lock env: - VERSION: ${{ needs.bump.outputs.version }} + VERSION: ${{ needs.configure.outputs.version }} run: | sed -i "s|VERSION = \".*\"|VERSION = \"$VERSION\"|" \ test/release/ruby/lib/bt_publishing_test/version.rb sed -i "s|bt-publishing-test ([0-9]*\.[0-9]*\.[0-9]*)|bt-publishing-test ($VERSION)|" \ test/release/ruby/Gemfile.lock - - name: Publish - uses: ./actions/release/lang/ruby/publish + - name: Build and ship + uses: ./actions/release/lang/ruby/build-and-ship with: sha: ${{ inputs.sha || github.event.pull_request.head.sha }} - checkout: 'false' # bt-publishing-test: skip internal checkout — version patching above must survive into the gem build + checkout: 'false' # bt-publishing-test: skip internal checkout — version patch above must survive working_directory: test/release/ruby dry_run: ${{ inputs.dry_run || github.event_name == 'pull_request' }} - release_tag: ${{ needs.validate.outputs.release_tag }} - github_release: 'false' # bt-publishing-test: omit GitHub release to avoid tag/release pollution from repeated test runs + release_tag: ${{ needs.configure.outputs.release_tag }} + # ⚠️ 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 }} - prev_release: ${{ needs.validate.outputs.prev_release }} - branch: ${{ needs.validate.outputs.branch }} - on_release_branch: ${{ needs.validate.outputs.on_release_branch }} + 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 }} - # Suppress Slack on PR auto dry-runs (still exercises message-building; just no real post). - # Gate on != pull_request: GHA's `A && '' || B` returns B (the '' is falsy), so suppression - # must be the trailing '' branch, not the "true" value. slack_token: ${{ github.event_name != 'pull_request' && secrets.SLACK_BOT_TOKEN || '' }} slack_channel: ${{ github.event_name != 'pull_request' && vars.SLACK_SDK_RELEASE_CHANNEL || '' }} - # slack_mention: '@sdk-eng' - # failure_slack_mention: '@sdk-eng' gem_name: bt-publishing-test - label: bt-publishing-test # distinguishes this from other packages in this repo + label: bt-publishing-test + emoji: ':ruby:' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1527391..fcaf4f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,27 @@ and regenerate: After any change, run `rake generate` and commit `templates/` and the regenerated `actions/` **together**. CI fails if they drift. +### Versioning + +Each action family (the top directory under `templates/actions/`, e.g. `release`) +carries a soft-semver in `templates/actions//VERSION`. `rake generate` +stamps it into every generated action's header as a machine-readable comment: + +``` +# sdk-actions: {"family":"release","version":"1.0.0"} +``` + +Consumers pin by SHA and diff this stamp across SHAs to judge upgrade risk, so bump +`VERSION` when a family's public interface changes and regenerate: + +- **major** — breaking (an input removed/renamed, a behavior contract changed) +- **minor** — additive (new action, new optional input) +- **patch** — fix + +We publish **no git tags** — SHA pinning is the contract. The version is a comment, +not a YAML key (`action.yml` has no native version field), so it never affects schema +validation. + ### Commands | command | what it does | diff --git a/README.md b/README.md index 997eee1..130a246 100644 --- a/README.md +++ b/README.md @@ -4,24 +4,47 @@ Shared GitHub actions and workflows for Braintrust SDK repositories. ## Releases -**Copy the canonical release workflow template** from `.github/workflows/release-.yml` and adapt it for your repository. Using it as an upstream source will enable the implemented release process to receive improvements in the future. +The release actions provide building blocks for implementing release workflows in your own repository. They contain features for security (e.g. trusted publishing, gating, provenance, SBOM, etc) and convenience (e.g. Slack notifications, summaries, automated GitHub release, etc) + +These actions can be used in combination to bootstrap quickly or individually to build out a customized release workflow. ### Pre-requisites -- **JS**: needs `packageManager` (or `pnpm_version`) **≥ pnpm 11.8** (for `pnpm sbom`) -- **Ruby**: needs a `rake build` that emits exactly one `pkg/*.gem`. +- **JS**: needs `packageManager` (or `pnpm_version`) **≥ pnpm 11.8** (for `pnpm sbom`). Bun (custom builds) needs a `bun build … --metafile` build. +- **Python**: a `uv`-buildable project; Python pinned via `.python-version` / `.tool-versions`. +- **Ruby**: needs a `rake build` that emits exactly one `pkg/*.gem`, and `rake lint`. ### Adopting the release workflow -1. **Copy the canonical template** for your language into your repo's +1. **Pick a shape**: + - **Turnkey** builds and publishes for you in one gated job. Recommended for most applications. + - **Custom build** lets you build your own artifact and hands it to a publish-only job that verifies and ships it, keeping the same security guarantees. + +2. **Copy the canonical template** for your language + shape into your repo's `.github/workflows/`: - - [`release-js.yml`](.github/workflows/release-js.yml) - - [`release-py.yml`](.github/workflows/release-py.yml) - - [`release-ruby.yml`](.github/workflows/release-ruby.yml) -2. **Configure an OIDC trusted publisher** on your registry (npm / PyPI / RubyGems) for this repo + workflow (and environment, if gated) — publishing and attestation use it, no long-lived tokens. (Adapting an existing workflow rather than copying? Also grant the publish job `attestations: write`.) -3. **Adapt the marked sections** — version source, package/gem name, working directory, and so on; the template's comments flag exactly what changes. -4. **Record the upstream version you based it on** — the sdk-actions ref (commit SHA or tag) your copy was adapted from, e.g. in a header comment. Tracking it lets you (or an agent) diff your copy against a newer upstream template and sync changes deliberately as this repo evolves. -5. **Bump the pinned SHA** to pick up action changes. Because each action is self-contained, one SHA bump pulls in the whole updated chain. + + | Language | Turnkey | Custom build | + |---|---|---| + | JavaScript | [`release-js.yml`](.github/workflows/release-js.yml) | [`release-js-custom.yml`](.github/workflows/release-js-custom.yml) | + | Python | [`release-py.yml`](.github/workflows/release-py.yml) | [`release-py-custom.yml`](.github/workflows/release-py-custom.yml) | + | Ruby | [`release-ruby.yml`](.github/workflows/release-ruby.yml) | [`release-ruby-custom.yml`](.github/workflows/release-ruby-custom.yml) | + +3. **Configure an OIDC trusted publisher** on your registry (npm / PyPI / RubyGems) for this repo + workflow filename (and environment, if gated) — publishing and attestation use it, no long-lived tokens. +4. **Adapt the template to your package.** The template demonstrates an actual package deploy and includes glue to make that function: other applications will want to remove that glue, so review the whole file before adapting. The comments flag what typically changes (version source, package/gem name, working directory), but they aren't exhaustive. +5. **Record the upstream SHA you based it on** Copy the sdk-actions commit your copy of the workflow was adapted from, e.g. in a header comment. Tracking it lets you (or an agent) diff your copy against a newer upstream template and sync changes deliberately as this repo evolves. + +### Updating a release workflow + +Actions are pinned by commit SHA. Bump the SHA to pick up changes. To judge whether a bump is safe, compare the version stamped into each `action.yml` header between the old and new SHA: + +```yaml +# sdk-actions: {"family":"release","version":"1.0.0"} + +# Tip: this is machine-readable: +# sed -n 's/^# sdk-actions: //p' action.yml | jq -r .version # → 1.0.0 +``` + +These actions follow semantic versioning: expect breaking changes when the major version updates. ### Available release actions @@ -32,14 +55,13 @@ pulls in everything it needs. | Action | Purpose | |---|---| -| `release/lang/ruby/publish` | Build the gem, generate + sign a CycloneDX SBOM and SLSA build provenance, push to RubyGems (OIDC trusted publishing), create the GitHub release (SBOM attached), and notify | -| `release/lang/ruby/validate` | Check out the SHA, set up Ruby, read the version, validate the release (tag/branch/metadata), lint + build + generate SBOM (pre-gate check) | -| `release/lang/js/publish` | Build, generate + sign a CycloneDX SBOM, publish to npm (OIDC trusted publishing + provenance), create the GitHub release (SBOM attached), and notify | -| `release/lang/js/validate` | Check out the SHA, set up Node + package manager, read the version, validate the release (channel/tag/branch/metadata), build + generate SBOM (pre-gate check) | -| `release/lang/py/publish` | Build, generate + sign a CycloneDX SBOM, publish to PyPI (OIDC trusted publishing + PEP 740 attestations), create the GitHub release (SBOM attached), and notify | -| `release/lang/py/validate` | Check out the SHA, set up uv + Python, read the version, validate the release (tag/branch/metadata, PyPI availability), build + generate SBOM (pre-gate check) | -| `release/notify-pending` | Post the pre-approval job summary and Slack notification | +| `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 | Inputs and outputs are documented in each `action.yml`. Reference an action by commit SHA: diff --git a/actions/release/lang/js/publish/action.yml b/actions/release/lang/js/build-and-ship/action.yml similarity index 96% rename from actions/release/lang/js/publish/action.yml rename to actions/release/lang/js/build-and-ship/action.yml index 3846b9f..af67456 100644 --- a/actions/release/lang/js/publish/action.yml +++ b/actions/release/lang/js/build-and-ship/action.yml @@ -1,11 +1,14 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. -name: Publish JavaScript Package +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Build and Ship JavaScript Package description: > - Full JavaScript package release: checks out the SHA, sets up Node and pnpm, - builds, generates and signs a CycloneDX SBOM (bound to the published tarball), - publishes to npm (OIDC trusted publishing + provenance), creates a GitHub release - (with the SBOM attached), and sends a Slack completion notification. Covers the - standard publish sequence for Braintrust npm packages. + Turnkey JavaScript release — the whole pipeline in one gated job: checks out the + SHA, sets up Node and pnpm, builds, generates and signs a CycloneDX SBOM (bound to + the published tarball via `pnpm pack` == `pnpm publish` bytes), publishes to npm + (OIDC trusted publishing + provenance), creates a GitHub release (with the SBOM + attached), and announces on Slack. This is the `build → attest → publish → release → + announce` pipeline; the custom-build path (pack + ship-package) is the same pipeline + split across two jobs at the build⟷publish seam. inputs: sha: diff --git a/actions/release/lang/js/configure/action.yml b/actions/release/lang/js/configure/action.yml new file mode 100644 index 0000000..09d27a1 --- /dev/null +++ b/actions/release/lang/js/configure/action.yml @@ -0,0 +1,196 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# 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. + +inputs: + sha: + description: "Commit SHA being released" + required: true + version: + description: > + Explicit version override. If provided, the version is not read from package.json. + Use when the version is computed externally (e.g. a run-number auto-bump). + required: false + default: '' + working_directory: + description: "Path to the package root (where package.json lives)" + required: false + default: '.' + tag_format: + description: "Git tag format, with a {version} placeholder (e.g. js-{version})" + required: false + default: 'v{version}' + release_type: + description: > + 'stable' | 'prerelease'. Maps to the channel + github_release defaults + (prerelease → channel 'rc', no GitHub release). Explicit channel/github_release + inputs override this. + required: false + default: 'stable' + channel: + description: "Explicit npm channel (dist-tag) override. Empty → derived from release_type." + required: false + default: '' + github_release: + description: "Explicit github_release override ('true'/'false'). Empty → derived from release_type." + required: false + default: '' + release_branch: + description: "Comma-separated allowed release branch patterns (globs)." + required: false + default: 'main' +outputs: + version: + description: "The resolved package version" + value: ${{ steps.read-version.outputs.version }} + release_tag: + description: "The release tag (e.g. v0.3.2)" + value: ${{ steps.configure.outputs.release_tag }} + prev_release: + description: "The previous release tag" + value: ${{ steps.configure.outputs.prev_release }} + branch: + description: "The branch containing the SHA" + value: ${{ steps.configure.outputs.branch }} + on_release_branch: + description: "Whether the SHA is on an allowed release branch" + value: ${{ steps.configure.outputs.on_release_branch }} + commit_message: + description: "The commit message at the SHA" + value: ${{ steps.configure.outputs.commit_message }} + channel: + description: "The resolved npm channel (dist-tag)" + value: ${{ steps.configure.outputs.channel }} + github_release: + description: "Whether a GitHub release should be created" + value: ${{ steps.configure.outputs.github_release }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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. + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 'lts/*' + + - 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: Validate SHA format + shell: bash + env: + SHA: ${{ inputs.sha }} + run: | + if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." + exit 1 + fi + + - name: Configure release + id: configure + shell: bash + env: + VERSION: ${{ steps.read-version.outputs.version }} + TAG_FORMAT: ${{ inputs.tag_format }} + RELEASE_BRANCH: ${{ inputs.release_branch }} + 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 }}" + GHR_OVERRIDE: "${{ inputs.github_release }}" + run: | + TAG="${TAG_FORMAT//\{version\}/$VERSION}" + COMMIT_MSG=$(git log -1 --format="%s" HEAD) + BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) + + ON_RELEASE_BRANCH=false + IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" + for pattern in "${PATTERNS[@]}"; do + pattern=$(echo "$pattern" | xargs) + if [[ "$BRANCH" == $pattern ]]; then ON_RELEASE_BRANCH=true; break; fi + done + + PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" + PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") + + # channel: explicit override wins; else map from release_type (prerelease→rc, stable→latest) + if [ -n "$CHANNEL_OVERRIDE" ]; then CHANNEL="$CHANNEL_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then CHANNEL="rc" + else CHANNEL="latest"; fi + + # github_release: explicit override wins; else prerelease→false, stable→true + if [ -n "$GHR_OVERRIDE" ]; then GHR="$GHR_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then GHR="false" + else GHR="true"; fi + + { + echo "release_tag=$TAG" + echo "commit_message=$COMMIT_MSG" + echo "branch=$BRANCH" + echo "on_release_branch=$ON_RELEASE_BRANCH" + echo "prev_release=$PREV_RELEASE" + echo "channel=$CHANNEL" + echo "github_release=$GHR" + } >> "$GITHUB_OUTPUT" + echo "Configured $TAG @ $VERSION (channel=$CHANNEL, github_release=$GHR, branch=$BRANCH, on_release_branch=$ON_RELEASE_BRANCH)" + + - name: Dump JavaScript environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== Node environment ===" + node --version + npm --version + echo "=== pnpm ===" + pnpm --version || echo "(unavailable)" + echo "=== package.json ===" + node -e ' + const p = require("./package.json"); + console.log(JSON.stringify({ + name: p.name, + version: p.version, + private: p.private || false, + packageManager: p.packageManager, + engines: p.engines, + devEngines: p.devEngines, + publishConfig: p.publishConfig, + lifecycle: Object.fromEntries( + ["prepublishOnly", "prepack", "prepare", "postpack"] + .filter((k) => p.scripts && p.scripts[k]) + .map((k) => [k, p.scripts[k]]), + ), + }, null, 2)); + ' 2>/dev/null || echo "(package.json unavailable)" diff --git a/actions/release/lang/js/pack-bun/action.yml b/actions/release/lang/js/pack-bun/action.yml new file mode 100644 index 0000000..6fb55f5 --- /dev/null +++ b/actions/release/lang/js/pack-bun/action.yml @@ -0,0 +1,225 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Pack JavaScript Package (custom build, bun) +description: > + Build side of the custom-build ("build-ownership") path for a Bun project — runs in an + UNPRIVILEGED job with NO publish credentials. Sets up Bun, runs `bun build --metafile` + (bundling deps into the artifact), packs the npm tarball (`bun pm pack`), generates a + CycloneDX SBOM FROM the build metafile (so it matches the bundled artifact), and attests + the SBOM + SLSA build provenance to that tarball. The workflow uploads the tarball + SBOM + for the gated ship-package job (shared with pnpm). Requires id-token: write + + attestations: write. pnpm projects use pack-pnpm instead. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before building. Set to false when the caller already checked out." + required: false + default: 'true' + working_directory: + description: "Path to the package root (where package.json lives)" + required: false + default: '.' + bun_version: + description: "Bun version. Empty → auto-detect from the project (.tool-versions / package.json) or latest." + required: false + default: '' + entry: + description: "bun build entrypoint" + required: false + default: 'src/index.ts' + outdir: + description: "bun build output directory" + required: false + default: 'dist' + target: + description: "bun build --target (bun|node|browser)" + required: false + default: 'bun' + format: + description: "bun build --format (esm|cjs|iife)" + required: false + default: 'esm' + sbom: + description: "Generate + attest a CycloneDX SBOM (from the build metafile). Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' +outputs: + tarball: + description: "Absolute path to the packed tarball (the exact bytes ship-package will publish)" + value: ${{ steps.pack.outputs.tarball }} + integrity: + description: "sha512 integrity of the packed tarball" + value: ${{ steps.pack.outputs.integrity }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: ${{ inputs.bun_version }} + - name: Install dependencies + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + bun install --frozen-lockfile || { + echo "::error title=Dependency install failed::'bun install --frozen-lockfile' failed — the lockfile is likely out of sync with package.json (run 'bun install' and commit bun.lock)." + exit 1 + } + + - name: Build (bun) + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + ENTRY: ${{ inputs.entry }} + OUTDIR: ${{ inputs.outdir }} + TARGET: ${{ inputs.target }} + FORMAT: ${{ inputs.format }} + METAFILE: meta.json + run: | + bun build "$ENTRY" --outdir "$OUTDIR" --target "$TARGET" --format "$FORMAT" --metafile="$METAFILE" + echo "Built $OUTDIR (metafile: $METAFILE)" + + - name: Pack (bun pm pack) + id: pack + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + PACK_DIR: ${{ runner.temp }}/pkgout + run: | + rm -rf "$PACK_DIR"; mkdir -p "$PACK_DIR" + bun pm 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" + echo "Packed $TGZ" + + - name: Validate packed tarball + shell: bash + env: + TGZ: ${{ steps.pack.outputs.tarball }} + run: | + if [ ! -f "$TGZ" ]; then + echo "::error title=Package not found::packed tarball does not exist: $TGZ"; exit 1 + fi + PKGJSON=$(tar -xzO -f "$TGZ" package/package.json) + NAME=$(printf '%s' "$PKGJSON" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).name || ""') + VER=$(printf '%s' "$PKGJSON" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).version || ""') + # Count shipped files EXCLUDING package.json — a tarball with only package.json means + # "files"/.npmignore excluded everything that should ship. + CONTENT=$(tar -tzf "$TGZ" | grep '^package/' | grep -vc '^package/package.json$' || true) + + if [ -z "$NAME" ]; then + echo "::error title=Invalid package::the packed tarball has no name."; exit 1 + fi + if [ -z "$VER" ]; then + echo "::error title=Invalid package::the packed tarball has no version."; exit 1 + fi + if [ "$CONTENT" -lt 1 ]; then + echo "::error title=Empty package::the tarball ships only package.json — check the package's \"files\"/.npmignore."; exit 1 + fi + echo "✓ $NAME@$VER — $CONTENT shipped file(s), tarball looks sane" + + - name: Generate SBOM (CycloneDX, from Bun bundle metafile) + if: ${{ inputs.sbom == 'true' }} + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + METAFILE: meta.json + OUTPUT: sbom.json + run: | + # .cjs so it runs as CommonJS even when the package is "type": "module". + cat > "$RUNNER_TEMP/bun-sbom.cjs" <<'CJS' + const fs = require("fs"), crypto = require("crypto"); + const meta = JSON.parse(fs.readFileSync(process.env.METAFILE, "utf8")); + const root = JSON.parse(fs.readFileSync("package.json", "utf8")); + // Collect every node_modules module the bundle actually included. The last + // node_modules segment is the installed package (handles nesting + @scope/name). + const seen = new Map(); + for (const out of Object.values(meta.outputs || {})) { + for (const input of Object.keys(out.inputs || {})) { + const m = input.match(/^(.*node_modules\/(?:@[^/]+\/[^/]+|[^/]+))\//); + if (!m || seen.has(m[1])) continue; + try { + const pj = JSON.parse(fs.readFileSync(m[1] + "/package.json", "utf8")); + if (pj.name && pj.version) seen.set(m[1], { name: pj.name, version: pj.version }); + } catch { /* not a package root; skip */ } + } + } + const purl = (n, v) => `pkg:npm/${n[0] === "@" ? "%40" + n.slice(1) : n}@${v}`; + const components = [...seen.values()] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((c) => ({ type: "library", name: c.name, version: c.version, purl: purl(c.name, c.version) })); + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.7", + // Required: actions/attest recognizes CycloneDX only with bomFormat + specVersion + serialNumber. + serialNumber: `urn:uuid:${crypto.randomUUID()}`, + version: 1, + metadata: { + tools: { components: [{ type: "application", group: "braintrust", name: "sdk-actions bun sbom" }] }, + component: { type: "application", name: root.name, version: root.version, purl: purl(root.name, root.version) }, + }, + components, + }; + fs.writeFileSync(process.env.OUTPUT, JSON.stringify(bom, null, 2)); + console.log(`Wrote ${process.env.OUTPUT}: ${components.length} bundled component(s)`); + CJS + node "$RUNNER_TEMP/bun-sbom.cjs" + + - name: Attest SBOM + if: ${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }} + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: |- + ${{ steps.pack.outputs.tarball }} + sbom-path: ${{ inputs.working_directory }}/sbom.json + + - name: Attest build provenance + if: ${{ inputs.dry_run != 'true' }} + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: |- + ${{ steps.pack.outputs.tarball }} + + - name: Dump JavaScript environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== Node environment ===" + node --version + npm --version + echo "=== pnpm ===" + pnpm --version || echo "(unavailable)" + echo "=== package.json ===" + node -e ' + const p = require("./package.json"); + console.log(JSON.stringify({ + name: p.name, + version: p.version, + private: p.private || false, + packageManager: p.packageManager, + engines: p.engines, + devEngines: p.devEngines, + publishConfig: p.publishConfig, + lifecycle: Object.fromEntries( + ["prepublishOnly", "prepack", "prepare", "postpack"] + .filter((k) => p.scripts && p.scripts[k]) + .map((k) => [k, p.scripts[k]]), + ), + }, null, 2)); + ' 2>/dev/null || echo "(package.json unavailable)" diff --git a/actions/release/lang/js/pack-pnpm/action.yml b/actions/release/lang/js/pack-pnpm/action.yml new file mode 100644 index 0000000..3b8cf22 --- /dev/null +++ b/actions/release/lang/js/pack-pnpm/action.yml @@ -0,0 +1,216 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Pack JavaScript Package (custom build, pnpm) +description: > + Build side of the custom-build ("build-ownership") path for a pnpm project — runs in + an UNPRIVILEGED job with NO publish credentials. Checks out the SHA, builds, packs the + exact tarball (`pnpm pack`), generates a CycloneDX SBOM (`pnpm sbom`), and attests both + the SBOM and SLSA build provenance to that tarball. The workflow uploads the tarball + + SBOM for the gated ship-package job to verify and publish. This is the + `build → pack → attest` prefix of the pipeline; ship-package is the + `publish → release → announce` suffix. Requires id-token: write + attestations: write + (but NOT publish credentials). Bun projects use pack-bun instead. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before packing. Set to false when the caller has already checked out and built." + required: false + default: 'true' + working_directory: + description: "Path to the package root (where package.json lives)" + required: false + default: '.' + node_version: + description: > + Node version (e.g. 24), or a path to a version file + (.tool-versions, .nvmrc, .node-version) relative to the repo root. + required: true + pnpm_version: + description: "pnpm version to install. When empty, read from package.json packageManager." + required: false + default: '' + build: + description: > + Whether to run the build step. Set to 'false' when the consumer's own build + already ran (the common monorepo case) — pack then just packages the output. + required: false + default: 'true' + build_command: + description: "npm script to run for the build (when build is 'true')" + required: false + default: 'build' + sbom: + description: "Generate + attest a CycloneDX SBOM bound to the packed tarball. Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' +outputs: + tarball: + description: "Absolute path to the packed tarball (the exact bytes ship-package will publish)" + value: ${{ steps.pack.outputs.tarball }} + integrity: + description: "sha512 integrity of the packed tarball" + value: ${{ steps.pack.outputs.integrity }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + + - name: Set up pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + # Empty version → action-setup reads the pnpm version from this package.json's + # "packageManager" field (one source of truth). A non-empty value overrides it. + version: ${{ inputs.pnpm_version }} + package_json_file: ${{ inputs.working_directory }}/package.json + + - name: Resolve Node version source + id: node-src + shell: bash + env: + NODE_VERSION: ${{ inputs.node_version }} + run: | + # node_version is either an explicit version (e.g. 24) or a path to a version + # file (.tool-versions / .nvmrc / .node-version). Route by file existence so a + # single input covers both; paths are relative to the repo root (as setup-node expects). + if [ -n "$NODE_VERSION" ] && [ -f "$NODE_VERSION" ]; then + echo "file=$NODE_VERSION" >> "$GITHUB_OUTPUT" + echo "spec=" >> "$GITHUB_OUTPUT" + else + echo "file=" >> "$GITHUB_OUTPUT" + echo "spec=$NODE_VERSION" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ steps.node-src.outputs.spec }} + node-version-file: ${{ steps.node-src.outputs.file }} + registry-url: ${{ inputs.registry }} + cache: pnpm + cache-dependency-path: ${{ inputs.working_directory }}/pnpm-lock.yaml + + - name: Install dependencies + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + pnpm install --frozen-lockfile || { + echo "::error title=Dependency install failed::'pnpm install --frozen-lockfile' failed — the lockfile is likely out of sync with package.json (run 'pnpm install' and commit pnpm-lock.yaml)." + exit 1 + } + + - name: Build + if: ${{ inputs.build == 'true' }} + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + BUILD_COMMAND: ${{ inputs.build_command }} + run: | + pnpm run "$BUILD_COMMAND" + + - name: Pack + id: pack + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + PACK_DIR: ${{ runner.temp }}/pkgout + run: | + rm -rf "$PACK_DIR"; mkdir -p "$PACK_DIR" + 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" + echo "Packed $TGZ" + + - name: Validate packed tarball + shell: bash + env: + TGZ: ${{ steps.pack.outputs.tarball }} + run: | + if [ ! -f "$TGZ" ]; then + echo "::error title=Package not found::packed tarball does not exist: $TGZ"; exit 1 + fi + PKGJSON=$(tar -xzO -f "$TGZ" package/package.json) + NAME=$(printf '%s' "$PKGJSON" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).name || ""') + VER=$(printf '%s' "$PKGJSON" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).version || ""') + # Count shipped files EXCLUDING package.json — a tarball with only package.json means + # "files"/.npmignore excluded everything that should ship. + CONTENT=$(tar -tzf "$TGZ" | grep '^package/' | grep -vc '^package/package.json$' || true) + + if [ -z "$NAME" ]; then + echo "::error title=Invalid package::the packed tarball has no name."; exit 1 + fi + if [ -z "$VER" ]; then + echo "::error title=Invalid package::the packed tarball has no version."; exit 1 + fi + if [ "$CONTENT" -lt 1 ]; then + echo "::error title=Empty package::the tarball ships only package.json — check the package's \"files\"/.npmignore."; exit 1 + fi + echo "✓ $NAME@$VER — $CONTENT shipped file(s), tarball looks sane" + + - name: Generate SBOM (CycloneDX) + if: ${{ inputs.sbom == 'true' }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + 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 + echo "Wrote $(pwd)/sbom.json" + + - name: Attest SBOM + if: ${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }} + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: |- + ${{ steps.pack.outputs.tarball }} + sbom-path: ${{ inputs.working_directory }}/sbom.json + + - name: Attest build provenance + if: ${{ inputs.dry_run != 'true' }} + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: |- + ${{ steps.pack.outputs.tarball }} + + - name: Dump JavaScript environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== Node environment ===" + node --version + npm --version + echo "=== pnpm ===" + pnpm --version || echo "(unavailable)" + echo "=== package.json ===" + node -e ' + const p = require("./package.json"); + console.log(JSON.stringify({ + name: p.name, + version: p.version, + private: p.private || false, + packageManager: p.packageManager, + engines: p.engines, + devEngines: p.devEngines, + publishConfig: p.publishConfig, + lifecycle: Object.fromEntries( + ["prepublishOnly", "prepack", "prepare", "postpack"] + .filter((k) => p.scripts && p.scripts[k]) + .map((k) => [k, p.scripts[k]]), + ), + }, null, 2)); + ' 2>/dev/null || echo "(package.json unavailable)" diff --git a/actions/release/lang/js/ship-package/action.yml b/actions/release/lang/js/ship-package/action.yml new file mode 100644 index 0000000..1cf5c5b --- /dev/null +++ b/actions/release/lang/js/ship-package/action.yml @@ -0,0 +1,482 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Ship JavaScript Package (custom build) +description: > + Publish side of the custom-build ("build-ownership") path — runs in the GATED job. + Verifies the downloaded artifact's attestations (SLSA build provenance + CycloneDX + SBOM) against its own bytes, then publishes THAT exact tarball to npm (OIDC trusted + publishing + provenance), creates a GitHub release (attaching the SBOM), and + announces on Slack. No build, no attest — it ships a prebuilt, attested artifact. + Tamper-evident: any change to the tarball in transit fails verification and blocks + the publish. Requires id-token: write + contents: write. The workflow downloads the + artifact (tarball + sbom.json) into working_directory before calling this action. + +inputs: + tarball: + description: "Path to the downloaded tarball to verify + publish (the exact bytes pack attested)" + required: true + working_directory: + description: "Directory holding the downloaded tarball + sbom.json (used to attach the SBOM to the release)" + required: false + default: '.' + sha: + description: "Commit SHA being released (release target)" + required: true + npm: + description: "Whether to publish to the npm registry" + required: false + default: 'true' + registry: + description: "npm registry URL" + required: false + default: 'https://registry.npmjs.org' + access: + description: "npm publish access: public | restricted (provenance requires public)" + required: false + default: 'public' + provenance: + description: "Publish with npm-native provenance (requires a public package)" + required: false + default: 'true' + channel: + description: "npm release channel (dist-tag) to publish under" + required: false + default: 'latest' + dry_run: + description: "Skip verification, npm publish, tag push, and GitHub release creation" + required: false + default: 'false' + release_tag: + description: "The release tag (e.g. v0.3.2)" + required: true + github_release: + description: "Whether to create a GitHub release" + required: false + default: 'true' + release_title: + description: "GitHub release name/title. Defaults to the release tag when empty." + required: false + default: '' + sbom: + description: "Attach the downloaded sbom.json to the GitHub release" + required: false + default: 'true' + version: + description: "The package version — used to build the npm link in notifications" + required: false + default: '' + package_name: + description: "npm package name — used to build the npm link in notifications" + required: false + default: '' + label: + description: "Display name for release notifications (e.g. the package name)." + required: false + default: '' + notes: + description: "Base64-encoded release notes from the prepare action" + required: false + default: '' + prev_release: + description: "Previous release tag — used in completion notification" + required: false + default: '' + branch: + description: "Branch the release was made from" + required: true + on_release_branch: + description: "Whether the SHA is on an expected release branch" + required: true + pr_list: + description: "x1f-delimited PR list from prepare action" + required: false + default: '' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if the publish fails" + required: false + default: '' + emoji: + description: "Emoji prefix for Slack messages" + required: false + default: ':package:' + +runs: + using: composite + steps: + - name: Verify attestations + if: ${{ inputs.dry_run != 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACTS: ${{ inputs.tarball }} + REPO: ${{ github.repository }} + run: | + shopt -s nullglob + FILES=( $ARTIFACTS ) + if [ ${#FILES[@]} -eq 0 ]; then + echo "::error title=Nothing to verify::no artifact files matched '$ARTIFACTS'." + exit 1 + fi + for f in "${FILES[@]}"; do + echo "Verifying $(basename "$f") — build provenance…" + gh attestation verify "$f" --repo "$REPO" || { + echo "::error title=Provenance verification failed::no build-provenance attestation matches $(basename "$f") — the artifact was not produced by a trusted build, or was tampered with in transit." + exit 1 + } + echo "Verifying $(basename "$f") — SBOM…" + gh attestation verify "$f" --repo "$REPO" --predicate-type https://cyclonedx.org/bom || { + echo "::error title=SBOM verification failed::no CycloneDX SBOM attestation matches $(basename "$f")." + exit 1 + } + done + echo "✓ attestations verified for: ${FILES[*]}" + + # Publishing a prebuilt tarball goes through the npm CLI, so this needs Node with a + # user-writable global prefix (to install a modern npm for OIDC) and the registry + # wired for trusted publishing — but no pnpm and no dependency install. + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 'lts/*' + registry-url: ${{ inputs.registry }} + + - name: Publish prebuilt tarball to npm + shell: bash + env: + TGZ: ${{ inputs.tarball }} + NPM_ENABLED: ${{ inputs.npm }} + CHANNEL: ${{ inputs.channel }} + ACCESS: ${{ inputs.access }} + PROVENANCE: ${{ inputs.provenance }} + REGISTRY: ${{ inputs.registry }} + DRY_RUN: ${{ inputs.dry_run }} + # Force OIDC: clear any inherited token so publish can't fall back to token auth. + NODE_AUTH_TOKEN: '' + NPM_TOKEN: '' + run: | + if [ "$NPM_ENABLED" != "true" ]; then + echo "npm registry publishing disabled; skipping." + exit 0 + fi + # pnpm can't publish a prebuilt tarball (it re-packs from a dir), so ship uses the + # npm CLI directly; pin >= 11.5.1 for OIDC (newer than setup-node's). + npm install -g "npm@11.6.2" + # Publish from the tarball's OWN directory — no package.json there, so a strict-pnpm + # consumer's devEngines onFail:error can't trip (it checks the CWD manifest). A bare + # path with a "/" is also read by npm as a GitHub shorthand, so we cd + use ./. + DIR=$(dirname "$TGZ"); FILE=$(basename "$TGZ") + cd "$DIR" + ARGS=(--tag "$CHANNEL" --access "$ACCESS" --registry "$REGISTRY") + [ "$PROVENANCE" = "true" ] && ARGS+=(--provenance) + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: npm publish ./$FILE ${ARGS[*]} --dry-run" + ARGS+=(--dry-run) + fi + npm publish "./$FILE" "${ARGS[@]}" || { + echo "::error title=npm publish failed::ENEEDAUTH → the npm trusted publisher must list this repo, workflow, and (if gated) environment; 'cannot publish over ' → the version already exists on npm (bump it)." + exit 1 + } + + - name: Create GitHub release + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + ENABLED: ${{ inputs.github_release }} + DRY_RUN: ${{ inputs.dry_run }} + TAG: ${{ inputs.release_tag }} + RELEASE_TITLE: ${{ inputs.release_title }} + NOTES_B64: ${{ inputs.notes }} + SHA: ${{ inputs.sha }} + SBOM_ENABLED: ${{ inputs.sbom }} + WORKING_DIRECTORY: ${{ inputs.working_directory }} + run: | + if [ "$ENABLED" != "true" ]; then + echo "GitHub release disabled; skipping." + exit 0 + fi + # Release name defaults to the tag when no explicit title is provided. + TITLE="${RELEASE_TITLE:-$TAG}" + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would create GitHub release $TAG (title: $TITLE)" + echo "--- Release notes preview ---" + 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. + if [ "$SBOM_ENABLED" = "true" ] && [ -f "$WORKING_DIRECTORY/sbom.json" ]; then + gh release upload "$TAG" "$WORKING_DIRECTORY/sbom.json" --clobber + fi + fi + + - name: Build npm links + id: links + shell: bash + env: + PACKAGE_NAME: ${{ inputs.package_name }} + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + LINKS='[]' + if [ "$DRY_RUN" != "true" ] && [ -n "$PACKAGE_NAME" ] && [ -n "$VERSION" ]; then + LINKS="[{\"label\":\"npm\",\"url\":\"https://www.npmjs.com/package/$PACKAGE_NAME/v/$VERSION\"}]" + fi + echo "links=$LINKS" >> $GITHUB_OUTPUT + + - name: Post release summary + shell: bash + env: + TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + PREV_RELEASE: ${{ inputs.prev_release }} + BRANCH: ${{ inputs.branch }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + DRY_RUN: ${{ inputs.dry_run }} + LINKS_JSON: ${{ steps.links.outputs.links }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" + + # Subject is the package label when set (disambiguates repos that publish + # multiple packages); otherwise the repository. The repo is still present in + # every link below, so it is never lost. + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + if [ "$DRY_RUN" = "true" ]; then + echo "## $SUBJECT $TAG — dry run complete" >> $GITHUB_STEP_SUMMARY + else + echo "## $SUBJECT $TAG — published ✅" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "$DRY_RUN" = "true" ]; then + echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY + echo "> Dry run: Nothing was tagged or published." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY + echo "> Released from non-release branch: [$BRANCH]($BRANCH_URL)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + echo "**Branch:** [$BRANCH]($BRANCH_URL)" >> $GITHUB_STEP_SUMMARY + + if [ -n "$PREV_RELEASE" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...$TAG" + echo "**Diff:** [$PREV_RELEASE...$TAG]($DIFF_URL)" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Published to" >> $GITHUB_STEP_SUMMARY + + if [ "$DRY_RUN" = "true" ]; then + echo "- [View dry run]($RUN_URL)" >> $GITHUB_STEP_SUMMARY + else + echo "- [GitHub Release]($RELEASE_URL)" >> $GITHUB_STEP_SUMMARY + if [ -n "$LINKS_JSON" ] && [ "$LINKS_JSON" != "[]" ]; then + echo "$LINKS_JSON" | jq -r '.[] | "- [\(.label)](\(.url))"' >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Build published message + id: message-published + shell: bash + env: + TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + EMOJI: ${{ inputs.emoji }} + BRANCH: ${{ inputs.branch }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + PREV_RELEASE: ${{ inputs.prev_release }} + DRY_RUN: ${{ inputs.dry_run }} + SLACK_MENTION: ${{ inputs.slack_mention }} + LINKS_JSON: ${{ steps.links.outputs.links }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + PR_LIST_RAW: ${{ inputs.pr_list }} + run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + + # Subject is the package label when set, else the repository (see summary step). + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + DIFF_PART="" + if [ -n "$PREV_RELEASE" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...$TAG" + DIFF_PART="<$DIFF_URL|${PREV_RELEASE}...$TAG> · " + fi + + PR_LIST=$(echo "$PR_LIST_RAW" | tr $'\x1f' '\n' | sed '/^$/d') + + if [ "$DRY_RUN" = "true" ]; then + TEXT="$EMOJI *$SUBJECT $TAG* complete" + TEXT="$TEXT\n> :information_source: _Dry run: nothing tagged or published._" + else + TEXT="$EMOJI *$SUBJECT $TAG* published" + 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>" + fi + + if [ "$DRY_RUN" = "true" ]; then + TEXT="$TEXT\n${DIFF_PART}<$RUN_URL|View dry run>" + else + SLACK_LINKS="${DIFF_PART}<$RELEASE_URL|View release>" + if [ -n "$LINKS_JSON" ] && [ "$LINKS_JSON" != "[]" ]; then + EXTRA=$(echo "$LINKS_JSON" | jq -r '[.[] | "<\(.url)|\(.label)>"] | join(" · ")') + SLACK_LINKS="$SLACK_LINKS · $EXTRA" + fi + TEXT="$TEXT\n$SLACK_LINKS" + fi + + if [ -n "$PR_LIST" ]; then + TEXT="$TEXT\n$PR_LIST" + fi + + if [ -n "$SLACK_MENTION" ]; then + TEXT="$TEXT\n$SLACK_MENTION" + fi + + { echo 'text<> $GITHUB_OUTPUT + + - name: Send Slack message + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-published.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi + + - name: Dump JavaScript environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== Node environment ===" + node --version + npm --version + echo "=== pnpm ===" + pnpm --version || echo "(unavailable)" + echo "=== package.json ===" + node -e ' + const p = require("./package.json"); + console.log(JSON.stringify({ + name: p.name, + version: p.version, + private: p.private || false, + packageManager: p.packageManager, + engines: p.engines, + devEngines: p.devEngines, + publishConfig: p.publishConfig, + lifecycle: Object.fromEntries( + ["prepublishOnly", "prepack", "prepare", "postpack"] + .filter((k) => p.scripts && p.scripts[k]) + .map((k) => [k, p.scripts[k]]), + ), + }, null, 2)); + ' 2>/dev/null || echo "(package.json unavailable)" + + - name: Build failure message + if: ${{ failure() }} + id: message-failure + shell: bash + env: + RELEASE_TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + STEP: publish + SLACK_MENTION: ${{ inputs.failure_slack_mention }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ACTOR_LINK: <${{ github.server_url }}/${{ github.actor }}|${{ github.actor }}> + run: | + # Subject is the package label when set, else the repository. + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + TEXT=":x: *$SUBJECT $RELEASE_TAG* failed at $STEP" + TEXT="$TEXT\nInitiated by: $ACTOR_LINK" + + if [ -n "$SLACK_MENTION" ]; then + TEXT="$TEXT $SLACK_MENTION" + fi + + TEXT="$TEXT\n<$RUN_URL|View failed run>" + + { echo 'text<> $GITHUB_OUTPUT + + - name: Send Slack message + if: ${{ failure() }} + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-failure.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi diff --git a/actions/release/lang/js/validate/action.yml b/actions/release/lang/js/validate/action.yml index df16d4c..b883461 100644 --- a/actions/release/lang/js/validate/action.yml +++ b/actions/release/lang/js/validate/action.yml @@ -1,25 +1,24 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} name: Validate JavaScript Release description: > - Checks out the release SHA, sets up Node and pnpm, reads the - package version, validates the release channel and metadata (tag existence, - branch, commit metadata), builds, and generates a CycloneDX SBOM — all to - confirm the package is publishable (and the SBOM tooling works) before the - approval gate. + Judges a JavaScript release against the facts `configure` derived — the toggleable + checks (tag unused, version unpublished, channel allowed, notes present, release + 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. inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Skip tag existence failure in dry runs" + description: "Warn instead of failing on an already-existing tag" required: false default: 'false' version: - description: > - Explicit version override. If provided, the version is not read from - package.json. Use when the version is computed externally (e.g. bt-publishing-test - auto-bump from run number). + description: "Version override (from configure). If empty, read from package.json." required: false default: '' working_directory: @@ -41,27 +40,33 @@ inputs: default: 'https://registry.npmjs.org' package_name: description: > - npm package name (matches the publish action's input). When set, validate fails - fast if this version is already published (covers prereleases, which aren't - git-tagged). Leave empty to skip. + 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: '' - tag_format: - description: "Git tag format, with a {version} placeholder (e.g. js-{version})" - required: false - default: 'v{version}' + release_tag: + description: "The release tag (from configure) — checked for prior existence" + required: true channel: - description: "npm release channel (dist-tag) this release targets" + description: "The npm channel/dist-tag (from configure) — checked against allowed_channels" required: false default: 'latest' allowed_channels: description: "Comma-separated allowlist of accepted release channels" required: false default: 'latest' + on_release_branch: + description: "Whether the SHA is on an allowed release branch (from configure)" + required: false + default: 'true' + notes: + description: "Base64-encoded release notes (from prepare) — checked non-blank" + required: false + default: '' build: description: > - Whether to run the build step. Set to 'false' for packages that publish - their sources directly (no build) — the build step then no-ops. + Whether to run the build step. Set to 'false' for packages that publish their + sources directly (no build) — the build step then no-ops. required: false default: 'true' build_command: @@ -70,30 +75,31 @@ inputs: default: 'build' sbom: description: > - Generate a CycloneDX SBOM as a pre-gate check (same generator as publish), so - SBOM-tooling failures (e.g. pnpm < 11.8) surface BEFORE the approval gate. The - SBOM is not signed or attached here — that happens in publish. + Generate a CycloneDX SBOM as a pre-gate check (same generator as build-and-ship), + so SBOM-tooling failures surface BEFORE the approval gate. Not signed or attached + here — that happens in build-and-ship. required: false default: 'true' -outputs: - release_tag: - description: "The release tag (e.g. v0.3.2)" - value: ${{ steps.validate.outputs.release_tag }} - prev_release: - description: "The previous release tag" - value: ${{ steps.validate.outputs.prev_release }} - branch: - description: "The branch containing the SHA" - value: ${{ steps.validate.outputs.branch }} - on_release_branch: - description: "Whether the SHA is on an expected release branch" - value: ${{ steps.validate.outputs.on_release_branch }} - commit_message: - description: "The commit message at the SHA" - value: ${{ steps.validate.outputs.commit_message }} - version: - description: "The resolved package version" - value: ${{ steps.read-version.outputs.version }} + check_tag_unused: + description: "Fail if the release tag already exists" + required: false + default: 'true' + check_version_unpublished: + description: "Fail if the version is already published to the registry" + required: false + default: 'true' + check_channel_allowed: + description: "Fail if channel is not in allowed_channels" + required: false + default: 'true' + check_notes_not_blank: + description: "Warn if release notes are blank" + required: false + default: 'true' + enforce_release_branch: + description: "Hard-fail (instead of warn) when the SHA is off the release branch" + required: false + default: 'false' runs: using: composite @@ -165,6 +171,7 @@ runs: echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Check npm version availability + if: ${{ inputs.check_version_unpublished == 'true' }} shell: bash env: PACKAGE_NAME: ${{ inputs.package_name }} @@ -194,6 +201,7 @@ runs: esac - name: Validate release channel + if: ${{ inputs.check_channel_allowed == 'true' }} shell: bash env: CHANNEL: ${{ inputs.channel }} @@ -206,31 +214,19 @@ runs: echo "::error title=Release channel not allowed::release channel '$CHANNEL' is not in the allowed set: $ALLOWED" exit 1 - - name: Validate SHA format - shell: bash - env: - SHA: ${{ inputs.sha }} - run: | - if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then - echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." - exit 1 - fi - - - name: Validate release - id: validate + - name: Check release shell: bash env: - VERSION: ${{ steps.read-version.outputs.version }} - TAG_FORMAT: ${{ inputs.tag_format }} + TAG: ${{ inputs.release_tag }} DRY_RUN: ${{ inputs.dry_run }} - RELEASE_BRANCH: 'main' - SHA: ${{ inputs.sha }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + NOTES_B64: ${{ inputs.notes }} + CHECK_TAG_UNUSED: ${{ inputs.check_tag_unused }} + CHECK_NOTES: ${{ inputs.check_notes_not_blank }} + ENFORCE_RELEASE_BRANCH: ${{ inputs.enforce_release_branch }} run: | - TAG="${TAG_FORMAT//\{version\}/$VERSION}" - COMMIT_MSG=$(git log -1 --format="%s" HEAD) - BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) - - if git rev-parse "$TAG" >/dev/null 2>&1; then + # 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 echo "::warning title=Release tag already exists::Tag $TAG already exists — skipping in dry run." else @@ -239,25 +235,23 @@ runs: fi fi - ON_RELEASE_BRANCH=false - IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" - for pattern in "${PATTERNS[@]}"; do - pattern=$(echo "$pattern" | xargs) - if [[ "$BRANCH" == $pattern ]]; then - ON_RELEASE_BRANCH=true - break + # On a release branch: warn by default, hard-fail only when enforced. + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + if [ "$ENFORCE_RELEASE_BRANCH" = "true" ]; then + echo "::error title=Off release branch::the release SHA is not on an allowed release branch (enforce_release_branch=true)." + exit 1 + else + echo "::warning title=Off release branch::the release SHA is not on an allowed release branch — releasing anyway." fi - done - - PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" - PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") + fi - echo "release_tag=$TAG" >> $GITHUB_OUTPUT - echo "commit_message=$COMMIT_MSG" >> $GITHUB_OUTPUT - echo "branch=$BRANCH" >> $GITHUB_OUTPUT - echo "on_release_branch=$ON_RELEASE_BRANCH" >> $GITHUB_OUTPUT - echo "prev_release=$PREV_RELEASE" >> $GITHUB_OUTPUT - echo "Validated $TAG @ $SHA ($COMMIT_MSG)" + # Release notes present (warn only — a genuine first release, or no PRs since the last tag, is legitimate). + if [ "$CHECK_NOTES" = "true" ]; then + NOTES=$(echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "") + if [ -z "$NOTES" ]; then + echo "::warning title=Empty release notes::no release notes were generated (first release, or no PRs since the last tag?)." + fi + fi - name: Build if: ${{ inputs.build == 'true' }} diff --git a/actions/release/lang/py/publish/action.yml b/actions/release/lang/py/build-and-ship/action.yml similarity index 96% rename from actions/release/lang/py/publish/action.yml rename to actions/release/lang/py/build-and-ship/action.yml index 79890f7..6890027 100644 --- a/actions/release/lang/py/publish/action.yml +++ b/actions/release/lang/py/build-and-ship/action.yml @@ -1,12 +1,13 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. -name: Publish Python Package +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Build and Ship Python Package description: > - Full Python package release: checks out the SHA, sets up uv and Python, - builds the sdist + wheel, generates and signs a CycloneDX SBOM (bound to the - built dists), publishes to PyPI (OIDC Trusted Publishing with PEP 740 - attestations), creates a GitHub release (with the SBOM attached), and sends a - Slack completion notification. Covers the standard publish sequence for - Braintrust PyPI packages. + Turnkey Python release — the whole pipeline in one gated job: checks out the SHA, + sets up uv and Python, builds the sdist + wheel, generates and signs a CycloneDX SBOM + (bound to the built dists), publishes to PyPI (OIDC Trusted Publishing with PEP 740 + attestations), creates a GitHub release (with the SBOM attached), and announces on + Slack. This is the `build → attest → publish → release → announce` pipeline; the + custom-build path (pack + ship-package) is the same pipeline split across two jobs. inputs: sha: @@ -146,6 +147,8 @@ runs: # Installs this Python and exports UV_PYTHON for the job, so subsequent uv # commands (build, read-version) use it. python-version: ${{ steps.py-src.outputs.spec }} + # Don't fail the job when there's nothing to cache (jobs that install no deps). + ignore-nothing-to-cache: "true" - name: Build distribution shell: bash diff --git a/actions/release/lang/py/configure/action.yml b/actions/release/lang/py/configure/action.yml new file mode 100644 index 0000000..2cfcdcc --- /dev/null +++ b/actions/release/lang/py/configure/action.yml @@ -0,0 +1,197 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# 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. + +inputs: + sha: + description: "Commit SHA being released" + required: true + version: + description: > + Explicit version override. If provided, the version is not read from version_file. + Use when the version is computed externally (e.g. a run-number auto-bump). + required: false + default: '' + version_file: + description: > + Path (repo-root-relative) to a Python module exposing VERSION (e.g. + py/pkg/version.py). Read when version is not overridden. + required: false + default: '' + working_directory: + description: "Path to the package root (where pyproject.toml lives)" + required: false + default: '.' + tag_format: + description: "Git tag format, with a {version} placeholder (e.g. py-{version})" + required: false + default: 'py-{version}' + release_type: + description: > + 'stable' | 'prerelease'. Maps to the github_release default (prerelease → no + GitHub release). An explicit github_release input overrides this. + required: false + default: 'stable' + github_release: + description: "Explicit github_release override ('true'/'false'). Empty → derived from release_type." + required: false + default: '' + release_branch: + description: "Comma-separated allowed release branch patterns (globs)." + required: false + default: 'main' +outputs: + version: + description: "The resolved package version" + value: ${{ steps.read-version.outputs.version }} + release_tag: + description: "The release tag (e.g. py-0.3.2)" + value: ${{ steps.configure.outputs.release_tag }} + prev_release: + description: "The previous release tag" + value: ${{ steps.configure.outputs.prev_release }} + branch: + description: "The branch containing the SHA" + value: ${{ steps.configure.outputs.branch }} + on_release_branch: + description: "Whether the SHA is on an allowed release branch" + value: ${{ steps.configure.outputs.on_release_branch }} + commit_message: + description: "The commit message at the SHA" + value: ${{ steps.configure.outputs.commit_message }} + github_release: + description: "Whether a GitHub release should be created" + value: ${{ steps.configure.outputs.github_release }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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. + - 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 + 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: Validate SHA format + shell: bash + env: + SHA: ${{ inputs.sha }} + run: | + if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." + exit 1 + fi + + - name: Configure release + id: configure + shell: bash + env: + VERSION: ${{ steps.read-version.outputs.version }} + TAG_FORMAT: ${{ inputs.tag_format }} + RELEASE_BRANCH: ${{ inputs.release_branch }} + 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: "" + GHR_OVERRIDE: "${{ inputs.github_release }}" + run: | + TAG="${TAG_FORMAT//\{version\}/$VERSION}" + COMMIT_MSG=$(git log -1 --format="%s" HEAD) + BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) + + ON_RELEASE_BRANCH=false + IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" + for pattern in "${PATTERNS[@]}"; do + pattern=$(echo "$pattern" | xargs) + if [[ "$BRANCH" == $pattern ]]; then ON_RELEASE_BRANCH=true; break; fi + done + + PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" + PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") + + # channel: explicit override wins; else map from release_type (prerelease→rc, stable→latest) + if [ -n "$CHANNEL_OVERRIDE" ]; then CHANNEL="$CHANNEL_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then CHANNEL="rc" + else CHANNEL="latest"; fi + + # github_release: explicit override wins; else prerelease→false, stable→true + if [ -n "$GHR_OVERRIDE" ]; then GHR="$GHR_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then GHR="false" + else GHR="true"; fi + + { + echo "release_tag=$TAG" + echo "commit_message=$COMMIT_MSG" + echo "branch=$BRANCH" + echo "on_release_branch=$ON_RELEASE_BRANCH" + echo "prev_release=$PREV_RELEASE" + echo "channel=$CHANNEL" + echo "github_release=$GHR" + } >> "$GITHUB_OUTPUT" + echo "Configured $TAG @ $VERSION (channel=$CHANNEL, github_release=$GHR, branch=$BRANCH, on_release_branch=$ON_RELEASE_BRANCH)" + + - name: Dump Python environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== uv / Python ===" + uv --version || echo "(uv unavailable)" + uv run --no-project python --version || echo "(python unavailable)" + echo "=== UV_PYTHON ===" + echo "${UV_PYTHON:-(unset)}" + echo "=== Built artifacts ===" + ls -la dist 2>/dev/null || echo "(no dist/)" + echo "=== pyproject.toml (release-relevant) ===" + uv run --no-project python -c ' + import json, tomllib, pathlib + t = tomllib.loads(pathlib.Path("pyproject.toml").read_text()) + proj = t.get("project", {}) + print(json.dumps({ + "name": proj.get("name"), + "version": proj.get("version") or ("(dynamic)" if "version" in proj.get("dynamic", []) else None), + "requires-python": proj.get("requires-python"), + "dependencies": proj.get("dependencies"), + "optional-dependencies": sorted((proj.get("optional-dependencies") or {}).keys()), + "build-system": t.get("build-system"), + }, indent=2)) + ' 2>/dev/null || echo "(pyproject.toml unavailable)" diff --git a/actions/release/lang/py/pack/action.yml b/actions/release/lang/py/pack/action.yml new file mode 100644 index 0000000..a561082 --- /dev/null +++ b/actions/release/lang/py/pack/action.yml @@ -0,0 +1,151 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Pack Python Package (custom build) +description: > + Custom-build side (unprivileged — no publish credentials): checks out the SHA, builds + the sdist + wheel, generates a CycloneDX SBOM, and attests the SBOM + SLSA build + provenance to those dists. The workflow uploads dist/* + sbom.json for the gated + ship-package job. Requires id-token: write + attestations: write. + + Note: unlike turnkey py (which relies on PyPI's native PEP 740 provenance), the custom + path also emits our own build provenance — ship-package verifies it against the + downloaded dists before publishing. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before building. Set to false when the caller already checked out." + required: false + default: 'true' + working_directory: + description: "Path to the package root (where pyproject.toml lives)" + required: false + default: '.' + python_version: + description: > + Python version (e.g. 3.13), or a path to a version file + (.python-version, .tool-versions) relative to the repo root. + required: true + build_command: + description: "Override build command. When empty, `uv build` is used." + required: false + default: '' + sbom: + description: "Generate + attest a CycloneDX SBOM bound to the dists. Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + + - name: Resolve Python version source + id: py-src + shell: bash + env: + PYTHON_VERSION: ${{ inputs.python_version }} + run: | + # python_version is either an explicit version (e.g. 3.13) or a path to a + # version file (.python-version / .tool-versions). Route by file existence. + if [ -n "$PYTHON_VERSION" ] && [ -f "$PYTHON_VERSION" ]; then + case "$PYTHON_VERSION" in + *.tool-versions) SPEC=$(awk '/^python /{print $2; exit}' "$PYTHON_VERSION") ;; + *) SPEC=$(head -1 "$PYTHON_VERSION" | tr -d '[:space:]') ;; + esac + else + SPEC="$PYTHON_VERSION" + fi + echo "spec=$SPEC" >> "$GITHUB_OUTPUT" + + - name: Set up uv + uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7.2.0 + with: + # Installs this Python and exports UV_PYTHON for the job, so subsequent uv + # commands (build, read-version) use it. + python-version: ${{ steps.py-src.outputs.spec }} + # Don't fail the job when there's nothing to cache (jobs that install no deps). + ignore-nothing-to-cache: "true" + + - name: Build distribution + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + BUILD_COMMAND: ${{ inputs.build_command }} + run: | + rm -rf dist + if [ -n "$BUILD_COMMAND" ]; then + echo "Running custom build: $BUILD_COMMAND" + eval "$BUILD_COMMAND" + else + uv build + fi + uvx twine check dist/* + + - name: Generate SBOM (CycloneDX) + if: ${{ inputs.sbom == 'true' }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + # cyclonedx-py can't read uv lockfiles, so scan the wheel's installed closure: + # install the built wheel into a throwaway venv, then generate from that env. + SBOM_VENV="$(mktemp -d)/venv" + uv venv "$SBOM_VENV" + uv pip install --python "$SBOM_VENV/bin/python" dist/*.whl + uvx --from "cyclonedx-bom==7.3.0" cyclonedx-py environment "$SBOM_VENV" \ + --spec-version 1.7 --output-format JSON --output-file sbom.json + echo "Wrote $(pwd)/sbom.json" + + - name: Attest SBOM + if: ${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }} + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: |- + ${{ inputs.working_directory }}/dist/*.whl + ${{ inputs.working_directory }}/dist/*.tar.gz + sbom-path: ${{ inputs.working_directory }}/sbom.json + + - name: Attest build provenance + if: ${{ inputs.dry_run != 'true' }} + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: |- + ${{ inputs.working_directory }}/dist/*.whl + ${{ inputs.working_directory }}/dist/*.tar.gz + + - name: Dump Python environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== uv / Python ===" + uv --version || echo "(uv unavailable)" + uv run --no-project python --version || echo "(python unavailable)" + echo "=== UV_PYTHON ===" + echo "${UV_PYTHON:-(unset)}" + echo "=== Built artifacts ===" + ls -la dist 2>/dev/null || echo "(no dist/)" + echo "=== pyproject.toml (release-relevant) ===" + uv run --no-project python -c ' + import json, tomllib, pathlib + t = tomllib.loads(pathlib.Path("pyproject.toml").read_text()) + proj = t.get("project", {}) + print(json.dumps({ + "name": proj.get("name"), + "version": proj.get("version") or ("(dynamic)" if "version" in proj.get("dynamic", []) else None), + "requires-python": proj.get("requires-python"), + "dependencies": proj.get("dependencies"), + "optional-dependencies": sorted((proj.get("optional-dependencies") or {}).keys()), + "build-system": t.get("build-system"), + }, indent=2)) + ' 2>/dev/null || echo "(pyproject.toml unavailable)" diff --git a/actions/release/lang/py/ship-package/action.yml b/actions/release/lang/py/ship-package/action.yml new file mode 100644 index 0000000..c978293 --- /dev/null +++ b/actions/release/lang/py/ship-package/action.yml @@ -0,0 +1,453 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Ship Python Package (custom build) +description: > + Custom-build gated publish side: verifies the downloaded dists' attestations against + their own bytes, publishes them to PyPI (OIDC Trusted Publishing + PEP 740), creates a + GitHub release (attaching the SBOM), and announces. No build, no attest — it ships + prebuilt, attested dists. Requires id-token: write + contents: write. The workflow + downloads dist/* (+ sbom.json) into working_directory first. + +inputs: + working_directory: + description: "Directory holding the downloaded dist/ (whl + sdist) and sbom.json" + required: false + default: '.' + sha: + description: "Commit SHA being released (release target)" + required: true + pypi: + description: "Whether to publish to PyPI" + required: false + default: 'true' + attestations: + description: "Publish PEP 740 attestations (Sigstore-signed provenance). Requires id-token: write." + required: false + default: 'true' + dry_run: + description: "Skip verification, PyPI upload, tag push, and GitHub release creation" + required: false + default: 'false' + release_tag: + description: "The release tag (e.g. py-0.3.2)" + required: true + github_release: + description: "Whether to create a GitHub release" + required: false + default: 'true' + release_title: + description: "GitHub release name/title. Defaults to the release tag when empty." + required: false + default: '' + sbom: + description: "Attach the downloaded sbom.json to the GitHub release" + required: false + default: 'true' + version: + description: "The package version — used to build the PyPI link in notifications" + required: false + default: '' + package_name: + description: "PyPI package name — used to build the PyPI link in notifications" + required: false + default: '' + label: + description: "Display name for release notifications (e.g. the package name)." + required: false + default: '' + notes: + description: "Base64-encoded release notes from the prepare action" + required: false + default: '' + prev_release: + description: "Previous release tag — used in completion notification" + required: false + default: '' + branch: + description: "Branch the release was made from" + required: true + on_release_branch: + description: "Whether the SHA is on an expected release branch" + required: true + pr_list: + description: "x1f-delimited PR list from prepare action" + required: false + default: '' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if the publish fails" + required: false + default: '' + emoji: + description: "Emoji prefix for Slack messages" + required: false + default: ':python:' + +runs: + using: composite + steps: + # Set up a default uv/Python so `uv publish` + PEP 740 signing work; no build here. + - name: Set up uv + uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7.2.0 + with: + python-version: '3.13' + + - name: Verify attestations + if: ${{ inputs.dry_run != 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACTS: ${{ inputs.working_directory }}/dist/*.whl ${{ inputs.working_directory }}/dist/*.tar.gz + REPO: ${{ github.repository }} + run: | + shopt -s nullglob + FILES=( $ARTIFACTS ) + if [ ${#FILES[@]} -eq 0 ]; then + echo "::error title=Nothing to verify::no artifact files matched '$ARTIFACTS'." + exit 1 + fi + for f in "${FILES[@]}"; do + echo "Verifying $(basename "$f") — build provenance…" + gh attestation verify "$f" --repo "$REPO" || { + echo "::error title=Provenance verification failed::no build-provenance attestation matches $(basename "$f") — the artifact was not produced by a trusted build, or was tampered with in transit." + exit 1 + } + echo "Verifying $(basename "$f") — SBOM…" + gh attestation verify "$f" --repo "$REPO" --predicate-type https://cyclonedx.org/bom || { + echo "::error title=SBOM verification failed::no CycloneDX SBOM attestation matches $(basename "$f")." + exit 1 + } + done + echo "✓ attestations verified for: ${FILES[*]}" + + - name: Publish to PyPI + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + PYPI_ENABLED: ${{ inputs.pypi }} + DRY_RUN: ${{ inputs.dry_run }} + ATTESTATIONS: ${{ inputs.attestations }} + run: | + if [ "$PYPI_ENABLED" != "true" ]; then + echo "PyPI publishing disabled; skipping." + exit 0 + fi + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: artifacts built and twine-checked; not uploaded to PyPI." + exit 0 + fi + # --trusted-publishing always: require OIDC (fail loudly rather than fall back + # to token auth). + ARGS=(--trusted-publishing always) + if [ "$ATTESTATIONS" = "true" ]; then + # uv uploads attestations but does not generate them; sign first (Sigstore, + # ambient GitHub OIDC) so the *.publish.attestation files exist for upload. + uvx pypi-attestations sign dist/*.whl dist/*.tar.gz + else + ARGS+=(--no-attestations) + fi + uv publish "${ARGS[@]}" || { + echo "::error title=PyPI publish failed::auth/OIDC error → the PyPI trusted publisher must list this repo, workflow, and (if gated) environment; a 400 'File already exists' → the version is already on PyPI (bump it)." + exit 1 + } + + - name: Create GitHub release + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + ENABLED: ${{ inputs.github_release }} + DRY_RUN: ${{ inputs.dry_run }} + TAG: ${{ inputs.release_tag }} + RELEASE_TITLE: ${{ inputs.release_title }} + NOTES_B64: ${{ inputs.notes }} + SHA: ${{ inputs.sha }} + SBOM_ENABLED: ${{ inputs.sbom }} + WORKING_DIRECTORY: ${{ inputs.working_directory }} + run: | + if [ "$ENABLED" != "true" ]; then + echo "GitHub release disabled; skipping." + exit 0 + fi + # Release name defaults to the tag when no explicit title is provided. + TITLE="${RELEASE_TITLE:-$TAG}" + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would create GitHub release $TAG (title: $TITLE)" + echo "--- Release notes preview ---" + 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. + if [ "$SBOM_ENABLED" = "true" ] && [ -f "$WORKING_DIRECTORY/sbom.json" ]; then + gh release upload "$TAG" "$WORKING_DIRECTORY/sbom.json" --clobber + fi + fi + + - name: Build PyPI links + id: links + shell: bash + env: + PACKAGE_NAME: ${{ inputs.package_name }} + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + LINKS='[]' + if [ "$DRY_RUN" != "true" ] && [ -n "$PACKAGE_NAME" ] && [ -n "$VERSION" ]; then + LINKS="[{\"label\":\"PyPI\",\"url\":\"https://pypi.org/project/$PACKAGE_NAME/$VERSION/\"}]" + fi + echo "links=$LINKS" >> "$GITHUB_OUTPUT" + + - name: Post release summary + shell: bash + env: + TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + PREV_RELEASE: ${{ inputs.prev_release }} + BRANCH: ${{ inputs.branch }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + DRY_RUN: ${{ inputs.dry_run }} + LINKS_JSON: ${{ steps.links.outputs.links }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" + + # Subject is the package label when set (disambiguates repos that publish + # multiple packages); otherwise the repository. The repo is still present in + # every link below, so it is never lost. + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + if [ "$DRY_RUN" = "true" ]; then + echo "## $SUBJECT $TAG — dry run complete" >> $GITHUB_STEP_SUMMARY + else + echo "## $SUBJECT $TAG — published ✅" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "$DRY_RUN" = "true" ]; then + echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY + echo "> Dry run: Nothing was tagged or published." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY + echo "> Released from non-release branch: [$BRANCH]($BRANCH_URL)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + echo "**Branch:** [$BRANCH]($BRANCH_URL)" >> $GITHUB_STEP_SUMMARY + + if [ -n "$PREV_RELEASE" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...$TAG" + echo "**Diff:** [$PREV_RELEASE...$TAG]($DIFF_URL)" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Published to" >> $GITHUB_STEP_SUMMARY + + if [ "$DRY_RUN" = "true" ]; then + echo "- [View dry run]($RUN_URL)" >> $GITHUB_STEP_SUMMARY + else + echo "- [GitHub Release]($RELEASE_URL)" >> $GITHUB_STEP_SUMMARY + if [ -n "$LINKS_JSON" ] && [ "$LINKS_JSON" != "[]" ]; then + echo "$LINKS_JSON" | jq -r '.[] | "- [\(.label)](\(.url))"' >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Build published message + id: message-published + shell: bash + env: + TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + EMOJI: ${{ inputs.emoji }} + BRANCH: ${{ inputs.branch }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + PREV_RELEASE: ${{ inputs.prev_release }} + DRY_RUN: ${{ inputs.dry_run }} + SLACK_MENTION: ${{ inputs.slack_mention }} + LINKS_JSON: ${{ steps.links.outputs.links }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + PR_LIST_RAW: ${{ inputs.pr_list }} + run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + + # Subject is the package label when set, else the repository (see summary step). + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + DIFF_PART="" + if [ -n "$PREV_RELEASE" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...$TAG" + DIFF_PART="<$DIFF_URL|${PREV_RELEASE}...$TAG> · " + fi + + PR_LIST=$(echo "$PR_LIST_RAW" | tr $'\x1f' '\n' | sed '/^$/d') + + if [ "$DRY_RUN" = "true" ]; then + TEXT="$EMOJI *$SUBJECT $TAG* complete" + TEXT="$TEXT\n> :information_source: _Dry run: nothing tagged or published._" + else + TEXT="$EMOJI *$SUBJECT $TAG* published" + 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>" + fi + + if [ "$DRY_RUN" = "true" ]; then + TEXT="$TEXT\n${DIFF_PART}<$RUN_URL|View dry run>" + else + SLACK_LINKS="${DIFF_PART}<$RELEASE_URL|View release>" + if [ -n "$LINKS_JSON" ] && [ "$LINKS_JSON" != "[]" ]; then + EXTRA=$(echo "$LINKS_JSON" | jq -r '[.[] | "<\(.url)|\(.label)>"] | join(" · ")') + SLACK_LINKS="$SLACK_LINKS · $EXTRA" + fi + TEXT="$TEXT\n$SLACK_LINKS" + fi + + if [ -n "$PR_LIST" ]; then + TEXT="$TEXT\n$PR_LIST" + fi + + if [ -n "$SLACK_MENTION" ]; then + TEXT="$TEXT\n$SLACK_MENTION" + fi + + { echo 'text<> $GITHUB_OUTPUT + + - name: Send Slack message + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-published.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi + + - name: Dump Python environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== uv / Python ===" + uv --version || echo "(uv unavailable)" + uv run --no-project python --version || echo "(python unavailable)" + echo "=== UV_PYTHON ===" + echo "${UV_PYTHON:-(unset)}" + echo "=== Built artifacts ===" + ls -la dist 2>/dev/null || echo "(no dist/)" + echo "=== pyproject.toml (release-relevant) ===" + uv run --no-project python -c ' + import json, tomllib, pathlib + t = tomllib.loads(pathlib.Path("pyproject.toml").read_text()) + proj = t.get("project", {}) + print(json.dumps({ + "name": proj.get("name"), + "version": proj.get("version") or ("(dynamic)" if "version" in proj.get("dynamic", []) else None), + "requires-python": proj.get("requires-python"), + "dependencies": proj.get("dependencies"), + "optional-dependencies": sorted((proj.get("optional-dependencies") or {}).keys()), + "build-system": t.get("build-system"), + }, indent=2)) + ' 2>/dev/null || echo "(pyproject.toml unavailable)" + + - name: Build failure message + if: ${{ failure() }} + id: message-failure + shell: bash + env: + RELEASE_TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + STEP: publish + SLACK_MENTION: ${{ inputs.failure_slack_mention }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ACTOR_LINK: <${{ github.server_url }}/${{ github.actor }}|${{ github.actor }}> + run: | + # Subject is the package label when set, else the repository. + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + TEXT=":x: *$SUBJECT $RELEASE_TAG* failed at $STEP" + TEXT="$TEXT\nInitiated by: $ACTOR_LINK" + + if [ -n "$SLACK_MENTION" ]; then + TEXT="$TEXT $SLACK_MENTION" + fi + + TEXT="$TEXT\n<$RUN_URL|View failed run>" + + { echo 'text<> $GITHUB_OUTPUT + + - name: Send Slack message + if: ${{ failure() }} + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-failure.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi diff --git a/actions/release/lang/py/validate/action.yml b/actions/release/lang/py/validate/action.yml index a2a5887..ce11327 100644 --- a/actions/release/lang/py/validate/action.yml +++ b/actions/release/lang/py/validate/action.yml @@ -1,31 +1,27 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} name: Validate Python Release description: > - Checks out the release SHA, sets up uv and Python, reads the package - version, validates the release metadata (tag existence, branch, commit - metadata) and that the version is not already on PyPI, then builds and generates - a CycloneDX SBOM — confirming the package is publishable (and the SBOM tooling - works) before the approval gate. + Judges a Python release against the facts `configure` derived — the toggleable checks + (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. inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Skip tag existence failure in dry runs" + description: "Warn instead of failing on an already-existing tag" required: false default: 'false' version: - description: > - Explicit version override. If provided, the version is not read from - version_file. Use when the version is computed externally (e.g. bt-publishing-test - auto-bump, or an ephemeral prerelease). + 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 (e.g. - py/pkg/version.py). Read when version is not overridden. + description: "Path (repo-root-relative) to a Python module exposing VERSION." required: false default: '' working_directory: @@ -39,45 +35,54 @@ inputs: required: true package_name: description: > - PyPI package name (matches the publish action's input). When set, validate - fails fast if this version is already published (covers prereleases, which - aren't git-tagged). Leave empty to skip. + 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: '' - tag_format: - description: "Git tag format, with a {version} placeholder (e.g. py-{version})" + release_tag: + description: "The release tag (from configure) — checked for prior existence" + required: true + on_release_branch: + description: "Whether the SHA is on an allowed release branch (from configure)" required: false - default: 'py-{version}' + default: 'true' + notes: + description: "Base64-encoded release notes (from prepare) — checked non-blank" + required: false + default: '' build_command: description: "Override build command. When empty, `uv build` is used." required: false default: '' + build: + description: > + Build the distribution as a pre-gate check. Set to 'false' in the custom-build + shape, where the pack job owns the build. + required: false + default: 'true' sbom: description: > - Generate a CycloneDX SBOM as a pre-gate check (same generator as publish), so - SBOM-tooling failures surface BEFORE the approval gate. Not signed or attached - here — that happens in publish. + Generate a CycloneDX SBOM as a pre-gate check (same generator as build-and-ship), + so SBOM-tooling failures surface BEFORE the approval gate. Not signed or attached + here — that happens in build-and-ship. required: false default: 'true' -outputs: - release_tag: - description: "The release tag (e.g. py-0.3.2)" - value: ${{ steps.validate.outputs.release_tag }} - prev_release: - description: "The previous release tag" - value: ${{ steps.validate.outputs.prev_release }} - branch: - description: "The branch containing the SHA" - value: ${{ steps.validate.outputs.branch }} - on_release_branch: - description: "Whether the SHA is on an expected release branch" - value: ${{ steps.validate.outputs.on_release_branch }} - commit_message: - description: "The commit message at the SHA" - value: ${{ steps.validate.outputs.commit_message }} - version: - description: "The resolved package version" - value: ${{ steps.read-version.outputs.version }} + check_tag_unused: + description: "Fail if the release tag already exists" + required: false + default: 'true' + check_version_unpublished: + description: "Fail if the version is already published to PyPI" + required: false + default: 'true' + check_notes_not_blank: + description: "Warn if release notes are blank" + required: false + default: 'true' + enforce_release_branch: + description: "Hard-fail (instead of warn) when the SHA is off the release branch" + required: false + default: 'false' runs: using: composite @@ -111,6 +116,8 @@ runs: # Installs this Python and exports UV_PYTHON for the job, so subsequent uv # commands (build, read-version) use it. python-version: ${{ steps.py-src.outputs.spec }} + # 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 @@ -133,6 +140,7 @@ runs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Check PyPI version availability + if: ${{ inputs.check_version_unpublished == 'true' }} shell: bash env: PACKAGE_NAME: ${{ inputs.package_name }} @@ -159,31 +167,19 @@ runs: ;; esac - - name: Validate SHA format - shell: bash - env: - SHA: ${{ inputs.sha }} - run: | - if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then - echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." - exit 1 - fi - - - name: Validate release - id: validate + - name: Check release shell: bash env: - VERSION: ${{ steps.read-version.outputs.version }} - TAG_FORMAT: ${{ inputs.tag_format }} + TAG: ${{ inputs.release_tag }} DRY_RUN: ${{ inputs.dry_run }} - RELEASE_BRANCH: 'main' - SHA: ${{ inputs.sha }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + NOTES_B64: ${{ inputs.notes }} + CHECK_TAG_UNUSED: ${{ inputs.check_tag_unused }} + CHECK_NOTES: ${{ inputs.check_notes_not_blank }} + ENFORCE_RELEASE_BRANCH: ${{ inputs.enforce_release_branch }} run: | - TAG="${TAG_FORMAT//\{version\}/$VERSION}" - COMMIT_MSG=$(git log -1 --format="%s" HEAD) - BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) - - if git rev-parse "$TAG" >/dev/null 2>&1; then + # 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 echo "::warning title=Release tag already exists::Tag $TAG already exists — skipping in dry run." else @@ -192,27 +188,26 @@ runs: fi fi - ON_RELEASE_BRANCH=false - IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" - for pattern in "${PATTERNS[@]}"; do - pattern=$(echo "$pattern" | xargs) - if [[ "$BRANCH" == $pattern ]]; then - ON_RELEASE_BRANCH=true - break + # On a release branch: warn by default, hard-fail only when enforced. + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + if [ "$ENFORCE_RELEASE_BRANCH" = "true" ]; then + echo "::error title=Off release branch::the release SHA is not on an allowed release branch (enforce_release_branch=true)." + exit 1 + else + echo "::warning title=Off release branch::the release SHA is not on an allowed release branch — releasing anyway." fi - done - - PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" - PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") + fi - echo "release_tag=$TAG" >> $GITHUB_OUTPUT - echo "commit_message=$COMMIT_MSG" >> $GITHUB_OUTPUT - echo "branch=$BRANCH" >> $GITHUB_OUTPUT - echo "on_release_branch=$ON_RELEASE_BRANCH" >> $GITHUB_OUTPUT - echo "prev_release=$PREV_RELEASE" >> $GITHUB_OUTPUT - echo "Validated $TAG @ $SHA ($COMMIT_MSG)" + # Release notes present (warn only — a genuine first release, or no PRs since the last tag, is legitimate). + if [ "$CHECK_NOTES" = "true" ]; then + NOTES=$(echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "") + if [ -z "$NOTES" ]; then + echo "::warning title=Empty release notes::no release notes were generated (first release, or no PRs since the last tag?)." + fi + fi - name: Build distribution + if: ${{ inputs.build == 'true' }} shell: bash working-directory: ${{ inputs.working_directory }} env: diff --git a/actions/release/lang/ruby/publish/action.yml b/actions/release/lang/ruby/build-and-ship/action.yml similarity index 95% rename from actions/release/lang/ruby/publish/action.yml rename to actions/release/lang/ruby/build-and-ship/action.yml index 320c099..9a072fa 100644 --- a/actions/release/lang/ruby/publish/action.yml +++ b/actions/release/lang/ruby/build-and-ship/action.yml @@ -1,11 +1,14 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. -name: Publish Ruby Package +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Build and Ship Ruby Package description: > - Full Ruby gem release: checks out the SHA, builds the gem, generates and signs a - CycloneDX SBOM plus SLSA build provenance (bound to the exact gem), publishes to - RubyGems via OIDC trusted publishing (pushing that same gem — no rebuild), creates - a GitHub release (with the SBOM attached), and sends a Slack completion - notification. Covers the standard publish sequence for all Braintrust Ruby gems. + Turnkey Ruby release — the whole pipeline in one gated job: checks out the SHA, + builds the gem, generates and signs a CycloneDX SBOM plus SLSA build provenance + (bound to the exact gem), publishes to RubyGems via OIDC trusted publishing (pushing + that same gem — no rebuild), creates a GitHub release (with the SBOM attached), and + announces on Slack. This is the `build → attest → publish → release → announce` + pipeline; Ruby's Path C (build once, attest, push that exact gem) is already the + custom-build shape un-split. inputs: sha: @@ -196,7 +199,8 @@ runs: if: ${{ inputs.dry_run != 'true' }} uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: - subject-path: ${{ inputs.working_directory }}/pkg/*.gem + subject-path: |- + ${{ inputs.working_directory }}/pkg/*.gem - name: Configure RubyGems credentials (OIDC trusted publishing) if: ${{ inputs.dry_run != 'true' }} @@ -205,9 +209,14 @@ runs: shell: bash working-directory: ${{ inputs.working_directory }} env: + GEM_GLOB: "pkg/*.gem" DRY_RUN: ${{ inputs.dry_run }} run: | - GEM=$(ls pkg/*.gem) + GEM=$(ls $GEM_GLOB 2>/dev/null | head -1) + if [ -z "$GEM" ]; then + echo "::error title=Gem not found::no gem matched '$GEM_GLOB' in $(pwd)." + exit 1 + fi if [ "$DRY_RUN" = "true" ]; then echo "DRY RUN: would gem push $GEM" exit 0 diff --git a/actions/release/lang/ruby/configure/action.yml b/actions/release/lang/ruby/configure/action.yml new file mode 100644 index 0000000..741e436 --- /dev/null +++ b/actions/release/lang/ruby/configure/action.yml @@ -0,0 +1,181 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# 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. + +inputs: + sha: + description: "Commit SHA being released" + required: true + version: + description: > + Explicit version override. If provided, version_file/version_module are ignored. + Use when the version is computed externally (e.g. a run-number auto-bump). + 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 + default: '.' + tag_format: + description: "Git tag format, with a {version} placeholder (e.g. v{version})" + required: false + default: 'v{version}' + release_type: + description: > + 'stable' | 'prerelease'. Maps to the github_release default (prerelease → no + GitHub release). An explicit github_release input overrides this. + required: false + default: 'stable' + github_release: + description: "Explicit github_release override ('true'/'false'). Empty → derived from release_type." + required: false + default: '' + release_branch: + description: "Comma-separated allowed release branch patterns (globs)." + required: false + default: 'main' +outputs: + version: + description: "The resolved gem version" + value: ${{ steps.read-version.outputs.version }} + release_tag: + description: "The release tag (e.g. v0.3.2)" + value: ${{ steps.configure.outputs.release_tag }} + prev_release: + description: "The previous release tag" + value: ${{ steps.configure.outputs.prev_release }} + branch: + description: "The branch containing the SHA" + value: ${{ steps.configure.outputs.branch }} + on_release_branch: + description: "Whether the SHA is on an allowed release branch" + value: ${{ steps.configure.outputs.on_release_branch }} + commit_message: + description: "The commit message at the SHA" + value: ${{ steps.configure.outputs.commit_message }} + github_release: + description: "Whether a GitHub release should be created" + value: ${{ steps.configure.outputs.github_release }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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. + - name: Set up Ruby + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 + with: + ruby-version: '3.3' + + - 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: Validate SHA format + shell: bash + env: + SHA: ${{ inputs.sha }} + run: | + if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." + exit 1 + fi + + - name: Configure release + id: configure + shell: bash + env: + VERSION: ${{ steps.read-version.outputs.version }} + TAG_FORMAT: ${{ inputs.tag_format }} + RELEASE_BRANCH: ${{ inputs.release_branch }} + 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: "" + GHR_OVERRIDE: "${{ inputs.github_release }}" + run: | + TAG="${TAG_FORMAT//\{version\}/$VERSION}" + COMMIT_MSG=$(git log -1 --format="%s" HEAD) + BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) + + ON_RELEASE_BRANCH=false + IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" + for pattern in "${PATTERNS[@]}"; do + pattern=$(echo "$pattern" | xargs) + if [[ "$BRANCH" == $pattern ]]; then ON_RELEASE_BRANCH=true; break; fi + done + + PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" + PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") + + # channel: explicit override wins; else map from release_type (prerelease→rc, stable→latest) + if [ -n "$CHANNEL_OVERRIDE" ]; then CHANNEL="$CHANNEL_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then CHANNEL="rc" + else CHANNEL="latest"; fi + + # github_release: explicit override wins; else prerelease→false, stable→true + if [ -n "$GHR_OVERRIDE" ]; then GHR="$GHR_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then GHR="false" + else GHR="true"; fi + + { + echo "release_tag=$TAG" + echo "commit_message=$COMMIT_MSG" + echo "branch=$BRANCH" + echo "on_release_branch=$ON_RELEASE_BRANCH" + echo "prev_release=$PREV_RELEASE" + echo "channel=$CHANNEL" + echo "github_release=$GHR" + } >> "$GITHUB_OUTPUT" + echo "Configured $TAG @ $VERSION (channel=$CHANNEL, github_release=$GHR, branch=$BRANCH, on_release_branch=$ON_RELEASE_BRANCH)" + + - name: Dump Ruby environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== Ruby environment ===" + ruby --version + bundle --version + echo "=== Default gems (version conflicts may cause activation errors) ===" + ruby -e "Gem::Specification.default_stubs.map { |s| \"#{s.name} #{s.version}\" }.sort.each { |g| puts g }" + echo "=== Gemfile.lock BUNDLED WITH ===" + grep -A1 "^BUNDLED WITH" Gemfile.lock 2>/dev/null || echo "(not found)" diff --git a/actions/release/lang/ruby/pack/action.yml b/actions/release/lang/ruby/pack/action.yml new file mode 100644 index 0000000..9e5fd60 --- /dev/null +++ b/actions/release/lang/ruby/pack/action.yml @@ -0,0 +1,150 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Pack Ruby Package (custom build) +description: > + Custom-build side (unprivileged — no publish credentials): checks out the SHA, builds + the gem, generates a CycloneDX SBOM, and attests the SBOM + SLSA build provenance to + that exact gem. The workflow uploads pkg/*.gem + sbom.json for the gated ship-package + job. Requires id-token: write + attestations: write. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before building. Set to false when the caller already checked out." + required: false + default: 'true' + working_directory: + description: "Path to the gem root (where Gemfile and gemspec live)" + required: false + default: '.' + ruby_version: + description: "Ruby version to build with, or a version file path (.tool-versions, .ruby-version)" + required: false + default: '4.0' + sbom: + description: "Generate + attest a CycloneDX SBOM bound to the gem. Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + + - name: Set up Ruby + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 + with: + ruby-version: ${{ inputs.ruby_version }} + bundler-cache: true + working-directory: ${{ inputs.working_directory }} + + - name: Build gem + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + bundle exec rake build + if [ "$(ls pkg/*.gem 2>/dev/null | wc -l)" -ne 1 ]; then + echo "::error title=Build failed::expected exactly one gem at pkg/*.gem after 'rake build' — found: $(ls pkg/*.gem 2>/dev/null || echo none)" + exit 1 + fi + echo "Built $(ls pkg/*.gem)" + + - name: Generate SBOM (CycloneDX) + if: ${{ inputs.sbom == 'true' }} + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + GEM_NAME: ${{ inputs.gem_name }} + run: | + if [ ! -f Gemfile.lock ]; then + echo "::error title=SBOM failed::Gemfile.lock not found in $(pwd) — bundle install must run before SBOM generation." + exit 1 + fi + # Map Gemfile.lock → CycloneDX 1.6 JSON with Bundler's own lockfile parser. + # Only the gem's RUNTIME closure is included: we walk the dependency graph + # from the gem itself, so the dev/test toolchain (rubocop, rake, …) that lives + # in Gemfile.lock but is not a runtime dependency of the published gem is + # excluded. The gem is the SBOM subject (metadata.component), its runtime + # dependency closure are the components. + ruby <<'RUBY' + require 'bundler' + require 'json' + require 'securerandom' + lock = Bundler::LockfileParser.new(File.read('Gemfile.lock')) + by_name = lock.specs.each_with_object({}) { |s, h| h[s.name] = s } + # The gem being released: prefer the explicit gem_name, else the local + # path/gemspec source spec (the gem defined in this repo). + root_name = ENV['GEM_NAME'].to_s.empty? ? nil : ENV['GEM_NAME'] + root = (by_name[root_name] if root_name) || + lock.specs.find { |s| s.source.is_a?(Bundler::Source::Path) } + abort("::error title=SBOM failed::could not identify the gem in Gemfile.lock (set gem_name).") unless root + # Breadth-first over each spec's runtime dependencies (as recorded per-gem in + # the GEM section of the lockfile), starting from the released gem. + seen = {} + queue = root.dependencies.map(&:name) + until queue.empty? + name = queue.shift + next if seen[name] || !by_name[name] + spec = by_name[name] + seen[name] = spec + queue.concat(spec.dependencies.map(&:name)) + end + component = ->(s) do + { 'type' => 'library', 'name' => s.name, 'version' => s.version.to_s, + 'purl' => "pkg:gem/#{s.name}@#{s.version}" } + end + bom = { + 'bomFormat' => 'CycloneDX', + 'specVersion' => '1.7', + # Required: actions/attest recognizes CycloneDX only when bomFormat + specVersion + # + serialNumber are all present (see actions/attest src/sbom.ts). Don't remove. + 'serialNumber' => "urn:uuid:#{SecureRandom.uuid}", + 'version' => 1, + 'metadata' => { + # CycloneDX ≥1.5 tools shape (the array-of-tools form was deprecated in 1.5). + 'tools' => { 'components' => [{ 'type' => 'application', 'group' => 'braintrust', 'name' => 'sdk-actions ruby sbom' }] }, + 'component' => component.call(root).merge('type' => 'application'), + }, + 'components' => seen.values.sort_by(&:name).map { |s| component.call(s) }, + } + File.write('sbom.json', JSON.pretty_generate(bom)) + puts "Wrote #{Dir.pwd}/sbom.json (subject: #{root.name}, #{seen.size} runtime components)" + RUBY + + - name: Attest SBOM + if: ${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }} + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: |- + ${{ inputs.working_directory }}/pkg/*.gem + sbom-path: ${{ inputs.working_directory }}/sbom.json + + - name: Attest build provenance + if: ${{ inputs.dry_run != 'true' }} + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: |- + ${{ inputs.working_directory }}/pkg/*.gem + + - name: Dump Ruby environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== Ruby environment ===" + ruby --version + bundle --version + echo "=== Default gems (version conflicts may cause activation errors) ===" + ruby -e "Gem::Specification.default_stubs.map { |s| \"#{s.name} #{s.version}\" }.sort.each { |g| puts g }" + echo "=== Gemfile.lock BUNDLED WITH ===" + grep -A1 "^BUNDLED WITH" Gemfile.lock 2>/dev/null || echo "(not found)" diff --git a/actions/release/lang/ruby/ship-package/action.yml b/actions/release/lang/ruby/ship-package/action.yml new file mode 100644 index 0000000..2feab13 --- /dev/null +++ b/actions/release/lang/ruby/ship-package/action.yml @@ -0,0 +1,422 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Ship Ruby Package (custom build) +description: > + Custom-build gated publish side: verifies the downloaded gem's attestations against + its own bytes, pushes that exact gem to RubyGems (OIDC trusted publishing), creates a + GitHub release (attaching the SBOM), and announces. No build, no attest — it ships a + prebuilt, attested gem. Requires id-token: write + contents: write. The workflow + downloads the gem + sbom.json into working_directory first. + +inputs: + working_directory: + description: "Directory holding the downloaded gem + sbom.json" + required: false + default: '.' + sha: + description: "Commit SHA being released (release target)" + required: true + dry_run: + description: "Skip verification, gem push, tag push, and GitHub release creation" + required: false + default: 'false' + release_tag: + description: "The release tag (e.g. v0.3.2)" + required: true + github_release: + description: "Whether to create a GitHub release" + required: false + default: 'true' + release_title: + description: "GitHub release name/title. Defaults to the release tag when empty." + required: false + default: '' + sbom: + description: "Attach the downloaded sbom.json to the GitHub release" + required: false + default: 'true' + gem_name: + description: "RubyGems gem name — used to build the RubyGems link in notifications" + required: false + default: '' + label: + description: "Display name for release notifications (e.g. the gem name)." + required: false + default: '' + notes: + description: "Base64-encoded release notes from the prepare action" + required: false + default: '' + prev_release: + description: "Previous release tag — used in completion notification" + required: false + default: '' + branch: + description: "Branch the release was made from" + required: true + on_release_branch: + description: "Whether the SHA is on an expected release branch" + required: true + pr_list: + description: "x1f-delimited PR list from prepare action" + required: false + default: '' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if the publish fails" + required: false + default: '' + emoji: + description: "Emoji prefix for Slack messages" + required: false + default: ':ruby:' + +runs: + using: composite + steps: + # Set up a default Ruby so `gem push` has a modern rubygems (OIDC trusted publishing); + # no bundle install — this job doesn't build. + - name: Set up Ruby + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 + with: + ruby-version: '3.3' + + - name: Verify attestations + if: ${{ inputs.dry_run != 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACTS: ${{ inputs.working_directory }}/*.gem + REPO: ${{ github.repository }} + run: | + shopt -s nullglob + FILES=( $ARTIFACTS ) + if [ ${#FILES[@]} -eq 0 ]; then + echo "::error title=Nothing to verify::no artifact files matched '$ARTIFACTS'." + exit 1 + fi + for f in "${FILES[@]}"; do + echo "Verifying $(basename "$f") — build provenance…" + gh attestation verify "$f" --repo "$REPO" || { + echo "::error title=Provenance verification failed::no build-provenance attestation matches $(basename "$f") — the artifact was not produced by a trusted build, or was tampered with in transit." + exit 1 + } + echo "Verifying $(basename "$f") — SBOM…" + gh attestation verify "$f" --repo "$REPO" --predicate-type https://cyclonedx.org/bom || { + echo "::error title=SBOM verification failed::no CycloneDX SBOM attestation matches $(basename "$f")." + exit 1 + } + done + echo "✓ attestations verified for: ${FILES[*]}" + + - 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 + shell: bash + working-directory: ${{ inputs.working_directory }} + env: + GEM_GLOB: "*.gem" + DRY_RUN: ${{ inputs.dry_run }} + run: | + GEM=$(ls $GEM_GLOB 2>/dev/null | head -1) + if [ -z "$GEM" ]; then + echo "::error title=Gem not found::no gem matched '$GEM_GLOB' in $(pwd)." + exit 1 + fi + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would gem push $GEM" + exit 0 + fi + gem push "$GEM" || { + echo "::error title=gem push failed::OIDC/trusted-publisher error → RubyGems must list a trusted publisher for this gem with this repo + workflow (and environment if gated); 'Repushing of gem versions is not allowed' → the version already exists (bump it)." + exit 1 + } + + - name: Create GitHub release + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + ENABLED: ${{ inputs.github_release }} + DRY_RUN: ${{ inputs.dry_run }} + TAG: ${{ inputs.release_tag }} + RELEASE_TITLE: ${{ inputs.release_title }} + NOTES_B64: ${{ inputs.notes }} + SHA: ${{ inputs.sha }} + SBOM_ENABLED: ${{ inputs.sbom }} + WORKING_DIRECTORY: ${{ inputs.working_directory }} + run: | + if [ "$ENABLED" != "true" ]; then + echo "GitHub release disabled; skipping." + exit 0 + fi + # Release name defaults to the tag when no explicit title is provided. + TITLE="${RELEASE_TITLE:-$TAG}" + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would create GitHub release $TAG (title: $TITLE)" + echo "--- Release notes preview ---" + 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. + if [ "$SBOM_ENABLED" = "true" ] && [ -f "$WORKING_DIRECTORY/sbom.json" ]; then + gh release upload "$TAG" "$WORKING_DIRECTORY/sbom.json" --clobber + fi + fi + + - name: Build RubyGems links + id: links + shell: bash + env: + GEM_NAME: ${{ inputs.gem_name }} + RELEASE_TAG: ${{ inputs.release_tag }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + LINKS='[]' + if [ "$DRY_RUN" != "true" ] && [ -n "$GEM_NAME" ]; then + VERSION="${RELEASE_TAG#v}" + LINKS="[{\"label\":\"RubyGems\",\"url\":\"https://rubygems.org/gems/$GEM_NAME/versions/$VERSION\"}]" + fi + echo "links=$LINKS" >> $GITHUB_OUTPUT + + - name: Post release summary + shell: bash + env: + TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + PREV_RELEASE: ${{ inputs.prev_release }} + BRANCH: ${{ inputs.branch }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + DRY_RUN: ${{ inputs.dry_run }} + LINKS_JSON: ${{ steps.links.outputs.links }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + BRANCH_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/tree/$BRANCH" + + # Subject is the package label when set (disambiguates repos that publish + # multiple packages); otherwise the repository. The repo is still present in + # every link below, so it is never lost. + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + if [ "$DRY_RUN" = "true" ]; then + echo "## $SUBJECT $TAG — dry run complete" >> $GITHUB_STEP_SUMMARY + else + echo "## $SUBJECT $TAG — published ✅" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "$DRY_RUN" = "true" ]; then + echo "> [!NOTE]" >> $GITHUB_STEP_SUMMARY + echo "> Dry run: Nothing was tagged or published." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY + echo "> Released from non-release branch: [$BRANCH]($BRANCH_URL)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + fi + + echo "**Branch:** [$BRANCH]($BRANCH_URL)" >> $GITHUB_STEP_SUMMARY + + if [ -n "$PREV_RELEASE" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...$TAG" + echo "**Diff:** [$PREV_RELEASE...$TAG]($DIFF_URL)" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Published to" >> $GITHUB_STEP_SUMMARY + + if [ "$DRY_RUN" = "true" ]; then + echo "- [View dry run]($RUN_URL)" >> $GITHUB_STEP_SUMMARY + else + echo "- [GitHub Release]($RELEASE_URL)" >> $GITHUB_STEP_SUMMARY + if [ -n "$LINKS_JSON" ] && [ "$LINKS_JSON" != "[]" ]; then + echo "$LINKS_JSON" | jq -r '.[] | "- [\(.label)](\(.url))"' >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Build published message + id: message-published + shell: bash + env: + TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + EMOJI: ${{ inputs.emoji }} + BRANCH: ${{ inputs.branch }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + PREV_RELEASE: ${{ inputs.prev_release }} + DRY_RUN: ${{ inputs.dry_run }} + SLACK_MENTION: ${{ inputs.slack_mention }} + LINKS_JSON: ${{ steps.links.outputs.links }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + PR_LIST_RAW: ${{ inputs.pr_list }} + run: | + RELEASE_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/$TAG" + + # Subject is the package label when set, else the repository (see summary step). + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + DIFF_PART="" + if [ -n "$PREV_RELEASE" ]; then + DIFF_URL="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/compare/${PREV_RELEASE}...$TAG" + DIFF_PART="<$DIFF_URL|${PREV_RELEASE}...$TAG> · " + fi + + PR_LIST=$(echo "$PR_LIST_RAW" | tr $'\x1f' '\n' | sed '/^$/d') + + if [ "$DRY_RUN" = "true" ]; then + TEXT="$EMOJI *$SUBJECT $TAG* complete" + TEXT="$TEXT\n> :information_source: _Dry run: nothing tagged or published._" + else + TEXT="$EMOJI *$SUBJECT $TAG* published" + 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>" + fi + + if [ "$DRY_RUN" = "true" ]; then + TEXT="$TEXT\n${DIFF_PART}<$RUN_URL|View dry run>" + else + SLACK_LINKS="${DIFF_PART}<$RELEASE_URL|View release>" + if [ -n "$LINKS_JSON" ] && [ "$LINKS_JSON" != "[]" ]; then + EXTRA=$(echo "$LINKS_JSON" | jq -r '[.[] | "<\(.url)|\(.label)>"] | join(" · ")') + SLACK_LINKS="$SLACK_LINKS · $EXTRA" + fi + TEXT="$TEXT\n$SLACK_LINKS" + fi + + if [ -n "$PR_LIST" ]; then + TEXT="$TEXT\n$PR_LIST" + fi + + if [ -n "$SLACK_MENTION" ]; then + TEXT="$TEXT\n$SLACK_MENTION" + fi + + { echo 'text<> $GITHUB_OUTPUT + + - name: Send Slack message + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-published.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi + + - name: Dump Ruby environment + if: ${{ failure() }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + echo "=== Ruby environment ===" + ruby --version + bundle --version + echo "=== Default gems (version conflicts may cause activation errors) ===" + ruby -e "Gem::Specification.default_stubs.map { |s| \"#{s.name} #{s.version}\" }.sort.each { |g| puts g }" + echo "=== Gemfile.lock BUNDLED WITH ===" + grep -A1 "^BUNDLED WITH" Gemfile.lock 2>/dev/null || echo "(not found)" + + - name: Build failure message + if: ${{ failure() }} + id: message-failure + shell: bash + env: + RELEASE_TAG: ${{ inputs.release_tag }} + LABEL: ${{ inputs.label }} + STEP: publish + SLACK_MENTION: ${{ inputs.failure_slack_mention }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ACTOR_LINK: <${{ github.server_url }}/${{ github.actor }}|${{ github.actor }}> + run: | + # Subject is the package label when set, else the repository. + SUBJECT="$GITHUB_REPOSITORY" + [ -n "$LABEL" ] && SUBJECT="$LABEL" + + TEXT=":x: *$SUBJECT $RELEASE_TAG* failed at $STEP" + TEXT="$TEXT\nInitiated by: $ACTOR_LINK" + + if [ -n "$SLACK_MENTION" ]; then + TEXT="$TEXT $SLACK_MENTION" + fi + + TEXT="$TEXT\n<$RUN_URL|View failed run>" + + { echo 'text<> $GITHUB_OUTPUT + + - name: Send Slack message + if: ${{ failure() }} + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-failure.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi diff --git a/actions/release/lang/ruby/validate/action.yml b/actions/release/lang/ruby/validate/action.yml index a89fb51..4d6f92a 100644 --- a/actions/release/lang/ruby/validate/action.yml +++ b/actions/release/lang/ruby/validate/action.yml @@ -1,24 +1,23 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} name: Validate Ruby Release description: > - Checks out the release SHA, sets up Ruby, reads the gem version, validates - the release (RubyGems availability, tag existence, branch, commit metadata), - and runs lint, build, and SBOM generation to confirm the gem is publishable - (and the SBOM tooling works) before the approval gate. + Judges a Ruby release against the facts `configure` derived — the toggleable checks + (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. inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Skip tag existence failure in dry runs" + description: "Warn instead of failing on an already-existing tag" required: false default: 'false' version: - description: > - Explicit version override. If provided, version_file and version_module - are ignored. Use when the version is computed externally (e.g. bt-publishing-test - auto-bump from run number). + description: "Version override (from configure). If empty, read from version_file/version_module." required: false default: '' version_file: @@ -33,40 +32,57 @@ inputs: description: "Path to the gem root (where Gemfile and gemspec live)" required: false default: '.' + ruby_version: + 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. When set, validate fails fast if this version is already - published — the registry, not the git tag, is the source of truth (covers - prereleases and force-deleted tags). Leave empty to skip. + 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: '' - ruby_version: - description: "Ruby version to use, or a version file path (.tool-versions, .ruby-version)" + release_tag: + description: "The release tag (from configure) — checked for prior existence" + required: true + on_release_branch: + description: "Whether the SHA is on an allowed release branch (from configure)" required: false - default: '4.0' + default: 'true' + notes: + description: "Base64-encoded release notes (from prepare) — checked non-blank" + required: false + default: '' + build: + description: > + Build the gem as a pre-gate check. Set to 'false' in the custom-build shape, where + the pack job owns the build (lint still runs). + required: false + default: 'true' sbom: description: > - Generate a CycloneDX SBOM as a pre-gate check (same generator as publish), so - SBOM-generation failures surface BEFORE the approval gate. Not signed or - attached here — that happens in publish. + Generate a CycloneDX SBOM as a pre-gate check (same generator as build-and-ship), + so SBOM-generation failures surface BEFORE the approval gate. Not signed or + attached here — that happens in build-and-ship. required: false default: 'true' -outputs: - release_tag: - description: "The release tag (e.g. v0.3.2)" - value: ${{ steps.validate.outputs.release_tag }} - prev_release: - description: "The previous release tag" - value: ${{ steps.validate.outputs.prev_release }} - branch: - description: "The branch containing the SHA" - value: ${{ steps.validate.outputs.branch }} - on_release_branch: - description: "Whether the SHA is on an expected release branch" - value: ${{ steps.validate.outputs.on_release_branch }} - commit_message: - description: "The commit message at the SHA" - value: ${{ steps.validate.outputs.commit_message }} + check_tag_unused: + description: "Fail if the release tag already exists" + required: false + default: 'true' + check_version_unpublished: + description: "Fail if the version is already published to RubyGems" + required: false + default: 'true' + check_notes_not_blank: + description: "Warn if release notes are blank" + required: false + default: 'true' + enforce_release_branch: + description: "Hard-fail (instead of warn) when the SHA is off the release branch" + required: false + default: 'false' runs: using: composite @@ -103,6 +119,7 @@ runs: echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Check RubyGems version availability + if: ${{ inputs.check_version_unpublished == 'true' }} shell: bash env: PACKAGE_NAME: ${{ inputs.package_name }} @@ -129,31 +146,19 @@ runs: ;; esac - - name: Validate SHA format - shell: bash - env: - SHA: ${{ inputs.sha }} - run: | - if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then - echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." - exit 1 - fi - - - name: Validate release - id: validate + - name: Check release shell: bash env: - VERSION: ${{ steps.read-version.outputs.version }} - TAG_FORMAT: 'v{version}' + TAG: ${{ inputs.release_tag }} DRY_RUN: ${{ inputs.dry_run }} - RELEASE_BRANCH: 'main' - SHA: ${{ inputs.sha }} + ON_RELEASE_BRANCH: ${{ inputs.on_release_branch }} + NOTES_B64: ${{ inputs.notes }} + CHECK_TAG_UNUSED: ${{ inputs.check_tag_unused }} + CHECK_NOTES: ${{ inputs.check_notes_not_blank }} + ENFORCE_RELEASE_BRANCH: ${{ inputs.enforce_release_branch }} run: | - TAG="${TAG_FORMAT//\{version\}/$VERSION}" - COMMIT_MSG=$(git log -1 --format="%s" HEAD) - BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) - - if git rev-parse "$TAG" >/dev/null 2>&1; then + # 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 echo "::warning title=Release tag already exists::Tag $TAG already exists — skipping in dry run." else @@ -162,25 +167,23 @@ runs: fi fi - ON_RELEASE_BRANCH=false - IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" - for pattern in "${PATTERNS[@]}"; do - pattern=$(echo "$pattern" | xargs) - if [[ "$BRANCH" == $pattern ]]; then - ON_RELEASE_BRANCH=true - break + # On a release branch: warn by default, hard-fail only when enforced. + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + if [ "$ENFORCE_RELEASE_BRANCH" = "true" ]; then + echo "::error title=Off release branch::the release SHA is not on an allowed release branch (enforce_release_branch=true)." + exit 1 + else + echo "::warning title=Off release branch::the release SHA is not on an allowed release branch — releasing anyway." fi - done - - PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" - PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") + fi - echo "release_tag=$TAG" >> $GITHUB_OUTPUT - echo "commit_message=$COMMIT_MSG" >> $GITHUB_OUTPUT - echo "branch=$BRANCH" >> $GITHUB_OUTPUT - echo "on_release_branch=$ON_RELEASE_BRANCH" >> $GITHUB_OUTPUT - echo "prev_release=$PREV_RELEASE" >> $GITHUB_OUTPUT - echo "Validated $TAG @ $SHA ($COMMIT_MSG)" + # Release notes present (warn only — a genuine first release, or no PRs since the last tag, is legitimate). + if [ "$CHECK_NOTES" = "true" ]; then + NOTES=$(echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "") + if [ -z "$NOTES" ]; then + echo "::warning title=Empty release notes::no release notes were generated (first release, or no PRs since the last tag?)." + fi + fi - name: Lint shell: bash @@ -188,6 +191,7 @@ runs: run: bundle exec rake lint - name: Build gem + if: ${{ inputs.build == 'true' }} shell: bash working-directory: ${{ inputs.working_directory }} run: | diff --git a/actions/release/prepare/action.yml b/actions/release/prepare/action.yml index 6014f73..1d792df 100644 --- a/actions/release/prepare/action.yml +++ b/actions/release/prepare/action.yml @@ -1,4 +1,5 @@ # 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. diff --git a/actions/release/notify-pending/action.yml b/actions/release/request-approval/action.yml similarity index 93% rename from actions/release/notify-pending/action.yml rename to actions/release/request-approval/action.yml index c15936f..27dbbff 100644 --- a/actions/release/notify-pending/action.yml +++ b/actions/release/request-approval/action.yml @@ -1,8 +1,12 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. -name: Notify Release Pending +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Request Release Approval description: > - Posts the pre-approval job summary and Slack notification. - Called before the environment gate — reviewer sees this before approving. + 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. inputs: sha: diff --git a/scripts/generate.rb b/scripts/generate.rb index 6eb8cc3..68d8e16 100644 --- a/scripts/generate.rb +++ b/scripts/generate.rb @@ -12,6 +12,7 @@ require 'erb' require 'fileutils' +require 'json' module ActionGenerator REPO_ROOT = File.expand_path('..', __dir__) @@ -21,6 +22,13 @@ module ActionGenerator GENERATED_HEADER = "# GENERATED by scripts/generate.rb — edit templates/, not this file.\n" + # Soft-semver, scoped per action FAMILY (the top dir under templates/actions/, e.g. + # `release`). The version lives in that family's VERSION file and is stamped into every + # action it generates. Consumers pin our actions by SHA — this number is NOT a git tag; + # it's a risk signal for diffing two pinned SHAs: a major jump = breaking change (inputs + # removed/renamed, behavior contract changed), minor = additive, patch = fix. Scoped per + # family so one family's breaking change never forces a major bump on the others. + # The rendering scope for a template. An instance's `binding` is handed to ERB, # so the ONLY methods a template (.yml.erb) can call are this class's PUBLIC # methods — currently just render_step. Internal helpers stay private. @@ -89,10 +97,35 @@ def template_paths def generate(template_path) out_path = output_path_for(template_path) FileUtils.mkdir_p(File.dirname(out_path)) - File.write(out_path, GENERATED_HEADER + render_action(template_path)) + File.write(out_path, header_for(template_path) + render_action(template_path)) puts " #{erb?(template_path) ? 'rendered' : 'copied '} → #{out_path.sub("#{REPO_ROOT}/", '')}" end + # The generated header plus, when the action belongs to a versioned family, a + # machine-parseable JSON metadata comment stamped from that family's VERSION file: + # # sdk-actions: {"family":"release","version":"1.0.0"} + # (a comment, not a YAML key — action.yml has no native version field, and this + # survives regeneration + can't trip the action schema). + def header_for(template_path) + family, version = family_version(template_path) + return GENERATED_HEADER unless version + "#{GENERATED_HEADER}# sdk-actions: #{JSON.generate('family' => family, 'version' => version)}\n" + end + + # Nearest VERSION file walking up from the template toward templates/actions/. + # Returns [family, version] (family = that dir relative to templates/actions/, + # e.g. "release"), or nil when no ancestor carries a VERSION. + def family_version(template_path) + dir = File.dirname(template_path) + while dir.start_with?(TEMPLATES_DIR) + version_file = File.join(dir, 'VERSION') + return [dir.sub("#{TEMPLATES_DIR}/", ''), File.read(version_file).strip] if File.exist?(version_file) + break if dir == TEMPLATES_DIR + dir = File.dirname(dir) + end + nil + end + def render_action(template_path) source = File.read(template_path) erb?(template_path) ? Template.new(source).render : source diff --git a/templates/actions/release/VERSION b/templates/actions/release/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/templates/actions/release/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/templates/actions/release/lang/js/publish.yml.erb b/templates/actions/release/lang/js/build-and-ship.yml.erb similarity index 81% rename from templates/actions/release/lang/js/publish.yml.erb rename to templates/actions/release/lang/js/build-and-ship.yml.erb index ac1d551..862a650 100644 --- a/templates/actions/release/lang/js/publish.yml.erb +++ b/templates/actions/release/lang/js/build-and-ship.yml.erb @@ -1,10 +1,12 @@ -name: Publish JavaScript Package +name: Build and Ship JavaScript Package description: > - Full JavaScript package release: checks out the SHA, sets up Node and pnpm, - builds, generates and signs a CycloneDX SBOM (bound to the published tarball), - publishes to npm (OIDC trusted publishing + provenance), creates a GitHub release - (with the SBOM attached), and sends a Slack completion notification. Covers the - standard publish sequence for Braintrust npm packages. + Turnkey JavaScript release — the whole pipeline in one gated job: checks out the + SHA, sets up Node and pnpm, builds, generates and signs a CycloneDX SBOM (bound to + the published tarball via `pnpm pack` == `pnpm publish` bytes), publishes to npm + (OIDC trusted publishing + provenance), creates a GitHub release (with the SBOM + attached), and announces on Slack. This is the `build → attest → publish → release → + announce` pipeline; the custom-build path (pack + ship-package) is the same pipeline + split across two jobs at the build⟷publish seam. inputs: sha: @@ -142,18 +144,15 @@ runs: ref: ${{ inputs.sha }} fetch-depth: 0 -<%= render_step('lang/js/setup') %> +<%= render_step('lang/js/pnpm/setup') %> -<%= render_step('lang/js/build', if: "${{ inputs.build == 'true' }}") %> +<%= render_step('lang/js/pnpm/build', if: "${{ inputs.build == 'true' }}") %> -<%= render_step('release/lang/js/sbom', if: "${{ inputs.sbom == 'true' }}") %> +<%= render_step('lang/js/pnpm/sbom', if: "${{ inputs.sbom == 'true' }}") %> -<%# Attest BEFORE publish: `pnpm pack` == the bytes `pnpm publish` uploads (pnpm ≥ 11.8), - so the SBOM binds to the published digest with no refetch, and a failed attestation - never leaves a published-but-unsigned package. -%> -<%= render_step('release/lang/js/attest-sbom', if: "${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}") %> +<%= render_step('lang/js/pnpm/attest-sbom', if: "${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}") %> -<%= render_step('release/lang/js/npm-publish') %> +<%= render_step('lang/js/npm/publish') %> <%= 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 new file mode 100644 index 0000000..1e48eb3 --- /dev/null +++ b/templates/actions/release/lang/js/configure.yml.erb @@ -0,0 +1,102 @@ +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. + +inputs: + sha: + description: "Commit SHA being released" + required: true + version: + description: > + Explicit version override. If provided, the version is not read from package.json. + Use when the version is computed externally (e.g. a run-number auto-bump). + required: false + default: '' + working_directory: + description: "Path to the package root (where package.json lives)" + required: false + default: '.' + tag_format: + description: "Git tag format, with a {version} placeholder (e.g. js-{version})" + required: false + default: 'v{version}' + release_type: + description: > + 'stable' | 'prerelease'. Maps to the channel + github_release defaults + (prerelease → channel 'rc', no GitHub release). Explicit channel/github_release + inputs override this. + required: false + default: 'stable' + channel: + description: "Explicit npm channel (dist-tag) override. Empty → derived from release_type." + required: false + default: '' + github_release: + description: "Explicit github_release override ('true'/'false'). Empty → derived from release_type." + required: false + default: '' + release_branch: + description: "Comma-separated allowed release branch patterns (globs)." + required: false + default: 'main' +outputs: + version: + description: "The resolved package version" + value: ${{ steps.read-version.outputs.version }} + release_tag: + description: "The release tag (e.g. v0.3.2)" + value: ${{ steps.configure.outputs.release_tag }} + prev_release: + description: "The previous release tag" + value: ${{ steps.configure.outputs.prev_release }} + branch: + description: "The branch containing the SHA" + value: ${{ steps.configure.outputs.branch }} + on_release_branch: + description: "Whether the SHA is on an allowed release branch" + value: ${{ steps.configure.outputs.on_release_branch }} + commit_message: + description: "The commit message at the SHA" + value: ${{ steps.configure.outputs.commit_message }} + channel: + description: "The resolved npm channel (dist-tag)" + value: ${{ steps.configure.outputs.channel }} + github_release: + description: "Whether a GitHub release should be created" + value: ${{ steps.configure.outputs.github_release }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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. + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 'lts/*' + +<%= render_step('lang/js/read-version') %> + +<%= render_step('release/configure', + version: "${{ steps.read-version.outputs.version }}", + sha: "${{ inputs.sha }}", + tag_format: "${{ inputs.tag_format }}", + release_branch: "${{ inputs.release_branch }}", + release_type: "${{ inputs.release_type }}", + channel: "${{ inputs.channel }}", + github_release: "${{ inputs.github_release }}") %> + +<%= render_step('lang/js/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/js/pack-bun.yml.erb b/templates/actions/release/lang/js/pack-bun.yml.erb new file mode 100644 index 0000000..ed8b1e8 --- /dev/null +++ b/templates/actions/release/lang/js/pack-bun.yml.erb @@ -0,0 +1,92 @@ +name: Pack JavaScript Package (custom build, bun) +description: > + Build side of the custom-build ("build-ownership") path for a Bun project — runs in an + UNPRIVILEGED job with NO publish credentials. Sets up Bun, runs `bun build --metafile` + (bundling deps into the artifact), packs the npm tarball (`bun pm pack`), generates a + CycloneDX SBOM FROM the build metafile (so it matches the bundled artifact), and attests + the SBOM + SLSA build provenance to that tarball. The workflow uploads the tarball + SBOM + for the gated ship-package job (shared with pnpm). Requires id-token: write + + attestations: write. pnpm projects use pack-pnpm instead. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before building. Set to false when the caller already checked out." + required: false + default: 'true' + working_directory: + description: "Path to the package root (where package.json lives)" + required: false + default: '.' + bun_version: + description: "Bun version. Empty → auto-detect from the project (.tool-versions / package.json) or latest." + required: false + default: '' + entry: + description: "bun build entrypoint" + required: false + default: 'src/index.ts' + outdir: + description: "bun build output directory" + required: false + default: 'dist' + target: + description: "bun build --target (bun|node|browser)" + required: false + default: 'bun' + format: + description: "bun build --format (esm|cjs|iife)" + required: false + default: 'esm' + sbom: + description: "Generate + attest a CycloneDX SBOM (from the build metafile). Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' +outputs: + tarball: + description: "Absolute path to the packed tarball (the exact bytes ship-package will publish)" + value: ${{ steps.pack.outputs.tarball }} + integrity: + description: "sha512 integrity of the packed tarball" + value: ${{ steps.pack.outputs.integrity }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + +<%= render_step('lang/js/bun/setup') %> + +<%= render_step('lang/js/bun/build', + entry: "${{ inputs.entry }}", + outdir: "${{ inputs.outdir }}", + target: "${{ inputs.target }}", + format: "${{ inputs.format }}") %> + +<%= render_step('lang/js/bun/pack') %> + +<%= render_step('release/lang/js/validate-package') %> + +<%= render_step('lang/js/bun/sbom', if: "${{ inputs.sbom == 'true' }}") %> + +<%# Attest the packed tarball BEFORE it leaves this job. Skipped on dry runs. -%> +<%= render_step('release/attest-sbom', + if: "${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}", + subject_path: "${{ steps.pack.outputs.tarball }}", + sbom_path: "${{ inputs.working_directory }}/sbom.json") %> + +<%= render_step('release/attest-provenance', + if: "${{ inputs.dry_run != 'true' }}", + subject_path: "${{ steps.pack.outputs.tarball }}") %> + +<%= 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 new file mode 100644 index 0000000..aa1cd3c --- /dev/null +++ b/templates/actions/release/lang/js/pack-pnpm.yml.erb @@ -0,0 +1,89 @@ +name: Pack JavaScript Package (custom build, pnpm) +description: > + Build side of the custom-build ("build-ownership") path for a pnpm project — runs in + an UNPRIVILEGED job with NO publish credentials. Checks out the SHA, builds, packs the + exact tarball (`pnpm pack`), generates a CycloneDX SBOM (`pnpm sbom`), and attests both + the SBOM and SLSA build provenance to that tarball. The workflow uploads the tarball + + SBOM for the gated ship-package job to verify and publish. This is the + `build → pack → attest` prefix of the pipeline; ship-package is the + `publish → release → announce` suffix. Requires id-token: write + attestations: write + (but NOT publish credentials). Bun projects use pack-bun instead. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before packing. Set to false when the caller has already checked out and built." + required: false + default: 'true' + working_directory: + description: "Path to the package root (where package.json lives)" + required: false + default: '.' + node_version: + description: > + Node version (e.g. 24), or a path to a version file + (.tool-versions, .nvmrc, .node-version) relative to the repo root. + required: true + pnpm_version: + description: "pnpm version to install. When empty, read from package.json packageManager." + required: false + default: '' + build: + description: > + Whether to run the build step. Set to 'false' when the consumer's own build + already ran (the common monorepo case) — pack then just packages the output. + required: false + default: 'true' + build_command: + description: "npm script to run for the build (when build is 'true')" + required: false + default: 'build' + sbom: + description: "Generate + attest a CycloneDX SBOM bound to the packed tarball. Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' +outputs: + tarball: + description: "Absolute path to the packed tarball (the exact bytes ship-package will publish)" + value: ${{ steps.pack.outputs.tarball }} + integrity: + description: "sha512 integrity of the packed tarball" + value: ${{ steps.pack.outputs.integrity }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + +<%= render_step('lang/js/pnpm/setup') %> + +<%= render_step('lang/js/pnpm/build', if: "${{ inputs.build == 'true' }}") %> + +<%= render_step('lang/js/pnpm/pack') %> + +<%= render_step('release/lang/js/validate-package') %> + +<%= render_step('lang/js/pnpm/sbom', if: "${{ inputs.sbom == 'true' }}") %> + +<%# Attest the packed tarball BEFORE it leaves this job. Skipped on dry runs (nothing + should hit the transparency log for a build that won't be published). -%> +<%= render_step('release/attest-sbom', + if: "${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}", + subject_path: "${{ steps.pack.outputs.tarball }}", + sbom_path: "${{ inputs.working_directory }}/sbom.json") %> + +<%= render_step('release/attest-provenance', + if: "${{ inputs.dry_run != 'true' }}", + subject_path: "${{ steps.pack.outputs.tarball }}") %> + +<%= render_step('lang/js/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/js/ship-package.yml.erb b/templates/actions/release/lang/js/ship-package.yml.erb new file mode 100644 index 0000000..ac73be2 --- /dev/null +++ b/templates/actions/release/lang/js/ship-package.yml.erb @@ -0,0 +1,137 @@ +name: Ship JavaScript Package (custom build) +description: > + Publish side of the custom-build ("build-ownership") path — runs in the GATED job. + Verifies the downloaded artifact's attestations (SLSA build provenance + CycloneDX + SBOM) against its own bytes, then publishes THAT exact tarball to npm (OIDC trusted + publishing + provenance), creates a GitHub release (attaching the SBOM), and + announces on Slack. No build, no attest — it ships a prebuilt, attested artifact. + Tamper-evident: any change to the tarball in transit fails verification and blocks + the publish. Requires id-token: write + contents: write. The workflow downloads the + artifact (tarball + sbom.json) into working_directory before calling this action. + +inputs: + tarball: + description: "Path to the downloaded tarball to verify + publish (the exact bytes pack attested)" + required: true + working_directory: + description: "Directory holding the downloaded tarball + sbom.json (used to attach the SBOM to the release)" + required: false + default: '.' + sha: + description: "Commit SHA being released (release target)" + required: true + npm: + description: "Whether to publish to the npm registry" + required: false + default: 'true' + registry: + description: "npm registry URL" + required: false + default: 'https://registry.npmjs.org' + access: + description: "npm publish access: public | restricted (provenance requires public)" + required: false + default: 'public' + provenance: + description: "Publish with npm-native provenance (requires a public package)" + required: false + default: 'true' + channel: + description: "npm release channel (dist-tag) to publish under" + required: false + default: 'latest' + dry_run: + description: "Skip verification, npm publish, tag push, and GitHub release creation" + required: false + default: 'false' + release_tag: + description: "The release tag (e.g. v0.3.2)" + required: true + github_release: + description: "Whether to create a GitHub release" + required: false + default: 'true' + release_title: + description: "GitHub release name/title. Defaults to the release tag when empty." + required: false + default: '' + sbom: + description: "Attach the downloaded sbom.json to the GitHub release" + required: false + default: 'true' + version: + description: "The package version — used to build the npm link in notifications" + required: false + default: '' + package_name: + description: "npm package name — used to build the npm link in notifications" + required: false + default: '' + label: + description: "Display name for release notifications (e.g. the package name)." + required: false + default: '' + notes: + description: "Base64-encoded release notes from the prepare action" + required: false + default: '' + prev_release: + description: "Previous release tag — used in completion notification" + required: false + default: '' + branch: + description: "Branch the release was made from" + required: true + on_release_branch: + description: "Whether the SHA is on an expected release branch" + required: true + pr_list: + description: "x1f-delimited PR list from prepare action" + required: false + default: '' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if the publish fails" + required: false + default: '' + emoji: + description: "Emoji prefix for Slack messages" + required: false + default: ':package:' + +runs: + using: composite + steps: +<%# Verify the attestations against the downloaded bytes BEFORE publishing (tamper-evident). + Skipped on dry runs, where pack didn't attest. -%> +<%= render_step('release/verify-package', + if: "${{ inputs.dry_run != 'true' }}", + artifacts: "${{ inputs.tarball }}") %> + + # Publishing a prebuilt tarball goes through the npm CLI, so this needs Node with a + # user-writable global prefix (to install a modern npm for OIDC) and the registry + # wired for trusted publishing — but no pnpm and no dependency install. + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 'lts/*' + registry-url: ${{ inputs.registry }} + +<%= render_step('lang/js/npm/publish-package', tarball: "${{ inputs.tarball }}") %> + +<%= render_step('release/create-github-release') %> + +<%= render_step('release/lang/js/notify-published') %> + +<%= render_step('release/lang/js/on-failure', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/js/validate.yml.erb b/templates/actions/release/lang/js/validate.yml.erb index f6924d0..0579a2a 100644 --- a/templates/actions/release/lang/js/validate.yml.erb +++ b/templates/actions/release/lang/js/validate.yml.erb @@ -1,24 +1,22 @@ name: Validate JavaScript Release description: > - Checks out the release SHA, sets up Node and pnpm, reads the - package version, validates the release channel and metadata (tag existence, - branch, commit metadata), builds, and generates a CycloneDX SBOM — all to - confirm the package is publishable (and the SBOM tooling works) before the - approval gate. + Judges a JavaScript release against the facts `configure` derived — the toggleable + checks (tag unused, version unpublished, channel allowed, notes present, release + 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. inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Skip tag existence failure in dry runs" + description: "Warn instead of failing on an already-existing tag" required: false default: 'false' version: - description: > - Explicit version override. If provided, the version is not read from - package.json. Use when the version is computed externally (e.g. bt-publishing-test - auto-bump from run number). + description: "Version override (from configure). If empty, read from package.json." required: false default: '' working_directory: @@ -40,27 +38,33 @@ inputs: default: 'https://registry.npmjs.org' package_name: description: > - npm package name (matches the publish action's input). When set, validate fails - fast if this version is already published (covers prereleases, which aren't - git-tagged). Leave empty to skip. + 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: '' - tag_format: - description: "Git tag format, with a {version} placeholder (e.g. js-{version})" - required: false - default: 'v{version}' + release_tag: + description: "The release tag (from configure) — checked for prior existence" + required: true channel: - description: "npm release channel (dist-tag) this release targets" + description: "The npm channel/dist-tag (from configure) — checked against allowed_channels" required: false default: 'latest' allowed_channels: description: "Comma-separated allowlist of accepted release channels" required: false default: 'latest' + on_release_branch: + description: "Whether the SHA is on an allowed release branch (from configure)" + required: false + default: 'true' + notes: + description: "Base64-encoded release notes (from prepare) — checked non-blank" + required: false + default: '' build: description: > - Whether to run the build step. Set to 'false' for packages that publish - their sources directly (no build) — the build step then no-ops. + Whether to run the build step. Set to 'false' for packages that publish their + sources directly (no build) — the build step then no-ops. required: false default: 'true' build_command: @@ -69,30 +73,31 @@ inputs: default: 'build' sbom: description: > - Generate a CycloneDX SBOM as a pre-gate check (same generator as publish), so - SBOM-tooling failures (e.g. pnpm < 11.8) surface BEFORE the approval gate. The - SBOM is not signed or attached here — that happens in publish. + Generate a CycloneDX SBOM as a pre-gate check (same generator as build-and-ship), + so SBOM-tooling failures surface BEFORE the approval gate. Not signed or attached + here — that happens in build-and-ship. required: false default: 'true' -outputs: - release_tag: - description: "The release tag (e.g. v0.3.2)" - value: ${{ steps.validate.outputs.release_tag }} - prev_release: - description: "The previous release tag" - value: ${{ steps.validate.outputs.prev_release }} - branch: - description: "The branch containing the SHA" - value: ${{ steps.validate.outputs.branch }} - on_release_branch: - description: "Whether the SHA is on an expected release branch" - value: ${{ steps.validate.outputs.on_release_branch }} - commit_message: - description: "The commit message at the SHA" - value: ${{ steps.validate.outputs.commit_message }} - version: - description: "The resolved package version" - value: ${{ steps.read-version.outputs.version }} + check_tag_unused: + description: "Fail if the release tag already exists" + required: false + default: 'true' + check_version_unpublished: + description: "Fail if the version is already published to the registry" + required: false + default: 'true' + check_channel_allowed: + description: "Fail if channel is not in allowed_channels" + required: false + default: 'true' + check_notes_not_blank: + description: "Warn if release notes are blank" + required: false + default: 'true' + enforce_release_branch: + description: "Hard-fail (instead of warn) when the SHA is off the release branch" + required: false + default: 'false' runs: using: composite @@ -102,19 +107,19 @@ runs: ref: ${{ inputs.sha }} fetch-depth: 0 -<%= render_step('lang/js/setup') %> +<%= render_step('lang/js/pnpm/setup') %> -<%= render_step('lang/js/read-version') %> +<%= render_step('lang/js/read-version', version: "${{ inputs.version }}") %> -<%= render_step('release/lang/js/npm-availability') %> +<%= render_step('lang/js/npm/availability', if: "${{ inputs.check_version_unpublished == 'true' }}") %> -<%= render_step('release/lang/js/validate-channel') %> +<%= render_step('release/lang/js/validate-channel', if: "${{ inputs.check_channel_allowed == 'true' }}") %> -<%= render_step('release/validate', version: "${{ steps.read-version.outputs.version }}", tag_format: "${{ inputs.tag_format }}") %> +<%= render_step('release/checks') %> -<%= render_step('lang/js/build', if: "${{ inputs.build == 'true' }}") %> +<%= render_step('lang/js/pnpm/build', if: "${{ inputs.build == 'true' }}") %> <%# Pre-gate check: confirm SBOM generation works before the approval gate (not signed here). -%> -<%= render_step('release/lang/js/sbom', if: "${{ inputs.sbom == 'true' }}") %> +<%= render_step('lang/js/pnpm/sbom', if: "${{ inputs.sbom == 'true' }}") %> <%= render_step('lang/js/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/py/publish.yml.erb b/templates/actions/release/lang/py/build-and-ship.yml.erb similarity index 87% rename from templates/actions/release/lang/py/publish.yml.erb rename to templates/actions/release/lang/py/build-and-ship.yml.erb index c12bbc8..7f8d7b0 100644 --- a/templates/actions/release/lang/py/publish.yml.erb +++ b/templates/actions/release/lang/py/build-and-ship.yml.erb @@ -1,11 +1,11 @@ -name: Publish Python Package +name: Build and Ship Python Package description: > - Full Python package release: checks out the SHA, sets up uv and Python, - builds the sdist + wheel, generates and signs a CycloneDX SBOM (bound to the - built dists), publishes to PyPI (OIDC Trusted Publishing with PEP 740 - attestations), creates a GitHub release (with the SBOM attached), and sends a - Slack completion notification. Covers the standard publish sequence for - Braintrust PyPI packages. + Turnkey Python release — the whole pipeline in one gated job: checks out the SHA, + sets up uv and Python, builds the sdist + wheel, generates and signs a CycloneDX SBOM + (bound to the built dists), publishes to PyPI (OIDC Trusted Publishing with PEP 740 + attestations), creates a GitHub release (with the SBOM attached), and announces on + Slack. This is the `build → attest → publish → release → announce` pipeline; the + custom-build path (pack + ship-package) is the same pipeline split across two jobs. inputs: sha: @@ -125,7 +125,7 @@ runs: <%= render_step('lang/py/build') %> -<%= render_step('release/lang/py/sbom', if: "${{ inputs.sbom == 'true' }}") %> +<%= render_step('lang/py/sbom', if: "${{ inputs.sbom == 'true' }}") %> <%# Attest only the distributions. `uv build` drops a `dist/.gitignore`, and actions/attest's glob matches dotfiles (with no brace support), so target the two dist extensions explicitly. -%> @@ -134,7 +134,7 @@ runs: subject_path: ["${{ inputs.working_directory }}/dist/*.whl", "${{ inputs.working_directory }}/dist/*.tar.gz"], sbom_path: "${{ inputs.working_directory }}/sbom.json") %> -<%= render_step('release/lang/py/pypi-publish') %> +<%= render_step('lang/py/pypi/publish') %> <%= 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 new file mode 100644 index 0000000..3ef4ca7 --- /dev/null +++ b/templates/actions/release/lang/py/configure.yml.erb @@ -0,0 +1,103 @@ +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. + +inputs: + sha: + description: "Commit SHA being released" + required: true + version: + description: > + Explicit version override. If provided, the version is not read from version_file. + Use when the version is computed externally (e.g. a run-number auto-bump). + required: false + default: '' + version_file: + description: > + Path (repo-root-relative) to a Python module exposing VERSION (e.g. + py/pkg/version.py). Read when version is not overridden. + required: false + default: '' + working_directory: + description: "Path to the package root (where pyproject.toml lives)" + required: false + default: '.' + tag_format: + description: "Git tag format, with a {version} placeholder (e.g. py-{version})" + required: false + default: 'py-{version}' + release_type: + description: > + 'stable' | 'prerelease'. Maps to the github_release default (prerelease → no + GitHub release). An explicit github_release input overrides this. + required: false + default: 'stable' + github_release: + description: "Explicit github_release override ('true'/'false'). Empty → derived from release_type." + required: false + default: '' + release_branch: + description: "Comma-separated allowed release branch patterns (globs)." + required: false + default: 'main' +outputs: + version: + description: "The resolved package version" + value: ${{ steps.read-version.outputs.version }} + release_tag: + description: "The release tag (e.g. py-0.3.2)" + value: ${{ steps.configure.outputs.release_tag }} + prev_release: + description: "The previous release tag" + value: ${{ steps.configure.outputs.prev_release }} + branch: + description: "The branch containing the SHA" + value: ${{ steps.configure.outputs.branch }} + on_release_branch: + description: "Whether the SHA is on an allowed release branch" + value: ${{ steps.configure.outputs.on_release_branch }} + commit_message: + description: "The commit message at the SHA" + value: ${{ steps.configure.outputs.commit_message }} + github_release: + description: "Whether a GitHub release should be created" + value: ${{ steps.configure.outputs.github_release }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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. + - 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') %> + +<%= render_step('release/configure', + version: "${{ steps.read-version.outputs.version }}", + sha: "${{ inputs.sha }}", + tag_format: "${{ inputs.tag_format }}", + release_branch: "${{ inputs.release_branch }}", + release_type: "${{ inputs.release_type }}", + channel: "", + github_release: "${{ inputs.github_release }}") %> + +<%= render_step('lang/py/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/py/pack.yml.erb b/templates/actions/release/lang/py/pack.yml.erb new file mode 100644 index 0000000..159862c --- /dev/null +++ b/templates/actions/release/lang/py/pack.yml.erb @@ -0,0 +1,68 @@ +name: Pack Python Package (custom build) +description: > + Custom-build side (unprivileged — no publish credentials): checks out the SHA, builds + the sdist + wheel, generates a CycloneDX SBOM, and attests the SBOM + SLSA build + provenance to those dists. The workflow uploads dist/* + sbom.json for the gated + ship-package job. Requires id-token: write + attestations: write. + + Note: unlike turnkey py (which relies on PyPI's native PEP 740 provenance), the custom + path also emits our own build provenance — ship-package verifies it against the + downloaded dists before publishing. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before building. Set to false when the caller already checked out." + required: false + default: 'true' + working_directory: + description: "Path to the package root (where pyproject.toml lives)" + required: false + default: '.' + python_version: + description: > + Python version (e.g. 3.13), or a path to a version file + (.python-version, .tool-versions) relative to the repo root. + required: true + build_command: + description: "Override build command. When empty, `uv build` is used." + required: false + default: '' + sbom: + description: "Generate + attest a CycloneDX SBOM bound to the dists. Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + +<%= render_step('lang/py/setup') %> + +<%= render_step('lang/py/build') %> + +<%= render_step('lang/py/sbom', if: "${{ inputs.sbom == 'true' }}") %> + +<%# uv build drops a dist/.gitignore, and actions/attest's glob matches dotfiles with no + brace support, so target the two dist extensions explicitly. -%> +<%= render_step('release/attest-sbom', + if: "${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}", + subject_path: ["${{ inputs.working_directory }}/dist/*.whl", "${{ inputs.working_directory }}/dist/*.tar.gz"], + sbom_path: "${{ inputs.working_directory }}/sbom.json") %> + +<%= render_step('release/attest-provenance', + if: "${{ inputs.dry_run != 'true' }}", + subject_path: ["${{ inputs.working_directory }}/dist/*.whl", "${{ inputs.working_directory }}/dist/*.tar.gz"]) %> + +<%= 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 new file mode 100644 index 0000000..f3b30c5 --- /dev/null +++ b/templates/actions/release/lang/py/ship-package.yml.erb @@ -0,0 +1,114 @@ +name: Ship Python Package (custom build) +description: > + Custom-build gated publish side: verifies the downloaded dists' attestations against + their own bytes, publishes them to PyPI (OIDC Trusted Publishing + PEP 740), creates a + GitHub release (attaching the SBOM), and announces. No build, no attest — it ships + prebuilt, attested dists. Requires id-token: write + contents: write. The workflow + downloads dist/* (+ sbom.json) into working_directory first. + +inputs: + working_directory: + description: "Directory holding the downloaded dist/ (whl + sdist) and sbom.json" + required: false + default: '.' + sha: + description: "Commit SHA being released (release target)" + required: true + pypi: + description: "Whether to publish to PyPI" + required: false + default: 'true' + attestations: + description: "Publish PEP 740 attestations (Sigstore-signed provenance). Requires id-token: write." + required: false + default: 'true' + dry_run: + description: "Skip verification, PyPI upload, tag push, and GitHub release creation" + required: false + default: 'false' + release_tag: + description: "The release tag (e.g. py-0.3.2)" + required: true + github_release: + description: "Whether to create a GitHub release" + required: false + default: 'true' + release_title: + description: "GitHub release name/title. Defaults to the release tag when empty." + required: false + default: '' + sbom: + description: "Attach the downloaded sbom.json to the GitHub release" + required: false + default: 'true' + version: + description: "The package version — used to build the PyPI link in notifications" + required: false + default: '' + package_name: + description: "PyPI package name — used to build the PyPI link in notifications" + required: false + default: '' + label: + description: "Display name for release notifications (e.g. the package name)." + required: false + default: '' + notes: + description: "Base64-encoded release notes from the prepare action" + required: false + default: '' + prev_release: + description: "Previous release tag — used in completion notification" + required: false + default: '' + branch: + description: "Branch the release was made from" + required: true + on_release_branch: + description: "Whether the SHA is on an expected release branch" + required: true + pr_list: + description: "x1f-delimited PR list from prepare action" + required: false + default: '' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if the publish fails" + required: false + default: '' + emoji: + description: "Emoji prefix for Slack messages" + required: false + default: ':python:' + +runs: + using: composite + steps: + # Set up a default uv/Python so `uv publish` + PEP 740 signing work; no build here. + - name: Set up uv + uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7.2.0 + with: + python-version: '3.13' + +<%= 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') %> + +<%= render_step('release/create-github-release') %> + +<%= render_step('release/lang/py/notify-published') %> + +<%= render_step('release/lang/py/on-failure', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/py/validate.yml.erb b/templates/actions/release/lang/py/validate.yml.erb index b6143ac..e3f3f99 100644 --- a/templates/actions/release/lang/py/validate.yml.erb +++ b/templates/actions/release/lang/py/validate.yml.erb @@ -1,30 +1,25 @@ name: Validate Python Release description: > - Checks out the release SHA, sets up uv and Python, reads the package - version, validates the release metadata (tag existence, branch, commit - metadata) and that the version is not already on PyPI, then builds and generates - a CycloneDX SBOM — confirming the package is publishable (and the SBOM tooling - works) before the approval gate. + Judges a Python release against the facts `configure` derived — the toggleable checks + (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. inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Skip tag existence failure in dry runs" + description: "Warn instead of failing on an already-existing tag" required: false default: 'false' version: - description: > - Explicit version override. If provided, the version is not read from - version_file. Use when the version is computed externally (e.g. bt-publishing-test - auto-bump, or an ephemeral prerelease). + 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 (e.g. - py/pkg/version.py). Read when version is not overridden. + description: "Path (repo-root-relative) to a Python module exposing VERSION." required: false default: '' working_directory: @@ -38,45 +33,54 @@ inputs: required: true package_name: description: > - PyPI package name (matches the publish action's input). When set, validate - fails fast if this version is already published (covers prereleases, which - aren't git-tagged). Leave empty to skip. + 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: '' - tag_format: - description: "Git tag format, with a {version} placeholder (e.g. py-{version})" + release_tag: + description: "The release tag (from configure) — checked for prior existence" + required: true + on_release_branch: + description: "Whether the SHA is on an allowed release branch (from configure)" required: false - default: 'py-{version}' + default: 'true' + notes: + description: "Base64-encoded release notes (from prepare) — checked non-blank" + required: false + default: '' build_command: description: "Override build command. When empty, `uv build` is used." required: false default: '' + build: + description: > + Build the distribution as a pre-gate check. Set to 'false' in the custom-build + shape, where the pack job owns the build. + required: false + default: 'true' sbom: description: > - Generate a CycloneDX SBOM as a pre-gate check (same generator as publish), so - SBOM-tooling failures surface BEFORE the approval gate. Not signed or attached - here — that happens in publish. + Generate a CycloneDX SBOM as a pre-gate check (same generator as build-and-ship), + so SBOM-tooling failures surface BEFORE the approval gate. Not signed or attached + here — that happens in build-and-ship. required: false default: 'true' -outputs: - release_tag: - description: "The release tag (e.g. py-0.3.2)" - value: ${{ steps.validate.outputs.release_tag }} - prev_release: - description: "The previous release tag" - value: ${{ steps.validate.outputs.prev_release }} - branch: - description: "The branch containing the SHA" - value: ${{ steps.validate.outputs.branch }} - on_release_branch: - description: "Whether the SHA is on an expected release branch" - value: ${{ steps.validate.outputs.on_release_branch }} - commit_message: - description: "The commit message at the SHA" - value: ${{ steps.validate.outputs.commit_message }} - version: - description: "The resolved package version" - value: ${{ steps.read-version.outputs.version }} + check_tag_unused: + description: "Fail if the release tag already exists" + required: false + default: 'true' + check_version_unpublished: + description: "Fail if the version is already published to PyPI" + required: false + default: 'true' + check_notes_not_blank: + description: "Warn if release notes are blank" + required: false + default: 'true' + enforce_release_branch: + description: "Hard-fail (instead of warn) when the SHA is off the release branch" + required: false + default: 'false' runs: using: composite @@ -90,13 +94,13 @@ runs: <%= render_step('lang/py/read-version') %> -<%= render_step('release/lang/py/pypi-availability') %> +<%= render_step('lang/py/pypi/availability', if: "${{ inputs.check_version_unpublished == 'true' }}") %> -<%= render_step('release/validate', version: "${{ steps.read-version.outputs.version }}", tag_format: "${{ inputs.tag_format }}") %> +<%= render_step('release/checks') %> -<%= render_step('lang/py/build') %> +<%= render_step('lang/py/build', if: "${{ inputs.build == 'true' }}") %> <%# Pre-gate check: confirm SBOM generation works before the approval gate (not signed here). -%> -<%= render_step('release/lang/py/sbom', if: "${{ inputs.sbom == 'true' }}") %> +<%= render_step('lang/py/sbom', if: "${{ inputs.sbom == 'true' }}") %> <%= render_step('lang/py/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/ruby/publish.yml.erb b/templates/actions/release/lang/ruby/build-and-ship.yml.erb similarity index 84% rename from templates/actions/release/lang/ruby/publish.yml.erb rename to templates/actions/release/lang/ruby/build-and-ship.yml.erb index 42c8e0c..d06d4e3 100644 --- a/templates/actions/release/lang/ruby/publish.yml.erb +++ b/templates/actions/release/lang/ruby/build-and-ship.yml.erb @@ -1,10 +1,12 @@ -name: Publish Ruby Package +name: Build and Ship Ruby Package description: > - Full Ruby gem release: checks out the SHA, builds the gem, generates and signs a - CycloneDX SBOM plus SLSA build provenance (bound to the exact gem), publishes to - RubyGems via OIDC trusted publishing (pushing that same gem — no rebuild), creates - a GitHub release (with the SBOM attached), and sends a Slack completion - notification. Covers the standard publish sequence for all Braintrust Ruby gems. + Turnkey Ruby release — the whole pipeline in one gated job: checks out the SHA, + builds the gem, generates and signs a CycloneDX SBOM plus SLSA build provenance + (bound to the exact gem), publishes to RubyGems via OIDC trusted publishing (pushing + that same gem — no rebuild), creates a GitHub release (with the SBOM attached), and + announces on Slack. This is the `build → attest → publish → release → announce` + pipeline; Ruby's Path C (build once, attest, push that exact gem) is already the + custom-build shape un-split. inputs: sha: @@ -107,9 +109,9 @@ runs: <%# Single build: the pkg/*.gem produced here is the exact file we attest AND push (no rebuild), so the attested digest is guaranteed to be the published digest. -%> -<%= render_step('release/lang/ruby/build') %> +<%= render_step('lang/ruby/build') %> -<%= render_step('release/lang/ruby/sbom', if: "${{ inputs.sbom == 'true' }}") %> +<%= render_step('lang/ruby/sbom', if: "${{ inputs.sbom == 'true' }}") %> <%# Attest the exact gem BEFORE it is pushed, so a failed attestation never leaves a published gem unsigned. -%> @@ -124,7 +126,7 @@ runs: if: "${{ inputs.dry_run != 'true' }}", subject_path: "${{ inputs.working_directory }}/pkg/*.gem") %> -<%= render_step('release/lang/ruby/rubygems-push') %> +<%= render_step('lang/ruby/rubygems/push') %> <%= render_step('release/create-github-release') %> diff --git a/templates/actions/release/lang/ruby/configure.yml.erb b/templates/actions/release/lang/ruby/configure.yml.erb new file mode 100644 index 0000000..3e2f650 --- /dev/null +++ b/templates/actions/release/lang/ruby/configure.yml.erb @@ -0,0 +1,102 @@ +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. + +inputs: + sha: + description: "Commit SHA being released" + required: true + version: + description: > + Explicit version override. If provided, version_file/version_module are ignored. + Use when the version is computed externally (e.g. a run-number auto-bump). + 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 + default: '.' + tag_format: + description: "Git tag format, with a {version} placeholder (e.g. v{version})" + required: false + default: 'v{version}' + release_type: + description: > + 'stable' | 'prerelease'. Maps to the github_release default (prerelease → no + GitHub release). An explicit github_release input overrides this. + required: false + default: 'stable' + github_release: + description: "Explicit github_release override ('true'/'false'). Empty → derived from release_type." + required: false + default: '' + release_branch: + description: "Comma-separated allowed release branch patterns (globs)." + required: false + default: 'main' +outputs: + version: + description: "The resolved gem version" + value: ${{ steps.read-version.outputs.version }} + release_tag: + description: "The release tag (e.g. v0.3.2)" + value: ${{ steps.configure.outputs.release_tag }} + prev_release: + description: "The previous release tag" + value: ${{ steps.configure.outputs.prev_release }} + branch: + description: "The branch containing the SHA" + value: ${{ steps.configure.outputs.branch }} + on_release_branch: + description: "Whether the SHA is on an allowed release branch" + value: ${{ steps.configure.outputs.on_release_branch }} + commit_message: + description: "The commit message at the SHA" + value: ${{ steps.configure.outputs.commit_message }} + github_release: + description: "Whether a GitHub release should be created" + value: ${{ steps.configure.outputs.github_release }} + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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. + - name: Set up Ruby + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 + with: + ruby-version: '3.3' + +<%= render_step('lang/ruby/read-version') %> + +<%= render_step('release/configure', + version: "${{ steps.read-version.outputs.version }}", + sha: "${{ inputs.sha }}", + tag_format: "${{ inputs.tag_format }}", + release_branch: "${{ inputs.release_branch }}", + release_type: "${{ inputs.release_type }}", + channel: "", + github_release: "${{ inputs.github_release }}") %> + +<%= render_step('lang/ruby/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/ruby/pack.yml.erb b/templates/actions/release/lang/ruby/pack.yml.erb new file mode 100644 index 0000000..73ffa20 --- /dev/null +++ b/templates/actions/release/lang/ruby/pack.yml.erb @@ -0,0 +1,57 @@ +name: Pack Ruby Package (custom build) +description: > + Custom-build side (unprivileged — no publish credentials): checks out the SHA, builds + the gem, generates a CycloneDX SBOM, and attests the SBOM + SLSA build provenance to + that exact gem. The workflow uploads pkg/*.gem + sbom.json for the gated ship-package + job. Requires id-token: write + attestations: write. + +inputs: + sha: + description: "Commit SHA to check out and pack" + required: true + checkout: + description: "Check out the SHA before building. Set to false when the caller already checked out." + required: false + default: 'true' + working_directory: + description: "Path to the gem root (where Gemfile and gemspec live)" + required: false + default: '.' + ruby_version: + description: "Ruby version to build with, or a version file path (.tool-versions, .ruby-version)" + required: false + default: '4.0' + sbom: + description: "Generate + attest a CycloneDX SBOM bound to the gem. Requires attestations: write." + required: false + default: 'true' + dry_run: + description: "Skip attestation (nothing is uploaded to the transparency log on a dry run)" + required: false + default: 'false' + +runs: + using: composite + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: ${{ inputs.checkout == 'true' }} + with: + ref: ${{ inputs.sha }} + fetch-depth: 0 + +<%= render_step('lang/ruby/setup') %> + +<%= render_step('lang/ruby/build') %> + +<%= render_step('lang/ruby/sbom', if: "${{ inputs.sbom == 'true' }}") %> + +<%= render_step('release/attest-sbom', + if: "${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}", + subject_path: "${{ inputs.working_directory }}/pkg/*.gem", + sbom_path: "${{ inputs.working_directory }}/sbom.json") %> + +<%= render_step('release/attest-provenance', + if: "${{ inputs.dry_run != 'true' }}", + subject_path: "${{ inputs.working_directory }}/pkg/*.gem") %> + +<%= 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 new file mode 100644 index 0000000..9fd6dd5 --- /dev/null +++ b/templates/actions/release/lang/ruby/ship-package.yml.erb @@ -0,0 +1,103 @@ +name: Ship Ruby Package (custom build) +description: > + Custom-build gated publish side: verifies the downloaded gem's attestations against + its own bytes, pushes that exact gem to RubyGems (OIDC trusted publishing), creates a + GitHub release (attaching the SBOM), and announces. No build, no attest — it ships a + prebuilt, attested gem. Requires id-token: write + contents: write. The workflow + downloads the gem + sbom.json into working_directory first. + +inputs: + working_directory: + description: "Directory holding the downloaded gem + sbom.json" + required: false + default: '.' + sha: + description: "Commit SHA being released (release target)" + required: true + dry_run: + description: "Skip verification, gem push, tag push, and GitHub release creation" + required: false + default: 'false' + release_tag: + description: "The release tag (e.g. v0.3.2)" + required: true + github_release: + description: "Whether to create a GitHub release" + required: false + default: 'true' + release_title: + description: "GitHub release name/title. Defaults to the release tag when empty." + required: false + default: '' + sbom: + description: "Attach the downloaded sbom.json to the GitHub release" + required: false + default: 'true' + gem_name: + description: "RubyGems gem name — used to build the RubyGems link in notifications" + required: false + default: '' + label: + description: "Display name for release notifications (e.g. the gem name)." + required: false + default: '' + notes: + description: "Base64-encoded release notes from the prepare action" + required: false + default: '' + prev_release: + description: "Previous release tag — used in completion notification" + required: false + default: '' + branch: + description: "Branch the release was made from" + required: true + on_release_branch: + description: "Whether the SHA is on an expected release branch" + required: true + pr_list: + description: "x1f-delimited PR list from prepare action" + required: false + default: '' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if the publish fails" + required: false + default: '' + emoji: + description: "Emoji prefix for Slack messages" + required: false + default: ':ruby:' + +runs: + using: composite + steps: + # Set up a default Ruby so `gem push` has a modern rubygems (OIDC trusted publishing); + # no bundle install — this job doesn't build. + - name: Set up Ruby + uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0 + with: + ruby-version: '3.3' + +<%= render_step('release/verify-package', + if: "${{ inputs.dry_run != 'true' }}", + artifacts: "${{ inputs.working_directory }}/*.gem") %> + +<%= render_step('lang/ruby/rubygems/push', gem: "*.gem") %> + +<%= render_step('release/create-github-release') %> + +<%= render_step('release/lang/ruby/notify-published') %> + +<%= render_step('release/lang/ruby/on-failure', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/ruby/validate.yml.erb b/templates/actions/release/lang/ruby/validate.yml.erb index d94dd52..1cf0539 100644 --- a/templates/actions/release/lang/ruby/validate.yml.erb +++ b/templates/actions/release/lang/ruby/validate.yml.erb @@ -1,23 +1,21 @@ name: Validate Ruby Release description: > - Checks out the release SHA, sets up Ruby, reads the gem version, validates - the release (RubyGems availability, tag existence, branch, commit metadata), - and runs lint, build, and SBOM generation to confirm the gem is publishable - (and the SBOM tooling works) before the approval gate. + Judges a Ruby release against the facts `configure` derived — the toggleable checks + (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. inputs: sha: description: "Commit SHA being released" required: true dry_run: - description: "Skip tag existence failure in dry runs" + description: "Warn instead of failing on an already-existing tag" required: false default: 'false' version: - description: > - Explicit version override. If provided, version_file and version_module - are ignored. Use when the version is computed externally (e.g. bt-publishing-test - auto-bump from run number). + description: "Version override (from configure). If empty, read from version_file/version_module." required: false default: '' version_file: @@ -32,40 +30,57 @@ inputs: description: "Path to the gem root (where Gemfile and gemspec live)" required: false default: '.' + ruby_version: + 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. When set, validate fails fast if this version is already - published — the registry, not the git tag, is the source of truth (covers - prereleases and force-deleted tags). Leave empty to skip. + 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: '' - ruby_version: - description: "Ruby version to use, or a version file path (.tool-versions, .ruby-version)" + release_tag: + description: "The release tag (from configure) — checked for prior existence" + required: true + on_release_branch: + description: "Whether the SHA is on an allowed release branch (from configure)" required: false - default: '4.0' + default: 'true' + notes: + description: "Base64-encoded release notes (from prepare) — checked non-blank" + required: false + default: '' + build: + description: > + Build the gem as a pre-gate check. Set to 'false' in the custom-build shape, where + the pack job owns the build (lint still runs). + required: false + default: 'true' sbom: description: > - Generate a CycloneDX SBOM as a pre-gate check (same generator as publish), so - SBOM-generation failures surface BEFORE the approval gate. Not signed or - attached here — that happens in publish. + Generate a CycloneDX SBOM as a pre-gate check (same generator as build-and-ship), + so SBOM-generation failures surface BEFORE the approval gate. Not signed or + attached here — that happens in build-and-ship. required: false default: 'true' -outputs: - release_tag: - description: "The release tag (e.g. v0.3.2)" - value: ${{ steps.validate.outputs.release_tag }} - prev_release: - description: "The previous release tag" - value: ${{ steps.validate.outputs.prev_release }} - branch: - description: "The branch containing the SHA" - value: ${{ steps.validate.outputs.branch }} - on_release_branch: - description: "Whether the SHA is on an expected release branch" - value: ${{ steps.validate.outputs.on_release_branch }} - commit_message: - description: "The commit message at the SHA" - value: ${{ steps.validate.outputs.commit_message }} + check_tag_unused: + description: "Fail if the release tag already exists" + required: false + default: 'true' + check_version_unpublished: + description: "Fail if the version is already published to RubyGems" + required: false + default: 'true' + check_notes_not_blank: + description: "Warn if release notes are blank" + required: false + default: 'true' + enforce_release_branch: + description: "Hard-fail (instead of warn) when the SHA is off the release branch" + required: false + default: 'false' runs: using: composite @@ -79,19 +94,19 @@ runs: <%= render_step('lang/ruby/read-version') %> -<%= render_step('release/lang/ruby/rubygems-availability') %> +<%= render_step('lang/ruby/rubygems/availability', if: "${{ inputs.check_version_unpublished == 'true' }}") %> -<%= render_step('release/validate', version: "${{ steps.read-version.outputs.version }}") %> +<%= render_step('release/checks') %> - name: Lint shell: bash working-directory: ${{ inputs.working_directory }} run: bundle exec rake lint -<%# Build the gem (same partial as publish) to confirm it builds before the gate. -%> -<%= render_step('release/lang/ruby/build') %> +<%# Build the gem (same partial as build-and-ship) to confirm it builds before the gate. -%> +<%= render_step('lang/ruby/build', if: "${{ inputs.build == 'true' }}") %> <%# Pre-gate check: confirm SBOM generation works before the approval gate (not signed here). -%> -<%= render_step('release/lang/ruby/sbom', if: "${{ inputs.sbom == 'true' }}") %> +<%= render_step('lang/ruby/sbom', if: "${{ inputs.sbom == 'true' }}") %> <%= render_step('lang/ruby/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/notify-pending.yml.erb b/templates/actions/release/request-approval.yml.erb similarity index 93% rename from templates/actions/release/notify-pending.yml.erb rename to templates/actions/release/request-approval.yml.erb index dd619dd..3cc7902 100644 --- a/templates/actions/release/notify-pending.yml.erb +++ b/templates/actions/release/request-approval.yml.erb @@ -1,7 +1,10 @@ -name: Notify Release Pending +name: Request Release Approval description: > - Posts the pre-approval job summary and Slack notification. - Called before the environment gate — reviewer sees this before approving. + 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. inputs: sha: diff --git a/templates/steps/lang/js/bun/build.yml.erb b/templates/steps/lang/js/bun/build.yml.erb new file mode 100644 index 0000000..1ed8b11 --- /dev/null +++ b/templates/steps/lang/js/bun/build.yml.erb @@ -0,0 +1,24 @@ +<%# + Builds a Bun project into a bundle and emits a metafile — the module graph of what + `bun build` actually inlined — which sbom-bun turns into an SBOM faithful to the + shipped bundle. Parametrized by the standard bun build knobs. + + @param entry [String] build entrypoint. Default: src/index.ts + @param outdir [String] output directory. Default: dist + @param target [String] bun build --target (bun|node|browser). Default: bun + @param format [String] bun build --format (esm|cjs|iife). Default: esm + @param metafile [String] metafile path, relative to working_directory. Default: meta.json + @param working_directory [String] package root. Default: ${{ inputs.working_directory }} +-%> +- name: Build (bun) + shell: bash + working-directory: <%= locals.fetch(:working_directory) { "${{ inputs.working_directory }}" } %> + env: + ENTRY: <%= locals.fetch(:entry) { "src/index.ts" } %> + OUTDIR: <%= locals.fetch(:outdir) { "dist" } %> + TARGET: <%= locals.fetch(:target) { "bun" } %> + FORMAT: <%= locals.fetch(:format) { "esm" } %> + METAFILE: <%= locals.fetch(:metafile) { "meta.json" } %> + run: | + bun build "$ENTRY" --outdir "$OUTDIR" --target "$TARGET" --format "$FORMAT" --metafile="$METAFILE" + echo "Built $OUTDIR (metafile: $METAFILE)" diff --git a/templates/steps/lang/js/bun/pack.yml.erb b/templates/steps/lang/js/bun/pack.yml.erb new file mode 100644 index 0000000..1af03f0 --- /dev/null +++ b/templates/steps/lang/js/bun/pack.yml.erb @@ -0,0 +1,22 @@ +<%# + Packs the Bun package into the exact npm tarball via `bun pm pack`, to a clean + directory. Sets id: pack with `tarball` (absolute path) + `integrity` (sha512) — + matching lang/js/pack-pnpm, so the composing action treats both toolchains + identically (validate/attest/ship steps are shared). + + @param working_directory [String] package root. Default: ${{ inputs.working_directory }} + @param pack_dir [String] clean output dir. Default: ${{ runner.temp }}/pkgout +-%> +- name: Pack (bun pm pack) + id: pack + shell: bash + working-directory: <%= locals.fetch(:working_directory) { "${{ inputs.working_directory }}" } %> + env: + PACK_DIR: <%= locals.fetch(:pack_dir) { "${{ runner.temp }}/pkgout" } %> + run: | + rm -rf "$PACK_DIR"; mkdir -p "$PACK_DIR" + bun pm 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" + echo "Packed $TGZ" diff --git a/templates/steps/lang/js/bun/sbom.yml.erb b/templates/steps/lang/js/bun/sbom.yml.erb new file mode 100644 index 0000000..6458730 --- /dev/null +++ b/templates/steps/lang/js/bun/sbom.yml.erb @@ -0,0 +1,59 @@ +<%# + Generates a CycloneDX SBOM for a BUNDLED Bun artifact from the `bun build` metafile — + the module graph of what was actually inlined — so the SBOM matches the shipped + bundle rather than a re-resolution of package.json. Maps each bundled node_modules + module to its package@version and emits CycloneDX 1.7 (with serialNumber, so + actions/attest recognizes it). Bun has no native SBOM, and cyclonedx-npm/cdxgen + describe declared deps, not the bundle — hence this metafile-based generator. + + Prerequisite: the build must have run `bun build … --metafile=`. + + @param working_directory [String] package root (holds package.json + the metafile). Default: ${{ inputs.working_directory }} + @param metafile [String] bun build metafile path, relative to working_directory. Default: meta.json + @param output [String] SBOM output path, relative to working_directory. Default: sbom.json +-%> +- name: Generate SBOM (CycloneDX, from Bun bundle metafile) + shell: bash + working-directory: <%= locals.fetch(:working_directory) { "${{ inputs.working_directory }}" } %> + env: + METAFILE: <%= locals.fetch(:metafile) { "meta.json" } %> + OUTPUT: <%= locals.fetch(:output) { "sbom.json" } %> + run: | + # .cjs so it runs as CommonJS even when the package is "type": "module". + cat > "$RUNNER_TEMP/bun-sbom.cjs" <<'CJS' + const fs = require("fs"), crypto = require("crypto"); + const meta = JSON.parse(fs.readFileSync(process.env.METAFILE, "utf8")); + const root = JSON.parse(fs.readFileSync("package.json", "utf8")); + // Collect every node_modules module the bundle actually included. The last + // node_modules segment is the installed package (handles nesting + @scope/name). + const seen = new Map(); + for (const out of Object.values(meta.outputs || {})) { + for (const input of Object.keys(out.inputs || {})) { + const m = input.match(/^(.*node_modules\/(?:@[^/]+\/[^/]+|[^/]+))\//); + if (!m || seen.has(m[1])) continue; + try { + const pj = JSON.parse(fs.readFileSync(m[1] + "/package.json", "utf8")); + if (pj.name && pj.version) seen.set(m[1], { name: pj.name, version: pj.version }); + } catch { /* not a package root; skip */ } + } + } + const purl = (n, v) => `pkg:npm/${n[0] === "@" ? "%40" + n.slice(1) : n}@${v}`; + const components = [...seen.values()] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((c) => ({ type: "library", name: c.name, version: c.version, purl: purl(c.name, c.version) })); + const bom = { + bomFormat: "CycloneDX", + specVersion: "1.7", + // Required: actions/attest recognizes CycloneDX only with bomFormat + specVersion + serialNumber. + serialNumber: `urn:uuid:${crypto.randomUUID()}`, + version: 1, + metadata: { + tools: { components: [{ type: "application", group: "braintrust", name: "sdk-actions bun sbom" }] }, + component: { type: "application", name: root.name, version: root.version, purl: purl(root.name, root.version) }, + }, + components, + }; + fs.writeFileSync(process.env.OUTPUT, JSON.stringify(bom, null, 2)); + console.log(`Wrote ${process.env.OUTPUT}: ${components.length} bundled component(s)`); + CJS + node "$RUNNER_TEMP/bun-sbom.cjs" diff --git a/templates/steps/lang/js/bun/setup.yml.erb b/templates/steps/lang/js/bun/setup.yml.erb new file mode 100644 index 0000000..47f7083 --- /dev/null +++ b/templates/steps/lang/js/bun/setup.yml.erb @@ -0,0 +1,20 @@ +<%# + Sets up Bun and installs dependencies (frozen). The Bun analogue of lang/js/setup. + When bun_version is empty, setup-bun resolves it from the project (.tool-versions / + package.json) or falls back to latest. + + @param bun_version [String] Bun version. Empty → auto-detect. Default: ${{ inputs.bun_version }} + @param working_directory [String] package root. Default: ${{ inputs.working_directory }} +-%> +- name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: <%= locals.fetch(:bun_version) { "${{ inputs.bun_version }}" } %> +- name: Install dependencies + shell: bash + working-directory: <%= locals.fetch(:working_directory) { "${{ inputs.working_directory }}" } %> + run: | + bun install --frozen-lockfile || { + echo "::error title=Dependency install failed::'bun install --frozen-lockfile' failed — the lockfile is likely out of sync with package.json (run 'bun install' and commit bun.lock)." + exit 1 + } diff --git a/templates/steps/release/lang/js/npm-availability.yml.erb b/templates/steps/lang/js/npm/availability.yml.erb similarity index 100% rename from templates/steps/release/lang/js/npm-availability.yml.erb rename to templates/steps/lang/js/npm/availability.yml.erb diff --git a/templates/steps/lang/js/npm/publish-package.yml.erb b/templates/steps/lang/js/npm/publish-package.yml.erb new file mode 100644 index 0000000..99a6931 --- /dev/null +++ b/templates/steps/lang/js/npm/publish-package.yml.erb @@ -0,0 +1,52 @@ +<%# + Publishes the EXACT prebuilt tarball to npm with OIDC trusted publishing (no token) + — the publish half of the custom-build ship. Runs `npm publish ` from the + tarball's OWN directory (never a source tree): that dir has no package.json, so a + strict-pnpm consumer's devEngines/onFail:error can't fire (the check reads the CWD + manifest — verified locally). Publishes the exact attested bytes, so + published == attested. Self-guards on the `npm` toggle and dry_run. + + @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 }} + @param access [String] public | restricted. Default: ${{ inputs.access }} + @param provenance [String] emit npm provenance. Default: ${{ inputs.provenance }} + @param registry [String] registry URL. Default: ${{ inputs.registry }} + @param dry_run [String] skip the upload. Default: ${{ inputs.dry_run }} +-%> +- name: Publish prebuilt tarball to npm + shell: bash + env: + TGZ: <%= locals.fetch(:tarball) %> + NPM_ENABLED: <%= locals.fetch(:npm) { "${{ inputs.npm }}" } %> + CHANNEL: <%= locals.fetch(:channel) { "${{ inputs.channel }}" } %> + ACCESS: <%= locals.fetch(:access) { "${{ inputs.access }}" } %> + PROVENANCE: <%= locals.fetch(:provenance) { "${{ inputs.provenance }}" } %> + REGISTRY: <%= locals.fetch(:registry) { "${{ inputs.registry }}" } %> + DRY_RUN: <%= locals.fetch(:dry_run) { "${{ inputs.dry_run }}" } %> + # Force OIDC: clear any inherited token so publish can't fall back to token auth. + NODE_AUTH_TOKEN: '' + NPM_TOKEN: '' + run: | + if [ "$NPM_ENABLED" != "true" ]; then + echo "npm registry publishing disabled; skipping." + exit 0 + fi + # pnpm can't publish a prebuilt tarball (it re-packs from a dir), so ship uses the + # npm CLI directly; pin >= 11.5.1 for OIDC (newer than setup-node's). + npm install -g "npm@11.6.2" + # Publish from the tarball's OWN directory — no package.json there, so a strict-pnpm + # consumer's devEngines onFail:error can't trip (it checks the CWD manifest). A bare + # path with a "/" is also read by npm as a GitHub shorthand, so we cd + use ./. + DIR=$(dirname "$TGZ"); FILE=$(basename "$TGZ") + cd "$DIR" + ARGS=(--tag "$CHANNEL" --access "$ACCESS" --registry "$REGISTRY") + [ "$PROVENANCE" = "true" ] && ARGS+=(--provenance) + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: npm publish ./$FILE ${ARGS[*]} --dry-run" + ARGS+=(--dry-run) + fi + npm publish "./$FILE" "${ARGS[@]}" || { + echo "::error title=npm publish failed::ENEEDAUTH → the npm trusted publisher must list this repo, workflow, and (if gated) environment; 'cannot publish over ' → the version already exists on npm (bump it)." + exit 1 + } diff --git a/templates/steps/release/lang/js/npm-publish.yml.erb b/templates/steps/lang/js/npm/publish.yml.erb similarity index 100% rename from templates/steps/release/lang/js/npm-publish.yml.erb rename to templates/steps/lang/js/npm/publish.yml.erb diff --git a/templates/steps/release/lang/js/attest-sbom.yml.erb b/templates/steps/lang/js/pnpm/attest-sbom.yml.erb similarity index 100% rename from templates/steps/release/lang/js/attest-sbom.yml.erb rename to templates/steps/lang/js/pnpm/attest-sbom.yml.erb diff --git a/templates/steps/lang/js/build.yml.erb b/templates/steps/lang/js/pnpm/build.yml.erb similarity index 100% rename from templates/steps/lang/js/build.yml.erb rename to templates/steps/lang/js/pnpm/build.yml.erb diff --git a/templates/steps/lang/js/pnpm/pack.yml.erb b/templates/steps/lang/js/pnpm/pack.yml.erb new file mode 100644 index 0000000..6103ed9 --- /dev/null +++ b/templates/steps/lang/js/pnpm/pack.yml.erb @@ -0,0 +1,24 @@ +<%# + Packs the built package into the exact tarball that will be published, to a clean + directory (a stray .tgz left in the package dir would be packed into the next + tarball — spike-observed). `pnpm pack` emits the exact bytes `pnpm publish` uploads + (pnpm ≥ 11.8, including workspace: rewriting — spike-verified), so this is the + 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 +-%> +- name: Pack + id: pack + shell: bash + working-directory: <%= locals.fetch(:working_directory) { "${{ inputs.working_directory }}" } %> + env: + 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 + TGZ=$(ls "$PACK_DIR"/*.tgz) + echo "tarball=$TGZ" >> "$GITHUB_OUTPUT" + echo "integrity=sha512-$(openssl dgst -sha512 -binary "$TGZ" | openssl base64 -A)" >> "$GITHUB_OUTPUT" + echo "Packed $TGZ" diff --git a/templates/steps/release/lang/js/sbom.yml.erb b/templates/steps/lang/js/pnpm/sbom.yml.erb similarity index 100% rename from templates/steps/release/lang/js/sbom.yml.erb rename to templates/steps/lang/js/pnpm/sbom.yml.erb diff --git a/templates/steps/lang/js/setup.yml.erb b/templates/steps/lang/js/pnpm/setup.yml.erb similarity index 100% rename from templates/steps/lang/js/setup.yml.erb rename to templates/steps/lang/js/pnpm/setup.yml.erb diff --git a/templates/steps/release/lang/py/pypi-availability.yml.erb b/templates/steps/lang/py/pypi/availability.yml.erb similarity index 100% rename from templates/steps/release/lang/py/pypi-availability.yml.erb rename to templates/steps/lang/py/pypi/availability.yml.erb diff --git a/templates/steps/release/lang/py/pypi-publish.yml.erb b/templates/steps/lang/py/pypi/publish.yml.erb similarity index 100% rename from templates/steps/release/lang/py/pypi-publish.yml.erb rename to templates/steps/lang/py/pypi/publish.yml.erb diff --git a/templates/steps/release/lang/py/sbom.yml.erb b/templates/steps/lang/py/sbom.yml.erb similarity index 100% rename from templates/steps/release/lang/py/sbom.yml.erb rename to templates/steps/lang/py/sbom.yml.erb diff --git a/templates/steps/lang/py/setup.yml.erb b/templates/steps/lang/py/setup.yml.erb index be3fdd7..f2a0d40 100644 --- a/templates/steps/lang/py/setup.yml.erb +++ b/templates/steps/lang/py/setup.yml.erb @@ -32,3 +32,5 @@ # Installs this Python and exports UV_PYTHON for the job, so subsequent uv # commands (build, read-version) use it. python-version: ${{ steps.py-src.outputs.spec }} + # Don't fail the job when there's nothing to cache (jobs that install no deps). + ignore-nothing-to-cache: "true" diff --git a/templates/steps/release/lang/ruby/build.yml.erb b/templates/steps/lang/ruby/build.yml.erb similarity index 100% rename from templates/steps/release/lang/ruby/build.yml.erb rename to templates/steps/lang/ruby/build.yml.erb diff --git a/templates/steps/release/lang/ruby/rubygems-availability.yml.erb b/templates/steps/lang/ruby/rubygems/availability.yml.erb similarity index 100% rename from templates/steps/release/lang/ruby/rubygems-availability.yml.erb rename to templates/steps/lang/ruby/rubygems/availability.yml.erb diff --git a/templates/steps/release/lang/ruby/rubygems-push.yml.erb b/templates/steps/lang/ruby/rubygems/push.yml.erb similarity index 52% rename from templates/steps/release/lang/ruby/rubygems-push.yml.erb rename to templates/steps/lang/ruby/rubygems/push.yml.erb index d457bbf..85c3777 100644 --- a/templates/steps/release/lang/ruby/rubygems-push.yml.erb +++ b/templates/steps/lang/ruby/rubygems/push.yml.erb @@ -1,11 +1,11 @@ <%# - Publishes the PRE-BUILT gem to RubyGems via OIDC trusted publishing — pushing the - exact gem built (and attested) earlier, with NO rebuild, so the attested digest is - the published digest. Uses rubygems/configure-rubygems-credentials (the official - RubyGems OIDC action, the same one rubygems/release-gem uses internally) to obtain - a short-lived trusted-publishing token, then `gem push`. On a dry run, skips both - credential setup and the push. Requires id-token: write and a RubyGems trusted - publisher configured for this gem (repo + workflow, and environment if gated). + Pushes a pre-built gem to RubyGems via OIDC trusted publishing (no token) — the exact + file, no rebuild, so attested digest == published digest. Skipped on dry runs. + Requires id-token: write + a RubyGems trusted publisher (repo + workflow, and + environment if gated). Shared by turnkey build-and-ship and custom ship-package; only + the gem's location differs. + + @param gem [String] gem glob, relative to working_directory. Default: pkg/*.gem -%> - name: Configure RubyGems credentials (OIDC trusted publishing) if: ${{ inputs.dry_run != 'true' }} @@ -14,9 +14,14 @@ shell: bash working-directory: ${{ inputs.working_directory }} env: + GEM_GLOB: "<%= locals.fetch(:gem) { "pkg/*.gem" } %>" DRY_RUN: ${{ inputs.dry_run }} run: | - GEM=$(ls pkg/*.gem) + GEM=$(ls $GEM_GLOB 2>/dev/null | head -1) + if [ -z "$GEM" ]; then + echo "::error title=Gem not found::no gem matched '$GEM_GLOB' in $(pwd)." + exit 1 + fi if [ "$DRY_RUN" = "true" ]; then echo "DRY RUN: would gem push $GEM" exit 0 diff --git a/templates/steps/release/lang/ruby/sbom.yml.erb b/templates/steps/lang/ruby/sbom.yml.erb similarity index 100% rename from templates/steps/release/lang/ruby/sbom.yml.erb rename to templates/steps/lang/ruby/sbom.yml.erb diff --git a/templates/steps/release/attest-provenance.yml.erb b/templates/steps/release/attest-provenance.yml.erb index 82cf4e2..174bae2 100644 --- a/templates/steps/release/attest-provenance.yml.erb +++ b/templates/steps/release/attest-provenance.yml.erb @@ -1,15 +1,19 @@ <%# - Generates SLSA build provenance for an artifact via GitHub's official + Signs SLSA build provenance for an artifact via GitHub's official actions/attest-build-provenance (Sigstore → Rekor), verifiable with - `gh attestation verify`. Used where the package registry doesn't mint its own - provenance (Ruby); npm (`--provenance`) and PyPI (PEP 740) already do. - Requires id-token: write + attestations: write. The caller stamps the `if:` - (not a dry run) and points subject_path at the built artifact, attested BEFORE - publish so a failed attestation never leaves a published artifact without provenance. + `gh attestation verify`. Used where the registry mints no native provenance (Ruby, + and every custom build — ship-package verifies it); npm (--provenance) and PyPI + (PEP 740) cover the turnkey js/py case. Requires id-token: write + attestations: + write. The caller stamps the `if:` (not a dry run) and points subject_path at the + artifact(s), attested BEFORE publish. - @param subject_path [String] path/glob to the artifact to attest. + @param subject_path [String, Array] path/glob(s) to attest. Pass an array for + multiple globs (e.g. ['dist/*.whl', 'dist/*.tar.gz']). -%> - name: Attest build provenance uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: - subject-path: <%= locals.fetch(:subject_path) %> + subject-path: |- +<%- Array(locals.fetch(:subject_path)).each do |path| -%> + <%= path %> +<%- end -%> diff --git a/templates/steps/release/checks.yml.erb b/templates/steps/release/checks.yml.erb new file mode 100644 index 0000000..ada9bb9 --- /dev/null +++ b/templates/steps/release/checks.yml.erb @@ -0,0 +1,55 @@ +<%# + Judges a release against the facts configure derived — the toggleable guards that can + fail on repository/registry state. Each is a boolean input defaulting to 'true' + (enforce_release_branch defaults 'false'), so a repo owner can opt out of any check. + Assumes the repo is checked out at the SHA (for the tag-existence check). Channel + membership and version availability are separate steps (validate-channel, the + availability check). Failures here are pre-gate — a bad release is caught before the + approval gate. + + @param release_tag [String] expr for the derived tag. Default: ${{ inputs.release_tag }} + @param dry_run [String] expr; warn instead of fail on an existing tag. Default: ${{ inputs.dry_run }} + @param on_release_branch [String] expr. Default: ${{ inputs.on_release_branch }} + @param notes [String] expr; base64 release notes. Default: ${{ inputs.notes }} + @param check_tag_unused [String] toggle. Default: ${{ inputs.check_tag_unused }} + @param check_notes_not_blank [String] toggle. Default: ${{ inputs.check_notes_not_blank }} + @param enforce_release_branch [String] toggle. Default: ${{ inputs.enforce_release_branch }} +-%> +- name: Check release + shell: bash + env: + TAG: <%= locals.fetch(:release_tag) { "${{ inputs.release_tag }}" } %> + DRY_RUN: <%= locals.fetch(:dry_run) { "${{ inputs.dry_run }}" } %> + ON_RELEASE_BRANCH: <%= locals.fetch(:on_release_branch) { "${{ inputs.on_release_branch }}" } %> + NOTES_B64: <%= locals.fetch(:notes) { "${{ inputs.notes }}" } %> + 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 }}" } %> + run: | + # 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 + echo "::warning title=Release tag already exists::Tag $TAG already exists — skipping in dry run." + else + echo "::error title=Release tag already exists::Tag $TAG already exists — has the version been bumped?" + exit 1 + fi + fi + + # On a release branch: warn by default, hard-fail only when enforced. + if [ "$ON_RELEASE_BRANCH" = "false" ]; then + if [ "$ENFORCE_RELEASE_BRANCH" = "true" ]; then + echo "::error title=Off release branch::the release SHA is not on an allowed release branch (enforce_release_branch=true)." + exit 1 + else + echo "::warning title=Off release branch::the release SHA is not on an allowed release branch — releasing anyway." + fi + fi + + # Release notes present (warn only — a genuine first release, or no PRs since the last tag, is legitimate). + if [ "$CHECK_NOTES" = "true" ]; then + NOTES=$(echo "$NOTES_B64" | base64 -d 2>/dev/null || echo "") + if [ -z "$NOTES" ]; then + echo "::warning title=Empty release notes::no release notes were generated (first release, or no PRs since the last tag?)." + fi + fi diff --git a/templates/steps/release/configure.yml.erb b/templates/steps/release/configure.yml.erb new file mode 100644 index 0000000..db52e24 --- /dev/null +++ b/templates/steps/release/configure.yml.erb @@ -0,0 +1,74 @@ +<%# + Derives release FACTS (no judging): the release tag, the branch containing the SHA + and whether it's a release branch, the commit message, the previous release tag, and + the channel + github_release defaults mapped from release_type (explicit overrides + win). Read-only — the "derive" half of the old release/validate step; the checks that + can fail on state moved to release/checks. Assumes the repo is checked out at the + target SHA and the version is resolved (read-version, id: read-version). Writes its + outputs on the `configure` step. + + @param version [String] expr for the version. Default: ${{ steps.read-version.outputs.version }} + @param sha [String] expr for the commit SHA. Default: ${{ inputs.sha }} + @param tag_format [String] git tag format, {version} placeholder. Default: v{version} + @param release_branch [String] comma-separated branch/glob patterns. Default: main + @param release_type [String] 'stable' | 'prerelease' — channel/github_release defaults. Default: ${{ inputs.release_type }} + @param channel [String] explicit channel override (wins over release_type). Default: ${{ inputs.channel }} + @param github_release [String] explicit github_release override (wins over release_type). Default: ${{ inputs.github_release }} +-%> +- name: Validate SHA format + shell: bash + env: + SHA: <%= locals.fetch(:sha) { "${{ inputs.sha }}" } %> + run: | + if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." + exit 1 + fi + +- name: Configure release + id: configure + shell: bash + env: + VERSION: <%= locals.fetch(:version) { "${{ steps.read-version.outputs.version }}" } %> + TAG_FORMAT: <%= locals.fetch(:tag_format) { "${{ inputs.tag_format }}" } %> + RELEASE_BRANCH: <%= locals.fetch(:release_branch) { "${{ inputs.release_branch }}" } %> + RELEASE_TYPE: <%= locals.fetch(: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: "<%= locals.fetch(:channel) { "${{ inputs.channel }}" } %>" + GHR_OVERRIDE: "<%= locals.fetch(:github_release) { "${{ inputs.github_release }}" } %>" + run: | + TAG="${TAG_FORMAT//\{version\}/$VERSION}" + COMMIT_MSG=$(git log -1 --format="%s" HEAD) + BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) + + ON_RELEASE_BRANCH=false + IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" + for pattern in "${PATTERNS[@]}"; do + pattern=$(echo "$pattern" | xargs) + if [[ "$BRANCH" == $pattern ]]; then ON_RELEASE_BRANCH=true; break; fi + done + + PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" + PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") + + # channel: explicit override wins; else map from release_type (prerelease→rc, stable→latest) + if [ -n "$CHANNEL_OVERRIDE" ]; then CHANNEL="$CHANNEL_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then CHANNEL="rc" + else CHANNEL="latest"; fi + + # github_release: explicit override wins; else prerelease→false, stable→true + if [ -n "$GHR_OVERRIDE" ]; then GHR="$GHR_OVERRIDE" + elif [ "$RELEASE_TYPE" = "prerelease" ]; then GHR="false" + else GHR="true"; fi + + { + echo "release_tag=$TAG" + echo "commit_message=$COMMIT_MSG" + echo "branch=$BRANCH" + echo "on_release_branch=$ON_RELEASE_BRANCH" + echo "prev_release=$PREV_RELEASE" + echo "channel=$CHANNEL" + echo "github_release=$GHR" + } >> "$GITHUB_OUTPUT" + echo "Configured $TAG @ $VERSION (channel=$CHANNEL, github_release=$GHR, branch=$BRANCH, on_release_branch=$ON_RELEASE_BRANCH)" diff --git a/templates/steps/release/lang/js/on-failure.yml.erb b/templates/steps/release/lang/js/on-failure.yml.erb index 89d72eb..e2e0639 100644 --- a/templates/steps/release/lang/js/on-failure.yml.erb +++ b/templates/steps/release/lang/js/on-failure.yml.erb @@ -1,7 +1,7 @@ <%# The publish failure handler — dumps the JavaScript environment for diagnostics and posts a Slack failure notification. Composed as the terminal - step of js/publish under `if: failure()`; the composing action stamps that + step of js/build-and-ship under `if: failure()`; the composing action stamps that guard onto each step here, and slack/send self-guards when Slack isn't set up. -%> <%= render_step('lang/js/env-dump', indent: 0) %> diff --git a/templates/steps/release/lang/js/validate-package.yml.erb b/templates/steps/release/lang/js/validate-package.yml.erb new file mode 100644 index 0000000..910c0df --- /dev/null +++ b/templates/steps/release/lang/js/validate-package.yml.erb @@ -0,0 +1,35 @@ +<%# + Sanity-checks the just-packed tarball before it's attested/uploaded — intrinsic + checks only (nothing the consumer has to assert): it exists, its package.json has a + name and a version, and it ships actual files (not just package.json — the real + footgun is a mis-configured "files"/.npmignore that publishes an empty package). + Runs inside pack (same job, before attest): neither independently runnable nor across + a security boundary, so it's a step, not an action. + + @param tarball [String] expr for the packed tarball path. Default: ${{ steps.pack.outputs.tarball }} +-%> +- name: Validate packed tarball + shell: bash + env: + TGZ: <%= locals.fetch(:tarball) { "${{ steps.pack.outputs.tarball }}" } %> + run: | + if [ ! -f "$TGZ" ]; then + echo "::error title=Package not found::packed tarball does not exist: $TGZ"; exit 1 + fi + PKGJSON=$(tar -xzO -f "$TGZ" package/package.json) + NAME=$(printf '%s' "$PKGJSON" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).name || ""') + VER=$(printf '%s' "$PKGJSON" | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).version || ""') + # Count shipped files EXCLUDING package.json — a tarball with only package.json means + # "files"/.npmignore excluded everything that should ship. + CONTENT=$(tar -tzf "$TGZ" | grep '^package/' | grep -vc '^package/package.json$' || true) + + if [ -z "$NAME" ]; then + echo "::error title=Invalid package::the packed tarball has no name."; exit 1 + fi + if [ -z "$VER" ]; then + echo "::error title=Invalid package::the packed tarball has no version."; exit 1 + fi + if [ "$CONTENT" -lt 1 ]; then + echo "::error title=Empty package::the tarball ships only package.json — check the package's \"files\"/.npmignore."; exit 1 + fi + echo "✓ $NAME@$VER — $CONTENT shipped file(s), tarball looks sane" diff --git a/templates/steps/release/validate.yml.erb b/templates/steps/release/validate.yml.erb deleted file mode 100644 index 20b08e4..0000000 --- a/templates/steps/release/validate.yml.erb +++ /dev/null @@ -1,66 +0,0 @@ -<%# - Validates a release SHA and computes release metadata. Composed - into every language's validate action. Assumes the repo is checked - out at the target SHA. - - @param version [String] expr for the version string. Default: ${{ inputs.version }} - @param sha [String] expr for the commit SHA. Default: ${{ inputs.sha }} - @param dry_run [String] expr; warn instead of fail on an existing tag. Default: ${{ inputs.dry_run }} - @param tag_format [String] git tag format, {version} placeholder. Default: v{version} - @param release_branch [String] comma-separated branch/glob patterns. Default: main - - Writes outputs on the `validate` step: release_tag, prev_release, branch, - on_release_branch, commit_message. --%> -- name: Validate SHA format - shell: bash - env: - SHA: <%= locals.fetch(:sha) { "${{ inputs.sha }}" } %> - run: | - if ! echo "$SHA" | grep -qE '^[0-9a-f]{40}$'; then - echo "::error title=Invalid release SHA::sha must be a full 40-character commit SHA — branch names and short SHAs are not accepted." - exit 1 - fi - -- name: Validate release - id: validate - shell: bash - env: - VERSION: <%= locals.fetch(:version) { "${{ inputs.version }}" } %> - TAG_FORMAT: <%= locals.fetch(:tag_format) { "'v{version}'" } %> - DRY_RUN: <%= locals.fetch(:dry_run) { "${{ inputs.dry_run }}" } %> - RELEASE_BRANCH: <%= locals.fetch(:release_branch) { "'main'" } %> - SHA: <%= locals.fetch(:sha) { "${{ inputs.sha }}" } %> - run: | - TAG="${TAG_FORMAT//\{version\}/$VERSION}" - COMMIT_MSG=$(git log -1 --format="%s" HEAD) - BRANCH=$(git branch -r --contains HEAD --format="%(refname:short)" | sed 's|origin/||' | head -1) - - if git rev-parse "$TAG" >/dev/null 2>&1; then - if [ "$DRY_RUN" = "true" ]; then - echo "::warning title=Release tag already exists::Tag $TAG already exists — skipping in dry run." - else - echo "::error title=Release tag already exists::Tag $TAG already exists — has the version been bumped?" - exit 1 - fi - fi - - ON_RELEASE_BRANCH=false - IFS=',' read -ra PATTERNS <<< "$RELEASE_BRANCH" - for pattern in "${PATTERNS[@]}"; do - pattern=$(echo "$pattern" | xargs) - if [[ "$BRANCH" == $pattern ]]; then - ON_RELEASE_BRANCH=true - break - fi - done - - PREV_RELEASE_PATTERN="${TAG_FORMAT//\{version\}/[0-9]*.[0-9]*.[0-9]*}" - PREV_RELEASE=$(git describe --tags --abbrev=0 --match="$PREV_RELEASE_PATTERN" HEAD^ 2>/dev/null || echo "") - - echo "release_tag=$TAG" >> $GITHUB_OUTPUT - echo "commit_message=$COMMIT_MSG" >> $GITHUB_OUTPUT - echo "branch=$BRANCH" >> $GITHUB_OUTPUT - echo "on_release_branch=$ON_RELEASE_BRANCH" >> $GITHUB_OUTPUT - echo "prev_release=$PREV_RELEASE" >> $GITHUB_OUTPUT - echo "Validated $TAG @ $SHA ($COMMIT_MSG)" diff --git a/templates/steps/release/verify-package.yml.erb b/templates/steps/release/verify-package.yml.erb new file mode 100644 index 0000000..5ba8e27 --- /dev/null +++ b/templates/steps/release/verify-package.yml.erb @@ -0,0 +1,38 @@ +<%# + Verifies the downloaded artifact(s)' attestations — SLSA build provenance + CycloneDX + SBOM — against their own bytes, BEFORE publishing. The tamper-evident half of the + custom-build handoff: `gh attestation verify` recomputes each file's digest and + requires a matching attestation; any mutation in transit → 404 → fails closed + (spike-verified). Iterates over every file matching `artifacts`, so it covers a single + tarball/gem or a Python dist set (whl + sdist). Needs a GH token with attestations: + read. Agnostic — used by every language's ship-package. + + @param artifacts [String] expr; space-separated file glob(s) to verify (e.g. dl/*.tgz, dl/*.whl dl/*.tar.gz). + @param repo [String] expr; owner/repo the attestations live under. Default: ${{ github.repository }} +-%> +- name: Verify attestations + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACTS: <%= locals.fetch(:artifacts) %> + REPO: <%= locals.fetch(:repo) { "${{ github.repository }}" } %> + run: | + shopt -s nullglob + FILES=( $ARTIFACTS ) + if [ ${#FILES[@]} -eq 0 ]; then + echo "::error title=Nothing to verify::no artifact files matched '$ARTIFACTS'." + exit 1 + fi + for f in "${FILES[@]}"; do + echo "Verifying $(basename "$f") — build provenance…" + gh attestation verify "$f" --repo "$REPO" || { + echo "::error title=Provenance verification failed::no build-provenance attestation matches $(basename "$f") — the artifact was not produced by a trusted build, or was tampered with in transit." + exit 1 + } + echo "Verifying $(basename "$f") — SBOM…" + gh attestation verify "$f" --repo "$REPO" --predicate-type https://cyclonedx.org/bom || { + echo "::error title=SBOM verification failed::no CycloneDX SBOM attestation matches $(basename "$f")." + exit 1 + } + done + echo "✓ attestations verified for: ${FILES[*]}" From 51afd5fe3d0d6210e4b32a3d5914a8de8a678b54 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Fri, 10 Jul 2026 14:01:26 +0200 Subject: [PATCH 3/9] feat: Add `create-package-releases` and `upload-package-sboms-` and `verify-package` --- README.md | 45 +++ .../create-package-releases/action.yml | 84 +++++ .../lang/js/publish-tarballs/action.yml | 326 ++++++++++++++++++ .../release/upload-package-sboms/action.yml | 119 +++++++ actions/release/verify-package/action.yml | 51 +++ .../release/create-package-releases.yml.erb | 82 +++++ .../release/lang/js/publish-tarballs.yml.erb | 248 +++++++++++++ .../release/upload-package-sboms.yml.erb | 55 +++ .../actions/release/verify-package.yml.erb | 26 ++ .../release/upload-package-sboms.yml.erb | 85 +++++ 10 files changed, 1121 insertions(+) create mode 100644 actions/release/create-package-releases/action.yml create mode 100644 actions/release/lang/js/publish-tarballs/action.yml create mode 100644 actions/release/upload-package-sboms/action.yml create mode 100644 actions/release/verify-package/action.yml create mode 100644 templates/actions/release/create-package-releases.yml.erb create mode 100644 templates/actions/release/lang/js/publish-tarballs.yml.erb create mode 100644 templates/actions/release/upload-package-sboms.yml.erb create mode 100644 templates/actions/release/verify-package.yml.erb create mode 100644 templates/steps/release/upload-package-sboms.yml.erb diff --git a/README.md b/README.md index 130a246..d76d1f0 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,47 @@ Actions are pinned by commit SHA. Bump the SHA to pick up changes. To judge whet These actions follow semantic versioning: expect breaking changes when the major version updates. +### Manifest-driven JavaScript tarball releases + +For monorepos that own their own build and package selection, create a manifest +plus prebuilt npm tarballs and SBOMs in the source repository, attest those +artifacts before upload, then call `release/lang/js/publish-tarballs` from the +gated publish job. The action verifies the downloaded tarball attestations, +then publishes the exact tarballs to npmjs in manifest order. After the source +repository pushes its package tags, call `release/create-package-releases`, then +`release/upload-package-sboms`. + +The manifest passed between build and publish jobs is JSON in this shape: + +```json +{ + "commit": "0123456789abcdef0123456789abcdef01234567", + "packages": [ + { + "name": "@scope/package", + "version": "1.2.3", + "dir": "packages/package", + "tag": "@scope/package@1.2.3", + "tarball_asset": "scope-package-1.2.3.tgz", + "sbom_asset": "scope-package-1.2.3.sbom.json", + "release_title": "@scope/package@1.2.3", + "release_body": "Markdown release notes", + "channel": "latest", + "provenance": true + } + ] +} +``` + +`name` and `version` are required. `packages` must already be in publish order. +`dir` and `commit` are source-repository metadata, useful while building and +tagging. `tarball_asset` and `sbom_asset` identify the downloaded artifact +basenames; when omitted, actions derive them from `name` and `version`. `tag` +defaults to `name@version`; `release_title` defaults to `tag`; and +`release_body` defaults to a short publish message. `channel` and `provenance` +may override the `publish-tarballs` defaults per package. JavaScript SBOM +generation commonly uses `pnpm sbom`, which requires pnpm `>= 11.8`. + ### Available release actions The release workflow is composed from these actions. You normally get them via @@ -62,6 +103,10 @@ pulls in everything it needs. | `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-tarballs` | Verify prebuilt npm tarballs by glob and publish the exact bytes to npmjs in manifest order | +| `release/create-package-releases` | Create per-package GitHub releases from existing manifest tags, titles, and bodies | +| `release/verify-package` | Verify downloaded artifact attestations without using a language-specific ship action | +| `release/upload-package-sboms` | Attach package SBOM assets by manifest and glob; missing SBOMs warn without failing | Inputs and outputs are documented in each `action.yml`. Reference an action by commit SHA: diff --git a/actions/release/create-package-releases/action.yml b/actions/release/create-package-releases/action.yml new file mode 100644 index 0000000..1edaef1 --- /dev/null +++ b/actions/release/create-package-releases/action.yml @@ -0,0 +1,84 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Create Package GitHub Releases +description: > + Create one GitHub release per package in a release manifest. Release tags must + already exist in the repository; this action never creates or moves tags. + +# Manifest contract: +# { +# "packages": [ +# { +# "name": "@scope/package", +# "version": "1.2.3", +# "tag": "@scope/package@1.2.3", +# "release_title": "@scope/package@1.2.3", +# "release_body": "Markdown notes" +# } +# ] +# } +# +# Required: name, version. +# Optional: tag defaults to @; release_title defaults to tag; +# release_body defaults to "Published @.". + +inputs: + manifest: + description: "Path to the release manifest described above" + required: true + repo: + description: "GitHub owner/repo containing the existing package tags" + required: false + default: '${{ github.repository }}' + dry_run: + description: "Print releases without modifying GitHub" + required: false + default: 'false' + +runs: + using: composite + steps: + - name: Create package GitHub releases + shell: bash + env: + GH_TOKEN: ${{ github.token }} + MANIFEST: ${{ inputs.manifest }} + REPO: ${{ inputs.repo }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + if ! command -v jq >/dev/null 2>&1; then + echo "::error title=jq not found::create-package-releases requires jq to read the release manifest." + exit 1 + fi + + if [ ! -f "$MANIFEST" ]; then + echo "::error title=Manifest not found::release manifest does not exist: $MANIFEST" + exit 1 + fi + + while IFS= read -r encoded; do + package_json="$(printf '%s' "$encoded" | base64 -d)" + name="$(jq -r '.name' <<< "$package_json")" + version="$(jq -r '.version' <<< "$package_json")" + tag="$(jq -r '.tag // "\(.name)@\(.version)"' <<< "$package_json")" + title="$(jq -r '.release_title // .tag // "\(.name)@\(.version)"' <<< "$package_json")" + notes_file="$(mktemp)" + jq -r '.release_body // "Published \(.name)@\(.version)."' <<< "$package_json" > "$notes_file" + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would create GitHub release $tag for $name@$version" + elif gh release view "$tag" --repo "$REPO" >/dev/null 2>&1; then + echo "GitHub release already exists for $tag" + else + gh release create "$tag" \ + --repo "$REPO" \ + --title "$title" \ + --notes-file "$notes_file" \ + --verify-tag + echo "Created GitHub release for $tag" + fi + + rm -f "$notes_file" + done < <(jq -r '.packages[]? | @base64' "$MANIFEST") diff --git a/actions/release/lang/js/publish-tarballs/action.yml b/actions/release/lang/js/publish-tarballs/action.yml new file mode 100644 index 0000000..eb6854d --- /dev/null +++ b/actions/release/lang/js/publish-tarballs/action.yml @@ -0,0 +1,326 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Publish JavaScript Tarballs +description: > + Gated publish action for manifest-driven JavaScript releases. Verifies prebuilt + npm tarball attestations, publishes the exact downloaded tarballs to npmjs with + OIDC, and posts a concise release summary. It does not build, pack, create + GitHub releases, or upload SBOM assets. + +inputs: + manifest: + description: "Path to a release manifest whose packages are already in publish order" + required: true + tarballs: + description: "Space-separated tarball paths or globs" + required: true + repo: + description: "GitHub owner/repo containing the tarball attestations" + required: false + default: '${{ github.repository }}' + provenance: + description: "Publish with npm-native provenance" + required: false + default: 'true' + channel: + description: "Default npm dist-tag" + required: false + default: 'latest' + dry_run: + description: "Skip attestation verification and run npm publish with --dry-run" + required: false + default: 'false' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if publishing fails" + required: false + default: '' + +runs: + using: composite + steps: + - name: Verify attestations + if: ${{ inputs.dry_run != 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACTS: ${{ inputs.tarballs }} + REPO: ${{ inputs.repo }} + run: | + shopt -s nullglob + FILES=( $ARTIFACTS ) + if [ ${#FILES[@]} -eq 0 ]; then + echo "::error title=Nothing to verify::no artifact files matched '$ARTIFACTS'." + exit 1 + fi + for f in "${FILES[@]}"; do + echo "Verifying $(basename "$f") — build provenance…" + gh attestation verify "$f" --repo "$REPO" || { + echo "::error title=Provenance verification failed::no build-provenance attestation matches $(basename "$f") — the artifact was not produced by a trusted build, or was tampered with in transit." + exit 1 + } + echo "Verifying $(basename "$f") — SBOM…" + gh attestation verify "$f" --repo "$REPO" --predicate-type https://cyclonedx.org/bom || { + echo "::error title=SBOM verification failed::no CycloneDX SBOM attestation matches $(basename "$f")." + exit 1 + } + done + echo "✓ attestations verified for: ${FILES[*]}" + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 'lts/*' + registry-url: https://registry.npmjs.org + + - name: Install npm with OIDC trusted publishing support + shell: bash + run: npm install -g "npm@11.6.2" + + - name: Publish tarballs in manifest order + shell: bash + env: + MANIFEST: ${{ inputs.manifest }} + TARBALLS: ${{ inputs.tarballs }} + DEFAULT_CHANNEL: ${{ inputs.channel }} + DEFAULT_PROVENANCE: ${{ inputs.provenance }} + DRY_RUN: ${{ inputs.dry_run }} + REPORT: ${{ runner.temp }}/sdk-actions-publish-tarballs.tsv + NODE_AUTH_TOKEN: '' + NPM_TOKEN: '' + run: | + set -euo pipefail + + if ! command -v jq >/dev/null 2>&1; then + echo "::error title=jq not found::publish-tarballs requires jq to read the release manifest." + exit 1 + fi + + if [ ! -f "$MANIFEST" ]; then + echo "::error title=Manifest not found::release manifest does not exist: $MANIFEST" + exit 1 + fi + + shopt -s nullglob + tarball_files=( $TARBALLS ) + if [ "${#tarball_files[@]}" -eq 0 ]; then + echo "::error title=No tarballs found::no files matched '$TARBALLS'." + exit 1 + fi + + : > "$REPORT" + + artifact_base() { + local name="$1" + local version="$2" + local base="${name#@}" + base="${base//\//-}" + base="${base//@/-}" + printf '%s-%s' "$base" "$version" + } + + find_tarball() { + local expected="$1" + local matched=() + for file in "${tarball_files[@]}"; do + if [ "$(basename "$file")" = "$expected" ]; then + matched+=("$file") + fi + done + + if [ "${#matched[@]}" -ne 1 ]; then + echo "::error title=Tarball match failed::expected exactly one tarball named $expected, found ${#matched[@]}." >&2 + return 1 + fi + + printf '%s\n' "${matched[0]}" + } + + while IFS= read -r encoded; do + package_json="$(printf '%s' "$encoded" | base64 -d)" + name="$(jq -r '.name' <<< "$package_json")" + version="$(jq -r '.version' <<< "$package_json")" + expected_tarball="$(jq -r '.tarball_asset // .tarball // empty' <<< "$package_json")" + channel="$(jq -r '.channel // empty' <<< "$package_json")" + provenance="$(jq -r '.provenance // empty' <<< "$package_json")" + + [ -n "$expected_tarball" ] || expected_tarball="$(artifact_base "$name" "$version").tgz" + [ -n "$channel" ] || channel="$DEFAULT_CHANNEL" + [ -n "$provenance" ] || provenance="$DEFAULT_PROVENANCE" + + tarball="$(find_tarball "$expected_tarball")" + packed_name="$(tar -xzO -f "$tarball" package/package.json | jq -r '.name')" + packed_version="$(tar -xzO -f "$tarball" package/package.json | jq -r '.version')" + if [ "$packed_name" != "$name" ] || [ "$packed_version" != "$version" ]; then + echo "::error title=Tarball metadata mismatch::$expected_tarball contains $packed_name@$packed_version, expected $name@$version." + exit 1 + fi + + args=(--tag "$channel" --access public --registry https://registry.npmjs.org) + [ "$provenance" = "true" ] && args+=(--provenance) + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: npm publish ./$expected_tarball ${args[*]} --dry-run" + status="dry-run" + elif npm view "$name@$version" version --registry https://registry.npmjs.org >/dev/null 2>&1; then + echo "$name@$version is already published; skipping npm publish." + status="already-published" + else + dir="$(dirname "$tarball")" + file="$(basename "$tarball")" + echo "Publishing $name@$version from $tarball" + (cd "$dir" && npm publish "./$file" "${args[@]}") || { + echo "::error title=npm publish failed::Trusted publishing must list this repo, workflow, and environment." + exit 1 + } + status="published" + fi + + printf '%s\t%s\t%s\t%s\n' "$name" "$version" "$status" "$tarball" >> "$REPORT" + done < <(jq -r '.packages[]? | @base64' "$MANIFEST") + + - name: Post publish summary + shell: bash + env: + MANIFEST: ${{ inputs.manifest }} + REPORT: ${{ runner.temp }}/sdk-actions-publish-tarballs.tsv + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + count="$(jq '.packages | length' "$MANIFEST")" + if [ "$DRY_RUN" = "true" ]; then + echo "## JavaScript tarball publish dry run" >> "$GITHUB_STEP_SUMMARY" + else + echo "## JavaScript tarballs published" >> "$GITHUB_STEP_SUMMARY" + fi + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Packages:** $count" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Package | npm |" >> "$GITHUB_STEP_SUMMARY" + echo "| --- | --- |" >> "$GITHUB_STEP_SUMMARY" + + while IFS=$'\t' read -r name version status tarball; do + npm_name="$(jq -rn --arg v "$name" '$v|@uri')" + npm_url="https://www.npmjs.com/package/$npm_name/v/$version" + echo "| \`$name@$version\` | [$status]($npm_url) |" >> "$GITHUB_STEP_SUMMARY" + done < "$REPORT" + + - name: Build published Slack message + id: message-published + shell: bash + env: + REPORT: ${{ runner.temp }}/sdk-actions-publish-tarballs.tsv + SLACK_MENTION: ${{ inputs.slack_mention }} + DRY_RUN: ${{ inputs.dry_run }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + + if [ "$DRY_RUN" = "true" ]; then + text="*JavaScript tarball publish dry run*" + else + text="*JavaScript tarballs published*" + fi + while IFS=$'\t' read -r name version status tarball; do + npm_name="$(jq -rn --arg v "$name" '$v|@uri')" + npm_url="https://www.npmjs.com/package/$npm_name/v/$version" + text="$text\n• <$npm_url|$name@$version> - $status" + done < "$REPORT" + text="$text\n<$RUN_URL|View run>" + if [ -n "$SLACK_MENTION" ]; then + text="$text\n$SLACK_MENTION" + fi + { + echo 'text<> "$GITHUB_OUTPUT" + + - name: Send Slack message + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-published.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi + + - name: Build failure Slack message + id: message-failed + if: ${{ failure() }} + shell: bash + env: + FAILURE_SLACK_MENTION: ${{ inputs.failure_slack_mention }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + text="*JavaScript tarball publish failed*\n<$RUN_URL|View run>" + if [ -n "$FAILURE_SLACK_MENTION" ]; then + text="$text\n$FAILURE_SLACK_MENTION" + fi + { + echo 'text<> "$GITHUB_OUTPUT" + + - name: Send Slack message + if: ${{ failure() }} + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-failed.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi diff --git a/actions/release/upload-package-sboms/action.yml b/actions/release/upload-package-sboms/action.yml new file mode 100644 index 0000000..a828ff6 --- /dev/null +++ b/actions/release/upload-package-sboms/action.yml @@ -0,0 +1,119 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Upload Package SBOMs to GitHub Releases +description: > + Attach package SBOM assets to package GitHub releases from a release manifest. This + is useful for monorepos that create one release per package and produce several + prebuilt package SBOMs in a separate build job. Missing SBOMs produce warnings and + do not fail the release. + +# Manifest contract: +# { +# "packages": [ +# { +# "name": "@scope/package", +# "version": "1.2.3", +# "tag": "@scope/package@1.2.3", +# "sbom_asset": "scope-package-1.2.3.sbom.json" +# } +# ] +# } +# +# Required: name, version. +# Optional: tag defaults to @. +# Optional: sbom_asset defaults to a basename derived from name and version: leading +# @ is removed, / becomes -, then -.sbom.json is appended. +# For each package, files from the sboms glob are matched by that exact basename and +# uploaded to the GitHub release identified by tag. Missing matches only warn. + +inputs: + manifest: + description: "Path to the release manifest described above" + required: true + sboms: + description: "Space-separated SBOM paths or globs to upload" + required: true + repo: + description: "GitHub owner/repo containing the releases" + required: false + default: '${{ github.repository }}' + clobber: + description: "Overwrite an existing release asset with the same name" + required: false + default: 'true' + dry_run: + description: "Print uploads without modifying GitHub releases" + required: false + default: 'false' + +runs: + using: composite + steps: + - name: Upload package SBOMs to GitHub releases + shell: bash + env: + GH_TOKEN: ${{ github.token }} + MANIFEST: ${{ inputs.manifest }} + SBOMS: ${{ inputs.sboms }} + REPO: ${{ inputs.repo }} + CLOBBER: ${{ inputs.clobber }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + if ! command -v jq >/dev/null 2>&1; then + echo "::error title=jq not found::upload-package-sboms requires jq to read the release manifest." + exit 1 + fi + + if [ "$DRY_RUN" != "true" ] && ! command -v gh >/dev/null 2>&1; then + echo "::error title=GitHub CLI not found::upload-package-sboms requires gh to upload release assets." + exit 1 + fi + + if [ ! -f "$MANIFEST" ]; then + echo "::error title=Manifest not found::release manifest does not exist: $MANIFEST" + exit 1 + fi + + shopt -s nullglob + files=( $SBOMS ) + if [ "${#files[@]}" -eq 0 ]; then + echo "::warning title=No SBOMs found::no files matched '$SBOMS'." + exit 0 + fi + + clobber_args=() + if [ "$CLOBBER" = "true" ]; then + clobber_args+=(--clobber) + fi + + while IFS=$'\t' read -r name version tag sbom_asset; do + artifact_base="${name#@}" + artifact_base="${artifact_base//\//-}" + artifact_base="${artifact_base//@/-}" + expected="${sbom_asset:-$artifact_base-$version.sbom.json}" + matched=() + + for file in "${files[@]}"; do + if [ "$(basename "$file")" = "$expected" ]; then + matched+=("$file") + fi + done + + if [ "${#matched[@]}" -eq 0 ]; then + echo "::warning title=Package SBOM missing::no SBOM named $expected found for $name@$version." + continue + fi + + for file in "${matched[@]}"; do + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would upload $file to $tag in $REPO" + else + gh release upload "$tag" "$file" --repo "$REPO" "${clobber_args[@]}" + echo "Uploaded $(basename "$file") to $tag" + fi + done + done < <( + jq -r '.packages[]? | [.name, .version, (.tag // "\(.name)@\(.version)"), (.sbom_asset // .sbom // "")] | @tsv' "$MANIFEST" + ) diff --git a/actions/release/verify-package/action.yml b/actions/release/verify-package/action.yml new file mode 100644 index 0000000..3923bf5 --- /dev/null +++ b/actions/release/verify-package/action.yml @@ -0,0 +1,51 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Verify Release Package Attestations +description: > + Verifies downloaded release artifact attestations against their own bytes. Use this + in custom release workflows that publish multiple prebuilt artifacts without using a + language-specific ship-package action. + +inputs: + artifacts: + description: "Space-separated artifact paths or globs to verify" + required: true + repo: + description: "GitHub owner/repo where the attestations live" + required: false + default: '${{ github.repository }}' + dry_run: + description: "Skip verification when artifacts were intentionally not attested" + required: false + default: 'false' + +runs: + using: composite + steps: + - name: Verify attestations + if: ${{ inputs.dry_run != 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACTS: ${{ inputs.artifacts }} + REPO: ${{ inputs.repo }} + run: | + shopt -s nullglob + FILES=( $ARTIFACTS ) + if [ ${#FILES[@]} -eq 0 ]; then + echo "::error title=Nothing to verify::no artifact files matched '$ARTIFACTS'." + exit 1 + fi + for f in "${FILES[@]}"; do + echo "Verifying $(basename "$f") — build provenance…" + gh attestation verify "$f" --repo "$REPO" || { + echo "::error title=Provenance verification failed::no build-provenance attestation matches $(basename "$f") — the artifact was not produced by a trusted build, or was tampered with in transit." + exit 1 + } + echo "Verifying $(basename "$f") — SBOM…" + gh attestation verify "$f" --repo "$REPO" --predicate-type https://cyclonedx.org/bom || { + echo "::error title=SBOM verification failed::no CycloneDX SBOM attestation matches $(basename "$f")." + exit 1 + } + done + echo "✓ attestations verified for: ${FILES[*]}" diff --git a/templates/actions/release/create-package-releases.yml.erb b/templates/actions/release/create-package-releases.yml.erb new file mode 100644 index 0000000..3dea1dd --- /dev/null +++ b/templates/actions/release/create-package-releases.yml.erb @@ -0,0 +1,82 @@ +name: Create Package GitHub Releases +description: > + Create one GitHub release per package in a release manifest. Release tags must + already exist in the repository; this action never creates or moves tags. + +# Manifest contract: +# { +# "packages": [ +# { +# "name": "@scope/package", +# "version": "1.2.3", +# "tag": "@scope/package@1.2.3", +# "release_title": "@scope/package@1.2.3", +# "release_body": "Markdown notes" +# } +# ] +# } +# +# Required: name, version. +# Optional: tag defaults to @; release_title defaults to tag; +# release_body defaults to "Published @.". + +inputs: + manifest: + description: "Path to the release manifest described above" + required: true + repo: + description: "GitHub owner/repo containing the existing package tags" + required: false + default: '${{ github.repository }}' + dry_run: + description: "Print releases without modifying GitHub" + required: false + default: 'false' + +runs: + using: composite + steps: + - name: Create package GitHub releases + shell: bash + env: + GH_TOKEN: ${{ github.token }} + MANIFEST: ${{ inputs.manifest }} + REPO: ${{ inputs.repo }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + if ! command -v jq >/dev/null 2>&1; then + echo "::error title=jq not found::create-package-releases requires jq to read the release manifest." + exit 1 + fi + + if [ ! -f "$MANIFEST" ]; then + echo "::error title=Manifest not found::release manifest does not exist: $MANIFEST" + exit 1 + fi + + while IFS= read -r encoded; do + package_json="$(printf '%s' "$encoded" | base64 -d)" + name="$(jq -r '.name' <<< "$package_json")" + version="$(jq -r '.version' <<< "$package_json")" + tag="$(jq -r '.tag // "\(.name)@\(.version)"' <<< "$package_json")" + title="$(jq -r '.release_title // .tag // "\(.name)@\(.version)"' <<< "$package_json")" + notes_file="$(mktemp)" + jq -r '.release_body // "Published \(.name)@\(.version)."' <<< "$package_json" > "$notes_file" + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would create GitHub release $tag for $name@$version" + elif gh release view "$tag" --repo "$REPO" >/dev/null 2>&1; then + echo "GitHub release already exists for $tag" + else + gh release create "$tag" \ + --repo "$REPO" \ + --title "$title" \ + --notes-file "$notes_file" \ + --verify-tag + echo "Created GitHub release for $tag" + fi + + rm -f "$notes_file" + done < <(jq -r '.packages[]? | @base64' "$MANIFEST") diff --git a/templates/actions/release/lang/js/publish-tarballs.yml.erb b/templates/actions/release/lang/js/publish-tarballs.yml.erb new file mode 100644 index 0000000..c1dc1b9 --- /dev/null +++ b/templates/actions/release/lang/js/publish-tarballs.yml.erb @@ -0,0 +1,248 @@ +name: Publish JavaScript Tarballs +description: > + Gated publish action for manifest-driven JavaScript releases. Verifies prebuilt + npm tarball attestations, publishes the exact downloaded tarballs to npmjs with + OIDC, and posts a concise release summary. It does not build, pack, create + GitHub releases, or upload SBOM assets. + +inputs: + manifest: + description: "Path to a release manifest whose packages are already in publish order" + required: true + tarballs: + description: "Space-separated tarball paths or globs" + required: true + repo: + description: "GitHub owner/repo containing the tarball attestations" + required: false + default: '${{ github.repository }}' + provenance: + description: "Publish with npm-native provenance" + required: false + default: 'true' + channel: + description: "Default npm dist-tag" + required: false + default: 'latest' + dry_run: + description: "Skip attestation verification and run npm publish with --dry-run" + required: false + default: 'false' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if publishing fails" + required: false + default: '' + +runs: + using: composite + steps: +<%= render_step('release/verify-package', + if: "${{ inputs.dry_run != 'true' }}", + artifacts: "${{ inputs.tarballs }}", + repo: "${{ inputs.repo }}") %> + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 'lts/*' + registry-url: https://registry.npmjs.org + + - name: Install npm with OIDC trusted publishing support + shell: bash + run: npm install -g "npm@11.6.2" + + - name: Publish tarballs in manifest order + shell: bash + env: + MANIFEST: ${{ inputs.manifest }} + TARBALLS: ${{ inputs.tarballs }} + DEFAULT_CHANNEL: ${{ inputs.channel }} + DEFAULT_PROVENANCE: ${{ inputs.provenance }} + DRY_RUN: ${{ inputs.dry_run }} + REPORT: ${{ runner.temp }}/sdk-actions-publish-tarballs.tsv + NODE_AUTH_TOKEN: '' + NPM_TOKEN: '' + run: | + set -euo pipefail + + if ! command -v jq >/dev/null 2>&1; then + echo "::error title=jq not found::publish-tarballs requires jq to read the release manifest." + exit 1 + fi + + if [ ! -f "$MANIFEST" ]; then + echo "::error title=Manifest not found::release manifest does not exist: $MANIFEST" + exit 1 + fi + + shopt -s nullglob + tarball_files=( $TARBALLS ) + if [ "${#tarball_files[@]}" -eq 0 ]; then + echo "::error title=No tarballs found::no files matched '$TARBALLS'." + exit 1 + fi + + : > "$REPORT" + + artifact_base() { + local name="$1" + local version="$2" + local base="${name#@}" + base="${base//\//-}" + base="${base//@/-}" + printf '%s-%s' "$base" "$version" + } + + find_tarball() { + local expected="$1" + local matched=() + for file in "${tarball_files[@]}"; do + if [ "$(basename "$file")" = "$expected" ]; then + matched+=("$file") + fi + done + + if [ "${#matched[@]}" -ne 1 ]; then + echo "::error title=Tarball match failed::expected exactly one tarball named $expected, found ${#matched[@]}." >&2 + return 1 + fi + + printf '%s\n' "${matched[0]}" + } + + while IFS= read -r encoded; do + package_json="$(printf '%s' "$encoded" | base64 -d)" + name="$(jq -r '.name' <<< "$package_json")" + version="$(jq -r '.version' <<< "$package_json")" + expected_tarball="$(jq -r '.tarball_asset // .tarball // empty' <<< "$package_json")" + channel="$(jq -r '.channel // empty' <<< "$package_json")" + provenance="$(jq -r '.provenance // empty' <<< "$package_json")" + + [ -n "$expected_tarball" ] || expected_tarball="$(artifact_base "$name" "$version").tgz" + [ -n "$channel" ] || channel="$DEFAULT_CHANNEL" + [ -n "$provenance" ] || provenance="$DEFAULT_PROVENANCE" + + tarball="$(find_tarball "$expected_tarball")" + packed_name="$(tar -xzO -f "$tarball" package/package.json | jq -r '.name')" + packed_version="$(tar -xzO -f "$tarball" package/package.json | jq -r '.version')" + if [ "$packed_name" != "$name" ] || [ "$packed_version" != "$version" ]; then + echo "::error title=Tarball metadata mismatch::$expected_tarball contains $packed_name@$packed_version, expected $name@$version." + exit 1 + fi + + args=(--tag "$channel" --access public --registry https://registry.npmjs.org) + [ "$provenance" = "true" ] && args+=(--provenance) + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: npm publish ./$expected_tarball ${args[*]} --dry-run" + status="dry-run" + elif npm view "$name@$version" version --registry https://registry.npmjs.org >/dev/null 2>&1; then + echo "$name@$version is already published; skipping npm publish." + status="already-published" + else + dir="$(dirname "$tarball")" + file="$(basename "$tarball")" + echo "Publishing $name@$version from $tarball" + (cd "$dir" && npm publish "./$file" "${args[@]}") || { + echo "::error title=npm publish failed::Trusted publishing must list this repo, workflow, and environment." + exit 1 + } + status="published" + fi + + printf '%s\t%s\t%s\t%s\n' "$name" "$version" "$status" "$tarball" >> "$REPORT" + done < <(jq -r '.packages[]? | @base64' "$MANIFEST") + + - name: Post publish summary + shell: bash + env: + MANIFEST: ${{ inputs.manifest }} + REPORT: ${{ runner.temp }}/sdk-actions-publish-tarballs.tsv + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + count="$(jq '.packages | length' "$MANIFEST")" + if [ "$DRY_RUN" = "true" ]; then + echo "## JavaScript tarball publish dry run" >> "$GITHUB_STEP_SUMMARY" + else + echo "## JavaScript tarballs published" >> "$GITHUB_STEP_SUMMARY" + fi + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Packages:** $count" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Package | npm |" >> "$GITHUB_STEP_SUMMARY" + echo "| --- | --- |" >> "$GITHUB_STEP_SUMMARY" + + while IFS=$'\t' read -r name version status tarball; do + npm_name="$(jq -rn --arg v "$name" '$v|@uri')" + npm_url="https://www.npmjs.com/package/$npm_name/v/$version" + echo "| \`$name@$version\` | [$status]($npm_url) |" >> "$GITHUB_STEP_SUMMARY" + done < "$REPORT" + + - name: Build published Slack message + id: message-published + shell: bash + env: + REPORT: ${{ runner.temp }}/sdk-actions-publish-tarballs.tsv + SLACK_MENTION: ${{ inputs.slack_mention }} + DRY_RUN: ${{ inputs.dry_run }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + + if [ "$DRY_RUN" = "true" ]; then + text="*JavaScript tarball publish dry run*" + else + text="*JavaScript tarballs published*" + fi + while IFS=$'\t' read -r name version status tarball; do + npm_name="$(jq -rn --arg v "$name" '$v|@uri')" + npm_url="https://www.npmjs.com/package/$npm_name/v/$version" + text="$text\n• <$npm_url|$name@$version> - $status" + done < "$REPORT" + text="$text\n<$RUN_URL|View run>" + if [ -n "$SLACK_MENTION" ]; then + text="$text\n$SLACK_MENTION" + fi + { + echo 'text<> "$GITHUB_OUTPUT" + +<%= render_step('slack/send', text: "${{ steps.message-published.outputs.text }}") %> + + - name: Build failure Slack message + id: message-failed + if: ${{ failure() }} + shell: bash + env: + FAILURE_SLACK_MENTION: ${{ inputs.failure_slack_mention }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + text="*JavaScript tarball publish failed*\n<$RUN_URL|View run>" + if [ -n "$FAILURE_SLACK_MENTION" ]; then + text="$text\n$FAILURE_SLACK_MENTION" + fi + { + echo 'text<> "$GITHUB_OUTPUT" + +<%= render_step('slack/send', + if: "${{ failure() }}", + text: "${{ steps.message-failed.outputs.text }}") %> diff --git a/templates/actions/release/upload-package-sboms.yml.erb b/templates/actions/release/upload-package-sboms.yml.erb new file mode 100644 index 0000000..e3d6d0b --- /dev/null +++ b/templates/actions/release/upload-package-sboms.yml.erb @@ -0,0 +1,55 @@ +name: Upload Package SBOMs to GitHub Releases +description: > + Attach package SBOM assets to package GitHub releases from a release manifest. This + is useful for monorepos that create one release per package and produce several + prebuilt package SBOMs in a separate build job. Missing SBOMs produce warnings and + do not fail the release. + +# Manifest contract: +# { +# "packages": [ +# { +# "name": "@scope/package", +# "version": "1.2.3", +# "tag": "@scope/package@1.2.3", +# "sbom_asset": "scope-package-1.2.3.sbom.json" +# } +# ] +# } +# +# Required: name, version. +# Optional: tag defaults to @. +# Optional: sbom_asset defaults to a basename derived from name and version: leading +# @ is removed, / becomes -, then -.sbom.json is appended. +# For each package, files from the sboms glob are matched by that exact basename and +# uploaded to the GitHub release identified by tag. Missing matches only warn. + +inputs: + manifest: + description: "Path to the release manifest described above" + required: true + sboms: + description: "Space-separated SBOM paths or globs to upload" + required: true + repo: + description: "GitHub owner/repo containing the releases" + required: false + default: '${{ github.repository }}' + clobber: + description: "Overwrite an existing release asset with the same name" + required: false + default: 'true' + dry_run: + description: "Print uploads without modifying GitHub releases" + required: false + default: 'false' + +runs: + using: composite + steps: +<%= render_step('release/upload-package-sboms', + manifest: "${{ inputs.manifest }}", + sboms: "${{ inputs.sboms }}", + repo: "${{ inputs.repo }}", + clobber: "${{ inputs.clobber }}", + dry_run: "${{ inputs.dry_run }}") %> diff --git a/templates/actions/release/verify-package.yml.erb b/templates/actions/release/verify-package.yml.erb new file mode 100644 index 0000000..41ecdf0 --- /dev/null +++ b/templates/actions/release/verify-package.yml.erb @@ -0,0 +1,26 @@ +name: Verify Release Package Attestations +description: > + Verifies downloaded release artifact attestations against their own bytes. Use this + in custom release workflows that publish multiple prebuilt artifacts without using a + language-specific ship-package action. + +inputs: + artifacts: + description: "Space-separated artifact paths or globs to verify" + required: true + repo: + description: "GitHub owner/repo where the attestations live" + required: false + default: '${{ github.repository }}' + dry_run: + description: "Skip verification when artifacts were intentionally not attested" + required: false + default: 'false' + +runs: + using: composite + steps: +<%= render_step('release/verify-package', + if: "${{ inputs.dry_run != 'true' }}", + artifacts: "${{ inputs.artifacts }}", + repo: "${{ inputs.repo }}") %> diff --git a/templates/steps/release/upload-package-sboms.yml.erb b/templates/steps/release/upload-package-sboms.yml.erb new file mode 100644 index 0000000..a4b3450 --- /dev/null +++ b/templates/steps/release/upload-package-sboms.yml.erb @@ -0,0 +1,85 @@ +<%# + Uploads per-package SBOM release assets from a manifest. + + The manifest shape intentionally matches the simple release manifests many package + release workflows already produce: + { "packages": [{ "name": "...", "version": "...", "tag": "..." }] } + + SBOM filenames are matched using npm's package artifact basename convention: + braintrust@1.2.3 -> braintrust-1.2.3.sbom.json + @braintrust/langchain-js@0.2.4 -> braintrust-langchain-js-0.2.4.sbom.json + + @param manifest [String] path to the release manifest + @param sboms [String] space-separated paths/globs for SBOM files + @param repo [String] GitHub owner/repo + @param clobber [String] whether to overwrite existing assets + @param dry_run [String] whether to only print planned uploads +-%> +- name: Upload package SBOMs to GitHub releases + shell: bash + env: + GH_TOKEN: ${{ github.token }} + MANIFEST: <%= locals.fetch(:manifest) { "${{ inputs.manifest }}" } %> + SBOMS: <%= locals.fetch(:sboms) { "${{ inputs.sboms }}" } %> + REPO: <%= locals.fetch(:repo) { "${{ inputs.repo }}" } %> + CLOBBER: <%= locals.fetch(:clobber) { "${{ inputs.clobber }}" } %> + DRY_RUN: <%= locals.fetch(:dry_run) { "${{ inputs.dry_run }}" } %> + run: | + set -euo pipefail + + if ! command -v jq >/dev/null 2>&1; then + echo "::error title=jq not found::upload-package-sboms requires jq to read the release manifest." + exit 1 + fi + + if [ "$DRY_RUN" != "true" ] && ! command -v gh >/dev/null 2>&1; then + echo "::error title=GitHub CLI not found::upload-package-sboms requires gh to upload release assets." + exit 1 + fi + + if [ ! -f "$MANIFEST" ]; then + echo "::error title=Manifest not found::release manifest does not exist: $MANIFEST" + exit 1 + fi + + shopt -s nullglob + files=( $SBOMS ) + if [ "${#files[@]}" -eq 0 ]; then + echo "::warning title=No SBOMs found::no files matched '$SBOMS'." + exit 0 + fi + + clobber_args=() + if [ "$CLOBBER" = "true" ]; then + clobber_args+=(--clobber) + fi + + while IFS=$'\t' read -r name version tag sbom_asset; do + artifact_base="${name#@}" + artifact_base="${artifact_base//\//-}" + artifact_base="${artifact_base//@/-}" + expected="${sbom_asset:-$artifact_base-$version.sbom.json}" + matched=() + + for file in "${files[@]}"; do + if [ "$(basename "$file")" = "$expected" ]; then + matched+=("$file") + fi + done + + if [ "${#matched[@]}" -eq 0 ]; then + echo "::warning title=Package SBOM missing::no SBOM named $expected found for $name@$version." + continue + fi + + for file in "${matched[@]}"; do + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would upload $file to $tag in $REPO" + else + gh release upload "$tag" "$file" --repo "$REPO" "${clobber_args[@]}" + echo "Uploaded $(basename "$file") to $tag" + fi + done + done < <( + jq -r '.packages[]? | [.name, .version, (.tag // "\(.name)@\(.version)"), (.sbom_asset // .sbom // "")] | @tsv' "$MANIFEST" + ) From e60910e69616ab50ae9316c2338ad2c361f2d193 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 14 Jul 2026 14:23:55 +0200 Subject: [PATCH 4/9] Fold upload-package-sboms from step into action --- .../actions/release/upload-package-sboms.yml | 117 ++++++++++++++++++ .../release/upload-package-sboms.yml.erb | 55 -------- .../release/upload-package-sboms.yml.erb | 85 ------------- 3 files changed, 117 insertions(+), 140 deletions(-) create mode 100644 templates/actions/release/upload-package-sboms.yml delete mode 100644 templates/actions/release/upload-package-sboms.yml.erb delete mode 100644 templates/steps/release/upload-package-sboms.yml.erb diff --git a/templates/actions/release/upload-package-sboms.yml b/templates/actions/release/upload-package-sboms.yml new file mode 100644 index 0000000..0e69755 --- /dev/null +++ b/templates/actions/release/upload-package-sboms.yml @@ -0,0 +1,117 @@ +name: Upload Package SBOMs to GitHub Releases +description: > + Attach package SBOM assets to package GitHub releases from a release manifest. This + is useful for monorepos that create one release per package and produce several + prebuilt package SBOMs in a separate build job. Missing SBOMs produce warnings and + do not fail the release. + +# Manifest contract: +# { +# "packages": [ +# { +# "name": "@scope/package", +# "version": "1.2.3", +# "tag": "@scope/package@1.2.3", +# "sbom_asset": "scope-package-1.2.3.sbom.json" +# } +# ] +# } +# +# Required: name, version. +# Optional: tag defaults to @. +# Optional: sbom_asset defaults to a basename derived from name and version: leading +# @ is removed, / becomes -, then -.sbom.json is appended. +# For each package, files from the sboms glob are matched by that exact basename and +# uploaded to the GitHub release identified by tag. Missing matches only warn. + +inputs: + manifest: + description: "Path to the release manifest described above" + required: true + sboms: + description: "Space-separated SBOM paths or globs to upload" + required: true + repo: + description: "GitHub owner/repo containing the releases" + required: false + default: '${{ github.repository }}' + clobber: + description: "Overwrite an existing release asset with the same name" + required: false + default: 'true' + dry_run: + description: "Print uploads without modifying GitHub releases" + required: false + default: 'false' + +runs: + using: composite + steps: + - name: Upload package SBOMs to GitHub releases + shell: bash + env: + GH_TOKEN: ${{ github.token }} + MANIFEST: ${{ inputs.manifest }} + SBOMS: ${{ inputs.sboms }} + REPO: ${{ inputs.repo }} + CLOBBER: ${{ inputs.clobber }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + if ! command -v jq >/dev/null 2>&1; then + echo "::error title=jq not found::upload-package-sboms requires jq to read the release manifest." + exit 1 + fi + + if [ "$DRY_RUN" != "true" ] && ! command -v gh >/dev/null 2>&1; then + echo "::error title=GitHub CLI not found::upload-package-sboms requires gh to upload release assets." + exit 1 + fi + + if [ ! -f "$MANIFEST" ]; then + echo "::error title=Manifest not found::release manifest does not exist: $MANIFEST" + exit 1 + fi + + shopt -s nullglob + files=( $SBOMS ) + if [ "${#files[@]}" -eq 0 ]; then + echo "::warning title=No SBOMs found::no files matched '$SBOMS'." + exit 0 + fi + + clobber_args=() + if [ "$CLOBBER" = "true" ]; then + clobber_args+=(--clobber) + fi + + while IFS=$'\t' read -r name version tag sbom_asset; do + artifact_base="${name#@}" + artifact_base="${artifact_base//\//-}" + artifact_base="${artifact_base//@/-}" + expected="${sbom_asset:-$artifact_base-$version.sbom.json}" + matched=() + + for file in "${files[@]}"; do + if [ "$(basename "$file")" = "$expected" ]; then + matched+=("$file") + fi + done + + if [ "${#matched[@]}" -eq 0 ]; then + echo "::warning title=Package SBOM missing::no SBOM named $expected found for $name@$version." + continue + fi + + for file in "${matched[@]}"; do + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would upload $file to $tag in $REPO" + else + gh release upload "$tag" "$file" --repo "$REPO" "${clobber_args[@]}" + echo "Uploaded $(basename "$file") to $tag" + fi + done + done < <( + jq -r '.packages[]? | [.name, .version, (.tag // "\(.name)@\(.version)"), (.sbom_asset // .sbom // "")] | @tsv' "$MANIFEST" + ) diff --git a/templates/actions/release/upload-package-sboms.yml.erb b/templates/actions/release/upload-package-sboms.yml.erb deleted file mode 100644 index e3d6d0b..0000000 --- a/templates/actions/release/upload-package-sboms.yml.erb +++ /dev/null @@ -1,55 +0,0 @@ -name: Upload Package SBOMs to GitHub Releases -description: > - Attach package SBOM assets to package GitHub releases from a release manifest. This - is useful for monorepos that create one release per package and produce several - prebuilt package SBOMs in a separate build job. Missing SBOMs produce warnings and - do not fail the release. - -# Manifest contract: -# { -# "packages": [ -# { -# "name": "@scope/package", -# "version": "1.2.3", -# "tag": "@scope/package@1.2.3", -# "sbom_asset": "scope-package-1.2.3.sbom.json" -# } -# ] -# } -# -# Required: name, version. -# Optional: tag defaults to @. -# Optional: sbom_asset defaults to a basename derived from name and version: leading -# @ is removed, / becomes -, then -.sbom.json is appended. -# For each package, files from the sboms glob are matched by that exact basename and -# uploaded to the GitHub release identified by tag. Missing matches only warn. - -inputs: - manifest: - description: "Path to the release manifest described above" - required: true - sboms: - description: "Space-separated SBOM paths or globs to upload" - required: true - repo: - description: "GitHub owner/repo containing the releases" - required: false - default: '${{ github.repository }}' - clobber: - description: "Overwrite an existing release asset with the same name" - required: false - default: 'true' - dry_run: - description: "Print uploads without modifying GitHub releases" - required: false - default: 'false' - -runs: - using: composite - steps: -<%= render_step('release/upload-package-sboms', - manifest: "${{ inputs.manifest }}", - sboms: "${{ inputs.sboms }}", - repo: "${{ inputs.repo }}", - clobber: "${{ inputs.clobber }}", - dry_run: "${{ inputs.dry_run }}") %> diff --git a/templates/steps/release/upload-package-sboms.yml.erb b/templates/steps/release/upload-package-sboms.yml.erb deleted file mode 100644 index a4b3450..0000000 --- a/templates/steps/release/upload-package-sboms.yml.erb +++ /dev/null @@ -1,85 +0,0 @@ -<%# - Uploads per-package SBOM release assets from a manifest. - - The manifest shape intentionally matches the simple release manifests many package - release workflows already produce: - { "packages": [{ "name": "...", "version": "...", "tag": "..." }] } - - SBOM filenames are matched using npm's package artifact basename convention: - braintrust@1.2.3 -> braintrust-1.2.3.sbom.json - @braintrust/langchain-js@0.2.4 -> braintrust-langchain-js-0.2.4.sbom.json - - @param manifest [String] path to the release manifest - @param sboms [String] space-separated paths/globs for SBOM files - @param repo [String] GitHub owner/repo - @param clobber [String] whether to overwrite existing assets - @param dry_run [String] whether to only print planned uploads --%> -- name: Upload package SBOMs to GitHub releases - shell: bash - env: - GH_TOKEN: ${{ github.token }} - MANIFEST: <%= locals.fetch(:manifest) { "${{ inputs.manifest }}" } %> - SBOMS: <%= locals.fetch(:sboms) { "${{ inputs.sboms }}" } %> - REPO: <%= locals.fetch(:repo) { "${{ inputs.repo }}" } %> - CLOBBER: <%= locals.fetch(:clobber) { "${{ inputs.clobber }}" } %> - DRY_RUN: <%= locals.fetch(:dry_run) { "${{ inputs.dry_run }}" } %> - run: | - set -euo pipefail - - if ! command -v jq >/dev/null 2>&1; then - echo "::error title=jq not found::upload-package-sboms requires jq to read the release manifest." - exit 1 - fi - - if [ "$DRY_RUN" != "true" ] && ! command -v gh >/dev/null 2>&1; then - echo "::error title=GitHub CLI not found::upload-package-sboms requires gh to upload release assets." - exit 1 - fi - - if [ ! -f "$MANIFEST" ]; then - echo "::error title=Manifest not found::release manifest does not exist: $MANIFEST" - exit 1 - fi - - shopt -s nullglob - files=( $SBOMS ) - if [ "${#files[@]}" -eq 0 ]; then - echo "::warning title=No SBOMs found::no files matched '$SBOMS'." - exit 0 - fi - - clobber_args=() - if [ "$CLOBBER" = "true" ]; then - clobber_args+=(--clobber) - fi - - while IFS=$'\t' read -r name version tag sbom_asset; do - artifact_base="${name#@}" - artifact_base="${artifact_base//\//-}" - artifact_base="${artifact_base//@/-}" - expected="${sbom_asset:-$artifact_base-$version.sbom.json}" - matched=() - - for file in "${files[@]}"; do - if [ "$(basename "$file")" = "$expected" ]; then - matched+=("$file") - fi - done - - if [ "${#matched[@]}" -eq 0 ]; then - echo "::warning title=Package SBOM missing::no SBOM named $expected found for $name@$version." - continue - fi - - for file in "${matched[@]}"; do - if [ "$DRY_RUN" = "true" ]; then - echo "DRY RUN: would upload $file to $tag in $REPO" - else - gh release upload "$tag" "$file" --repo "$REPO" "${clobber_args[@]}" - echo "Uploaded $(basename "$file") to $tag" - fi - done - done < <( - jq -r '.packages[]? | [.name, .version, (.tag // "\(.name)@\(.version)"), (.sbom_asset // .sbom // "")] | @tsv' "$MANIFEST" - ) From 2a583dcd51f3a88e4cd0e6830dde18663a6f8893 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 14 Jul 2026 14:30:40 +0200 Subject: [PATCH 5/9] fold `upload-package-sboms` into `create-package-releases` --- README.md | 7 +- .../create-package-releases/action.yml | 67 +++++++++- .../release/upload-package-sboms/action.yml | 119 ------------------ .../release/create-package-releases.yml.erb | 67 +++++++++- .../actions/release/upload-package-sboms.yml | 117 ----------------- 5 files changed, 131 insertions(+), 246 deletions(-) delete mode 100644 actions/release/upload-package-sboms/action.yml delete mode 100644 templates/actions/release/upload-package-sboms.yml diff --git a/README.md b/README.md index 18fdf49..9dfcd5d 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,8 @@ plus prebuilt npm tarballs and SBOMs in the source repository, attest those artifacts before upload, then call `release/lang/js/publish-tarballs` from the gated publish job. The action verifies the downloaded tarball attestations, then publishes the exact tarballs to npmjs in manifest order. After the source -repository pushes its package tags, call `release/create-package-releases`, then -`release/upload-package-sboms`. +repository pushes its package tags, call `release/create-package-releases` with +`sboms` to attach the per-package SBOM assets. The manifest passed between build and publish jobs is JSON in this shape: @@ -104,9 +104,8 @@ pulls in everything it needs. | `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-tarballs` | Verify prebuilt npm tarballs by glob and publish the exact bytes to npmjs in manifest order | -| `release/create-package-releases` | Create per-package GitHub releases from existing manifest tags, titles, and bodies | +| `release/create-package-releases` | Create per-package GitHub releases from existing manifest tags, titles, and bodies, with optional per-package SBOM assets | | `release/verify-package` | Verify downloaded artifact attestations without using a language-specific ship action | -| `release/upload-package-sboms` | Attach package SBOM assets by manifest and glob; missing SBOMs warn without failing | Inputs and outputs are documented in each `action.yml`. Reference an action by commit SHA: diff --git a/actions/release/create-package-releases/action.yml b/actions/release/create-package-releases/action.yml index 1edaef1..1325729 100644 --- a/actions/release/create-package-releases/action.yml +++ b/actions/release/create-package-releases/action.yml @@ -2,8 +2,9 @@ # sdk-actions: {"family":"release","version":"1.0.0"} name: Create Package GitHub Releases description: > - Create one GitHub release per package in a release manifest. Release tags must - already exist in the repository; this action never creates or moves tags. + Create one GitHub release per package in a release manifest and optionally + attach each package's SBOM asset. Release tags must already exist in the + repository; this action never creates or moves tags. # Manifest contract: # { @@ -12,6 +13,7 @@ description: > # "name": "@scope/package", # "version": "1.2.3", # "tag": "@scope/package@1.2.3", +# "sbom_asset": "scope-package-1.2.3.sbom.json", # "release_title": "@scope/package@1.2.3", # "release_body": "Markdown notes" # } @@ -21,6 +23,8 @@ description: > # Required: name, version. # Optional: tag defaults to @; release_title defaults to tag; # release_body defaults to "Published @.". +# Optional: sbom_asset defaults to a basename derived from name and version: +# leading @ is removed, / becomes -, then -.sbom.json is appended. inputs: manifest: @@ -30,8 +34,16 @@ inputs: description: "GitHub owner/repo containing the existing package tags" required: false default: '${{ github.repository }}' + sboms: + description: "Space-separated SBOM paths or globs to attach to package releases; leave empty to skip" + required: false + default: '' + clobber: + description: "Overwrite an existing SBOM release asset with the same name" + required: false + default: 'true' dry_run: - description: "Print releases without modifying GitHub" + description: "Print releases and SBOM uploads without modifying GitHub" required: false default: 'false' @@ -44,6 +56,8 @@ runs: GH_TOKEN: ${{ github.token }} MANIFEST: ${{ inputs.manifest }} REPO: ${{ inputs.repo }} + SBOMS: ${{ inputs.sboms }} + CLOBBER: ${{ inputs.clobber }} DRY_RUN: ${{ inputs.dry_run }} run: | set -euo pipefail @@ -53,16 +67,36 @@ runs: exit 1 fi + if [ "$DRY_RUN" != "true" ] && ! command -v gh >/dev/null 2>&1; then + echo "::error title=GitHub CLI not found::create-package-releases requires gh to create releases and upload SBOM assets." + exit 1 + fi + if [ ! -f "$MANIFEST" ]; then echo "::error title=Manifest not found::release manifest does not exist: $MANIFEST" exit 1 fi + shopt -s nullglob + sbom_files=() + if [ -n "$SBOMS" ]; then + sbom_files=( $SBOMS ) + if [ "${#sbom_files[@]}" -eq 0 ]; then + echo "::warning title=No SBOMs found::no files matched '$SBOMS'." + fi + fi + + clobber_args=() + if [ "$CLOBBER" = "true" ]; then + clobber_args+=(--clobber) + fi + while IFS= read -r encoded; do package_json="$(printf '%s' "$encoded" | base64 -d)" name="$(jq -r '.name' <<< "$package_json")" version="$(jq -r '.version' <<< "$package_json")" tag="$(jq -r '.tag // "\(.name)@\(.version)"' <<< "$package_json")" + sbom_asset="$(jq -r '.sbom_asset // .sbom // empty' <<< "$package_json")" title="$(jq -r '.release_title // .tag // "\(.name)@\(.version)"' <<< "$package_json")" notes_file="$(mktemp)" jq -r '.release_body // "Published \(.name)@\(.version)."' <<< "$package_json" > "$notes_file" @@ -80,5 +114,32 @@ runs: echo "Created GitHub release for $tag" fi + if [ "${#sbom_files[@]}" -gt 0 ]; then + artifact_base="${name#@}" + artifact_base="${artifact_base//\//-}" + artifact_base="${artifact_base//@/-}" + expected="${sbom_asset:-$artifact_base-$version.sbom.json}" + matched=() + + for file in "${sbom_files[@]}"; do + if [ "$(basename "$file")" = "$expected" ]; then + matched+=("$file") + fi + done + + if [ "${#matched[@]}" -eq 0 ]; then + echo "::warning title=Package SBOM missing::no SBOM named $expected found for $name@$version." + else + for file in "${matched[@]}"; do + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would upload $file to $tag in $REPO" + else + gh release upload "$tag" "$file" --repo "$REPO" "${clobber_args[@]}" + echo "Uploaded $(basename "$file") to $tag" + fi + done + fi + fi + rm -f "$notes_file" done < <(jq -r '.packages[]? | @base64' "$MANIFEST") diff --git a/actions/release/upload-package-sboms/action.yml b/actions/release/upload-package-sboms/action.yml deleted file mode 100644 index a828ff6..0000000 --- a/actions/release/upload-package-sboms/action.yml +++ /dev/null @@ -1,119 +0,0 @@ -# GENERATED by scripts/generate.rb — edit templates/, not this file. -# sdk-actions: {"family":"release","version":"1.0.0"} -name: Upload Package SBOMs to GitHub Releases -description: > - Attach package SBOM assets to package GitHub releases from a release manifest. This - is useful for monorepos that create one release per package and produce several - prebuilt package SBOMs in a separate build job. Missing SBOMs produce warnings and - do not fail the release. - -# Manifest contract: -# { -# "packages": [ -# { -# "name": "@scope/package", -# "version": "1.2.3", -# "tag": "@scope/package@1.2.3", -# "sbom_asset": "scope-package-1.2.3.sbom.json" -# } -# ] -# } -# -# Required: name, version. -# Optional: tag defaults to @. -# Optional: sbom_asset defaults to a basename derived from name and version: leading -# @ is removed, / becomes -, then -.sbom.json is appended. -# For each package, files from the sboms glob are matched by that exact basename and -# uploaded to the GitHub release identified by tag. Missing matches only warn. - -inputs: - manifest: - description: "Path to the release manifest described above" - required: true - sboms: - description: "Space-separated SBOM paths or globs to upload" - required: true - repo: - description: "GitHub owner/repo containing the releases" - required: false - default: '${{ github.repository }}' - clobber: - description: "Overwrite an existing release asset with the same name" - required: false - default: 'true' - dry_run: - description: "Print uploads without modifying GitHub releases" - required: false - default: 'false' - -runs: - using: composite - steps: - - name: Upload package SBOMs to GitHub releases - shell: bash - env: - GH_TOKEN: ${{ github.token }} - MANIFEST: ${{ inputs.manifest }} - SBOMS: ${{ inputs.sboms }} - REPO: ${{ inputs.repo }} - CLOBBER: ${{ inputs.clobber }} - DRY_RUN: ${{ inputs.dry_run }} - run: | - set -euo pipefail - - if ! command -v jq >/dev/null 2>&1; then - echo "::error title=jq not found::upload-package-sboms requires jq to read the release manifest." - exit 1 - fi - - if [ "$DRY_RUN" != "true" ] && ! command -v gh >/dev/null 2>&1; then - echo "::error title=GitHub CLI not found::upload-package-sboms requires gh to upload release assets." - exit 1 - fi - - if [ ! -f "$MANIFEST" ]; then - echo "::error title=Manifest not found::release manifest does not exist: $MANIFEST" - exit 1 - fi - - shopt -s nullglob - files=( $SBOMS ) - if [ "${#files[@]}" -eq 0 ]; then - echo "::warning title=No SBOMs found::no files matched '$SBOMS'." - exit 0 - fi - - clobber_args=() - if [ "$CLOBBER" = "true" ]; then - clobber_args+=(--clobber) - fi - - while IFS=$'\t' read -r name version tag sbom_asset; do - artifact_base="${name#@}" - artifact_base="${artifact_base//\//-}" - artifact_base="${artifact_base//@/-}" - expected="${sbom_asset:-$artifact_base-$version.sbom.json}" - matched=() - - for file in "${files[@]}"; do - if [ "$(basename "$file")" = "$expected" ]; then - matched+=("$file") - fi - done - - if [ "${#matched[@]}" -eq 0 ]; then - echo "::warning title=Package SBOM missing::no SBOM named $expected found for $name@$version." - continue - fi - - for file in "${matched[@]}"; do - if [ "$DRY_RUN" = "true" ]; then - echo "DRY RUN: would upload $file to $tag in $REPO" - else - gh release upload "$tag" "$file" --repo "$REPO" "${clobber_args[@]}" - echo "Uploaded $(basename "$file") to $tag" - fi - done - done < <( - jq -r '.packages[]? | [.name, .version, (.tag // "\(.name)@\(.version)"), (.sbom_asset // .sbom // "")] | @tsv' "$MANIFEST" - ) diff --git a/templates/actions/release/create-package-releases.yml.erb b/templates/actions/release/create-package-releases.yml.erb index 3dea1dd..39f93cb 100644 --- a/templates/actions/release/create-package-releases.yml.erb +++ b/templates/actions/release/create-package-releases.yml.erb @@ -1,7 +1,8 @@ name: Create Package GitHub Releases description: > - Create one GitHub release per package in a release manifest. Release tags must - already exist in the repository; this action never creates or moves tags. + Create one GitHub release per package in a release manifest and optionally + attach each package's SBOM asset. Release tags must already exist in the + repository; this action never creates or moves tags. # Manifest contract: # { @@ -10,6 +11,7 @@ description: > # "name": "@scope/package", # "version": "1.2.3", # "tag": "@scope/package@1.2.3", +# "sbom_asset": "scope-package-1.2.3.sbom.json", # "release_title": "@scope/package@1.2.3", # "release_body": "Markdown notes" # } @@ -19,6 +21,8 @@ description: > # Required: name, version. # Optional: tag defaults to @; release_title defaults to tag; # release_body defaults to "Published @.". +# Optional: sbom_asset defaults to a basename derived from name and version: +# leading @ is removed, / becomes -, then -.sbom.json is appended. inputs: manifest: @@ -28,8 +32,16 @@ inputs: description: "GitHub owner/repo containing the existing package tags" required: false default: '${{ github.repository }}' + sboms: + description: "Space-separated SBOM paths or globs to attach to package releases; leave empty to skip" + required: false + default: '' + clobber: + description: "Overwrite an existing SBOM release asset with the same name" + required: false + default: 'true' dry_run: - description: "Print releases without modifying GitHub" + description: "Print releases and SBOM uploads without modifying GitHub" required: false default: 'false' @@ -42,6 +54,8 @@ runs: GH_TOKEN: ${{ github.token }} MANIFEST: ${{ inputs.manifest }} REPO: ${{ inputs.repo }} + SBOMS: ${{ inputs.sboms }} + CLOBBER: ${{ inputs.clobber }} DRY_RUN: ${{ inputs.dry_run }} run: | set -euo pipefail @@ -51,16 +65,36 @@ runs: exit 1 fi + if [ "$DRY_RUN" != "true" ] && ! command -v gh >/dev/null 2>&1; then + echo "::error title=GitHub CLI not found::create-package-releases requires gh to create releases and upload SBOM assets." + exit 1 + fi + if [ ! -f "$MANIFEST" ]; then echo "::error title=Manifest not found::release manifest does not exist: $MANIFEST" exit 1 fi + shopt -s nullglob + sbom_files=() + if [ -n "$SBOMS" ]; then + sbom_files=( $SBOMS ) + if [ "${#sbom_files[@]}" -eq 0 ]; then + echo "::warning title=No SBOMs found::no files matched '$SBOMS'." + fi + fi + + clobber_args=() + if [ "$CLOBBER" = "true" ]; then + clobber_args+=(--clobber) + fi + while IFS= read -r encoded; do package_json="$(printf '%s' "$encoded" | base64 -d)" name="$(jq -r '.name' <<< "$package_json")" version="$(jq -r '.version' <<< "$package_json")" tag="$(jq -r '.tag // "\(.name)@\(.version)"' <<< "$package_json")" + sbom_asset="$(jq -r '.sbom_asset // .sbom // empty' <<< "$package_json")" title="$(jq -r '.release_title // .tag // "\(.name)@\(.version)"' <<< "$package_json")" notes_file="$(mktemp)" jq -r '.release_body // "Published \(.name)@\(.version)."' <<< "$package_json" > "$notes_file" @@ -78,5 +112,32 @@ runs: echo "Created GitHub release for $tag" fi + if [ "${#sbom_files[@]}" -gt 0 ]; then + artifact_base="${name#@}" + artifact_base="${artifact_base//\//-}" + artifact_base="${artifact_base//@/-}" + expected="${sbom_asset:-$artifact_base-$version.sbom.json}" + matched=() + + for file in "${sbom_files[@]}"; do + if [ "$(basename "$file")" = "$expected" ]; then + matched+=("$file") + fi + done + + if [ "${#matched[@]}" -eq 0 ]; then + echo "::warning title=Package SBOM missing::no SBOM named $expected found for $name@$version." + else + for file in "${matched[@]}"; do + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would upload $file to $tag in $REPO" + else + gh release upload "$tag" "$file" --repo "$REPO" "${clobber_args[@]}" + echo "Uploaded $(basename "$file") to $tag" + fi + done + fi + fi + rm -f "$notes_file" done < <(jq -r '.packages[]? | @base64' "$MANIFEST") diff --git a/templates/actions/release/upload-package-sboms.yml b/templates/actions/release/upload-package-sboms.yml deleted file mode 100644 index 0e69755..0000000 --- a/templates/actions/release/upload-package-sboms.yml +++ /dev/null @@ -1,117 +0,0 @@ -name: Upload Package SBOMs to GitHub Releases -description: > - Attach package SBOM assets to package GitHub releases from a release manifest. This - is useful for monorepos that create one release per package and produce several - prebuilt package SBOMs in a separate build job. Missing SBOMs produce warnings and - do not fail the release. - -# Manifest contract: -# { -# "packages": [ -# { -# "name": "@scope/package", -# "version": "1.2.3", -# "tag": "@scope/package@1.2.3", -# "sbom_asset": "scope-package-1.2.3.sbom.json" -# } -# ] -# } -# -# Required: name, version. -# Optional: tag defaults to @. -# Optional: sbom_asset defaults to a basename derived from name and version: leading -# @ is removed, / becomes -, then -.sbom.json is appended. -# For each package, files from the sboms glob are matched by that exact basename and -# uploaded to the GitHub release identified by tag. Missing matches only warn. - -inputs: - manifest: - description: "Path to the release manifest described above" - required: true - sboms: - description: "Space-separated SBOM paths or globs to upload" - required: true - repo: - description: "GitHub owner/repo containing the releases" - required: false - default: '${{ github.repository }}' - clobber: - description: "Overwrite an existing release asset with the same name" - required: false - default: 'true' - dry_run: - description: "Print uploads without modifying GitHub releases" - required: false - default: 'false' - -runs: - using: composite - steps: - - name: Upload package SBOMs to GitHub releases - shell: bash - env: - GH_TOKEN: ${{ github.token }} - MANIFEST: ${{ inputs.manifest }} - SBOMS: ${{ inputs.sboms }} - REPO: ${{ inputs.repo }} - CLOBBER: ${{ inputs.clobber }} - DRY_RUN: ${{ inputs.dry_run }} - run: | - set -euo pipefail - - if ! command -v jq >/dev/null 2>&1; then - echo "::error title=jq not found::upload-package-sboms requires jq to read the release manifest." - exit 1 - fi - - if [ "$DRY_RUN" != "true" ] && ! command -v gh >/dev/null 2>&1; then - echo "::error title=GitHub CLI not found::upload-package-sboms requires gh to upload release assets." - exit 1 - fi - - if [ ! -f "$MANIFEST" ]; then - echo "::error title=Manifest not found::release manifest does not exist: $MANIFEST" - exit 1 - fi - - shopt -s nullglob - files=( $SBOMS ) - if [ "${#files[@]}" -eq 0 ]; then - echo "::warning title=No SBOMs found::no files matched '$SBOMS'." - exit 0 - fi - - clobber_args=() - if [ "$CLOBBER" = "true" ]; then - clobber_args+=(--clobber) - fi - - while IFS=$'\t' read -r name version tag sbom_asset; do - artifact_base="${name#@}" - artifact_base="${artifact_base//\//-}" - artifact_base="${artifact_base//@/-}" - expected="${sbom_asset:-$artifact_base-$version.sbom.json}" - matched=() - - for file in "${files[@]}"; do - if [ "$(basename "$file")" = "$expected" ]; then - matched+=("$file") - fi - done - - if [ "${#matched[@]}" -eq 0 ]; then - echo "::warning title=Package SBOM missing::no SBOM named $expected found for $name@$version." - continue - fi - - for file in "${matched[@]}"; do - if [ "$DRY_RUN" = "true" ]; then - echo "DRY RUN: would upload $file to $tag in $REPO" - else - gh release upload "$tag" "$file" --repo "$REPO" "${clobber_args[@]}" - echo "Uploaded $(basename "$file") to $tag" - fi - done - done < <( - jq -r '.packages[]? | [.name, .version, (.tag // "\(.name)@\(.version)"), (.sbom_asset // .sbom // "")] | @tsv' "$MANIFEST" - ) From 0cd5ac424da378d7605d2d23cac36d3036245a62 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 15 Jul 2026 12:49:52 +0200 Subject: [PATCH 6/9] make sbom assets required in manifest --- README.md | 13 ++-- .../create-package-releases/action.yml | 77 ++++++++----------- .../release/create-package-releases.yml.erb | 77 ++++++++----------- 3 files changed, 69 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 9dfcd5d..b89c479 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,8 @@ plus prebuilt npm tarballs and SBOMs in the source repository, attest those artifacts before upload, then call `release/lang/js/publish-tarballs` from the gated publish job. The action verifies the downloaded tarball attestations, then publishes the exact tarballs to npmjs in manifest order. After the source -repository pushes its package tags, call `release/create-package-releases` with -`sboms` to attach the per-package SBOM assets. +repository pushes its package tags, call `release/create-package-releases`; it +attaches each package's `sbom_asset` from the manifest directory. The manifest passed between build and publish jobs is JSON in this shape: @@ -80,9 +80,10 @@ The manifest passed between build and publish jobs is JSON in this shape: `name` and `version` are required. `packages` must already be in publish order. `dir` and `commit` are source-repository metadata, useful while building and -tagging. `tarball_asset` and `sbom_asset` identify the downloaded artifact -basenames; when omitted, actions derive them from `name` and `version`. `tag` -defaults to `name@version`; `release_title` defaults to `tag`; and +tagging. `tarball_asset` identifies the downloaded tarball basename; when omitted, +`publish-tarballs` derives it from `name` and `version`. `sbom_asset` is required +by `create-package-releases` and is resolved relative to the manifest file's +directory. `tag` defaults to `name@version`; `release_title` defaults to `tag`; and `release_body` defaults to a short publish message. `channel` and `provenance` may override the `publish-tarballs` defaults per package. JavaScript SBOM generation commonly uses `pnpm sbom`, which requires pnpm `>= 11.8`. @@ -104,7 +105,7 @@ pulls in everything it needs. | `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-tarballs` | Verify prebuilt npm tarballs by glob and publish the exact bytes to npmjs in manifest order | -| `release/create-package-releases` | Create per-package GitHub releases from existing manifest tags, titles, and bodies, with optional per-package SBOM assets | +| `release/create-package-releases` | Create per-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 diff --git a/actions/release/create-package-releases/action.yml b/actions/release/create-package-releases/action.yml index 1325729..75ece69 100644 --- a/actions/release/create-package-releases/action.yml +++ b/actions/release/create-package-releases/action.yml @@ -2,9 +2,9 @@ # sdk-actions: {"family":"release","version":"1.0.0"} name: Create Package GitHub Releases description: > - Create one GitHub release per package in a release manifest and optionally - attach each package's SBOM asset. Release tags must already exist in the - repository; this action never creates or moves tags. + Create one GitHub release per package in a release manifest and attach each + package's SBOM asset from the manifest directory. Release tags must already + exist in the repository; this action never creates or moves tags. # Manifest contract: # { @@ -20,11 +20,10 @@ description: > # ] # } # -# Required: name, version. +# Required: name, version, sbom_asset. # Optional: tag defaults to @; release_title defaults to tag; # release_body defaults to "Published @.". -# Optional: sbom_asset defaults to a basename derived from name and version: -# leading @ is removed, / becomes -, then -.sbom.json is appended. +# SBOM assets are resolved relative to the manifest file's directory. inputs: manifest: @@ -34,10 +33,6 @@ inputs: description: "GitHub owner/repo containing the existing package tags" required: false default: '${{ github.repository }}' - sboms: - description: "Space-separated SBOM paths or globs to attach to package releases; leave empty to skip" - required: false - default: '' clobber: description: "Overwrite an existing SBOM release asset with the same name" required: false @@ -56,7 +51,6 @@ runs: GH_TOKEN: ${{ github.token }} MANIFEST: ${{ inputs.manifest }} REPO: ${{ inputs.repo }} - SBOMS: ${{ inputs.sboms }} CLOBBER: ${{ inputs.clobber }} DRY_RUN: ${{ inputs.dry_run }} run: | @@ -77,15 +71,7 @@ runs: exit 1 fi - shopt -s nullglob - sbom_files=() - if [ -n "$SBOMS" ]; then - sbom_files=( $SBOMS ) - if [ "${#sbom_files[@]}" -eq 0 ]; then - echo "::warning title=No SBOMs found::no files matched '$SBOMS'." - fi - fi - + manifest_dir="$(cd "$(dirname "$MANIFEST")" && pwd)" clobber_args=() if [ "$CLOBBER" = "true" ]; then clobber_args+=(--clobber) @@ -96,11 +82,30 @@ runs: name="$(jq -r '.name' <<< "$package_json")" version="$(jq -r '.version' <<< "$package_json")" tag="$(jq -r '.tag // "\(.name)@\(.version)"' <<< "$package_json")" - sbom_asset="$(jq -r '.sbom_asset // .sbom // empty' <<< "$package_json")" + sbom_asset="$(jq -r '.sbom_asset // empty' <<< "$package_json")" title="$(jq -r '.release_title // .tag // "\(.name)@\(.version)"' <<< "$package_json")" notes_file="$(mktemp)" jq -r '.release_body // "Published \(.name)@\(.version)."' <<< "$package_json" > "$notes_file" + if [ -z "$sbom_asset" ]; then + echo "::error title=SBOM asset missing::manifest package $name@$version must set sbom_asset." + rm -f "$notes_file" + exit 1 + fi + + if [ "$(basename "$sbom_asset")" != "$sbom_asset" ]; then + echo "::error title=Invalid SBOM asset::sbom_asset must be a file basename, got: $sbom_asset" + rm -f "$notes_file" + exit 1 + fi + + sbom_path="$manifest_dir/$sbom_asset" + if [ ! -f "$sbom_path" ]; then + echo "::error title=SBOM asset not found::expected $sbom_asset for $name@$version next to $MANIFEST." + rm -f "$notes_file" + exit 1 + fi + if [ "$DRY_RUN" = "true" ]; then echo "DRY RUN: would create GitHub release $tag for $name@$version" elif gh release view "$tag" --repo "$REPO" >/dev/null 2>&1; then @@ -114,31 +119,11 @@ runs: echo "Created GitHub release for $tag" fi - if [ "${#sbom_files[@]}" -gt 0 ]; then - artifact_base="${name#@}" - artifact_base="${artifact_base//\//-}" - artifact_base="${artifact_base//@/-}" - expected="${sbom_asset:-$artifact_base-$version.sbom.json}" - matched=() - - for file in "${sbom_files[@]}"; do - if [ "$(basename "$file")" = "$expected" ]; then - matched+=("$file") - fi - done - - if [ "${#matched[@]}" -eq 0 ]; then - echo "::warning title=Package SBOM missing::no SBOM named $expected found for $name@$version." - else - for file in "${matched[@]}"; do - if [ "$DRY_RUN" = "true" ]; then - echo "DRY RUN: would upload $file to $tag in $REPO" - else - gh release upload "$tag" "$file" --repo "$REPO" "${clobber_args[@]}" - echo "Uploaded $(basename "$file") to $tag" - fi - done - fi + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would upload $sbom_path to $tag in $REPO" + else + gh release upload "$tag" "$sbom_path" --repo "$REPO" "${clobber_args[@]}" + echo "Uploaded $sbom_asset to $tag" fi rm -f "$notes_file" diff --git a/templates/actions/release/create-package-releases.yml.erb b/templates/actions/release/create-package-releases.yml.erb index 39f93cb..b19502a 100644 --- a/templates/actions/release/create-package-releases.yml.erb +++ b/templates/actions/release/create-package-releases.yml.erb @@ -1,8 +1,8 @@ name: Create Package GitHub Releases description: > - Create one GitHub release per package in a release manifest and optionally - attach each package's SBOM asset. Release tags must already exist in the - repository; this action never creates or moves tags. + Create one GitHub release per package in a release manifest and attach each + package's SBOM asset from the manifest directory. Release tags must already + exist in the repository; this action never creates or moves tags. # Manifest contract: # { @@ -18,11 +18,10 @@ description: > # ] # } # -# Required: name, version. +# Required: name, version, sbom_asset. # Optional: tag defaults to @; release_title defaults to tag; # release_body defaults to "Published @.". -# Optional: sbom_asset defaults to a basename derived from name and version: -# leading @ is removed, / becomes -, then -.sbom.json is appended. +# SBOM assets are resolved relative to the manifest file's directory. inputs: manifest: @@ -32,10 +31,6 @@ inputs: description: "GitHub owner/repo containing the existing package tags" required: false default: '${{ github.repository }}' - sboms: - description: "Space-separated SBOM paths or globs to attach to package releases; leave empty to skip" - required: false - default: '' clobber: description: "Overwrite an existing SBOM release asset with the same name" required: false @@ -54,7 +49,6 @@ runs: GH_TOKEN: ${{ github.token }} MANIFEST: ${{ inputs.manifest }} REPO: ${{ inputs.repo }} - SBOMS: ${{ inputs.sboms }} CLOBBER: ${{ inputs.clobber }} DRY_RUN: ${{ inputs.dry_run }} run: | @@ -75,15 +69,7 @@ runs: exit 1 fi - shopt -s nullglob - sbom_files=() - if [ -n "$SBOMS" ]; then - sbom_files=( $SBOMS ) - if [ "${#sbom_files[@]}" -eq 0 ]; then - echo "::warning title=No SBOMs found::no files matched '$SBOMS'." - fi - fi - + manifest_dir="$(cd "$(dirname "$MANIFEST")" && pwd)" clobber_args=() if [ "$CLOBBER" = "true" ]; then clobber_args+=(--clobber) @@ -94,11 +80,30 @@ runs: name="$(jq -r '.name' <<< "$package_json")" version="$(jq -r '.version' <<< "$package_json")" tag="$(jq -r '.tag // "\(.name)@\(.version)"' <<< "$package_json")" - sbom_asset="$(jq -r '.sbom_asset // .sbom // empty' <<< "$package_json")" + sbom_asset="$(jq -r '.sbom_asset // empty' <<< "$package_json")" title="$(jq -r '.release_title // .tag // "\(.name)@\(.version)"' <<< "$package_json")" notes_file="$(mktemp)" jq -r '.release_body // "Published \(.name)@\(.version)."' <<< "$package_json" > "$notes_file" + if [ -z "$sbom_asset" ]; then + echo "::error title=SBOM asset missing::manifest package $name@$version must set sbom_asset." + rm -f "$notes_file" + exit 1 + fi + + if [ "$(basename "$sbom_asset")" != "$sbom_asset" ]; then + echo "::error title=Invalid SBOM asset::sbom_asset must be a file basename, got: $sbom_asset" + rm -f "$notes_file" + exit 1 + fi + + sbom_path="$manifest_dir/$sbom_asset" + if [ ! -f "$sbom_path" ]; then + echo "::error title=SBOM asset not found::expected $sbom_asset for $name@$version next to $MANIFEST." + rm -f "$notes_file" + exit 1 + fi + if [ "$DRY_RUN" = "true" ]; then echo "DRY RUN: would create GitHub release $tag for $name@$version" elif gh release view "$tag" --repo "$REPO" >/dev/null 2>&1; then @@ -112,31 +117,11 @@ runs: echo "Created GitHub release for $tag" fi - if [ "${#sbom_files[@]}" -gt 0 ]; then - artifact_base="${name#@}" - artifact_base="${artifact_base//\//-}" - artifact_base="${artifact_base//@/-}" - expected="${sbom_asset:-$artifact_base-$version.sbom.json}" - matched=() - - for file in "${sbom_files[@]}"; do - if [ "$(basename "$file")" = "$expected" ]; then - matched+=("$file") - fi - done - - if [ "${#matched[@]}" -eq 0 ]; then - echo "::warning title=Package SBOM missing::no SBOM named $expected found for $name@$version." - else - for file in "${matched[@]}"; do - if [ "$DRY_RUN" = "true" ]; then - echo "DRY RUN: would upload $file to $tag in $REPO" - else - gh release upload "$tag" "$file" --repo "$REPO" "${clobber_args[@]}" - echo "Uploaded $(basename "$file") to $tag" - fi - done - fi + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: would upload $sbom_path to $tag in $REPO" + else + gh release upload "$tag" "$sbom_path" --repo "$REPO" "${clobber_args[@]}" + echo "Uploaded $sbom_asset to $tag" fi rm -f "$notes_file" From 8d8f07ae1b2d7701fcf041d4f37708e99c525d18 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 15 Jul 2026 13:33:35 +0200 Subject: [PATCH 7/9] rename and fold --- README.md | 18 +++++++-------- .../action.yml | 10 ++++----- .../action.yml | 22 +++++++++---------- ...reate-npm-package-github-releases.yml.erb} | 10 ++++----- ...s.yml.erb => publish-npm-tarballs.yml.erb} | 22 +++++++++---------- 5 files changed, 41 insertions(+), 41 deletions(-) rename actions/release/{create-package-releases => create-npm-package-github-releases}/action.yml (90%) rename actions/release/lang/js/{publish-tarballs => publish-npm-tarballs}/action.yml (94%) rename templates/actions/release/{create-package-releases.yml.erb => create-npm-package-github-releases.yml.erb} (90%) rename templates/actions/release/lang/js/{publish-tarballs.yml.erb => publish-npm-tarballs.yml.erb} (91%) diff --git a/README.md b/README.md index b89c479..5ed419c 100644 --- a/README.md +++ b/README.md @@ -46,14 +46,14 @@ Actions are pinned by commit SHA. Bump the SHA to pick up changes. To judge whet These actions follow semantic versioning: expect breaking changes when the major version updates. -### Manifest-driven JavaScript tarball releases +### Manifest-driven npm tarball releases For monorepos that own their own build and package selection, create a manifest plus prebuilt npm tarballs and SBOMs in the source repository, attest those -artifacts before upload, then call `release/lang/js/publish-tarballs` from the +artifacts before upload, then call `release/lang/js/publish-npm-tarballs` from the gated publish job. The action verifies the downloaded tarball attestations, then publishes the exact tarballs to npmjs in manifest order. After the source -repository pushes its package tags, call `release/create-package-releases`; it +repository pushes its package tags, call `release/create-npm-package-github-releases`; it attaches each package's `sbom_asset` from the manifest directory. The manifest passed between build and publish jobs is JSON in this shape: @@ -81,11 +81,11 @@ The manifest passed between build and publish jobs is JSON in this shape: `name` and `version` are required. `packages` must already be in publish order. `dir` and `commit` are source-repository metadata, useful while building and tagging. `tarball_asset` identifies the downloaded tarball basename; when omitted, -`publish-tarballs` derives it from `name` and `version`. `sbom_asset` is required -by `create-package-releases` and is resolved relative to the manifest file's -directory. `tag` defaults to `name@version`; `release_title` defaults to `tag`; and +`publish-npm-tarballs` derives it from `name` and `version`. `sbom_asset` is required +by `create-npm-package-github-releases` and is resolved relative to the manifest +file's directory. `tag` defaults to `name@version`; `release_title` defaults to `tag`; and `release_body` defaults to a short publish message. `channel` and `provenance` -may override the `publish-tarballs` defaults per package. JavaScript SBOM +may override the `publish-npm-tarballs` defaults per package. JavaScript SBOM generation commonly uses `pnpm sbom`, which requires pnpm `>= 11.8`. ### Available release actions @@ -104,8 +104,8 @@ pulls in everything it needs. | `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-tarballs` | Verify prebuilt npm tarballs by glob and publish the exact bytes to npmjs in manifest order | -| `release/create-package-releases` | Create per-package GitHub releases from existing manifest tags, titles, and bodies, attaching each `sbom_asset` from the manifest directory | +| `release/lang/js/publish-npm-tarballs` | Verify prebuilt npm tarballs by glob and publish the exact bytes to npmjs in manifest order | +| `release/create-npm-package-github-releases` | Create npm 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 diff --git a/actions/release/create-package-releases/action.yml b/actions/release/create-npm-package-github-releases/action.yml similarity index 90% rename from actions/release/create-package-releases/action.yml rename to actions/release/create-npm-package-github-releases/action.yml index 75ece69..830d5f3 100644 --- a/actions/release/create-package-releases/action.yml +++ b/actions/release/create-npm-package-github-releases/action.yml @@ -1,8 +1,8 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. # sdk-actions: {"family":"release","version":"1.0.0"} -name: Create Package GitHub Releases +name: Create npm Package GitHub Releases description: > - Create one GitHub release per package in a release manifest and attach each + Create one GitHub release per npm package in a release manifest and attach each package's SBOM asset from the manifest directory. Release tags must already exist in the repository; this action never creates or moves tags. @@ -45,7 +45,7 @@ inputs: runs: using: composite steps: - - name: Create package GitHub releases + - name: Create npm package GitHub releases shell: bash env: GH_TOKEN: ${{ github.token }} @@ -57,12 +57,12 @@ runs: set -euo pipefail if ! command -v jq >/dev/null 2>&1; then - echo "::error title=jq not found::create-package-releases requires jq to read the release manifest." + echo "::error title=jq not found::create-npm-package-github-releases requires jq to read the release manifest." exit 1 fi if [ "$DRY_RUN" != "true" ] && ! command -v gh >/dev/null 2>&1; then - echo "::error title=GitHub CLI not found::create-package-releases requires gh to create releases and upload SBOM assets." + echo "::error title=GitHub CLI not found::create-npm-package-github-releases requires gh to create releases and upload SBOM assets." exit 1 fi diff --git a/actions/release/lang/js/publish-tarballs/action.yml b/actions/release/lang/js/publish-npm-tarballs/action.yml similarity index 94% rename from actions/release/lang/js/publish-tarballs/action.yml rename to actions/release/lang/js/publish-npm-tarballs/action.yml index eb6854d..fdc0c8e 100644 --- a/actions/release/lang/js/publish-tarballs/action.yml +++ b/actions/release/lang/js/publish-npm-tarballs/action.yml @@ -1,8 +1,8 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. # sdk-actions: {"family":"release","version":"1.0.0"} -name: Publish JavaScript Tarballs +name: Publish npm Tarballs description: > - Gated publish action for manifest-driven JavaScript releases. Verifies prebuilt + Gated publish action for manifest-driven npm package releases. Verifies prebuilt npm tarball attestations, publishes the exact downloaded tarballs to npmjs with OIDC, and posts a concise release summary. It does not build, pack, create GitHub releases, or upload SBOM assets. @@ -96,14 +96,14 @@ runs: DEFAULT_CHANNEL: ${{ inputs.channel }} DEFAULT_PROVENANCE: ${{ inputs.provenance }} DRY_RUN: ${{ inputs.dry_run }} - REPORT: ${{ runner.temp }}/sdk-actions-publish-tarballs.tsv + REPORT: ${{ runner.temp }}/sdk-actions-publish-npm-tarballs.tsv NODE_AUTH_TOKEN: '' NPM_TOKEN: '' run: | set -euo pipefail if ! command -v jq >/dev/null 2>&1; then - echo "::error title=jq not found::publish-tarballs requires jq to read the release manifest." + echo "::error title=jq not found::publish-npm-tarballs requires jq to read the release manifest." exit 1 fi @@ -194,16 +194,16 @@ runs: shell: bash env: MANIFEST: ${{ inputs.manifest }} - REPORT: ${{ runner.temp }}/sdk-actions-publish-tarballs.tsv + REPORT: ${{ runner.temp }}/sdk-actions-publish-npm-tarballs.tsv DRY_RUN: ${{ inputs.dry_run }} run: | set -euo pipefail count="$(jq '.packages | length' "$MANIFEST")" if [ "$DRY_RUN" = "true" ]; then - echo "## JavaScript tarball publish dry run" >> "$GITHUB_STEP_SUMMARY" + echo "## npm tarball publish dry run" >> "$GITHUB_STEP_SUMMARY" else - echo "## JavaScript tarballs published" >> "$GITHUB_STEP_SUMMARY" + echo "## npm tarballs published" >> "$GITHUB_STEP_SUMMARY" fi echo "" >> "$GITHUB_STEP_SUMMARY" echo "**Packages:** $count" >> "$GITHUB_STEP_SUMMARY" @@ -221,7 +221,7 @@ runs: id: message-published shell: bash env: - REPORT: ${{ runner.temp }}/sdk-actions-publish-tarballs.tsv + REPORT: ${{ runner.temp }}/sdk-actions-publish-npm-tarballs.tsv SLACK_MENTION: ${{ inputs.slack_mention }} DRY_RUN: ${{ inputs.dry_run }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -229,9 +229,9 @@ runs: set -euo pipefail if [ "$DRY_RUN" = "true" ]; then - text="*JavaScript tarball publish dry run*" + text="*npm tarball publish dry run*" else - text="*JavaScript tarballs published*" + text="*npm tarballs published*" fi while IFS=$'\t' read -r name version status tarball; do npm_name="$(jq -rn --arg v "$name" '$v|@uri')" @@ -285,7 +285,7 @@ runs: FAILURE_SLACK_MENTION: ${{ inputs.failure_slack_mention }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | - text="*JavaScript tarball publish failed*\n<$RUN_URL|View run>" + text="*npm tarball publish failed*\n<$RUN_URL|View run>" if [ -n "$FAILURE_SLACK_MENTION" ]; then text="$text\n$FAILURE_SLACK_MENTION" fi diff --git a/templates/actions/release/create-package-releases.yml.erb b/templates/actions/release/create-npm-package-github-releases.yml.erb similarity index 90% rename from templates/actions/release/create-package-releases.yml.erb rename to templates/actions/release/create-npm-package-github-releases.yml.erb index b19502a..abc3470 100644 --- a/templates/actions/release/create-package-releases.yml.erb +++ b/templates/actions/release/create-npm-package-github-releases.yml.erb @@ -1,6 +1,6 @@ -name: Create Package GitHub Releases +name: Create npm Package GitHub Releases description: > - Create one GitHub release per package in a release manifest and attach each + Create one GitHub release per npm package in a release manifest and attach each package's SBOM asset from the manifest directory. Release tags must already exist in the repository; this action never creates or moves tags. @@ -43,7 +43,7 @@ inputs: runs: using: composite steps: - - name: Create package GitHub releases + - name: Create npm package GitHub releases shell: bash env: GH_TOKEN: ${{ github.token }} @@ -55,12 +55,12 @@ runs: set -euo pipefail if ! command -v jq >/dev/null 2>&1; then - echo "::error title=jq not found::create-package-releases requires jq to read the release manifest." + echo "::error title=jq not found::create-npm-package-github-releases requires jq to read the release manifest." exit 1 fi if [ "$DRY_RUN" != "true" ] && ! command -v gh >/dev/null 2>&1; then - echo "::error title=GitHub CLI not found::create-package-releases requires gh to create releases and upload SBOM assets." + echo "::error title=GitHub CLI not found::create-npm-package-github-releases requires gh to create releases and upload SBOM assets." exit 1 fi diff --git a/templates/actions/release/lang/js/publish-tarballs.yml.erb b/templates/actions/release/lang/js/publish-npm-tarballs.yml.erb similarity index 91% rename from templates/actions/release/lang/js/publish-tarballs.yml.erb rename to templates/actions/release/lang/js/publish-npm-tarballs.yml.erb index c1dc1b9..7c9d3ba 100644 --- a/templates/actions/release/lang/js/publish-tarballs.yml.erb +++ b/templates/actions/release/lang/js/publish-npm-tarballs.yml.erb @@ -1,6 +1,6 @@ -name: Publish JavaScript Tarballs +name: Publish npm Tarballs description: > - Gated publish action for manifest-driven JavaScript releases. Verifies prebuilt + Gated publish action for manifest-driven npm package releases. Verifies prebuilt npm tarball attestations, publishes the exact downloaded tarballs to npmjs with OIDC, and posts a concise release summary. It does not build, pack, create GitHub releases, or upload SBOM assets. @@ -71,14 +71,14 @@ runs: DEFAULT_CHANNEL: ${{ inputs.channel }} DEFAULT_PROVENANCE: ${{ inputs.provenance }} DRY_RUN: ${{ inputs.dry_run }} - REPORT: ${{ runner.temp }}/sdk-actions-publish-tarballs.tsv + REPORT: ${{ runner.temp }}/sdk-actions-publish-npm-tarballs.tsv NODE_AUTH_TOKEN: '' NPM_TOKEN: '' run: | set -euo pipefail if ! command -v jq >/dev/null 2>&1; then - echo "::error title=jq not found::publish-tarballs requires jq to read the release manifest." + echo "::error title=jq not found::publish-npm-tarballs requires jq to read the release manifest." exit 1 fi @@ -169,16 +169,16 @@ runs: shell: bash env: MANIFEST: ${{ inputs.manifest }} - REPORT: ${{ runner.temp }}/sdk-actions-publish-tarballs.tsv + REPORT: ${{ runner.temp }}/sdk-actions-publish-npm-tarballs.tsv DRY_RUN: ${{ inputs.dry_run }} run: | set -euo pipefail count="$(jq '.packages | length' "$MANIFEST")" if [ "$DRY_RUN" = "true" ]; then - echo "## JavaScript tarball publish dry run" >> "$GITHUB_STEP_SUMMARY" + echo "## npm tarball publish dry run" >> "$GITHUB_STEP_SUMMARY" else - echo "## JavaScript tarballs published" >> "$GITHUB_STEP_SUMMARY" + echo "## npm tarballs published" >> "$GITHUB_STEP_SUMMARY" fi echo "" >> "$GITHUB_STEP_SUMMARY" echo "**Packages:** $count" >> "$GITHUB_STEP_SUMMARY" @@ -196,7 +196,7 @@ runs: id: message-published shell: bash env: - REPORT: ${{ runner.temp }}/sdk-actions-publish-tarballs.tsv + REPORT: ${{ runner.temp }}/sdk-actions-publish-npm-tarballs.tsv SLACK_MENTION: ${{ inputs.slack_mention }} DRY_RUN: ${{ inputs.dry_run }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -204,9 +204,9 @@ runs: set -euo pipefail if [ "$DRY_RUN" = "true" ]; then - text="*JavaScript tarball publish dry run*" + text="*npm tarball publish dry run*" else - text="*JavaScript tarballs published*" + text="*npm tarballs published*" fi while IFS=$'\t' read -r name version status tarball; do npm_name="$(jq -rn --arg v "$name" '$v|@uri')" @@ -233,7 +233,7 @@ runs: FAILURE_SLACK_MENTION: ${{ inputs.failure_slack_mention }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | - text="*JavaScript tarball publish failed*\n<$RUN_URL|View run>" + text="*npm tarball publish failed*\n<$RUN_URL|View run>" if [ -n "$FAILURE_SLACK_MENTION" ]; then text="$text\n$FAILURE_SLACK_MENTION" fi From 2bac048174eef473603b7ab63e46b5258c7c58e3 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 15 Jul 2026 17:17:59 +0200 Subject: [PATCH 8/9] extract npm setup step --- .../lang/js/publish-npm-tarballs/action.yml | 10 ++++++++-- .../release/lang/js/ship-package/action.yml | 15 ++++++++++---- .../lang/js/publish-npm-tarballs.yml.erb | 16 +++++++-------- .../release/lang/js/ship-package.yml.erb | 13 +++++------- templates/steps/lang/js/npm/setup.yml.erb | 20 +++++++++++++++++++ 5 files changed, 51 insertions(+), 23 deletions(-) create mode 100644 templates/steps/lang/js/npm/setup.yml.erb diff --git a/actions/release/lang/js/publish-npm-tarballs/action.yml b/actions/release/lang/js/publish-npm-tarballs/action.yml index fdc0c8e..cbaad94 100644 --- a/actions/release/lang/js/publish-npm-tarballs/action.yml +++ b/actions/release/lang/js/publish-npm-tarballs/action.yml @@ -26,6 +26,10 @@ inputs: description: "Default npm dist-tag" required: false default: 'latest' + npm_version: + description: "npm CLI version to install for OIDC trusted publishing" + required: false + default: '11.6.2' dry_run: description: "Skip attestation verification and run npm publish with --dry-run" required: false @@ -81,12 +85,14 @@ runs: - name: Set up Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: 'lts/*' + node-version: lts/* registry-url: https://registry.npmjs.org - name: Install npm with OIDC trusted publishing support shell: bash - run: npm install -g "npm@11.6.2" + env: + NPM_VERSION: ${{ inputs.npm_version }} + run: npm install -g "npm@$NPM_VERSION" - name: Publish tarballs in manifest order shell: bash diff --git a/actions/release/lang/js/ship-package/action.yml b/actions/release/lang/js/ship-package/action.yml index 1cf5c5b..51585c4 100644 --- a/actions/release/lang/js/ship-package/action.yml +++ b/actions/release/lang/js/ship-package/action.yml @@ -42,6 +42,10 @@ inputs: description: "npm release channel (dist-tag) to publish under" required: false default: 'latest' + npm_version: + description: "npm CLI version to install for OIDC trusted publishing" + required: false + default: '11.6.2' dry_run: description: "Skip verification, npm publish, tag push, and GitHub release creation" required: false @@ -143,15 +147,18 @@ runs: done echo "✓ attestations verified for: ${FILES[*]}" - # Publishing a prebuilt tarball goes through the npm CLI, so this needs Node with a - # user-writable global prefix (to install a modern npm for OIDC) and the registry - # wired for trusted publishing — but no pnpm and no dependency install. - name: Set up Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: 'lts/*' + node-version: lts/* registry-url: ${{ inputs.registry }} + - name: Install npm with OIDC trusted publishing support + shell: bash + env: + NPM_VERSION: ${{ inputs.npm_version }} + run: npm install -g "npm@$NPM_VERSION" + - name: Publish prebuilt tarball to npm shell: bash env: diff --git a/templates/actions/release/lang/js/publish-npm-tarballs.yml.erb b/templates/actions/release/lang/js/publish-npm-tarballs.yml.erb index 7c9d3ba..9925fa3 100644 --- a/templates/actions/release/lang/js/publish-npm-tarballs.yml.erb +++ b/templates/actions/release/lang/js/publish-npm-tarballs.yml.erb @@ -24,6 +24,10 @@ inputs: description: "Default npm dist-tag" required: false default: 'latest' + npm_version: + description: "npm CLI version to install for OIDC trusted publishing" + required: false + default: '11.6.2' dry_run: description: "Skip attestation verification and run npm publish with --dry-run" required: false @@ -53,15 +57,9 @@ runs: artifacts: "${{ inputs.tarballs }}", repo: "${{ inputs.repo }}") %> - - name: Set up Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 'lts/*' - registry-url: https://registry.npmjs.org - - - name: Install npm with OIDC trusted publishing support - shell: bash - run: npm install -g "npm@11.6.2" +<%= render_step('lang/js/npm/setup', + registry: "https://registry.npmjs.org", + npm_version: "${{ inputs.npm_version }}") %> - name: Publish tarballs in manifest order shell: bash diff --git a/templates/actions/release/lang/js/ship-package.yml.erb b/templates/actions/release/lang/js/ship-package.yml.erb index ac73be2..8776104 100644 --- a/templates/actions/release/lang/js/ship-package.yml.erb +++ b/templates/actions/release/lang/js/ship-package.yml.erb @@ -40,6 +40,10 @@ inputs: description: "npm release channel (dist-tag) to publish under" required: false default: 'latest' + npm_version: + description: "npm CLI version to install for OIDC trusted publishing" + required: false + default: '11.6.2' dry_run: description: "Skip verification, npm publish, tag push, and GitHub release creation" required: false @@ -119,14 +123,7 @@ runs: if: "${{ inputs.dry_run != 'true' }}", artifacts: "${{ inputs.tarball }}") %> - # Publishing a prebuilt tarball goes through the npm CLI, so this needs Node with a - # user-writable global prefix (to install a modern npm for OIDC) and the registry - # wired for trusted publishing — but no pnpm and no dependency install. - - name: Set up Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 'lts/*' - registry-url: ${{ inputs.registry }} +<%= render_step('lang/js/npm/setup') %> <%= render_step('lang/js/npm/publish-package', tarball: "${{ inputs.tarball }}") %> diff --git a/templates/steps/lang/js/npm/setup.yml.erb b/templates/steps/lang/js/npm/setup.yml.erb new file mode 100644 index 0000000..5479c64 --- /dev/null +++ b/templates/steps/lang/js/npm/setup.yml.erb @@ -0,0 +1,20 @@ +<%# + Sets up Node for direct npm CLI publishing and installs a modern npm with OIDC + trusted publishing support. This is for publish-only flows that do not need + pnpm/bun or dependency installation. + + @param node_version [String] Node version. Default: lts/* + @param registry [String] npm registry URL. Default: ${{ inputs.registry }} + @param npm_version [String] npm CLI version. Default: ${{ inputs.npm_version }} +-%> +- name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: <%= locals.fetch(:node_version) { "lts/*" } %> + registry-url: <%= locals.fetch(:registry) { "${{ inputs.registry }}" } %> + +- name: Install npm with OIDC trusted publishing support + shell: bash + env: + NPM_VERSION: <%= locals.fetch(:npm_version) { "${{ inputs.npm_version }}" } %> + run: npm install -g "npm@$NPM_VERSION" From 2691565c235ed84e5f49e9b0a54c18f3e1783e7c Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Fri, 17 Jul 2026 15:09:42 +0200 Subject: [PATCH 9/9] feedback --- README.md | 12 ++-- .../action.yml | 63 +++++++++++++------ .../lang/js/publish-npm-tarballs/action.yml | 45 ++++++++----- ...=> create-package-github-releases.yml.erb} | 63 +++++++++++++------ .../lang/js/publish-npm-tarballs.yml.erb | 45 ++++++++----- 5 files changed, 158 insertions(+), 70 deletions(-) rename actions/release/{create-npm-package-github-releases => create-package-github-releases}/action.yml (66%) rename templates/actions/release/{create-npm-package-github-releases.yml.erb => create-package-github-releases.yml.erb} (66%) diff --git a/README.md b/README.md index 044a2ee..16bd413 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ plus prebuilt npm tarballs and SBOMs in the source repository, attest those artifacts before upload, then call `release/lang/js/publish-npm-tarballs` from the gated publish job. The action verifies the downloaded tarball attestations, then publishes the exact tarballs to npmjs in manifest order. After the source -repository pushes its package tags, call `release/create-npm-package-github-releases`; it +repository pushes its package tags, call `release/create-package-github-releases`; it attaches each package's `sbom_asset` from the manifest directory. The manifest passed between build and publish jobs is JSON in this shape: @@ -86,10 +86,10 @@ The manifest passed between build and publish jobs is JSON in this shape: `name` and `version` are required. `packages` must already be in publish order. `dir` and `commit` are source-repository metadata, useful while building and -tagging. `tarball_asset` identifies the downloaded tarball basename; when omitted, -`publish-npm-tarballs` derives it from `name` and `version`. `sbom_asset` is required -by `create-npm-package-github-releases` and is resolved relative to the manifest -file's directory. `tag` defaults to `name@version`; `release_title` defaults to `tag`; and +tagging. `tarball_asset` identifies the downloaded tarball basename and is required +by `publish-npm-tarballs`. `sbom_asset` is required by +`create-package-github-releases` and is resolved relative to the manifest file's +directory. `tag` defaults to `name@version`; `release_title` defaults to `tag`; and `release_body` defaults to a short publish message. `channel` and `provenance` may override the `publish-npm-tarballs` defaults per package. JavaScript SBOM generation commonly uses `pnpm sbom`, which requires pnpm `>= 11.8`. @@ -108,7 +108,7 @@ The workflow is composed from these actions — `bin/workflow generate` wires th | `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-npm-package-github-releases` | Create npm package GitHub releases from existing manifest tags, titles, and bodies, attaching each `sbom_asset` from the manifest directory | +| `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: diff --git a/actions/release/create-npm-package-github-releases/action.yml b/actions/release/create-package-github-releases/action.yml similarity index 66% rename from actions/release/create-npm-package-github-releases/action.yml rename to actions/release/create-package-github-releases/action.yml index 830d5f3..e9f38e7 100644 --- a/actions/release/create-npm-package-github-releases/action.yml +++ b/actions/release/create-package-github-releases/action.yml @@ -1,10 +1,10 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. # sdk-actions: {"family":"release","version":"1.0.0"} -name: Create npm Package GitHub Releases +name: Create Package GitHub Releases description: > - Create one GitHub release per npm package in a release manifest and attach each - package's SBOM asset from the manifest directory. Release tags must already - exist in the repository; this action never creates or moves tags. + Create one GitHub release per package in a release manifest and attach each package's + SBOM asset from the manifest directory. Release tags must already exist in the + repository; this action never creates or moves tags. # Manifest contract: # { @@ -45,7 +45,7 @@ inputs: runs: using: composite steps: - - name: Create npm package GitHub releases + - name: Create package GitHub releases shell: bash env: GH_TOKEN: ${{ github.token }} @@ -57,12 +57,12 @@ runs: set -euo pipefail if ! command -v jq >/dev/null 2>&1; then - echo "::error title=jq not found::create-npm-package-github-releases requires jq to read the release manifest." + echo "::error title=jq not found::create-package-github-releases requires jq to read the release manifest." exit 1 fi if [ "$DRY_RUN" != "true" ] && ! command -v gh >/dev/null 2>&1; then - echo "::error title=GitHub CLI not found::create-npm-package-github-releases requires gh to create releases and upload SBOM assets." + echo "::error title=GitHub CLI not found::create-package-github-releases requires gh to create releases and upload SBOM assets." exit 1 fi @@ -77,39 +77,68 @@ runs: clobber_args+=(--clobber) fi + packages_tsv="$(mktemp)" + trap 'rm -f "$packages_tsv" "${notes_files[@]:-}"' EXIT + notes_files=() + while IFS= read -r encoded; do package_json="$(printf '%s' "$encoded" | base64 -d)" - name="$(jq -r '.name' <<< "$package_json")" - version="$(jq -r '.version' <<< "$package_json")" + name="$(jq -r '.name // empty' <<< "$package_json")" + version="$(jq -r '.version // empty' <<< "$package_json")" tag="$(jq -r '.tag // "\(.name)@\(.version)"' <<< "$package_json")" sbom_asset="$(jq -r '.sbom_asset // empty' <<< "$package_json")" - title="$(jq -r '.release_title // .tag // "\(.name)@\(.version)"' <<< "$package_json")" notes_file="$(mktemp)" + notes_files+=("$notes_file") jq -r '.release_body // "Published \(.name)@\(.version)."' <<< "$package_json" > "$notes_file" + if [ -z "$name" ] || [ -z "$version" ]; then + echo "::error title=Manifest package invalid::each package must set name and version." + exit 1 + fi + if [ -z "$sbom_asset" ]; then echo "::error title=SBOM asset missing::manifest package $name@$version must set sbom_asset." - rm -f "$notes_file" exit 1 fi if [ "$(basename "$sbom_asset")" != "$sbom_asset" ]; then echo "::error title=Invalid SBOM asset::sbom_asset must be a file basename, got: $sbom_asset" - rm -f "$notes_file" exit 1 fi sbom_path="$manifest_dir/$sbom_asset" if [ ! -f "$sbom_path" ]; then echo "::error title=SBOM asset not found::expected $sbom_asset for $name@$version next to $MANIFEST." - rm -f "$notes_file" exit 1 fi + if [ "$DRY_RUN" != "true" ]; then + gh api --silent "repos/$REPO/git/ref/tags/$tag" || { + echo "::error title=Tag not found::release tag $tag for $name@$version does not exist in $REPO." + exit 1 + } + fi + + printf '%s\t%s\t%s\n' "$encoded" "$notes_file" "$sbom_path" >> "$packages_tsv" + done < <(jq -r '.packages[]? | @base64' "$MANIFEST") + + if [ ! -s "$packages_tsv" ]; then + echo "::error title=Manifest empty::release manifest must contain at least one package." + exit 1 + fi + + while IFS=$'\t' read -r encoded notes_file sbom_path; do + package_json="$(printf '%s' "$encoded" | base64 -d)" + name="$(jq -r '.name' <<< "$package_json")" + version="$(jq -r '.version' <<< "$package_json")" + tag="$(jq -r '.tag // "\(.name)@\(.version)"' <<< "$package_json")" + title="$(jq -r '.release_title // .tag // "\(.name)@\(.version)"' <<< "$package_json")" + if [ "$DRY_RUN" = "true" ]; then echo "DRY RUN: would create GitHub release $tag for $name@$version" elif gh release view "$tag" --repo "$REPO" >/dev/null 2>&1; then - echo "GitHub release already exists for $tag" + echo "GitHub release already exists for $tag; skipping release publication." + continue else gh release create "$tag" \ --repo "$REPO" \ @@ -123,8 +152,6 @@ runs: echo "DRY RUN: would upload $sbom_path to $tag in $REPO" else gh release upload "$tag" "$sbom_path" --repo "$REPO" "${clobber_args[@]}" - echo "Uploaded $sbom_asset to $tag" + echo "Uploaded $(basename "$sbom_path") to $tag" fi - - rm -f "$notes_file" - done < <(jq -r '.packages[]? | @base64' "$MANIFEST") + done < "$packages_tsv" diff --git a/actions/release/lang/js/publish-npm-tarballs/action.yml b/actions/release/lang/js/publish-npm-tarballs/action.yml index cbaad94..041889a 100644 --- a/actions/release/lang/js/publish-npm-tarballs/action.yml +++ b/actions/release/lang/js/publish-npm-tarballs/action.yml @@ -127,15 +127,6 @@ runs: : > "$REPORT" - artifact_base() { - local name="$1" - local version="$2" - local base="${name#@}" - base="${base//\//-}" - base="${base//@/-}" - printf '%s-%s' "$base" "$version" - } - find_tarball() { local expected="$1" local matched=() @@ -153,15 +144,32 @@ runs: printf '%s\n' "${matched[0]}" } + packages_tsv="$(mktemp)" + trap 'rm -f "$packages_tsv"' EXIT + while IFS= read -r encoded; do package_json="$(printf '%s' "$encoded" | base64 -d)" - name="$(jq -r '.name' <<< "$package_json")" - version="$(jq -r '.version' <<< "$package_json")" - expected_tarball="$(jq -r '.tarball_asset // .tarball // empty' <<< "$package_json")" + name="$(jq -r '.name // empty' <<< "$package_json")" + version="$(jq -r '.version // empty' <<< "$package_json")" + expected_tarball="$(jq -r '.tarball_asset // empty' <<< "$package_json")" channel="$(jq -r '.channel // empty' <<< "$package_json")" provenance="$(jq -r '.provenance // empty' <<< "$package_json")" - [ -n "$expected_tarball" ] || expected_tarball="$(artifact_base "$name" "$version").tgz" + if [ -z "$name" ] || [ -z "$version" ]; then + echo "::error title=Manifest package invalid::each package must set name and version." + exit 1 + fi + + if [ -z "$expected_tarball" ]; then + echo "::error title=Tarball asset missing::manifest package $name@$version must set tarball_asset." + exit 1 + fi + + if [ "$(basename "$expected_tarball")" != "$expected_tarball" ]; then + echo "::error title=Invalid tarball asset::tarball_asset must be a file basename, got: $expected_tarball" + exit 1 + fi + [ -n "$channel" ] || channel="$DEFAULT_CHANNEL" [ -n "$provenance" ] || provenance="$DEFAULT_PROVENANCE" @@ -173,6 +181,15 @@ runs: exit 1 fi + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$name" "$version" "$expected_tarball" "$tarball" "$channel" "$provenance" >> "$packages_tsv" + done < <(jq -r '.packages[]? | @base64' "$MANIFEST") + + if [ ! -s "$packages_tsv" ]; then + echo "::error title=Manifest empty::release manifest must contain at least one package." + exit 1 + fi + + while IFS=$'\t' read -r name version expected_tarball tarball channel provenance; do args=(--tag "$channel" --access public --registry https://registry.npmjs.org) [ "$provenance" = "true" ] && args+=(--provenance) @@ -194,7 +211,7 @@ runs: fi printf '%s\t%s\t%s\t%s\n' "$name" "$version" "$status" "$tarball" >> "$REPORT" - done < <(jq -r '.packages[]? | @base64' "$MANIFEST") + done < "$packages_tsv" - name: Post publish summary shell: bash diff --git a/templates/actions/release/create-npm-package-github-releases.yml.erb b/templates/actions/release/create-package-github-releases.yml.erb similarity index 66% rename from templates/actions/release/create-npm-package-github-releases.yml.erb rename to templates/actions/release/create-package-github-releases.yml.erb index abc3470..3db7f75 100644 --- a/templates/actions/release/create-npm-package-github-releases.yml.erb +++ b/templates/actions/release/create-package-github-releases.yml.erb @@ -1,8 +1,8 @@ -name: Create npm Package GitHub Releases +name: Create Package GitHub Releases description: > - Create one GitHub release per npm package in a release manifest and attach each - package's SBOM asset from the manifest directory. Release tags must already - exist in the repository; this action never creates or moves tags. + Create one GitHub release per package in a release manifest and attach each package's + SBOM asset from the manifest directory. Release tags must already exist in the + repository; this action never creates or moves tags. # Manifest contract: # { @@ -43,7 +43,7 @@ inputs: runs: using: composite steps: - - name: Create npm package GitHub releases + - name: Create package GitHub releases shell: bash env: GH_TOKEN: ${{ github.token }} @@ -55,12 +55,12 @@ runs: set -euo pipefail if ! command -v jq >/dev/null 2>&1; then - echo "::error title=jq not found::create-npm-package-github-releases requires jq to read the release manifest." + echo "::error title=jq not found::create-package-github-releases requires jq to read the release manifest." exit 1 fi if [ "$DRY_RUN" != "true" ] && ! command -v gh >/dev/null 2>&1; then - echo "::error title=GitHub CLI not found::create-npm-package-github-releases requires gh to create releases and upload SBOM assets." + echo "::error title=GitHub CLI not found::create-package-github-releases requires gh to create releases and upload SBOM assets." exit 1 fi @@ -75,39 +75,68 @@ runs: clobber_args+=(--clobber) fi + packages_tsv="$(mktemp)" + trap 'rm -f "$packages_tsv" "${notes_files[@]:-}"' EXIT + notes_files=() + while IFS= read -r encoded; do package_json="$(printf '%s' "$encoded" | base64 -d)" - name="$(jq -r '.name' <<< "$package_json")" - version="$(jq -r '.version' <<< "$package_json")" + name="$(jq -r '.name // empty' <<< "$package_json")" + version="$(jq -r '.version // empty' <<< "$package_json")" tag="$(jq -r '.tag // "\(.name)@\(.version)"' <<< "$package_json")" sbom_asset="$(jq -r '.sbom_asset // empty' <<< "$package_json")" - title="$(jq -r '.release_title // .tag // "\(.name)@\(.version)"' <<< "$package_json")" notes_file="$(mktemp)" + notes_files+=("$notes_file") jq -r '.release_body // "Published \(.name)@\(.version)."' <<< "$package_json" > "$notes_file" + if [ -z "$name" ] || [ -z "$version" ]; then + echo "::error title=Manifest package invalid::each package must set name and version." + exit 1 + fi + if [ -z "$sbom_asset" ]; then echo "::error title=SBOM asset missing::manifest package $name@$version must set sbom_asset." - rm -f "$notes_file" exit 1 fi if [ "$(basename "$sbom_asset")" != "$sbom_asset" ]; then echo "::error title=Invalid SBOM asset::sbom_asset must be a file basename, got: $sbom_asset" - rm -f "$notes_file" exit 1 fi sbom_path="$manifest_dir/$sbom_asset" if [ ! -f "$sbom_path" ]; then echo "::error title=SBOM asset not found::expected $sbom_asset for $name@$version next to $MANIFEST." - rm -f "$notes_file" exit 1 fi + if [ "$DRY_RUN" != "true" ]; then + gh api --silent "repos/$REPO/git/ref/tags/$tag" || { + echo "::error title=Tag not found::release tag $tag for $name@$version does not exist in $REPO." + exit 1 + } + fi + + printf '%s\t%s\t%s\n' "$encoded" "$notes_file" "$sbom_path" >> "$packages_tsv" + done < <(jq -r '.packages[]? | @base64' "$MANIFEST") + + if [ ! -s "$packages_tsv" ]; then + echo "::error title=Manifest empty::release manifest must contain at least one package." + exit 1 + fi + + while IFS=$'\t' read -r encoded notes_file sbom_path; do + package_json="$(printf '%s' "$encoded" | base64 -d)" + name="$(jq -r '.name' <<< "$package_json")" + version="$(jq -r '.version' <<< "$package_json")" + tag="$(jq -r '.tag // "\(.name)@\(.version)"' <<< "$package_json")" + title="$(jq -r '.release_title // .tag // "\(.name)@\(.version)"' <<< "$package_json")" + if [ "$DRY_RUN" = "true" ]; then echo "DRY RUN: would create GitHub release $tag for $name@$version" elif gh release view "$tag" --repo "$REPO" >/dev/null 2>&1; then - echo "GitHub release already exists for $tag" + echo "GitHub release already exists for $tag; skipping release publication." + continue else gh release create "$tag" \ --repo "$REPO" \ @@ -121,8 +150,6 @@ runs: echo "DRY RUN: would upload $sbom_path to $tag in $REPO" else gh release upload "$tag" "$sbom_path" --repo "$REPO" "${clobber_args[@]}" - echo "Uploaded $sbom_asset to $tag" + echo "Uploaded $(basename "$sbom_path") to $tag" fi - - rm -f "$notes_file" - done < <(jq -r '.packages[]? | @base64' "$MANIFEST") + done < "$packages_tsv" diff --git a/templates/actions/release/lang/js/publish-npm-tarballs.yml.erb b/templates/actions/release/lang/js/publish-npm-tarballs.yml.erb index 9925fa3..bdf6271 100644 --- a/templates/actions/release/lang/js/publish-npm-tarballs.yml.erb +++ b/templates/actions/release/lang/js/publish-npm-tarballs.yml.erb @@ -94,15 +94,6 @@ runs: : > "$REPORT" - artifact_base() { - local name="$1" - local version="$2" - local base="${name#@}" - base="${base//\//-}" - base="${base//@/-}" - printf '%s-%s' "$base" "$version" - } - find_tarball() { local expected="$1" local matched=() @@ -120,15 +111,32 @@ runs: printf '%s\n' "${matched[0]}" } + packages_tsv="$(mktemp)" + trap 'rm -f "$packages_tsv"' EXIT + while IFS= read -r encoded; do package_json="$(printf '%s' "$encoded" | base64 -d)" - name="$(jq -r '.name' <<< "$package_json")" - version="$(jq -r '.version' <<< "$package_json")" - expected_tarball="$(jq -r '.tarball_asset // .tarball // empty' <<< "$package_json")" + name="$(jq -r '.name // empty' <<< "$package_json")" + version="$(jq -r '.version // empty' <<< "$package_json")" + expected_tarball="$(jq -r '.tarball_asset // empty' <<< "$package_json")" channel="$(jq -r '.channel // empty' <<< "$package_json")" provenance="$(jq -r '.provenance // empty' <<< "$package_json")" - [ -n "$expected_tarball" ] || expected_tarball="$(artifact_base "$name" "$version").tgz" + if [ -z "$name" ] || [ -z "$version" ]; then + echo "::error title=Manifest package invalid::each package must set name and version." + exit 1 + fi + + if [ -z "$expected_tarball" ]; then + echo "::error title=Tarball asset missing::manifest package $name@$version must set tarball_asset." + exit 1 + fi + + if [ "$(basename "$expected_tarball")" != "$expected_tarball" ]; then + echo "::error title=Invalid tarball asset::tarball_asset must be a file basename, got: $expected_tarball" + exit 1 + fi + [ -n "$channel" ] || channel="$DEFAULT_CHANNEL" [ -n "$provenance" ] || provenance="$DEFAULT_PROVENANCE" @@ -140,6 +148,15 @@ runs: exit 1 fi + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$name" "$version" "$expected_tarball" "$tarball" "$channel" "$provenance" >> "$packages_tsv" + done < <(jq -r '.packages[]? | @base64' "$MANIFEST") + + if [ ! -s "$packages_tsv" ]; then + echo "::error title=Manifest empty::release manifest must contain at least one package." + exit 1 + fi + + while IFS=$'\t' read -r name version expected_tarball tarball channel provenance; do args=(--tag "$channel" --access public --registry https://registry.npmjs.org) [ "$provenance" = "true" ] && args+=(--provenance) @@ -161,7 +178,7 @@ runs: fi printf '%s\t%s\t%s\t%s\n' "$name" "$version" "$status" "$tarball" >> "$REPORT" - done < <(jq -r '.packages[]? | @base64' "$MANIFEST") + done < "$packages_tsv" - name: Post publish summary shell: bash