Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions .github/workflows/auto-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Auto-merge PRs after CI passes
# Automatically merges approved PRs to main once all checks pass

name: Auto Merge

on:
pull_request_review:
types: [submitted]
workflow_run:
workflows: ["CI"]
types: [completed]

jobs:
auto-merge:
name: Auto Merge PR
runs-on: ubuntu-latest

# Only run on approved PRs targeting main
if: |
(github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')

Comment on lines +19 to +22
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The job condition runs on any successful workflow_run of CI, including CI runs triggered by direct pushes to main/develop (where there is no PR). That will cause this workflow to error (no PR found) and may also risk selecting the wrong PR when matching by branch name. Gate workflow_run handling to PR-triggered runs only (e.g., require github.event.workflow_run.event == 'pull_request' and/or a non-empty workflow_run.pull_requests list), and ensure the PR targets main before enabling auto-merge.

Copilot uses AI. Check for mistakes.
permissions:
contents: write
pull-requests: write
checks: read

steps:
- uses: actions/checkout@v4

- name: Get PR info
id: pr
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get PR number from the event
if [ "${{ github.event_name }}" == "pull_request_review" ]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
elif [ "${{ github.event_name }}" == "workflow_run" ]; then
# Extract PR number from workflow run
PR_NUMBER=$(gh pr list --json number,headRefName --jq '.[] | select(.headRefName=="${{ github.event.workflow_run.head_branch }}") | .number' | head -1)

Check failure

Code scanning / CodeQL

Code injection Critical

Potential code injection in
${ github.event.workflow_run.head_branch }
, which may be controlled by an external user (
workflow_run
).

Copilot Autofix

AI about 10 hours ago

In general, to fix this type of problem in GitHub Actions, you should avoid using ${{ ... }} expressions with untrusted values directly inside run: scripts. Instead, pass the value into the step via env: and then consume it using the shell’s variable expansion syntax (e.g., $HEAD_BRANCH) or via process.env in JavaScript. This ensures that the shell does not reinterpret the value as code, regardless of its contents.

For this specific workflow, the risky part is line 41:

PR_NUMBER=$(gh pr list --json number,headRefName --jq '.[] | select(.headRefName=="${{ github.event.workflow_run.head_branch }}") | .number' | head -1)

The best fix that preserves existing functionality is:

  1. Add an environment variable (e.g., HEAD_BRANCH) to this step, assigned from ${{ github.event.workflow_run.head_branch }}.
  2. Update the run: script to reference $HEAD_BRANCH inside the jq filter, using shell variable expansion, while preserving the required quoting so that jq receives the correct string and the branch name is not interpreted as code.

Concretely:

  • Extend the env: block in the “Get PR info” step with HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}.
  • Change the PR_NUMBER assignment to use $HEAD_BRANCH instead of an inlined ${{ ... }}. Because the script is single-quoted with an internal double-quoted jq string, the safest approach is to break out of single quotes just around the $HEAD_BRANCH reference: first build the jq filter in a shell variable using proper quoting, then pass that variable to gh pr list --jq.

All changes are within .github/workflows/auto-merge.yml, in the shown step.

Suggested changeset 1
.github/workflows/auto-merge.yml

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml
--- a/.github/workflows/auto-merge.yml
+++ b/.github/workflows/auto-merge.yml
@@ -32,13 +32,15 @@
         id: pr
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
         run: |
           # Get PR number from the event
           if [ "${{ github.event_name }}" == "pull_request_review" ]; then
             PR_NUMBER="${{ github.event.pull_request.number }}"
           elif [ "${{ github.event_name }}" == "workflow_run" ]; then
             # Extract PR number from workflow run
-            PR_NUMBER=$(gh pr list --json number,headRefName --jq '.[] | select(.headRefName=="${{ github.event.workflow_run.head_branch }}") | .number' | head -1)
+            JQ_FILTER=".[] | select(.headRefName==\"$HEAD_BRANCH\") | .number"
+            PR_NUMBER=$(gh pr list --json number,headRefName --jq "$JQ_FILTER" | head -1)
           else
             echo "No PR found for event type: ${{ github.event_name }}"
             exit 1
