Skip to content
Merged
63 changes: 54 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,64 @@ bin/workflow update release.yml # 3-way merge upstream changes in, keeping y
# machine-readable: sed -n 's/^# sdk-actions: //p' action.yml | jq -r .version
```

### Manifest-driven npm tarball releases

For monorepos that own their own build and package selection, create a manifest
plus prebuilt npm tarballs and SBOMs in the source repository, attest those
artifacts before upload, then call `release/lang/js/publish-npm-tarballs` from the
gated publish job. The action verifies the downloaded tarball attestations,
then publishes the exact tarballs to npmjs in manifest order. After the source
repository pushes its package tags, call `release/create-package-github-releases`; it
attaches each package's `sbom_asset` from the manifest directory.

The manifest passed between build and publish jobs is JSON in this shape:

```json
{
"commit": "0123456789abcdef0123456789abcdef01234567",
"packages": [
{
"name": "@scope/package",
"version": "1.2.3",
"dir": "packages/package",
"tag": "@scope/package@1.2.3",
"tarball_asset": "scope-package-1.2.3.tgz",
"sbom_asset": "scope-package-1.2.3.sbom.json",
"release_title": "@scope/package@1.2.3",
"release_body": "Markdown release notes",
"channel": "latest",
"provenance": true
}
]
}
```

`name` and `version` are required. `packages` must already be in publish order.
`dir` and `commit` are source-repository metadata, useful while building and
tagging. `tarball_asset` identifies the downloaded tarball basename and is required
by `publish-npm-tarballs`. `sbom_asset` is required by
`create-package-github-releases` and is resolved relative to the manifest file's
directory. `tag` defaults to `name@version`; `release_title` defaults to `tag`; and
`release_body` defaults to a short publish message. `channel` and `provenance`
may override the `publish-npm-tarballs` defaults per package. JavaScript SBOM
generation commonly uses `pnpm sbom`, which requires pnpm `>= 11.8`.

### Available release actions

The workflow is composed from these actions — `bin/workflow generate` wires them for you, but they're listed here for reference. Each is **self-contained** (calls no other action in this repo), so a single SHA pin pulls in everything it needs.

| Action | Purpose |
|---|---|
| `release/lang/<lang>/configure` | Derive release facts (tag, channel, rc suffix, `github_release`) from the version + `release_type` — read-only |
| `release/prepare` | Fetch the PR list and release notes |
| `release/lang/<lang>/validate` | Validate the release (tag / channel / branch / metadata, registry availability) and run a pre-gate build + SBOM generation |
| `release/request-approval` | Post the pre-approval job summary and Slack notification |
| `release/lang/<lang>/build-and-ship` | **Turnkey**: build → sign a CycloneDX SBOM (+ build provenance for Ruby) → publish (OIDC trusted publishing) → create the GitHub release (SBOM attached) → notify |
| `release/lang/js/pack-pnpm` · `pack-bun`, `release/lang/{py,ruby}/pack` | **Custom build**: build + pack + sign SBOM + build provenance in an unprivileged job (no publish credentials) |
| `release/lang/<lang>/ship-package` | **Custom build**: verify the attestation against the downloaded artifact → publish that exact artifact → create the GitHub release → notify |
| Action | Purpose |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `release/lang/<lang>/configure` | Derive release facts (tag, channel, rc suffix, `github_release`) from the version + `release_type` — read-only |
| `release/prepare` | Fetch the PR list and release notes |
| `release/lang/<lang>/validate` | Validate the release (tag / channel / branch / metadata, registry availability) and run a pre-gate build + SBOM generation |
| `release/request-approval` | Post the pre-approval job summary and Slack notification |
| `release/lang/<lang>/build-and-ship` | **Turnkey**: build → sign a CycloneDX SBOM (+ build provenance for Ruby) → publish (OIDC trusted publishing) → create the GitHub release (SBOM attached) → notify |
| `release/lang/js/pack-pnpm` · `pack-bun`, `release/lang/{py,ruby}/pack` | **Custom build**: build + pack + sign SBOM + build provenance in an unprivileged job (no publish credentials) |
| `release/lang/<lang>/ship-package` | **Custom build**: verify the attestation against the downloaded artifact → publish that exact artifact → create the GitHub release → notify |
| `release/lang/js/publish-npm-tarballs` | Verify prebuilt npm tarballs by glob and publish the exact bytes to npmjs in manifest order |
| `release/create-package-github-releases` | Create package GitHub releases from existing manifest tags, titles, and bodies, attaching each `sbom_asset` from the manifest directory |
| `release/verify-package` | Verify downloaded artifact attestations without using a language-specific ship action |

Inputs and outputs are documented in each `action.yml`. Reference an action by commit SHA:

Expand Down
157 changes: 157 additions & 0 deletions actions/release/create-package-github-releases/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# GENERATED by scripts/generate.rb — edit templates/, not this file.
# sdk-actions: {"family":"release","version":"1.0.0"}
name: Create Package GitHub Releases
description: >
Create one GitHub release per package in a release manifest and attach each package's
SBOM asset from the manifest directory. Release tags must already exist in the
repository; this action never creates or moves tags.

# Manifest contract:
# {
# "packages": [
# {
# "name": "@scope/package",
# "version": "1.2.3",
# "tag": "@scope/package@1.2.3",
# "sbom_asset": "scope-package-1.2.3.sbom.json",
# "release_title": "@scope/package@1.2.3",
# "release_body": "Markdown notes"
# }
# ]
# }
#
# Required: name, version, sbom_asset.
# Optional: tag defaults to <name>@<version>; release_title defaults to tag;
# release_body defaults to "Published <name>@<version>.".
# SBOM assets are resolved relative to the manifest file's directory.

inputs:
manifest:
description: "Path to the release manifest described above"
required: true
repo:
description: "GitHub owner/repo containing the existing package tags"
required: false
default: '${{ github.repository }}'
clobber:
description: "Overwrite an existing SBOM release asset with the same name"
required: false
default: 'true'
dry_run:
description: "Print releases and SBOM uploads without modifying GitHub"
required: false
default: 'false'

runs:
using: composite
steps:
- name: Create package GitHub releases
shell: bash
env:
GH_TOKEN: ${{ github.token }}
MANIFEST: ${{ inputs.manifest }}
REPO: ${{ inputs.repo }}
CLOBBER: ${{ inputs.clobber }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail

if ! command -v jq >/dev/null 2>&1; then
echo "::error title=jq not found::create-package-github-releases requires jq to read the release manifest."
exit 1
fi

if [ "$DRY_RUN" != "true" ] && ! command -v gh >/dev/null 2>&1; then
echo "::error title=GitHub CLI not found::create-package-github-releases requires gh to create releases and upload SBOM assets."
exit 1
fi

if [ ! -f "$MANIFEST" ]; then
echo "::error title=Manifest not found::release manifest does not exist: $MANIFEST"
exit 1
fi

manifest_dir="$(cd "$(dirname "$MANIFEST")" && pwd)"
clobber_args=()
if [ "$CLOBBER" = "true" ]; then
clobber_args+=(--clobber)
fi

packages_tsv="$(mktemp)"
trap 'rm -f "$packages_tsv" "${notes_files[@]:-}"' EXIT
notes_files=()

while IFS= read -r encoded; do
package_json="$(printf '%s' "$encoded" | base64 -d)"
name="$(jq -r '.name // empty' <<< "$package_json")"
version="$(jq -r '.version // empty' <<< "$package_json")"
tag="$(jq -r '.tag // "\(.name)@\(.version)"' <<< "$package_json")"
sbom_asset="$(jq -r '.sbom_asset // empty' <<< "$package_json")"
notes_file="$(mktemp)"
notes_files+=("$notes_file")
jq -r '.release_body // "Published \(.name)@\(.version)."' <<< "$package_json" > "$notes_file"

if [ -z "$name" ] || [ -z "$version" ]; then
echo "::error title=Manifest package invalid::each package must set name and version."
exit 1
fi

if [ -z "$sbom_asset" ]; then
echo "::error title=SBOM asset missing::manifest package $name@$version must set sbom_asset."
exit 1
fi

if [ "$(basename "$sbom_asset")" != "$sbom_asset" ]; then
echo "::error title=Invalid SBOM asset::sbom_asset must be a file basename, got: $sbom_asset"
exit 1
fi

sbom_path="$manifest_dir/$sbom_asset"
if [ ! -f "$sbom_path" ]; then
echo "::error title=SBOM asset not found::expected $sbom_asset for $name@$version next to $MANIFEST."
exit 1
fi

if [ "$DRY_RUN" != "true" ]; then
gh api --silent "repos/$REPO/git/ref/tags/$tag" || {
echo "::error title=Tag not found::release tag $tag for $name@$version does not exist in $REPO."
exit 1
}
fi

printf '%s\t%s\t%s\n' "$encoded" "$notes_file" "$sbom_path" >> "$packages_tsv"
done < <(jq -r '.packages[]? | @base64' "$MANIFEST")

if [ ! -s "$packages_tsv" ]; then
echo "::error title=Manifest empty::release manifest must contain at least one package."
exit 1
fi

while IFS=$'\t' read -r encoded notes_file sbom_path; do
package_json="$(printf '%s' "$encoded" | base64 -d)"
name="$(jq -r '.name' <<< "$package_json")"
version="$(jq -r '.version' <<< "$package_json")"
tag="$(jq -r '.tag // "\(.name)@\(.version)"' <<< "$package_json")"
title="$(jq -r '.release_title // .tag // "\(.name)@\(.version)"' <<< "$package_json")"

if [ "$DRY_RUN" = "true" ]; then
echo "DRY RUN: would create GitHub release $tag for $name@$version"
elif gh release view "$tag" --repo "$REPO" >/dev/null 2>&1; then
echo "GitHub release already exists for $tag; skipping release publication."
continue
else
gh release create "$tag" \
--repo "$REPO" \
--title "$title" \
--notes-file "$notes_file" \
--verify-tag
echo "Created GitHub release for $tag"
fi

if [ "$DRY_RUN" = "true" ]; then
echo "DRY RUN: would upload $sbom_path to $tag in $REPO"
else
gh release upload "$tag" "$sbom_path" --repo "$REPO" "${clobber_args[@]}"
echo "Uploaded $(basename "$sbom_path") to $tag"
fi
done < "$packages_tsv"
Loading
Loading