Skip to content

Commit d54c781

Browse files
committed
chore(dc-init): update workflows,actions
1 parent 53e140c commit d54c781

12 files changed

Lines changed: 471 additions & 171 deletions

File tree

.github/actions/identity/action.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# Template created by https://github.com/XAOSTECH/dev-control
2+
# See templates folder documentation for details.
3+
14
name: Bot Setup
25
description: >
36
Register the machine user GPG key to their GitHub profile, import and configure it

.github/workflows/anglicise.yml

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# Template created by https://github.com/XAOSTECH/dev-control
2+
# See templates folder documentation for details.
3+
14
name: British English Spelling Check
25

36
on:
@@ -16,13 +19,13 @@ jobs:
1619

1720
steps:
1821
- name: Checkout code
19-
uses: actions/checkout@v4
22+
uses: actions/checkout@v6
2023
with:
2124
fetch-depth: 0
2225

2326
- name: Generate App Token
2427
id: app_token
25-
uses: actions/create-github-app-token@v1
28+
uses: actions/create-github-app-token@v2
2629
with:
2730
app-id: ${{ secrets['XB_AI'] }}
2831
private-key: ${{ secrets['XB_PK'] }}
@@ -305,12 +308,29 @@ jobs:
305308
! -path "./.git/*" \
306309
! -path "./node_modules/*" \
307310
! -path "./vendor/*" \
308-
! -path "./.github/workflows/*" \
311+
! -path "./.github/workflows/anglicise.yml" \
309312
! -path "./workflows-templates/anglicise.yml" \
310313
! -path "./licence-templates/*" \
311-
! -path "./LICENSE" \
314+
! -path "./LICENCE" \
312315
2>/dev/null | sort)
313316
317+
is_workflow_file() {
318+
local file="$1"
319+
[[ "$file" =~ ^\./\.github/workflows/.*\.ya?ml$ || "$file" =~ ^\./workflows-templates/.*\.ya?ml$ ]]
320+
}
321+
322+
is_workflow_text_context() {
323+
local line="$1"
324+
[[ "$line" =~ ^[[:space:]]*# ]] ||
325+
[[ "$line" =~ ^[[:space:]-]*name:[[:space:]] ]] ||
326+
[[ "$line" =~ ^[[:space:]-]*(description|title|body):[[:space:]] ]]
327+
}
328+
329+
is_excluded_text_line() {
330+
local line="$1"
331+
echo "$line" | grep -qE '(https?://|file://|github/|[?&](color|center|gray|licence)=[a-zA-Z0-9]+|"Authorization": "Bearer|types:[[:space:]]*\[[^]]+\]|--[a-z-]+ (center|color|gray)|\$\{?(color|center|gray);\}|\b(color|center|gray|licence)[_A-Z]*=|gum |\\$|shields\.io|stop-color|[a-z-]color[a-z-]*:|color[a-z-]*:[[:space:]]*#|align-items:)'
332+
}
333+
314334
echo "## British English Spelling Check" >> $GITHUB_STEP_SUMMARY
315335
echo "" >> $GITHUB_STEP_SUMMARY
316336
echo "🔍 Checking $(echo "$FILES" | wc -l) files for American spellings..." | tee -a $GITHUB_STEP_SUMMARY
@@ -335,16 +355,23 @@ jobs:
335355
# - Gum/TUI library options
336356
# - Variable references ($color, ${color})
337357
# - Assignment patterns (color=, COLOR=) including query params
338-
MATCHES=$(grep -inw "$american" "$file" 2>/dev/null | \
339-
grep -vE '(https?://|file://|github/|[?&](color|center|gray|licence)=[a-zA-Z0-9]+|"Authorization": "Bearer|types:[[:space:]]*\[[^]]+\]|--[a-z-]+ (center|color|gray)|\$\{?(color|center|gray);}|\b(color|center|gray|licence)[_A-Z]*=|gum |\\$|shields\.io)' || true)
358+
MATCHES=$(grep -inw "$american" "$file" 2>/dev/null || true)
340359
341360
if [[ -n "$MATCHES" ]]; then
342-
FILES_TO_FIX["$file"]=1
343361
while IFS= read -r match; do
344362
[[ -z "$match" ]] && continue
345363
LINE_NUM=$(echo "$match" | cut -d: -f1)
346-
LINE_CONTENT=$(echo "$match" | cut -d: -f2- | sed 's/^[[:space:]]*//' | head -c 100)
347-
364+
LINE_CONTENT=$(echo "$match" | cut -d: -f2-)
365+
366+
if is_workflow_file "$file" && ! is_workflow_text_context "$LINE_CONTENT"; then
367+
continue
368+
fi
369+
370+
if is_excluded_text_line "$LINE_CONTENT"; then
371+
continue
372+
fi
373+
374+
FILES_TO_FIX["$file"]=1
348375
echo " ❌ $file:$LINE_NUM - '$american' → '$british'"
349376
ISSUE_OUTPUT+="| \`$file:$LINE_NUM\` | $american | $british | $pattern |"$'\n'
350377
ISSUES_FOUND=$((ISSUES_FOUND + 1))
@@ -390,11 +417,28 @@ jobs:
390417
! -path "./.git/*" \
391418
! -path "./node_modules/*" \
392419
! -path "./vendor/*" \
393-
! -path "./.github/workflows/*" \
420+
! -path "./.github/workflows/anglicise.yml" \
394421
! -path "./workflows-templates/anglicise.yml" \
395422
! -path "./licence-templates/*" \
396-
! -path "./LICENSE" \
423+
! -path "./LICENCE" \
397424
2>/dev/null)
425+
426+
is_workflow_file() {
427+
local file="$1"
428+
[[ "$file" =~ ^\./\.github/workflows/.*\.ya?ml$ || "$file" =~ ^\./workflows-templates/.*\.ya?ml$ ]]
429+
}
430+
431+
is_workflow_text_context() {
432+
local line="$1"
433+
[[ "$line" =~ ^[[:space:]]*# ]] ||
434+
[[ "$line" =~ ^[[:space:]-]*name:[[:space:]] ]] ||
435+
[[ "$line" =~ ^[[:space:]-]*(description|title|body):[[:space:]] ]]
436+
}
437+
438+
is_excluded_text_line() {
439+
local line="$1"
440+
echo "$line" | grep -qE "(https?://|github/|[?&](color|center|gray|licence)=|=[\"'](center|color|licence)|types:[[:space:]]*\[[^]]+\]|--[a-z-]+ (center|color|gray)|--color|--center|\$\{?(color|center|gray|licence)|\b(COLOR|CENTER|GRAY|LICENCE)[_A-Z]*=|gum |\\$|shields\.io|stop-color|[a-z-]color[a-z-]*:|color[a-z-]*:[[:space:]]*#|align-items:)"
441+
}
398442
399443
FIXED_COUNT=0
400444
@@ -419,7 +463,9 @@ jobs:
419463
# - Escaped line continuations (trailing backslash)
420464
# NOTE: filename exclusion is handled in the perl regex via (?!\.)
421465
# so that 'colors' in a comment is fixed even if 'colours.sh' appears on the same line
422-
if echo "$line" | grep -qE "(https?://|github/|[?&](color|center|gray|licence)=|=[\"'](center|color|licence)|types:[[:space:]]*\[[^]]+\]|--[a-z-]+ (center|color|gray)|--color|--center|\$\{?(color|center|gray|licence)|\b(COLOR|CENTER|GRAY|LICENCE)[_A-Z]*=|gum |\\$|shields\.io)"; then
466+
if is_workflow_file "$file" && ! is_workflow_text_context "$line"; then
467+
echo "$line"
468+
elif is_excluded_text_line "$line"; then
423469
echo "$line"
424470
else
425471
# Apply case-preserving replacement using perl
@@ -455,7 +501,21 @@ jobs:
455501
456502
# Check if file contains the word (case-insensitive, whole word)
457503
# Exclude lines where the match is inside a code/CLI pattern (filename exclusion handled by perl (?!\.))
458-
if grep -inw "$american" "$file" 2>/dev/null | grep -qvE "(https?://|file://|github/|[?&](color|center|gray|licence)=[a-zA-Z0-9]+|\"Authorization\": \"Bearer|types:[[:space:]]*\[[^]]+\]|=[\"'](center|color|licence)|--[a-z-]+ (center|color|gray)|--color|--center|\$\{?(color|center|gray|licence)|\b(COLOR|CENTER|GRAY|LICENCE)[_A-Z]*|gum |\\$|shields\.io)"; then
504+
SHOULD_FIX=false
505+
while IFS= read -r match; do
506+
[[ -z "$match" ]] && continue
507+
MATCH_LINE=$(echo "$match" | cut -d: -f2-)
508+
if is_workflow_file "$file" && ! is_workflow_text_context "$MATCH_LINE"; then
509+
continue
510+
fi
511+
if is_excluded_text_line "$MATCH_LINE"; then
512+
continue
513+
fi
514+
SHOULD_FIX=true
515+
break
516+
done < <(grep -inw "$american" "$file" 2>/dev/null || true)
517+
518+
if [[ "$SHOULD_FIX" == true ]]; then
459519
case_preserve_replace "$file" "$american" "$british"
460520
echo " ✓ Fixed in: $file"
461521
FIXED_COUNT=$((FIXED_COUNT + 1))
@@ -465,6 +525,23 @@ jobs:
465525
466526
echo "✅ Applied fixes to $FIXED_COUNT file(s)"
467527
528+
- name: Validate workflow identifiers remain canonical
529+
if: steps.check.outputs.needs_fix == 'true'
530+
run: |
531+
set -euo pipefail
532+
BAD=0
533+
for file in .github/workflows/*.yml workflows-templates/*.yml; do
534+
[[ -f "$file" ]] || continue
535+
if grep -nE '^[[:space:]]*analyse:[[:space:]]*$|needs:[[:space:]]*\[[^]]*\banalyse\b[^]]*\]|uses:[[:space:]]+[^[:space:]]+/analyse@' "$file"; then
536+
BAD=1
537+
fi
538+
done
539+
if [[ "$BAD" -ne 0 ]]; then
540+
echo "❌ Found UK spelling in workflow functional identifiers; aborting."
541+
exit 1
542+
fi
543+
echo "✅ Workflow functional identifiers remain canonical."
544+
468545
- name: Setup bot identity (if fixes needed)
469546
id: import_gpg
470547
if: steps.check.outputs.needs_fix == 'true'

.github/workflows/automerge.yml

Lines changed: 50 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# Template created by https://github.com/XAOSTECH/dev-control
2+
# See templates folder documentation for details.
3+
14
name: Automerge
25

36
# PRIVILEGED workflow - runs with write permissions after validation
@@ -210,135 +213,65 @@ jobs:
210213
steps:
211214
- name: Generate App Token
212215
id: app_token
213-
uses: actions/create-github-app-token@v1
216+
uses: actions/create-github-app-token@v2
214217
with:
215218
app-id: ${{ secrets.XB_AI }}
216219
private-key: ${{ secrets.XB_PK }}
217220

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'
315223
uses: actions/github-script@v7
316224
env:
317225
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 }}
319227
with:
320228
github-token: ${{ steps.app_token.outputs.token }}
321229
script: |
322230
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;
328236
}
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;
334241
}
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`);
343276
}
344277

0 commit comments

Comments
 (0)