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
37 changes: 37 additions & 0 deletions .github/scripts/auto-rebase/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# auto-rebase scripts

Supporting logic for the org-level **auto-rebase** reusable workflow
(`.github/workflows/auto-rebase-reusable.yml`). All bash/jq decision logic
lives here so it can be unit-tested with bats
(`test/workflows/auto-rebase/`) instead of being trapped inline in YAML.

The reusable workflow checks this repo out at `inputs.tooling_ref` (default
`v1`) and sources `lib/eligibility.sh` to decide which behind PRs to update.

## `lib/eligibility.sh`

Pure, side-effect-free predicates. Source the file, then call:

| Function | Input | Returns |
|----------|-------|---------|
| `auto_rebase_has_current_approval` | PR reviews JSON array on stdin (`GET /repos/{repo}/pulls/{n}/reviews`, oldest-first) | `0` if the PR has a current APPROVED review, else `1` |
| `auto_rebase_has_ready_label LABEL` | PR labels JSON array on stdin | `0` if a label named `LABEL` is present, else `1` |
| `auto_rebase_pr_eligible MODE IS_DRAFT IS_APPROVED HAS_LABEL` | mode + three `true`/`false` strings | `0` eligible, `1` not eligible, `2` unknown mode |

### Approval semantics

`auto_rebase_has_current_approval` inspects the **actual review states**, not
`reviewDecision` (which is `null` on repos without required reviews). The most
recent *decision* review per reviewer wins — a later `CHANGES_REQUESTED` or
`DISMISSED` cancels an earlier `APPROVED`, while `COMMENTED`/`PENDING` reviews
do not change a reviewer's stance.

### Eligibility modes (the tunable `eligibility` workflow input)

| Mode | Meaning |
|------|---------|
| `review-ready` (default) | non-draft **AND** (current approval **OR** carries the ready label) |
| `all` | every behind PR, including drafts — restores the original unrestricted fan-out |

New modes (e.g. a future "front-of-queue N") can be added here and selected by
callers via the `eligibility` input with no change to the workflow file.
67 changes: 67 additions & 0 deletions .github/scripts/auto-rebase/lib/eligibility.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Eligibility predicates for the auto-rebase reusable workflow.
#
# These are pure, side-effect-free functions so they can be unit-tested with
# bats (see test/workflows/auto-rebase/eligibility.bats). The reusable
# workflow sources this file and uses the predicate to decide which behind
# PRs to update-branch, instead of fanning out to every behind PR.
#
# Contract: see .github/scripts/auto-rebase/README.md

# auto_rebase_has_current_approval
# Reads a GitHub pull-request reviews JSON array on stdin (the response of
# `GET /repos/{repo}/pulls/{n}/reviews`, oldest-first) and returns 0 if the
# PR currently has at least one APPROVED review, else 1.
#
# "Current" means the reviewer's most recent decision review wins: a later
# CHANGES_REQUESTED or DISMISSED cancels an earlier APPROVED, while
# COMMENTED/PENDING reviews do not change a reviewer's stance. We check the
# real review states here rather than reviewDecision, which is null on repos
# without required reviews (issue #465 implementer note).
auto_rebase_has_current_approval() {
local result
result=$(jq -r '
reduce (.[] | select(.state == "APPROVED" or .state == "CHANGES_REQUESTED" or .state == "DISMISSED")) as $r ({}; .[$r.user.login] = $r.state)
| any(. == "APPROVED")
')
Comment thread
don-petry marked this conversation as resolved.
[[ "$result" == "true" ]]
}

# auto_rebase_has_ready_label LABEL
# Reads a PR labels JSON array on stdin (objects with a .name field) and
# returns 0 if a label named LABEL is present, else 1.
auto_rebase_has_ready_label() {
local label="$1" present
present=$(jq -r --arg L "$label" 'any(.[]; .name == $L)')
[[ "$present" == "true" ]]
}

# auto_rebase_pr_eligible MODE IS_DRAFT IS_APPROVED HAS_LABEL
# Decides whether a behind PR should be updated, given its draft/approval/
# label state. IS_DRAFT, IS_APPROVED and HAS_LABEL are the strings "true"
# or "false". Returns 0 (eligible), 1 (not eligible), or 2 (unknown mode).
#
# Modes (the tunable `eligibility` workflow input):
# review-ready non-draft AND (approved OR carries the ready label).
# The default — restricts fan-out to review-ready PRs.
# all every behind PR, including drafts. Escape hatch that
# restores the original unrestricted fan-out behavior.
auto_rebase_pr_eligible() {
local mode="$1" is_draft="$2" is_approved="$3" has_label="$4"
case "$mode" in
all)
return 0
;;
review-ready)
[[ "$is_draft" == "true" ]] && return 1
if [[ "$is_approved" == "true" || "$has_label" == "true" ]]; then
return 0
fi
return 1
;;
*)
echo "auto_rebase_pr_eligible: unknown eligibility mode '$mode'" >&2
return 2
;;
esac
}
83 changes: 80 additions & 3 deletions .github/workflows/auto-rebase-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@
#
# Solution: after every push to main (typically a merged PR), this workflow:
# 1. Finds all open non-Dependabot PRs from the same repo (no fork PRs)
# 2. Updates behind PRs using the merge method (not rebase)
# 2. Keeps only the PRs eligible under the `eligibility` input (default
# `review-ready`: non-draft AND (current APPROVED review OR a ready label))
# 3. Updates the remaining behind PRs using the merge method (not rebase)
#
# Why the eligibility gate (issue #465): updating *every* behind PR fans out
# 170-220 redundant branch-update CI runs/day, most re-staled before review.
# Restricting to review-ready PRs cuts that waste for free. The predicate lives
# in .github/scripts/auto-rebase/lib/eligibility.sh (unit-tested via bats) and
# is selectable via the `eligibility` input so a future "front-of-queue N"
# variant needs no change to this file.
#
# Skips Dependabot PRs — those are handled by dependabot-rebase-reusable.yml.
# Skips fork PRs — update-branch requires push access to the head branch.
Expand All @@ -35,6 +44,33 @@ name: Auto-rebase non-Dependabot PRs (Reusable)

