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
129 changes: 129 additions & 0 deletions .github/workflows/auto-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# 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]
Comment on lines +7 to +8
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

The workflow triggers on all pull_request_review submissions (line 7-8), not just approvals. This means it will run for comments, changes requested, and dismissals as well. While the workflow does check for approval status later (line 95), it's more efficient to filter at the trigger level. Consider adding a filter: types: [submitted] with if: github.event.review.state == 'approved' at the job level, or change the trigger to only fire on approvals.

Copilot uses AI. Check for mistakes.
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_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')

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 18 days ago

In general, to fix code injection issues in GitHub Actions, never interpolate untrusted ${{ ... }} expressions directly inside run: scripts. Instead, assign the value to an environment variable via env: (where the expression is safely resolved by the runner before the shell starts) and then reference it using the shell’s native syntax (e.g. $VAR or "${VAR}"). This prevents the untrusted data from being interpreted as shell syntax.

For this workflow, the only problematic usage is ${{ github.event.workflow_run.head_branch }} inside the PR_NUMBER=$(...) command. We should move github.event.workflow_run.head_branch into an env var (e.g. HEAD_BRANCH) on the step, and then use $HEAD_BRANCH solely via shell expansion. Additionally, any log messages that currently embed ${{ github.event.workflow_run.head_branch }} inside the script should be updated to use $HEAD_BRANCH as well, so the script doesn’t reference the expression syntax at all.

Concretely:

  • In the “Get PR info” step, add HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} under env: (alongside GITHUB_TOKEN).
  • Change line 41 from using "${{ github.event.workflow_run.head_branch }}" inside the jq filter to using "${HEAD_BRANCH}".
  • Change the error message on line 48 from ... ${{ github.event.workflow_run.head_branch }} to $HEAD_BRANCH.

No new methods or external libraries are needed; we only adjust the YAML workflow and rely on normal Bash variable expansion.


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,20 +32,21 @@
         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)
+            PR_NUMBER=$(gh pr list --json number,headRefName --jq ".[] | select(.headRefName==\"${HEAD_BRANCH}\") | .number" | head -1)
           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 branch ${{ github.event.workflow_run.head_branch }}"
+            echo "No PR number found for branch $HEAD_BRANCH"
             exit 1
           fi
           
EOF
@@ -32,20 +32,21 @@
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)
PR_NUMBER=$(gh pr list --json number,headRefName --jq ".[] | select(.headRefName==\"${HEAD_BRANCH}\") | .number" | head -1)
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 branch ${{ github.event.workflow_run.head_branch }}"
echo "No PR number found for branch $HEAD_BRANCH"
exit 1
fi

Copilot is powered by AI and may make mistakes. Always verify output.
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 branch ${{ github.event.workflow_run.head_branch }}"

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 18 days ago

In general, the fix is to avoid using ${{ github.* }} (or any untrusted expression) directly inside the shell script body. Instead, pass the potentially untrusted value into the step via env: and reference it inside the script using native shell variable expansion ($VAR). This way, the expression engine only assigns to an environment variable, and the shell never sees ${{ ... }} syntax and cannot misinterpret it as part of a command.

For this workflow, the best minimal fix without changing functionality is:

  1. Add an environment variable (e.g., HEAD_BRANCH) in the Get PR info step, assigning it from ${{ github.event.workflow_run.head_branch }}.
  2. Replace the inline uses of ${{ github.event.workflow_run.head_branch }} inside the run: script with $HEAD_BRANCH.

This preserves the behavior (same branch name value) but follows GitHub’s recommended pattern and eliminates the code-injection warning. Concretely, edit .github/workflows/auto-merge.yml:

  • In the Get PR info step’s env: block, add HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}.
  • In the PR_NUMBER=$(...) line, replace the expression inside the jq filter from "${{ github.event.workflow_run.head_branch }}" to "$HEAD_BRANCH".
  • In the error echo line, replace ${{ github.event.workflow_run.head_branch }} with $HEAD_BRANCH.

