diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5ee7ecebc..6de5f40f4 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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: {} @@ -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 @@ -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="" @@ -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:-}'" # Package artifacts cross a single immutable release boundary: pack once, @@ -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. diff --git a/docs/reference/npm-package-supply-chain.md b/docs/reference/npm-package-supply-chain.md index 52f3543a2..ac0e20c24 100644 --- a/docs/reference/npm-package-supply-chain.md +++ b/docs/reference/npm-package-supply-chain.md @@ -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 diff --git a/governance/repository-health/exceptions.json b/governance/repository-health/exceptions.json index b5704f475..09e59de88 100644 --- a/governance/repository-health/exceptions.json +++ b/governance/repository-health/exceptions.json @@ -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", diff --git a/scripts/configure-ts-stack-npm-trust.sh b/scripts/configure-ts-stack-npm-trust.sh index 739fc977d..0fc894072 100755 --- a/scripts/configure-ts-stack-npm-trust.sh +++ b/scripts/configure-ts-stack-npm-trust.sh @@ -3,6 +3,7 @@ set -euo pipefail REPO='bsv-blockchain/ts-stack' FILE='release.yaml' +ENVIRONMENT='npm-production' PKGS=( "@bsv/amountinator" "@bsv/wallet-helper" @@ -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" @@ -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)" @@ -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 @@ -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" diff --git a/scripts/package-release-artifacts.test.mjs b/scripts/package-release-artifacts.test.mjs index dc43228aa..3e03908f4 100644 --- a/scripts/package-release-artifacts.test.mjs +++ b/scripts/package-release-artifacts.test.mjs @@ -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')