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
72 changes: 72 additions & 0 deletions .github/workflows/pr-branch-updater.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Reusable workflow — update PR branches that are behind their base branch.
#
# Usage (from a consumer repo):
#
# jobs:
# update:
# uses: kyverno/.github/.github/workflows/pr-branch-updater.yml@main
# secrets:
# GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
#
# Inputs:
# base_branch — only update PRs targeting this branch.
# Defaults to the repository's default branch.
#
# The workflow iterates every non-draft open PR, fetches its full record to get
# a reliable mergeable_state (the bulk list endpoint returns "unknown"), and
# calls the update-branch API for each PR whose state is "behind".
# Per-PR failures are non-fatal: the job logs them and continues.
# The job exits non-zero only if every attempted update encountered an
# unexpected error (i.e. the total unexpected-failure count is > 0).

name: PR Branch Auto-Updater

on:
workflow_call:
inputs:
base_branch:
description: >-
Only update PRs targeting this branch.
Leave empty to use the repository default branch.
required: false
type: string
default: ""
secrets:
GH_TOKEN:
description: >-
GitHub token with contents:write and pull-requests:write permissions.
Falls back to the built-in github.token when not supplied.
required: false

permissions: {}

jobs:
update-pr-branches:
name: Update out-of-date PR branches
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Check out shared scripts
# Sparse-checkout only the scripts/ directory from kyverno/.github so
# the workspace stays clean. Pin to a tag/SHA for production stability.
uses: actions/checkout@v4
with:
repository: kyverno/.github
ref: main
path: .kyverno-github
sparse-checkout: scripts
sparse-checkout-cone-mode: true
persist-credentials: false

- name: Find and update behind PRs
env:
# Prefer the caller-supplied token; fall back to the built-in one.
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
REPO: ${{ github.repository }}
# Use the caller's explicit base branch or the repo default branch.
# github.event.repository.default_branch is available on workflow_call.
TARGET_BASE: ${{ inputs.base_branch || github.event.repository.default_branch }}
GITHUB_SERVER_URL: ${{ github.server_url }}
run: bash .kyverno-github/scripts/update-pr-branches.sh
37 changes: 37 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Test Scripts

