diff --git a/README.md b/README.md index c8408ec..16bd413 100644 --- a/README.md +++ b/README.md @@ -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//configure` | Derive release facts (tag, channel, rc suffix, `github_release`) from the version + `release_type` — read-only | -| `release/prepare` | Fetch the PR list and release notes | -| `release/lang//validate` | Validate the release (tag / channel / branch / metadata, registry availability) and run a pre-gate build + SBOM generation | -| `release/request-approval` | Post the pre-approval job summary and Slack notification | -| `release/lang//build-and-ship` | **Turnkey**: build → sign a CycloneDX SBOM (+ build provenance for Ruby) → publish (OIDC trusted publishing) → create the GitHub release (SBOM attached) → notify | -| `release/lang/js/pack-pnpm` · `pack-bun`, `release/lang/{py,ruby}/pack` | **Custom build**: build + pack + sign SBOM + build provenance in an unprivileged job (no publish credentials) | -| `release/lang//ship-package` | **Custom build**: verify the attestation against the downloaded artifact → publish that exact artifact → create the GitHub release → notify | +| Action | Purpose | +| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `release/lang//configure` | Derive release facts (tag, channel, rc suffix, `github_release`) from the version + `release_type` — read-only | +| `release/prepare` | Fetch the PR list and release notes | +| `release/lang//validate` | Validate the release (tag / channel / branch / metadata, registry availability) and run a pre-gate build + SBOM generation | +| `release/request-approval` | Post the pre-approval job summary and Slack notification | +| `release/lang//build-and-ship` | **Turnkey**: build → sign a CycloneDX SBOM (+ build provenance for Ruby) → publish (OIDC trusted publishing) → create the GitHub release (SBOM attached) → notify | +| `release/lang/js/pack-pnpm` · `pack-bun`, `release/lang/{py,ruby}/pack` | **Custom build**: build + pack + sign SBOM + build provenance in an unprivileged job (no publish credentials) | +| `release/lang//ship-package` | **Custom build**: verify the attestation against the downloaded artifact → publish that exact artifact → create the GitHub release → notify | +| `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: diff --git a/actions/release/create-package-github-releases/action.yml b/actions/release/create-package-github-releases/action.yml new file mode 100644 index 0000000..e9f38e7 --- /dev/null +++ b/actions/release/create-package-github-releases/action.yml @@ -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 @; release_title defaults to tag; +# release_body defaults to "Published @.". +# 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" diff --git a/actions/release/lang/js/publish-npm-tarballs/action.yml b/actions/release/lang/js/publish-npm-tarballs/action.yml new file mode 100644 index 0000000..041889a --- /dev/null +++ b/actions/release/lang/js/publish-npm-tarballs/action.yml @@ -0,0 +1,349 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Publish npm Tarballs +description: > + Gated publish action for manifest-driven npm package releases. Verifies prebuilt + npm tarball attestations, publishes the exact downloaded tarballs to npmjs with + OIDC, and posts a concise release summary. It does not build, pack, create + GitHub releases, or upload SBOM assets. + +inputs: + manifest: + description: "Path to a release manifest whose packages are already in publish order" + required: true + tarballs: + description: "Space-separated tarball paths or globs" + required: true + repo: + description: "GitHub owner/repo containing the tarball attestations" + required: false + default: '${{ github.repository }}' + provenance: + description: "Publish with npm-native provenance" + required: false + default: 'true' + channel: + description: "Default npm dist-tag" + required: false + default: 'latest' + npm_version: + description: "npm CLI version to install for OIDC trusted publishing" + required: false + default: '11.6.2' + dry_run: + description: "Skip attestation verification and run npm publish with --dry-run" + required: false + default: 'false' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if publishing fails" + required: false + default: '' + +runs: + using: composite + steps: + - name: Verify attestations + if: ${{ inputs.dry_run != 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACTS: ${{ inputs.tarballs }} + REPO: ${{ inputs.repo }} + run: | + shopt -s nullglob + FILES=( $ARTIFACTS ) + if [ ${#FILES[@]} -eq 0 ]; then + echo "::error title=Nothing to verify::no artifact files matched '$ARTIFACTS'." + exit 1 + fi + for f in "${FILES[@]}"; do + echo "Verifying $(basename "$f") — build provenance…" + gh attestation verify "$f" --repo "$REPO" || { + echo "::error title=Provenance verification failed::no build-provenance attestation matches $(basename "$f") — the artifact was not produced by a trusted build, or was tampered with in transit." + exit 1 + } + echo "Verifying $(basename "$f") — SBOM…" + gh attestation verify "$f" --repo "$REPO" --predicate-type https://cyclonedx.org/bom || { + echo "::error title=SBOM verification failed::no CycloneDX SBOM attestation matches $(basename "$f")." + exit 1 + } + done + echo "✓ attestations verified for: ${FILES[*]}" + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: lts/* + registry-url: https://registry.npmjs.org + + - name: Install npm with OIDC trusted publishing support + shell: bash + env: + NPM_VERSION: ${{ inputs.npm_version }} + run: npm install -g "npm@$NPM_VERSION" + + - name: Publish tarballs in manifest order + shell: bash + env: + MANIFEST: ${{ inputs.manifest }} + TARBALLS: ${{ inputs.tarballs }} + DEFAULT_CHANNEL: ${{ inputs.channel }} + DEFAULT_PROVENANCE: ${{ inputs.provenance }} + DRY_RUN: ${{ inputs.dry_run }} + REPORT: ${{ runner.temp }}/sdk-actions-publish-npm-tarballs.tsv + NODE_AUTH_TOKEN: '' + NPM_TOKEN: '' + run: | + set -euo pipefail + + if ! command -v jq >/dev/null 2>&1; then + echo "::error title=jq not found::publish-npm-tarballs requires jq to read the release manifest." + exit 1 + fi + + if [ ! -f "$MANIFEST" ]; then + echo "::error title=Manifest not found::release manifest does not exist: $MANIFEST" + exit 1 + fi + + shopt -s nullglob + tarball_files=( $TARBALLS ) + if [ "${#tarball_files[@]}" -eq 0 ]; then + echo "::error title=No tarballs found::no files matched '$TARBALLS'." + exit 1 + fi + + : > "$REPORT" + + find_tarball() { + local expected="$1" + local matched=() + for file in "${tarball_files[@]}"; do + if [ "$(basename "$file")" = "$expected" ]; then + matched+=("$file") + fi + done + + if [ "${#matched[@]}" -ne 1 ]; then + echo "::error title=Tarball match failed::expected exactly one tarball named $expected, found ${#matched[@]}." >&2 + return 1 + fi + + printf '%s\n' "${matched[0]}" + } + + packages_tsv="$(mktemp)" + trap 'rm -f "$packages_tsv"' EXIT + + while IFS= read -r encoded; do + package_json="$(printf '%s' "$encoded" | base64 -d)" + name="$(jq -r '.name // empty' <<< "$package_json")" + version="$(jq -r '.version // empty' <<< "$package_json")" + expected_tarball="$(jq -r '.tarball_asset // empty' <<< "$package_json")" + channel="$(jq -r '.channel // empty' <<< "$package_json")" + provenance="$(jq -r '.provenance // empty' <<< "$package_json")" + + if [ -z "$name" ] || [ -z "$version" ]; then + echo "::error title=Manifest package invalid::each package must set name and version." + exit 1 + fi + + if [ -z "$expected_tarball" ]; then + echo "::error title=Tarball asset missing::manifest package $name@$version must set tarball_asset." + exit 1 + fi + + if [ "$(basename "$expected_tarball")" != "$expected_tarball" ]; then + echo "::error title=Invalid tarball asset::tarball_asset must be a file basename, got: $expected_tarball" + exit 1 + fi + + [ -n "$channel" ] || channel="$DEFAULT_CHANNEL" + [ -n "$provenance" ] || provenance="$DEFAULT_PROVENANCE" + + tarball="$(find_tarball "$expected_tarball")" + packed_name="$(tar -xzO -f "$tarball" package/package.json | jq -r '.name')" + packed_version="$(tar -xzO -f "$tarball" package/package.json | jq -r '.version')" + if [ "$packed_name" != "$name" ] || [ "$packed_version" != "$version" ]; then + echo "::error title=Tarball metadata mismatch::$expected_tarball contains $packed_name@$packed_version, expected $name@$version." + exit 1 + fi + + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$name" "$version" "$expected_tarball" "$tarball" "$channel" "$provenance" >> "$packages_tsv" + done < <(jq -r '.packages[]? | @base64' "$MANIFEST") + + if [ ! -s "$packages_tsv" ]; then + echo "::error title=Manifest empty::release manifest must contain at least one package." + exit 1 + fi + + while IFS=$'\t' read -r name version expected_tarball tarball channel provenance; do + args=(--tag "$channel" --access public --registry https://registry.npmjs.org) + [ "$provenance" = "true" ] && args+=(--provenance) + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: npm publish ./$expected_tarball ${args[*]} --dry-run" + status="dry-run" + elif npm view "$name@$version" version --registry https://registry.npmjs.org >/dev/null 2>&1; then + echo "$name@$version is already published; skipping npm publish." + status="already-published" + else + dir="$(dirname "$tarball")" + file="$(basename "$tarball")" + echo "Publishing $name@$version from $tarball" + (cd "$dir" && npm publish "./$file" "${args[@]}") || { + echo "::error title=npm publish failed::Trusted publishing must list this repo, workflow, and environment." + exit 1 + } + status="published" + fi + + printf '%s\t%s\t%s\t%s\n' "$name" "$version" "$status" "$tarball" >> "$REPORT" + done < "$packages_tsv" + + - name: Post publish summary + shell: bash + env: + MANIFEST: ${{ inputs.manifest }} + REPORT: ${{ runner.temp }}/sdk-actions-publish-npm-tarballs.tsv + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + count="$(jq '.packages | length' "$MANIFEST")" + if [ "$DRY_RUN" = "true" ]; then + echo "## npm tarball publish dry run" >> "$GITHUB_STEP_SUMMARY" + else + echo "## npm tarballs published" >> "$GITHUB_STEP_SUMMARY" + fi + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Packages:** $count" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Package | npm |" >> "$GITHUB_STEP_SUMMARY" + echo "| --- | --- |" >> "$GITHUB_STEP_SUMMARY" + + while IFS=$'\t' read -r name version status tarball; do + npm_name="$(jq -rn --arg v "$name" '$v|@uri')" + npm_url="https://www.npmjs.com/package/$npm_name/v/$version" + echo "| \`$name@$version\` | [$status]($npm_url) |" >> "$GITHUB_STEP_SUMMARY" + done < "$REPORT" + + - name: Build published Slack message + id: message-published + shell: bash + env: + REPORT: ${{ runner.temp }}/sdk-actions-publish-npm-tarballs.tsv + SLACK_MENTION: ${{ inputs.slack_mention }} + DRY_RUN: ${{ inputs.dry_run }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + + if [ "$DRY_RUN" = "true" ]; then + text="*npm tarball publish dry run*" + else + text="*npm tarballs published*" + fi + while IFS=$'\t' read -r name version status tarball; do + npm_name="$(jq -rn --arg v "$name" '$v|@uri')" + npm_url="https://www.npmjs.com/package/$npm_name/v/$version" + text="$text\n• <$npm_url|$name@$version> - $status" + done < "$REPORT" + text="$text\n<$RUN_URL|View run>" + if [ -n "$SLACK_MENTION" ]; then + text="$text\n$SLACK_MENTION" + fi + { + echo 'text<> "$GITHUB_OUTPUT" + + - name: Send Slack message + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-published.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi + + - name: Build failure Slack message + id: message-failed + if: ${{ failure() }} + shell: bash + env: + FAILURE_SLACK_MENTION: ${{ inputs.failure_slack_mention }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + text="*npm tarball publish failed*\n<$RUN_URL|View run>" + if [ -n "$FAILURE_SLACK_MENTION" ]; then + text="$text\n$FAILURE_SLACK_MENTION" + fi + { + echo 'text<> "$GITHUB_OUTPUT" + + - name: Send Slack message + if: ${{ failure() }} + shell: bash + env: + SLACK_BOT_TOKEN: ${{ inputs.slack_token }} + SLACK_CHANNEL: ${{ inputs.slack_channel }} + TEXT: ${{ steps.message-failed.outputs.text }} + FAIL_ON_ERROR: 'false' + run: | + # Self-guard: no-op when Slack isn't configured, so callers needn't gate this step. + if [ -z "$SLACK_BOT_TOKEN" ] || [ -z "$SLACK_CHANNEL" ]; then + echo "Slack not configured; skipping notification." + exit 0 + fi + # jq --arg passes strings literally, so \n must be real newlines before encoding + TEXT=$(printf '%b' "$TEXT") + RESPONSE=$(curl -s --max-time 10 -X POST "https://slack.com/api/chat.postMessage" \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H "Content-Type: application/json; charset=utf-8" \ + -d "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$TEXT" \ + '{channel: $channel, text: $text}')") || true + if echo "$RESPONSE" | jq -e '.ok' > /dev/null 2>&1; then + echo "Slack notification sent." + elif [ "$FAIL_ON_ERROR" = "true" ]; then + echo "::error title=Slack notification failed::$RESPONSE" + exit 1 + else + echo "::warning::Slack notification failed: $RESPONSE" + fi diff --git a/actions/release/lang/js/ship-package/action.yml b/actions/release/lang/js/ship-package/action.yml index 1cf5c5b..51585c4 100644 --- a/actions/release/lang/js/ship-package/action.yml +++ b/actions/release/lang/js/ship-package/action.yml @@ -42,6 +42,10 @@ inputs: description: "npm release channel (dist-tag) to publish under" required: false default: 'latest' + npm_version: + description: "npm CLI version to install for OIDC trusted publishing" + required: false + default: '11.6.2' dry_run: description: "Skip verification, npm publish, tag push, and GitHub release creation" required: false @@ -143,15 +147,18 @@ runs: done echo "✓ attestations verified for: ${FILES[*]}" - # Publishing a prebuilt tarball goes through the npm CLI, so this needs Node with a - # user-writable global prefix (to install a modern npm for OIDC) and the registry - # wired for trusted publishing — but no pnpm and no dependency install. - name: Set up Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: 'lts/*' + node-version: lts/* registry-url: ${{ inputs.registry }} + - name: Install npm with OIDC trusted publishing support + shell: bash + env: + NPM_VERSION: ${{ inputs.npm_version }} + run: npm install -g "npm@$NPM_VERSION" + - name: Publish prebuilt tarball to npm shell: bash env: diff --git a/actions/release/verify-package/action.yml b/actions/release/verify-package/action.yml new file mode 100644 index 0000000..3923bf5 --- /dev/null +++ b/actions/release/verify-package/action.yml @@ -0,0 +1,51 @@ +# GENERATED by scripts/generate.rb — edit templates/, not this file. +# sdk-actions: {"family":"release","version":"1.0.0"} +name: Verify Release Package Attestations +description: > + Verifies downloaded release artifact attestations against their own bytes. Use this + in custom release workflows that publish multiple prebuilt artifacts without using a + language-specific ship-package action. + +inputs: + artifacts: + description: "Space-separated artifact paths or globs to verify" + required: true + repo: + description: "GitHub owner/repo where the attestations live" + required: false + default: '${{ github.repository }}' + dry_run: + description: "Skip verification when artifacts were intentionally not attested" + required: false + default: 'false' + +runs: + using: composite + steps: + - name: Verify attestations + if: ${{ inputs.dry_run != 'true' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + ARTIFACTS: ${{ inputs.artifacts }} + REPO: ${{ inputs.repo }} + run: | + shopt -s nullglob + FILES=( $ARTIFACTS ) + if [ ${#FILES[@]} -eq 0 ]; then + echo "::error title=Nothing to verify::no artifact files matched '$ARTIFACTS'." + exit 1 + fi + for f in "${FILES[@]}"; do + echo "Verifying $(basename "$f") — build provenance…" + gh attestation verify "$f" --repo "$REPO" || { + echo "::error title=Provenance verification failed::no build-provenance attestation matches $(basename "$f") — the artifact was not produced by a trusted build, or was tampered with in transit." + exit 1 + } + echo "Verifying $(basename "$f") — SBOM…" + gh attestation verify "$f" --repo "$REPO" --predicate-type https://cyclonedx.org/bom || { + echo "::error title=SBOM verification failed::no CycloneDX SBOM attestation matches $(basename "$f")." + exit 1 + } + done + echo "✓ attestations verified for: ${FILES[*]}" diff --git a/templates/actions/release/create-package-github-releases.yml.erb b/templates/actions/release/create-package-github-releases.yml.erb new file mode 100644 index 0000000..3db7f75 --- /dev/null +++ b/templates/actions/release/create-package-github-releases.yml.erb @@ -0,0 +1,155 @@ +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 @; release_title defaults to tag; +# release_body defaults to "Published @.". +# 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" diff --git a/templates/actions/release/lang/js/publish-npm-tarballs.yml.erb b/templates/actions/release/lang/js/publish-npm-tarballs.yml.erb new file mode 100644 index 0000000..bdf6271 --- /dev/null +++ b/templates/actions/release/lang/js/publish-npm-tarballs.yml.erb @@ -0,0 +1,263 @@ +name: Publish npm Tarballs +description: > + Gated publish action for manifest-driven npm package releases. Verifies prebuilt + npm tarball attestations, publishes the exact downloaded tarballs to npmjs with + OIDC, and posts a concise release summary. It does not build, pack, create + GitHub releases, or upload SBOM assets. + +inputs: + manifest: + description: "Path to a release manifest whose packages are already in publish order" + required: true + tarballs: + description: "Space-separated tarball paths or globs" + required: true + repo: + description: "GitHub owner/repo containing the tarball attestations" + required: false + default: '${{ github.repository }}' + provenance: + description: "Publish with npm-native provenance" + required: false + default: 'true' + channel: + description: "Default npm dist-tag" + required: false + default: 'latest' + npm_version: + description: "npm CLI version to install for OIDC trusted publishing" + required: false + default: '11.6.2' + dry_run: + description: "Skip attestation verification and run npm publish with --dry-run" + required: false + default: 'false' + slack_token: + description: "Slack bot token" + required: false + default: '' + slack_channel: + description: "Slack channel ID" + required: false + default: '' + slack_mention: + description: "Optional Slack handle or group to @mention on completion" + required: false + default: '' + failure_slack_mention: + description: "Optional Slack handle or group to @mention if publishing fails" + required: false + default: '' + +runs: + using: composite + steps: +<%= render_step('release/verify-package', + if: "${{ inputs.dry_run != 'true' }}", + artifacts: "${{ inputs.tarballs }}", + repo: "${{ inputs.repo }}") %> + +<%= render_step('lang/js/npm/setup', + registry: "https://registry.npmjs.org", + npm_version: "${{ inputs.npm_version }}") %> + + - name: Publish tarballs in manifest order + shell: bash + env: + MANIFEST: ${{ inputs.manifest }} + TARBALLS: ${{ inputs.tarballs }} + DEFAULT_CHANNEL: ${{ inputs.channel }} + DEFAULT_PROVENANCE: ${{ inputs.provenance }} + DRY_RUN: ${{ inputs.dry_run }} + REPORT: ${{ runner.temp }}/sdk-actions-publish-npm-tarballs.tsv + NODE_AUTH_TOKEN: '' + NPM_TOKEN: '' + run: | + set -euo pipefail + + if ! command -v jq >/dev/null 2>&1; then + echo "::error title=jq not found::publish-npm-tarballs requires jq to read the release manifest." + exit 1 + fi + + if [ ! -f "$MANIFEST" ]; then + echo "::error title=Manifest not found::release manifest does not exist: $MANIFEST" + exit 1 + fi + + shopt -s nullglob + tarball_files=( $TARBALLS ) + if [ "${#tarball_files[@]}" -eq 0 ]; then + echo "::error title=No tarballs found::no files matched '$TARBALLS'." + exit 1 + fi + + : > "$REPORT" + + find_tarball() { + local expected="$1" + local matched=() + for file in "${tarball_files[@]}"; do + if [ "$(basename "$file")" = "$expected" ]; then + matched+=("$file") + fi + done + + if [ "${#matched[@]}" -ne 1 ]; then + echo "::error title=Tarball match failed::expected exactly one tarball named $expected, found ${#matched[@]}." >&2 + return 1 + fi + + printf '%s\n' "${matched[0]}" + } + + packages_tsv="$(mktemp)" + trap 'rm -f "$packages_tsv"' EXIT + + while IFS= read -r encoded; do + package_json="$(printf '%s' "$encoded" | base64 -d)" + name="$(jq -r '.name // empty' <<< "$package_json")" + version="$(jq -r '.version // empty' <<< "$package_json")" + expected_tarball="$(jq -r '.tarball_asset // empty' <<< "$package_json")" + channel="$(jq -r '.channel // empty' <<< "$package_json")" + provenance="$(jq -r '.provenance // empty' <<< "$package_json")" + + if [ -z "$name" ] || [ -z "$version" ]; then + echo "::error title=Manifest package invalid::each package must set name and version." + exit 1 + fi + + if [ -z "$expected_tarball" ]; then + echo "::error title=Tarball asset missing::manifest package $name@$version must set tarball_asset." + exit 1 + fi + + if [ "$(basename "$expected_tarball")" != "$expected_tarball" ]; then + echo "::error title=Invalid tarball asset::tarball_asset must be a file basename, got: $expected_tarball" + exit 1 + fi + + [ -n "$channel" ] || channel="$DEFAULT_CHANNEL" + [ -n "$provenance" ] || provenance="$DEFAULT_PROVENANCE" + + tarball="$(find_tarball "$expected_tarball")" + packed_name="$(tar -xzO -f "$tarball" package/package.json | jq -r '.name')" + packed_version="$(tar -xzO -f "$tarball" package/package.json | jq -r '.version')" + if [ "$packed_name" != "$name" ] || [ "$packed_version" != "$version" ]; then + echo "::error title=Tarball metadata mismatch::$expected_tarball contains $packed_name@$packed_version, expected $name@$version." + exit 1 + fi + + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$name" "$version" "$expected_tarball" "$tarball" "$channel" "$provenance" >> "$packages_tsv" + done < <(jq -r '.packages[]? | @base64' "$MANIFEST") + + if [ ! -s "$packages_tsv" ]; then + echo "::error title=Manifest empty::release manifest must contain at least one package." + exit 1 + fi + + while IFS=$'\t' read -r name version expected_tarball tarball channel provenance; do + args=(--tag "$channel" --access public --registry https://registry.npmjs.org) + [ "$provenance" = "true" ] && args+=(--provenance) + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: npm publish ./$expected_tarball ${args[*]} --dry-run" + status="dry-run" + elif npm view "$name@$version" version --registry https://registry.npmjs.org >/dev/null 2>&1; then + echo "$name@$version is already published; skipping npm publish." + status="already-published" + else + dir="$(dirname "$tarball")" + file="$(basename "$tarball")" + echo "Publishing $name@$version from $tarball" + (cd "$dir" && npm publish "./$file" "${args[@]}") || { + echo "::error title=npm publish failed::Trusted publishing must list this repo, workflow, and environment." + exit 1 + } + status="published" + fi + + printf '%s\t%s\t%s\t%s\n' "$name" "$version" "$status" "$tarball" >> "$REPORT" + done < "$packages_tsv" + + - name: Post publish summary + shell: bash + env: + MANIFEST: ${{ inputs.manifest }} + REPORT: ${{ runner.temp }}/sdk-actions-publish-npm-tarballs.tsv + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + count="$(jq '.packages | length' "$MANIFEST")" + if [ "$DRY_RUN" = "true" ]; then + echo "## npm tarball publish dry run" >> "$GITHUB_STEP_SUMMARY" + else + echo "## npm tarballs published" >> "$GITHUB_STEP_SUMMARY" + fi + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Packages:** $count" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Package | npm |" >> "$GITHUB_STEP_SUMMARY" + echo "| --- | --- |" >> "$GITHUB_STEP_SUMMARY" + + while IFS=$'\t' read -r name version status tarball; do + npm_name="$(jq -rn --arg v "$name" '$v|@uri')" + npm_url="https://www.npmjs.com/package/$npm_name/v/$version" + echo "| \`$name@$version\` | [$status]($npm_url) |" >> "$GITHUB_STEP_SUMMARY" + done < "$REPORT" + + - name: Build published Slack message + id: message-published + shell: bash + env: + REPORT: ${{ runner.temp }}/sdk-actions-publish-npm-tarballs.tsv + SLACK_MENTION: ${{ inputs.slack_mention }} + DRY_RUN: ${{ inputs.dry_run }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + + if [ "$DRY_RUN" = "true" ]; then + text="*npm tarball publish dry run*" + else + text="*npm tarballs published*" + fi + while IFS=$'\t' read -r name version status tarball; do + npm_name="$(jq -rn --arg v "$name" '$v|@uri')" + npm_url="https://www.npmjs.com/package/$npm_name/v/$version" + text="$text\n• <$npm_url|$name@$version> - $status" + done < "$REPORT" + text="$text\n<$RUN_URL|View run>" + if [ -n "$SLACK_MENTION" ]; then + text="$text\n$SLACK_MENTION" + fi + { + echo 'text<> "$GITHUB_OUTPUT" + +<%= render_step('slack/send', text: "${{ steps.message-published.outputs.text }}") %> + + - name: Build failure Slack message + id: message-failed + if: ${{ failure() }} + shell: bash + env: + FAILURE_SLACK_MENTION: ${{ inputs.failure_slack_mention }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + text="*npm tarball publish failed*\n<$RUN_URL|View run>" + if [ -n "$FAILURE_SLACK_MENTION" ]; then + text="$text\n$FAILURE_SLACK_MENTION" + fi + { + echo 'text<> "$GITHUB_OUTPUT" + +<%= render_step('slack/send', + if: "${{ failure() }}", + text: "${{ steps.message-failed.outputs.text }}") %> diff --git a/templates/actions/release/lang/js/ship-package.yml.erb b/templates/actions/release/lang/js/ship-package.yml.erb index ac73be2..8776104 100644 --- a/templates/actions/release/lang/js/ship-package.yml.erb +++ b/templates/actions/release/lang/js/ship-package.yml.erb @@ -40,6 +40,10 @@ inputs: description: "npm release channel (dist-tag) to publish under" required: false default: 'latest' + npm_version: + description: "npm CLI version to install for OIDC trusted publishing" + required: false + default: '11.6.2' dry_run: description: "Skip verification, npm publish, tag push, and GitHub release creation" required: false @@ -119,14 +123,7 @@ runs: if: "${{ inputs.dry_run != 'true' }}", artifacts: "${{ inputs.tarball }}") %> - # Publishing a prebuilt tarball goes through the npm CLI, so this needs Node with a - # user-writable global prefix (to install a modern npm for OIDC) and the registry - # wired for trusted publishing — but no pnpm and no dependency install. - - name: Set up Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 'lts/*' - registry-url: ${{ inputs.registry }} +<%= render_step('lang/js/npm/setup') %> <%= render_step('lang/js/npm/publish-package', tarball: "${{ inputs.tarball }}") %> diff --git a/templates/actions/release/verify-package.yml.erb b/templates/actions/release/verify-package.yml.erb new file mode 100644 index 0000000..41ecdf0 --- /dev/null +++ b/templates/actions/release/verify-package.yml.erb @@ -0,0 +1,26 @@ +name: Verify Release Package Attestations +description: > + Verifies downloaded release artifact attestations against their own bytes. Use this + in custom release workflows that publish multiple prebuilt artifacts without using a + language-specific ship-package action. + +inputs: + artifacts: + description: "Space-separated artifact paths or globs to verify" + required: true + repo: + description: "GitHub owner/repo where the attestations live" + required: false + default: '${{ github.repository }}' + dry_run: + description: "Skip verification when artifacts were intentionally not attested" + required: false + default: 'false' + +runs: + using: composite + steps: +<%= render_step('release/verify-package', + if: "${{ inputs.dry_run != 'true' }}", + artifacts: "${{ inputs.artifacts }}", + repo: "${{ inputs.repo }}") %> diff --git a/templates/steps/lang/js/npm/setup.yml.erb b/templates/steps/lang/js/npm/setup.yml.erb new file mode 100644 index 0000000..5479c64 --- /dev/null +++ b/templates/steps/lang/js/npm/setup.yml.erb @@ -0,0 +1,20 @@ +<%# + Sets up Node for direct npm CLI publishing and installs a modern npm with OIDC + trusted publishing support. This is for publish-only flows that do not need + pnpm/bun or dependency installation. + + @param node_version [String] Node version. Default: lts/* + @param registry [String] npm registry URL. Default: ${{ inputs.registry }} + @param npm_version [String] npm CLI version. Default: ${{ inputs.npm_version }} +-%> +- name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: <%= locals.fetch(:node_version) { "lts/*" } %> + registry-url: <%= locals.fetch(:registry) { "${{ inputs.registry }}" } %> + +- name: Install npm with OIDC trusted publishing support + shell: bash + env: + NPM_VERSION: <%= locals.fetch(:npm_version) { "${{ inputs.npm_version }}" } %> + run: npm install -g "npm@$NPM_VERSION"