@@ -17,6 +17,13 @@ permissions:
1717 contents : write
1818 pull-requests : read
1919
20+ # Scheduled reconciliation sweeps share a single group so they cannot pile up on
21+ # top of each other. Per-pull-request runs get their own group so they are never
22+ # queued behind a sweep.
23+ concurrency :
24+ group : ${{ github.workflow }}-${{ github.event_name == 'schedule' && 'reconcile' || github.run_id }}
25+ cancel-in-progress : false
26+
2027jobs :
2128 delete-staging-and-head-branches :
2229 if : ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request') }}
5966 jq -rn --arg value "$1" '$value | @uri'
6067 }
6168
69+ request_pull_request() {
70+ local body_file="$1"
71+ local pr_number="$2"
72+
73+ curl --silent --show-error \
74+ --request GET \
75+ --output "${body_file}" \
76+ --write-out '%{http_code}' \
77+ --header "Accept: application/vnd.github+json" \
78+ --header "Authorization: Bearer ${GH_TOKEN}" \
79+ --header "X-GitHub-Api-Version: 2022-11-28" \
80+ "https://api.github.com/repos/${REPOSITORY}/pulls/${pr_number}"
81+ }
82+
83+ # Prints the pull request JSON on stdout. Returns 0 when the pull request
84+ # was read, 2 when it does not exist, and 1 when it could not be read.
85+ fetch_pr_json() {
86+ local pr_number="$1"
87+ local body_file status
88+
89+ body_file="$(mktemp)"
90+ if ! status="$(request_pull_request "${body_file}" "${pr_number}")"; then
91+ rm -f "${body_file}"
92+ echo "::error::Failed to read pull request ${pr_number}." >&2
93+ return 1
94+ fi
95+
96+ if [[ "${status}" == "404" || "${status}" == "410" ]]; then
97+ rm -f "${body_file}"
98+ return 2
99+ fi
100+
101+ if [[ "${status}" != "200" ]]; then
102+ cat "${body_file}" >&2
103+ rm -f "${body_file}"
104+ echo "::error::Failed to read pull request ${pr_number}: GitHub API returned ${status}." >&2
105+ return 1
106+ fi
107+
108+ if ! cat "${body_file}"; then
109+ rm -f "${body_file}"
110+ echo "::error::Failed to read the response body for pull request ${pr_number}." >&2
111+ return 1
112+ fi
113+ rm -f "${body_file}"
114+ }
115+
62116 request_ref() {
63117 local body_file="$1"
64118 local method="$2"
@@ -104,7 +158,11 @@ jobs:
104158 return 1
105159 fi
106160
107- current_sha="$(jq -er '.object.sha' "${body_file}")"
161+ if ! current_sha="$(jq -er '.object.sha' "${body_file}")"; then
162+ rm -f "${body_file}"
163+ echo "::error::Could not read the current SHA of branch ${branch}."
164+ return 1
165+ fi
108166 if [[ -n "${expected_sha}" && "${current_sha}" != "${expected_sha}" ]]; then
109167 rm -f "${body_file}"
110168 echo "::error::Head branch ${branch} now points to ${current_sha}, not ${expected_sha}; leaving it and the staging branch in place."
@@ -136,15 +194,36 @@ jobs:
136194
137195 process_pr() {
138196 local advisory_file_pages base_ref base_repo expected_staging_branch head_ref head_repo head_sha
139- local pr_json pr_number="$1" state
197+ local fetch_status=0 pr_json pr_number="$1" state
140198 expected_staging_branch="${2:-}"
141199
142200 if ! is_pr_number "${pr_number}"; then
143201 echo "::error::Unexpected pull request number: ${pr_number}"
144202 return 1
145203 fi
146204
147- pr_json="$(gh api "repos/${REPOSITORY}/pulls/${pr_number}")"
205+ pr_json="$(fetch_pr_json "${pr_number}")" || fetch_status=$?
206+ if (( fetch_status == 2 )); then
207+ # A manual run names one pull request explicitly, so a missing one is an
208+ # operator error. During a sweep, or after a close event, it just means the
209+ # pull request was deleted and there is nothing left to verify against.
210+ if (( MISSING_PR_IS_ERROR )); then
211+ echo "::error::Pull request ${pr_number} does not exist."
212+ return 1
213+ fi
214+ echo "::warning::Pull request ${pr_number} no longer exists; leaving its branches in place."
215+ return 0
216+ fi
217+ if (( fetch_status != 0 )); then
218+ return 1
219+ fi
220+ # set -e is disabled inside this function because it is called from an "if !"
221+ # context, so an unusable response body would otherwise turn into a silent
222+ # skip on a green run rather than a reported failure.
223+ if ! jq -e 'type == "object" and has("state")' >/dev/null 2>&1 <<<"${pr_json}"; then
224+ echo "::error::Could not parse the API response for pull request ${pr_number}."
225+ return 1
226+ fi
148227 state="$(jq -r '.state' <<<"${pr_json}")"
149228 base_ref="$(jq -r '.base.ref' <<<"${pr_json}")"
150229 base_repo="$(jq -r '.base.repo.full_name' <<<"${pr_json}")"
@@ -182,15 +261,20 @@ jobs:
182261 return 1
183262 fi
184263
185- advisory_file_pages="$(gh api --paginate "repos/${REPOSITORY}/pulls/${pr_number}/files?per_page=100" \
186- --jq 'any(.[]; .filename | startswith("advisories/"))')"
264+ # process_pr is invoked from a conditional in the reconciliation loop, which
265+ # disables `set -e` inside this function, so every failure is explicit below.
266+ if ! advisory_file_pages="$(gh api --paginate "repos/${REPOSITORY}/pulls/${pr_number}/files?per_page=100" \
267+ --jq 'any(.[]; .filename | startswith("advisories/"))')"; then
268+ echo "::error::Failed to list the files changed by pull request ${pr_number}."
269+ return 1
270+ fi
187271 if ! grep -qx 'true' <<<"${advisory_file_pages}"; then
188272 echo "Pull request ${pr_number} does not modify advisories/; skipping."
189273 return 0
190274 fi
191275
192276 if [[ "${head_ref}" == "${base_ref}" ]]; then
193- delete_branch "${base_ref}" "${head_sha}"
277+ delete_branch "${base_ref}" "${head_sha}" || return 1
194278 return 0
195279 fi
196280
@@ -199,22 +283,115 @@ jobs:
199283 return 1
200284 fi
201285
202- delete_branch "${head_ref}" "${head_sha}"
286+ # Never delete the staging branch when the head branch could not be removed.
287+ delete_branch "${head_ref}" "${head_sha}" || return 1
203288 delete_branch "${base_ref}"
204289 }
205290
291+ # Resolves a batch of pull requests in a single GraphQL call and prints the
292+ # ones that still look like cleanup candidates as "<number>\t<base branch>".
293+ # Deleted pull requests come back as null alongside a NOT_FOUND error, so a
294+ # partial response is expected and is not treated as a failure.
295+ triage_chunk() {
296+ local chunk_file="$1"
297+ local errors pr_number query response
298+
299+ # shellcheck disable=SC2016 # $owner and $name are GraphQL variables, not shell ones.
300+ query='query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) {'
301+ while IFS=$'\t' read -r pr_number _; do
302+ is_pr_number "${pr_number}" || continue
303+ query+=" pr${pr_number}: pullRequest(number: ${pr_number}) { number state baseRefName headRepository { nameWithOwner } }"
304+ done < "${chunk_file}"
305+ query+=' } }'
306+
307+ response="$(mktemp)"
308+ errors="$(mktemp)"
309+ gh api graphql \
310+ -F owner="${REPOSITORY%%/*}" \
311+ -F name="${REPOSITORY#*/}" \
312+ -f query="${query}" > "${response}" 2>"${errors}" || true
313+
314+ if ! jq -e '.data.repository' "${response}" >/dev/null 2>&1; then
315+ # Only transport-level failures reach here; a response that merely reports
316+ # deleted pull requests still carries .data.repository. Surface the reason
317+ # so a persistently failing triage is diagnosable from the run log.
318+ head -n 10 "${errors}" | sed 's/^/graphql: /' >&2 || true
319+ rm -f "${response}" "${errors}"
320+ return 1
321+ fi
322+
323+ # A failing jq must not be masked by the trailing cleanup, otherwise the
324+ # batch would be silently dropped without being counted as a failure.
325+ if ! jq -r --arg repo "${REPOSITORY}" '
326+ .data.repository
327+ | to_entries[]
328+ | .value
329+ | select(. != null)
330+ | select(.state == "CLOSED" or .state == "MERGED")
331+ | select(.headRepository != null and .headRepository.nameWithOwner == $repo)
332+ | [(.number | tostring), .baseRefName]
333+ | @tsv
334+ ' "${response}"; then
335+ rm -f "${response}" "${errors}"
336+ return 1
337+ fi
338+ rm -f "${response}" "${errors}"
339+ }
340+
206341 collect_reconciliation_targets() {
207- local branch branches
342+ local branch branches candidates_file chunk chunk_dir join_status=0 pairs_file
343+
344+ if ! branches="$(gh api --paginate "repos/${REPOSITORY}/branches?per_page=100" --jq '.[].name')"; then
345+ echo "::error::Failed to list the branches of ${REPOSITORY}." >&2
346+ return 1
347+ fi
348+
349+ pairs_file="$(mktemp)"
350+ candidates_file="$(mktemp)"
351+ chunk_dir="$(mktemp -d)"
208352
209- branches="$(gh api --paginate "repos/${REPOSITORY}/branches?per_page=100" --jq '.[].name')"
210353 while IFS= read -r branch; do
211354 if [[ "${branch}" =~ ^[^/]+/advisory-improvement-([0-9]+)$ ]] &&
212355 git check-ref-format "refs/heads/${branch}" >/dev/null; then
213356 printf '%s\t%s\n' "${BASH_REMATCH[1]}" "${branch}"
214357 fi
215- done <<<"${branches}"
358+ done <<<"${branches}" > "${pairs_file}"
359+
360+ if [[ ! -s "${pairs_file}" ]]; then
361+ rm -rf "${pairs_file}" "${candidates_file}" "${chunk_dir}"
362+ return 0
363+ fi
364+
365+ # Resolving every staging branch over REST costs one request per branch.
366+ # Batching the triage keeps a full sweep to a couple of dozen requests.
367+ split -l "${TRIAGE_CHUNK_SIZE}" "${pairs_file}" "${chunk_dir}/chunk_"
368+ for chunk in "${chunk_dir}"/chunk_*; do
369+ if ! triage_chunk "${chunk}" >> "${candidates_file}"; then
370+ TRIAGE_FAILURES=$(( TRIAGE_FAILURES + 1 ))
371+ echo "::warning::Could not triage a batch of staging branches; they will be retried on the next run." >&2
372+ fi
373+ done
374+
375+ # Keep the branch name observed in the branch listing rather than the one
376+ # reported by GraphQL, so process_pr still detects a pull request that was
377+ # retargeted between the listing and the deletion.
378+ if [[ -s "${candidates_file}" ]]; then
379+ awk -F'\t' 'NR==FNR { keep[$0] = 1; next } ($1 FS $2) in keep' \
380+ "${candidates_file}" "${pairs_file}" || join_status=$?
381+ fi
382+
383+ echo "Inspected $(wc -l < "${pairs_file}" | tr -d ' ') staging branches." >&2
384+ rm -rf "${pairs_file}" "${candidates_file}" "${chunk_dir}"
385+ # The cleanup above must not mask a failed join, otherwise the sweep would
386+ # report success while having reconciled nothing.
387+ return "${join_status}"
216388 }
217389
390+ TRIAGE_CHUNK_SIZE=50
391+ TRIAGE_FAILURES=0
392+ PROCESS_FAILURES=0
393+ MISSING_PR_IS_ERROR=0
394+
218395 if [[ "${GITHUB_EVENT_NAME}" == "workflow_run" ]]; then
219396 PR_NUMBER="${WORKFLOW_RUN_PR_NUMBER}"
220397 if ! is_pr_number "${PR_NUMBER:-}"; then
@@ -223,14 +400,27 @@ jobs:
223400 fi
224401 process_pr "${PR_NUMBER}"
225402 elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
403+ MISSING_PR_IS_ERROR=1
226404 process_pr "${DISPATCH_PR_NUMBER}"
227405 else
228- TARGETS="$(collect_reconciliation_targets)"
229- if [[ -z "${TARGETS}" ]]; then
230- echo "No staging branches need reconciliation."
231- exit 0
232- fi
406+ TARGETS_FILE="$(mktemp)"
407+ collect_reconciliation_targets > "${TARGETS_FILE}"
408+ echo "Reconciling $(wc -l < "${TARGETS_FILE}" | tr -d ' ') staging branch(es)."
409+
410+ # A single unreconcilable pull request must not stop the sweep, otherwise
411+ # every branch after it is never reconciled.
233412 while IFS=$'\t' read -r PR_NUMBER STAGING_BRANCH; do
234- process_pr "${PR_NUMBER}" "${STAGING_BRANCH}"
235- done <<<"${TARGETS}"
413+ [[ -n "${PR_NUMBER}" ]] || continue
414+ if ! process_pr "${PR_NUMBER}" "${STAGING_BRANCH}"; then
415+ PROCESS_FAILURES=$(( PROCESS_FAILURES + 1 ))
416+ echo "::error::Failed to reconcile pull request ${PR_NUMBER} (${STAGING_BRANCH})."
417+ fi
418+ done < "${TARGETS_FILE}"
419+ rm -f "${TARGETS_FILE}"
420+
421+ if (( TRIAGE_FAILURES > 0 || PROCESS_FAILURES > 0 )); then
422+ echo "::error::Reconciliation finished with ${PROCESS_FAILURES} pull request failure(s) and ${TRIAGE_FAILURES} triage failure(s)."
423+ exit 1
424+ fi
425+ echo "Reconciliation completed."
236426 fi
0 commit comments