Skip to content

Commit b62c8a1

Browse files
authored
ci: add dependabot weekly summary workflow (#3616)
Adds a Mon 08:00 UTC workflow that posts a summary of open Dependabot alerts and PRs to Slack. Uses env-scoped secrets so the alerts PAT and Slack token are only available to this workflow.
1 parent 9caf4ce commit b62c8a1

1 file changed

Lines changed: 206 additions & 0 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
name: Dependabot Weekly Summary
2+
3+
on:
4+
schedule:
5+
- cron: "0 8 * * 1" # Mon 08:00 UTC
6+
workflow_dispatch:
7+
8+
# Single-purpose monitoring workflow; serialise on workflow name only - we never
9+
# want two concurrent summary runs racing to post the same digest.
10+
concurrency:
11+
group: ${{ github.workflow }}
12+
cancel-in-progress: false
13+
14+
permissions:
15+
contents: read # gh CLI baseline
16+
pull-requests: read # gh pr list (open dependabot PRs)
17+
actions: read # gh run list / view (parse latest dependabot run logs)
18+
19+
jobs:
20+
summary:
21+
name: Post weekly Dependabot summary
22+
runs-on: ubuntu-latest
23+
environment: dependabot-summary
24+
env:
25+
# Severities surface in the actions list when their remaining TTR drops
26+
# below this many days. Override via repo/env var ACTION_THRESHOLD_DAYS.
27+
THRESHOLD_DAYS: ${{ vars.ACTION_THRESHOLD_DAYS || '7' }}
28+
steps:
29+
- name: Fetch alerts and compute summaries
30+
id: alerts
31+
env:
32+
GH_TOKEN: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }}
33+
REPO: ${{ github.repository }}
34+
run: |
35+
if ! gh api -X GET "/repos/$REPO/dependabot/alerts" --paginate > pages.json 2> err.txt; then
36+
echo "total=?" >> "$GITHUB_OUTPUT"
37+
ERR=$(head -c 200 err.txt | tr '\n' ' ')
38+
echo "by_severity=:x: _failed to fetch alerts: ${ERR}_" >> "$GITHUB_OUTPUT"
39+
echo "actions=:x: _alerts unavailable_" >> "$GITHUB_OUTPUT"
40+
exit 0
41+
fi
42+
jq -s '[.[][] | select(.state == "open")]' pages.json > open.json
43+
44+
TOTAL=$(jq 'length' open.json)
45+
echo "total=$TOTAL" >> "$GITHUB_OUTPUT"
46+
47+
if [ "$TOTAL" = "0" ]; then
48+
echo "by_severity=:white_check_mark: No open alerts." >> "$GITHUB_OUTPUT"
49+
echo "actions=_None_" >> "$GITHUB_OUTPUT"
50+
exit 0
51+
fi
52+
53+
# Severity breakdown - real newlines so jq --arg in the payload
54+
# builder encodes them as proper \n in JSON (Slack renders as breaks).
55+
BY_SEV=$(jq -r '
56+
group_by(.security_advisory.severity)
57+
| map({sev: .[0].security_advisory.severity,
58+
count: length,
59+
weight: ({"critical":0,"high":1,"medium":2,"low":3}[.[0].security_advisory.severity])})
60+
| sort_by(.weight)
61+
| map("• *\(.count)* \(.sev)")
62+
| join("\n")
63+
' open.json)
64+
{
65+
echo "by_severity<<EOF"
66+
echo "$BY_SEV"
67+
echo "EOF"
68+
} >> "$GITHUB_OUTPUT"
69+
70+
# Actions: alerts within THRESHOLD_DAYS of their TTR (P0=7d, P1=30d, P2=90d, P3=no deadline)
71+
# Grouped by (package, severity); shows earliest deadline per group.
72+
ACTIONS=$(jq -r --argjson threshold "$THRESHOLD_DAYS" '
73+
[.[]
74+
| (.security_advisory.severity) as $sev
75+
| ({"critical":7,"high":30,"medium":90,"low":null}[$sev]) as $ttr
76+
| select($ttr != null)
77+
| ((now - (.created_at | fromdateiso8601)) / 86400 | floor) as $age
78+
| {pkg: .dependency.package.name, sev: $sev, remaining: ($ttr - $age)}
79+
]
80+
| group_by([.pkg, .sev])
81+
| map({pkg: .[0].pkg, sev: .[0].sev, count: length, min_remaining: ([.[].remaining] | min)})
82+
| map(select(.min_remaining < $threshold))
83+
| sort_by(.min_remaining)
84+
| if length == 0 then "_None_"
85+
else (map(
86+
"• *\(.pkg)* (\(.sev))" +
87+
(if .count > 1 then " ×\(.count)" else "" end) + " - " +
88+
(if .min_remaining < 0 then "*OVERDUE* by \(-.min_remaining)d"
89+
else "\(.min_remaining)d remaining" end)
90+
) | join("\n"))
91+
end
92+
' open.json)
93+
{
94+
echo "actions<<EOF"
95+
echo "$ACTIONS"
96+
echo "EOF"
97+
} >> "$GITHUB_OUTPUT"
98+
99+
- name: Fetch open dependabot PRs
100+
id: prs
101+
env:
102+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
103+
REPO: ${{ github.repository }}
104+
REPO_URL: https://github.com/${{ github.repository }}
105+
run: |
106+
if ! PR_JSON=$(gh pr list --repo "$REPO" --state open --author "app/dependabot" --json number,title 2> err.txt); then
107+
ERR=$(head -c 200 err.txt | tr '\n' ' ')
108+
echo "list=:x: _failed to fetch PRs: ${ERR}_" >> "$GITHUB_OUTPUT"
109+
exit 0
110+
fi
111+
LIST=$(echo "$PR_JSON" | jq -r --arg url "$REPO_URL" '
112+
if length == 0 then "_None_"
113+
else (map("• <\($url)/pull/\(.number)|#\(.number)> \(.title)") | join("\n"))
114+
end
115+
')
116+
{
117+
echo "list<<EOF"
118+
echo "$LIST"
119+
echo "EOF"
120+
} >> "$GITHUB_OUTPUT"
121+
122+
- name: Find latest npm dependabot run
123+
id: latest
124+
env:
125+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
126+
REPO: ${{ github.repository }}
127+
run: |
128+
# Repos without a dependabot.yml have no "Dependabot Updates" workflow;
129+
# treat the lookup failure as "no recent run found" rather than failing.
130+
if ! RUN_ID=$(gh run list --repo "$REPO" --workflow "Dependabot Updates" --status success --limit 30 --json databaseId,name --jq 'first(.[] | select(.name | startswith("npm_and_yarn")) | .databaseId) // empty' 2>/dev/null); then
131+
RUN_ID=""
132+
fi
133+
echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"
134+
135+
- name: Extract stuck deps (only if actions pending)
136+
id: stuck
137+
env:
138+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
139+
REPO: ${{ github.repository }}
140+
RUN_ID: ${{ steps.latest.outputs.run_id }}
141+
ACTIONS: ${{ steps.alerts.outputs.actions }}
142+
run: |
143+
# Skip the stuck section entirely when nothing in the actions list
144+
# - keeps the digest tidy when there's nothing to actually act on.
145+
if [ "$ACTIONS" = "_None_" ]; then
146+
echo "section=" >> "$GITHUB_OUTPUT"
147+
exit 0
148+
fi
149+
HEADER=$'\n\n*Couldn\'t auto-fix (need manual `pnpm.overrides`):*\n'
150+
if [ -z "$RUN_ID" ]; then
151+
{
152+
echo "section<<EOF"
153+
echo "${HEADER}_(no recent npm run found)_"
154+
echo "EOF"
155+
} >> "$GITHUB_OUTPUT"
156+
exit 0
157+
fi
158+
gh run view "$RUN_ID" --repo "$REPO" --log > log.txt 2>&1 || true
159+
STUCK=$(grep -oE "No update possible for [^[:space:]]+ [0-9][^[:space:]]*" log.txt | sed 's/No update possible for //' | sort -u || true)
160+
if [ -z "$STUCK" ]; then
161+
{
162+
echo "section<<EOF"
163+
echo "${HEADER}_None_"
164+
echo "EOF"
165+
} >> "$GITHUB_OUTPUT"
166+
exit 0
167+
fi
168+
LIST=$(echo "$STUCK" | awk 'NR>1{printf "\n"} {printf "• *%s* %s", $1, $2}')
169+
{
170+
echo "section<<EOF"
171+
echo "${HEADER}${LIST}"
172+
echo "EOF"
173+
} >> "$GITHUB_OUTPUT"
174+
175+
- name: Build Slack payload
176+
env:
177+
REPO: ${{ github.repository }}
178+
CHANNEL: ${{ vars.SLACK_CHANNEL_ID }}
179+
TOTAL: ${{ steps.alerts.outputs.total }}
180+
BY_SEVERITY: ${{ steps.alerts.outputs.by_severity }}
181+
PRS_LIST: ${{ steps.prs.outputs.list }}
182+
ACTIONS: ${{ steps.alerts.outputs.actions }}
183+
STUCK: ${{ steps.stuck.outputs.section }}
184+
run: |
185+
# Build payload via jq so PR titles or error strings containing
186+
# quotes/backslashes/newlines can't break the JSON.
187+
jq -n \
188+
--arg channel "$CHANNEL" \
189+
--arg repo "$REPO" \
190+
--arg total "$TOTAL" \
191+
--arg by_severity "$BY_SEVERITY" \
192+
--arg prs_list "$PRS_LIST" \
193+
--arg actions "$ACTIONS" \
194+
--arg stuck "$STUCK" \
195+
--arg threshold "$THRESHOLD_DAYS" \
196+
'{
197+
channel: $channel,
198+
text: ":calendar: *Weekly Dependabot summary* - `\($repo)`\n\n*Open alerts (\($total)):*\n\($by_severity)\n\n*Open Dependabot PRs:*\n\($prs_list)\n\n*Actions needed (<\($threshold)d remaining):*\n\($actions)\($stuck)\n\n<https://github.com/\($repo)/security/dependabot|Dependabot alerts>"
199+
}' > payload.json
200+
201+
- name: Post Slack summary
202+
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
203+
with:
204+
method: chat.postMessage
205+
token: ${{ secrets.SLACK_BOT_TOKEN }}
206+
payload-file-path: payload.json

0 commit comments

Comments
 (0)