Group Dependabot PRs #155
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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!" |