From f242acb4d9451d5ce47c22017a4412efde506502 Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Thu, 10 Sep 2026 19:33:03 -0300 Subject: [PATCH] ci(review): gate the claude pairing and share the branch probe The claude checkout is executed, not merely read: workspace.sh runs from it, the .claude/ it syncs supplies hooks and settings that run outside the review's tool allowlist, and the procedure it carries becomes the agent's prompt. Selecting its ref by the PR head branch name therefore lowered the bar from landing a reviewed commit on claude@master to pushing any branch to claude, and a fork PR could name its branch after one. Only an admin author pairs now; for everyone else the checkout takes master, as before. That checkout also kept the app token in its working tree, and the token reaches two repos. The probe read every failure as "branch absent": an expired token or an API outage silently became master. It now warns on anything but 200 and 404. The resolution itself existed three times in this repo. The composite action is checked out from master rather than from the pull request, so a fork PR cannot rewrite the code the workflow runs before the review starts. --- .github/actions/paired-branch/action.yml | 91 +++++++++++++++++++ .github/workflows/claude-code-review.yml | 108 ++++++++++------------- .github/workflows/claude.yml | 38 ++++---- 3 files changed, 152 insertions(+), 85 deletions(-) create mode 100644 .github/actions/paired-branch/action.yml diff --git a/.github/actions/paired-branch/action.yml b/.github/actions/paired-branch/action.yml new file mode 100644 index 00000000000..78ff1136e70 --- /dev/null +++ b/.github/actions/paired-branch/action.yml @@ -0,0 +1,91 @@ +name: Paired branch +description: >- + Resolve the branch of another repository that pairs with this pull request — one named the same + as the PR head branch — falling back to a default when there is none. + +inputs: + repository: + description: Repository to look the branch up in, as owner/name. + required: true + head-ref: + description: >- + The PR head branch name. Pass an empty string to skip pairing and take the default; a caller + that gates pairing on authorization does that. + required: false + default: '' + pr-repository: + description: >- + Repository holding the pull request, as owner/name. Used to look the head branch up when + head-ref is empty because the event payload carries no head — an issue_comment event. + required: false + default: '' + pr-number: + description: Pull request number, read together with pr-repository. + required: false + default: '' + default-branch: + description: Branch to use when the paired branch does not exist or cannot be probed. + required: false + default: master + token: + description: Token authorising the branch probe. Required when the repository is private. + required: false + default: '' + gh-token: + description: Token for the gh CLI, used only when looking up the head branch name. + required: false + default: '' + +outputs: + ref: + description: The paired branch, or the default branch. + value: ${{ steps.resolve.outputs.ref }} + +runs: + using: composite + steps: + - id: resolve + shell: bash + env: + REPOSITORY: ${{ inputs.repository }} + HEAD_REF: ${{ inputs.head-ref }} + PR_REPOSITORY: ${{ inputs.pr-repository }} + PR_NUMBER: ${{ inputs.pr-number }} + DEFAULT_BRANCH: ${{ inputs.default-branch }} + TOKEN: ${{ inputs.token }} + GH_TOKEN: ${{ inputs.gh-token }} + run: | + BRANCH="$HEAD_REF" + + if [[ -z "$BRANCH" && -n "$PR_REPOSITORY" && -n "$PR_NUMBER" ]]; then + BRANCH=$(gh pr view "$PR_NUMBER" --repo "$PR_REPOSITORY" --json headRefName --jq '.headRefName') || { + echo "::warning::Could not read the head branch of $PR_REPOSITORY#$PR_NUMBER; using $DEFAULT_BRANCH" + BRANCH="" + } + fi + + if [[ -z "$BRANCH" ]]; then + echo "ref=$DEFAULT_BRANCH" >> "$GITHUB_OUTPUT" + exit 0 + fi + + AUTH=() + if [[ -n "$TOKEN" ]]; then + AUTH=(-H "Authorization: Bearer $TOKEN") + fi + + STATUS=$(curl -s -o /dev/null -w '%{http_code}' "${AUTH[@]}" \ + "https://api.github.com/repos/$REPOSITORY/branches/$BRANCH") + + case "$STATUS" in + 200) + echo "ref=$BRANCH" >> "$GITHUB_OUTPUT" + ;; + 404) + echo "ref=$DEFAULT_BRANCH" >> "$GITHUB_OUTPUT" + ;; + *) + echo "::warning::Probing $REPOSITORY for branch $BRANCH returned HTTP $STATUS; using $DEFAULT_BRANCH" + echo "ref=$DEFAULT_BRANCH" >> "$GITHUB_OUTPUT" + ;; + esac diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index f8bf97d5733..260e1517800 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -147,31 +147,26 @@ jobs: gh api "repos/$REPO/issues/comments/$id" -X DELETE > /dev/null || true done + - name: Checkout trusted actions + if: steps.gate.outputs.proceed == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: master + sparse-checkout: .github/actions + sparse-checkout-cone-mode: false + path: .actions + - name: Determine cloud branch id: cloud-branch if: steps.gate.outputs.proceed == 'true' - env: - HEAD_REF: ${{ github.head_ref || github.event.pull_request.head.ref }} - PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} - APP_TOKEN: ${{ steps.app-token.outputs.token }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - run: | - if [[ -n "$HEAD_REF" ]]; then - BRANCH="$HEAD_REF" - else - BRANCH=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefName --jq '.headRefName') || { - echo "::warning::Failed to resolve PR head ref name, falling back to master" - true - } - BRANCH="${BRANCH:-master}" - fi - if curl -sf -H "Authorization: Bearer $APP_TOKEN" \ - "https://api.github.com/repos/shellhub-io/cloud/branches/$BRANCH" > /dev/null 2>&1; then - echo "ref=$BRANCH" >> "$GITHUB_OUTPUT" - else - echo "ref=master" >> "$GITHUB_OUTPUT" - fi + uses: ./.actions/.github/actions/paired-branch + with: + repository: shellhub-io/cloud + head-ref: ${{ github.head_ref || github.event.pull_request.head.ref }} + pr-repository: ${{ github.repository }} + pr-number: ${{ github.event.pull_request.number || github.event.issue.number }} + token: ${{ steps.app-token.outputs.token }} + gh-token: ${{ secrets.GITHUB_TOKEN }} - name: Checkout cloud (context) if: steps.gate.outputs.proceed == 'true' @@ -183,47 +178,6 @@ jobs: fetch-depth: 1 path: cloud - - name: Determine claude branch - id: claude-branch - if: steps.gate.outputs.proceed == 'true' - env: - HEAD_REF: ${{ github.head_ref || github.event.pull_request.head.ref }} - PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} - APP_TOKEN: ${{ steps.app-token.outputs.token }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - run: | - if [[ -n "$HEAD_REF" ]]; then - BRANCH="$HEAD_REF" - else - BRANCH=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefName --jq '.headRefName') || { - echo "::warning::Failed to resolve PR head ref name, falling back to master" - true - } - BRANCH="${BRANCH:-master}" - fi - if curl -sf -H "Authorization: Bearer $APP_TOKEN" \ - "https://api.github.com/repos/shellhub-io/claude/branches/$BRANCH" > /dev/null 2>&1; then - echo "ref=$BRANCH" >> "$GITHUB_OUTPUT" - else - echo "ref=master" >> "$GITHUB_OUTPUT" - fi - - - name: Checkout claude config - if: steps.gate.outputs.proceed == 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - repository: shellhub-io/claude - token: ${{ steps.app-token.outputs.token }} - ref: ${{ steps.claude-branch.outputs.ref }} - fetch-depth: 1 - path: claude - - - name: Setup workspace context - if: steps.gate.outputs.proceed == 'true' - run: | - "$GITHUB_WORKSPACE/claude/workspace.sh" sync -w "$GITHUB_WORKSPACE" --project shellhub - - name: Check PR author team membership id: author-check if: steps.gate.outputs.proceed == 'true' @@ -246,6 +200,34 @@ jobs: echo "is_admin=false" >> "$GITHUB_OUTPUT" fi + - name: Determine claude branch + id: claude-branch + if: steps.gate.outputs.proceed == 'true' + uses: ./.actions/.github/actions/paired-branch + with: + repository: shellhub-io/claude + head-ref: ${{ steps.author-check.outputs.is_admin == 'true' && (github.head_ref || github.event.pull_request.head.ref) || '' }} + pr-repository: ${{ steps.author-check.outputs.is_admin == 'true' && github.repository || '' }} + pr-number: ${{ github.event.pull_request.number || github.event.issue.number }} + token: ${{ steps.app-token.outputs.token }} + gh-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Checkout claude config + if: steps.gate.outputs.proceed == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: shellhub-io/claude + token: ${{ steps.app-token.outputs.token }} + persist-credentials: false + ref: ${{ steps.claude-branch.outputs.ref }} + fetch-depth: 1 + path: claude + + - name: Setup workspace context + if: steps.gate.outputs.proceed == 'true' + run: | + "$GITHUB_WORKSPACE/claude/workspace.sh" sync -w "$GITHUB_WORKSPACE" --project shellhub + - name: Load review procedure id: review-procedure if: steps.gate.outputs.proceed == 'true' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 22a9b2ac78a..4db669b981f 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -64,31 +64,25 @@ jobs: with: ref: ${{ steps.pr-ref.outputs.sha || '' }} + - name: Checkout trusted actions + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: master + sparse-checkout: .github/actions + sparse-checkout-cone-mode: false + path: .actions + - name: Determine cloud branch id: cloud-branch if: github.event_name == 'pull_request_review_comment' || (github.event_name == 'issue_comment' && github.event.issue.pull_request) - env: - HEAD_REF: ${{ github.head_ref || github.event.pull_request.head.ref }} - PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} - APP_TOKEN: ${{ steps.app-token.outputs.token }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - run: | - if [[ -n "$HEAD_REF" ]]; then - BRANCH="$HEAD_REF" - else - BRANCH=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefName --jq '.headRefName') || { - echo "::warning::Failed to resolve PR head ref name, falling back to master" - true - } - BRANCH="${BRANCH:-master}" - fi - if curl -sf -H "Authorization: Bearer $APP_TOKEN" \ - "https://api.github.com/repos/shellhub-io/cloud/branches/$BRANCH" > /dev/null 2>&1; then - echo "ref=$BRANCH" >> "$GITHUB_OUTPUT" - else - echo "ref=master" >> "$GITHUB_OUTPUT" - fi + uses: ./.actions/.github/actions/paired-branch + with: + repository: shellhub-io/cloud + head-ref: ${{ github.head_ref || github.event.pull_request.head.ref }} + pr-repository: ${{ github.repository }} + pr-number: ${{ github.event.pull_request.number || github.event.issue.number }} + token: ${{ steps.app-token.outputs.token }} + gh-token: ${{ secrets.GITHUB_TOKEN }} - name: Checkout cloud (context) uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7