Delete PR staging and head branches writer #1345
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |