Skip to content

Commit 57ead69

Browse files
taladraneCopilot
andcommitted
Keep the staging branch sweep alive when a pull request is deleted
The scheduled reconciliation sweep has failed on every run since 19 Aug. `process_pr` assumed every `<login>/advisory-improvement-<N>` branch still has a pull request `N`, so the unguarded `gh api repos/.../pulls/N` returned 404 and `set -euo pipefail` killed the whole step. Branches are processed in alphabetical order, so the sweep aborted at the same branch every time -- 57 of 1,608 -- and never reconciled the remaining 97%. Runs triggered by `workflow_run` were unaffected, which is why per-pull-request cleanup kept working and the breakage went unnoticed. Distinguish a deleted pull request from a transport failure, and isolate per-target failures so one unreconcilable pull request no longer stops the sweep. Failures are still counted and reported, and the step exits non-zero at the end, so a real problem stays visible. Resolving all 1,608 branches over REST would have cost ~1,770 requests and ~43 minutes on a 10-minute cron, so triage the branches in batches of 50 over GraphQL first. That brings a full sweep to roughly 55 API calls and about 90 seconds. The triage only ever narrows the candidate set -- `process_pr` still re-verifies every target over REST before deleting -- and the join deliberately keeps the branch name observed in the branch listing rather than the one GraphQL reported, so a pull request retargeted between the listing and the deletion is still caught. Add a `concurrency` group so sweeps cannot overlap, keyed per-run for the per-pull-request triggers so those are never queued behind a sweep. `set -e` is disabled inside `process_pr` because it is invoked from an `if !` context. Make the failure paths explicit rather than relying on it: guard the response parsing, propagate a failed head-branch deletion so the staging branch is never deleted after it, and stop trailing `rm` cleanups from masking the exit status of the command they follow. Without that last one a failed triage batch would have been silently dropped without being counted, turning "always red and does nothing" into "always green and does nothing". Verified against the live repository: 1,608 branches triage to 140 actionable targets in 1m25s with no triage failures, and the run that previously aborted now completes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: daca0885-6779-4adb-bb02-ff6920d73ab3
1 parent a050f34 commit 57ead69

1 file changed

Lines changed: 207 additions & 17 deletions

File tree

.github/workflows/delete_staging_and_head_branches_writer.yaml

Lines changed: 207 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
2027
jobs:
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') }}
@@ -59,6 +66,53 @@ jobs:
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

Comments
 (0)