EOF
@@ -32,13 +32,15 @@
id: pr
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
# Get PR number from the event
if [ "${{ github.event_name }}" == "pull_request_review" ]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
elif [ "${{ github.event_name }}" == "workflow_run" ]; then
# Extract PR number from workflow run
PR_NUMBER=$(gh pr list --json number,headRefName --jq '.[] | select(.headRefName=="${{ github.event.workflow_run.head_branch }}") | .number' | head -1)
JQ_FILTER=".[] | select(.headRefName==\"$HEAD_BRANCH\") | .number"
PR_NUMBER=$(gh pr list --json number,headRefName --jq "$JQ_FILTER" | head -1)
else
echo "No PR found for event type: ${{ github.event_name }}"
exit 1
Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +40 to +41
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For workflow_run, deriving the PR number by listing PRs and matching headRefName to workflow_run.head_branch is ambiguous and can return the wrong PR if multiple PRs share the same head branch name (or return nothing and fail). Prefer using the PR number(s) provided in the workflow_run event payload (when present) to identify the exact PR that triggered CI.

Suggested change
# Extract PR number from workflow run
PR_NUMBER=$(gh pr list --json number,headRefName --jq '.[] | select(.headRefName=="${{ github.event.workflow_run.head_branch }}") | .number' | head -1)
# Extract PR number directly from workflow_run payload
if [ -n "${{ github.event.workflow_run.pull_requests[0].number }}" ]; then
PR_NUMBER="${{ github.event.workflow_run.pull_requests[0].number }}"
else
echo "Workflow run is not associated with a pull request."
exit 1
fi

Copilot uses AI. Check for mistakes.
else
echo "No PR found for event type: ${{ github.event_name }}"
exit 1
fi

if [ -z "$PR_NUMBER" ]; then
echo "No PR number found for event ${{ github.event_name }}"
exit 1
fi

echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT

# Get PR details
PR_DATA=$(gh pr view $PR_NUMBER --json number,title,state,mergeable,reviewDecision,statusCheckRollup)

echo "$PR_DATA" | jq .

STATE=$(echo "$PR_DATA" | jq -r .state)
MERGEABLE=$(echo "$PR_DATA" | jq -r .mergeable)
REVIEW_DECISION=$(echo "$PR_DATA" | jq -r .reviewDecision)

echo "state=$STATE" >> $GITHUB_OUTPUT
echo "mergeable=$MERGEABLE" >> $GITHUB_OUTPUT
echo "review_decision=$REVIEW_DECISION" >> $GITHUB_OUTPUT

- name: Check merge conditions
id: check
run: |
STATE="${{ steps.pr.outputs.state }}"

Check failure

Code scanning / CodeQL

Code injection Critical

Potential code injection in
${ steps.pr.outputs.state }
, which may be controlled by an external user (
pull_request_review
).
Potential code injection in
${ steps.pr.outputs.state }
, which may be controlled by an external user (
workflow_run
).

Copilot Autofix

AI about 10 hours ago

To fix this, we should stop using GitHub expression syntax (${{ ... }}) directly inside the shell script body for values that originate from workflow inputs/outputs. Instead, we should bind those values to environment variables at the step level (env:) and then reference them using standard shell variable expansion (e.g., $STATE) inside the script. This aligns with GitHub’s guidance and breaks the taint flow into the shell command parser.

Concretely, for the “Check merge conditions” step (lines 67–87), we will:

  • Add an env: section that maps STATE, MERGEABLE, and REVIEW_DECISION to ${{ steps.pr.outputs.state }}, ${{ steps.pr.outputs.mergeable }}, and ${{ steps.pr.outputs.review_decision }} respectively.
  • Simplify the run: script so it relies solely on $STATE, $MERGEABLE, and $REVIEW_DECISION, removing the inline ${{ ... }} assignments in the script body.

No other steps need modification to address this particular alert, and this change preserves existing behavior: the variables in the script will contain exactly the same values as before, but are populated in a safer way by the runner rather than the shell parsing interpolated expressions.


Suggested changeset 1
.github/workflows/auto-merge.yml

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml
--- a/.github/workflows/auto-merge.yml
+++ b/.github/workflows/auto-merge.yml
@@ -66,11 +66,11 @@
 
       - name: Check merge conditions
         id: check
+        env:
+          STATE: ${{ steps.pr.outputs.state }}
+          MERGEABLE: ${{ steps.pr.outputs.mergeable }}
+          REVIEW_DECISION: ${{ steps.pr.outputs.review_decision }}
         run: |
