Skip to content

Commit 3e5a737

Browse files
chore: added github workflow to group dependabot security updates (#1858)
Co-authored-by: Copilot <[email protected]>
1 parent e791e43 commit 3e5a737

File tree

1 file changed

+267
-0
lines changed

1 file changed

+267
-0
lines changed
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
# Workflow: Group Dependabot PRs
2+
# Description:
3+
# This GitHub Actions workflow automatically groups open Dependabot PRs by ecosystem (pip, npm).
4+
# It cherry-picks individual PR changes into grouped branches, resolves merge conflicts automatically, and opens consolidated PRs.
5+
# It also closes the original Dependabot PRs and carries over their labels and metadata.
6+
# Improvements:
7+
# - Handles multiple conflicting files during cherry-pick
8+
# - Deduplicates entries in PR description
9+
# - Avoids closing original PRs unless grouped PR creation succeeds
10+
# - More efficient retry logic
11+
# - Ecosystem grouping is now configurable via native YAML map
12+
# - Uses safe namespaced branch naming (e.g. actions/grouped-...) to avoid developer conflict
13+
# - Ensures PR body formatting uses real newlines for better readability
14+
# - Adds strict error handling for script robustness
15+
# - Accounts for tool dependencies (jq, gh) and race conditions
16+
# - Optimized PR metadata lookup by preloading into associative array
17+
# - Supports --dry-run mode for validation/testing without side effects
18+
# - Note: PRs created during workflow execution will be picked up in the next scheduled run.
19+
20+
name: Group Dependabot PRs
21+
22+
on:
23+
schedule:
24+
- cron: '0 0 * * *' # Run daily at midnight UTC
25+
workflow_dispatch:
26+
inputs:
27+
group_config_pip:
28+
description: "Group name for pip ecosystem"
29+
required: false
30+
default: "backend"
31+
group_config_npm:
32+
description: "Group name for npm ecosystem"
33+
required: false
34+
default: "frontend"
35+
group_config_yarn:
36+
description: "Group name for yarn ecosystem"
37+
required: false
38+
default: "frontend"
39+
dry_run:
40+
description: "Run in dry-run mode (no changes will be pushed or PRs created/closed)"
41+
required: false
42+
default: false
43+
type: boolean
44+
45+
jobs:
46+
group-dependabot-prs:
47+
runs-on: ubuntu-latest
48+
permissions:
49+
contents: write
50+
pull-requests: write
51+
env:
52+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
53+
TARGET_BRANCH: "main"
54+
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
55+
GROUP_CONFIG_PIP: ${{ github.event.inputs.group_config_pip || 'backend' }}
56+
GROUP_CONFIG_NPM: ${{ github.event.inputs.group_config_npm || 'frontend' }}
57+
GROUP_CONFIG_YARN: ${{ github.event.inputs.group_config_yarn || 'frontend' }}
58+
steps:
59+
- name: Checkout default branch
60+
uses: actions/checkout@v4
61+
62+
- name: Set up Git
63+
run: |
64+
git config --global user.name "github-actions"
65+
git config --global user.email "[email protected]"
66+
67+
- name: Install required tools
68+
uses: awalsh128/[email protected]
69+
with:
70+
packages: "jq gh"
71+
72+
- name: Enable strict error handling
73+
shell: bash
74+
run: |
75+
set -euo pipefail
76+
77+
- name: Fetch open Dependabot PRs targeting main
78+
id: fetch_prs
79+
run: |
80+
gh pr list \
81+
--search "author:dependabot[bot] base:$TARGET_BRANCH is:open" \
82+
--limit 100 \
83+
--json number,title,headRefName,labels,files,url \
84+
--jq '[.[] | {number, title, url, ref: .headRefName, labels: [.labels[].name], files: [.files[].path]}]' > prs.json
85+
cat prs.json
86+
87+
- name: Validate prs.json
88+
run: |
89+
jq empty prs.json 2> jq_error.log || { echo "Malformed JSON in prs.json: $(cat jq_error.log)"; exit 1; }
90+
91+
- name: Check if any PRs exist
92+
id: check_prs
93+
run: |
94+
count=$(jq length prs.json)
95+
echo "Found $count PRs"
96+
if [ "$count" -eq 0 ]; then
97+
echo "No PRs to group. Exiting."
98+
echo "skip=true" >> $GITHUB_OUTPUT
99+
fi
100+
101+
- name: Exit early if no PRs
102+
if: steps.check_prs.outputs.skip == 'true'
103+
run: exit 0
104+
105+
- name: Dry-run validation (CI/test only)
106+
if: env.DRY_RUN == 'true'
107+
run: |
108+
echo "Running in dry-run mode. No changes will be pushed or PRs created/closed."
109+
# Optionally, add more validation logic here (e.g., check grouped files, print planned actions).
110+
111+
- name: Group PRs by ecosystem and cherry-pick with retry
112+
run: |
113+
declare -A GROUP_CONFIG=(
114+
[pip]="${GROUP_CONFIG_PIP:-backend}"
115+
[npm]="${GROUP_CONFIG_NPM:-frontend}"
116+
[yarn]="${GROUP_CONFIG_YARN:-frontend}"
117+
)
118+
mkdir -p grouped
119+
jq -c '.[]' prs.json | while read pr; do
120+
ref=$(echo "$pr" | jq -r '.ref')
121+
number=$(echo "$pr" | jq -r '.number')
122+
group="misc"
123+
for key in "${!GROUP_CONFIG[@]}"; do
124+
if [[ "$ref" == *"$key"* ]]; then
125+
group="${GROUP_CONFIG[$key]}"
126+
break
127+
fi
128+
done
129+
echo "$number $ref $group" >> grouped/$group.txt
130+
done
131+
132+
shopt -s nullglob
133+
grouped_files=(grouped/*.txt)
134+
135+
if [ ${#grouped_files[@]} -eq 0 ]; then
136+
echo "No groups were formed. Exiting."
137+
exit 0
138+
fi
139+
140+
declare -A pr_metadata_map
141+
while IFS=$'\t' read -r number title url labels; do
142+
pr_metadata_map["$number"]="$title|$url|$labels"
143+
done < <(jq -r '.[] | "\(.number)\t\(.title)\t\(.url)\t\(.labels | join(","))"' prs.json)
144+
145+
for file in "${grouped_files[@]}"; do
146+
group_name=$(basename "$file" .txt)
147+
# Sanitize group_name: allow only alphanum, dash, underscore
148+
safe_group_name=$(echo "$group_name" | tr -c '[:alnum:]_-' '-')
149+
branch_name="security/grouped-${safe_group_name}-updates"
150+
git checkout -B "$branch_name"
151+
152+
while read -r number ref group; do
153+
git fetch origin "$ref"
154+
if ! git cherry-pick FETCH_HEAD; then
155+
echo "Conflict found in $ref. Attempting to resolve."
156+
conflict_files=($(git diff --name-only --diff-filter=U))
157+
if [ ${#conflict_files[@]} -gt 0 ]; then
158+
echo "Resolving conflicts in files: ${conflict_files[*]}"
159+
for conflict_file in "${conflict_files[@]}"; do
160+
echo "Resolving conflict in $conflict_file"
161+
git checkout --theirs "$conflict_file"
162+
git add "$conflict_file"
163+
done
164+
git cherry-pick --continue || {
165+
echo "Failed to continue cherry-pick. Aborting."
166+
git cherry-pick --abort
167+
continue 2
168+
}
169+
else
170+
echo "No conflicting files found. Aborting."
171+
git cherry-pick --abort
172+
continue 2
173+
fi
174+
fi
175+
done < "$file"
176+
177+
# Non-destructive push: check for drift before force-pushing
178+
if [ "$DRY_RUN" == "true" ]; then
179+
echo "[DRY-RUN] Skipping git push for $branch_name"
180+
else
181+
remote_hash=$(git ls-remote origin "$branch_name" | awk '{print $1}')
182+
local_hash=$(git rev-parse "$branch_name")
183+
if [ -n "$remote_hash" ] && [ "$remote_hash" != "$local_hash" ]; then
184+
echo "Remote branch $branch_name has diverged. Skipping force-push to avoid overwriting changes."
185+
continue
186+
fi
187+
git push --force-with-lease origin "$branch_name"
188+
fi
189+
190+
new_lines=""
191+
while read -r number ref group; do
192+
IFS="|" read -r title url _ <<< "${pr_metadata_map["$number"]}"
193+
new_lines+="$title - [#$number]($url)\n"
194+
done < "$file"
195+
196+
pr_title="chore(deps): bump grouped $group_name Dependabot updates"
197+
# Add --state open to ensure only open PRs are considered
198+
existing_url=$(gh pr list --head "$branch_name" --base "$TARGET_BRANCH" --state open --json url --jq '.[0].url // empty')
199+
200+
if [ -n "$existing_url" ]; then
201+
echo "PR already exists: $existing_url"
202+
pr_url="$existing_url"
203+
current_body=$(gh pr view "$pr_url" --json body --jq .body)
204+
# Simplified duplicate-detection using Bash array
205+
IFS=$'\n' read -d '' -r -a current_lines < <(printf '%s\0' "$current_body")
206+
IFS=$'\n' read -d '' -r -a new_lines_arr < <(printf '%b\0' "$new_lines")
207+
declare -A seen
208+
for line in "${current_lines[@]}"; do
209+
seen["$line"]=1
210+
done
211+
filtered_lines=""
212+
for line in "${new_lines_arr[@]}"; do
213+
if [[ -n "$line" && -z "${seen["$line"]}" ]]; then
214+
filtered_lines+="$line\n"
215+
fi
216+
done
217+
# Ensure a newline separator between the existing body and new lines
218+
if [ -n "$filtered_lines" ]; then
219+
new_body="$current_body"$'\n'"$filtered_lines"
220+
else
221+
new_body="$current_body"
222+
fi
223+
if [ "$DRY_RUN" == "true" ]; then
224+
echo "[DRY-RUN] Would update PR body for $pr_url"
225+
else
226+
tmpfile=$(mktemp)
227+
printf '%s' "$new_body" > "$tmpfile"
228+
gh pr edit "$pr_url" --body-file "$tmpfile"
229+
rm -f "$tmpfile"
230+
fi
231+
else
232+
pr_body=$(printf "This PR groups multiple open PRs by Dependabot for %s.\n\n%b" "$group_name" "$new_lines")
233+
if [ "$DRY_RUN" == "true" ]; then
234+
echo "[DRY-RUN] Would create PR titled: $pr_title"
235+
echo "$pr_body"
236+
pr_url=""
237+
else
238+
pr_url=$(gh pr create \
239+
--title "$pr_title" \
240+
--body "$pr_body" \
241+
--base "$TARGET_BRANCH" \
242+
--head "$branch_name")
243+
fi
244+
fi
245+
246+
if [ -n "$pr_url" ]; then
247+
for number in $(cut -d ' ' -f1 "$file"); do
248+
IFS="|" read -r _ _ labels <<< "${pr_metadata_map["$number"]}"
249+
IFS="," read -ra label_arr <<< "$labels"
250+
for label in "${label_arr[@]}"; do
251+
if [ "$DRY_RUN" == "true" ]; then
252+
echo "[DRY-RUN] Would add label $label to $pr_url"
253+
else
254+
gh pr edit "$pr_url" --add-label "$label"
255+
fi
256+
done
257+
if [ "$DRY_RUN" == "true" ]; then
258+
echo "[DRY-RUN] Would close PR #$number"
259+
else
260+
gh pr close "$number" --comment "Grouped into $pr_url."
261+
fi
262+
done
263+
echo "Grouped PR created. Leaving branch $branch_name for now."
264+
else
265+
echo "Grouped PR was not created. Skipping closing of original PRs."
266+
fi
267+
done

0 commit comments

Comments
 (0)