Skip to content
Merged
295 changes: 295 additions & 0 deletions .github/workflows/fix-dependabot-alerts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

# Automatically remediate Dependabot security alerts by running the
# tools/scripts/fix-dependabot-alerts.mjs script, verifying each fix
# against dotnet restore (NuGetAudit), build, and tests, then opening
# a single squash-PR with the passing changes.
#
# Authentication: uses a GitHub App (via actions/create-github-app-token)
# because the Dependabot alerts REST API isn't reachable with the default
# GITHUB_TOKEN. Requires repo or org variables:
# - DEPENDABOT_APP_ID (variable)
# - DEPENDABOT_APP_PRIVATE_KEY (secret)

name: fix-dependabot-alerts

on:
schedule:
# Daily at 09:00 UTC
- cron: "0 9 * * *"
workflow_dispatch:
inputs:
dry-run:
description: "Dry run — analyse only, don't apply fixes"
type: boolean
default: false
skip-tests:
description: "Skip 'dotnet test' during per-fix verification (build only)"
type: boolean
default: false

concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true

permissions:
contents: write
pull-requests: write

jobs:
fix-alerts:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Don't leave the default GITHUB_TOKEN in .git/config — the
# remediation script invokes ``dotnet`` against potentially
# untrusted dependency updates (MSBuild tasks, source
# generators, and test runners can execute arbitrary code),
# and we don't want push credentials reachable from those.
persist-credentials: false

- uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"

- uses: actions/setup-node@v4
with:
# The remediation script itself is Node.js; no project depends
# on node, so we don't need a package-lock cache key.
node-version: 24

- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
# Hash the central props file so a CPM change invalidates the
# cache. Cache restore from any prior key prefix on miss.
key: nuget-fixer-${{ runner.os }}-${{ hashFiles('Directory.Packages.props') }}
restore-keys: |
nuget-fixer-${{ runner.os }}-

- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ vars.DEPENDABOT_APP_ID }}
private-key: ${{ secrets.DEPENDABOT_APP_PRIVATE_KEY }}
Comment thread
TalZaccai marked this conversation as resolved.

- name: Verify gh authentication
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
gh auth status
# Fail fast if the Dependabot API isn't reachable — otherwise
# the script would see 0 alerts and silently report "nothing
# to do", masking an infra outage as a clean run.
if ! gh api "repos/${{ github.repository }}/dependabot/alerts?per_page=1" --jq 'length'; then
echo "::error::Dependabot API probe failed — aborting before silently misreporting alerts"
exit 1
fi

# Restore prior rollback-state so packages rolled back in earlier
# runs (build/test failures) aren't retried until cooldown expires
# or the underlying Directory.Packages.props SHA changes.
#
# Cache keys must be unique per save (caches are immutable per key),
# so we save under a run-id-suffixed key and restore from the prefix.
- name: Restore rollback state
id: restore-state
uses: actions/cache/restore@v4
with:
path: ${{ runner.temp }}/fix-dependabot-alerts-rollback-state.json
key: fix-dep-rollback-state-v1-${{ github.run_id }}
restore-keys: |
fix-dep-rollback-state-v1-

- name: Run remediation script
id: fix
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
DEP_ROLLBACK_STATE_PATH: ${{ runner.temp }}/fix-dependabot-alerts-rollback-state.json
run: |
FLAGS=""
if [ "${{ inputs.dry-run }}" != "true" ]; then
FLAGS="$FLAGS --auto-fix"
fi
if [ "${{ inputs.skip-tests }}" = "true" ]; then
FLAGS="$FLAGS --skip-tests"
fi
node tools/scripts/fix-dependabot-alerts.mjs $FLAGS

# Always persist the (possibly updated) rollback state, even if
# later steps fail — otherwise a rollback recorded this run would
# be forgotten and the same broken upgrade re-tried tomorrow.
- name: Save rollback state
if: always()
uses: actions/cache/save@v4
with:
path: ${{ runner.temp }}/fix-dependabot-alerts-rollback-state.json
# ``run_id`` is reused across job re-runs; include
# ``run_attempt`` so each attempt gets a unique (immutable)
# cache key. The restore step uses a shared prefix so any
# prior attempt's state is still picked up.
key: fix-dep-rollback-state-v1-${{ github.run_id }}-${{ github.run_attempt }}

