diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 27fed784e..2daeb4983 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -6,11 +6,16 @@ inputs: description: Node.js version to install via actions/setup-node. Reads .nvmrc when omitted. required: false default: "" + setup-node: + description: Set to "false" when the job installs Node.js itself, as the npm publish job does. + required: false + default: "true" runs: using: composite steps: - name: Setup Node.js + if: ${{ inputs.setup-node != 'false' }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ inputs.node-version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad49c2808..44f5c80e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,10 +5,11 @@ # and CHANGELOG.md entry are generated from Conventional Commit messages since the last # release (feat -> minor, fix -> patch, `!`/`BREAKING CHANGE:` -> major). Nothing is # published yet. A maintainer reviews and merges that PR: that merge is confirmation #1. -# 2. Merging the release PR makes release-please tag the release commit and create a GitHub -# Release, which triggers this same workflow again. This time `release_created` is `true`, -# so the `publish-npm` job builds, validates and STAGES the package on npm -# (`npm stage publish --provenance`). A staged version is not installable until a +# 2. Merging the release PR makes release-please tag the release commit and create a draft +# GitHub Release, which triggers this same workflow again. This time `release_created` is +# `true`: the `release-assets` job attaches the tarball, its signed provenance and the SBOM to +# the draft and publishes it, and the `publish-npm` job builds, validates and STAGES the +# package on npm (`npm stage publish --provenance`). A staged version is not installable until a # maintainer approves it with 2FA, on npmjs.com (package -> Staged versions) or with # `npm stage approve `. That approval is confirmation #2 (npm's # "proof-of-presence"), and the trusted publisher is configured for staged publishing only, @@ -88,10 +89,19 @@ jobs: ref: ${{ needs.release-please.outputs.tag_name }} persist-credentials: false + # setup-node runs in the workflow itself, not in the composite action, with the npm registry + # URL npm's trusted publishing guide uses: OpenSSF Scorecard (Packaging) only recognizes an + # npm publishing job by this step next to the publish command. + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + - name: Setup uses: ./.github/actions/setup with: - node-version: 24 + setup-node: "false" - name: Install dependencies run: npm ci @@ -157,14 +167,18 @@ jobs: - name: Publish to JSR run: deno publish - sbom: - name: Record the SBOM of the release + release-assets: + name: Attach the signed package to the release needs: release-please if: ${{ needs.release-please.outputs.release_created == 'true' }} runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 permissions: - contents: read + contents: write + id-token: write + attestations: write + env: + TAG: ${{ needs.release-please.outputs.tag_name }} steps: - name: Checkout the release tag @@ -175,18 +189,40 @@ jobs: - name: Setup uses: ./.github/actions/setup + with: + node-version: 24 - - name: Generate the CycloneDX SBOM of the published package - # The package has no runtime dependencies, so the SBOM describes the package itself; - # --package-lock-only reads the lockfile instead of installing anything. - run: npm sbom --sbom-format cyclonedx --omit dev --package-lock-only > brazilian-utils.cdx.json + - name: Install dependencies + run: npm ci + + - name: Run build + run: npm run build - # Releases are immutable in this repository, so an asset cannot be added after release-please - # creates one (HTTP 422 on 2.4.0); the SBOM is kept as an artifact of the release run instead, - # next to the provenance statement npm records for the package. - - name: Keep the SBOM as an artifact of the release run - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + - name: Pack the package as npm publishes it + id: pack + # The SBOM goes in first because `files` in package.json ships it inside the tarball. + run: | + npm sbom --sbom-format cyclonedx --omit dev --package-lock-only > brazilian-utils.cdx.json + echo "tarball=$(npm pack --silent)" >> "$GITHUB_OUTPUT" + + - name: Attest the build provenance of the tarball + id: attest + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: - name: sbom-${{ needs.release-please.outputs.tag_name }} - path: brazilian-utils.cdx.json - if-no-files-found: error + subject-path: ${{ steps.pack.outputs.tarball }} + + # release-please creates the release as a draft (release-please-config.json) because releases + # are immutable in this repository: assets can only be added before the release is published. + # The Sigstore bundle can be checked with `gh attestation verify`; the .intoto.jsonl file holds + # the same signed SLSA provenance as a DSSE envelope, the form OpenSSF Scorecard + # (Signed-Releases) looks for. + - name: Attach the tarball, its provenance and the SBOM, then publish the release + env: + GH_TOKEN: ${{ github.token }} + TARBALL: ${{ steps.pack.outputs.tarball }} + BUNDLE: ${{ steps.attest.outputs.bundle-path }} + run: | + head -n 1 "$BUNDLE" | jq . > "$TARBALL.sigstore.json" + jq -c .dsseEnvelope "$BUNDLE" > "$TARBALL.intoto.jsonl" + gh release upload "$TAG" "$TARBALL" "$TARBALL.sigstore.json" "$TARBALL.intoto.jsonl" brazilian-utils.cdx.json --repo "$GITHUB_REPOSITORY" + gh release edit "$TAG" --draft=false --latest --repo "$GITHUB_REPOSITORY" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07982584b..39fcad1c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -385,11 +385,12 @@ public signatures are pinned by the `describe(" types")` blocks in the tes permissions, branch protection, code review, dependency updates, SAST) rather than the code, publishes the score and uploads the findings to the Security tab. - Every release ships `brazilian-utils.cdx.json` inside the package, a CycloneDX SBOM generated - with `npm sbom` from the release tag right before staging on npm, and keeps the same file as a - workflow artifact (`sbom-`); releases are immutable here, so the file cannot be attached to - the release itself. The package has no - runtime dependencies, so the document describes the package itself; it exists for consumers - whose supply-chain policy requires one. + with `npm sbom` from the release tag right before staging on npm, and attaches the same file to + the GitHub Release. The package has no runtime dependencies, so the document describes the + package itself; it exists for consumers whose supply-chain policy requires one. +- Releases are immutable here, so release-please creates each one as a draft: the + `release-assets` job attaches the packed tarball, its signed build provenance (a Sigstore bundle + and the same DSSE envelope as `.intoto.jsonl`) and the SBOM, then publishes the release. - Commit messages are checked with commitlint on every pull request, since release-please derives the version bump and the changelog from them. - The URLs cited in the Markdown files and in the `@see` tags of the source are checked by hand @@ -520,8 +521,9 @@ There are no local release commands to run. hidden. 2. A maintainer reviews the release PR (version bump, changelog) and merges it. **Merging the release PR is the first confirmation.** Nothing is published yet at this point. -3. Merging tags the release and publishes a GitHub Release, which triggers the `publish-npm` job in - `.github/workflows/release.yml`. That job builds and validates the package and **stages** it on +3. Merging tags the release and creates a draft GitHub Release, which triggers the + `release-assets` and `publish-npm` jobs in `.github/workflows/release.yml`. The first attaches + the signed tarball and the SBOM to the draft and publishes it; the second builds and validates the package and **stages** it on npm with `npm stage publish --provenance` (npm Trusted Publishing/OIDC; no npm token is stored in the repository). A staged version is not installable yet. 4. A maintainer approves the staged version with 2FA, on npmjs.com (package → Staged versions) or diff --git a/SECURITY.md b/SECURITY.md index ebc338f36..920c268be 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -110,9 +110,13 @@ It verifies the registry signature and the provenance attestation of every insta The attestation, with the commit and the workflow run that built the version, is also shown on the ["Provenance" panel of the version on npmjs.com](https://www.npmjs.com/package/@brazilian-utils/brazilian-utils?activeTab=versions). A CycloneDX SBOM of each version ships inside the package as `brazilian-utils.cdx.json` (so it -sits in `node_modules/@brazilian-utils/brazilian-utils/` after install) and is also kept as the -`sbom-` artifact of its release run under -[Actions → Release](https://github.com/brazilian-utils/javascript/actions/workflows/release.yml). +sits in `node_modules/@brazilian-utils/brazilian-utils/` after install) and is also attached to +its [GitHub Release](https://github.com/brazilian-utils/javascript/releases), next to the packed +tarball and its signed build provenance. To check a tarball downloaded from the release: + +```bash +gh attestation verify brazilian-utils-brazilian-utils-.tgz --repo brazilian-utils/javascript +``` ## Secrets and credentials diff --git a/release-please-config.json b/release-please-config.json index 35b4d413d..9fbd20622 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -4,6 +4,8 @@ "include-v-in-tag": false, "include-component-in-tag": false, "changelog-path": "CHANGELOG.md", + "draft": true, + "force-tag-creation": true, "bump-minor-pre-major": false, "changelog-sections": [ { "type": "feat", "section": "Features" },