diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml index fca06d5..7324d43 100644 --- a/.github/workflows/dependabot-automerge.yml +++ b/.github/workflows/dependabot-automerge.yml @@ -1,4 +1,4 @@ -name: Dependabot auto-approve and auto-merge +name: Bot PR auto-approve and auto-merge on: pull_request_target: @@ -9,32 +9,128 @@ permissions: contents: write jobs: - dependabot: + automerge: runs-on: ubuntu-latest - if: github.actor == 'dependabot[bot]' + # Trusted automation only. Keyed on the PR author so the job still runs, and + # can revoke, when someone else pushes to a bot PR. Keep in sync across repos. + if: contains(fromJSON('["dependabot[bot]", "aikido-autofix[bot]"]'), github.event.pull_request.user.login) steps: - - name: Checkout Repo - uses: actions/checkout@v7 - with: - fetch-depth: 1 + - name: Check the PR was pushed by its author + id: trust + run: | + # A later push by anyone else (e.g. a collaborator with write access) + # must not inherit an approval or armed auto-merge given to the bot. + if [ "$ACTOR" = "$AUTHOR" ]; then + echo "trusted=true" >> "$GITHUB_OUTPUT" + else + echo "trusted=false" >> "$GITHUB_OUTPUT" + fi + env: + ACTOR: ${{ github.actor }} + AUTHOR: ${{ github.event.pull_request.user.login }} - - name: Install GitHub CLI + # A green CI run does not prove a transitive bump is safe: the repo's own + # tests never exercise how the intermediate package uses the changed API. + # Anything matching below is neither approved nor auto-merged, so it cannot + # satisfy the required-review count and a human has to sign it off. + - name: Assess dependency risk + id: risk + if: steps.trust.outputs.trusted == 'true' run: | - sudo apt-get update - sudo apt-get install -y gh + FILES=$(gh api "repos/$REPO/pulls/$PR_NUMBER/files" --paginate) + manifests() { + printf '%s' "$FILES" | jq -r ".[] | select(.filename|test(\"$1\")) | .patch // \"\"" + } + REASON="" + + # 1. The bot names major bumps in its own title. + if printf '%s' "$PR_TITLE" | grep -qiE 'major version upgrade'; then + REASON="major version upgrade" + fi + + # 2. A forced transitive pin in a JS manifest (resolutions/overrides/glob). + if [ -z "$REASON" ] && manifests 'package\\.json$' \ + | grep -qE '^[ +-].*"(resolutions|overrides)"[[:space:]]*:|^\+[[:space:]]*"\*\*/'; then + REASON="forced transitive override" + fi + + # 3. A Go major bump. + if [ -z "$REASON" ] && manifests 'go\\.mod$' | grep -qE '^\+.*\+incompatible'; then + REASON="go major (+incompatible) bump" + fi - - name: Authenticate gh - run: echo "${{ secrets.GITHUB_TOKEN }}" | gh auth login --with-token + # 4. A bulk sweep across many packages at once. + N=$(manifests '(go\\.mod|package\\.json)$' \ + | grep -cE '^\+[[:space:]]+([a-zA-Z0-9./_-]+ v[0-9]|"[^"]+"[[:space:]]*:)' || true) + if [ -z "$REASON" ] && [ "${N:-0}" -gt 6 ]; then + REASON="bulk sweep ($N dependency lines changed)" + fi - - name: Approve dependabot PRs + # 5. A wide lockfile rewrite means many transitive packages moved, + # even when the manifest diff looks small. + CHURN=$(printf '%s' "$FILES" \ + | jq '[.[] | select(.filename|test("(yarn\\.lock|package-lock\\.json|go\\.sum|pnpm-lock\\.yaml)$")) | .additions + .deletions] | add // 0') + if [ -z "$REASON" ] && [ "${CHURN:-0}" -gt 600 ]; then + REASON="wide lockfile rewrite ($CHURN lines)" + fi + + # 6. Aikido only fixes dependencies. Any other touched file is not what + # the bot normally produces, so a human has to look at it. + if [ -z "$REASON" ] && [ "$GITHUB_ACTOR" = "aikido-autofix[bot]" ]; then + OTHER=$(printf '%s' "$FILES" | jq -r '.[].filename | select((split("/")[-1] | test("^(package\\.json|yarn\\.lock|package-lock\\.json|pnpm-lock\\.yaml|go\\.mod|go\\.sum|requirements[^/]*\\.(txt|in))$")) | not)' | head -3 | tr '\n' ' ') + if [ -n "$OTHER" ]; then + REASON="aikido PR touches non-dependency files: $OTHER" + fi + fi + + if [ -n "$REASON" ]; then + echo "risky=true" >> "$GITHUB_OUTPUT" + echo "reason=$REASON" >> "$GITHUB_OUTPUT" + echo "::warning::Not auto-merging: $REASON" + else + echo "risky=false" >> "$GITHUB_OUTPUT" + fi + env: + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Approve bot PR + if: steps.risk.outputs.risky == 'false' run: gh pr review --approve "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Merge for dependabot PRs + - name: Enable auto-merge for bot PR + if: steps.risk.outputs.risky == 'false' run: gh pr merge --auto --squash "$PR_URL" env: PR_URL: ${{ github.event.pull_request.html_url }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Hold bot PR for human review + if: steps.trust.outputs.trusted == 'false' || steps.risk.outputs.risky == 'true' + run: | + # An earlier run may have approved and armed this PR while it still + # looked routine, so undo both before flagging it. + gh pr merge --disable-auto "$PR_URL" || true + + gh api "repos/$REPO/pulls/$PR_NUMBER/reviews" \ + --jq '.[] | select(.state == "APPROVED" and .user.login == "github-actions[bot]") | .id' \ + | while read -r id; do + gh api -X PUT "repos/$REPO/pulls/$PR_NUMBER/reviews/$id/dismissals" \ + -f message="Withdrawn: $REASON" >/dev/null || true + done + + gh pr edit "$PR_URL" --add-label needs-human + gh pr comment "$PR_URL" --body \ + "Held for human review: **$REASON**. This PR was deliberately **not** approved and auto-merge is off; any earlier approval was withdrawn. It needs a human to review and merge it." + env: + PR_URL: ${{ github.event.pull_request.html_url }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REASON: ${{ steps.risk.outputs.reason || format('{0} pushed to this PR, not the bot that opened it', github.actor) }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}