Skip to content

Commit bc97335

Browse files
author
David Elner
committed
WIP: SBOM
1 parent 43fb2fd commit bc97335

28 files changed

Lines changed: 690 additions & 82 deletions

.github/workflows/release-js.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ jobs:
208208
permissions:
209209
contents: write
210210
id-token: write # OIDC trusted publishing + provenance
211+
attestations: write # signed SBOM attestation (actions/attest-sbom)
211212
steps:
212213
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
213214
with:

.github/workflows/release-py.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ jobs:
198198
permissions:
199199
contents: write
200200
id-token: write # OIDC trusted publishing + PEP 740 attestations
201+
attestations: write # signed SBOM attestation (actions/attest-sbom)
201202
steps:
202203
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
203204
with:

.github/workflows/release-ruby.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ jobs:
196196
permissions:
197197
contents: write
198198
id-token: write
199+
attestations: write # signed SBOM attestation (actions/attest-sbom)
199200
steps:
200201
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
201202
with:

CONTRIBUTING.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,14 @@ copied verbatim. `render_step(name, indent: 4, **locals)`:
9898

9999
- **Control flow lives in the action.** Compose pure "do" steps, then a terminal
100100
failure handler: `… → render_step('on-failure', if: "${{ failure() }}")`.
101-
- **Non-control conditions are shell self-guards, not `if:`.** "Slack
102-
configured?", "dry run?", "create a GitHub release?" are handled with an early
103-
`exit 0` inside the step's own `run:` — so there's never an `if:` for the
104-
generator to merge, and control flow stays readable at the call site.
101+
- **A toggle that enables/disables a whole step uses `if:` at the call site.**
102+
e.g. `render_step('lang/js/build', if: "${{ inputs.build == 'true' }}")` and
103+
`render_step('release/lang/ruby/sbom', if: "${{ inputs.sbom == 'true' }}")`.
104+
Attestation steps (which must not run pre-approval or on dry runs) are gated the
105+
same way: `if: "${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}"`.
106+
- **A condition that changes behavior *within* a step is a shell self-guard, not
107+
`if:`.** "Slack configured?", "dry run → skip the push but still preview", "create
108+
a GitHub release?" are handled with an early `exit 0` (or a branch) inside the
109+
step's own `run:` — keeping the step self-contained and the call site readable.
105110

106111

README.md

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,22 @@ Shared GitHub actions and workflows for Braintrust SDK repositories.
66