-          STATE="${{ steps.pr.outputs.state }}"
-          MERGEABLE="${{ steps.pr.outputs.mergeable }}"
-          REVIEW_DECISION="${{ steps.pr.outputs.review_decision }}"
-          
           echo "PR State: $STATE"
           echo "Mergeable: $MERGEABLE"
           echo "Review Decision: $REVIEW_DECISION"
EOF
@@ -66,11 +66,11 @@

- name: Check merge conditions
id: check
env:
STATE: ${{ steps.pr.outputs.state }}
MERGEABLE: ${{ steps.pr.outputs.mergeable }}
REVIEW_DECISION: ${{ steps.pr.outputs.review_decision }}
run: |
STATE="${{ steps.pr.outputs.state }}"
MERGEABLE="${{ steps.pr.outputs.mergeable }}"
REVIEW_DECISION="${{ steps.pr.outputs.review_decision }}"

echo "PR State: $STATE"
echo "Mergeable: $MERGEABLE"
echo "Review Decision: $REVIEW_DECISION"
Copilot is powered by AI and may make mistakes. Always verify output.
MERGEABLE="${{ steps.pr.outputs.mergeable }}"

Check failure

Code scanning / CodeQL

Code injection Critical

Potential code injection in
${ steps.pr.outputs.mergeable }
, which may be controlled by an external user (
pull_request_review
).
Potential code injection in
${ steps.pr.outputs.mergeable }
, which may be controlled by an external user (
workflow_run
).

Copilot Autofix

AI about 10 hours ago

In general, to fix this class of problems in GitHub Actions, any untrusted or potentially untrusted data that needs to be used in a shell script should be assigned to an environment variable at the workflow level (using ${{ ... }} only in the env: mapping), and then referenced inside the script using the shell’s own variable syntax ($VAR), not ${{ env.VAR }} or similar expression syntax.

For this workflow, the problematic use is in the “Check merge conditions” step, where the script begins with:

run: |
  STATE="${{ steps.pr.outputs.state }}"
  MERGEABLE="${{ steps.pr.outputs.mergeable }}"
  REVIEW_DECISION="${{ steps.pr.outputs.review_decision }}"

The safest fix, without altering behavior, is:

  1. Add an env: section to that step, mapping:
    • STATE: ${{ steps.pr.outputs.state }}
    • MERGEABLE: ${{ steps.pr.outputs.mergeable }}
    • REVIEW_DECISION: ${{ steps.pr.outputs.review_decision }}
  2. Remove the three assignment lines inside the run: block and instead rely on those environment variables directly ($STATE, $MERGEABLE, $REVIEW_DECISION) everywhere in the script, which is already the case for subsequent references.

This keeps functionality identical (the variables have the same names and contents) while complying with the recommended pattern: untrusted values are injected only into environment variables via expressions, and the shell script reads them with native syntax. No additional imports or external dependencies are required, and no other steps need to change.

Suggested changeset 1
.github/workflows/auto-merge.yml

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml
--- a/.github/workflows/auto-merge.yml
+++ b/.github/workflows/auto-merge.yml
@@ -66,11 +66,11 @@
 
       - name: Check merge conditions
         id: check
+        env:
+          STATE: ${{ steps.pr.outputs.state }}
+          MERGEABLE: ${{ steps.pr.outputs.mergeable }}
+          REVIEW_DECISION: ${{ steps.pr.outputs.review_decision }}
         run: |
-          STATE="${{ steps.pr.outputs.state }}"
-          MERGEABLE="${{ steps.pr.outputs.mergeable }}"
-          REVIEW_DECISION="${{ steps.pr.outputs.review_decision }}"
-          
           echo "PR State: $STATE"
           echo "Mergeable: $MERGEABLE"
           echo "Review Decision: $REVIEW_DECISION"
EOF
@@ -66,11 +66,11 @@

- name: Check merge conditions
id: check
env:
STATE: ${{ steps.pr.outputs.state }}
MERGEABLE: ${{ steps.pr.outputs.mergeable }}
REVIEW_DECISION: ${{ steps.pr.outputs.review_decision }}
run: |
STATE="${{ steps.pr.outputs.state }}"
MERGEABLE="${{ steps.pr.outputs.mergeable }}"
REVIEW_DECISION="${{ steps.pr.outputs.review_decision }}"

