From cbf0cbee717d9fc658cd83e16078ebfc15e9484f Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:36:34 -0300 Subject: [PATCH] ci(release): attach the signed tarball to each release and set up Node where Scorecard sees it OpenSSF Scorecard left Packaging and Signed-Releases without a score: - Packaging only recognizes an npm publishing job by an actions/setup-node step with the npm registry URL next to the publish command, and the job set Node up inside the composite action. The job now runs setup-node itself, with the registry URL npm's trusted publishing guide uses. - Signed-Releases needs signed assets on the release, and releases are immutable here, so nothing could be attached after release-please published them. release-please now creates a draft (with the tag forced, so the next run still finds it); a release-assets job packs the package, attests its build provenance, attaches the tarball, the Sigstore bundle, the DSSE envelope as .intoto.jsonl and the SBOM, and publishes the release. It replaces the job that kept the SBOM as a run artifact. --- .github/actions/setup/action.yml | 5 ++ .github/workflows/release.yml | 78 +++++++++++++++++++++++--------- CONTRIBUTING.md | 16 ++++--- SECURITY.md | 10 ++-- release-please-config.json | 2 + 5 files changed, 80 insertions(+), 31 deletions(-) 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" },