on:
workflow_call:
inputs:
eligibility:
description: |
Which behind PRs to update:
review-ready (default) — non-draft AND (current APPROVED review OR
the `ready_label`). Restricts fan-out to review-ready PRs.
all — every behind PR, including drafts. Escape hatch that restores
the original unrestricted fan-out.
New modes can be added to lib/eligibility.sh and selected here without
changing this workflow.
required: false
default: 'review-ready'
type: string
ready_label:
description: 'Label that opts a non-draft PR into auto-rebase even without an approval.'
required: false
default: 'auto-rebase:ready'
type: string
tooling_ref:
description: |
Ref of petry-projects/.github to source the auto-rebase scripts from.
Defaults to `v1` to match the @v1 pin used by caller stubs. Override
only to test a fork/PR branch (`tooling_ref: my-branch`) or main
(`tooling_ref: main`) end-to-end.
required: false
default: 'v1'
type: string
secrets:
GH_PAT_WORKFLOWS:
description: "PAT with workflows scope — required for sentinel comment to trigger dev-lead rebase"
Expand All @@ -47,12 +83,26 @@ jobs:
contents: write # needed for update-branch (may touch .github/workflows/)
pull-requests: write # needed to post comments on PRs
steps:
- name: Checkout auto-rebase tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: petry-projects/.github
ref: ${{ inputs.tooling_ref }}
path: .auto-rebase-tooling
fetch-depth: 1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
persist-credentials: false

- name: Update behind non-Dependabot PRs
env:
GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS || github.token }}
HAS_PAT: ${{ secrets.GH_PAT_WORKFLOWS != '' }}
REPO: ${{ github.repository }}
ELIGIBILITY: ${{ inputs.eligibility }}
READY_LABEL: ${{ inputs.ready_label }}
run: |
# shellcheck source=/dev/null
. .auto-rebase-tooling/.github/scripts/auto-rebase/lib/eligibility.sh

# Find open non-Dependabot PRs from the same repo (exclude forks)
PRS=$(gh api "repos/$REPO/pulls?state=open&per_page=100" \
--jq '.[] | select(.user.login != "dependabot[bot]") | select(.head.repo != null) | select(.head.repo.full_name == .base.repo.full_name) | "\(.number) \(.head.ref)"')
Expand All @@ -63,8 +113,35 @@ jobs:
fi

while IFS=' ' read -r PR_NUMBER HEAD_REF; do
# Get the base branch for this PR
BASE_BRANCH=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.base.ref')
# Fetch PR metadata once: base branch, draft state, labels.
PR_JSON=$(gh api "repos/$REPO/pulls/$PR_NUMBER")
BASE_BRANCH=$(jq -r '.base.ref' <<< "$PR_JSON")
IS_DRAFT=$(jq -r '.draft' <<< "$PR_JSON")

# Eligibility gate (issue #465): only update review-ready PRs.
# Approval is read from the actual review states, not reviewDecision
# (which is null on repos without required reviews).
if auto_rebase_has_ready_label "$READY_LABEL" <<< "$(jq -c '.labels' <<< "$PR_JSON")"; then
HAS_LABEL=true
else
HAS_LABEL=false
fi
if gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/reviews" | jq -s 'add | .' | auto_rebase_has_current_approval; then
IS_APPROVED=true
else
IS_APPROVED=false
fi

ELIG=0
auto_rebase_pr_eligible "$ELIGIBILITY" "$IS_DRAFT" "$IS_APPROVED" "$HAS_LABEL" || ELIG=$?
if [[ "$ELIG" -eq 2 ]]; then
echo "::error::Unknown eligibility mode '$ELIGIBILITY'"
exit 1
fi
if [[ "$ELIG" -ne 0 ]]; then
echo "PR #$PR_NUMBER ($HEAD_REF) not eligible under '$ELIGIBILITY' (draft=$IS_DRAFT approved=$IS_APPROVED label=$HAS_LABEL) — skipping"
continue
fi

