Skip to content

Group Dependabot PRs #155

Group Dependabot PRs

Group Dependabot PRs #155

# Workflow: Group Dependabot PRs
# Description:
# This GitHub Actions workflow automatically groups open Dependabot PRs by ecosystem.
# It uses a sophisticated "Smart Apply" strategy for conflict resolution,
# ensuring that dependency updates are combined correctly even when they conflict.
#
# Key Features:
# - Chronological processing of PRs (sorted by PR number).
# - Intelligent Conflict Resolution: Instead of merging complex lockfiles, it applies
# changes to the manifest file (e.g., package.json) and regenerates a fresh,
# consistent lockfile using the native package manager (npm, yarn, pip).
# - Stale Branch Handling: Automatically rebases the grouped branch against the target
# branch (main) at the start of each run to incorporate the latest changes.
# - Deduplication: Checks if a PR has already been applied to avoid duplicates.
# - Multi-Ecosystem Support: Configurable grouping and conflict resolution for npm, yarn, and pip.
# - Dry-Run Mode: Allows for safe testing and validation of the workflow's logic.
name: Group Dependabot PRs
on:
schedule:
- cron: '0 0 * * *' # Run daily at midnight UTC
workflow_dispatch:
inputs:
group_config_pip:
description: "Group name for pip ecosystem"
required: false
default: "backend"
group_config_npm:
description: "Group name for npm ecosystem"
required: false
default: "frontend"
group_config_yarn:
description: "Group name for yarn ecosystem"
required: false
default: "frontend"
dry_run:
description: "Run in dry-run mode (no changes will be pushed or PRs created/closed)"
required: false
default: false
type: boolean
jobs:
group-dependabot-prs:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_BRANCH: "main"
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
GROUP_CONFIG_PIP: ${{ github.event.inputs.group_config_pip || 'backend' }}
GROUP_CONFIG_NPM: ${{ github.event.inputs.group_config_npm || 'frontend' }}
GROUP_CONFIG_YARN: ${{ github.event.inputs.group_config_yarn || 'frontend' }}
steps:
- name: Checkout default branch
uses: actions/checkout@v4
- name: Set up Git
run: |
git config --global user.name "github-actions"
git config --global user.email "[email protected]"
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install Yarn
run: npm install -g yarn
- name: Install required tools
run: |
sudo apt-get update
sudo apt-get install -y jq gh
- name: Enable strict error handling
shell: bash
run: |
set -euo pipefail
- name: Fetch open Dependabot PRs targeting main (sorted by PR number)
id: fetch_prs
run: |
gh pr list \
--search "author:dependabot[bot] base:$TARGET_BRANCH is:open" \
--limit 100 \
--json number,title,headRefName,labels,files,url \
--jq '[.[] | {number, title, url, ref: .headRefName, labels: [.labels[].name], files: [.files[].path]}] | sort_by(.number)' > prs.json
cat prs.json
- name: Validate prs.json
run: |
jq empty prs.json 2> jq_error.log || { echo "Malformed JSON in prs.json: $(cat jq_error.log)"; exit 1; }
- name: Check if any PRs exist
id: check_prs
run: |
count=$(jq length prs.json)
echo "Found $count PRs"
if [ "$count" -eq 0 ]; then
echo "No PRs to group. Exiting."
echo "skip=true" >> $GITHUB_OUTPUT
fi
- name: Exit early if no PRs
if: steps.check_prs.outputs.skip == 'true'
run: exit 0
- name: Dry-run validation (CI/test only)
if: env.DRY_RUN == 'true'
run: |
echo "Running in dry-run mode. No changes will be pushed or PRs created/closed."
- name: Group PRs by ecosystem and apply with rebase
run: |
declare -A GROUP_CONFIG=(
[pip]="${GROUP_CONFIG_PIP:-backend}"
[npm]="${GROUP_CONFIG_NPM:-frontend}"
[yarn]="${GROUP_CONFIG_YARN:-frontend}"
)
mkdir -p grouped
jq -c '.[]' prs.json | while read pr; do
ref=$(echo "$pr" | jq -r '.ref')
number=$(echo "$pr" | jq -r '.number')
group="misc"
for key in "${!GROUP_CONFIG[@]}"; do
if [[ "$ref" == *"$key"* ]]; then
group="${GROUP_CONFIG[$key]}"
break
fi
done
echo "$number $ref $group" >> grouped/$group.txt
done
shopt -s nullglob
grouped_files=(grouped/*.txt)
if [ ${#grouped_files[@]} -eq 0 ]; then
echo "No groups were formed. Exiting."
exit 0
fi
declare -A pr_metadata_map
while IFS=$'\t' read -r number title url labels; do
pr_metadata_map["$number"]="$title|$url|$labels"
done < <(jq -r '.[] | "\(.number)\t\(.title)\t\(.url)\t\(.labels | join(","))"' prs.json)
git fetch origin "$TARGET_BRANCH"
for file in "${grouped_files[@]}"; do
group_name=$(basename "$file" .txt)
safe_group_name=$(echo "$group_name" | tr -c '[:alnum:]_-' '-' | sed 's/--/-/g' | sed 's/-$//')
branch_name="security/grouped-${safe_group_name}-updates"
echo "Processing group: $group_name (branch: $branch_name)"
if git rev-parse --verify "origin/$branch_name" >/dev/null 2>&1; then
echo "Existing grouped branch 'origin/$branch_name' found. Checking it out."
git checkout -B "$branch_name" "origin/$branch_name"
echo "Rebasing '$branch_name' onto 'origin/$TARGET_BRANCH' to incorporate latest changes."
if ! git rebase "origin/$TARGET_BRANCH"; then
echo "Aborting rebase due to conflicts. The grouped PR for '$group_name' will not be updated this run."
git rebase --abort
continue
fi
else
echo "No existing grouped branch found. Creating new branch from 'origin/$TARGET_BRANCH'."
git checkout -B "$branch_name" "origin/$TARGET_BRANCH"
fi
applied_pr_numbers=()
while read -r number ref group; do
pr_title_grep=$(echo "${pr_metadata_map["$number"]}" | cut -d'|' -f1)
# if git log --oneline --grep="$pr_title_grep"; then
# echo "PR #$number seems to be already applied. Skipping."
# continue
# fi
echo "Applying PR #$number ($ref) to $branch_name"
git fetch origin "$ref"
if git cherry-pick FETCH_HEAD; then
echo "✅ Successfully applied PR #$number without conflicts."
applied_pr_numbers+=($number)
else
echo "❌ Conflict found with PR #$number. Attempting 'Clean and Update' strategy."
manifest_files_conflicted=$(git diff --name-only --diff-filter=U | grep -E "package.json|requirements.txt|pyproject.toml" || true)
if [ -n "$manifest_files_conflicted" ]; then
echo "Conflict is in a known manifest. Proceeding with resolution."
# Get a list of ALL conflicted files (manifests and lockfiles).
all_conflicted_files=$(git diff --name-only --diff-filter=U)
# First, resolve ALL conflicts by accepting our current version for all files.
# This gives us a clean, non-conflicted working directory to apply the new changes to.
echo "Resolving conflicts by accepting 'ours' for: $all_conflicted_files"
git checkout --ours $all_conflicted_files
# Now, intelligently apply the dependency changes from the incoming PR.
case "$group_name" in
frontend)
pkg_json_path=$(echo "$manifest_files_conflicted" | grep "package.json" | head -n1)
project_dir=$(dirname "$pkg_json_path")
echo "Extracting dependencies from incoming PR for $pkg_json_path"
git show FETCH_HEAD:"$pkg_json_path" | jq -r '.dependencies + .devDependencies | to_entries[] | "\(.key)@\(.value)"' > deps_to_install.txt
if [ -s deps_to_install.txt ]; then
if [ -f "$project_dir/yarn.lock" ]; then
echo "Using yarn to add/update dependencies in $project_dir..."
(cd "$project_dir" && xargs yarn add < "$GITHUB_WORKSPACE/deps_to_install.txt")
git add "$project_dir/yarn.lock"
else
echo "Using npm to install/update dependencies in $project_dir..."
(cd "$project_dir" && xargs npm install < "$GITHUB_WORKSPACE/deps_to_install.txt")
git add "$project_dir/package-lock.json"
fi
git add "$pkg_json_path"
else
echo "No dependencies found to install."
fi
;;
backend)
req_file_path=$(echo "$manifest_files_conflicted" | grep "requirements.txt" | head -n1)
if [ -n "$req_file_path" ]; then
echo "Smart-merging Python requirements for '$req_file_path'"
git show FETCH_HEAD:"$req_file_path" > incoming_reqs.txt
cat incoming_reqs.txt "$req_file_path" | sort -t'=' -k1,1 -u > merged_reqs.txt
mv merged_reqs.txt "$req_file_path"
pip install -r "$req_file_path"
git add "$req_file_path"
fi
;;
esac
# All files should now be resolved and staged. We can commit.
if git commit -C FETCH_HEAD; then
echo "✅ Successfully resolved conflict and applied PR #$number."
applied_pr_numbers+=($number)
else
echo "❌ Failed to commit after resolving conflicts. Aborting."
git cherry-pick --abort
fi
else
echo "❌ Cherry-pick failed with a conflict in a file other than a known manifest. Aborting."
git cherry-pick --abort
fi
fi
done < "$file"
if [ ${#applied_pr_numbers[@]} -eq 0 ]; then
echo "No new PRs were applied for group '$group_name'. Nothing to do."
continue
fi
if [ "$DRY_RUN" == "true" ]; then
echo "[DRY-RUN] Skipping git push for $branch_name"
else
git push --force origin "$branch_name"
fi
new_lines=""
all_pr_numbers_in_group=()
while read -r number ref group; do
all_pr_numbers_in_group+=($number)
done < "$file"
for number in "${all_pr_numbers_in_group[@]}"; do
pr_title_grep=$(echo "${pr_metadata_map["$number"]}" | cut -d'|' -f1)
if git log "origin/$TARGET_BRANCH"..HEAD --oneline --grep="$pr_title_grep"; then
IFS="|" read -r title url _ <<< "${pr_metadata_map["$number"]}"
new_lines+="- $title - [#$number]($url)\n"
fi
done
pr_title="chore(deps): bump grouped $group_name dependencies"
existing_url=$(gh pr list --head "$branch_name" --base "$TARGET_BRANCH" --state open --json url --jq '.[0].url // empty')
if [ -n "$existing_url" ]; then
echo "PR already exists: $existing_url. Updating it."
pr_url="$existing_url"
pr_body=$(printf "This PR groups multiple open Dependabot PRs for the %s ecosystem.\n\n**Included PRs:**\n%b" "$group_name" "$new_lines")
if [ "$DRY_RUN" == "true" ]; then
echo "[DRY-RUN] Would update PR body for $pr_url"
else
gh pr edit "$pr_url" --body "$pr_body"
fi
else
pr_body=$(printf "This PR groups multiple open Dependabot PRs for the %s ecosystem.\n\n**Included PRs:**\n%b" "$group_name" "$new_lines")
if [ "$DRY_RUN" == "true" ]; then
echo "[DRY-RUN] Would create PR titled: $pr_title"
pr_url=""
else
pr_url=$(gh pr create \
--title "$pr_title" \
--body "$pr_body" \
--base "$TARGET_BRANCH" \
--head "$branch_name")
fi
fi
if [ -n "$pr_url" ]; then
for number in "${applied_pr_numbers[@]}"; do
IFS="|" read -r _ _ labels <<< "${pr_metadata_map["$number"]}"
if [ -n "$labels" ]; then
if [ "$DRY_RUN" == "true" ]; then
echo "[DRY-RUN] Would add labels '$labels' to $pr_url"
else
gh pr edit "$pr_url" --add-label "$labels" 2>/dev/null || echo "Failed to add labels"
fi
fi
if [ "$DRY_RUN" == "true" ]; then
echo "[DRY-RUN] Would close PR #$number"
else
gh pr close "$number" --comment "Grouped into $pr_url for easier review and testing." || echo "Failed to close PR #$number"
fi
done
echo "✅ Successfully processed group '$group_name' with branch '$branch_name'"
else
echo "❌ Grouped PR was not created for group '$group_name'. Skipping closing of original PRs."
fi
done
echo "🎉 Workflow completed successfully!"