echo "PR State: $STATE"
echo "Mergeable: $MERGEABLE"
echo "Review Decision: $REVIEW_DECISION"
Copilot is powered by AI and may make mistakes. Always verify output.
REVIEW_DECISION="${{ steps.pr.outputs.review_decision }}"

Check failure

Code scanning / CodeQL

Code injection Critical

Potential code injection in
${ steps.pr.outputs.review_decision }
, which may be controlled by an external user (
pull_request_review
).
Potential code injection in
${ steps.pr.outputs.review_decision }
, which may be controlled by an external user (
workflow_run
).

Copilot Autofix

AI about 10 hours ago

General approach: Avoid using ${{ steps.pr.outputs.review_decision }} directly inside the run: script. Instead, pass the value into the step via env: and use standard shell variable expansion ($REVIEW_DECISION) within the script. This aligns with the recommended pattern of copying untrusted expressions into environment variables and only using native shell syntax in the command body.

Concrete fix for this workflow:

  • In the Check merge conditions step (lines 67–96), add an env: section that maps a new environment variable (e.g., STATE, MERGEABLE, REVIEW_DECISION) from the step outputs:
    REVIEW_DECISION: ${{ steps.pr.outputs.review_decision }}.

  • In the run: script of that same step, stop reassigning these variables using ${{ ... }}. Instead, rely on the environment variables already populated by env:. So remove or simplify the lines:

    STATE="${{ steps.pr.outputs.state }}"
    MERGEABLE="${{ steps.pr.outputs.mergeable }}"
    REVIEW_DECISION="${{ steps.pr.outputs.review_decision }}"

    and use $STATE, $MERGEABLE, and $REVIEW_DECISION directly.

  • No other steps need to change, because the CodeQL alert is specific to ${{ steps.pr.outputs.review_decision }} at line 72.

This preserves behavior (the logic still checks the same values) while ensuring no GitHub Actions expressions are present inside the actual shell script body.


Suggested changeset 1
.github/workflows/auto-merge.yml

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml
--- a/.github/workflows/auto-merge.yml
+++ b/.github/workflows/auto-merge.yml
@@ -66,11 +66,11 @@
 
       - name: Check merge conditions
         id: check
+        env:
+          STATE: ${{ steps.pr.outputs.state }}
+          MERGEABLE: ${{ steps.pr.outputs.mergeable }}
+          REVIEW_DECISION: ${{ steps.pr.outputs.review_decision }}
         run: |
-          STATE="${{ steps.pr.outputs.state }}"
-          MERGEABLE="${{ steps.pr.outputs.mergeable }}"
-          REVIEW_DECISION="${{ steps.pr.outputs.review_decision }}"
-          
           echo "PR State: $STATE"
           echo "Mergeable: $MERGEABLE"
           echo "Review Decision: $REVIEW_DECISION"
EOF
@@ -66,11 +66,11 @@

- name: Check merge conditions
id: check
env:
STATE: ${{ steps.pr.outputs.state }}
MERGEABLE: ${{ steps.pr.outputs.mergeable }}
REVIEW_DECISION: ${{ steps.pr.outputs.review_decision }}
run: |
STATE="${{ steps.pr.outputs.state }}"
MERGEABLE="${{ steps.pr.outputs.mergeable }}"
REVIEW_DECISION="${{ steps.pr.outputs.review_decision }}"

echo "PR State: $STATE"
echo "Mergeable: $MERGEABLE"
echo "Review Decision: $REVIEW_DECISION"
Copilot is powered by AI and may make mistakes. Always verify output.

echo "PR State: $STATE"
echo "Mergeable: $MERGEABLE"
echo "Review Decision: $REVIEW_DECISION"

# Check conditions
CAN_MERGE=false

if [ "$STATE" == "OPEN" ] && \
[ "$MERGEABLE" == "MERGEABLE" ] && \
[ "$REVIEW_DECISION" == "APPROVED" ]; then
Comment on lines +81 to +83

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce base branch before auto-merge

The merge gate only checks state, mergeable, and reviewDecision and never verifies the PR’s base branch. Because CI also runs on develop, an approved PR targeting develop will satisfy these checks and be auto-merged, despite the workflow’s stated intent to merge only to main. Add a base-branch condition (e.g., github.event.pull_request.base.ref == 'main' or equivalent for workflow_run) to prevent unintended merges to non-main branches.

