Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
@@ -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."
51 changes: 45 additions & 6 deletions .github/workflows/self-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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."
93 changes: 93 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions claude-review-audit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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
Expand Down
Loading