From e4c9b10fd069b4ed407ad875886a990fa9b8a13d Mon Sep 17 00:00:00 2001 From: David Elner Date: Wed, 1 Jul 2026 22:39:33 +0000 Subject: [PATCH] WIP: SBOM --- .github/workflows/release-js.yml | 1 + .github/workflows/release-py.yml | 1 + .github/workflows/release-ruby.yml | 1 + CONTRIBUTING.md | 13 +- README.md | 30 ++-- actions/release/lang/js/publish/action.yml | 51 ++++++- actions/release/lang/js/validate/action.yml | 24 +++- actions/release/lang/py/publish/action.yml | 44 +++++- actions/release/lang/py/validate/action.yml | 26 +++- actions/release/lang/ruby/publish/action.yml | 130 ++++++++++++++++-- actions/release/lang/ruby/validate/action.yml | 90 +++++++++++- .../actions/release/lang/js/publish.yml.erb | 21 ++- .../actions/release/lang/js/validate.yml.erb | 15 +- .../actions/release/lang/py/publish.yml.erb | 24 +++- .../actions/release/lang/py/validate.yml.erb | 15 +- .../actions/release/lang/ruby/publish.yml.erb | 34 ++++- .../release/lang/ruby/validate.yml.erb | 25 ++-- .../steps/release/attest-provenance.yml.erb | 15 ++ templates/steps/release/attest-sbom.yml.erb | 25 ++++ .../release/create-github-release.yml.erb | 8 ++ .../steps/release/lang/js/attest-sbom.yml.erb | 24 ++++ templates/steps/release/lang/js/sbom.yml.erb | 21 +++ templates/steps/release/lang/py/sbom.yml.erb | 24 ++++ .../steps/release/lang/ruby/build.yml.erb | 21 +++ .../release/lang/ruby/rubygems-push.yml.erb | 34 +++-- .../steps/release/lang/ruby/sbom.yml.erb | 74 ++++++++++ test/release/js/package.json | 2 +- test/release/ruby/bt-publishing-test.gemspec | 6 +- 28 files changed, 717 insertions(+), 82 deletions(-) create mode 100644 templates/steps/release/attest-provenance.yml.erb create mode 100644 templates/steps/release/attest-sbom.yml.erb create mode 100644 templates/steps/release/lang/js/attest-sbom.yml.erb create mode 100644 templates/steps/release/lang/js/sbom.yml.erb create mode 100644 templates/steps/release/lang/py/sbom.yml.erb create mode 100644 templates/steps/release/lang/ruby/build.yml.erb create mode 100644 templates/steps/release/lang/ruby/sbom.yml.erb diff --git a/.github/workflows/release-js.yml b/.github/workflows/release-js.yml index 5dae509..8181222 100644 --- a/.github/workflows/release-js.yml +++ b/.github/workflows/release-js.yml @@ -208,6 +208,7 @@ jobs: permissions: contents: write id-token: write # OIDC trusted publishing + provenance + attestations: write # signed SBOM attestation (actions/attest-sbom) steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/.github/workflows/release-py.yml b/.github/workflows/release-py.yml index 5b8fb34..06413c2 100644 --- a/.github/workflows/release-py.yml +++ b/.github/workflows/release-py.yml @@ -198,6 +198,7 @@ jobs: permissions: contents: write id-token: write # OIDC trusted publishing + PEP 740 attestations + attestations: write # signed SBOM attestation (actions/attest-sbom) steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/.github/workflows/release-ruby.yml b/.github/workflows/release-ruby.yml index 0ce2cb4..0c8882f 100644 --- a/.github/workflows/release-ruby.yml +++ b/.github/workflows/release-ruby.yml @@ -196,6 +196,7 @@ jobs: permissions: contents: write id-token: write + attestations: write # signed SBOM attestation (actions/attest-sbom) steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c1fdeb9..1527391 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,9 +98,14 @@ copied verbatim. `render_step(name, indent: 4, **locals)`: - **Control flow lives in the action.** Compose pure "do" steps, then a terminal failure handler: `… → render_step('on-failure', if: "${{ failure() }}")`. -- **Non-control conditions are shell self-guards, not `if:`.** "Slack - configured?", "dry run?", "create a GitHub release?" are handled with an early - `exit 0` inside the step's own `run:` — so there's never an `if:` for the - generator to merge, and control flow stays readable at the call site. +- **A toggle that enables/disables a whole step uses `if:` at the call site.** + e.g. `render_step('lang/js/build', if: "${{ inputs.build == 'true' }}")` and + `render_step('release/lang/ruby/sbom', if: "${{ inputs.sbom == 'true' }}")`. + Attestation steps (which must not run pre-approval or on dry runs) are gated the + same way: `if: "${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}"`. +- **A condition that changes behavior *within* a step is a shell self-guard, not + `if:`.** "Slack configured?", "dry run → skip the push but still preview", "create + a GitHub release?" are handled with an early `exit 0` (or a branch) inside the + step's own `run:` — keeping the step self-contained and the call site readable. diff --git a/README.md b/README.md index c15f417..997eee1 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ Shared GitHub actions and workflows for Braintrust SDK repositories. **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. +### 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`. + ### Adopting the release workflow 1. **Copy the canonical template** for your language into your repo's @@ -13,15 +18,10 @@ Shared GitHub actions and workflows for Braintrust SDK repositories. - [`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. **Adapt the marked sections** — version source, gem name, working directory, - and so on. The template's comments flag exactly what changes per repo; - everything else is provided by the pinned actions. -3. **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. -4. **Bump the pinned SHA** to pick up action changes. Because each action is - self-contained, one SHA bump pulls in the whole updated chain. +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. ### Available release actions @@ -32,12 +32,12 @@ pulls in everything it needs. | Action | Purpose | |---|---| -| `release/lang/ruby/publish` | Push the gem to RubyGems, create the GitHub release, and notify | -| `release/lang/ruby/validate` | Check out the SHA, set up Ruby, read the version, validate the release (tag/branch/metadata), lint + build | -| `release/lang/js/publish` | Publish the package to npm (OIDC trusted publishing), create the GitHub release, 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 | -| `release/lang/py/publish` | Publish the package to PyPI (OIDC trusted publishing + PEP 740 attestations), create the GitHub release, 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 | +| `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/prepare` | Fetch the PR list and release notes | diff --git a/actions/release/lang/js/publish/action.yml b/actions/release/lang/js/publish/action.yml index 041d36c..3846b9f 100644 --- a/actions/release/lang/js/publish/action.yml +++ b/actions/release/lang/js/publish/action.yml @@ -2,9 +2,10 @@ name: Publish JavaScript Package description: > Full JavaScript package release: checks out the SHA, sets up Node and pnpm, - builds, publishes to npm (OIDC trusted publishing), creates a - GitHub release, and sends a Slack completion notification. Covers the standard - publish sequence for Braintrust npm packages. + 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. inputs: sha: @@ -72,6 +73,13 @@ inputs: description: "Whether to publish with provenance/attestation (requires a public package)" required: false default: 'true' + sbom: + description: > + Generate a CycloneDX SBOM (pnpm sbom — needs pnpm ≥ 11.8), sign it (GitHub + SBOM attestation bound to the published tarball), and attach it to the + GitHub release. Requires attestations: write on the job. + required: false + default: 'true' channel: description: "npm release channel (dist-tag) to publish under" required: false @@ -187,6 +195,37 @@ runs: run: | pnpm run "$BUILD_COMMAND" + - 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: Pack tarball for SBOM attestation + if: ${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }} + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + # pnpm pack emits the exact tarball `pnpm publish` will upload (one packing + # pipeline in pnpm ≥ 11.8), so attesting it binds the SBOM to the published + # digest. Packed into a temp dir OUTSIDE the package so it can't be published. + PACKDIR=$(mktemp -d) + pnpm pack --pack-destination "$PACKDIR" >/dev/null + mv "$PACKDIR"/*.tgz "$RUNNER_TEMP/sbom-subject.tgz" + - name: Attest SBOM + if: ${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }} + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: |- + ${{ runner.temp }}/sbom-subject.tgz + sbom-path: ${{ inputs.working_directory }}/sbom.json + - name: Publish to npm shell: bash working-directory: ${{ inputs.working_directory }} @@ -229,6 +268,8 @@ runs: 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." @@ -246,6 +287,10 @@ runs: --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 diff --git a/actions/release/lang/js/validate/action.yml b/actions/release/lang/js/validate/action.yml index 13e2ad7..df16d4c 100644 --- a/actions/release/lang/js/validate/action.yml +++ b/actions/release/lang/js/validate/action.yml @@ -3,8 +3,9 @@ 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), and builds to confirm the package is publishable - before the approval gate. + 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. inputs: sha: @@ -67,6 +68,13 @@ inputs: description: "npm script to run for the build (when build is 'true')" required: false 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. + required: false + default: 'true' outputs: release_tag: description: "The release tag (e.g. v0.3.2)" @@ -260,6 +268,18 @@ runs: run: | pnpm run "$BUILD_COMMAND" + - 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: Dump JavaScript environment if: ${{ failure() }} shell: bash diff --git a/actions/release/lang/py/publish/action.yml b/actions/release/lang/py/publish/action.yml index 9111b6f..79890f7 100644 --- a/actions/release/lang/py/publish/action.yml +++ b/actions/release/lang/py/publish/action.yml @@ -2,9 +2,11 @@ name: Publish Python Package description: > Full Python package release: checks out the SHA, sets up uv and Python, - builds the sdist + wheel, publishes to PyPI (OIDC Trusted Publishing with PEP - 740 attestations), creates a GitHub release, and sends a Slack completion - notification. Covers the standard publish sequence for Braintrust PyPI packages. + 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. inputs: sha: @@ -54,6 +56,13 @@ inputs: description: "Whether to publish PEP 740 attestations (Sigstore-signed provenance). Requires id-token: write." required: false default: 'true' + sbom: + description: > + Generate a CycloneDX SBOM, sign it (GitHub SBOM attestation bound to the + published dist), and attach it to the GitHub release. Requires + attestations: write on the job. + required: false + default: 'true' notes: description: "Base64-encoded release notes from the prepare action" required: false @@ -153,6 +162,29 @@ runs: 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: Publish to PyPI shell: bash working-directory: ${{ inputs.working_directory }} @@ -194,6 +226,8 @@ runs: 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." @@ -211,6 +245,10 @@ runs: --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 diff --git a/actions/release/lang/py/validate/action.yml b/actions/release/lang/py/validate/action.yml index 5112662..a2a5887 100644 --- a/actions/release/lang/py/validate/action.yml +++ b/actions/release/lang/py/validate/action.yml @@ -3,8 +3,9 @@ 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 to confirm - the package is publishable before the approval gate. + 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. inputs: sha: @@ -51,6 +52,13 @@ inputs: description: "Override build command. When empty, `uv build` is used." required: false default: '' + 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. + required: false + default: 'true' outputs: release_tag: description: "The release tag (e.g. py-0.3.2)" @@ -219,6 +227,20 @@ runs: 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: Dump Python environment if: ${{ failure() }} shell: bash diff --git a/actions/release/lang/ruby/publish/action.yml b/actions/release/lang/ruby/publish/action.yml index 9bb7a30..320c099 100644 --- a/actions/release/lang/ruby/publish/action.yml +++ b/actions/release/lang/ruby/publish/action.yml @@ -1,9 +1,11 @@ # GENERATED by scripts/generate.rb — edit templates/, not this file. name: Publish Ruby Package description: > - Full Ruby gem release: checks out the SHA, pushes to RubyGems, creates - a GitHub release, and sends a Slack completion notification. Covers the - standard publish sequence for all Braintrust Ruby gems. + 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. inputs: sha: @@ -32,6 +34,13 @@ inputs: description: "Whether to create a GitHub release" required: false default: 'true' + sbom: + description: > + Generate a CycloneDX SBOM, sign it (GitHub SBOM attestation bound to the + published gem), and attach it to the GitHub release. Requires + attestations: write on the job. + required: false + default: 'true' release_title: description: "GitHub release name/title. Defaults to the release tag when empty." required: false @@ -102,16 +111,111 @@ runs: bundler-cache: true working-directory: ${{ inputs.working_directory }} - - name: Publish gem with attestation - uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0 + - 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: - await-release: ${{ inputs.dry_run == 'true' && 'false' || 'true' }} - setup-trusted-publisher: ${{ inputs.dry_run == 'true' && 'false' || 'true' }} - attestations: ${{ inputs.dry_run == 'true' && 'false' || 'true' }} - working-directory: ${{ inputs.working_directory }} + 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: 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: - GITHUB_TOKEN: ${{ github.token }} DRY_RUN: ${{ inputs.dry_run }} + run: | + GEM=$(ls pkg/*.gem) + 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 @@ -123,6 +227,8 @@ runs: 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." @@ -140,6 +246,10 @@ runs: --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 diff --git a/actions/release/lang/ruby/validate/action.yml b/actions/release/lang/ruby/validate/action.yml index 4331cce..a89fb51 100644 --- a/actions/release/lang/ruby/validate/action.yml +++ b/actions/release/lang/ruby/validate/action.yml @@ -3,7 +3,8 @@ 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 to confirm the gem is publishable before the approval gate. + and runs lint, build, and SBOM generation to confirm the gem is publishable + (and the SBOM tooling works) before the approval gate. inputs: sha: @@ -43,6 +44,13 @@ inputs: description: "Ruby version to use, or a version file path (.tool-versions, .ruby-version)" required: false default: '4.0' + 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. + required: false + default: 'true' outputs: release_tag: description: "The release tag (e.g. v0.3.2)" @@ -174,15 +182,83 @@ runs: echo "prev_release=$PREV_RELEASE" >> $GITHUB_OUTPUT echo "Validated $TAG @ $SHA ($COMMIT_MSG)" - - name: Lint and build + - name: Lint + shell: bash + working-directory: ${{ inputs.working_directory }} + run: bundle exec rake lint + + - 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: - # Simulate rubygems/release-gem's RUBYOPT injection, which pre-loads gems - # (including openssl for attestation) before bundle exec runs. Exposes - # default gem activation conflicts before the approval gate. - RUBYOPT: "-r openssl" - run: bundle exec rake lint build + 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: Dump Ruby environment if: ${{ failure() }} diff --git a/templates/actions/release/lang/js/publish.yml.erb b/templates/actions/release/lang/js/publish.yml.erb index 4fcb656..ac1d551 100644 --- a/templates/actions/release/lang/js/publish.yml.erb +++ b/templates/actions/release/lang/js/publish.yml.erb @@ -1,9 +1,10 @@ name: Publish JavaScript Package description: > Full JavaScript package release: checks out the SHA, sets up Node and pnpm, - builds, publishes to npm (OIDC trusted publishing), creates a - GitHub release, and sends a Slack completion notification. Covers the standard - publish sequence for Braintrust npm packages. + 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. inputs: sha: @@ -71,6 +72,13 @@ inputs: description: "Whether to publish with provenance/attestation (requires a public package)" required: false default: 'true' + sbom: + description: > + Generate a CycloneDX SBOM (pnpm sbom — needs pnpm ≥ 11.8), sign it (GitHub + SBOM attestation bound to the published tarball), and attach it to the + GitHub release. Requires attestations: write on the job. + required: false + default: 'true' channel: description: "npm release channel (dist-tag) to publish under" required: false @@ -138,6 +146,13 @@ runs: <%= render_step('lang/js/build', if: "${{ inputs.build == 'true' }}") %> +<%= render_step('release/lang/js/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('release/lang/js/npm-publish') %> <%= render_step('release/create-github-release') %> diff --git a/templates/actions/release/lang/js/validate.yml.erb b/templates/actions/release/lang/js/validate.yml.erb index af25bb2..f6924d0 100644 --- a/templates/actions/release/lang/js/validate.yml.erb +++ b/templates/actions/release/lang/js/validate.yml.erb @@ -2,8 +2,9 @@ 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), and builds to confirm the package is publishable - before the approval gate. + 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. inputs: sha: @@ -66,6 +67,13 @@ inputs: description: "npm script to run for the build (when build is 'true')" required: false 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. + required: false + default: 'true' outputs: release_tag: description: "The release tag (e.g. v0.3.2)" @@ -106,4 +114,7 @@ runs: <%= render_step('lang/js/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/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/py/publish.yml.erb b/templates/actions/release/lang/py/publish.yml.erb index 94044f6..c12bbc8 100644 --- a/templates/actions/release/lang/py/publish.yml.erb +++ b/templates/actions/release/lang/py/publish.yml.erb @@ -1,9 +1,11 @@ name: Publish Python Package description: > Full Python package release: checks out the SHA, sets up uv and Python, - builds the sdist + wheel, publishes to PyPI (OIDC Trusted Publishing with PEP - 740 attestations), creates a GitHub release, and sends a Slack completion - notification. Covers the standard publish sequence for Braintrust PyPI packages. + 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. inputs: sha: @@ -53,6 +55,13 @@ inputs: description: "Whether to publish PEP 740 attestations (Sigstore-signed provenance). Requires id-token: write." required: false default: 'true' + sbom: + description: > + Generate a CycloneDX SBOM, sign it (GitHub SBOM attestation bound to the + published dist), and attach it to the GitHub release. Requires + attestations: write on the job. + required: false + default: 'true' notes: description: "Base64-encoded release notes from the prepare action" required: false @@ -116,6 +125,15 @@ runs: <%= render_step('lang/py/build') %> +<%= render_step('release/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. -%> +<%= 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/lang/py/pypi-publish') %> <%= render_step('release/create-github-release') %> diff --git a/templates/actions/release/lang/py/validate.yml.erb b/templates/actions/release/lang/py/validate.yml.erb index a1e24ae..b6143ac 100644 --- a/templates/actions/release/lang/py/validate.yml.erb +++ b/templates/actions/release/lang/py/validate.yml.erb @@ -2,8 +2,9 @@ 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 to confirm - the package is publishable before the approval gate. + 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. inputs: sha: @@ -50,6 +51,13 @@ inputs: description: "Override build command. When empty, `uv build` is used." required: false default: '' + 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. + required: false + default: 'true' outputs: release_tag: description: "The release tag (e.g. py-0.3.2)" @@ -88,4 +96,7 @@ runs: <%= render_step('lang/py/build') %> +<%# 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/env-dump', if: "${{ failure() }}") %> diff --git a/templates/actions/release/lang/ruby/publish.yml.erb b/templates/actions/release/lang/ruby/publish.yml.erb index 184a15d..42c8e0c 100644 --- a/templates/actions/release/lang/ruby/publish.yml.erb +++ b/templates/actions/release/lang/ruby/publish.yml.erb @@ -1,8 +1,10 @@ name: Publish Ruby Package description: > - Full Ruby gem release: checks out the SHA, pushes to RubyGems, creates - a GitHub release, and sends a Slack completion notification. Covers the - standard publish sequence for all Braintrust Ruby gems. + 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. inputs: sha: @@ -31,6 +33,13 @@ inputs: description: "Whether to create a GitHub release" required: false default: 'true' + sbom: + description: > + Generate a CycloneDX SBOM, sign it (GitHub SBOM attestation bound to the + published gem), and attach it to the GitHub release. Requires + attestations: write on the job. + required: false + default: 'true' release_title: description: "GitHub release name/title. Defaults to the release tag when empty." required: false @@ -96,6 +105,25 @@ runs: <%= render_step('lang/ruby/setup') %> +<%# 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('release/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. -%> +<%= 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") %> + +<%# SLSA build provenance for the gem (RubyGems mints none of its own); replaces + rubygems/release-gem's experimental attestation with GitHub's official action. -%> +<%= render_step('release/attest-provenance', + if: "${{ inputs.dry_run != 'true' }}", + subject_path: "${{ inputs.working_directory }}/pkg/*.gem") %> + <%= render_step('release/lang/ruby/rubygems-push') %> <%= render_step('release/create-github-release') %> diff --git a/templates/actions/release/lang/ruby/validate.yml.erb b/templates/actions/release/lang/ruby/validate.yml.erb index f5e5967..d94dd52 100644 --- a/templates/actions/release/lang/ruby/validate.yml.erb +++ b/templates/actions/release/lang/ruby/validate.yml.erb @@ -2,7 +2,8 @@ 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 to confirm the gem is publishable before the approval gate. + and runs lint, build, and SBOM generation to confirm the gem is publishable + (and the SBOM tooling works) before the approval gate. inputs: sha: @@ -42,6 +43,13 @@ inputs: description: "Ruby version to use, or a version file path (.tool-versions, .ruby-version)" required: false default: '4.0' + 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. + required: false + default: 'true' outputs: release_tag: description: "The release tag (e.g. v0.3.2)" @@ -75,14 +83,15 @@ runs: <%= render_step('release/validate', version: "${{ steps.read-version.outputs.version }}") %> - - name: Lint and build + - name: Lint shell: bash working-directory: ${{ inputs.working_directory }} - env: - # Simulate rubygems/release-gem's RUBYOPT injection, which pre-loads gems - # (including openssl for attestation) before bundle exec runs. Exposes - # default gem activation conflicts before the approval gate. - RUBYOPT: "-r openssl" - run: bundle exec rake lint build + 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') %> + +<%# 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/env-dump', if: "${{ failure() }}") %> diff --git a/templates/steps/release/attest-provenance.yml.erb b/templates/steps/release/attest-provenance.yml.erb new file mode 100644 index 0000000..82cf4e2 --- /dev/null +++ b/templates/steps/release/attest-provenance.yml.erb @@ -0,0 +1,15 @@ +<%# + Generates 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. + + @param subject_path [String] path/glob to the artifact to attest. +-%> +- name: Attest build provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: <%= locals.fetch(:subject_path) %> diff --git a/templates/steps/release/attest-sbom.yml.erb b/templates/steps/release/attest-sbom.yml.erb new file mode 100644 index 0000000..b6ae08c --- /dev/null +++ b/templates/steps/release/attest-sbom.yml.erb @@ -0,0 +1,25 @@ +<%# + Signs a CycloneDX SBOM with a GitHub attestation (Sigstore → Rekor) whose + subject is the *published* artifact, via GitHub's official actions/attest (the + `sbom-path` input; `actions/attest-sbom` is deprecated in favor of this). + The action computes the subject digest from the file(s) at subject_path, so the + attested digest is exactly the artifact's — the caller must point subject_path + at the same bytes that were published. Requires `id-token: write` + + `attestations: write` on the job. + + This step has no self-guard: attestations write to a public transparency log, + so the caller stamps the `if:` (SBOM enabled AND not a dry run) via render_step. + + @param subject_path [String, Array] path/glob(s) to the published artifact(s) to + attest. Pass an array for multiple globs (actions/attest's glob doesn't support braces, + and matches dotfiles — so target extensions explicitly, e.g. ['dist/*.whl', 'dist/*.tar.gz']). + @param sbom_path [String] path to the CycloneDX JSON SBOM. Default: sbom.json +-%> +- name: Attest SBOM + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: |- +<%- Array(locals.fetch(:subject_path)).each do |path| -%> + <%= path %> +<%- end -%> + sbom-path: <%= locals.fetch(:sbom_path) { "sbom.json" } %> diff --git a/templates/steps/release/create-github-release.yml.erb b/templates/steps/release/create-github-release.yml.erb index 6b519c9..183261d 100644 --- a/templates/steps/release/create-github-release.yml.erb +++ b/templates/steps/release/create-github-release.yml.erb @@ -4,6 +4,8 @@ (preview vs. create), so the composing action needs no `if:` on it. Requires the composing action to expose github_release / dry_run / release_tag / notes / sha inputs. Optionally uses release_title for the release name (defaults to the tag). + When an SBOM was generated (inputs.sbom == 'true' and /sbom.json + exists), it is attached to the created release. -%> - name: Create GitHub release shell: bash @@ -15,6 +17,8 @@ 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." @@ -32,4 +36,8 @@ --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 diff --git a/templates/steps/release/lang/js/attest-sbom.yml.erb b/templates/steps/release/lang/js/attest-sbom.yml.erb new file mode 100644 index 0000000..016ad6b --- /dev/null +++ b/templates/steps/release/lang/js/attest-sbom.yml.erb @@ -0,0 +1,24 @@ +<%# + Signs the JS SBOM against the published tarball — with NO post-publish refetch. + In pnpm ≥ 11.8, `pnpm pack` emits the exact bytes `pnpm publish` uploads (verified: + identical integrity), so we pack, attest that file, and let the later + `pnpm publish` (from the same tree) upload the identical tarball. Attesting BEFORE + publish means a failed attestation can never leave a published package without a + signed SBOM. The composing action gates BOTH steps with a single `if:` (sbom + enabled AND not a dry run — nothing is published on a dry run), stamped onto each. + + @param working_directory [String] expr for the package root. Default: ${{ inputs.working_directory }} +-%> +- name: Pack tarball for SBOM attestation + shell: bash + working-directory: <%= locals.fetch(:working_directory) { "${{ inputs.working_directory }}" } %> + run: | + # pnpm pack emits the exact tarball `pnpm publish` will upload (one packing + # pipeline in pnpm ≥ 11.8), so attesting it binds the SBOM to the published + # digest. Packed into a temp dir OUTSIDE the package so it can't be published. + PACKDIR=$(mktemp -d) + pnpm pack --pack-destination "$PACKDIR" >/dev/null + mv "$PACKDIR"/*.tgz "$RUNNER_TEMP/sbom-subject.tgz" +<%= render_step('release/attest-sbom', indent: 0, + subject_path: "${{ runner.temp }}/sbom-subject.tgz", + sbom_path: "#{locals.fetch(:working_directory) { "${{ inputs.working_directory }}" }}/sbom.json") %> diff --git a/templates/steps/release/lang/js/sbom.yml.erb b/templates/steps/release/lang/js/sbom.yml.erb new file mode 100644 index 0000000..38ce92f --- /dev/null +++ b/templates/steps/release/lang/js/sbom.yml.erb @@ -0,0 +1,21 @@ +<%# + Generates a CycloneDX SBOM for the package and writes it to + /sbom.json using pnpm's native `pnpm sbom` (pnpm ≥ 11.8). + `--prod` restricts it to the production dependency closure — the deps a consumer + of the published package actually gets — so the dev toolchain isn't included. + The composing action gates this step with `if: inputs.sbom == 'true'`. Assumes + lang/js/setup installed dependencies. Fails fast with a clear message when pnpm + is too old to provide the native `sbom` command. + + @param working_directory [String] expr for the package root. Default: ${{ inputs.working_directory }} +-%> +- name: Generate SBOM (CycloneDX) + shell: bash + working-directory: <%= locals.fetch(: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" diff --git a/templates/steps/release/lang/py/sbom.yml.erb b/templates/steps/release/lang/py/sbom.yml.erb new file mode 100644 index 0000000..55e506c --- /dev/null +++ b/templates/steps/release/lang/py/sbom.yml.erb @@ -0,0 +1,24 @@ +<%# + Generates a CycloneDX SBOM for the built distribution and writes it to + /sbom.json using cyclonedx-py (OWASP CycloneDX, pinned), run + ephemerally via uvx so it is never a project dependency. cyclonedx-py has no + native uv-lockfile reader, so we install the just-built wheel into a throwaway + venv and scan that environment — the SBOM is the wheel's resolved runtime + closure. The composing action gates this step with `if: inputs.sbom == 'true'` + (it still runs on dry runs, so the SBOM is previewed; signing/upload are gated + separately). Assumes lang/py/build produced /dist/*.whl. + + @param working_directory [String] expr for the package root. Default: ${{ inputs.working_directory }} +-%> +- name: Generate SBOM (CycloneDX) + shell: bash + working-directory: <%= locals.fetch(: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" diff --git a/templates/steps/release/lang/ruby/build.yml.erb b/templates/steps/release/lang/ruby/build.yml.erb new file mode 100644 index 0000000..ab59087 --- /dev/null +++ b/templates/steps/release/lang/ruby/build.yml.erb @@ -0,0 +1,21 @@ +<%# + Builds the gem into /pkg via the gem's own `rake build` task + (Bundler convention). This is the SINGLE build: the pkg/*.gem produced here is the + exact file we attest AND push (no rebuild anywhere downstream), so the attested + digest is guaranteed to be the published digest. Runs unconditionally, so it also + validates the build on dry runs. + + Consumer contract: the gem's `rake build` must produce exactly one gem at pkg/*.gem. + + @param working_directory [String] expr for the gem root. Default: ${{ inputs.working_directory }} +-%> +- name: Build gem + shell: bash + working-directory: <%= locals.fetch(: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)" diff --git a/templates/steps/release/lang/ruby/rubygems-push.yml.erb b/templates/steps/release/lang/ruby/rubygems-push.yml.erb index 90a14e0..d457bbf 100644 --- a/templates/steps/release/lang/ruby/rubygems-push.yml.erb +++ b/templates/steps/release/lang/ruby/rubygems-push.yml.erb @@ -1,15 +1,27 @@ <%# - Pushes the gem to RubyGems with OIDC trusted publishing and - SLSA attestation via rubygems/release-gem. On a dry run, builds without - pushing. Assumes Ruby is already set up. + 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). -%> -- name: Publish gem with attestation - uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0 - with: - await-release: ${{ inputs.dry_run == 'true' && 'false' || 'true' }} - setup-trusted-publisher: ${{ inputs.dry_run == 'true' && 'false' || 'true' }} - attestations: ${{ inputs.dry_run == 'true' && 'false' || 'true' }} - working-directory: ${{ inputs.working_directory }} +- 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: - GITHUB_TOKEN: ${{ github.token }} DRY_RUN: ${{ inputs.dry_run }} + run: | + GEM=$(ls pkg/*.gem) + 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 + } diff --git a/templates/steps/release/lang/ruby/sbom.yml.erb b/templates/steps/release/lang/ruby/sbom.yml.erb new file mode 100644 index 0000000..4f5ee99 --- /dev/null +++ b/templates/steps/release/lang/ruby/sbom.yml.erb @@ -0,0 +1,74 @@ +<%# + Generates a CycloneDX (JSON) SBOM from the gem's Gemfile.lock and writes it to + /sbom.json. Handrolled from Bundler's own LockfileParser (a + default gem, already in the toolchain) because the OWASP cyclonedx-ruby gem only + emits XML, and actions/attest-sbom requires JSON — so no trusted-official JSON + generator exists for Ruby. This keeps generation native: no extra tool, binary, + or publisher enters the privileged job. The CycloneDX JSON schema and Gemfile.lock + format are both stable, so the mapping is small and low-maintenance. Runs before + the gem is pushed; describes the gem's resolved dependency closure. The composing + action gates this step with `if: inputs.sbom == 'true'`. Assumes lang/ruby/setup + ran bundle install so Gemfile.lock is present. + + @param working_directory [String] expr for the gem root. Default: ${{ inputs.working_directory }} +-%> +- name: Generate SBOM (CycloneDX) + shell: bash + working-directory: <%= locals.fetch(: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 diff --git a/test/release/js/package.json b/test/release/js/package.json index 0974497..2692a57 100644 --- a/test/release/js/package.json +++ b/test/release/js/package.json @@ -7,7 +7,7 @@ "type": "git", "url": "git+https://github.com/braintrustdata/sdk-actions.git" }, - "packageManager": "pnpm@10.33.0", + "packageManager": "pnpm@11.9.0", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ diff --git a/test/release/ruby/bt-publishing-test.gemspec b/test/release/ruby/bt-publishing-test.gemspec index 182ae2a..c30dd10 100644 --- a/test/release/ruby/bt-publishing-test.gemspec +++ b/test/release/ruby/bt-publishing-test.gemspec @@ -9,13 +9,13 @@ Gem::Specification.new do |spec| spec.email = ["info@braintrust.dev"] spec.summary = "Braintrust test package. Used only to validate release workflows end-to-end. Not a real package; do not use." spec.license = "Apache-2.0" + spec.homepage = "https://github.com/braintrustdata/sdk-actions" spec.required_ruby_version = ">= 3.2.0" spec.files = Dir.glob("lib/**/*.rb") spec.require_paths = ["lib"] - # Mirrors real SDK gemspecs that depend on openssl. Keeps the lockfile pinned - # to the runner's default gem version so the RUBYOPT activation test in - # validate-ruby remains meaningful as Ruby versions change. + # Mirrors real SDK gemspecs that depend on openssl, and gives the generated SBOM a + # real runtime component to exercise the dependency-closure walk. spec.add_runtime_dependency "openssl", ">= 3.3.1", "< 5.0" end