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
31 changes: 31 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ on:
description: 'pnpm filter (single mode only, e.g. ./packages/sdk)'
required: false
default: ''
chirp_bootstrap:
description: 'One-time @bsv/chirp registry bootstrap (must use single mode and the CHIRP filter)'
type: boolean
default: false

permissions: {}

Expand All @@ -38,6 +42,7 @@ jobs:
count: ${{ steps.artifacts.outputs.count }}
filter: ${{ steps.plan.outputs.filter }}
mode: ${{ steps.plan.outputs.mode }}
chirp_bootstrap: ${{ steps.plan.outputs.chirp_bootstrap }}

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -109,6 +114,7 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
DISPATCH_MODE: ${{ inputs.mode }}
DISPATCH_FILTER: ${{ inputs.filter }}
DISPATCH_CHIRP_BOOTSTRAP: ${{ inputs.chirp_bootstrap }}
run: |
mode=""
filter=""
Expand Down Expand Up @@ -139,8 +145,17 @@ jobs:
fi
fi

chirp_bootstrap="${DISPATCH_CHIRP_BOOTSTRAP:-false}"
if [[ "$chirp_bootstrap" == "true" ]]; then
if [[ "$EVENT_NAME" != "workflow_dispatch" || "$mode" != "single" || "$filter" != "./packages/network/chirp" ]]; then
echo "::error::The one-time CHIRP bootstrap requires a manual single-package release filtered to ./packages/network/chirp."
exit 1
fi
fi

echo "mode=$mode" >> "$GITHUB_OUTPUT"
echo "filter=$filter" >> "$GITHUB_OUTPUT"
echo "chirp_bootstrap=$chirp_bootstrap" >> "$GITHUB_OUTPUT"
echo "Resolved release plan: mode=$mode filter='${filter:-<all governed packages>}'"

# Package artifacts cross a single immutable release boundary: pack once,
Expand Down Expand Up @@ -270,8 +285,24 @@ jobs:
retention-days: 90

- name: Publish the attested npm tarballs
if: needs.prepare.outputs.chirp_bootstrap != 'true'
run: node scripts/package-release-artifacts.mjs publish release-artifacts/manifest.json

# npm requires a package record before a trusted publisher can be bound.
# This temporary, protected-environment path is constrained by the plan
# step to the first @bsv/chirp publication and is removed immediately
# after the package's OIDC trust relationship is established.
- name: Bootstrap the attested @bsv/chirp tarball
if: needs.prepare.outputs.chirp_bootstrap == 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_CHIRP_BOOTSTRAP_TOKEN }}
run: |
if [[ -z "$NODE_AUTH_TOKEN" ]]; then
echo "::error::The protected CHIRP bootstrap token is unavailable."
exit 1
fi
node scripts/package-release-artifacts.mjs publish release-artifacts/manifest.json

# Publication credentials never coexist with source-controlled package build
# code. The separate sync job receives only repository/PR authority and
# exposes its GitHub credential solely during the push and PR steps.
Expand Down
11 changes: 10 additions & 1 deletion docs/reference/npm-package-supply-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,20 @@ tags: [reference, packages, npm, security, releases]

# npm Package Supply Chain

All 30 public packages are released from `.github/workflows/release.yaml`. The
All 32 public packages are released from `.github/workflows/release.yaml`. The
workflow is the only supported publication path. It uses the protected
`npm-production` environment and npm trusted publishing (OIDC); release
automation must not use a long-lived npm write token.

`@bsv/chirp@0.1.0` is the one-time registry bootstrap exception: npm requires
the package record to exist before its trusted publisher can be configured.
The manual release input is fail-closed to single mode and
`./packages/network/chirp`, reads a one-day `@bsv`-scope token only inside the
protected publish job, and publishes the same scanned and attested tarball with
provenance. Remove the input, secret binding, token, and governed exception as
soon as the package is published and its `release.yaml` / `npm-production`
trusted-publisher relationship is verified.

## Release boundary