77
**Copy the canonical release workflow template** from `.github/workflows/release-<LANG>.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.
88

9+
### Pre-requisites
10+
11+
- **JS**: needs `packageManager` (or `pnpm_version`) **≥ pnpm 11.8** (for `pnpm sbom`)
12+
- **Ruby**: needs a `rake build` that emits exactly one `pkg/*.gem`.
13+
914
### Adopting the release workflow
1015

1116
1. **Copy the canonical template** for your language into your repo's
1217
`.github/workflows/`:
1318
- [`release-js.yml`](.github/workflows/release-js.yml)
1419
- [`release-py.yml`](.github/workflows/release-py.yml)
1520
- [`release-ruby.yml`](.github/workflows/release-ruby.yml)
16-
2. **Adapt the marked sections** — version source, gem name, working directory,
17-
and so on. The template's comments flag exactly what changes per repo;
18-
everything else is provided by the pinned actions.
19-
3. **Record the upstream version you based it on** — the sdk-actions ref (commit
20-
SHA or tag) your copy was adapted from, e.g. in a header comment. Tracking it
21-
lets you (or an agent) diff your copy against a newer upstream template and
22-
sync changes deliberately as this repo evolves.
23-
4. **Bump the pinned SHA** to pick up action changes. Because each action is
24-
self-contained, one SHA bump pulls in the whole updated chain.
21+
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`.)
22+
3. **Adapt the marked sections** — version source, package/gem name, working directory, and so on; the template's comments flag exactly what changes.
23+
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.
24+
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.
2525

2626
### Available release actions
2727

@@ -32,12 +32,12 @@ pulls in everything it needs.
3232

3333
| Action | Purpose |
3434
|---|---|
35-
| `release/lang/ruby/publish` | Push the gem to RubyGems, create the GitHub release, and notify |
36-
| `release/lang/ruby/validate` | Check out the SHA, set up Ruby, read the version, validate the release (tag/branch/metadata), lint + build |
37-
| `release/lang/js/publish` | Publish the package to npm (OIDC trusted publishing), create the GitHub release, and notify |
38-
| `release/lang/js/validate` | Check out the SHA, set up Node + package manager, read the version, validate the release (channel/tag/branch/metadata), build |
39-
| `release/lang/py/publish` | Publish the package to PyPI (OIDC trusted publishing + PEP 740 attestations), create the GitHub release, and notify |
40-
| `release/lang/py/validate` | Check out the SHA, set up uv + Python, read the version, validate the release (tag/branch/metadata, PyPI availability), build |
35+
| `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 |
36+
| `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) |
37+
| `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 |
38+
| `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) |
39+
| `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 |
40+
| `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) |
4141
| `release/notify-pending` | Post the pre-approval job summary and Slack notification |
4242
| `release/prepare` | Fetch the PR list and release notes |
4343

actions/release/lang/js/publish/action.yml

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
name: Publish JavaScript Package
33
description: >
44
Full JavaScript package release: checks out the SHA, sets up Node and pnpm,
5-
builds, publishes to npm (OIDC trusted publishing), creates a
6-
GitHub release, and sends a Slack completion notification. Covers the standard
7-
publish sequence for Braintrust npm packages.
5+
builds, generates and signs a CycloneDX SBOM (bound to the published tarball),
6+
publishes to npm (OIDC trusted publishing + provenance), creates a GitHub release
7+
(with the SBOM attached), and sends a Slack completion notification. Covers the
8+
standard publish sequence for Braintrust npm packages.
89
910
inputs:
1011
sha:
@@ -72,6 +73,13 @@ inputs:
7273
description: "Whether to publish with provenance/attestation (requires a public package)"
7374
required: false
7475
default: 'true'
76+
sbom:
77+
description: >
78+
Generate a CycloneDX SBOM (pnpm sbom — needs pnpm ≥ 11.8), sign it (GitHub
79+
SBOM attestation bound to the published tarball), and attach it to the
80+
GitHub release. Requires attestations: write on the job.
81+
required: false
82+
default: 'true'
7583
channel:
7684
description: "npm release channel (dist-tag) to publish under"
7785
required: false
@@ -187,6 +195,36 @@ runs:
187195
run: |
188196
pnpm run "$BUILD_COMMAND"
189197
198+
- name: Generate SBOM (CycloneDX)
199+
if: ${{ inputs.sbom == 'true' }}
200+
shell: bash
201+
working-directory: ${{ inputs.working_directory }}
202+
run: |
203+
if ! pnpm sbom --help >/dev/null 2>&1; then
204+
echo "::error title=pnpm sbom unavailable::'pnpm sbom' needs pnpm >= 11.8. Set the package's packageManager (or pnpm_version) to >= 11.8."
205+
exit 1
206+
fi
207+
pnpm sbom --sbom-format cyclonedx --prod --out sbom.json
208+
echo "Wrote $(pwd)/sbom.json"
209+
210+
- name: Pack tarball for SBOM attestation
211+
if: ${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}
212+
shell: bash
213+
working-directory: ${{ inputs.working_directory }}
214+
run: |
215+
# pnpm pack emits the exact tarball `pnpm publish` will upload (one packing
216+
# pipeline in pnpm ≥ 11.8), so attesting it binds the SBOM to the published
217+
# digest. Packed into a temp dir OUTSIDE the package so it can't be published.
218+
PACKDIR=$(mktemp -d)
219+
pnpm pack --pack-destination "$PACKDIR" >/dev/null
220+
mv "$PACKDIR"/*.tgz "$RUNNER_TEMP/sbom-subject.tgz"
221+
- name: Attest SBOM
222+
if: ${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}
223+
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
224+
with:
225+
subject-path: ${{ runner.temp }}/sbom-subject.tgz
226+
sbom-path: ${{ inputs.working_directory }}/sbom.json
227+
190228
- name: Publish to npm
191229
shell: bash
192230
working-directory: ${{ inputs.working_directory }}
@@ -229,6 +267,8 @@ runs:
229267
RELEASE_TITLE: ${{ inputs.release_title }}
230268
NOTES_B64: ${{ inputs.notes }}
231269
SHA: ${{ inputs.sha }}
270+
SBOM_ENABLED: ${{ inputs.sbom }}
271+
WORKING_DIRECTORY: ${{ inputs.working_directory }}
232272
run: |
233273
if [ "$ENABLED" != "true" ]; then
234274
echo "GitHub release disabled; skipping."
@@ -246,6 +286,10 @@ runs:
246286
--title "$TITLE" \
247287
--notes-file /tmp/release-notes.md \
248288
--target "$SHA"
289+
# Attach the SBOM when one was generated for this release.
290+
if [ "$SBOM_ENABLED" = "true" ] && [ -f "$WORKING_DIRECTORY/sbom.json" ]; then
291+
gh release upload "$TAG" "$WORKING_DIRECTORY/sbom.json" --clobber
292+
fi
249293
fi
250294
251295
- name: Build npm links

actions/release/lang/js/validate/action.yml

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ name: Validate JavaScript Release
33
description: >
44
Checks out the release SHA, sets up Node and pnpm, reads the
55
package version, validates the release channel and metadata (tag existence,
6-
branch, commit metadata), and builds to confirm the package is publishable
7-
before the approval gate.
6+
branch, commit metadata), builds, and generates a CycloneDX SBOM — all to
7+
confirm the package is publishable (and the SBOM tooling works) before the
8+
approval gate.
89
910
inputs:
1011
sha:
@@ -67,6 +68,13 @@ inputs:
6768
description: "npm script to run for the build (when build is 'true')"
6869
required: false
6970
default: 'build'
71+
sbom:
72+
description: >
73+
Generate a CycloneDX SBOM as a pre-gate check (same generator as publish), so
74+
SBOM-tooling failures (e.g. pnpm < 11.8) surface BEFORE the approval gate. The
75+
SBOM is not signed or attached here — that happens in publish.
76+
required: false
77+
default: 'true'
7078
outputs:
7179
release_tag:
7280
description: "The release tag (e.g. v0.3.2)"
@@ -260,6 +268,18 @@ runs:
260268
run: |
261269
pnpm run "$BUILD_COMMAND"
262270
271+
- name: Generate SBOM (CycloneDX)
272+
if: ${{ inputs.sbom == 'true' }}
273+
shell: bash
274+
working-directory: ${{ inputs.working_directory }}
275+
run: |
276+
if ! pnpm sbom --help >/dev/null 2>&1; then
277+
echo "::error title=pnpm sbom unavailable::'pnpm sbom' needs pnpm >= 11.8. Set the package's packageManager (or pnpm_version) to >= 11.8."
278+
exit 1
279+
fi
280+
pnpm sbom --sbom-format cyclonedx --prod --out sbom.json
281+
echo "Wrote $(pwd)/sbom.json"
282+
263283
- name: Dump JavaScript environment
264284
if: ${{ failure() }}
265285
shell: bash

actions/release/lang/py/publish/action.yml

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
name: Publish Python Package
33
description: >
44
Full Python package release: checks out the SHA, sets up uv and Python,
5-
builds the sdist + wheel, publishes to PyPI (OIDC Trusted Publishing with PEP
6-
740 attestations), creates a GitHub release, and sends a Slack completion
7-
notification. Covers the standard publish sequence for Braintrust PyPI packages.
5+
builds the sdist + wheel, generates and signs a CycloneDX SBOM (bound to the
6+
built dists), publishes to PyPI (OIDC Trusted Publishing with PEP 740
7+
attestations), creates a GitHub release (with the SBOM attached), and sends a
8+
Slack completion notification. Covers the standard publish sequence for
9+
Braintrust PyPI packages.
810
911
inputs:
1012
sha:
@@ -54,6 +56,13 @@ inputs:
5456
description: "Whether to publish PEP 740 attestations (Sigstore-signed provenance). Requires id-token: write."
5557
required: false
5658
default: 'true'
59+
sbom:
60+
description: >
61+
Generate a CycloneDX SBOM, sign it (GitHub SBOM attestation bound to the
62+
published dist), and attach it to the GitHub release. Requires
63+
attestations: write on the job.
64+
required: false
65+
default: 'true'
5766
notes:
5867
description: "Base64-encoded release notes from the prepare action"
5968
required: false
@@ -153,6 +162,27 @@ runs:
153162
fi
154163
uvx twine check dist/*
155164
165+
- name: Generate SBOM (CycloneDX)
166+
if: ${{ inputs.sbom == 'true' }}
167+
shell: bash
168+
working-directory: ${{ inputs.working_directory }}
169+
run: |
170+
# cyclonedx-py can't read uv lockfiles, so scan the wheel's installed closure:
171+
# install the built wheel into a throwaway venv, then generate from that env.
172+
SBOM_VENV="$(mktemp -d)/venv"
173+
uv venv "$SBOM_VENV"
174+
uv pip install --python "$SBOM_VENV/bin/python" dist/*.whl
175+
uvx --from "cyclonedx-bom==7.3.0" cyclonedx-py environment "$SBOM_VENV" \
176+
--output-format JSON --output-file sbom.json
177+
echo "Wrote $(pwd)/sbom.json"
178+
179+
- name: Attest SBOM
180+
if: ${{ inputs.sbom == 'true' && inputs.dry_run != 'true' }}
181+
uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
182+
with:
183+
subject-path: ${{ inputs.working_directory }}/dist/*
184+
sbom-path: ${{ inputs.working_directory }}/sbom.json
185+
156186
- name: Publish to PyPI
157187
shell: bash
158188
working-directory: ${{ inputs.working_directory }}
@@ -194,6 +224,8 @@ runs:
194224
RELEASE_TITLE: ${{ inputs.release_title }}
195225
NOTES_B64: ${{ inputs.notes }}
196226
SHA: ${{ inputs.sha }}
227+
SBOM_ENABLED: ${{ inputs.sbom }}
228+
WORKING_DIRECTORY: ${{ inputs.working_directory }}
197229
run: |
198230
if [ "$ENABLED" != "true" ]; then
199231
echo "GitHub release disabled; skipping."
@@ -211,6 +243,10 @@ runs:
211243
--title "$TITLE" \
212244
--notes-file /tmp/release-notes.md \
213245
--target "$SHA"
246+
# Attach the SBOM when one was generated for this release.
247+
if [ "$SBOM_ENABLED" = "true" ] && [ -f "$WORKING_DIRECTORY/sbom.json" ]; then
248+
gh release upload "$TAG" "$WORKING_DIRECTORY/sbom.json" --clobber
249+
fi
214250
fi
215251
216252
- name: Build PyPI links

actions/release/lang/py/validate/action.yml

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ name: Validate Python Release
33
description: >
44
Checks out the release SHA, sets up uv and Python, reads the package
55
version, validates the release metadata (tag existence, branch, commit
6-
metadata) and that the version is not already on PyPI, then builds to confirm
7-
the package is publishable before the approval gate.
6+
metadata) and that the version is not already on PyPI, then builds and generates
7+
a CycloneDX SBOM — confirming the package is publishable (and the SBOM tooling
8+
works) before the approval gate.
89
910
inputs:
1011
sha:
@@ -51,6 +52,13 @@ inputs:
5152
description: "Override build command. When empty, `uv build` is used."
5253
required: false
5354
default: ''
55+
sbom:
56+
description: >
57+
Generate a CycloneDX SBOM as a pre-gate check (same generator as publish), so
58+
SBOM-tooling failures surface BEFORE the approval gate. Not signed or attached
59+
here — that happens in publish.
60+
required: false
61+
default: 'true'
5462
outputs:
5563
release_tag:
5664
description: "The release tag (e.g. py-0.3.2)"
@@ -219,6 +227,20 @@ runs:
219227
fi
220228
uvx twine check dist/*
221229
230+
- name: Generate SBOM (CycloneDX)
231+
if: ${{ inputs.sbom == 'true' }}
232+
shell: bash
233+
working-directory: ${{ inputs.working_directory }}
234+
run: |
235+
# cyclonedx-py can't read uv lockfiles, so scan the wheel's installed closure:
236+
# install the built wheel into a throwaway venv, then generate from that env.
237+
SBOM_VENV="$(mktemp -d)/venv"
238+
uv venv "$SBOM_VENV"
239+
uv pip install --python "$SBOM_VENV/bin/python" dist/*.whl
240+
uvx --from "cyclonedx-bom==7.3.0" cyclonedx-py environment "$SBOM_VENV" \
241+
--output-format JSON --output-file sbom.json
242+
echo "Wrote $(pwd)/sbom.json"
243+
222244
- name: Dump Python environment
223245
if: ${{ failure() }}
224246
shell: bash

0 commit comments

Comments
 (0)