No new methods or external tools are required; this is purely a YAML/inline shell change within 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,20 +32,21 @@
         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)
+            PR_NUMBER=$(gh pr list --json number,headRefName --jq ".[] | select(.headRefName==\"$HEAD_BRANCH\") | .number" | head -1)
           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 branch ${{ github.event.workflow_run.head_branch }}"
+            echo "No PR number found for branch $HEAD_BRANCH"
             exit 1
           fi
           
EOF
@@ -32,20 +32,21 @@
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)
PR_NUMBER=$(gh pr list --json number,headRefName --jq ".[] | select(.headRefName==\"$HEAD_BRANCH\") | .number" | head -1)
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 branch ${{ github.event.workflow_run.head_branch }}"
echo "No PR number found for branch $HEAD_BRANCH"
exit 1
fi

Copilot is powered by AI and may make mistakes. Always verify output.
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

The error message on line 48 references github.event.workflow_run.head_branch even when the event type is pull_request_review. This will cause confusing error messages when a pull_request_review event doesn't have a PR number. Consider making this error message conditional on the event type or using a generic message that doesn't reference event-specific fields.

Suggested change
echo "No PR number found for branch ${{ github.event.workflow_run.head_branch }}"
echo "No PR number found for event ${{ github.event_name }}"

Copilot uses AI. Check for mistakes.
exit 1
Comment on lines +47 to +49

Choose a reason for hiding this comment

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

P1 Badge Guard workflow_run when no PR exists

On workflow_run events, CI also runs for direct pushes to main/develop (see .github/workflows/ci.yml on.push.branches), so there is often no associated PR. In that case PR_NUMBER will be empty and this step exits 1, causing the auto-merge workflow to fail on every push/merge to those branches. Consider skipping the job when github.event.workflow_run.pull_requests is empty or explicitly gating workflow_run to PR-triggered runs to avoid persistent failures.

Useful? React with 👍 / 👎.

Comment on lines +48 to +49
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

The workflow will fail with "exit 1" when no PR number is found for a branch in workflow_run events. However, this is normal behavior when CI runs on direct commits to main (not from a PR). The workflow should handle this case gracefully by exiting early without error, rather than failing the entire workflow. Consider checking if this is a PR-related run before attempting to find the PR number, or exit with success (exit 0) when no PR is found.

Suggested change
echo "No PR number found for branch ${{ github.event.workflow_run.head_branch }}"
exit 1
if [ "${{ github.event_name }}" == "workflow_run" ]; then
echo "No PR number found for branch ${{ github.event.workflow_run.head_branch }}; this workflow_run is not associated with a PR. Exiting successfully."
exit 0
else
echo "No PR number found for event type: ${{ github.event_name }}"
exit 1
fi

Copilot uses AI. Check for mistakes.
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 18 days ago

General fix approach: avoid using ${{ ... }} expression interpolation directly inside shell run: blocks for data that might be tainted. Instead, assign those values to environment variables at the step level using ${{ ... }} once, then access them inside the script using the native shell syntax ($VAR). This prevents GitHub’s expression language from injecting into the shell parsing context.

Best concrete fix here: in the “Check merge conditions” step, move the three interpolations of steps.pr.outputs.state, mergeable, and review_decision out of the script body and into an env: section, then in the script simply reference $STATE, $MERGEABLE, and $REVIEW_DECISION. This preserves existing behavior (the same strings are passed, the same comparisons and outputs occur), but closes the code injection pattern flagged by CodeQL. Only that step needs modification; other uses like if: steps.check.outputs.can_merge == 'true' are in YAML expressions, not shell.

Required changes in .github/workflows/auto-merge.yml:

  • Add an env: block to the “Check merge conditions” step that maps:
    • STATE: ${{ steps.pr.outputs.state }}
    • MERGEABLE: ${{ steps.pr.outputs.mergeable }}
    • REVIEW_DECISION: ${{ steps.pr.outputs.review_decision }}
  • Remove the three initial assignment lines inside the run: block that currently assign those via ${{ ... }}.
  • Keep all subsequent usage of $STATE, $MERGEABLE, $REVIEW_DECISION unchanged, since they already use safe shell variable expansion.

No new methods or external libraries are needed; this is pure YAML and shell syntax.


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 18 days ago

General approach: Avoid using ${{ steps.pr.outputs.* }} directly inside shell code. Instead, map those values into environment variables in the step definition (env:) and then read them using standard shell variable expansion ($VAR). This matches GitHub’s recommended mitigation and prevents CodeQL from flagging code injection, while preserving behavior.

Best concrete fix here:

  • In the “Check merge conditions” step, remove inline GitHub expression usage within the run: script:
    • Replace:
      STATE="${{ steps.pr.outputs.state }}"
      MERGEABLE="${{ steps.pr.outputs.mergeable }}"
      REVIEW_DECISION="${{ steps.pr.outputs.review_decision }}"
    • With references to environment variables (e.g., STATE="$STATE", etc.).
  • Configure those environment variables using the step’s env: block:
    env:
      STATE: ${{ steps.pr.outputs.state }}
      MERGEABLE: ${{ steps.pr.outputs.mergeable }}
      REVIEW_DECISION: ${{ steps.pr.outputs.review_decision }}
  • Optionally, for consistency and future-proofing, we could do the same style for other steps, but the reported taint path is specifically for mergeable at line 71 in this step, so we will minimally adjust this step only.
  • No additional imports, tools, or external dependencies are needed; we just restructure how data is passed into the shell.

Concrete changes (line-level):

  • In .github/workflows/auto-merge.yml, in the “Check merge conditions” step (around lines 67–76):
    • Add an env: section with STATE, MERGEABLE, and REVIEW_DECISION populated from ${{ steps.pr.outputs.* }}.
    • Simplify the run: script to rely on those shell environment variables rather than embedding expressions inside the script.

This preserves all existing logic and outputs, only changing how the values are passed to the shell.

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 18 days ago

To fix the problem, we should stop interpolating ${{ steps.pr.outputs.review_decision }} directly into the shell script and instead pass it through an environment variable, then read it using native shell variable syntax ($REVIEW_DECISION) inside the run: block. This matches GitHub’s recommended pattern and prevents expression-based injection.

Concretely, in the Check merge conditions step (lines 68–87), we will:

  • Add an env: section that maps the step outputs (state, mergeable, review_decision) to environment variables (STATE, MERGEABLE, REVIEW_DECISION).
  • Change the first lines of the script so that STATE, MERGEABLE, and REVIEW_DECISION are either used directly from the environment or explicitly assigned from $STATE, $MERGEABLE, $REVIEW_DECISION using pure shell syntax, without any ${{ ... }} interpolation inside run:.

This keeps the workflow behavior identical: it still checks the same conditions and sets the same can_merge output, but the untrusted data path into the shell is now via a normal environment variable, which GitHub scopes safely at the expression level. No new imports or external dependencies are needed, and all changes are confined to the .github/workflows/auto-merge.yml file in the Check merge conditions 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
@@ -66,10 +66,14 @@
 
       - 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 }}"
+          STATE="$STATE"
+          MERGEABLE="$MERGEABLE"
+          REVIEW_DECISION="$REVIEW_DECISION"
           
           echo "PR State: $STATE"
           echo "Mergeable: $MERGEABLE"
EOF
@@ -66,10 +66,14 @@

- 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 }}"
STATE="$STATE"
MERGEABLE="$MERGEABLE"
REVIEW_DECISION="$REVIEW_DECISION"

echo "PR State: $STATE"
echo "Mergeable: $MERGEABLE"
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
gh pr merge $PR_NUMBER --auto --squash --delete-branch

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 18 days ago

In general, to fix this class of issues in GitHub Actions, avoid using ${{ ... }} expressions directly inside shell scripts (run: blocks) with values that might be influenced by untrusted input. Instead, map those values into environment variables with workflow expression syntax, and then read them within the script using the shell’s own variable syntax (e.g., $VAR), which is what GitHub recommends.