Useful? React with 👍 / 👎.

CAN_MERGE=true
fi

echo "can_merge=$CAN_MERGE" >> $GITHUB_OUTPUT

if [ "$CAN_MERGE" == "true" ]; then
echo "✓ All conditions met for auto-merge"
else
echo "⚠️ Conditions not met:"
[ "$STATE" != "OPEN" ] && echo " - PR is not open"
[ "$MERGEABLE" != "MERGEABLE" ] && echo " - PR has conflicts or is not mergeable"
[ "$REVIEW_DECISION" != "APPROVED" ] && echo " - PR is not approved"
fi

- name: Auto-merge PR
if: steps.check.outputs.can_merge == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ steps.pr.outputs.pr_number }}"

echo "🚀 Auto-merging PR #$PR_NUMBER to main..."

# Enable auto-merge with squash, with explicit error handling
if ! gh pr merge "$PR_NUMBER" --auto --squash --delete-branch; then
echo "❌ Failed to enable auto-merge for PR #$PR_NUMBER. Please check the PR status, required approvals, branch protection rules, and GitHub API availability."
exit 1
fi

echo "✓ Auto-merge enabled"

- name: Comment on PR
if: steps.check.outputs.can_merge == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ steps.pr.outputs.pr_number }}"

gh pr comment $PR_NUMBER --body "🤖 Auto-merge enabled. This PR will be merged automatically once all status checks pass."

- name: Summary
if: always()
run: |
echo "🎯 Auto-merge Summary"
echo ""
echo "PR: #${{ steps.pr.outputs.pr_number }}"
echo "State: ${{ steps.pr.outputs.state }}"

Check failure

Code scanning / CodeQL

Code injection Critical

Potential code injection in
${ steps.pr.outputs.state }
, which may be controlled by an external user (
pull_request_review
).
Potential code injection in
${ steps.pr.outputs.state }
, which may be controlled by an external user (
workflow_run
).

Copilot Autofix

AI about 10 hours ago

General fix approach: avoid using ${{ ... }} directly inside shell scripts for values derived from external data. Instead, assign those expression values to environment variables in the step configuration (env:) and then reference them in the script using the shell’s native $VAR syntax.

Best concrete fix here: in the Summary step, move all ${{ steps.* }} and ${{ job.status }} references out of the run: body into env: variables, then reference them with $VAR inside the script. This removes the vulnerable pattern and also addresses any related variants (not only state but pr_number, can_merge, job.status) in one place.

Specifically, in .github/workflows/auto-merge.yml:

  • For the Summary step (lines 124–132), add an env: section with variables like SUMMARY_PR_NUMBER, SUMMARY_STATE, SUMMARY_CAN_MERGE, and SUMMARY_JOB_STATUS, each assigned from the corresponding ${{ ... }} expression.
  • Update the run: block to echo using $SUMMARY_PR_NUMBER, $SUMMARY_STATE, $SUMMARY_CAN_MERGE, and $SUMMARY_JOB_STATUS instead of interpolated expressions.
    No new methods or imports are required; this is purely a YAML/workflow configuration change.
Suggested changeset 1
.github/workflows/auto-merge.yml

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml
--- a/.github/workflows/auto-merge.yml
+++ b/.github/workflows/auto-merge.yml
@@ -123,10 +123,15 @@
 
       - name: Summary
         if: always()
+        env:
+          SUMMARY_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
+          SUMMARY_STATE: ${{ steps.pr.outputs.state }}
+          SUMMARY_CAN_MERGE: ${{ steps.check.outputs.can_merge }}
+          SUMMARY_JOB_STATUS: ${{ job.status }}
         run: |
           echo "🎯 Auto-merge Summary"
           echo ""
-          echo "PR: #${{ steps.pr.outputs.pr_number }}"
-          echo "State: ${{ steps.pr.outputs.state }}"
-          echo "Can merge: ${{ steps.check.outputs.can_merge }}"
-          echo "Status: ${{ job.status }}"
+          echo "PR: #$SUMMARY_PR_NUMBER"
+          echo "State: $SUMMARY_STATE"
+          echo "Can merge: $SUMMARY_CAN_MERGE"
+          echo "Status: $SUMMARY_JOB_STATUS"
EOF
@@ -123,10 +123,15 @@

