diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..e510a64 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,110 @@ +name: Dependabot Auto-Merge + +# Reusable workflow: safely auto-merges Dependabot PRs after CI passes. +# Scope is narrow: +# - Only runs when github.actor == 'dependabot[bot]' (not spoofable; +# GitHub sets this from the authenticated user). +# - Only auto-merges patch + minor updates; major-version bumps are +# left open for manual review. +# - Uses pull_request_target so the BASE-branch workflow runs, not +# the PR branch's — a PR modifying this file cannot bypass itself. +# - Never executes PR code; the only actions taken are `gh pr review` +# and `gh pr merge --auto`, both GitHub-side API calls. +# +# DO NOT add `actions/checkout` to this workflow. It uses +# `pull_request_target`, which runs with base-branch secrets available. +# It is currently safe BECAUSE no PR-controlled code is ever executed — +# only API calls (fetch-metadata, gh pr review, gh pr merge). Checking +# out PR code here would expose those secrets to malicious dependency +# PRs (see the Ultralytics, nx, and tj-actions incidents). This +# invariant is also enforced by a CI guardrail — see self-review.yml's +# `guard-no-checkout` job in this repo, and the read-only fleet-wide +# check in claude-review-audit.sh. Closes #64. +# +# Callers MUST NOT use `secrets: inherit`. This workflow needs no +# secrets beyond the ambient `GITHUB_TOKEN` it mints itself — it does +# not declare a `secrets:` block below and none should be added for +# convenience. `secrets: inherit` on a caller hands every repo secret +# (CLAUDE_CODE_OAUTH_TOKEN, deploy keys, etc.) to a job that evaluates +# `pull_request_target` on externally-authored PR content. The +# no-checkout property above doesn't protect against that leak — it +# only means the leak, if it ever happened, wouldn't arrive bundled +# with an obvious code-execution vector. Splitting this file out of +# each caller repo makes `secrets: inherit` look like a tempting +# one-line fix the next time someone hits an auth error here; it is +# not a fix, it is a new secret-leak vector this split must not +# introduce. Caller stubs should declare no `secrets:` block at all. +# +# `gh pr review --approve` satisfies branch-protection rules that +# require review. `--auto` means the merge only happens after all +# status checks pass; failing CI leaves the PR open indefinitely. +# Both `gh` calls degrade to a visible ::warning:: rather than a hard +# failure — see #87 (a hard failure on either call left an +# unactionable red check with no visible cause). +# +# Usage in a caller workflow: +# +# permissions: +# contents: write +# pull-requests: write +# +# jobs: +# dependabot-auto-merge: +# uses: smartwatermelon/github-workflows/.github/workflows/dependabot-auto-merge.yml@dependabot-auto-merge-v1 +# +# Do NOT add a `secrets:` block to the caller (see above). +# +# Versioning: this file uses a prefixed tag namespace +# (dependabot-auto-merge-v1, dependabot-auto-merge-v1.0.0, ...) rather +# than the bare v1/v2/v3 tags used elsewhere in this repo. Those bare +# tags are already live and consumed by claude-assistant.yml@v1; a +# second, unrelated file cannot safely "start its own v1" in the same +# repo-wide tag namespace. + +on: + workflow_call: {} + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Verify caller permissions + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + # Mirrors claude-blocking-review.yml's preflight — fail loudly and + # specifically if the caller didn't grant contents:write / + # pull-requests:write, rather than letting `gh pr merge` fail + # later with an unattributable error (the #87 failure mode). + if ! gh api "repos/${REPO}/pulls/${PR_NUMBER}" --silent 2>/dev/null; then + echo "::error::Permission check failed." + echo "::error::The caller workflow must declare top-level permissions:" + echo "::error:: contents: write, pull-requests: write" + exit 1 + fi + + - name: Fetch Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2 + + - name: Approve and enable auto-merge (patch/minor only) + if: steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor' + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr review --approve "$PR_URL" \ + || echo "::warning::Approval failed (check can_approve_pull_request_reviews is enabled for this repo); continuing to auto-merge." + gh pr merge --auto --squash --delete-branch "$PR_URL" \ + || echo "::warning::Auto-merge enable failed — check branch protection / auto-merge repo setting." diff --git a/.github/workflows/self-review.yml b/.github/workflows/self-review.yml index bcc2597..0939f00 100644 --- a/.github/workflows/self-review.yml +++ b/.github/workflows/self-review.yml @@ -11,18 +11,17 @@ name: Self-Review # Replaces the .github/workflows/claude-code-review.yml caller that was # deleted in commit 52e688b during the v1 rename. -permissions: - contents: read - pull-requests: write - issues: write - id-token: write - on: pull_request: types: [opened, synchronize, ready_for_review, reopened] jobs: claude-review: + permissions: + contents: read + pull-requests: write + issues: write + id-token: write uses: ./.github/workflows/claude-blocking-review.yml with: pr_number: ${{ github.event.pull_request.number }} @@ -36,3 +35,43 @@ jobs: - Grep/regex changes in the escape-hatch path (see #38 history) secrets: claude_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + guard-no-checkout: + # Plain-shell job, no LLM call involved — deliberately NOT a step + # inside claude-review above (that job is `uses: + # ./.github/workflows/claude-blocking-review.yml`; a job is either a + # reusable-workflow call or a normal job with steps, not both) and + # deliberately not folded into the Claude review prompt (that job + # skips workflow-self-modification PRs, which is exactly the PR + # category this guardrail exists to check). Closes #64. + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Fail if dependabot-auto-merge.yml uses actions/checkout + run: | + file=".github/workflows/dependabot-auto-merge.yml" + if [ ! -f "$file" ]; then + echo "::notice::${file} not found — nothing to check." + exit 0 + fi + # Strip comment lines first — the header comment in this file + # legitimately names `actions/checkout` while explaining why it + # must never be added, which a bare substring grep would flag + # against itself. Match the real usage form (a `uses:` step), + # not just the string appearing anywhere in the file. + if grep -v '^\s*#' "$file" | grep -qE 'uses:\s*actions/checkout'; then + echo "::error::${file} must NEVER use actions/checkout." + echo "::error::This workflow uses pull_request_target with no-checkout as its" + echo "::error::core safety property. Adding actions/checkout would expose" + echo "::error::base-branch secrets to externally-authored PR content. See the" + echo "::error::header comment in ${file} and issue #64." + exit 1 + fi + echo "No actions/checkout found in ${file} — guardrail passed." diff --git a/README.md b/README.md index b0c3be8..a221f37 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,99 @@ The BLOCK criteria are in the workflow prompt. To adjust: --- +## `dependabot-auto-merge` + +Reusable workflow: approves and auto-merges Dependabot PRs for patch and +minor version updates once CI passes. Major-version bumps are left open +for manual review. + +### Setup + +`.github/workflows/dependabot-auto-merge.yml` in your repo: + +```yaml +name: Dependabot Auto-Merge + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + contents: write + pull-requests: write + +jobs: + dependabot-auto-merge: + uses: smartwatermelon/github-workflows/.github/workflows/dependabot-auto-merge.yml@dependabot-auto-merge-v1 +``` + +Also add `can_approve_pull_request_reviews: true` to the repo's Actions +workflow permissions (Settings → Actions → General → Workflow +permissions), or run the fleet-wide fix in this repo's +`README.md`/issue #87 history. Without it, `gh pr review --approve` +fails; the workflow degrades to a visible `::warning::` instead of a +silent stall, but auto-merge still won't proceed if the repo's branch +protection requires an approval. + +**Do NOT add `secrets: inherit` to the caller.** This workflow needs no +secrets beyond the `GITHUB_TOKEN` it mints internally and declares no +`secrets:` input. Splitting this workflow out of each caller repo (so +that fixing a bug means editing one file, not 26+) creates a real +temptation: a future maintainer who hits an auth-shaped failure here +has an easy one-line "fix" available in `secrets: inherit`. That line +would hand every repo secret — `CLAUDE_CODE_OAUTH_TOKEN`, deploy keys, +anything else the repo holds — to a job that runs against +externally-authored PR content under `pull_request_target`. The +workflow's no-checkout property (see below) does not protect against +this: it only means a secret leak, if it happened, wouldn't arrive +bundled with an obvious code-execution vector. If `gh pr review` or +`gh pr merge` is failing for a reason unrelated to +`can_approve_pull_request_reviews`, debug the actual cause — don't +reach for `secrets: inherit`. + +### Security invariant: no `actions/checkout` + +This workflow uses `pull_request_target`, which runs with base-branch +secrets available — the trigger implicated in several real supply-chain +incidents (Ultralytics, nx, tj-actions) when combined with a checkout +of PR-controlled code. This workflow is safe today because it never +executes PR code: the only actions are API calls (`dependabot/fetch-metadata`, +`gh pr review`, `gh pr merge`). **`actions/checkout` must never be added +to this file.** Two guardrails enforce this (closes #64): + +- `self-review.yml`'s `guard-no-checkout` job greps this repo's own + copy of `dependabot-auto-merge.yml` and fails the PR if + `actions/checkout` appears. +- `claude-review-audit.sh` performs a read-only, fleet-wide check: any + caller stub referencing `dependabot-auto-merge.yml` that contains + `actions/checkout` or `secrets: inherit` is flagged in the audit + report. This check does not block or gate anything — it's audit-only, + same as the rest of that script. + +### Versioning + +Tagged with a prefixed namespace — `dependabot-auto-merge-v1`, +`dependabot-auto-merge-v1.0.0`, etc. — rather than the bare `v1`/`v2`/`v3` +tags used by `claude-blocking-review` and `claude-assistant`. Git tags +are repo-scoped, not per-file; a bare `v1` on this repo already exists +and is live, consumed by `claude-assistant.yml@v1`. A second, unrelated +file can't safely "start its own v1" in the same tag namespace. + +### Rollout discipline + +New tags of this workflow (and behavior-changing bumps) are pointed at +from 2-3 low-traffic pilot repos first, pinned to the specific new tag +(not a floating major). Let at least one real Dependabot PR flow +through each pilot and confirm correct patch/minor-only behavior before +repointing any floating tag fleet-wide. This workflow approves and +merges PRs unattended with no fallback reviewer behind it (unlike +`claude-blocking-review`, which explicitly skips Dependabot PRs) — a +bug here ships to every repo pinned to the affected tag at once, so it +gets the same pilot-then-fleet discipline as the org-level rollout +work. + +--- + ## `claude-assistant` Reusable workflow that invokes Claude Code Action. The caller handles triggers diff --git a/claude-review-audit.sh b/claude-review-audit.sh index fb4a469..174957d 100755 --- a/claude-review-audit.sh +++ b/claude-review-audit.sh @@ -77,6 +77,14 @@ uses_blocking_review() { echo "${content}" | grep -q "claude-blocking-review\.yml" } +# Check if a workflow file's content references the dependabot-auto-merge +# reusable workflow (caller stub, in any repo in the fleet) +uses_dependabot_auto_merge() { + local content + content=$(strip_comments "${1}") + echo "${content}" | grep -q "dependabot-auto-merge\.yml" +} + # Check if a workflow file is the Claude assistant (responds to @claude) is_claude_assistant() { local content @@ -179,6 +187,28 @@ check_repo() { fi fi + if uses_dependabot_auto_merge "${raw}"; then + # READ-ONLY audit check for #64's guardrail extended to caller stubs + # fleet-wide (the reusable's own no-checkout invariant is enforced + # by self-review.yml's guard-no-checkout job, but that only ever + # sees this repo's copy of the file — it never sees the 26+ caller + # stubs living in other repos, which is exactly where + # `actions/checkout` or `secrets: inherit` would actually be added). + # This does not block or gate anything — it only surfaces in the + # audit report, matching the rest of this script's behavior. + if echo "${raw}" | grep -q "actions/checkout"; then + fail "Caller stub ${wf} references dependabot-auto-merge.yml AND contains actions/checkout" + issues+=("SECURITY: remove actions/checkout from ${wf} — dependabot-auto-merge.yml uses pull_request_target and must never check out PR code (see #64)") + fi + if echo "${raw}" | grep -q "secrets:[[:space:]]*inherit"; then + fail "Caller stub ${wf} references dependabot-auto-merge.yml AND uses secrets: inherit" + issues+=("SECURITY: remove 'secrets: inherit' from ${wf} — dependabot-auto-merge.yml needs no repo secrets; inherit hands every repo secret to a pull_request_target job evaluating external PR content") + fi + if ! echo "${raw}" | grep -q "actions/checkout" && ! echo "${raw}" | grep -q "secrets:[[:space:]]*inherit"; then + ok "Dependabot auto-merge caller: ${wf} (no checkout, no secrets: inherit)" + fi + fi + if is_claude_assistant "${raw}"; then has_claude_assistant=true ok "Claude assistant workflow: ${wf}" @@ -345,6 +375,7 @@ printf " 2. Caller passes claude_oauth_token secret to reusable workflow\n" printf " 3. Secret: CLAUDE_CODE_OAUTH_TOKEN (repo + org level)\n" printf " 4. Branch protection & required status checks (for blocking review)\n" printf " 5. GitHub Actions enabled\n" +printf " 6. Dependabot auto-merge caller stubs: no actions/checkout, no secrets: inherit (audit-only, #64)\n" printf "\nNOTE: This script is read-only — it reports issues but makes no changes.\n" for owner in "${OWNERS[@]}"; do