diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1c44ff..dd6d67a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,11 @@ name: Release # build cannot forge, and the registries are configured to require it. # 6. No third-party actions in the publish path. Only actions/* and pypa/*. # +# The npm tarball goes to two registries. npm resolves registries per scope with no per-package +# override, and `@matchory/ui` lives on GitHub Packages, so a repository consuming both must point +# the whole scope at one place. Publishing identical bytes to npmjs and GitHub Packages makes either +# mapping work. npmjs is canonical and is the copy that carries provenance. +# # Packagist is absent on purpose: it reads tags from this repository and builds nothing, so there is # no artefact to attest. Composer has no provenance mechanism, which makes the signed tag and the # repository's branch protection the whole of its supply-chain story. See SECURITY.md. @@ -344,7 +349,7 @@ jobs: env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - if npm view @matchory/coding-style version --registry https://registry.npmjs.org > /dev/null 2>&1; then + if npm view @matchory/coding-style version --@matchory:registry=https://registry.npmjs.org > /dev/null 2>&1; then exists=true else exists=false @@ -366,18 +371,30 @@ jobs: echo 'Publishing via OIDC trusted publishing' fi - # `publishConfig.registry` in package.json does NOT win over an `@scope:registry` entry in an - # .npmrc, and npm's provenance code does not validate which registry it is publishing to. So a - # stray scope mapping — a developer's ~/.npmrc, a repository .npmrc added later, setup-node's - # registry wiring — could redirect a provenance-signed release to another registry entirely. - # Pinning --registry on the command line removes that vector, and this asserts it took effect. - - name: The resolved publish registry is npmjs + # For a SCOPED package npm resolves the registry from `@scope:registry`, and only falls back to + # the generic `registry` key. `--registry` sets the generic key, so it loses to any + # `@matchory:registry` entry — in a developer .npmrc, a repository .npmrc, or setup-node's + # config. This was verified the hard way: a publish with `--registry https://registry.npmjs.org` + # went to GitHub Packages instead, because a scope mapping outranked it. + # + # `--@matchory:registry=` sets the key that actually wins. npm's provenance code does not + # validate the destination registry at all, so without this a provenance-signed release could + # land somewhere unintended. + - name: Confirm the publish target is npmjs run: | - resolved="$(npm config get @matchory:registry)" + target="$(npm publish ./dist/*.tgz \ + --dry-run \ + --@matchory:registry=https://registry.npmjs.org \ + --access public \ + --tag '${{ needs.verify.outputs.dist_tag }}' 2>&1 \ + | grep -oE 'Publishing to [^ ]+' | head -1)" - if [ "${resolved}" != 'undefined' ] && [ "${resolved#https://registry.npmjs.org}" = "${resolved}" ]; then - echo "::warning::@matchory:registry resolves to ${resolved}; --registry overrides it for this publish" - fi + echo "${target:-}" + + case "${target}" in + *'https://registry.npmjs.org'*) echo 'Target confirmed: npmjs' ;; + *) echo "::error::resolved publish target is not npmjs (${target:-unknown}); refusing to publish"; exit 1 ;; + esac - name: Publish env: @@ -389,7 +406,7 @@ jobs: fi npm publish ./dist/*.tgz \ - --registry https://registry.npmjs.org \ + --@matchory:registry=https://registry.npmjs.org \ --provenance \ --access public \ --tag '${{ needs.verify.outputs.dist_tag }}' @@ -397,6 +414,57 @@ jobs: # Leave nothing behind on the runner even though it is ephemeral. npm config delete '//registry.npmjs.org/:_authToken' || true + publish-github-packages: + name: Publish to GitHub Packages + needs: [verify, build-npm, publish-npm] + runs-on: ubuntu-latest + environment: release + permissions: + contents: read + packages: write + steps: + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + + - name: Pin npm + run: npm install -g "npm@${NPM_VERSION}" + + - name: Download the attested tarball + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + name: npm-tarball + path: dist + + # Byte-identical to what npmjs received, and to what was attested. + - name: Checksum matches what was attested + run: | + cd dist + sha256sum --check ./*.sha256 + + # npm resolves a registry per SCOPE, with no per-package override. `@matchory/ui` is published + # here because it is proprietary, so any repository consuming it maps the whole `@matchory` scope + # to GitHub Packages — and would therefore never find this package on npmjs. Publishing the same + # tarball to both registries is what makes the scope work either way. + # + # npmjs remains canonical: it is public, carries the provenance statement, and is what external + # consumers and the README point at. + - name: Publish + env: + GH_PACKAGES_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + npm config set '//npm.pkg.github.com/:_authToken' "${GH_PACKAGES_TOKEN}" + + # --provenance is deliberately omitted. GitHub Packages neither displays nor verifies npm + # provenance statements, and the same bytes already carry one on npmjs plus a GitHub artefact + # attestation. Generating a second statement here would add a claim nobody can check. + npm publish ./dist/*.tgz \ + --@matchory:registry=https://npm.pkg.github.com \ + --tag '${{ needs.verify.outputs.dist_tag }}' + + npm config delete '//npm.pkg.github.com/:_authToken' || true + publish-pypi: name: Publish to PyPI needs: [verify, build-python] @@ -428,7 +496,7 @@ jobs: github-release: name: Publish the GitHub release - needs: [verify, publish-npm, publish-pypi] + needs: [verify, publish-npm, publish-github-packages, publish-pypi] runs-on: ubuntu-latest permissions: contents: write diff --git a/CHANGELOG.md b/CHANGELOG.md index b511553..202fb7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,10 @@ ecosystems, so a malicious version would run in our CI and on developer machines - OIDC **trusted publishing** on both npm and PyPI. No long-lived registry credential exists in this repository. +- The npm package is published to **npmjs and GitHub Packages** from the same attested tarball. npm + resolves registries per scope with no per-package override, and `@matchory/ui` is on GitHub Packages, + so a repository consuming both has to point the whole scope at one registry. npmjs is canonical and + carries the provenance statement. - npm artefacts carry [provenance](https://docs.npmjs.com/generating-provenance-statements); PyPI artefacts carry [PEP 740](https://peps.python.org/pep-0740/) attestations. Both also get GitHub artefact attestations, verifiable with `gh attestation verify --repo matchory/coding-style`. diff --git a/README.md b/README.md index 34c8f98..a29d79c 100644 --- a/README.md +++ b/README.md @@ -238,8 +238,16 @@ repository, like not using Rector at all. pnpm add -D @matchory/coding-style ``` -Published to GitHub Packages. Consumers need `@matchory:registry=https://npm.pkg.github.com` in -`.npmrc`, same as `@matchory/ui`. +Published to **both npmjs and GitHub Packages**, as identical bytes from the same attested tarball, so +it resolves whichever way your `.npmrc` points the `@matchory` scope. + +That is not redundancy for its own sake. npm resolves a registry per *scope* and has no per-package +override, and `@matchory/ui` is proprietary so it lives on GitHub Packages. A repository consuming both +must therefore point the whole scope at one registry. Publishing to both is what lets it. + +npmjs is canonical: it is public, and it is the copy that carries +[provenance](https://docs.npmjs.com/generating-provenance-statements). Prefer it unless you already map +the scope to GitHub Packages for `@matchory/ui`. ESLint plugins are regular `dependencies` so their versions are pinned centrally — that is the point of the package. The tools themselves (`eslint`, `oxlint`, `oxfmt`, `typescript`) are optional peer diff --git a/SECURITY.md b/SECURITY.md index 2850fab..7bf03b5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,6 +27,12 @@ npm audit signatures **PyPI** — published with [PEP 740](https://peps.python.org/pep-0740/) attestations, visible under the file's details on PyPI. +The npm package is published to **npmjs and GitHub Packages** from the same attested tarball, because +npm resolves registries per scope and `@matchory/ui` lives on GitHub Packages. The two copies are +byte-identical, verified by SHA-256 before each upload. Only the npmjs copy carries an npm provenance +statement: GitHub Packages neither displays nor verifies them, so generating a second statement there +would add a claim nobody can check. The GitHub artefact attestation below covers both. + **GitHub artefact attestations** — both the npm tarball and the Python distributions are attested and attached to the release: @@ -99,6 +105,9 @@ Stated explicitly rather than left for someone to discover: - **Composer has no artefact provenance.** See above. The mitigation is repository protection plus signed tags. +- **The GitHub Packages copy has no npm provenance statement**, only the shared GitHub artefact + attestation. It is byte-identical to the npmjs copy, which does carry provenance, and the checksum is + verified before upload. Verify against npmjs if you need the provenance chain. - **`@matchory/coding-style@0.0.0` was published from a maintainer machine and has no provenance.** npm cannot configure a trusted publisher for a package that does not exist, so something had to be published first. The placeholder contains only a README and the licence, is deprecated, and is not diff --git a/js/README.md b/js/README.md index 9dbf9da..289cfba 100644 --- a/js/README.md +++ b/js/README.md @@ -10,6 +10,9 @@ ruff presets for Python. All three share one version number. pnpm add -D @matchory/coding-style ``` +Published to both npmjs and GitHub Packages as identical bytes, so it resolves whichever registry your +`.npmrc` maps the `@matchory` scope to. npmjs is canonical and carries provenance. + ```ts // oxfmt.config.ts import { oxfmtBase } from '@matchory/coding-style/oxfmt';