- name: Summary
if: always()
env:
SUMMARY_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
SUMMARY_STATE: ${{ steps.pr.outputs.state }}
SUMMARY_CAN_MERGE: ${{ steps.check.outputs.can_merge }}
SUMMARY_JOB_STATUS: ${{ job.status }}
run: |
echo "🎯 Auto-merge Summary"
echo ""
echo "PR: #${{ steps.pr.outputs.pr_number }}"
echo "State: ${{ steps.pr.outputs.state }}"
echo "Can merge: ${{ steps.check.outputs.can_merge }}"
echo "Status: ${{ job.status }}"
echo "PR: #$SUMMARY_PR_NUMBER"
echo "State: $SUMMARY_STATE"
echo "Can merge: $SUMMARY_CAN_MERGE"
echo "Status: $SUMMARY_JOB_STATUS"
Copilot is powered by AI and may make mistakes. Always verify output.
echo "Can merge: ${{ steps.check.outputs.can_merge }}"
echo "Status: ${{ job.status }}"
31 changes: 30 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,40 @@ jobs:
print(f'✓ registry.yaml: {len(reg[\"orgs\"])} orgs, {len(reg[\"rules\"])} rules')
"

# Test sync functionality
test-sync:
name: Test Sync
runs-on: ubuntu-latest

permissions:
contents: read

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}

- name: Install dependencies
run: pip install pyyaml

- name: Run sync tests
run: |
chmod +x tests/test_sync.py
python tests/test_sync.py

# Signal summary job
summary:
name: CI Summary
runs-on: ubuntu-latest
needs: [lint, test-operator, test-dispatcher, test-webhooks, validate-config]
needs: [lint, test-operator, test-dispatcher, test-webhooks, validate-config, test-sync]
if: always()

permissions:
contents: read

steps:
- name: Check results
run: |
Expand All @@ -196,3 +224,4 @@ jobs:
echo " Dispatcher: ${{ needs.test-dispatcher.result }}"
echo " Webhooks: ${{ needs.test-webhooks.result }}"
echo " Config: ${{ needs.validate-config.result }}"
echo " Sync: ${{ needs.test-sync.result }}"
197 changes: 197 additions & 0 deletions .github/workflows/sync-to-orgs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# Sync shared workflows and configs to other org repos
# This workflow pushes templates and shared files to target organizations

name: Sync to Orgs

on:
push:
branches: [main]
paths:
- 'templates/**'
- '.github/workflows/**'
- '!.github/workflows/sync-to-orgs.yml'
- 'routes/registry.yaml'
workflow_dispatch:
inputs:
target_orgs:
description: 'Target orgs (comma-separated, or "all")'
required: false
default: 'all'
type: string
dry_run:
description: 'Dry run (test without pushing)'
required: false
type: boolean
default: false

jobs:
sync:
name: Sync to Organizations
runs-on: ubuntu-latest

permissions:
contents: read

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install dependencies
run: pip install pyyaml requests

- name: Load registry
id: registry
run: |
python -c "
import yaml
import json

with open('routes/registry.yaml') as f:
registry = yaml.safe_load(f)

# Extract active orgs
orgs = []
for code, org in registry.get('orgs', {}).items():
if org.get('status') == 'active':
orgs.append({
'code': code,
'name': org['name'],
'github': org['github'],
'repos': org.get('repos', [])
})

print(f'Found {len(orgs)} active orgs')
for org in orgs:
print(f' - {org[\"code\"]}: {org[\"name\"]}')

# Output for next steps
with open('$GITHUB_OUTPUT', 'a') as f:
f.write(f'orgs={json.dumps(orgs)}\\n')
Comment on lines +73 to +75
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this Python step, open('$GITHUB_OUTPUT', 'a') writes to a literal file named $GITHUB_OUTPUT rather than the Actions output file path. As a result, steps.registry.outputs.orgs will be empty and the dispatch step will likely fail when it tries to json.loads the missing/empty env var. Use the GITHUB_OUTPUT environment variable (e.g., via os.environ['GITHUB_OUTPUT']) or write the output from the shell instead.

Copilot uses AI. Check for mistakes.
"

- name: Dispatch to target orgs
env:
GITHUB_TOKEN: ${{ secrets.DISPATCH_TOKEN || secrets.GITHUB_TOKEN }}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

