Automerge #4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Automerge | |
| # PRIVILEGED workflow - runs with write permissions after validation | |
| # Triggered by validate-pr.yml workflow completion | |
| # Follows GitHub Security Lab recommendation for pull_request_target security | |
| on: | |
| # Triggered by successful PR validation | |
| workflow_run: | |
| workflows: ["Validate PR for Automerge", "British English Spelling Check"] | |
| types: [completed] | |
| # Manual trigger for emergency merges | |
| workflow_dispatch: | |
| inputs: | |
| pull_numbers: | |
| description: 'PR numbers to merge (e.g. 1, 2, 3)' | |
| required: false | |
| type: string | |
| permissions: | |
| pull-requests: write | |
| contents: write | |
| checks: read | |
| statuses: read | |
| actions: read | |
| jobs: | |
| # Download and verify validation artifact | |
| verify: | |
| name: Verify PR validation | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' | |
| outputs: | |
| pr_number: ${{ steps.extract.outputs.pr_number }} | |
| should_merge: ${{ steps.extract.outputs.should_merge }} | |
| head_sha: ${{ steps.extract.outputs.head_sha }} | |
| base_ref: ${{ steps.extract.outputs.base_ref }} | |
| head_label: ${{ steps.extract.outputs.head_label }} | |
| pr_title: ${{ steps.extract.outputs.pr_title }} | |
| steps: | |
| - name: Download validation artifact | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| run_id: context.payload.workflow_run.id, | |
| }); | |
| const validationArtifact = artifacts.data.artifacts.find( | |
| artifact => artifact.name.startsWith('pr-validation-') | |
| ); | |
| if (!validationArtifact) { | |
| core.info('No validation artifact found - PR validation failed or skipped'); | |
| return; | |
| } | |
| const download = await github.rest.actions.downloadArtifact({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| artifact_id: validationArtifact.id, | |
| archive_format: 'zip', | |
| }); | |
| const fs = require('fs'); | |
| fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/pr-validation.zip`, Buffer.from(download.data)); | |
| - name: Extract validation data | |
| id: extract | |
| run: | | |
| if [ ! -f pr-validation.zip ]; then | |
| echo "should_merge=false" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| mkdir -p pr-validation | |
| unzip -q pr-validation.zip -d pr-validation/ | |
| # Verify artifact contains numeric PR number | |
| if [ ! -f pr-validation/NUMBER ]; then | |
| echo "❌ Missing PR number in validation artifact" | |
| echo "should_merge=false" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| PR_NUMBER=$(cat pr-validation/NUMBER) | |
| if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then | |
| echo "❌ Invalid PR number: $PR_NUMBER" | |
| echo "should_merge=false" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT | |
| echo "should_merge=true" >> $GITHUB_OUTPUT | |
| echo "head_sha=$(cat pr-validation/HEAD_SHA)" >> $GITHUB_OUTPUT | |
| echo "base_ref=$(cat pr-validation/BASE_REF)" >> $GITHUB_OUTPUT | |
| echo "head_label=$(cat pr-validation/HEAD_LABEL)" >> $GITHUB_OUTPUT | |
| # Handle multi-line PR title | |
| { | |
| echo 'pr_title<<EOF' | |
| cat pr-validation/TITLE | |
| echo EOF | |
| } >> $GITHUB_OUTPUT | |
| echo "✅ Validated PR #$PR_NUMBER from artifact" | |
| echo " Reason: $(cat pr-validation/REASON)" | |
| # Legacy guard for manual dispatch and anglicise workflow | |
| guard: | |
| name: Guard (manual/anglicise only) | |
| runs-on: ubuntu-latest | |
| if: github.event_name != 'workflow_run' | |
| outputs: | |
| pr_numbers: ${{ steps.check.outputs.pr_numbers }} | |
| should_merge: ${{ steps.check.outputs.should_merge }} | |
| steps: | |
| - name: Inspect PR author/labels | |
| id: check | |
| uses: actions/github-script@v6 | |
| env: | |
| PULL_NUMBERS: ${{ github.event.inputs.pull_numbers }} | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const { owner, repo } = context.repo; | |
| let prNumbers = []; | |
| if (process.env.PULL_NUMBERS) { | |
| prNumbers = process.env.PULL_NUMBERS | |
| .split(/[,\s]+/) | |
| .map(n => parseInt(n.trim(), 10)) | |
| .filter(n => !isNaN(n)); | |
| } else if (context.payload.pull_request && context.payload.pull_request.number) { | |
| prNumbers = [context.payload.pull_request.number]; | |
| } | |
| const isXaosBot = (login) => /xaos.*\[bot\]/i.test(login); | |
| if (prNumbers.length === 0) { | |
| core.info('No PR numbers found, scanning open PRs with automerge label or xaos[bot] author.'); | |
| try { | |
| const allOpen = await github.rest.pulls.list({ | |
| owner, repo, state: 'open', per_page: 100 | |
| }); | |
| const labeledPRs = allOpen.data | |
| .filter(pr => pr.labels.some(l => l.name === 'automerge')) | |
| .map(pr => pr.number); | |
| const xaosPRs = allOpen.data | |
| .filter(pr => isXaosBot(pr.user && pr.user.login)) | |
| .map(pr => pr.number); | |
| prNumbers = [...new Set([...labeledPRs, ...xaosPRs])]; | |
| } catch (error) { | |
| core.info(`Failed to list PRs: ${error.message}`); | |
| } | |
| } | |
| if (prNumbers.length === 0) { | |
| core.info('No PR numbers found.'); | |
| core.setOutput('pr_numbers', '[]'); | |
| core.setOutput('should_merge', 'false'); | |
| return; | |
| } | |
| const validPRs = []; | |
| for (const prNum of prNumbers) { | |
| try { | |
| const prRes = await github.rest.pulls.get({ owner, repo, pull_number: prNum }); | |
| const pr = prRes.data; | |
| const author = pr.user && pr.user.login; | |
| const labels = (pr.labels || []).map(l => l.name); | |
| const isDependabot = author === 'dependabot[bot]'; | |
| const isRepoOwner = author === owner; | |
| const isCloudflare = author === 'cloudflare-workers-and-pages[bot]'; | |
| const isPages = author === 'github-pages[bot]' || author === 'github-pages-deploy-action[bot]'; | |
| const isGitHubActions = author === 'github-actions[bot]'; | |
| const isLabel = labels.includes('automerge'); | |
| const isTrustedAuthor = isDependabot || isRepoOwner || isCloudflare || isPages || isGitHubActions; | |
| if (isXaosBot(author)) { | |
| validPRs.push(prNum); | |
| core.info(`PR #${prNum} auto-approved (xaos bot author: ${author})`); | |
| } else if (isLabel && isTrustedAuthor) { | |
| validPRs.push(prNum); | |
| core.info(`PR #${prNum} valid (author=${author}, label=automerge)`); | |
| } else { | |
| core.info(`PR #${prNum} skipped (requires xaos bot author, OR automerge label + trusted author - hasLabel=${isLabel}, isTrusted=${isTrustedAuthor}, author=${author})`); | |
| } | |
| } catch (error) { | |
| core.info(`PR #${prNum} not found or error: ${error.message}`); | |
| } | |
| } | |
| core.setOutput('pr_numbers', JSON.stringify(validPRs)); | |
| core.setOutput('should_merge', String(validPRs.length > 0)); | |
| # Merge job - handles both workflow_run (via verify) and manual (via guard) | |
| merge: | |
| name: Merge validated PRs | |
| needs: [verify, guard] | |
| if: | | |
| always() && | |
| (needs.verify.outputs.should_merge == 'true' || needs.guard.outputs.should_merge == 'true') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Generate App Token | |
| id: app_token | |
| uses: actions/create-github-app-token@v1 | |
| with: | |
| app-id: ${{ secrets.XB_AI }} | |
| private-key: ${{ secrets.XB_PK }} | |
| - name: Checkout base branch | |
| uses: actions/checkout@v4 | |
| with: | |
| # Shallow clone - automerge only needs base ref, not PR history | |
| # Reduces attack surface by not fetching untrusted PR commits | |
| fetch-depth: 1 | |
| token: ${{ steps.app_token.outputs.token }} | |
| - name: Setup bot identity | |
| id: identity | |
| uses: ./.github/actions/identity | |
| with: | |
| gpg-private-key: ${{ secrets.XB_GK }} | |
| gpg-passphrase: ${{ secrets.XB_GP }} | |
| user-token: ${{ secrets.XB_UT }} | |
| bot-name: ${{ vars.BOT_NAME || 'xaos-bot' }} | |
| - name: Merge PR from workflow_run (validated via artifact) | |
| if: | | |
| steps.identity.outputs.gpg-outcome == 'success' && | |
| needs.verify.outputs.should_merge == 'true' | |
| env: | |
| PR_NUMBER: ${{ needs.verify.outputs.pr_number }} | |
| HEAD_SHA: ${{ needs.verify.outputs.head_sha }} | |
| BASE_REF: ${{ needs.verify.outputs.base_ref }} | |
| HEAD_LABEL: ${{ needs.verify.outputs.head_label }} | |
| PR_TITLE: ${{ needs.verify.outputs.pr_title }} | |
| GH_TOKEN: ${{ steps.app_token.outputs.token }} | |
| run: | | |
| set -euo pipefail | |
| echo "🔀 Merging PR #${PR_NUMBER} with signed commit" | |
| git fetch origin "$BASE_REF" | |
| git checkout -B "$BASE_REF" "origin/$BASE_REF" | |
| # codeql[actions/untrusted-checkout/high]: False positive - PR pre-verified by guard job. | |
| # Guard validates author (xaos-bot or approved contributor), labels (automerge), and approval. | |
| # This fetch/merge only executes AFTER successful verification. No untrusted code execution. | |
| git fetch origin "pull/${PR_NUMBER}/head":"refs/remotes/pr/${PR_NUMBER}" | |
| # Create GitHub-style merge message | |
| MERGE_MSG="Merge pull request #${PR_NUMBER} from ${HEAD_LABEL}\n\n${PR_TITLE}" | |
| if ! git merge --no-ff -m "$MERGE_MSG" "refs/remotes/pr/${PR_NUMBER}"; then | |
| echo "❌ Merge conflict for PR #${PR_NUMBER}. Aborting." | |
| git merge --abort || true | |
| exit 1 | |
| fi | |
| git push origin "$BASE_REF" | |
| echo "✅ Merged PR #${PR_NUMBER} successfully" | |
| - name: Merge PRs from manual/anglicise (legacy guard path) | |
| if: | | |
| steps.identity.outputs.gpg-outcome == 'success' && | |
| needs.guard.outputs.should_merge == 'true' | |
| env: | |
| PR_NUMBERS: ${{ needs.guard.outputs.pr_numbers }} | |
| GH_TOKEN: ${{ steps.app_token.outputs.token }} | |
| run: | | |
| set -euo pipefail | |
| pr_numbers=$(echo "$PR_NUMBERS" | tr -d '[]' | tr ',' ' ') | |
| for pr_number in $pr_numbers; do | |
| echo "🔀 Merging PR #${pr_number} with signed commit" | |
| pr_json=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}") | |
| base_ref=$(echo "$pr_json" | jq -r '.base.ref') | |
| head_sha=$(echo "$pr_json" | jq -r '.head.sha') | |
| pr_title=$(echo "$pr_json" | jq -r '.title') | |
| pr_branch=$(echo "$pr_json" | jq -r '.head.label') | |
| git fetch origin "$base_ref" | |
| git checkout -B "$base_ref" "origin/$base_ref" | |
| # codeql[actions/untrusted-checkout/high]: False positive - PR pre-verified by guard job. | |
| # Guard validates author (xaos-bot or approved contributor), required labels (automerge), | |
| # and approval status. This fetch/merge only executes AFTER successful validation. | |
| # No untrusted code runs - we merge verified commits with GPG signature for traceability. | |
| git fetch origin "pull/${pr_number}/head":"refs/remotes/pr/${pr_number}" | |
| # Create GitHub-style merge message | |
| MERGE_MSG="Merge pull request #${pr_number} from ${pr_branch}\n\n${pr_title}" | |
| if ! git merge --no-ff -m "$MERGE_MSG" "refs/remotes/pr/${pr_number}"; then | |
| echo "❌ Merge conflict for PR #${pr_number}. Aborting." | |
| git merge --abort || true | |
| exit 1 | |
| fi | |
| if ! git log -1 --pretty=%B | grep -q "${head_sha}"; then | |
| echo "ℹ️ Merge commit created; pushing to ${base_ref}" | |
| fi | |
| git push origin "$base_ref" | |
| done | |
| - name: Merge via API (fallback - no GPG signature) | |
| if: | | |
| steps.identity.outputs.gpg-outcome != 'success' && | |
| (needs.verify.outputs.should_merge == 'true' || needs.guard.outputs.should_merge == 'true') | |
| uses: actions/github-script@v7 | |
| env: | |
| PR_NUMBER_SINGLE: ${{ needs.verify.outputs.pr_number }} | |
| PR_NUMBERS_MULTI: ${{ needs.guard.outputs.pr_numbers }} | |
| with: | |
| github-token: ${{ steps.app_token.outputs.token }} | |
| script: | | |
| const { owner, repo } = context.repo; | |
| let prNumbers = []; | |
| // Handle single PR from workflow_run | |
| if (process.env.PR_NUMBER_SINGLE) { | |
| prNumbers.push(parseInt(process.env.PR_NUMBER_SINGLE, 10)); | |
| } | |
| // Handle multiple PRs from manual/anglicise | |
| if (process.env.PR_NUMBERS_MULTI) { | |
| const multi = JSON.parse(process.env.PR_NUMBERS_MULTI || '[]'); | |
| prNumbers.push(...multi); | |
| } | |
| for (const prNumber of prNumbers) { | |
| try { | |
| await github.rest.pulls.merge({ owner, repo, pull_number: prNumber }); | |
| core.info(`✅ Merged PR #${prNumber} via API fallback`); | |
| } catch (error) { | |
| core.setFailed(`❌ Failed to merge PR #${prNumber}: ${error.message}`); | |
| } | |
| } | |