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
30 changes: 24 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -203,28 +203,46 @@ jobs:
pull-requests: write # to comment on released pull requests
id-token: write # OIDC identity for npm trusted publishing and provenance (no NPM_TOKEN)
steps:
# The default GITHUB_TOKEN cannot bypass a repository ruleset (GitHub does not support naming the github-actions[bot] identity as a bypass actor), so semantic-release's release commit and tag push needs its own bypass identity. A deploy key with write access, listed as a DeployKey bypass actor on the main ruleset, is the lightest one: repository-scoped, with no GitHub App installation and no personal or organisation-admin credential. checkout@v7 writes the key into core.sshCommand, where later steps' git commands find it, only when persist-credentials is true (with it false the key exists only as a step-scoped GIT_SSH_COMMAND export that no later step inherits, so semantic-release finds no key for the SSH repositoryUrl, falls back to an https URL with GITHUB_TOKEN embedded, and the ruleset rejects that push). release.config.ts's SSH-form repositoryUrl selects the SSH push; persisting credentials arms it.
# The default GITHUB_TOKEN cannot bypass a repository ruleset (GitHub does not support naming the github-actions[bot] identity as a bypass actor), so semantic-release's release commit and tag push needs its own bypass identity. A deploy key with write access, listed as a DeployKey bypass actor on the main ruleset, is the lightest one: repository-scoped, with no GitHub App installation and no personal or organisation-admin credential. Because the key can push straight to main, it exists on the runner only for the last two steps: checkout does not receive it, so it is not in git config or on disk while the setup actions, the install and the npm upgrade run. That is the whole gain. Those earlier steps can still change PATH, the environment or installed code (node_modules, global npm) that later runs with the key present, so this narrows exposure to code that merely reads the secret when it runs and does not isolate the key from a compromised earlier step. Full isolation needs the install separated from the credential.
- uses: actions/checkout@v7
with:
# semantic-release analyses the full commit history since the last release.
fetch-depth: 0
ssh-key: ${{ secrets.RELEASE_DEPLOY_KEY }}
# true on purpose (see the comment above): false leaves semantic-release's push with no SSH key, and the ruleset rejects the GITHUB_TOKEN fallback. The persisted token extraheader is inert for this push, which goes to the SSH URL, not an https:// one.
persist-credentials: true
# The deploy key is deliberately not passed here, and credentials are not persisted: checkout fetches with the job's own token and then removes it, leaving nothing in git config for later steps.
persist-credentials: false
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: '22'
cache: pnpm
# registry-url is deliberately absent: setup-node would otherwise write an .npmrc _authToken line that wins over the OIDC token exchange, breaking trusted publishing.
- run: pnpm install --frozen-lockfile
# Pinned to an explicit release rather than npm@latest, so a newly published (or compromised) latest never runs in the job that holds the OIDC token. Trusted publishing needs npm CLI >=11.5.1; raise the pin deliberately, choosing a release that is at least a week old.
- name: Upgrade npm for OIDC trusted publishing (needs npm CLI >=11.5.1)
run: npm install -g npm@latest
run: npm install -g npm@11.20.0
# The last step before Release and the only place the key is provisioned: written to the runner's temp directory with mode 600 from the step's env (never a command line), together with GitHub's ed25519 host key, pinned so the connection is verified rather than trusted on first use (fingerprint SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU, as published at https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints). The step then proves the key has write access to the repository, because semantic-release falls back to an https URL with GITHUB_TOKEN embedded when its own SSH push check fails, and the ruleset would reject that push only after the release had started. The dry run targets a ref name that does not exist, so it can never be rejected as non-fast-forward when main has moved on since checkout (semantic-release itself skips that case cleanly, and the step must not turn it into a failure). GitHub does not evaluate rulesets on a dry run, so this does not prove the key is a bypass actor. BASH_ENV is blanked and ssh and git are called by absolute path so that a variable or PATH entry set by an earlier step does not redirect them.
- name: Provision the deploy key for the Release step
env:
RELEASE_DEPLOY_KEY: ${{ secrets.RELEASE_DEPLOY_KEY }}
# Blanked so a BASH_ENV exported by an earlier step cannot run in the shell that holds the key.
BASH_ENV: ''
run: |
install -d -m 700 "$RUNNER_TEMP/release-ssh"
(umask 077 && printf '%s\n' "${RELEASE_DEPLOY_KEY%$'\n'}" > "$RUNNER_TEMP/release-ssh/id")
printf '%s\n' 'github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl' > "$RUNNER_TEMP/release-ssh/known_hosts"
# Must match the GIT_SSH_COMMAND of the Release step.
GIT_SSH_COMMAND="/usr/bin/ssh -i $RUNNER_TEMP/release-ssh/id -o IdentitiesOnly=yes -o UserKnownHostsFile=$RUNNER_TEMP/release-ssh/known_hosts -o StrictHostKeyChecking=yes" \
/usr/bin/git push --dry-run --no-verify "git@github.com:$GITHUB_REPOSITORY.git" "HEAD:refs/heads/release-key-probe-$GITHUB_RUN_ID"
- name: Release
# HUSKY=0 so the commit-msg hook never fires against the automated release commit.
# HUSKY=0 so the commit-msg hook never fires against the automated release commit. GIT_SSH_COMMAND is set on this step alone, so semantic-release's push to the SSH repositoryUrl authenticates with the deploy key.
run: HUSKY=0 pnpm exec semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Must match the GIT_SSH_COMMAND of the provisioning step.
GIT_SSH_COMMAND: /usr/bin/ssh -i ${{ runner.temp }}/release-ssh/id -o IdentitiesOnly=yes -o UserKnownHostsFile=${{ runner.temp }}/release-ssh/known_hosts -o StrictHostKeyChecking=yes
# Blanked, not omitted: an inherited NPM_TOKEN or NODE_AUTH_TOKEN would otherwise be used in preference to the OIDC exchange.
NPM_TOKEN: ''
NODE_AUTH_TOKEN: ''
- name: Remove the deploy key
if: always()
run: rm -rf "$RUNNER_TEMP/release-ssh"
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,6 @@ CI also runs `pnpm exec publint` and `pnpm exec attw --pack` after the build. It

