Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release-js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release-py.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release-ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 9 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.


30 changes: 15 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,22 @@ Shared GitHub actions and workflows for Braintrust SDK repositories.

**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.

### 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
`.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. **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

Expand All @@ -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 |

Expand Down
51 changes: 48 additions & 3 deletions actions/release/lang/js/publish/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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."
Expand All @@ -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
Expand Down
24 changes: 22 additions & 2 deletions actions/release/lang/js/validate/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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
Expand Down
44 changes: 41 additions & 3 deletions actions/release/lang/py/publish/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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."
Expand All @@ -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
Expand Down
26 changes: 24 additions & 2 deletions actions/release/lang/py/validate/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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
Expand Down
Loading