Skip to content

Resync ci_dependabot_automerge.yml with the canonical template (guidance#15) #3

Resync ci_dependabot_automerge.yml with the canonical template (guidance#15)

Resync ci_dependabot_automerge.yml with the canonical template (guidance#15) #3

---
name: ci_dependabot_automerge
# This file is maintained in vln-devsecops/guidance, which keeps two
# byte-identical copies of it - one under .github/workflows/ and one under
# scaffold/repository-compliance/.github/workflows/ - and fails CI if they
# drift. Change it there, not in place, and read
# runbooks/dependabot-automerge.md first: every piece of this workflow is
# load-bearing and most of it is not obvious.
on:
pull_request_target:
types: [opened, synchronize, reopened]
# Read-only. Every write this workflow performs - approving and merging -
# uses DEPENDABOT_AUTOMERGE_TOKEN, not GITHUB_TOKEN, so GITHUB_TOKEN needs no
# write scope. That matters more than usual here: the job runs under
# pull_request_target, so a compromised step or action would otherwise hold a
# repo-write token.
permissions:
contents: read
pull-requests: read
checks: read
jobs:
automerge:
runs-on: ubuntu-latest
# The PR's author, not github.actor. github.actor is whoever triggered
# the event, so on `reopened` - or when anyone else pushes to the branch -
# it is that person, this condition goes false, and the job silently does
# nothing on a real Dependabot PR. Nothing reports that; it just never
# merges.
#
# This deliberately does NOT decide whether a human has touched the
# branch. That is a separate question with a separate answer - see the
# commit-authorship check in the merge step - because the two were
# previously conflated here, and the accidental protection it gave was
# paid for by silent no-ops.
if: github.event.pull_request.user.login == 'dependabot[bot]'
env:
# Auto-merge is opt-in per repository, and provisioning
# DEPENDABOT_AUTOMERGE_TOKEN is what opts in. Without it every step
# below skips and the PR is left for a human.
#
# There is deliberately no fallback to GITHUB_TOKEN. GITHUB_TOKEN can
# only approve when the org/repo setting "Allow GitHub Actions to create
# and approve pull requests" is enabled, so falling back would make
# whether a repo auto-merges depend on an org-level setting nobody sets
# per repo - and would silently start auto-merging in repos where that
# was never the intent. Dormant is the safe failure here: a human
# merging is a fine outcome, an unintended auto-merge is not.
#
# secrets is not an allowed context in `if:`, so it is surfaced through
# job-level env, where it is allowed.
HAS_AUTOMERGE_PAT: ${{ secrets.DEPENDABOT_AUTOMERGE_TOKEN != '' }}
steps:
- name: Report that auto-merge is not enabled
if: env.HAS_AUTOMERGE_PAT != 'true'
env:
# Folded scalar rather than shell line continuations: a backslash
# continuation inside the quoted string embeds the indentation of
# each continued line into the notice text.
MSG: >-
DEPENDABOT_AUTOMERGE_TOKEN is not set for this repository, so
Dependabot auto-merge is disabled and this PR will not be merged
automatically. See runbooks/dependabot-automerge.md in
vln-devsecops/guidance to provision it.
run: echo "::notice::$MSG"
- name: Fetch Dependabot metadata
id: meta
if: env.HAS_AUTOMERGE_PAT == 'true'
uses: dependabot/fetch-metadata@v3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
# `gh pr merge --auto` only actually waits for anything if the default
# branch has a required status check configured via branch protection or
# a ruleset. Most repos in this portfolio have none - private repos on
# the Free plan get a 403 configuring one at all - and with nothing
# required, `--auto` merges the moment it is invoked, regardless of
# sibling jobs still running or already failed. That is what let
# node-dashboard#37 merge past a failing `plan` job. This step gates on
# the commit's own check runs instead, so it fails closed on every plan
# tier and does not depend on branch-protection config staying correct.
- name: Wait for other checks on this commit
if: >
env.HAS_AUTOMERGE_PAT == 'true' &&
(steps.meta.outputs.update-type == 'version-update:semver-minor' ||
steps.meta.outputs.update-type == 'version-update:semver-patch')
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
SHA: ${{ github.event.pull_request.head.sha }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
# How long to keep waiting while *no* sibling check has registered
# yet. A short fixed sleep is not enough: a queued Windows or
# self-hosted runner can take minutes to post its first check run,
# and "no checks yet" is indistinguishable from "this repo has no
# other CI". This window separates the two.
settle_seconds=180
# Overall bound once checks have appeared.
max_seconds=1800
interval=15
started=$SECONDS
while :; do
elapsed=$((SECONDS - started))
# --paginate emits one JSON document per page; --slurp collects
# them into a single array, so a commit with enough check runs to
# paginate does not yield concatenated (invalid) JSON.
#
# Exclude every job of *this* workflow run rather than matching on
# a job name: this job's own check run is posted against the same
# head SHA, so without the exclusion it waits on itself until the
# timeout and blocks the merge forever. A name match only works
# while the job happens to be called "automerge"; details_url is
# .../actions/runs/<run_id>/job/<job_id>, which pins the run
# exactly and needs no extra token scope.
runs_json=$(gh api "repos/$REPO/commits/$SHA/check-runs" --paginate --slurp \
| jq --arg run "$RUN_ID" '[.[] | .check_runs[]
| select(((.details_url // "")
| contains("/actions/runs/" + $run + "/")) | not)]')
total=$(jq 'length' <<<"$runs_json")
if [ "$total" -eq 0 ]; then
if [ "$elapsed" -lt "$settle_seconds" ]; then
echo "No sibling check runs on $SHA yet (${elapsed}s elapsed), waiting..."
sleep "$interval"
continue
fi
# Fail closed. A repo with no check at all on a Dependabot PR
# has nothing for this gate to gate on, and merging anyway
# would be the original unguarded behaviour wearing a gate's
# name - the PR would look checked because this job went green.
#
# Auto-merge is only supposed to be enabled once a repo has at
# least one PR-triggered check (see
# runbooks/dependabot-automerge.md), so reaching this point
# means the repo is misconfigured. Say so, loudly, rather than
# merging and looking correct.
echo "No sibling check runs appeared on $SHA within" \
"${settle_seconds}s. Refusing to auto-merge: there is" \
"nothing gating this PR. Add a PR-triggered check, or" \
"remove this workflow."
exit 1
fi
incomplete=$(jq '[.[] | select(.status != "completed")] | length' <<<"$runs_json")
if [ "$incomplete" -gt 0 ]; then
if [ "$elapsed" -ge "$max_seconds" ]; then
echo "Timed out after ${elapsed}s with $incomplete check(s) still running:"
jq -r '.[] | select(.status != "completed") | "- \(.name): \(.status)"' <<<"$runs_json"
exit 1
fi
echo "$incomplete of $total check(s) still running (${elapsed}s elapsed), waiting..."
sleep "$interval"
continue
fi
# Anything that is not success/skipped/neutral blocks the merge,
# including cancelled, timed_out, action_required and stale.
failed=$(jq '[.[] | select(.conclusion != "success"
and .conclusion != "skipped"
and .conclusion != "neutral")]' <<<"$runs_json")
if [ "$(jq 'length' <<<"$failed")" -gt 0 ]; then
echo "Refusing to auto-merge; check(s) did not succeed:"
jq -r '.[] | "- \(.name): \(.conclusion)"' <<<"$failed"
exit 1
fi
echo "All $total other check run(s) on $SHA succeeded."
exit 0
done
# Approves with the fine-grained PAT only - see HAS_AUTOMERGE_PAT above
# for why there is no GITHUB_TOKEN fallback.
#
# The trigger is pull_request_target, not pull_request, because a
# Dependabot PR running under pull_request cannot read Actions secrets -
# the PAT would silently evaluate to empty there, and this workflow
# would then skip in every repo rather than only the unprovisioned ones.
#
# SECURITY: this job must never check out or execute PR head code.
# pull_request_target runs with repository secrets against the base
# branch; adding actions/checkout of the PR head here would expose those
# secrets to code from the PR branch.
- name: Approve and enable auto-merge for minor/patch updates
if: >
env.HAS_AUTOMERGE_PAT == 'true' &&
(steps.meta.outputs.update-type == 'version-update:semver-minor' ||
steps.meta.outputs.update-type == 'version-update:semver-patch')
run: |
set -euo pipefail
# The gate above validated $SHA, the head at the time this run
# started. If Dependabot has pushed since (a rebase, a follow-up
# bump), the PR now points at a commit this run never checked -
# and `gh pr merge --auto` applies to the PR, not to a SHA, so it
# would enable auto-merge for that unchecked head. Without a
# required status check there is nothing downstream to catch it.
#
# Bail out and let the run triggered by that newer commit do the
# gating. Exit 0, not 1: being superseded is normal, not a failure.
current=$(gh pr view "$PR_URL" --json headRefOid --jq .headRefOid)
if [ "$current" != "$SHA" ]; then
echo "PR head moved $SHA -> $current since this run started;" \
"leaving it to the run for $current."
exit 0
fi
# Auto-merge covers what Dependabot wrote, and nothing else. If
# anyone has pushed onto the branch, the PR now carries changes no
# one reviewed and that the "it's only a version bump" reasoning
# does not apply to, so hand it back to a human.
#
# Commits with no resolvable author count as foreign: an unmatched
# commit email is exactly what an unexpected push looks like, and
# guessing in favour of merging is the wrong way to be wrong.
foreign=$(gh pr view "$PR_URL" --json commits --jq '
[ .commits[]
| (.authors // [])
| if length == 0 then ["unknown"] else map(.login // "unknown") end
| .[]
] | unique | map(select(. != "dependabot[bot]")) | join(", ")')
if [ -n "$foreign" ]; then
echo "::notice::PR carries commits authored by: $foreign." \
"Auto-merge only covers Dependabot's own commits, so this" \
"PR is left for human review."
exit 0
fi
gh pr review --approve "$PR_URL"
gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
SHA: ${{ github.event.pull_request.head.sha }}
GH_TOKEN: ${{ secrets.DEPENDABOT_AUTOMERGE_TOKEN }}