From 5f9ca0179e4511b34cd168e1dc9abee6875009c4 Mon Sep 17 00:00:00 2001 From: Moritz Mazetti Date: Thu, 30 Jul 2026 14:50:12 +0200 Subject: [PATCH 1/2] fix(release): override the SCOPE registry, not the generic one The `--registry` pin added in the previous commit does not work for a scoped package, and I found out by publishing to the wrong registry. npm resolves the registry for `@matchory/coding-style` from `@matchory:registry`, falling back to the generic `registry` key only when the scope key is unset. `--registry` sets the generic key, so it loses to any scope mapping. Publishing the placeholder with `--registry https://registry.npmjs.org` sent it to GitHub Packages, because a developer .npmrc maps the @matchory scope there. Both `publishConfig.registry` and `--registry` were outranked. `--@matchory:registry=` sets the key that actually wins. Verified against all three combinations by dry-run: `--registry` alone does not retarget, the scope override does. This matters beyond tidiness because libnpmpublish never validates the destination registry, so a provenance-signed release could have landed somewhere unintended and npm would have signed it regardless. Replaced the previous config-inspection warning with a real gate: a dry-run publish using the exact flags, asserting the reported target is npmjs. That tests the resolved behaviour instead of guessing at precedence, which is the mistake that caused this. The stray 0.0.0 in GitHub Packages is harmless in itself, but it exposed a design problem that needs a decision: @matchory/ui is on GitHub Packages while @matchory/coding-style is headed for npmjs, and npm registry configuration is per-scope with no per-package override. A consuming repository therefore cannot take one @matchory package from each. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CEdTd43qLEEE5qCsL1A7gW --- .github/workflows/release.yml | 36 +++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d1c44ff..b2b29c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -344,7 +344,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 +366,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 +401,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 }}' From c5caad45c2ba0f8a454dc8ba7c3118b7cfa36fb0 Mon Sep 17 00:00:00 2001 From: Moritz Mazetti Date: Thu, 30 Jul 2026 14:52:02 +0200 Subject: [PATCH 2/2] feat(release): publish the npm package to both registries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm resolves a registry per scope and offers no per-package override. `@matchory/ui` is proprietary and lives on GitHub Packages, so any repository consuming it maps the whole `@matchory` scope there — and would never find this package on npmjs. Publishing to one registry only would have meant every internal consumer either cannot install this package or must stop consuming `@matchory/ui` the way it does today. So the same attested tarball goes to both. Byte-identical, with the SHA-256 verified before each upload, which is what makes "identical" checkable rather than asserted. npmjs stays canonical: public, and the copy that carries the provenance statement. GitHub Packages authenticates with the job's GITHUB_TOKEN, so dual publishing introduces no new credential, and runs in the same gated `release` environment behind the same approval. --provenance is deliberately omitted for GitHub Packages. It neither displays nor verifies npm provenance statements, so a second statement there would be a claim nobody can check; the shared GitHub artefact attestation covers both copies. Verified the retargeting empirically in both directions rather than trusting precedence rules, having already been wrong about them once: the scope override sends the publish to GitHub Packages (proved by the "cannot publish over previously published 0.0.0" conflict, since the misdirected placeholder is there) and to npmjs (clean dry-run). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CEdTd43qLEEE5qCsL1A7gW --- .github/workflows/release.yml | 58 ++++++++++++++++++++++++++++++++++- CHANGELOG.md | 4 +++ README.md | 12 ++++++-- SECURITY.md | 9 ++++++ js/README.md | 3 ++ 5 files changed, 83 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2b29c3..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. @@ -409,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] @@ -440,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';