Dependabot Weekly Summary #1
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
| name: Dependabot Weekly Summary | |
| on: | |
| schedule: | |
| - cron: "0 8 * * 1" # Mon 08:00 UTC | |
| workflow_dispatch: | |
| # Single-purpose monitoring workflow; serialise on workflow name only - we never | |
| # want two concurrent summary runs racing to post the same digest. | |
| concurrency: | |
| group: ${{ github.workflow }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read # gh CLI baseline | |
| pull-requests: read # gh pr list (open dependabot PRs) | |
| actions: read # gh run list / view (parse latest dependabot run logs) | |
| jobs: | |
| summary: | |
| name: Post weekly Dependabot summary | |
| runs-on: ubuntu-latest | |
| environment: dependabot-summary | |
| env: | |
| # Severities surface in the actions list when their remaining TTR drops | |
| # below this many days. Override via repo/env var ACTION_THRESHOLD_DAYS. | |
| THRESHOLD_DAYS: ${{ vars.ACTION_THRESHOLD_DAYS || '7' }} | |
| steps: | |
| - name: Fetch alerts and compute summaries | |
| id: alerts | |
| env: | |
| GH_TOKEN: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| if ! gh api -X GET "/repos/$REPO/dependabot/alerts" --paginate > pages.json 2> err.txt; then | |
| echo "total=?" >> "$GITHUB_OUTPUT" | |
| ERR=$(head -c 200 err.txt | tr '\n' ' ') | |
| echo "by_severity=:x: _failed to fetch alerts: ${ERR}_" >> "$GITHUB_OUTPUT" | |
| echo "actions=:x: _alerts unavailable_" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| jq -s '[.[][] | select(.state == "open")]' pages.json > open.json | |
| TOTAL=$(jq 'length' open.json) | |
| echo "total=$TOTAL" >> "$GITHUB_OUTPUT" | |
| if [ "$TOTAL" = "0" ]; then | |
| echo "by_severity=:white_check_mark: No open alerts." >> "$GITHUB_OUTPUT" | |
| echo "actions=_None_" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Severity breakdown - real newlines so jq --arg in the payload | |
| # builder encodes them as proper \n in JSON (Slack renders as breaks). | |
| BY_SEV=$(jq -r ' | |
| group_by(.security_advisory.severity) | |
| | map({sev: .[0].security_advisory.severity, | |
| count: length, | |
| weight: ({"critical":0,"high":1,"medium":2,"low":3}[.[0].security_advisory.severity])}) | |
| | sort_by(.weight) | |
| | map("• *\(.count)* \(.sev)") | |
| | join("\n") | |
| ' open.json) | |
| { | |
| echo "by_severity<<EOF" | |
| echo "$BY_SEV" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| # Actions: alerts within THRESHOLD_DAYS of their TTR (P0=7d, P1=30d, P2=90d, P3=no deadline) | |
| # Grouped by (package, severity); shows earliest deadline per group. | |
| ACTIONS=$(jq -r --argjson threshold "$THRESHOLD_DAYS" ' | |
| [.[] | |
| | (.security_advisory.severity) as $sev | |
| | ({"critical":7,"high":30,"medium":90,"low":null}[$sev]) as $ttr | |
| | select($ttr != null) | |
| | ((now - (.created_at | fromdateiso8601)) / 86400 | floor) as $age | |
| | {pkg: .dependency.package.name, sev: $sev, remaining: ($ttr - $age)} | |
| ] | |
| | group_by([.pkg, .sev]) | |
| | map({pkg: .[0].pkg, sev: .[0].sev, count: length, min_remaining: ([.[].remaining] | min)}) | |
| | map(select(.min_remaining < $threshold)) | |
| | sort_by(.min_remaining) | |
| | if length == 0 then "_None_" | |
| else (map( | |
| "• *\(.pkg)* (\(.sev))" + | |
| (if .count > 1 then " ×\(.count)" else "" end) + " - " + | |
| (if .min_remaining < 0 then "*OVERDUE* by \(-.min_remaining)d" | |
| else "\(.min_remaining)d remaining" end) | |
| ) | join("\n")) | |
| end | |
| ' open.json) | |
| { | |
| echo "actions<<EOF" | |
| echo "$ACTIONS" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Fetch open dependabot PRs | |
| id: prs | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| REPO_URL: https://github.com/${{ github.repository }} | |
| run: | | |
| if ! PR_JSON=$(gh pr list --repo "$REPO" --state open --author "app/dependabot" --json number,title 2> err.txt); then | |
| ERR=$(head -c 200 err.txt | tr '\n' ' ') | |
| echo "list=:x: _failed to fetch PRs: ${ERR}_" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| LIST=$(echo "$PR_JSON" | jq -r --arg url "$REPO_URL" ' | |
| if length == 0 then "_None_" | |
| else (map("• <\($url)/pull/\(.number)|#\(.number)> \(.title)") | join("\n")) | |
| end | |
| ') | |
| { | |
| echo "list<<EOF" | |
| echo "$LIST" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Find latest npm dependabot run | |
| id: latest | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| # Repos without a dependabot.yml have no "Dependabot Updates" workflow; | |
| # treat the lookup failure as "no recent run found" rather than failing. | |
| 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 | |
| RUN_ID="" | |
| fi | |
| echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" | |
| - name: Extract stuck deps (only if actions pending) | |
| id: stuck | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| RUN_ID: ${{ steps.latest.outputs.run_id }} | |
| ACTIONS: ${{ steps.alerts.outputs.actions }} | |
| run: | | |
| # Skip the stuck section entirely when nothing in the actions list | |
| # - keeps the digest tidy when there's nothing to actually act on. | |
| if [ "$ACTIONS" = "_None_" ]; then | |
| echo "section=" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| HEADER=$'\n\n*Couldn\'t auto-fix (need manual `pnpm.overrides`):*\n' | |
| if [ -z "$RUN_ID" ]; then | |
| { | |
| echo "section<<EOF" | |
| echo "${HEADER}_(no recent npm run found)_" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| gh run view "$RUN_ID" --repo "$REPO" --log > log.txt 2>&1 || true | |
| STUCK=$(grep -oE "No update possible for [^[:space:]]+ [0-9][^[:space:]]*" log.txt | sed 's/No update possible for //' | sort -u || true) | |
| if [ -z "$STUCK" ]; then | |
| { | |
| echo "section<<EOF" | |
| echo "${HEADER}_None_" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| LIST=$(echo "$STUCK" | awk 'NR>1{printf "\n"} {printf "• *%s* %s", $1, $2}') | |
| { | |
| echo "section<<EOF" | |
| echo "${HEADER}${LIST}" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Build Slack payload | |
| env: | |
| REPO: ${{ github.repository }} | |
| CHANNEL: ${{ vars.SLACK_CHANNEL_ID }} | |
| TOTAL: ${{ steps.alerts.outputs.total }} | |
| BY_SEVERITY: ${{ steps.alerts.outputs.by_severity }} | |
| PRS_LIST: ${{ steps.prs.outputs.list }} | |
| ACTIONS: ${{ steps.alerts.outputs.actions }} | |
| STUCK: ${{ steps.stuck.outputs.section }} | |
| run: | | |
| # Build payload via jq so PR titles or error strings containing | |
| # quotes/backslashes/newlines can't break the JSON. | |
| jq -n \ | |
| --arg channel "$CHANNEL" \ | |
| --arg repo "$REPO" \ | |
| --arg total "$TOTAL" \ | |
| --arg by_severity "$BY_SEVERITY" \ | |
| --arg prs_list "$PRS_LIST" \ | |
| --arg actions "$ACTIONS" \ | |
| --arg stuck "$STUCK" \ | |
| --arg threshold "$THRESHOLD_DAYS" \ | |
| '{ | |
| channel: $channel, | |
| 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>" | |
| }' > payload.json | |
| - name: Post Slack summary | |
| uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 | |
| with: | |
| method: chat.postMessage | |
| token: ${{ secrets.SLACK_BOT_TOKEN }} | |
| payload-file-path: payload.json |