From ce74771b922529224c4b19c63ac64fd4605c33b1 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Sat, 9 May 2026 17:26:08 +0100 Subject: [PATCH 1/5] ci: add dependabot weekly summary workflow --- .../workflows/dependabot-weekly-summary.yml | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 .github/workflows/dependabot-weekly-summary.yml diff --git a/.github/workflows/dependabot-weekly-summary.yml b/.github/workflows/dependabot-weekly-summary.yml new file mode 100644 index 0000000000..15622d0089 --- /dev/null +++ b/.github/workflows/dependabot-weekly-summary.yml @@ -0,0 +1,151 @@ +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 + 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 - single-line output with \n escapes for JSON safety + 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=$BY_SEV" >> "$GITHUB_OUTPUT" + + # Actions: alerts with <7d to TTR (P0=7d, P1=30d, P2=90d, P3=no deadline) + # Grouped by (package, severity); shows earliest deadline per group. + ACTIONS=$(jq -r ' + [.[] + | (.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 < 7)) + | 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=$ACTIONS" >> "$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=$LIST" >> "$GITHUB_OUTPUT" + + - name: Find latest npm dependabot run + id: latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + RUN_ID=$(gh run list --repo "$REPO" --workflow "Dependabot Updates" --status success --limit 30 --json databaseId,name --jq '[.[] | select(.name | startswith("npm_and_yarn"))][0].databaseId') + 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=${HEADER}_(no recent npm run found)_" >> "$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=${HEADER}_None_" >> "$GITHUB_OUTPUT" + exit 0 + fi + LIST=$(echo "$STUCK" | awk 'NR>1{printf "\\n"} {printf "• *%s* %s", $1, $2}') + echo "section=${HEADER}${LIST}" >> "$GITHUB_OUTPUT" + + - name: Post Slack summary + uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + { + "channel": "${{ vars.SLACK_CHANNEL_ID }}", + "text": ":calendar: *Weekly Dependabot summary* - `${{ github.repository }}`\n\n*Open alerts (${{ steps.alerts.outputs.total }}):*\n${{ steps.alerts.outputs.by_severity }}\n\n*Open Dependabot PRs:*\n${{ steps.prs.outputs.list }}\n\n*Actions needed (<7d remaining):*\n${{ steps.alerts.outputs.actions }}${{ steps.stuck.outputs.section }}\n\n" + } From 82b82f3c793b3ab02158ea5ce04e205cb726e7d5 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 13 May 2026 22:32:14 +0100 Subject: [PATCH 2/5] fix(ci): handle null run id and json-encode slack payload --- .../workflows/dependabot-weekly-summary.yml | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dependabot-weekly-summary.yml b/.github/workflows/dependabot-weekly-summary.yml index 15622d0089..5382d97737 100644 --- a/.github/workflows/dependabot-weekly-summary.yml +++ b/.github/workflows/dependabot-weekly-summary.yml @@ -108,7 +108,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} run: | - RUN_ID=$(gh run list --repo "$REPO" --workflow "Dependabot Updates" --status success --limit 30 --json databaseId,name --jq '[.[] | select(.name | startswith("npm_and_yarn"))][0].databaseId') + 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') echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" - name: Extract stuck deps (only if actions pending) @@ -139,13 +139,34 @@ jobs: LIST=$(echo "$STUCK" | awk 'NR>1{printf "\\n"} {printf "• *%s* %s", $1, $2}') echo "section=${HEADER}${LIST}" >> "$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" \ + '{ + 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 (<7d remaining):*\n\($actions)\($stuck)\n\n" + }' > 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: | - { - "channel": "${{ vars.SLACK_CHANNEL_ID }}", - "text": ":calendar: *Weekly Dependabot summary* - `${{ github.repository }}`\n\n*Open alerts (${{ steps.alerts.outputs.total }}):*\n${{ steps.alerts.outputs.by_severity }}\n\n*Open Dependabot PRs:*\n${{ steps.prs.outputs.list }}\n\n*Actions needed (<7d remaining):*\n${{ steps.alerts.outputs.actions }}${{ steps.stuck.outputs.section }}\n\n" - } + payload-file-path: payload.json From 54f48e10633cf17020a811975798517a3600927a Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 13 May 2026 23:12:02 +0100 Subject: [PATCH 3/5] fix(ci): use real newlines so slack renders line breaks --- .../workflows/dependabot-weekly-summary.yml | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/.github/workflows/dependabot-weekly-summary.yml b/.github/workflows/dependabot-weekly-summary.yml index 5382d97737..e5ec75edcb 100644 --- a/.github/workflows/dependabot-weekly-summary.yml +++ b/.github/workflows/dependabot-weekly-summary.yml @@ -46,7 +46,8 @@ jobs: exit 0 fi - # Severity breakdown - single-line output with \n escapes for JSON safety + # 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, @@ -54,9 +55,13 @@ jobs: weight: ({"critical":0,"high":1,"medium":2,"low":3}[.[0].security_advisory.severity])}) | sort_by(.weight) | map("• *\(.count)* \(.sev)") - | join("\\n") + | join("\n") ' open.json) - echo "by_severity=$BY_SEV" >> "$GITHUB_OUTPUT" + { + echo "by_severity<> "$GITHUB_OUTPUT" # Actions: alerts with <7d to TTR (P0=7d, P1=30d, P2=90d, P3=no deadline) # Grouped by (package, severity); shows earliest deadline per group. @@ -78,10 +83,14 @@ jobs: (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")) + ) | join("\n")) end ' open.json) - echo "actions=$ACTIONS" >> "$GITHUB_OUTPUT" + { + echo "actions<> "$GITHUB_OUTPUT" - name: Fetch open dependabot PRs id: prs @@ -97,10 +106,14 @@ jobs: 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")) + else (map("• <\($url)/pull/\(.number)|#\(.number)> \(.title)") | join("\n")) end ') - echo "list=$LIST" >> "$GITHUB_OUTPUT" + { + echo "list<> "$GITHUB_OUTPUT" - name: Find latest npm dependabot run id: latest @@ -125,19 +138,31 @@ jobs: echo "section=" >> "$GITHUB_OUTPUT" exit 0 fi - HEADER="\\n\\n*Couldn't auto-fix (need manual \`pnpm.overrides\`):*\\n" + HEADER=$'\n\n*Couldn\'t auto-fix (need manual `pnpm.overrides`):*\n' if [ -z "$RUN_ID" ]; then - echo "section=${HEADER}_(no recent npm run found)_" >> "$GITHUB_OUTPUT" + { + echo "section<> "$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=${HEADER}_None_" >> "$GITHUB_OUTPUT" + { + echo "section<> "$GITHUB_OUTPUT" exit 0 fi - LIST=$(echo "$STUCK" | awk 'NR>1{printf "\\n"} {printf "• *%s* %s", $1, $2}') - echo "section=${HEADER}${LIST}" >> "$GITHUB_OUTPUT" + LIST=$(echo "$STUCK" | awk 'NR>1{printf "\n"} {printf "• *%s* %s", $1, $2}') + { + echo "section<> "$GITHUB_OUTPUT" - name: Build Slack payload env: From 41083a04b2daa75ba2407fbd160f9512a09f91ae Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 14 May 2026 13:18:23 +0100 Subject: [PATCH 4/5] ci(dependabot-summary): make action threshold configurable --- .github/workflows/dependabot-weekly-summary.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dependabot-weekly-summary.yml b/.github/workflows/dependabot-weekly-summary.yml index e5ec75edcb..15d010b95c 100644 --- a/.github/workflows/dependabot-weekly-summary.yml +++ b/.github/workflows/dependabot-weekly-summary.yml @@ -21,6 +21,10 @@ jobs: 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 @@ -63,9 +67,9 @@ jobs: echo "EOF" } >> "$GITHUB_OUTPUT" - # Actions: alerts with <7d to TTR (P0=7d, P1=30d, P2=90d, P3=no deadline) + # 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 ' + ACTIONS=$(jq -r --argjson threshold "$THRESHOLD_DAYS" ' [.[] | (.security_advisory.severity) as $sev | ({"critical":7,"high":30,"medium":90,"low":null}[$sev]) as $ttr @@ -75,7 +79,7 @@ jobs: ] | group_by([.pkg, .sev]) | map({pkg: .[0].pkg, sev: .[0].sev, count: length, min_remaining: ([.[].remaining] | min)}) - | map(select(.min_remaining < 7)) + | map(select(.min_remaining < $threshold)) | sort_by(.min_remaining) | if length == 0 then "_None_" else (map( @@ -184,9 +188,10 @@ jobs: --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 (<7d remaining):*\n\($actions)\($stuck)\n\n" + 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" }' > payload.json - name: Post Slack summary From ff57d2bc61797ada352132b4c4315cb16a309669 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 14 May 2026 13:32:54 +0100 Subject: [PATCH 5/5] ci(dependabot-summary): handle repos without a Dependabot Updates workflow --- .github/workflows/dependabot-weekly-summary.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dependabot-weekly-summary.yml b/.github/workflows/dependabot-weekly-summary.yml index 15d010b95c..fb2717e2fb 100644 --- a/.github/workflows/dependabot-weekly-summary.yml +++ b/.github/workflows/dependabot-weekly-summary.yml @@ -125,7 +125,11 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} run: | - 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') + # 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)