# ── Final clean build verification ──────────────────────────────
#
# The per-fix incremental verification reuses the NuGet cache and
# MSBuild incremental restore state; a stale obj/ folder could
# theoretically mask an issue. A full clean restore + build + test
# catches anything the incremental flow missed.
- name: Final clean build verification
if: ${{ steps.fix.outputs.changes == 'true' && inputs.dry-run != 'true' }}
id: build
run: |
# Wipe per-project intermediate state so the verify build
# doesn't reuse anything from the per-fix iterations.
find . -path ./.git -prune -o -type d \( -name bin -o -name obj \) -prune -exec rm -rf {} +
dotnet restore TypeChat.sln --force
# -m:1 avoids a known parallel-build race in examples'
# Directory.Build.targets file copy.
dotnet build TypeChat.sln -c Release /warnaserror --no-restore -m:1
if [ "${{ inputs.skip-tests }}" != "true" ]; then
dotnet test tests/TypeChat.UnitTests -c Release --no-build --nologo
fi
echo "build_ok=true" >> "$GITHUB_OUTPUT"

# ── Create PR ───────────────────────────────────────────────────
#
# App tokens expire after 1 hour; the build/verify phase can
# outrun that. Re-mint immediately before any late ``gh`` calls.
- name: Refresh app token
if: ${{ steps.fix.outputs.changes == 'true' && steps.build.outputs.build_ok == 'true' }}
id: app-token-pr
uses: actions/create-github-app-token@v1
with:
app-id: ${{ vars.DEPENDABOT_APP_ID }}
private-key: ${{ secrets.DEPENDABOT_APP_PRIVATE_KEY }}

- name: Create pull request
if: ${{ steps.fix.outputs.changes == 'true' && steps.build.outputs.build_ok == 'true' }}
env:
# GH_TOKEN is the App token — used by the ``gh`` CLI for
# ``gh pr create`` / labelling / closing superseded PRs so the
# PR appears under the bot's identity.
GH_TOKEN: ${{ steps.app-token-pr.outputs.token }}
# GIT_PUSH_TOKEN is the workflow's default GITHUB_TOKEN, scoped
# via the workflow-level ``permissions: contents: write`` block.
# We use it only at the very end, after all untrusted ``dotnet``
# build/test phases have finished, to avoid persisting any push
# credential in .git/config (where MSBuild tasks / source
# generators / test runners could read it).
GIT_PUSH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH="automated/fix-dependabot-alerts-$(date +%Y%m%d)-${{ github.run_number }}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add -A

# Belt-and-suspenders: only commit/push if there are real
# working-tree changes. ``changes=true`` from the script means
# "applied at least one fix"; an upstream no-op update could
# still produce no diff.
if git diff --cached --quiet; then
echo "No actual file changes to commit despite applied fixes — skipping PR."
exit 0
fi

git commit -F - <<EOF
fix: remediate Dependabot security alerts

Automated by fix-dependabot-alerts workflow.

Applied:${{ steps.fix.outputs.applied_packages }}
Rolled back:${{ steps.fix.outputs.rolled_back_packages }}
Unfixable: ${{ steps.fix.outputs.unfixable_count }} package(s)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
EOF
# Push using the workflow's default GITHUB_TOKEN (scoped to
# contents:write at the workflow level). Configured here, not
# via actions/checkout's ``persist-credentials``, so the token
# isn't reachable from the dotnet build/test phase earlier in
# the job.
git remote set-url origin "https://x-access-token:${GIT_PUSH_TOKEN}@github.com/${{ github.repository }}.git"
git push origin "$BRANCH"

APPLIED="${{ steps.fix.outputs.applied_packages }}"
PINS="${{ steps.fix.outputs.applied_transitive_pins }}"
ROLLED="${{ steps.fix.outputs.rolled_back_packages }}"
UNFIXABLE="${{ steps.fix.outputs.unfixable_packages }}"
COOLDOWN="${{ steps.fix.outputs.cooldown_packages }}"

BODY=$(cat <<EOF
## Automated Dependabot Alert Remediation

This PR was generated by the \`fix-dependabot-alerts\` workflow.
Each fix is a single edit to \`Directory.Packages.props\` and was verified against \`dotnet restore\` (with \`NuGetAudit\`), \`dotnet build /warnaserror\`, and \`dotnet test\` before inclusion.

### Summary
- **Applied (${{ steps.fix.outputs.applied_count }}):**${APPLIED:- (none)}
- **Applied as transitive pins (new \`<PackageVersion>\` entries):**${PINS:- (none)}
- **Rolled back (${{ steps.fix.outputs.rolled_back_count }}):**${ROLLED:- (none)}
- **Unfixable (${{ steps.fix.outputs.unfixable_count }}):**${UNFIXABLE:- (none)}
- **Skipped (recent rollback cooldown, ${{ steps.fix.outputs.cooldown_count }}):**${COOLDOWN:- (none)}

> Packages marked **Unfixable** could not be lifted to the advisory's \`first_patched_version\` via a CPM edit — typically because the patched version doesn't yet exist in NuGet, or the upgrade introduces transitive constraint violations. Triage manually.

> Packages marked as **transitive pins** are tracked technical debt — the \`<PackageVersion>\` entry will hold the package at the pinned version across the whole repo until removed. Remove the entry once the natural dependency graph would resolve to a safe version on its own.

### How this works
1. Reads open Dependabot alerts via the REST API.
2. For each alert, edits \`Directory.Packages.props\` to set the package's \`<PackageVersion>\` to the advisory's \`first_patched_version\` (adding a new entry if the package wasn't previously declared — works for transitive deps thanks to \`CentralPackageTransitivePinningEnabled\`).
3. Runs \`dotnet restore --force\` and confirms no NU1901–NU1904 audit warnings remain for the alert's GHSA.
4. Runs \`dotnet build -c Release /warnaserror\` and \`dotnet test\`; rolls back on failure and records a 7-day cooldown.
5. Only fixes that pass all phases land in this PR.

### Review checklist
- [ ] Verify \`Directory.Packages.props\` changes are limited to the listed packages
- [ ] Investigate any newly-rolled-back packages separately
- [ ] If transitive pins were added, confirm the pinned version is acceptable policy
EOF
)

# Create the new PR FIRST, capture its number, THEN close
# superseded PRs. Otherwise a transient ``gh pr create`` failure
# could leave the repo with no open remediation PR.
NEW_PR=$(gh pr create \
--base main \
--head "$BRANCH" \
--title "fix: remediate Dependabot security alerts ($(date +%Y-%m-%d))" \
--body "$BODY" \
| tail -1)
echo "Created $NEW_PR"
NEW_PR_NUM=$(echo "$NEW_PR" | grep -oE '[0-9]+$' || true)

# Best-effort labels (won't fail the workflow if a label is
# missing — the PR itself is the important artifact).
if [ -n "$NEW_PR_NUM" ]; then
gh pr edit "$NEW_PR_NUM" --add-label "dependencies,security,automated" \
|| echo "::warning::Could not apply all labels to PR #$NEW_PR_NUM"
fi

# Dedup older auto-PRs from this workflow. Match by branch
# prefix using a jq filter (the GH issue-search ``head:`` /
# ``in:branch`` qualifiers are not reliable for prefix
# matching). Exclude the PR we just created.
PREV_PRS=$(gh pr list \
--state open \
--json number,headRefName \
--jq '.[] | select(.headRefName | startswith("automated/fix-dependabot-alerts-")) | select(.headRefName != "'"$BRANCH"'") | .number')
if [ -n "$PREV_PRS" ]; then
echo "Closing superseded Dependabot fix PRs: $PREV_PRS"
for PR in $PREV_PRS; do
gh pr close "$PR" \
--delete-branch \
--comment "Superseded by #${NEW_PR_NUM:-newer PR}." \
|| echo "::warning::Failed to close PR #$PR"
done
fi
Loading
Loading