A release first builds and validates the workspace in an uncredentialed
Expand Down
14 changes: 14 additions & 0 deletions governance/repository-health/exceptions.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@
"schemaVersion": 1,
"lastReviewed": "2026-08-24",
"exceptions": [
{
"id": "chirp-first-publication-bootstrap",
"category": "security",
"target": "@bsv/chirp@0.1.0 first npm publication",
"owner": "ts-stack-maintainers",
"reason": "npm requires a new package record before its trusted GitHub Actions publisher can be configured. The one-time fallback is constrained to a manual single-package CHIRP release, a one-day granular token with write access only to the @bsv scope, and the protected npm-production publish job that scans, attests, and publishes the immutable candidate with provenance.",
"evidence": [
"https://docs.npmjs.com/trusted-publishers#troubleshooting",
"https://github.com/bsv-blockchain/ts-stack/actions/runs/33030007295"
],
"created": "2026-08-24",
"reviewBy": "2026-08-28",
"removeWhen": "Remove the workflow input and secret binding, delete the GitHub environment secret, revoke the npm token, and delete this exception immediately after @bsv/chirp@0.1.0 is published and the bsv-blockchain/ts-stack release.yaml trusted publisher is verified for npm-production."
},
{
"id": "scorecard-maintainer-merge-policy",
"category": "security",
Expand Down
8 changes: 5 additions & 3 deletions scripts/configure-ts-stack-npm-trust.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ set -euo pipefail

REPO='bsv-blockchain/ts-stack'
FILE='release.yaml'
ENVIRONMENT='npm-production'
PKGS=(
"@bsv/amountinator"
"@bsv/wallet-helper"
Expand All @@ -18,6 +19,7 @@ PKGS=(
"@bsv/auth-express-middleware"
"@bsv/payment-express-middleware"
"@bsv/teranode-listener"
"@bsv/chirp"
"@bsv/gasp"
"@bsv/overlay-discovery-services"
"@bsv/overlay-express"
Expand Down Expand Up @@ -160,7 +162,7 @@ for pkg in "${PKGS[@]}"; do
has_target=0
ids_to_revoke=""
if [[ -n "${trust:-}" ]] && [[ "$trust" != "[]" ]]; then
has_target=$(echo "$trust" | jq -r --arg repo "$REPO" --arg file "$FILE" 'map(select(.repository == $repo and .file == $file and .type == "github")) | length')
has_target=$(echo "$trust" | jq -r --arg repo "$REPO" --arg file "$FILE" --arg environment "$ENVIRONMENT" 'map(select(.repository == $repo and .file == $file and .environment == $environment and .type == "github")) | length')
ids_to_revoke=$(echo "$trust" | jq -r '.[].id')
else
echo "No trust entries found (creating new one)"
Expand All @@ -173,7 +175,7 @@ for pkg in "${PKGS[@]}"; do
if [[ -n "$ids_to_revoke" ]]; then
echo "[dry-run] would revoke: ${ids_to_revoke//$'\n'/, }"
fi
echo "[dry-run] would add: trust github ${pkg} --repository ${REPO} --file ${FILE} --yes"
echo "[dry-run] would add: trust github ${pkg} --repository ${REPO} --file ${FILE} --environment ${ENVIRONMENT} --yes"
fi
rm -f /tmp/ts-stack-trust-err.txt
echo
Expand All @@ -198,7 +200,7 @@ for pkg in "${PKGS[@]}"; do
fi

echo "Applying target trust"
if run_npm_capture "trust github for ${pkg}" trust github "$pkg" --repository "$REPO" --file "$FILE" --yes >/dev/null; then
if run_npm_capture "trust github for ${pkg}" trust github "$pkg" --repository "$REPO" --file "$FILE" --environment "$ENVIRONMENT" --yes >/dev/null; then
echo "OK"
else
echo "FAILED"
Expand Down
19 changes: 17 additions & 2 deletions scripts/package-release-artifacts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -421,16 +421,31 @@ test('npm release workflow preserves scan, attestation, verification, and exact-
assert.match(workflow, /actions\/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d/g)
assert.match(workflow, /actions\/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a/)
assert.doesNotMatch(workflow, /pnpm\s+-r[\s\S]{0,100}\spublish\b/)
assert.doesNotMatch(workflow, /NODE_AUTH_TOKEN|NPM_TOKEN/)
const bootstrapInput = workflow.indexOf('chirp_bootstrap:')
const bootstrapStep = workflow.indexOf('- name: Bootstrap the attested @bsv/chirp tarball')
assert.ok(bootstrapInput > 0)
assert.ok(bootstrapStep > 0)
assert.match(workflow, /default: false/)
assert.match(workflow, /\$filter" != "\.\/packages\/network\/chirp"/)
assert.equal(workflow.match(/NODE_AUTH_TOKEN/g)?.length, 2)
assert.doesNotMatch(workflow, /NPM_TOKEN/)
assert.equal(workflow.match(/runs-on: ubuntu-24\.04/g)?.length, 3)
assert.equal(workflow.match(/node-version: 24\.18\.0/g)?.length, 3)
assert.equal(workflow.match(/persist-credentials: false/g)?.length, 3)
assert.match(workflow, /candidate: \$\{\{ steps\.artifacts\.outputs\.candidate \}\}/)
assert.match(workflow, /name: \$\{\{ needs\.prepare\.outputs\.candidate \}\}/)
const prepareJob = workflow.slice(workflow.indexOf(' prepare:'), workflow.indexOf(' publish:'))
assert.doesNotMatch(prepareJob, /id-token: write|environment: npm-production/)
assert.doesNotMatch(
prepareJob,
/id-token: write|environment: npm-production|NODE_AUTH_TOKEN|NPM_CHIRP_BOOTSTRAP_TOKEN/
)
assert.match(prepareJob, /permissions:\n\s+contents: read/)

const bootstrapPublish = workflow.slice(bootstrapStep, workflow.indexOf(' sync-versions:'))
assert.match(bootstrapPublish, /needs\.prepare\.outputs\.chirp_bootstrap == 'true'/)
assert.match(bootstrapPublish, /secrets\.NPM_CHIRP_BOOTSTRAP_TOKEN/)
assert.match(bootstrapPublish, /package-release-artifacts\.mjs publish/)

const stage = workflow.indexOf('- name: Stage exact npm release artifacts')
const scan = workflow.indexOf('- name: Reject high and critical package findings')
const provenance = workflow.indexOf('- name: Attest npm package build provenance')
Expand Down
Loading