Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 81 additions & 13 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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:-<no target reported>}"

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:
Expand All @@ -389,14 +406,65 @@ 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 }}'

# 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]
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file> --repo matchory/coding-style`.
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading