From 378223d42a7225315bc2f724edf8b8599394530d Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Mon, 24 Aug 2026 15:11:54 +0100 Subject: [PATCH 1/3] ci(release): reject fork PRs before checking out snapshot code The !snapshot job warned on fork PRs but still checked out the fork's head and ran bun install + the versioning script in the default-branch context with a write-capable GITHUB_TOKEN; is_fork only gated the downstream jobs. Fail before checkout instead, add the same PR freshness guard as clerk/javascript, and stop persisting credentials on the checkout. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc668e9a..6fdbd13d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -381,7 +381,6 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} sha: ${{ steps.pr.outputs.sha }} - is_fork: ${{ steps.pr.outputs.is_fork }} steps: - name: React to comment env: @@ -391,27 +390,36 @@ jobs: gh api "repos/${GH_REPO}/issues/comments/${{ github.event.comment.id }}/reactions" \ -f content=eyes --silent - - name: Get PR head SHA + # Runs before checkout: this job executes the PR's package.json scripts + # in the default branch's privileged context, so fork code must never be + # fetched. Also reject PRs that changed after the comment was posted so + # the reviewer approved the code that actually runs. + - name: Validate PR source and freshness id: pr env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} + COMMENT_CREATED_AT: ${{ github.event.comment.created_at }} run: | pr_json=$(gh api "repos/${GH_REPO}/pulls/${{ github.event.issue.number }}") sha=$(echo "$pr_json" | jq -r '.head.sha') head_repo=$(echo "$pr_json" | jq -r '.head.repo.full_name') base_repo=$(echo "$pr_json" | jq -r '.base.repo.full_name') - is_fork="false" + pr_updated_at=$(echo "$pr_json" | jq -r '.updated_at') if [ "$head_repo" != "$base_repo" ]; then - is_fork="true" - echo "::warning::Snapshot requested on fork PR (head: ${head_repo}) — publish will be skipped." + echo "::error::Snapshots are restricted to branches within ${base_repo} (PR head: ${head_repo})." + exit 1 + fi + if [[ "$pr_updated_at" > "$COMMENT_CREATED_AT" ]]; then + echo "::error::The PR has been updated since !snapshot was posted. Review the changes and comment !snapshot again." + exit 1 fi echo "sha=${sha}" >> "$GITHUB_OUTPUT" - echo "is_fork=${is_fork}" >> "$GITHUB_OUTPUT" - uses: actions/checkout@v7 with: ref: ${{ steps.pr.outputs.sha }} + persist-credentials: false - uses: oven-sh/setup-bun@v2 - run: bun install --frozen-lockfile @@ -429,7 +437,6 @@ jobs: snapshot-ci: needs: snapshot - if: needs.snapshot.outputs.is_fork != 'true' uses: ./.github/workflows/ci.yml with: ref: ${{ needs.snapshot.outputs.sha }} @@ -437,7 +444,6 @@ jobs: snapshot-build: needs: [snapshot, snapshot-ci] - if: needs.snapshot.outputs.is_fork != 'true' uses: ./.github/workflows/build-binaries.yml with: version: ${{ needs.snapshot.outputs.version }} @@ -447,7 +453,6 @@ jobs: snapshot-sign-macos: needs: [snapshot, snapshot-build] - if: needs.snapshot.outputs.is_fork != 'true' uses: ./.github/workflows/sign-macos.yml with: artifact-prefix: clerk-snapshot @@ -460,7 +465,6 @@ jobs: snapshot-smoke-test: needs: [snapshot, snapshot-build, snapshot-sign-macos] - if: needs.snapshot.outputs.is_fork != 'true' uses: ./.github/workflows/smoke-test.yml with: version: ${{ needs.snapshot.outputs.version }} @@ -469,7 +473,6 @@ jobs: snapshot-publish: needs: [snapshot, snapshot-build, snapshot-sign-macos, snapshot-smoke-test] - if: needs.snapshot.outputs.is_fork != 'true' # Must run on GitHub-hosted runner for npm OIDC trusted publishing runs-on: ubuntu-latest timeout-minutes: 15 From e548e59523f6886dd80c61429b530ee5bbae8494 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Mon, 24 Aug 2026 15:31:52 +0100 Subject: [PATCH 2/3] ci(release): address snapshot review feedback - Key the freshness gate on the head commit's committer date instead of the PR's updated_at, which moves on comments, labels, and reviews and would fail the run for reasons unrelated to the code. - Re-check the fork boundary inside snapshot-publish before checkout; that job mints the npm OIDC token and shouldn't depend on an upstream step for it. Also stop persisting credentials on its checkout. - Surface the rejection reason in the failure PR comment instead of only in the run log. - Document both gates in docs/releasing.md. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 43 +++++++++++++++++++++++++++-------- docs/releasing.md | 2 +- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fdbd13d..0de40405 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -381,6 +381,7 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} sha: ${{ steps.pr.outputs.sha }} + reason: ${{ steps.pr.outputs.reason }} steps: - name: React to comment env: @@ -392,27 +393,32 @@ jobs: # Runs before checkout: this job executes the PR's package.json scripts # in the default branch's privileged context, so fork code must never be - # fetched. Also reject PRs that changed after the comment was posted so - # the reviewer approved the code that actually runs. + # fetched. Also reject PRs whose head commit is newer than the comment so + # the reviewer approved the code that actually runs. Keyed on the commit + # date rather than the PR's updated_at, which moves on comments/labels. - name: Validate PR source and freshness id: pr env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} COMMENT_CREATED_AT: ${{ github.event.comment.created_at }} + LC_ALL: C run: | pr_json=$(gh api "repos/${GH_REPO}/pulls/${{ github.event.issue.number }}") sha=$(echo "$pr_json" | jq -r '.head.sha') head_repo=$(echo "$pr_json" | jq -r '.head.repo.full_name') base_repo=$(echo "$pr_json" | jq -r '.base.repo.full_name') - pr_updated_at=$(echo "$pr_json" | jq -r '.updated_at') - if [ "$head_repo" != "$base_repo" ]; then - echo "::error::Snapshots are restricted to branches within ${base_repo} (PR head: ${head_repo})." + reject() { + echo "reason=$1" >> "$GITHUB_OUTPUT" + echo "::error::$1" exit 1 + } + if [ "$head_repo" != "$base_repo" ]; then + reject "Snapshots are restricted to branches within ${base_repo} (PR head: ${head_repo})." fi - if [[ "$pr_updated_at" > "$COMMENT_CREATED_AT" ]]; then - echo "::error::The PR has been updated since !snapshot was posted. Review the changes and comment !snapshot again." - exit 1 + head_committed_at=$(gh api "repos/${GH_REPO}/commits/${sha}" --jq '.commit.committer.date') + if [[ "$head_committed_at" > "$COMMENT_CREATED_AT" ]]; then + reject "The PR head moved after \`!snapshot\` was posted (${sha:0:7} committed at ${head_committed_at}). Review the changes and comment \`!snapshot\` again." fi echo "sha=${sha}" >> "$GITHUB_OUTPUT" @@ -481,9 +487,23 @@ jobs: pull-requests: write id-token: write steps: + # Independent of the snapshot job's check: this job mints the npm OIDC + # token, so it must not rely on an upstream step for the fork boundary. + - name: Verify PR head is not a fork + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + pr_json=$(gh api "repos/${GH_REPO}/pulls/${{ github.event.issue.number }}") + if [ "$(echo "$pr_json" | jq -r '.head.repo.full_name')" != "$(echo "$pr_json" | jq -r '.base.repo.full_name')" ]; then + echo "::error::Refusing to publish from a fork PR." + exit 1 + fi + - uses: actions/checkout@v7 with: ref: ${{ needs.snapshot.outputs.sha }} + persist-credentials: false - uses: oven-sh/setup-bun@v2 - uses: actions/setup-node@v7 with: @@ -568,10 +588,15 @@ jobs: GH_REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.issue.number }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REASON: ${{ needs.snapshot.outputs.reason }} run: | { echo '## Snapshot failed' echo '' - echo "The snapshot publish workflow failed. [View the workflow run](${RUN_URL}) for details." + if [ -n "$REASON" ]; then + echo "$REASON" + echo '' + fi + echo "[View the workflow run](${RUN_URL}) for details." } > /tmp/comment-body.md gh pr comment "${PR_NUMBER}" --repo "${GH_REPO}" --body-file /tmp/comment-body.md diff --git a/docs/releasing.md b/docs/releasing.md index 0d41a0a0..da72ed8d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -53,7 +53,7 @@ Install via npm: `npm install -g clerk@canary` ### Snapshot (`@snapshot`) -Published on-demand from PR branches by commenting `!snapshot` (or `!snapshot `) on a pull request. The commenter must be a member or owner of the repository's organization. `scripts/snapshot.ts` uses Changesets snapshot mode to produce versions in the format `x.y.z-.v` (e.g., `0.0.1-snapshot.v20260313145959` or `0.0.1-my-feature.v20260313145959`). The datetime format ensures multiple snapshots from the same PR sort monotonically in semver. +Published on-demand from PR branches by commenting `!snapshot` (or `!snapshot `) on a pull request. The commenter must be a member or owner of the repository's organization, the PR branch must live in `clerk/cli` (fork PRs are rejected before any code is checked out), and the PR's head commit must predate the `!snapshot` comment — if the branch moves after you comment, the run fails and you comment again once you've reviewed the new code. `scripts/snapshot.ts` uses Changesets snapshot mode to produce versions in the format `x.y.z-.v` (e.g., `0.0.1-snapshot.v20260313145959` or `0.0.1-my-feature.v20260313145959`). The datetime format ensures multiple snapshots from the same PR sort monotonically in semver. Install: `npm install -g clerk@` (version is posted as a PR comment after publishing) From 19c82515d59f98c7ccd347435c9068d4b9f79e75 Mon Sep 17 00:00:00 2001 From: Dominic Couture Date: Mon, 24 Aug 2026 15:38:19 +0100 Subject: [PATCH 3/3] ci(release): clarify that the freshness gate is not a trust boundary Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0de40405..1177249e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -393,9 +393,11 @@ jobs: # Runs before checkout: this job executes the PR's package.json scripts # in the default branch's privileged context, so fork code must never be - # fetched. Also reject PRs whose head commit is newer than the comment so - # the reviewer approved the code that actually runs. Keyed on the commit - # date rather than the PR's updated_at, which moves on comments/labels. + # fetched. The fork check plus the MEMBER/OWNER gate above are the trust + # boundary. The freshness check is only a race guard for the honest case + # (a colleague pushes between the comment and this step); it keys on the + # commit date, which is client-settable, and that is acceptable because + # pushing to a same-repo branch already requires write access. - name: Validate PR source and freshness id: pr env: