Skip to content

Automerge

Automerge #4

Workflow file for this run

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}`);
}
}