on:
push:
branches: [main]
paths:
- scripts/**
- tests/**
pull_request:
paths:
- scripts/**
- tests/**

permissions:
contents: read

jobs:
shellcheck:
name: ShellCheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run ShellCheck
run: shellcheck scripts/*.sh

bats:
name: Unit tests (bats)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install bats
run: |
git clone --depth 1 --branch v1.11.0 \
https://github.com/bats-core/bats-core.git /tmp/bats-core
sudo /tmp/bats-core/install.sh /usr/local
- name: Run tests
run: bats tests/update-pr-branches.bats
161 changes: 161 additions & 0 deletions scripts/update-pr-branches.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# update-pr-branches.sh — Find open PRs that are behind their base branch and
# trigger GitHub's built-in branch-update mechanism for each one.
#
# Required env vars:
# REPO — owner/name, e.g. "kyverno/kyverno"
# GH_TOKEN — token with contents:write + pull-requests:write
# (picked up automatically by the gh CLI)
#
# Optional env vars:
# TARGET_BASE — only update PRs targeting this branch;
# empty = update PRs regardless of base branch
# GITHUB_SERVER_URL — defaults to "https://github.com" (GHE support)
#
# Exit codes:
# 0 — success (even if some PRs were skipped)
# 1 — one or more unexpected API failures occurred

set -euo pipefail

: "${REPO:?REPO env var is required (e.g. owner/name)}"

GITHUB_SERVER_URL="${GITHUB_SERVER_URL:-https://github.com}"

# Derive GH_HOST for GitHub Enterprise compatibility.
GH_HOST="${GITHUB_SERVER_URL#https://}"
GH_HOST="${GH_HOST#http://}"
export GH_HOST

echo "Repository : $REPO"
echo "Base branch: ${TARGET_BASE:-(any)}"
echo ""

# ------------------------------------------------------------------------------
# Step 1 — Fetch all non-draft open PRs (bulk endpoint only returns "unknown"
# for mergeable_state, so we only keep the minimum fields needed here).
# ------------------------------------------------------------------------------
PR_LIST_FILE=$(mktemp)
gh api --paginate "repos/$REPO/pulls?state=open&per_page=100" \
| jq -s 'add // [] | map(select(.draft == false)) | map({number, title, base_ref: .base.ref})' \
> "$PR_LIST_FILE"

TOTAL=$(jq length "$PR_LIST_FILE")
echo "Found $TOTAL non-draft open PRs"
echo ""

if [ "$TOTAL" -eq 0 ]; then
echo "Nothing to do."
rm -f "$PR_LIST_FILE"
exit 0
fi

UPDATED=0
SKIPPED=0
FAILED=0

# ------------------------------------------------------------------------------
# Step 2 — For each PR, re-fetch the full record to get reliable mergeable_state.
# ------------------------------------------------------------------------------
while IFS= read -r pr_json; do
PR_NUMBER=$(echo "$pr_json" | jq -r '.number')
PR_BASE=$(echo "$pr_json" | jq -r '.base_ref')

# Skip PRs that don't target the requested base branch.
if [ -n "${TARGET_BASE:-}" ] && [ "$PR_BASE" != "$TARGET_BASE" ]; then
echo " - PR #$PR_NUMBER — targeting '$PR_BASE', not '$TARGET_BASE', skipping"
SKIPPED=$((SKIPPED + 1))
continue
fi

# Fetch the full PR record; non-fatal on error.
DETAILS_FILE=$(mktemp)
if ! gh api \
-H "Accept: application/vnd.github+json" \
"repos/$REPO/pulls/$PR_NUMBER" \
> "$DETAILS_FILE" 2>&1; then
echo " - PR #$PR_NUMBER — failed to fetch details, skipping"
rm -f "$DETAILS_FILE"
SKIPPED=$((SKIPPED + 1))
continue
fi

if ! jq -e . >/dev/null 2>&1 < "$DETAILS_FILE"; then
echo " - PR #$PR_NUMBER — invalid JSON from details fetch, skipping"
rm -f "$DETAILS_FILE"
SKIPPED=$((SKIPPED + 1))
continue
fi

MERGEABLE_STATE=$(jq -r '.mergeable_state // empty' "$DETAILS_FILE")
PR_STATE=$(jq -r '.state // empty' "$DETAILS_FILE")
PR_DRAFT=$(jq -r '.draft // false' "$DETAILS_FILE")
rm -f "$DETAILS_FILE"

# Double-check still open + non-draft (may have changed since bulk fetch).
if [ "$PR_STATE" != "open" ]; then
echo " - PR #$PR_NUMBER — no longer open, skipping"
SKIPPED=$((SKIPPED + 1))
continue
fi

if [ "$PR_DRAFT" = "true" ]; then
echo " - PR #$PR_NUMBER — became a draft, skipping"
SKIPPED=$((SKIPPED + 1))
continue
fi

if [ "$MERGEABLE_STATE" != "behind" ]; then
echo " - PR #$PR_NUMBER — mergeable_state='${MERGEABLE_STATE:-unknown}', skipping"
SKIPPED=$((SKIPPED + 1))
continue
fi

# ----------------------------------------------------------------------------
# Step 3 — Call GitHub's update-branch endpoint.
# Branch on HTTP status code (stable contract), not response message text.
# ----------------------------------------------------------------------------
echo "Updating PR #$PR_NUMBER..."

RESPONSE_FILE=$(mktemp)
gh api \
--include \
--method PUT \
-H "Accept: application/vnd.github+json" \
"repos/$REPO/pulls/$PR_NUMBER/update-branch" \
> "$RESPONSE_FILE" 2>&1 || true

STATUS_CODE=$(grep -m1 -E '^HTTP/' "$RESPONSE_FILE" | awk '{print $2}' || echo "")
RESPONSE_BODY=$(awk 'BEGIN{body=0} body{print} /^(\r)?$/{body=1}' "$RESPONSE_FILE")

if echo "$RESPONSE_BODY" | jq -e . >/dev/null 2>&1; then
MSG=$(echo "$RESPONSE_BODY" | jq -r '.message // empty')
else
MSG=$(cat "$RESPONSE_FILE")
fi
rm -f "$RESPONSE_FILE"

case "$STATUS_CODE" in
202|204)
echo " ✓ PR #$PR_NUMBER — branch update scheduled (HTTP $STATUS_CODE)${MSG:+: $MSG}"
UPDATED=$((UPDATED + 1))
;;
409|422)
# 409 = already up-to-date; 422 = fork / maintainer update not allowed.
echo " - PR #$PR_NUMBER — update not needed or not allowed (HTTP $STATUS_CODE)${MSG:+: $MSG}"
SKIPPED=$((SKIPPED + 1))
;;
*)
echo " ✗ PR #$PR_NUMBER — unexpected response (HTTP ${STATUS_CODE:-unknown})${MSG:+: $MSG}"
FAILED=$((FAILED + 1))
;;
esac

done < <(jq -c '.[]' "$PR_LIST_FILE")

rm -f "$PR_LIST_FILE"

echo ""
echo "Summary: $UPDATED updated, $SKIPPED skipped, $FAILED failed (of $TOTAL non-draft open PRs)"

[ "$FAILED" -eq 0 ] || exit 1
Loading
Loading