Concretely for this workflow, the problematic use is in the Summary step:

- name: Summary
  if: always()
  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 }}"

We should (a) pass the needed values into the step via env: using ${{ ... }} only there, and (b) in the run: block, refer to them with shell syntax ($PR_NUMBER, $STATE, etc.). This removes direct interpolation of potentially tainted data into the script and satisfies CodeQL’s recommendation.

Implementation details, all in .github/workflows/auto-merge.yml:

  • Modify the Summary step to add an env: section defining:
    • PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
    • STATE: ${{ steps.pr.outputs.state }}
    • CAN_MERGE: ${{ steps.check.outputs.can_merge }}
    • JOB_STATUS: ${{ job.status }}
  • Update the run: script in that step to use those environment variables:
    • echo "PR: #$PR_NUMBER"
    • echo "State: $STATE"
    • echo "Can merge: $CAN_MERGE"
    • echo "Status: $JOB_STATUS"
  • No new methods or external dependencies are needed; we only adjust YAML structure and shell variable usage.

This single change addresses all alert variants related to ${{ steps.pr.outputs.state }} in this 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
@@ -120,10 +120,15 @@
 
       - name: Summary
         if: always()
+        env:
+          PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
+          STATE: ${{ steps.pr.outputs.state }}
+          CAN_MERGE: ${{ steps.check.outputs.can_merge }}
+          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: #$PR_NUMBER"
+          echo "State: $STATE"
+          echo "Can merge: $CAN_MERGE"
+          echo "Status: $JOB_STATUS"
EOF
@@ -120,10 +120,15 @@

- name: Summary
if: always()
env:
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
STATE: ${{ steps.pr.outputs.state }}
CAN_MERGE: ${{ steps.check.outputs.can_merge }}
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: #$PR_NUMBER"
echo "State: $STATE"
echo "Can merge: $CAN_MERGE"
echo "Status: $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 }}"
186 changes: 186 additions & 0 deletions .github/workflows/sync-to-orgs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# 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/**'
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

The workflow triggers on changes to .github/workflows/** which includes this workflow file itself. This could potentially cause an infinite loop if the sync process modifies workflow files and commits them back. While this PR shows the workflow dispatching to other repos (not modifying this repo), this path pattern should be more specific to avoid triggering on unrelated workflow changes. Consider being more specific about which workflows should trigger sync, or exclude this specific workflow file from the trigger path.

Suggested change
- '.github/workflows/**'
- '.github/workflows/**'
- '!.github/workflows/sync-to-orgs.yml'

Copilot uses AI. Check for mistakes.
- '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: 0

- 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')
"

- name: Dispatch to target orgs
env:
GITHUB_TOKEN: ${{ secrets.DISPATCH_TOKEN || secrets.GITHUB_TOKEN }}
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_name = repo['name']
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'
}
payload = {
'event_type': 'sync_from_bridge',
'client_payload': {
'source': 'BlackRoad-OS/.github',
'ref': os.environ.get('GITHUB_SHA', 'main'),
'timestamp': '${{ github.event.head_commit.timestamp }}'
Comment on lines +142 to +147
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

The timestamp field uses GitHub Actions syntax '${{ github.event.head_commit.timestamp }}' inside a Python string literal, which won't be interpolated. This will result in the literal string being sent rather than the actual timestamp value. To fix this, the timestamp should be passed as an environment variable and accessed via os.environ.get(), similar to how GITHUB_SHA is handled on line 146.

Suggested change
payload = {
'event_type': 'sync_from_bridge',
'client_payload': {
'source': 'BlackRoad-OS/.github',
'ref': os.environ.get('GITHUB_SHA', 'main'),
'timestamp': '${{ github.event.head_commit.timestamp }}'
# Derive head commit timestamp from the GitHub event payload
import json
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

Copilot uses AI. Check for mistakes.
}
}

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('')
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