diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc668e9a..1177249e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -381,7 +381,7 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} sha: ${{ steps.pr.outputs.sha }} - is_fork: ${{ steps.pr.outputs.is_fork }} + reason: ${{ steps.pr.outputs.reason }} steps: - name: React to comment env: @@ -391,27 +391,43 @@ 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. 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: 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') - is_fork="false" + reject() { + echo "reason=$1" >> "$GITHUB_OUTPUT" + echo "::error::$1" + exit 1 + } if [ "$head_repo" != "$base_repo" ]; then - is_fork="true" - echo "::warning::Snapshot requested on fork PR (head: ${head_repo}) — publish will be skipped." + reject "Snapshots are restricted to branches within ${base_repo} (PR head: ${head_repo})." + fi + 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" - 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 +445,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 +452,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 +461,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 +473,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 +481,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 @@ -478,9 +489,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: @@ -565,10 +590,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)