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
99 changes: 93 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ jobs:
actions: read
outputs:
version: ${{ steps.version.outputs.version }}
dist_tag: ${{ steps.version.outputs.dist_tag }}
prerelease: ${{ steps.version.outputs.prerelease }}
steps:
- name: Checkout
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
Expand All @@ -50,7 +52,7 @@ jobs:
persist-credentials: false

- id: version
name: Resolve version from the tag
name: Resolve version and dist-tag from the tag
run: |
version="${GITHUB_REF_NAME#v}"

Expand All @@ -59,7 +61,25 @@ jobs:
exit 1
fi

echo "version=${version}" >> "$GITHUB_OUTPUT"
# A prerelease must NOT become the default install for every consumer, which is what
# publishing it under the `latest` dist-tag would do. Derive the channel from the prerelease
# identifier: 1.0.0-rc.1 -> rc, 1.0.0-beta.2 -> beta, anything unrecognised -> next.
if printf '%s' "${version}" | grep -q -- '-'; then
channel="$(printf '%s' "${version#*-}" | cut -d. -f1 | tr -cd '[:alpha:]')"
[ -n "${channel}" ] || channel='next'
prerelease=true
else
channel='latest'
prerelease=false
fi

{
echo "version=${version}"
echo "dist_tag=${channel}"
echo "prerelease=${prerelease}"
} >> "$GITHUB_OUTPUT"

echo "Releasing ${version} to dist-tag '${channel}' (prerelease: ${prerelease})"

# An unsigned tag is an unauthenticated instruction to publish. GitHub's own verification
# status is used rather than importing keys here, so the check reflects the same trust GitHub
Expand Down Expand Up @@ -288,11 +308,14 @@ jobs:
with:
persist-credentials: false

# npm's documented workflow uses setup-node's `registry-url`. It is omitted here because the
# publish command pins `--registry` explicitly, which is strictly stronger: it cannot be
# overridden by an ambient .npmrc, whereas `publishConfig.registry` and setup-node's config can
# both lose to an `@scope:registry` entry.
- name: Set up Node
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: 24
registry-url: https://registry.npmjs.org

- name: Pin npm
run: npm install -g "npm@${NPM_VERSION}"
Expand All @@ -308,14 +331,71 @@ jobs:
cd dist
sha256sum --check ./*.sha256

# No NODE_AUTH_TOKEN: authentication is the OIDC token minted for the `release` environment,
# which npmjs is configured to accept for this package and nothing else.
# npm will not let a trusted publisher be attached to a package that does not exist yet, so the
# very first publish has to authenticate with a token. That path is deliberately single-use:
# once the package exists, a token becomes a hard failure rather than a quiet fallback, which is
# what stops the bootstrap credential from silently becoming permanent.
#
# Provenance is unaffected either way. `--provenance` is generated from this job's OIDC identity
# token via Sigstore and is independent of how npm authenticates, so v0.1.0 is fully attested
# even though it predates trusted publishing.
- name: Decide the authentication path
id: auth
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if npm view @matchory/coding-style version --registry https://registry.npmjs.org > /dev/null 2>&1; then
exists=true
else
exists=false
fi

if [ -n "${NPM_TOKEN}" ] && [ "${exists}" = 'true' ]; then
echo "::error::@matchory/coding-style already exists on npmjs, so trusted publishing can be configured. Delete the NPM_TOKEN secret from the 'release' environment and revoke the token; this workflow will then authenticate via OIDC."
exit 1
fi

if [ -n "${NPM_TOKEN}" ]; then
echo 'method=token' >> "$GITHUB_OUTPUT"
echo "::warning::Bootstrapping the first publish with a token because the package does not exist yet. Immediately afterwards: configure the trusted publisher on npmjs, set publishing access to 'Require trusted publishing', then delete the NPM_TOKEN secret and revoke the token. The next release will fail until you do."
elif [ "${exists}" = 'false' ]; then
echo "::error::@matchory/coding-style does not exist on npmjs and no NPM_TOKEN is set. npm cannot attach a trusted publisher to a non-existent package, so the first publish needs a short-lived granular token in the 'release' environment. See SECURITY.md."
exit 1
else
echo 'method=oidc' >> "$GITHUB_OUTPUT"
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
run: |
resolved="$(npm config get @matchory:registry)"

if [ "${resolved}" != 'undefined' ] && [ "${resolved#https://registry.npmjs.org}" = "${resolved}" ]; then
echo "::warning::@matchory:registry resolves to ${resolved}; --registry overrides it for this publish"
fi

- name: Publish
env:
# Empty on every release after the first, in which case npm authenticates via OIDC.
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ '${{ steps.auth.outputs.method }}' = 'token' ]; then
npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}"
fi

npm publish ./dist/*.tgz \
--registry https://registry.npmjs.org \
--provenance \
--access public \
--tag latest
--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-pypi:
name: Publish to PyPI
Expand Down Expand Up @@ -380,8 +460,15 @@ jobs:
sed -n "/^## \[${VERSION}\]/,/^## \[/p" CHANGELOG.md | sed '$d'
} > notes.md

prerelease_flag=''
if [ '${{ needs.verify.outputs.prerelease }}' = 'true' ]; then
prerelease_flag='--prerelease'
fi

# shellcheck disable=SC2086
gh release create "${GITHUB_REF_NAME}" \
--title "${GITHUB_REF_NAME}" \
--notes-file notes.md \
--verify-tag \
${prerelease_flag} \
artefacts/*
85 changes: 71 additions & 14 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ or by repository configuration, not by convention.

| Control | What it prevents |
|---|---|
| **No long-lived registry credentials.** npm and PyPI both authenticate via OIDC trusted publishing. | There is no token in this repository, its secrets, or a maintainer's keychain to steal. |
| **No registry credentials at all.** npm and PyPI both authenticate via OIDC trusted publishing. npm's package-creation problem was solved with a deprecated placeholder rather than a CI token. | There is no token in this repository or its secrets to steal, at any point in its history. |
| **`release` environment, restricted to `v*` tags.** | A workflow run from a branch or pull request cannot obtain an OIDC token whose environment claim the registries accept. |
| **`release` environment requires a human approval.** | An automated or accidental tag push cannot publish unattended. |
| **Signed, annotated tags only.** The workflow rejects a lightweight tag and asks GitHub whether the signature verifies. | An attacker with push access but no signing key cannot trigger a release. |
Expand All @@ -66,6 +66,8 @@ or by repository configuration, not by convention.
| **`--ignore-scripts` when installing to build.** | A dependency lifecycle hook executing on the machine that produces a published artefact. |
| **Lockfile enforced** (`--frozen-lockfile`). | A resolution change slipping in between review and release. |
| **Version agreement checked** across all three manifests and the tag. | Publishing artefacts nobody can correlate across ecosystems. |
| **`--registry` pinned on the publish command.** | An ambient `.npmrc` redirecting a provenance-signed release to another registry. `publishConfig.registry` does *not* beat an `@scope:registry` entry, and npm's provenance code does not validate the destination registry. |
| **dist-tag derived from the version.** A prerelease publishes to `rc`/`beta`/`next`, never `latest`. | A prerelease silently becoming the default install for every consumer. |

## Repository configuration

Expand Down Expand Up @@ -97,6 +99,11 @@ Stated explicitly rather than left for someone to discover:

- **Composer has no artefact provenance.** See above. The mitigation is repository protection plus
signed tags.
- **`@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
under the `latest` dist-tag. Every version anyone would actually install is published through OIDC
with provenance. No npm token has ever been stored in this repository.
- **A single maintainer can both author and approve a release.** `prevent_self_review` is off on the
`release` environment because the team is small enough that enabling it would block releases
entirely. Adding a second reviewer is the fix, and is a people problem rather than a configuration
Expand All @@ -108,6 +115,13 @@ Stated explicitly rather than left for someone to discover:
important half: `refs/tags/v*` cannot be deleted, updated, force-pushed, or created unsigned, so a
published version tag cannot be moved to different code. Confirm the GitHub-native setting in
Settings → General as well, if it is available to the organisation.
- **The build/publish split is not npm's documented shape.** npm's recommended workflow publishes from
the source directory in a single job; this one builds a tarball, attests it, and publishes that
artefact from a separate job. The split was kept because it is what makes "publish cannot substitute
content" true, and because it matches PyPA's recommendation for PyPI. It was verified against npm's
implementation rather than assumed: `libnpmpublish` derives the provenance subject from the tarball's
own integrity digest and the build metadata from GitHub Actions environment variables, so no source
directory is required and provenance is generated identically either way.
- **No egress filtering on runners.** `step-security/harden-runner` is the usual recommendation and
would detect a build step phoning home. It is deliberately absent: adding a third-party action to
the publish path widens the trusted set on exactly the job where that matters most. Worth revisiting
Expand All @@ -132,19 +146,62 @@ PyPI supports *pending* publishers, so this works before the project exists:

### npm

1. Publish or claim `@matchory/coding-style`, then open its settings on npmjs.com.
2. Under **Trusted publisher**, select GitHub Actions and enter:
- Organisation/repository: `matchory/coding-style`
- Workflow: `release.yml`
- Environment: `release`
3. Set **Publishing access** to *Require trusted publishing*, which disallows token-based publishes
entirely.
4. Enable **Require two-factor authentication** for the package.

npm may require the package to exist before a trusted publisher can be attached. If so, the first
publish needs a granular automation token, scoped to this package only, with the shortest available
expiry — then configure trusted publishing and delete the token. Check the current npm documentation
before assuming either way.
npm cannot configure a trusted publisher for a package that does not exist, and offers no way to
reserve a name — there is no equivalent of PyPI's pending publishers. The package therefore has to
exist before OIDC can be used at all.

That gap is crossed with a **deprecated `0.0.0` placeholder**, published manually, so that no token
ever enters this repository and the first version anyone installs (`0.1.0`) is published entirely
through OIDC with provenance.

#### Step 1 — publish the placeholder

From a maintainer machine, `npm login` first: the `--registry` flag is required because an
`@matchory:registry` entry in a developer `.npmrc` points the scope at GitHub Packages and beats
`publishConfig.registry`.

```bash
npm login --registry https://registry.npmjs.org
```

Then publish a package containing nothing but a README and the licence, under a dist-tag that is not
`latest`:

```bash
npm publish --registry https://registry.npmjs.org --access public --tag placeholder
```

#### Step 2 — configure the trusted publisher

At `https://www.npmjs.com/package/@matchory/coding-style/access` — not the account-level packages page:

- Organisation/repository: `matchory/coding-style`
- Workflow: `release.yml`
- Environment: `release`

Then set **Publishing access** to *Require trusted publishing*, which disallows token-based publishing
outright, and enable **Require two-factor authentication** for the package.

#### Step 3 — release normally

Tag `v0.1.0`. The workflow sees that the package exists and that no `NPM_TOKEN` secret is set, so it
authenticates via OIDC. `0.1.0` becomes `latest`.

#### Step 4 — tidy up

```bash
npm deprecate @matchory/coding-style@0.0.0 "Placeholder with no contents; install 0.1.0 or later."
npm dist-tag rm @matchory/coding-style placeholder
```

#### The token path exists but is a fallback

`release.yml` still accepts an `NPM_TOKEN` secret on the `release` environment, for the case where a
future package has to be bootstrapped the other way. It is self-limiting: the workflow **refuses to
use a token once the package exists on npmjs**, so a bootstrap credential cannot quietly become
permanent. If you ever use it, make it a granular token scoped to `@matchory` with the shortest
available expiry, store it as an environment secret rather than a repository one, and delete it
immediately afterwards — the next release fails until you do.

### Packagist

Expand Down
Loading