name: 'Tools: PR Conflict Checker' on: # zizmor: ignore[dangerous-triggers] - intentional: needs write access for conflict labels/comments, checkout uses PR head SHA for read-only grep pull_request_target: types: - 'opened' - 'synchronize' - 'reopened' branches: - 'master' - 'v5.*' concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: {} jobs: check-conflicts: runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read pull-requests: write issues: write steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit - name: Checkout PR head uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 1 persist-credentials: false # No write token in the untrusted PR-head tree; public repo so base fetch/changed-files work unauthenticated - name: Fetch PR base ref for tj-actions/changed-files env: BASE_REF: ${{ github.event.pull_request.base.ref }} run: git fetch --depth=1 origin "${BASE_REF}" - name: Get changed files id: changed-files uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: '**' safe_output: false # Raw paths (list read via env var, injection-safe); default escaping backslash-quotes chars like () and breaks the -f test separator: "\n" # Newline-delimited so the reader tolerates spaces and glob chars in paths - name: Check for conflict markers id: conflict-check run: | echo "Checking for conflict markers in changed files..." CONFLICT_FILES="" HAS_CONFLICTS=false # Read newline-delimited paths so spaces/globs neither word-split nor glob-expand while IFS= read -r file; do [ -n "$file" ] || continue [ -f "$file" ] || continue echo "Checking file: $file" if grep -qE '^(<<<<<<<|=======|>>>>>>>)' "$file" 2>/dev/null; then echo "Conflict markers found in: $file" CONFLICT_FILES="${CONFLICT_FILES}- \`${file}\`"$'\n' HAS_CONFLICTS=true fi done <<< "$STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES" if [ "$HAS_CONFLICTS" = true ]; then echo "has_conflicts=true" >> "$GITHUB_OUTPUT" { echo "conflict_files<> "$GITHUB_OUTPUT" echo "Conflict markers detected" else echo "has_conflicts=false" >> "$GITHUB_OUTPUT" echo "No conflict markers found in changed files" fi env: STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} - name: Check base-branch mergeability id: merge-check env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} run: | MERGEABLE=null # GitHub computes mergeability async, so .mergeable is null until ready; poll until resolved for attempt in 1 2 3 4 5; do MERGEABLE=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.mergeable') if [ "$MERGEABLE" != "null" ]; then break fi echo "Attempt ${attempt}: mergeability not computed yet, retrying..." sleep 3 done # Keep 'unknown' distinct from 'clean' so we never assert a clean merge we could not confirm case "$MERGEABLE" in false) STATUS=conflict; echo "PR branch cannot be merged cleanly into its base branch" ;; true) STATUS=clean; echo "PR branch merges cleanly into its base branch" ;; *) STATUS=unknown; echo "::warning::Mergeability did not resolve after retries; leaving it undetermined" ;; esac echo "merge_status=${STATUS}" >> "$GITHUB_OUTPUT" - name: Manage conflict label env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} HAS_CONFLICTS: ${{ steps.conflict-check.outputs.has_conflicts }} MERGE_STATUS: ${{ steps.merge-check.outputs.merge_status }} run: | LABEL_NAME="has-conflicts" if [ "$HAS_CONFLICTS" = "true" ] || [ "$MERGE_STATUS" = "conflict" ]; then echo "Adding conflict label to PR #${PR_NUMBER}..." gh pr edit "$PR_NUMBER" --add-label "$LABEL_NAME" --repo ${{ github.repository }} || true elif [ "$MERGE_STATUS" = "unknown" ]; then # Don't drop the label on an undetermined merge state; a later run will settle it echo "Mergeability undetermined; leaving label unchanged" else echo "Removing conflict label from PR #${PR_NUMBER}..." gh pr edit "$PR_NUMBER" --remove-label "$LABEL_NAME" --repo ${{ github.repository }} || true fi - name: Find existing comment uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0 id: find-comment with: issue-number: ${{ github.event.pull_request.number }} comment-author: 'github-actions[bot]' body-includes: '' - name: Create or update comment uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 with: comment-id: ${{ steps.find-comment.outputs.comment-id }} issue-number: ${{ github.event.pull_request.number }} edit-mode: replace body: | ${{ (steps.conflict-check.outputs.has_conflicts == 'true' || steps.merge-check.outputs.merge_status == 'conflict') && '⚠️ **Conflicts Detected**' || (steps.merge-check.outputs.merge_status == 'unknown' && 'ℹ️ **Conflict Check Incomplete**' || '✅ **No Conflicts**') }} ${{ steps.conflict-check.outputs.has_conflicts == 'true' && format(' **Conflict markers** are present in the following files: {0} Resolve them by removing every `<<<<<<<`, `=======`, and `>>>>>>>` marker, then commit and push.', steps.conflict-check.outputs.conflict_files) || '' }} ${{ steps.merge-check.outputs.merge_status == 'conflict' && ' **Merge conflict with the base branch.** This PR cannot be merged cleanly. Update your branch with the latest base (rebase or merge) and resolve the conflicts.' || '' }} ${{ steps.merge-check.outputs.merge_status == 'unknown' && ' GitHub had not finished computing mergeability, so base-branch conflict status could not be verified on this run.' || '' }} ${{ (steps.conflict-check.outputs.has_conflicts != 'true' && steps.merge-check.outputs.merge_status == 'clean') && ' No conflict markers, and the branch merges cleanly into its base.' || '' }} - name: Fail workflow if conflicts detected if: steps.conflict-check.outputs.has_conflicts == 'true' || steps.merge-check.outputs.merge_status == 'conflict' env: HAS_CONFLICTS: ${{ steps.conflict-check.outputs.has_conflicts }} MERGE_STATUS: ${{ steps.merge-check.outputs.merge_status }} run: | [ "$HAS_CONFLICTS" = "true" ] && echo "::error::Conflict markers detected in changed files" [ "$MERGE_STATUS" = "conflict" ] && echo "::error::PR branch has merge conflicts with the base branch" exit 1