BEHIND=$(gh api "repos/$REPO/compare/${BASE_BRANCH}...${HEAD_REF}" \
--jq '.behind_by')
Expand Down
65 changes: 65 additions & 0 deletions .github/workflows/auto-rebase-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Quality gates for the reusable auto-rebase workflow and its supporting
# eligibility scripts.
#
# Triggered on any PR that touches:
# - .github/workflows/auto-rebase*.yml
# - .github/scripts/auto-rebase/**
# - test/workflows/auto-rebase/**
# - standards/workflows/auto-rebase.yml
#
# Gates (all must pass before merge):
# 1. shellcheck — static analysis for the eligibility lib
# 2. bats — unit tests for the eligibility predicate
#
# Standard: this workflow enforces the contract documented in
# .github/scripts/auto-rebase/README.md.

name: Auto-rebase Tests

on:
pull_request:
paths:
- '.github/workflows/auto-rebase*.yml'
- '.github/scripts/auto-rebase/**'
- 'test/workflows/auto-rebase/**'
- 'standards/workflows/auto-rebase.yml'
push:
branches:
- main
paths:
- '.github/workflows/auto-rebase*.yml'
- '.github/scripts/auto-rebase/**'
- 'test/workflows/auto-rebase/**'
- 'standards/workflows/auto-rebase.yml'

permissions:
contents: read

concurrency:
group: auto-rebase-tests-${{ github.ref }}
cancel-in-progress: true
Comment thread
coderabbitai[bot] marked this conversation as resolved.

jobs:
test:
name: Lint and bats
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 1

- name: Install bats, shellcheck, and jq
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends bats shellcheck jq

- name: shellcheck
run: |
set -euo pipefail
shellcheck -x .github/scripts/auto-rebase/lib/eligibility.sh

- name: Run bats suite
run: bats --print-output-on-failure test/workflows/auto-rebase/
21 changes: 20 additions & 1 deletion standards/ci-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -638,13 +638,32 @@ A copy-paste ready template is available at [`standards/workflows/auto-rebase.ym
On each run the workflow:

1. Lists all open same-repo PRs excluding `dependabot[bot]` and fork PRs.
2. For each PR that is behind the base branch, calls `PUT /pulls/{n}/update-branch` with `merge` method to fast-forward it.
2. Keeps only the PRs that are **eligible** under the `eligibility` input (see below),
then for each eligible PR that is behind the base branch, calls
`PUT /pulls/{n}/update-branch` with `merge` method to fast-forward it.
The `merge` method is used (never `rebase`) so existing approvals are not invalidated.
3. On `workflows` permission error: posts an idempotent comment (sentinel `<!-- auto-rebase-blocked -->`) asking the author to rebase manually.
4. On merge conflict (422): deletes any prior sentinel and posts a fresh comment
(sentinel `<!-- auto-rebase-conflict -->`), which triggers the `claude-rebase`
job in `claude-code-reusable.yml` to automatically resolve the conflict.
If Claude cannot resolve it, it posts a clear failure comment with manual instructions.

**Eligibility (fan-out restriction):** Updating *every* behind PR generates a large
volume of redundant branch-update CI runs, most re-staled before review. The reusable
therefore gates updates on a tunable `eligibility` input:

| `eligibility` | Updates a behind PR when… |
|---------------|---------------------------|
| `review-ready` (default) | the PR is **non-draft** AND (it has a **current `APPROVED` review** OR carries the `ready_label`, default `auto-rebase:ready`) |
| `all` | always — restores the original unrestricted fan-out |

Approval is determined from the **actual review states** (latest decision review per
reviewer wins; a later `CHANGES_REQUESTED`/`DISMISSED` cancels an earlier `APPROVED`),
**not** `reviewDecision`, which is `null` on repos without required reviews. The
predicate lives in `.github/scripts/auto-rebase/lib/eligibility.sh` and is unit-tested
via bats; new modes (e.g. a future "front-of-queue N") can be added there and selected
through the `eligibility` input with no change to the workflow file.

**Secrets:** `GH_PAT_WORKFLOWS` is optional but **required for `claude-rebase` to be triggered** —
comments posted with `GITHUB_TOKEN` do not fire `issue_comment` workflow runs (GitHub limitation).
Without it the sentinel comment still appears but no automatic resolution will run.
Expand Down
10 changes: 10 additions & 0 deletions standards/workflows/auto-rebase.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@
# Auto-rebase non-Dependabot PRs — thin caller for the org-level reusable.
# To adopt: copy this file to .github/workflows/auto-rebase.yml in your repo.
# No secrets required — uses GITHUB_TOKEN only.
#
# By default the reusable only updates *review-ready* PRs: non-draft AND
# (carrying a current APPROVED review OR the `auto-rebase:ready` label). This
# keeps the workflow from fanning out branch-update CI runs to every behind PR.
# To tune it, pass inputs to the reusable, e.g.:
#
# with:
# eligibility: review-ready # default; or `all` to update every behind PR
# ready_label: auto-rebase:ready # label that opts a non-draft PR in
#
name: Auto-rebase non-Dependabot PRs

on:
Expand Down
Loading
Loading