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
94 changes: 80 additions & 14 deletions .github/actions/auto-approve-bot-prs/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
# Auto-approve bot PRs

Approves PRs from trusted bot authors whose title or branch matches a known
safe pattern, after all other CI checks pass. Never hard-fails the job --
every failure mode degrades to a notice-level skip.
safe pattern, after all other CI checks pass. No API, parse, permission or
input-validation failure exits non-zero: each degrades to an annotated skip and
exit 0.

Refusing to approve is annotated at **error** level, because it is a real
outcome that something downstream may be blocking on (a release cut waiting for
the bump PR to merge, for example). That raises an annotation only; the step
still exits 0, so the job conclusion and any required check stay green.

Safe patterns: `chore(` / `chore:` titles, `fix(deps):` titles,
`backport/` / `renovate/` / `update-platform-version-` branches.
Expand All @@ -29,26 +35,86 @@ slow external checks have not shown up yet.

<!-- AUTO-DOC-INPUT:START - Do not remove or modify this section -->

| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
|--------------------|--------|----------|------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|
| auto-merge | string | false | `"false"` | Enable GitHub auto-merge after approval |
| github-token | string | true | | PAT used to read PR state, <br>approve, and enable auto-merge. Must NOT <br>match the PR author. |
| merge-method | string | false | `"squash"` | Merge method for auto-merge (squash|merge|rebase) |
| trusted-authors | string | false | `"renovate[bot],loft-bot,github-actions[bot]"` | Comma-separated list of trusted bot logins |
| wait-max-attempts | string | false | `"90"` | Max polling attempts waiting for other <br>CI checks |
| wait-min-attempts | string | false | `"12"` | Minimum polls before ci_green=true is allowed. <br>Prevents early approval while slow external <br>checks (e.g. Netlify) have not yet registered. |
| wait-sleep-seconds | string | false | `"10"` | Seconds between polling attempts |
| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
|--------------------|--------|----------|------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| auto-merge | string | false | `"false"` | Enable GitHub auto-merge after approval |
| ci-read-token | string | false | | Token for the read-only CI poll <br>only; defaults to the caller GITHUB_TOKEN, <br>which needs `checks: read` and `statuses: read`. Never <br>the approving PAT. See README "Two <br>tokens, on purpose". |
| github-token | string | true | | PAT used to read PR state, <br>approve, and enable auto-merge. Must NOT <br>match the PR author. |
| merge-method | string | false | `"squash"` | Merge method for auto-merge (squash|merge|rebase) |
| trusted-authors | string | false | `"renovate[bot],loft-bot,github-actions[bot]"` | Comma-separated list of trusted bot logins |
| wait-max-attempts | string | false | `"90"` | Max polling attempts waiting for other <br>CI checks |
| wait-min-attempts | string | false | `"12"` | Minimum polls before ci_green=true is allowed. <br>Prevents early approval while slow external <br>checks (e.g. Netlify) have not yet registered. |
| wait-sleep-seconds | string | false | `"10"` | Seconds between polling attempts |

<!-- AUTO-DOC-INPUT:END -->

## Two tokens, on purpose

Approving needs a PAT, because GitHub forbids self-approval and the approver
identity must differ from the PR author. **Reading CI state does not.** The two
are split:

| Step | Token |
|------|-------|
| `check-pr-ready`, *Approve PR*, *Enable auto-merge* | `github-token` (PAT) |
| *Wait for other CI to pass* | `ci-read-token`, defaulting to `GITHUB_TOKEN` |

Do not point `ci-read-token` at the approving PAT.
**Fine-grained PATs cannot call the Checks API at all** — there is no `Checks`
permission to grant, and the fine-grained permissions reference lists no
`/check-runs` endpoints. The poll would fail on every attempt and the action
would default-deny forever. This is not hypothetical: it stalled the `v0.36.1`
release cut for ~70 minutes (DEVOPS-1254).

Note that a fine-grained PAT appears to work on a **public** repository, where
`/check-runs` answers with no credentials at all, so working there proved nothing
about a private caller. Do not extrapolate the reverse: `GITHUB_TOKEN` is scoped
by the `permissions:` block whatever the repository's visibility, so the grants
below are required either way.

## Required caller permissions

Unless the caller supplies `ci-read-token`, it must grant these itself. They
cannot be added by this action or by the reusable workflow that wraps it: per GitHub, *"the `GITHUB_TOKEN` permissions
passed from the caller workflow can be only downgraded (not elevated) by the
called workflow"*, and any permission the caller omits defaults to `none`.

```yaml
permissions:
contents: read
pull-requests: write
checks: read # CI poll: /commits/:sha/check-runs
statuses: read # CI poll: /commits/:sha/status
```

Omitting `checks`/`statuses` does not fail loudly. CI stays green, the job stays
green, and the PR is simply never approved.

## Usage

The `permissions:` block is part of the usage, not an optional extra. Copying
this snippet without it reproduces the silent no-approve failure described above.

```yaml
- uses: loft-sh/github-actions/.github/actions/auto-approve-bot-prs@auto-approve-bot-prs/v1
with:
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
jobs:
auto-approve:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
checks: read # CI poll: /commits/:sha/check-runs
statuses: read # CI poll: /commits/:sha/status
steps:
- uses: loft-sh/github-actions/.github/actions/auto-approve-bot-prs@auto-approve-bot-prs/v1
with:
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
```

If the caller genuinely cannot grant `checks: read` (an org policy pinning the
default token, say), pass `ci-read-token` instead: a classic PAT with `repo`
scope, or a GitHub App token. Both can reach the Checks API. A fine-grained PAT
cannot, so `github-token` is never a valid value for it.

## Testing

```bash
Expand Down
19 changes: 16 additions & 3 deletions .github/actions/auto-approve-bot-prs/action.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
name: Auto-approve bot PRs
description: |
Approves PRs from trusted bot authors whose title/branch matches a known
safe pattern, after all other CI checks pass. Never hard-fails the job —
every failure mode degrades to a notice-level skip.
safe pattern, after all other CI checks pass. Once running, no API, parse or
permission failure exits non-zero: each degrades to an annotated skip and exit
0, and an out-of-range wait input is coerced to its default with a warning.
(Missing required env is the one deliberate exception and does exit non-zero.)
Outcomes that need a human and raise no other red signal are annotated at error
level; that raises an annotation only, never a non-zero exit.
inputs:
trusted-authors:
description: 'Comma-separated list of trusted bot logins'
Expand All @@ -19,6 +23,9 @@ inputs:
github-token:
description: 'PAT used to read PR state, approve, and enable auto-merge. Must NOT match the PR author.'
required: true
ci-read-token:
description: 'Token for the read-only CI poll only; defaults to the caller GITHUB_TOKEN, which needs `checks: read` and `statuses: read`. Never the approving PAT. See README "Two tokens, on purpose".'
required: false
wait-max-attempts:
description: 'Max polling attempts waiting for other CI checks'
required: false
Expand Down Expand Up @@ -60,7 +67,13 @@ runs:
id: ci
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
# Deliberately NOT inputs.github-token. See ci-read-token above.
# The fallback lives here rather than in an input `default:` because a
# default only applies when the input is OMITTED. Our own reusable
# workflow always passes the value through from an optional secret, so it
# arrives as an explicit empty string when the caller sets nothing, and a
# `default:` would not fire. `||` treats empty as falsy and does.
GH_TOKEN: ${{ inputs.ci-read-token || github.token }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
SELF_RUN_ID: ${{ github.run_id }}
WAIT_MAX_ATTEMPTS: ${{ inputs.wait-max-attempts }}
Expand Down
Loading
Loading