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
32 changes: 28 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,18 +184,19 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: write # to push the release commit and tags, and create a GitHub Release per package
contents: write # to create a GitHub Release per package; the release commit and tags are pushed with the deploy key, not this token
issues: write # to comment on released issues
pull-requests: write # to comment on released pull requests
id-token: write # OIDC identity for npm trusted publishing (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 the orchestrator'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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# ref: main, not the bare event SHA: the orchestrator pushes to the current branch by name, so a detached HEAD stops the run with a WorkspaceStateError rather than pushing HEAD:HEAD. fetch-depth: 0 because each package's release range is analysed from its own last matching tag.
# ref: main, not the bare event SHA: the orchestrator pushes to the current branch by name, so a detached HEAD stops the run with a WorkspaceStateError rather than pushing HEAD:HEAD. fetch-depth: 0 because each package's release is analysed from its own last matching tag.
ref: main
fetch-depth: 0
# Left at its default: the orchestrator pushes its commit and tags with plain `git push origin`, which needs the checkout's credential (this job's GITHUB_TOKEN) to stay in the git config for the duration of the job.
persist-credentials: true
# The deploy key, not this job's token, is the push credential, so nothing is left in git config for later steps. @semantic-release/github calls the GitHub API with GITHUB_TOKEN from the Release step's env and needs no git credential.
persist-credentials: false
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
standalone: true
Expand All @@ -209,13 +210,36 @@ jobs:
# Pinned, not npm@latest: a moved dist-tag must not run new code in a job that goes on to hold a push credential. 11.5.1 is the first release with trusted publishing (OIDC); the pin is the newest release at least a week old when it was chosen, matching the install age gate, and its Node engine range includes the version in .tool-versions. Raise it deliberately.
# zizmor's adhoc-packages audit flags any install outside a lockfile; this one is a single exact version, chosen and raised deliberately as the comment above says, so it is ignored here rather than in the whole repository.
run: npm install -g npm@12.1.0 # zizmor: ignore[adhoc-packages]
# 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 authenticates with write access to the repository, so a missing or wrong key fails here, before the orchestrator starts. 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. 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 every package that changed, in dependency order
# One orchestrator run discovers the workspace from pnpm-workspace.yaml, orders the packages topologically and runs semantic-release per package, with commits path-filtered to that package's own directory and tags in `name@version` form. A package whose only change is a sibling's release still gets a patch release, because its published dependency range changed. HUSKY=0 so local git hooks never fire against the automated commits.
run: HUSKY=0 pnpm release
env:
# Used by @semantic-release/github for the GitHub API (releases, issue and pull request comments), never for git.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The orchestrator pushes with plain `git push --atomic origin HEAD:<branch> <tags>`, and the checkout's origin is the https URL. These three variables are git's own per-process config (GIT_CONFIG_COUNT and its numbered KEY and VALUE pairs): they rewrite every https GitHub URL to its SSH form for this step alone, so that push, and the fetches and ls-remote calls around it, authenticate with the deploy key through GIT_SSH_COMMAND, and nothing is written to the repository's config.
GIT_CONFIG_COUNT: "1"
GIT_CONFIG_KEY_0: url.git@github.com:.insteadOf
GIT_CONFIG_VALUE_0: https://github.com/
# 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
# Provenance for every package the run publishes, matching each package.json's own publishConfig.provenance.
NPM_CONFIG_PROVENANCE: "true"
# Blanked, not omitted: an inherited NPM_TOKEN or NODE_AUTH_TOKEN from a workflow-level env block, reusable workflow or composite action 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"
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Every publishable package versions and publishes on its own. `release-workspace.
- Each release is a `<name>@<version>` tag, a package-level `CHANGELOG.md` and a GitHub Release. The whole run's version bumps, changelog entries and dependency-range rewrites are committed once and pushed atomically with the tags before anything is published.
- When a package releases, every dependent's sibling range is rewritten to the new version, the lockfile is regenerated and the dependent releases a patch even if nothing else about it changed, so a published manifest never names a version the repository has moved past. Sibling dependencies are exact versions rather than the `workspace:` protocol for this reason: `npm publish` ships a specifier as written.
- Publishing uses npm trusted publishing (OIDC): `id-token: write` on the job, no `NPM_TOKEN`, and no `registry-url` on `setup-node`, because that writes a token line that wins over the OIDC exchange.
- The release commit and tags reach the protected `main` through a repository deploy key with write access (the `RELEASE_DEPLOY_KEY` secret), which the `main` ruleset lists as a bypass actor; `GITHUB_TOKEN` cannot bypass a ruleset. The `release` job checks out without persisting a credential, writes the key to the runner's temporary directory only in the step before the release, and removes it afterwards. The orchestrator pushes with plain `git push origin`, so the release step rewrites https GitHub URLs to SSH through git's per-process config and sets `GIT_SSH_COMMAND` to use only that key; `GITHUB_TOKEN` stays in that step's environment for the GitHub API calls that create releases and comment on issues and pull requests.

Release configuration lives only in `release-workspace.config.ts`. The orchestrator sets the tag format, plugins and analysis options on every per-package run, so a `release.config.*` inside a package would be overridden rather than honoured.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config

Each package versions and publishes independently through [`@exadev/semantic-release-workspace`](https://github.com/ExaDev/semantic-release-workspace), configured in `release-workspace.config.ts` and run by the `release` job in CI. A package releases when commits touch its own directory, or when a sibling it depends on releases; packages release in dependency order, so the entry point follows the packages it re-exports. Tags are `<name>@<version>`, and each package keeps its own `CHANGELOG.md`.

The release commit and tags are pushed to the protected `main` with a repository deploy key (the `RELEASE_DEPLOY_KEY` secret) over SSH, because `GITHUB_TOKEN` cannot bypass a ruleset; the `main` ruleset lists that deploy key as a bypass actor. `GITHUB_TOKEN` is still used for the GitHub API calls that create releases and comment on issues and pull requests.

A new package name must be reserved on npm, and its trusted publisher registered, by a person before its first CI publish; the steps are in CONTRIBUTING.md.

## Contributing
Expand Down
Loading