diff --git a/standards/dependabot-policy.md b/standards/dependabot-policy.md index 9b282c3..66ad686 100644 --- a/standards/dependabot-policy.md +++ b/standards/dependabot-policy.md @@ -38,6 +38,7 @@ Each repository must have: |------|---------| | `.github/dependabot.yml` | Dependabot config scoped to the repo's ecosystems | | `.github/workflows/dependabot-automerge.yml` | Auto-approve + squash-merge security PRs | +| `.github/workflows/dependabot-rebase.yml` | Rebase behind Dependabot PRs after merges | | `.github/workflows/dependency-audit.yml` | CI check — fail on known vulnerabilities | ## Dependabot Templates @@ -147,6 +148,36 @@ Behavior: - **Major** updates are left for human review - Uses `gh pr merge --auto --squash` so the merge only happens after CI passes +## Update and Merge Behind PRs Workflow + +See [`workflows/dependabot-rebase.yml`](workflows/dependabot-rebase.yml). + +When branch protection requires branches to be up-to-date (`strict: true`), +merging one Dependabot PR makes the others fall behind. Dependabot only rebases +PRs on its scheduled run (weekly) or when there are merge conflicts — not when +a PR merely falls behind `main`. Additionally, GitHub's auto-merge (`--auto`) +may not trigger when rulesets cause `mergeable_state` to report "blocked" even +when all requirements are met. Together, these issues stall Dependabot PR +merges indefinitely. + +This workflow fires on every push to `main` and: + +1. **Updates behind PRs** — uses the GitHub API `update-branch` endpoint with + the **merge** method to bring Dependabot PR branches up to date with `main`. +2. **Merges ready PRs** — directly merges any Dependabot PR that is up-to-date, + has auto-merge enabled, and has all CI checks passing. + +Using the app token for merges ensures each merge triggers a new push to `main`, +creating a self-sustaining chain that serializes Dependabot PR merges. + +**Important:** always use the **merge** method (not rebase) with `update-branch`. +The rebase method force-pushes, replacing Dependabot's commit signature, which +breaks `dependabot/fetch-metadata` verification and causes Dependabot to refuse +future operations ("edited by someone other than Dependabot"). The merge method +preserves the original commits. The automerge workflow must use +`skip-commit-verification: true` in `dependabot/fetch-metadata` since the merge +commit is authored by GitHub, not Dependabot. + ## Vulnerability Audit CI Check See [`workflows/dependency-audit.yml`](workflows/dependency-audit.yml). @@ -169,9 +200,10 @@ The workflow fails if any known vulnerability is found, blocking the PR from mer 1. Copy the appropriate `dependabot.yml` template to `.github/dependabot.yml`, adjusting `directory` paths as needed. 2. Add `workflows/dependabot-automerge.yml` to `.github/workflows/`. -3. Add `workflows/dependency-audit.yml` to `.github/workflows/`. -4. Ensure the repository has the GitHub App secrets (`APP_ID`, `APP_PRIVATE_KEY`) - configured for auto-merge. -5. Create the `security` and `dependencies` labels in the repository if they +3. Add `workflows/dependabot-rebase.yml` to `.github/workflows/`. +4. Add `workflows/dependency-audit.yml` to `.github/workflows/`. +5. Ensure the repository has the GitHub App secrets (`APP_ID`, `APP_PRIVATE_KEY`) + configured for auto-merge and rebase. +6. Create the `security` and `dependencies` labels in the repository if they don't already exist. -6. Add `dependency-audit` as a required status check in branch protection rules. +7. Add `dependency-audit` as a required status check in branch protection rules. diff --git a/standards/workflows/dependabot-automerge.yml b/standards/workflows/dependabot-automerge.yml index 6080c9a..2e05b18 100644 --- a/standards/workflows/dependabot-automerge.yml +++ b/standards/workflows/dependabot-automerge.yml @@ -38,6 +38,7 @@ jobs: uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 # v2 with: github-token: "${{ secrets.GITHUB_TOKEN }}" + skip-commit-verification: true - name: Determine if auto-merge eligible id: eligible diff --git a/standards/workflows/dependabot-rebase.yml b/standards/workflows/dependabot-rebase.yml new file mode 100644 index 0000000..46c756f --- /dev/null +++ b/standards/workflows/dependabot-rebase.yml @@ -0,0 +1,134 @@ +# Dependabot update and merge workflow +# Copy to .github/workflows/dependabot-rebase.yml +# +# Requires repository secrets: +# APP_ID — GitHub App ID with contents:write and pull-requests:write +# APP_PRIVATE_KEY — GitHub App private key +# +# Problem: when branch protection requires branches to be up-to-date +# (strict status checks), merging one Dependabot PR makes the others fall +# behind. Dependabot does not auto-rebase PRs that are merely behind — it +# only rebases on its next scheduled run or when there are merge conflicts. +# Additionally, GitHub auto-merge (--auto) may not trigger when rulesets +# cause mergeable_state to report "blocked" even though all requirements +# are actually met. +# +# Solution: after every push to main (typically a merged PR), this workflow: +# 1. Updates behind Dependabot PRs using the merge method (not rebase) +# 2. Merges any Dependabot PR that is up-to-date, approved, and passing CI +# +# Using the app token for merges ensures the resulting push to main triggers +# this workflow again, creating a self-sustaining chain that serializes +# Dependabot PR merges one at a time. +# +# Important: never use the API update-branch endpoint with rebase method on +# Dependabot PRs — it replaces Dependabot's commit signature with GitHub's, +# which breaks dependabot/fetch-metadata verification and causes Dependabot +# to refuse future rebases on that PR. The merge method preserves the +# original commits. +# +# Note: the merge commit is authored by GitHub, not Dependabot, so the +# dependabot-automerge workflow must use skip-commit-verification: true +# in the dependabot/fetch-metadata step. +name: Dependabot update and merge + +on: + push: + branches: + - main + +concurrency: + group: dependabot-update-and-merge + cancel-in-progress: false + +permissions: {} + +jobs: + update-and-merge: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Update and merge Dependabot PRs + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + run: | + # Find open Dependabot PRs + PRS=$(gh pr list --repo "$REPO" --author "app/dependabot" \ + --json number,headRefName \ + --jq '.[] | "\(.number) \(.headRefName)"') + + if [[ -z "$PRS" ]]; then + echo "No open Dependabot PRs" + exit 0 + fi + + MERGED=false + + while IFS=' ' read -r PR_NUMBER HEAD_REF; do + BEHIND=$(gh api "repos/$REPO/compare/main...$HEAD_REF" \ + --jq '.behind_by') + + if [[ "$BEHIND" -gt 0 ]]; then + echo "PR #$PR_NUMBER ($HEAD_REF) is $BEHIND commit(s) behind — merging main into branch" + gh api "repos/$REPO/pulls/$PR_NUMBER/update-branch" \ + -X PUT -f update_method=merge \ + --silent || echo "Warning: failed to update PR #$PR_NUMBER" + continue + fi + + echo "PR #$PR_NUMBER ($HEAD_REF) is up to date — checking if merge-ready" + + # Skip if we already merged one (strict mode means others are now behind) + if [[ "$MERGED" == "true" ]]; then + echo " Skipping — already merged a PR this run" + continue + fi + + # Check if auto-merge is enabled (set by the automerge workflow) + AUTO_MERGE=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ + --json autoMergeRequest --jq '.autoMergeRequest != null') + + if [[ "$AUTO_MERGE" != "true" ]]; then + echo " Skipping — auto-merge not enabled" + continue + fi + + # Check if all required checks pass (look at overall rollup) + CHECKS_PASS=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ + --json statusCheckRollup \ + --jq '[.statusCheckRollup[]? | select(.name != null and .status == "COMPLETED") | .conclusion] | all(. == "SUCCESS" or . == "NEUTRAL" or . == "SKIPPED")') + + CHECKS_PENDING=$(gh pr view "$PR_NUMBER" --repo "$REPO" \ + --json statusCheckRollup \ + --jq '[.statusCheckRollup[]? | select(.name != null and .status != "COMPLETED")] | length') + + if [[ "$CHECKS_PENDING" -gt 0 ]]; then + echo " Skipping — $CHECKS_PENDING check(s) still pending" + continue + fi + + if [[ "$CHECKS_PASS" != "true" ]]; then + echo " Skipping — some checks failed" + continue + fi + + echo " All checks pass — merging PR #$PR_NUMBER" + if gh api "repos/$REPO/pulls/$PR_NUMBER/merge" \ + -X PUT -f merge_method=squash \ + --silent; then + echo " Merged PR #$PR_NUMBER" + MERGED=true + else + echo " Warning: failed to merge PR #$PR_NUMBER" + fi + done <<< "$PRS"