Skip to content
Merged
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
31 changes: 27 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -310,18 +310,21 @@ jobs:
# Deliberately a GitHub-hosted runner, not the resolved one: npm trusted publishing only works from GitHub-hosted runners, so the OIDC token exchange cannot happen on the self-hosted fleet.
runs-on: ubuntu-latest
timeout-minutes: 15
# The deploy key lives in this environment, whose deployment branches are limited to main, so no other branch's workflow can read it.
environment: release
permissions:
contents: write # to push the release commit and tag and create the GitHub Release
contents: write # to create the GitHub Release; the release commit and tags are pushed over SSH with the deploy key
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 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 the release commit and tags are pushed over SSH with a deploy key, which a ruleset can name as a bypass actor. The key lives in the `release` environment, whose deployment branches are limited to main.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# semantic-release analyses the full commit history since the last release.
fetch-depth: 0
# Credentials stay persisted: main has no ruleset, so semantic-release pushes the release commit and tag to https with this job's own GITHUB_TOKEN.
persist-credentials: true # zizmor: ignore[artipacked]
# Credentials are not persisted: checkout fetches with the job's own token and then removes it, leaving nothing in git config for later steps. The deploy key is provisioned apart, below.
persist-credentials: false
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
Expand All @@ -335,11 +338,31 @@ jobs:
- name: Upgrade npm for OIDC trusted publishing (needs npm CLI >=11.5.1)
# 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@11.20.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 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"
git remote set-url origin "git@github.com:$GITHUB_REPOSITORY.git"
- name: Release
# HUSKY=0 so the commit-msg hook never fires against the automated release commit.
run: HUSKY=0 pnpm exec semantic-release
# semantic-release pushes to its repositoryUrl, which defaults to package.json's https repository field rather than to origin, so the SSH URL is passed explicitly for the push to use the deploy key.
run: HUSKY=0 pnpm exec semantic-release --repository-url "git@github.com:$GITHUB_REPOSITORY.git"
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"