secrets.GITHUB_TOKEN is repository-scoped and typically cannot call repository_dispatch on other repositories (even within the same org). Falling back to it will make sync appear configured but fail at runtime; consider requiring DISPATCH_TOKEN (fail fast if missing) or only using the fallback when dispatching to the current repo.

Suggested change
GITHUB_TOKEN: ${{ secrets.DISPATCH_TOKEN || secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.DISPATCH_TOKEN }}

Copilot uses AI. Check for mistakes.
TARGET_ORGS: ${{ inputs.target_orgs || 'all' }}
DRY_RUN: ${{ inputs.dry_run || 'false' }}
ORGS_JSON: ${{ steps.registry.outputs.orgs }}
run: |
echo "🎯 Dispatching sync to organizations..."
echo ""

python -c "
import os
import json
import requests

token = os.environ.get('GITHUB_TOKEN')
target_input = os.environ.get('TARGET_ORGS', 'all')
dry_run = os.environ.get('DRY_RUN', 'false').lower() == 'true'
orgs = json.loads(os.environ.get('ORGS_JSON', '[]'))

# Parse target orgs
if target_input == 'all':
target_codes = [org['code'] for org in orgs]
else:
target_codes = [c.strip() for c in target_input.split(',')]

print(f'Target orgs: {target_codes}')
print(f'Dry run: {dry_run}')
print('')

# Track failures
failures = []

# Dispatch to each target org
for org in orgs:
if org['code'] not in target_codes:
continue

print(f'📡 {org[\"code\"]}: {org[\"name\"]}')

# For each repo in the org, dispatch a workflow
for repo in org.get('repos', []):
repo_url = repo['url']

# Extract owner/repo from URL
parts = repo_url.replace('https://github.com/', '').split('/')
if len(parts) < 2:
continue

owner = parts[0]
repo_slug = parts[1]

print(f' -> {owner}/{repo_slug}')

if dry_run:
print(f' [DRY RUN] Would dispatch to {owner}/{repo_slug}')
continue

# Send repository_dispatch event
url = f'https://api.github.com/repos/{owner}/{repo_slug}/dispatches'
headers = {
'Authorization': f'token {token}',
'Accept': 'application/vnd.github.v3+json'
}
# Derive head commit timestamp from the GitHub event payload
head_commit_timestamp = None
event_path = os.environ.get('GITHUB_EVENT_PATH')
if event_path:
try:
with open(event_path, 'r', encoding='utf-8') as f:
event = json.load(f)
head_commit = event.get('head_commit') or {}
head_commit_timestamp = head_commit.get('timestamp')
except Exception:
head_commit_timestamp = None
payload = {
'event_type': 'sync_from_bridge',
'client_payload': {
'source': 'BlackRoad-OS/.github',
'ref': os.environ.get('GITHUB_SHA', 'main'),
'timestamp': head_commit_timestamp
}
}

try:
resp = requests.post(url, json=payload, headers=headers, timeout=30)
if resp.status_code == 204:
print(f' ✓ Dispatched')
elif resp.status_code == 404:
msg = f'{owner}/{repo_slug}: Repo not found or no dispatch workflow'
print(f' ⚠️ {msg}')
failures.append(msg)
else:
msg = f'{owner}/{repo_slug}: HTTP {resp.status_code}'
print(f' ❌ {msg}')
failures.append(msg)
except Exception as e:
msg = f'{owner}/{repo_slug}: {e}'
print(f' ❌ {msg}')
failures.append(msg)

print('')
if failures:
print(f'⚠️ {len(failures)} dispatch(es) failed:')
for failure in failures:
print(f' - {failure}')
print('')
Comment on lines +180 to +184
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dispatch script collects failures but never exits non-zero when dispatches fail, so the workflow can report success even when nothing was synced. Consider failing the job when failures is non-empty (and potentially exempting dry_run) so broken dispatches surface immediately.

Copilot uses AI. Check for mistakes.
print('Note: 404 errors are expected if target repos have not set up dispatch workflows yet.')
else:
print('✓ All dispatches successful')
"

- name: Summary
run: |
echo "📡 Sync Summary"
echo ""
echo "Status: ${{ job.status }}"
echo "Trigger: ${{ github.event_name }}"
echo "Branch: ${{ github.ref_name }}"
echo "Commit: ${{ github.sha }}"
Loading
Loading