Skip to content

Delete PR staging and head branches writer #1345

Delete PR staging and head branches writer

Delete PR staging and head branches writer #1345

name: Delete PR staging and head branches writer
on:
workflow_run:
workflows: ["Delete PR staging and head branches"]
types: [completed]
schedule:
- cron: "5-55/10 * * * *"
workflow_dispatch:
inputs:
pr_number:
description: Pull request number to process
required: true
type: number
permissions:
contents: write
pull-requests: read
# Scheduled reconciliation sweeps share a single group so they cannot pile up on
# top of each other. Per-pull-request runs get their own group so they are never
# queued behind a sweep.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'schedule' && 'reconcile' || github.run_id }}
cancel-in-progress: false
jobs:
delete-staging-and-head-branches:
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request') }}
runs-on: ubuntu-latest
steps:
- name: Delete staging and head branches
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY: ${{ github.repository }}
WORKFLOW_RUN_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
DISPATCH_PR_NUMBER: ${{ inputs.pr_number }}
run: |
set -euo pipefail
is_pr_number() {
[[ "$1" =~ ^[0-9]+$ ]]
}
is_staging_branch_for_pr() {
local branch="$1"
local pr_number="$2"
local prefix suffix
is_pr_number "${pr_number}" || return 1
git check-ref-format "refs/heads/${branch}" >/dev/null || return 1
suffix="/advisory-improvement-${pr_number}"
[[ "${branch}" == *"${suffix}" ]] || return 1
prefix="${branch%"${suffix}"}"
[[ -n "${prefix}" && "${prefix}" != */* ]]
}
is_deletable_branch() {
local branch="$1"
[[ -n "${branch}" && "${branch}" != "main" ]] || return 1
git check-ref-format "refs/heads/${branch}" >/dev/null
}
encode_ref() {
jq -rn --arg value "$1" '$value | @uri'
}
request_pull_request() {
local body_file="$1"
local pr_number="$2"
curl --silent --show-error \
--request GET \
--output "${body_file}" \
--write-out '%{http_code}' \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer ${GH_TOKEN}" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${REPOSITORY}/pulls/${pr_number}"
}
# Prints the pull request JSON on stdout. Returns 0 when the pull request
# was read, 2 when it does not exist, and 1 when it could not be read.
fetch_pr_json() {
local pr_number="$1"
local body_file status
body_file="$(mktemp)"
if ! status="$(request_pull_request "${body_file}" "${pr_number}")"; then
rm -f "${body_file}"
echo "::error::Failed to read pull request ${pr_number}." >&2
return 1
fi
if [[ "${status}" == "404" || "${status}" == "410" ]]; then
rm -f "${body_file}"
return 2
fi
if [[ "${status}" != "200" ]]; then
cat "${body_file}" >&2
rm -f "${body_file}"
echo "::error::Failed to read pull request ${pr_number}: GitHub API returned ${status}." >&2
return 1
fi
if ! cat "${body_file}"; then
rm -f "${body_file}"
echo "::error::Failed to read the response body for pull request ${pr_number}." >&2
return 1
fi
rm -f "${body_file}"
}
request_open_prs_for_head() {
local body_file="$1"
local encoded_branch="$2"
curl --silent --show-error \
--request GET \
--output "${body_file}" \
--write-out '%{http_code}' \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer ${GH_TOKEN}" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${REPOSITORY}/pulls?state=open&per_page=100&head=${REPOSITORY%%/*}:${encoded_branch}"
}
# Prints the number of an open pull request that currently uses the branch as its
# head ref. Returns 0 when one exists, 2 when none does, and 1 when the lookup
# failed. A failed lookup must never be read as "nothing is using this branch".
open_pr_using_branch() {
local branch="$1"
local body_file encoded_branch pr_number status
encoded_branch="$(encode_ref "${branch}")"
body_file="$(mktemp)"
if ! status="$(request_open_prs_for_head "${body_file}" "${encoded_branch}")"; then
rm -f "${body_file}"
echo "::error::Failed to look up open pull requests for branch ${branch}." >&2
return 1
fi
if [[ "${status}" != "200" ]]; then
cat "${body_file}" >&2
rm -f "${body_file}"
echo "::error::Failed to look up open pull requests for branch ${branch}: GitHub API returned ${status}." >&2
return 1
fi
# The head filter is re-checked locally so that an unexpected response shape
# reports "none found" rather than silently authorising a deletion.
if ! pr_number="$(jq -er --arg branch "${branch}" --arg repo "${REPOSITORY}" \
'map(select(.head.ref == $branch and .head.repo.full_name == $repo)) | first | .number' \
"${body_file}")"; then
rm -f "${body_file}"
return 2
fi
rm -f "${body_file}"
printf '%s\n' "${pr_number}"
}
# Caches, once per run, every ref in this repository that an open pull request is
# still using. A staging branch is normally the base of a pull request, but the
# curation flow can also open one with a staging branch as its head, and either
# use has to keep the branch alive. Both come back in the same listing, so
# covering the head case costs no additional requests. A sweep checks hundreds of
# branches, so asking per branch would cost one request each; the whole set fits
# in a handful of paginated requests instead.
load_open_pr_refs() {
local file raw
[[ -z "${OPEN_PR_REFS_FILE}" ]] || return 0
raw="$(mktemp)"
if ! gh api --paginate "repos/${REPOSITORY}/pulls?state=open&per_page=100" \
--jq '.[] | [(.number | tostring), .base.ref, (.head.ref // ""), (.head.repo.full_name // "")] | @tsv' > "${raw}"; then
rm -f "${raw}"
echo "::error::Failed to list the open pull requests of ${REPOSITORY}." >&2
return 1
fi
file="$(mktemp)"
# A head ref is only indexed when it lives in this repository; a fork's branch
# shares no namespace with ours and must not mask a deletable staging branch.
if ! awk -F'\t' -v repo="${REPOSITORY}" 'BEGIN { OFS = "\t" }
{ print $2, $1; if ($4 == repo && $3 != "") print $3, $1 }' "${raw}" > "${file}"; then
rm -f "${raw}" "${file}"
echo "::error::Failed to index the open pull requests of ${REPOSITORY}." >&2
return 1
fi
rm -f "${raw}"
OPEN_PR_REFS_FILE="${file}"
}
# Prints the number of an open pull request that is still using the ref, as either
# its base or its head. Returns 0 when one exists, 1 when none does, and 2 when the
# lookup failed, so that a failed lookup is never mistaken for "nothing is using
# this branch". The cache is loaded by the caller rather than here: this function
# is used from a command substitution, and anything it populated would be discarded
# along with that subshell, silently refetching the whole list once per branch.
open_pr_using_ref() {
local branch="$1"
local matched="" rc=0
if [[ -z "${OPEN_PR_REFS_FILE}" ]]; then
echo "::error::The open pull request cache was not loaded before searching for ${branch}." >&2
return 2
fi
matched="$(awk -F'\t' -v branch="${branch}" \
'$1 == branch { print $2; found = 1; exit } END { exit(found ? 0 : 1) }' \
"${OPEN_PR_REFS_FILE}")" || rc=$?
if (( rc == 1 )); then
return 1
fi
if (( rc != 0 )); then
echo "::error::Failed to search the open pull requests of ${REPOSITORY}." >&2
return 2
fi
printf '%s\n' "${matched}"
}
request_ref() {
local body_file="$1"
local method="$2"
local encoded_branch="$3"
local ref_path="git/refs"
if [[ "${method}" == "GET" ]]; then
ref_path="git/ref"
fi
curl --silent --show-error \
--request "${method}" \
--output "${body_file}" \
--write-out '%{http_code}' \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer ${GH_TOKEN}" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${REPOSITORY}/${ref_path}/heads/${encoded_branch}"
}
delete_branch() {
local branch="$1"
local expected_sha="${2:-}"
local blocking_pr body_file current_sha encoded_branch lookup_status status
encoded_branch="$(encode_ref "${branch}")"
body_file="$(mktemp)"
if ! status="$(request_ref "${body_file}" GET "${encoded_branch}")"; then
rm -f "${body_file}"
echo "::error::Failed to inspect branch ${branch}."
return 1
fi
if [[ "${status}" == "404" ]]; then
rm -f "${body_file}"
echo "Branch ${branch} is already absent."
return 0
fi
if [[ "${status}" != "200" ]]; then
cat "${body_file}" >&2
rm -f "${body_file}"
echo "::error::Failed to inspect branch ${branch}: GitHub API returned ${status}."
return 1
fi
if ! current_sha="$(jq -er '.object.sha' "${body_file}")"; then
rm -f "${body_file}"
echo "::error::Could not read the current SHA of branch ${branch}."
return 1
fi
if [[ -n "${expected_sha}" && "${current_sha}" != "${expected_sha}" ]]; then
rm -f "${body_file}"
# The branch moved after the pull request closed. That is only safe to ignore
# when another open pull request is using it: deleting it would break that
# pull request, and it is collected anyway once that pull request closes in
# turn. Any other cause -- a push after closure, or a ref deleted and
# recreated -- leaves an orphan that nothing else will ever collect, so it has
# to be surfaced rather than silently reported as a self-healing skip.
lookup_status=0
blocking_pr="$(open_pr_using_branch "${branch}")" || lookup_status=$?
if (( lookup_status == 0 )); then
BLOCKING_PR="${blocking_pr}"
echo "::warning::Branch ${branch} now points to ${current_sha}, not ${expected_sha}, because open pull request #${blocking_pr} is using it; it and the staging branch are left in place."
return 3
fi
if (( lookup_status == 2 )); then
echo "::error::Branch ${branch} now points to ${current_sha}, not ${expected_sha}, and no open pull request is using it. Leaving it in place because the change is unexplained; this branch needs manual investigation."
return 1
fi
echo "::error::Could not determine whether branch ${branch} is still in use, so it was left in place."
return 1
fi
if ! status="$(request_ref "${body_file}" DELETE "${encoded_branch}")"; then
rm -f "${body_file}"
echo "::error::Failed to delete branch ${branch}."
return 1
fi
if [[ "${status}" == "204" ]]; then
rm -f "${body_file}"
echo "Deleted branch ${branch}."
return 0
fi
if [[ "${status}" == "404" ]]; then
rm -f "${body_file}"
echo "Branch ${branch} was already absent when deletion was attempted."
return 0
fi
cat "${body_file}" >&2
rm -f "${body_file}"
echo "::error::Failed to delete branch ${branch}: GitHub API returned ${status}."
return 1
}
# Records a branch that was deliberately left in place, along with the open pull
# request that is using it, so the job summary can report it without the run
# having to fail.
# Callers run under "if ! process_pr", which disables set -e for everything they
# call, so a failed write here has to be returned and propagated by hand. A skip
# that cannot be recorded is reported as a failure rather than dropped, because
# the job summary is the only record of which branches were left in place.
record_skip() {
if ! printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "${SKIPPED_FILE}"; then
echo "::error::Could not record that branch $2 was left in place for pull request $1."
return 1
fi
}
# Removes the staging branch a closed pull request targeted. The branch always
# lives in this repository even when the pull request came from a fork, and its
# name encodes the pull request number, so it cannot be claimed by a later one.
# It is still confirmed to be unused immediately before deletion, because this
# workflow can write to the repository and the branch listing it works from is
# minutes old by the time the sweep reaches this point.
delete_staging_branch() {
local pr_number="$1"
local branch="$2"
local blocking_pr delete_status=0 lookup_status=0
if ! is_staging_branch_for_pr "${branch}" "${pr_number}"; then
echo "::error::Refusing to delete ${branch}: it is not the staging branch of pull request ${pr_number}."
return 1
fi
# Loaded from this shell rather than from inside the command substitution below,
# so the cache survives and the open pull requests are listed once per run.
if ! load_open_pr_refs; then
echo "::error::Could not determine whether staging branch ${branch} is still in use, so it was left in place."
return 1
fi
blocking_pr="$(open_pr_using_ref "${branch}")" || lookup_status=$?
if (( lookup_status == 0 )); then
echo "::warning::Staging branch ${branch} is still in use by open pull request #${blocking_pr}; leaving it in place."
record_skip "${pr_number}" "${branch}" "${blocking_pr}" || return 1
return 0
fi
if (( lookup_status != 1 )); then
echo "::error::Could not determine whether staging branch ${branch} is still in use, so it was left in place."
return 1
fi
# No SHA is passed, so delete_branch cannot currently return 3. It is handled
# anyway so that adding a SHA guard here later cannot turn a legitimate "still
# in use by an open pull request" skip into a run failure.
delete_branch "${branch}" || delete_status=$?
if (( delete_status == 3 )); then
record_skip "${pr_number}" "${branch}" "${BLOCKING_PR}" || return 1
return 0
fi
(( delete_status == 0 )) || return 1
return 0
}
process_pr() {
local advisory_file_pages base_ref base_repo expected_staging_branch head_ref head_repo head_sha
local delete_status=0 fetch_status=0 head_is_local=1 pr_json pr_number="$1" state
expected_staging_branch="${2:-}"
if ! is_pr_number "${pr_number}"; then
echo "::error::Unexpected pull request number: ${pr_number}"
return 1
fi
pr_json="$(fetch_pr_json "${pr_number}")" || fetch_status=$?
if (( fetch_status == 2 )); then
# A manual run names one pull request explicitly, so a missing one is an
# operator error. During a sweep, or after a close event, it just means the
# pull request was deleted and there is nothing left to verify against.
if (( MISSING_PR_IS_ERROR )); then
echo "::error::Pull request ${pr_number} does not exist."
return 1
fi
echo "::warning::Pull request ${pr_number} no longer exists; leaving its branches in place."
return 0
fi
if (( fetch_status != 0 )); then
return 1
fi
# set -e is disabled inside this function because it is called from an "if !"
# context, so an unusable response body would otherwise turn into a silent
# skip on a green run rather than a reported failure.
if ! jq -e 'type == "object" and has("state")' >/dev/null 2>&1 <<<"${pr_json}"; then
echo "::error::Could not parse the API response for pull request ${pr_number}."
return 1
fi
state="$(jq -r '.state' <<<"${pr_json}")"
base_ref="$(jq -r '.base.ref' <<<"${pr_json}")"
base_repo="$(jq -r '.base.repo.full_name' <<<"${pr_json}")"
head_ref="$(jq -r '.head.ref // empty' <<<"${pr_json}")"
head_repo="$(jq -r '.head.repo.full_name // empty' <<<"${pr_json}")"
head_sha="$(jq -r '.head.sha // empty' <<<"${pr_json}")"
if [[ "${state}" != "closed" ]]; then
echo "Pull request ${pr_number} is ${state}, not closed; skipping."
return 0
fi
if [[ "${base_repo}" != "${REPOSITORY}" ]]; then
echo "Pull request ${pr_number} targets ${base_repo}, not ${REPOSITORY}; skipping."
return 0
fi
if [[ -n "${expected_staging_branch}" && "${base_ref}" != "${expected_staging_branch}" ]]; then
echo "Pull request ${pr_number} no longer targets ${expected_staging_branch}; skipping."
return 0
fi
if ! is_staging_branch_for_pr "${base_ref}" "${pr_number}"; then
echo "Pull request ${pr_number} base branch ${base_ref} is not its advisory improvement branch; skipping."
return 0
fi
# Most advisory improvements are opened from a fork, so the head branch is not
# ours to delete and its SHA guards nothing in this repository. That is a reason
# to leave the head branch alone, not a reason to abandon the staging branch it
# targeted, which is what the fork-only path below collects.
if [[ "${head_repo}" != "${REPOSITORY}" ]]; then
head_is_local=0
fi
if (( head_is_local )) && [[ ! "${head_sha}" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Pull request ${pr_number} has an unexpected head SHA: ${head_sha}"
return 1
fi
# process_pr is invoked from a conditional in the reconciliation loop, which
# disables `set -e` inside this function, so every failure is explicit below.
if ! advisory_file_pages="$(gh api --paginate "repos/${REPOSITORY}/pulls/${pr_number}/files?per_page=100" \
--jq 'any(.[]; .filename | startswith("advisories/"))')"; then
echo "::error::Failed to list the files changed by pull request ${pr_number}."
return 1
fi
if ! grep -qx 'true' <<<"${advisory_file_pages}"; then
echo "Pull request ${pr_number} does not modify advisories/; skipping."
return 0
fi
if (( ! head_is_local )); then
echo "Pull request ${pr_number} was opened from ${head_repo:-a deleted fork}; leaving its head branch alone and collecting staging branch ${base_ref}."
delete_staging_branch "${pr_number}" "${base_ref}"
return $?
fi
if [[ "${head_ref}" == "${base_ref}" ]]; then
delete_branch "${base_ref}" "${head_sha}" || delete_status=$?
if (( delete_status == 3 )); then
record_skip "${pr_number}" "${base_ref}" "${BLOCKING_PR}" || return 1
return 0
fi
(( delete_status == 0 )) || return 1
return 0
fi
if ! is_deletable_branch "${head_ref}"; then
echo "::error::Head branch ${head_ref} is not a valid deletable Git branch."
return 1
fi
# Never delete the staging branch when the head branch could not be removed.
delete_branch "${head_ref}" "${head_sha}" || delete_status=$?
if (( delete_status == 3 )); then
record_skip "${pr_number}" "${head_ref}" "${BLOCKING_PR}" || return 1
return 0
fi
if (( delete_status != 0 )); then
return 1
fi
delete_branch "${base_ref}"
}
# Resolves a batch of pull requests in a single GraphQL call and prints the
# ones that still look like cleanup candidates as "<number>\t<base branch>".
# Deleted pull requests come back as null alongside a NOT_FOUND error, so a
# partial response is expected and is not treated as a failure.
triage_chunk() {
local chunk_file="$1"
local errors pr_number query response
# shellcheck disable=SC2016 # $owner and $name are GraphQL variables, not shell ones.
query='query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) {'
while IFS=$'\t' read -r pr_number _; do
is_pr_number "${pr_number}" || continue
query+=" pr${pr_number}: pullRequest(number: ${pr_number}) { number state baseRefName }"
done < "${chunk_file}"
query+=' } }'
response="$(mktemp)"
errors="$(mktemp)"
gh api graphql \
-F owner="${REPOSITORY%%/*}" \
-F name="${REPOSITORY#*/}" \
-f query="${query}" > "${response}" 2>"${errors}" || true
if ! jq -e '.data.repository' "${response}" >/dev/null 2>&1; then
# Only transport-level failures reach here; a response that merely reports
# deleted pull requests still carries .data.repository. Surface the reason
# so a persistently failing triage is diagnosable from the run log.
head -n 10 "${errors}" | sed 's/^/graphql: /' >&2 || true
rm -f "${response}" "${errors}"
return 1
fi
if ! jq -e '
(.errors // [])
| all(.[]; .type == "NOT_FOUND"
and (.path | length == 2)
and .path[0] == "repository"
and (.path[1] | test("^pr[0-9]+$")))
' "${response}" >/dev/null 2>&1; then
echo "::error::GraphQL triage returned an unexpected error." >&2
jq -c '.errors' "${response}" >&2 || true
rm -f "${response}" "${errors}"
return 1
fi
# A failing jq must not be masked by the trailing cleanup, otherwise the
# batch would be silently dropped without being counted as a failure.
# Pull requests opened from a fork are included on purpose: their staging
# branch is in this repository even though their head branch is not. Requiring
# the base to be the pull request's own staging branch keeps the ones that were
# retargeted at main out of the reconciliation loop, and process_pr re-validates
# both facts over REST before anything is deleted.
if ! jq -r '
.data.repository
| to_entries[]
| .value
| select(. != null)
| select(.state == "CLOSED" or .state == "MERGED")
| . as $pr
| select(($pr.baseRefName // "")
| endswith("/advisory-improvement-" + ($pr.number | tostring)))
| [($pr.number | tostring), $pr.baseRefName]
| @tsv
' "${response}"; then
rm -f "${response}" "${errors}"
return 1
fi
rm -f "${response}" "${errors}"
}
collect_reconciliation_targets() {
local branch branches candidates_file chunk chunk_dir join_status=0 pairs_file
if ! branches="$(gh api --paginate "repos/${REPOSITORY}/branches?per_page=100" --jq '.[].name')"; then
echo "::error::Failed to list the branches of ${REPOSITORY}." >&2
return 1
fi
pairs_file="$(mktemp)"
candidates_file="$(mktemp)"
chunk_dir="$(mktemp -d)"
while IFS= read -r branch; do
if [[ "${branch}" =~ ^[^/]+/advisory-improvement-([0-9]+)$ ]] &&
git check-ref-format "refs/heads/${branch}" >/dev/null; then
printf '%s\t%s\n' "${BASH_REMATCH[1]}" "${branch}"
fi
done <<<"${branches}" > "${pairs_file}"
if [[ ! -s "${pairs_file}" ]]; then
rm -rf "${pairs_file}" "${candidates_file}" "${chunk_dir}"
return 0
fi
# Resolving every staging branch over REST costs one request per branch.
# Batching the triage keeps a full sweep to a couple of dozen requests.
split -l "${TRIAGE_CHUNK_SIZE}" "${pairs_file}" "${chunk_dir}/chunk_"
for chunk in "${chunk_dir}"/chunk_*; do
if ! triage_chunk "${chunk}" >> "${candidates_file}"; then
TRIAGE_FAILURES=$(( TRIAGE_FAILURES + 1 ))
echo "::warning::Could not triage a batch of staging branches; they will be retried on the next run." >&2
fi
done
# Keep the branch name observed in the branch listing rather than the one
# reported by GraphQL, so process_pr still detects a pull request that was
# retargeted between the listing and the deletion.
if [[ -s "${candidates_file}" ]]; then
awk -F'\t' 'NR==FNR { keep[$0] = 1; next } ($1 FS $2) in keep' \
"${candidates_file}" "${pairs_file}" || join_status=$?
fi
INSPECTED_COUNT="$(wc -l < "${pairs_file}" | tr -d ' ')"
echo "Inspected ${INSPECTED_COUNT} staging branches." >&2
rm -rf "${pairs_file}" "${candidates_file}" "${chunk_dir}"
# The cleanup above must not mask a failed join, otherwise the sweep would
# report success while having reconciled nothing.
return "${join_status}"
}
# Branches left in place are expected rather than exceptional, so they are
# reported in the run summary instead of being buried in the log.
write_job_summary() {
local blocking_pr branch pr skipped_count=0
[[ -n "${GITHUB_STEP_SUMMARY:-}" ]] || return 0
if [[ -s "${SKIPPED_FILE}" ]]; then
skipped_count="$(wc -l < "${SKIPPED_FILE}" | tr -d ' ')"
fi
# A per-pull-request run with nothing to report should not add an empty section.
if (( ! SWEEP && skipped_count == 0 )); then
return 0
fi
{
echo "### Staging branch cleanup"
echo
if (( SWEEP )); then
echo "| Metric | Count |"
echo "| --- | ---: |"
echo "| Staging branches inspected | ${INSPECTED_COUNT} |"
echo "| Pull requests attempted | ${RECONCILE_COUNT} |"
echo "| Deferred to the next sweep | ${DEFERRED_COUNT} |"
echo "| Left in place (branch still used by an open pull request) | ${skipped_count} |"
echo "| Pull request failures | ${PROCESS_FAILURES} |"
echo "| Triage batches failed | ${TRIAGE_FAILURES} |"
echo
fi
if (( skipped_count > 0 )); then
echo "<details><summary>Branch still in use by an open pull request (${skipped_count})</summary>"
echo
echo "Each of these branches was verified to still be in use by an open pull request, either as its head ref or as its base, so deleting it would break that pull request and it was left alone. They are collected automatically once that pull request is closed. No action is needed."
echo
while IFS=$'\t' read -r pr branch blocking_pr; do
echo "- \`${branch}\` — left over from #${pr}, still in use by open pull request #${blocking_pr}"
done < "${SKIPPED_FILE}"
echo
echo "</details>"
fi
} >> "${GITHUB_STEP_SUMMARY}"
}
cleanup() {
rm -f "${SKIPPED_FILE}"
[[ -z "${OPEN_PR_REFS_FILE}" ]] || rm -f "${OPEN_PR_REFS_FILE}"
}
TRIAGE_CHUNK_SIZE=50
TRIAGE_FAILURES=0
PROCESS_FAILURES=0
MISSING_PR_IS_ERROR=0
INSPECTED_COUNT=0
RECONCILE_COUNT=0
DEFERRED_COUNT=0
# Deleting refs is destructive, so a sweep works a bounded slice rather than the
# whole backlog at once. Whatever is left over is picked up by the next scheduled
# sweep, which keeps a single run's blast radius and runtime bounded without
# needing anyone to drive the cleanup manually.
MAX_RECONCILE_PER_RUN=400
SWEEP=0
BLOCKING_PR=""
OPEN_PR_REFS_FILE=""
SKIPPED_FILE="$(mktemp)"
# An EXIT trap keeps the summary accurate even when the run ends in failure.
trap 'write_job_summary || true; cleanup' EXIT
if [[ "${GITHUB_EVENT_NAME}" == "workflow_run" ]]; then
PR_NUMBER="${WORKFLOW_RUN_PR_NUMBER}"
if ! is_pr_number "${PR_NUMBER:-}"; then
echo "No pull request number was provided; skipping."
exit 0
fi
process_pr "${PR_NUMBER}"
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
MISSING_PR_IS_ERROR=1
process_pr "${DISPATCH_PR_NUMBER}"
else
SWEEP=1
TARGETS_FILE="$(mktemp)"
collect_reconciliation_targets > "${TARGETS_FILE}"
RECONCILE_COUNT="$(wc -l < "${TARGETS_FILE}" | tr -d ' ')"
if (( RECONCILE_COUNT > MAX_RECONCILE_PER_RUN )); then
DEFERRED_COUNT=$(( RECONCILE_COUNT - MAX_RECONCILE_PER_RUN ))
head -n "${MAX_RECONCILE_PER_RUN}" "${TARGETS_FILE}" > "${TARGETS_FILE}.capped"
mv "${TARGETS_FILE}.capped" "${TARGETS_FILE}"
RECONCILE_COUNT="${MAX_RECONCILE_PER_RUN}"
echo "::notice::Reconciling ${MAX_RECONCILE_PER_RUN} staging branch(es) this run and deferring ${DEFERRED_COUNT} to the next sweep."
fi
echo "Reconciling ${RECONCILE_COUNT} staging branch(es)."
# A single unreconcilable pull request must not stop the sweep, otherwise
# every branch after it is never reconciled.
while IFS=$'\t' read -r PR_NUMBER STAGING_BRANCH; do
[[ -n "${PR_NUMBER}" ]] || continue
if ! process_pr "${PR_NUMBER}" "${STAGING_BRANCH}"; then
PROCESS_FAILURES=$(( PROCESS_FAILURES + 1 ))
echo "::error::Failed to reconcile pull request ${PR_NUMBER} (${STAGING_BRANCH})."
fi
done < "${TARGETS_FILE}"
rm -f "${TARGETS_FILE}"
if (( TRIAGE_FAILURES > 0 || PROCESS_FAILURES > 0 )); then
echo "::error::Reconciliation finished with ${PROCESS_FAILURES} pull request failure(s) and ${TRIAGE_FAILURES} triage failure(s)."
exit 1
fi
echo "Reconciliation completed."
fi