Both cosmiconfig majors are installed as dev dependencies under the aliases `cosmiconfig-9` and `cosmiconfig-10`, so `src/interop.integration.test.ts` exercises the loader and transform against each. `test/source` holds tests relocated from the project this package was extracted from, together with the domain they exercise, adapted onto the public API. They are the compatibility check for `extends` semantics.

CI selects its runner with `ExaDev/runner-fallback-action` (self-hosted fleet first, Blacksmith as fallback). The release job stays on a GitHub-hosted runner because npm trusted publishing needs one, and publishes with provenance through OIDC, with no stored token. Its checkout uses a repository deploy key (the `RELEASE_DEPLOY_KEY` secret) and `release.config.ts` sets an SSH `repositoryUrl`, so semantic-release pushes the release commit and tag over SSH as a deploy key, which a ruleset on `main` can list as a bypass actor; the default `GITHUB_TOKEN` cannot bypass a ruleset.
CI selects its runner with `ExaDev/runner-fallback-action` (self-hosted fleet first, Blacksmith as fallback). The release job stays on a GitHub-hosted runner because npm trusted publishing needs one, and publishes with provenance through OIDC, with no stored token. The repository deploy key (the `RELEASE_DEPLOY_KEY` secret) is provisioned only for the semantic-release step: checkout gets no key and persists no credentials, and the key is written to the runner's temp directory just before that step, used over SSH with GitHub's pinned host key, and deleted afterwards. That keeps it out of git config and off disk while checkout, the setup actions, the install and the npm upgrade run, but code those earlier steps installed or configured (PATH, environment, `node_modules`, global npm) still runs with the key present, so it is not isolated from a compromised earlier step. `release.config.ts` sets an SSH `repositoryUrl`, so semantic-release pushes the release commit and tag as the deploy key, which a ruleset on `main` can list as a bypass actor; the default `GITHUB_TOKEN` cannot bypass a ruleset.

Commits follow [Conventional Commits](https://www.conventionalcommits.org); semantic-release derives the version from them.
2 changes: 1 addition & 1 deletion release.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const commitTypes: readonly CommitType[] = [
*/
const config: Options = {
branches: ['main'],
// Deliberately the SSH form, not package.json's own git+https:// repository field (that field stays https://; it is public consumer-facing metadata, unrelated to how this release pushes). semantic-release only embeds an x-access-token:$GITHUB_TOKEN@ credential into an https:// repositoryUrl, and the default GITHUB_TOKEN it would embed cannot bypass main's branch ruleset, so the push is rejected. An SSH URL skips that embedding, so the push authenticates with the deploy key the Release job's checkout wires into core.sshCommand (checkout@v7 writes that config only while persist-credentials is true; see .github/workflows/ci.yml), which the ruleset lists as a DeployKey bypass actor.
// Deliberately the SSH form, not package.json's own git+https:// repository field (that field stays https://; it is public consumer-facing metadata, unrelated to how this release pushes). semantic-release first tries to push to the repositoryUrl as given, and only if that fails rewrites it to https with the GITHUB_TOKEN embedded; the default GITHUB_TOKEN cannot bypass main's branch ruleset, so that fallback push would be rejected. The push as given authenticates with the deploy key the Release job provisions through GIT_SSH_COMMAND for the Release step alone (see .github/workflows/ci.yml), which the ruleset lists as a DeployKey bypass actor.
repositoryUrl: 'git@github.com:ExaDev/cosmiconfig-extends.git',
plugins: [
[
Expand Down