|
| 1 | +# Template created by https://github.com/XAOSTECH/dev-control |
| 2 | +# See templates folder documentation for details. |
| 3 | + |
1 | 4 | name: Automerge |
2 | 5 |
|
3 | 6 | # PRIVILEGED workflow - runs with write permissions after validation |
@@ -210,135 +213,65 @@ jobs: |
210 | 213 | steps: |
211 | 214 | - name: Generate App Token |
212 | 215 | id: app_token |
213 | | - uses: actions/create-github-app-token@v1 |
| 216 | + uses: actions/create-github-app-token@v2 |
214 | 217 | with: |
215 | 218 | app-id: ${{ secrets.XB_AI }} |
216 | 219 | private-key: ${{ secrets.XB_PK }} |
217 | 220 |
|
218 | | - - name: Checkout base branch |
219 | | - uses: actions/checkout@v4 |
220 | | - with: |
221 | | - # Shallow clone - automerge only needs base ref, not PR history |
222 | | - # Reduces attack surface by not fetching untrusted PR commits |
223 | | - fetch-depth: 1 |
224 | | - token: ${{ steps.app_token.outputs.token }} |
225 | | - |
226 | | - - name: Setup bot identity |
227 | | - id: identity |
228 | | - uses: ./.github/actions/identity |
229 | | - with: |
230 | | - gpg-private-key: ${{ secrets.XB_GK }} |
231 | | - gpg-passphrase: ${{ secrets.XB_GP }} |
232 | | - user-token: ${{ secrets.XB_UT }} |
233 | | - bot-name: ${{ vars.BOT_NAME || 'xaos-bot' }} |
234 | | - |
235 | | - - name: Merge PR from workflow_run (validated via artifact) |
236 | | - if: | |
237 | | - steps.identity.outputs.gpg-outcome == 'success' && |
238 | | - needs.verify.outputs.should_merge == 'true' |
239 | | - env: |
240 | | - PR_NUMBER: ${{ needs.verify.outputs.pr_number }} |
241 | | - HEAD_SHA: ${{ needs.verify.outputs.head_sha }} |
242 | | - BASE_REF: ${{ needs.verify.outputs.base_ref }} |
243 | | - HEAD_LABEL: ${{ needs.verify.outputs.head_label }} |
244 | | - PR_TITLE: ${{ needs.verify.outputs.pr_title }} |
245 | | - GH_TOKEN: ${{ steps.app_token.outputs.token }} |
246 | | - run: | |
247 | | - set -euo pipefail |
248 | | - echo "🔀 Merging PR #${PR_NUMBER} with signed commit" |
249 | | - |
250 | | - git fetch origin "$BASE_REF" |
251 | | - git checkout -B "$BASE_REF" "origin/$BASE_REF" |
252 | | - # codeql[actions/untrusted-checkout/high]: False positive - PR pre-verified by guard job. |
253 | | - # Guard validates author (xaos-bot or approved contributor), labels (automerge), and approval. |
254 | | - # This fetch/merge only executes AFTER successful verification. No untrusted code execution. |
255 | | - git fetch origin "pull/${PR_NUMBER}/head":"refs/remotes/pr/${PR_NUMBER}" |
256 | | -
|
257 | | - # Create GitHub-style merge message |
258 | | - MERGE_MSG="Merge pull request #${PR_NUMBER} from ${HEAD_LABEL}\n\n${PR_TITLE}" |
259 | | -
|
260 | | - if ! git merge --no-ff -m "$MERGE_MSG" "refs/remotes/pr/${PR_NUMBER}"; then |
261 | | - echo "❌ Merge conflict for PR #${PR_NUMBER}. Aborting." |
262 | | - git merge --abort || true |
263 | | - exit 1 |
264 | | - fi |
265 | | -
|
266 | | - git push origin "$BASE_REF" |
267 | | - echo "✅ Merged PR #${PR_NUMBER} successfully" |
268 | | -
|
269 | | - - name: Merge PRs from manual/anglicise (legacy guard path) |
270 | | - if: | |
271 | | - steps.identity.outputs.gpg-outcome == 'success' && |
272 | | - needs.guard.outputs.should_merge == 'true' |
273 | | - env: |
274 | | - PR_NUMBERS: ${{ needs.guard.outputs.pr_numbers }} |
275 | | - GH_TOKEN: ${{ steps.app_token.outputs.token }} |
276 | | - run: | |
277 | | - set -euo pipefail |
278 | | - pr_numbers=$(echo "$PR_NUMBERS" | tr -d '[]' | tr ',' ' ') |
279 | | - for pr_number in $pr_numbers; do |
280 | | - echo "🔀 Merging PR #${pr_number} with signed commit" |
281 | | - pr_json=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}") |
282 | | - base_ref=$(echo "$pr_json" | jq -r '.base.ref') |
283 | | - head_sha=$(echo "$pr_json" | jq -r '.head.sha') |
284 | | - pr_title=$(echo "$pr_json" | jq -r '.title') |
285 | | - pr_branch=$(echo "$pr_json" | jq -r '.head.label') |
286 | | -
|
287 | | - git fetch origin "$base_ref" |
288 | | - git checkout -B "$base_ref" "origin/$base_ref" |
289 | | - # codeql[actions/untrusted-checkout/high]: False positive - PR pre-verified by guard job. |
290 | | - # Guard validates author (xaos-bot or approved contributor), required labels (automerge), |
291 | | - # and approval status. This fetch/merge only executes AFTER successful validation. |
292 | | - # No untrusted code runs - we merge verified commits with GPG signature for traceability. |
293 | | - git fetch origin "pull/${pr_number}/head":"refs/remotes/pr/${pr_number}" |
294 | | -
|
295 | | - # Create GitHub-style merge message |
296 | | - MERGE_MSG="Merge pull request #${pr_number} from ${pr_branch}\n\n${pr_title}" |
297 | | -
|
298 | | - if ! git merge --no-ff -m "$MERGE_MSG" "refs/remotes/pr/${pr_number}"; then |
299 | | - echo "❌ Merge conflict for PR #${pr_number}. Aborting." |
300 | | - git merge --abort || true |
301 | | - exit 1 |
302 | | - fi |
303 | | -
|
304 | | - if ! git log -1 --pretty=%B | grep -q "${head_sha}"; then |
305 | | - echo "ℹ️ Merge commit created; pushing to ${base_ref}" |
306 | | - fi |
307 | | -
|
308 | | - git push origin "$base_ref" |
309 | | - done |
310 | | -
|
311 | | - - name: Merge via API (fallback - no GPG signature) |
312 | | - if: | |
313 | | - steps.identity.outputs.gpg-outcome != 'success' && |
314 | | - (needs.verify.outputs.should_merge == 'true' || needs.guard.outputs.should_merge == 'true') |
| 221 | + - name: Merge PR from workflow_run via API (safe privileged path) |
| 222 | + if: needs.verify.outputs.should_merge == 'true' |
315 | 223 | uses: actions/github-script@v7 |
316 | 224 | env: |
317 | 225 | PR_NUMBER_SINGLE: ${{ needs.verify.outputs.pr_number }} |
318 | | - PR_NUMBERS_MULTI: ${{ needs.guard.outputs.pr_numbers }} |
| 226 | + EXPECTED_HEAD_SHA: ${{ needs.verify.outputs.head_sha }} |
319 | 227 | with: |
320 | 228 | github-token: ${{ steps.app_token.outputs.token }} |
321 | 229 | script: | |
322 | 230 | const { owner, repo } = context.repo; |
323 | | - let prNumbers = []; |
324 | | - |
325 | | - // Handle single PR from workflow_run |
326 | | - if (process.env.PR_NUMBER_SINGLE) { |
327 | | - prNumbers.push(parseInt(process.env.PR_NUMBER_SINGLE, 10)); |
| 231 | + const prNumber = parseInt(process.env.PR_NUMBER_SINGLE || '', 10); |
| 232 | + const expectedHead = process.env.EXPECTED_HEAD_SHA; |
| 233 | + if (!Number.isInteger(prNumber)) { |
| 234 | + core.setFailed('Invalid PR number from validation artifact'); |
| 235 | + return; |
328 | 236 | } |
329 | | - |
330 | | - // Handle multiple PRs from manual/anglicise |
331 | | - if (process.env.PR_NUMBERS_MULTI) { |
332 | | - const multi = JSON.parse(process.env.PR_NUMBERS_MULTI || '[]'); |
333 | | - prNumbers.push(...multi); |
| 237 | + const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); |
| 238 | + if (expectedHead && pr.data.head.sha !== expectedHead) { |
| 239 | + core.setFailed(`Head SHA changed for PR #${prNumber}; expected ${expectedHead}, got ${pr.data.head.sha}`); |
| 240 | + return; |
334 | 241 | } |
335 | | - |
336 | | - for (const prNumber of prNumbers) { |
337 | | - try { |
338 | | - await github.rest.pulls.merge({ owner, repo, pull_number: prNumber }); |
339 | | - core.info(`✅ Merged PR #${prNumber} via API fallback`); |
340 | | - } catch (error) { |
341 | | - core.setFailed(`❌ Failed to merge PR #${prNumber}: ${error.message}`); |
342 | | - } |
| 242 | + await github.rest.pulls.merge({ |
| 243 | + owner, |
| 244 | + repo, |
| 245 | + pull_number: prNumber, |
| 246 | + sha: pr.data.head.sha, |
| 247 | + merge_method: 'merge', |
| 248 | + commit_title: `Merge pull request #${prNumber} from ${pr.data.head.label}`, |
| 249 | + commit_message: pr.data.title, |
| 250 | + }); |
| 251 | + core.info(`✅ Merged PR #${prNumber} via workflow_run artifact validation`); |
| 252 | + - name: Merge PRs from manual/anglicise via API (safe path) |
| 253 | + if: needs.guard.outputs.should_merge == 'true' |
| 254 | + uses: actions/github-script@v7 |
| 255 | + env: |
| 256 | + PR_NUMBERS_MULTI: ${{ needs.guard.outputs.pr_numbers }} |
| 257 | + with: |
| 258 | + github-token: ${{ steps.app_token.outputs.token }} |
| 259 | + script: | |
| 260 | + const { owner, repo } = context.repo; |
| 261 | + const prNumbers = JSON.parse(process.env.PR_NUMBERS_MULTI || '[]'); |
| 262 | + for (const raw of prNumbers) { |
| 263 | + const prNumber = parseInt(String(raw), 10); |
| 264 | + if (!Number.isInteger(prNumber)) continue; |
| 265 | + const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); |
| 266 | + await github.rest.pulls.merge({ |
| 267 | + owner, |
| 268 | + repo, |
| 269 | + pull_number: prNumber, |
| 270 | + sha: pr.data.head.sha, |
| 271 | + merge_method: 'merge', |
| 272 | + commit_title: `Merge pull request #${prNumber} from ${pr.data.head.label}`, |
| 273 | + commit_message: pr.data.title, |
| 274 | + }); |
| 275 | + core.info(`✅ Merged PR #${prNumber} via guarded manual path`); |
343 | 276 | } |
344 | 277 |
|
0 commit comments