Skip to content

Commit 6372acb

Browse files
fix(ci): persist Hacktoberfest tracker via a PR (protected-branch push was silently failing) (#15231)
* fix(ci): open a PR to persist Hacktoberfest tracker instead of pushing to protected master * ci: use bundled gh CLI instead of peter-evans/create-pull-request Per @cclauss / zizmor 'superfluous actions' audit, persist the rolling tracker PR with the gh CLI rather than a third-party action. * fix(ci): make tracker refresh degrade gracefully when rate limited The dry run was failing because a run can exhaust the GITHUB_TOKEN's 1000/hour-per-repo budget (shared across concurrent runs) — chiefly the awaiting-reviews directory scan. A single exhausted request then raised and killed the whole job. - _request now honours Retry-After (secondary limits) and, once retries are exhausted, raises BestEffortError instead of a bare RuntimeError. - Row resolution, the directory scan, and the search counts catch BestEffortError and degrade (keep the row / mark the stat unavailable) instead of failing. Only the post-Oct-1 retirement exits non-zero. - Trim the directory scan to 120 PRs and CONCURRENCY to 5 to stay well under the shared budget in the first place.
1 parent b2ab92b commit 6372acb

2 files changed

Lines changed: 130 additions & 34 deletions

File tree

.github/workflows/hacktoberfest_prep.yml

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ on:
2020

2121
permissions:
2222
contents: write
23+
pull-requests: write
2324

2425
jobs:
2526
hacktoberfest-prep:
@@ -57,13 +58,42 @@ jobs:
5758
if git diff --quiet -- docs/hacktober_2026_prep.md; then
5859
echo "No changes to docs/hacktober_2026_prep.md."
5960
fi
60-
- name: Commit any changes
61+
# `master` is a protected branch: direct pushes are rejected with
62+
# `GH006: Protected branch update failed ... Changes must be made through
63+
# a pull request`. So instead of committing straight to master, persist
64+
# the refreshed tracker on a single rolling branch and open — or, since
65+
# re-pushing the branch updates the existing PR in place, leave open — one
66+
# pull request. Uses the bundled `gh` CLI rather than a third-party
67+
# action (see zizmor's "superfluous actions" audit).
68+
- name: Open or update the tracker pull request
6169
if: github.event_name != 'push' && github.event_name != 'pull_request'
70+
env:
71+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
72+
BRANCH: chore/hacktoberfest-2026-prep-refresh
6273
run: |
74+
set -euo pipefail
75+
if git diff --quiet -- docs/hacktober_2026_prep.md; then
76+
echo "No changes to docs/hacktober_2026_prep.md; nothing to persist."
77+
exit 0
78+
fi
6379
git config --global user.name "$GITHUB_ACTOR"
6480
git config --global user.email "$GITHUB_ACTOR@users.noreply.github.com"
81+
git switch -c "$BRANCH"
6582
git add docs/hacktober_2026_prep.md
66-
git commit -m "chore: refresh Hacktoberfest 2026 prep tracker" || echo "No changes to commit"
67-
git push || echo "Nothing to push"
83+
git commit -m "chore: refresh Hacktoberfest 2026 prep tracker"
84+
# Force-push so the rolling branch always carries just the latest
85+
# snapshot on top of master; this also updates any open PR in place.
86+
git push --force origin "$BRANCH"
87+
if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then
88+
gh pr create \
89+
--base master \
90+
--head "$BRANCH" \
91+
--title "chore: refresh Hacktoberfest 2026 prep tracker" \
92+
--body "Automated daily refresh of the Hacktoberfest 2026 open-PR cleanup tracker (\`docs/hacktober_2026_prep.md\`): ticks off any tracked pull request that has since been merged/closed and rewrites the **Automated statistics** section.
93+
94+
This PR is updated in place by the \`hacktoberfest_prep\` workflow, so it always reflects the latest scheduled run. Merge it whenever you want to capture the current snapshot."
95+
else
96+
echo "Open tracker PR already exists; force-push updated it in place."
97+
fi
6898
- name: Propagate the script's exit code
6999
run: exit ${{ steps.update.outputs.exit_code }}

scripts/hacktoberfest_prep_update.py

Lines changed: 97 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,14 @@
4343
AWAITING_LABEL = "awaiting reviews"
4444
HACKTOBERFEST_START = dt.date(2026, 10, 1)
4545

46-
# How many API requests to keep in flight at once. GitHub's authenticated
47-
# primary limit is 5000/hour, but bursts of concurrent requests can trip the
48-
# secondary limits, so keep this modest.
49-
CONCURRENCY = 8
46+
# How many API requests to keep in flight at once. A user token's primary
47+
# limit is 5000/hour, but the ``GITHUB_TOKEN`` the runner hands us is capped at
48+
# 1000/hour *per repository* and shared across every concurrent workflow run, so
49+
# several dry runs in the same hour can exhaust it between them. Bursts of
50+
# concurrent requests can also trip the secondary limits. Keep this modest and
51+
# let the callers degrade gracefully when a request can't be satisfied (see
52+
# ``BestEffortError``) rather than failing the whole job.
53+
CONCURRENCY = 5
5054

5155
# A tracked row looks like: ``12. [ ] #15144 awaiting reviews``
5256
ROW_RE = re.compile(
@@ -55,6 +59,16 @@
5559
STATS_HEADER = "## Automated statistics"
5660

5761

62+
class BestEffortError(RuntimeError):
63+
"""A row/statistic could not be fetched (e.g. rate limited).
64+
65+
Raised by :func:`_request` once every retry is exhausted. The refresh is
66+
best-effort: callers catch this so an unreachable API degrades the tracker
67+
(keep the old value / omit a stat) instead of failing the whole job. The
68+
only intentional non-zero exit is the post-Oct-1 retirement.
69+
"""
70+
71+
5872
def _log(message: str) -> None:
5973
"""Emit a progress line to stderr, flushed so Actions shows it live."""
6074
print(message, file=sys.stderr, flush=True)
@@ -86,19 +100,32 @@ async def _request(
86100
resp = await client.get(url, params=params)
87101
if resp.is_success:
88102
return resp.json(), dict(resp.headers)
89-
remaining = resp.headers.get("X-RateLimit-Remaining")
90-
if resp.status_code in (403, 429) and remaining == "0":
91-
reset = int(resp.headers.get("X-RateLimit-Reset", "0"))
92-
wait = max(1, reset - int(time.time())) + 1
93-
_log(f"Rate limited on {url}; sleeping {min(wait, 90)}s")
94-
await asyncio.sleep(min(wait, 90))
95-
continue
103+
# Both primary ("remaining == 0") and secondary/abuse rate limits
104+
# come back as 403/429. Primary limits advertise a reset epoch;
105+
# secondary limits instead send a ``Retry-After`` (seconds) and may
106+
# still report a non-zero remaining, so honour either signal.
107+
if resp.status_code in (403, 429):
108+
remaining = resp.headers.get("X-RateLimit-Remaining")
109+
retry_after = resp.headers.get("Retry-After")
110+
if retry_after is not None:
111+
wait = int(retry_after) + 1
112+
elif remaining == "0":
113+
reset = int(resp.headers.get("X-RateLimit-Reset", "0"))
114+
wait = max(1, reset - int(time.time())) + 1
115+
else:
116+
wait = 0
117+
if wait and attempt < 3:
118+
_log(f"Rate limited on {url}; sleeping {min(wait, 90)}s")
119+
await asyncio.sleep(min(wait, 90))
120+
continue
96121
if resp.status_code >= 500 and attempt < 3:
97122
await asyncio.sleep(2 * (attempt + 1))
98123
continue
99124
resp.raise_for_status()
100-
msg = f"giving up on {url}"
101-
raise RuntimeError(msg)
125+
# Exhausted every retry (typically the shared per-repo budget ran dry).
126+
# Signal the callers to degrade rather than crash the whole run.
127+
msg = f"giving up on {url} after repeated rate limiting"
128+
raise BestEffortError(msg)
102129

103130

104131
async def _search_count(
@@ -135,7 +162,7 @@ async def top_awaiting_directories(
135162
client: httpx2.AsyncClient,
136163
sem: asyncio.Semaphore,
137164
limit: int = 3,
138-
max_prs: int = 400,
165+
max_prs: int = 120,
139166
) -> list[tuple[str, int]]:
140167
"""Count open ``awaiting reviews`` PRs by the top-level directory they touch."""
141168
query = f'repo:{REPO} is:pr is:open label:"{AWAITING_LABEL}"'
@@ -176,12 +203,22 @@ async def dirs_for(number: int) -> set[str]:
176203
_log(f" ...scanned {done}/{total} PR(s)")
177204
return dirs
178205

179-
results = await asyncio.gather(*(dirs_for(n) for n in numbers))
206+
results = await asyncio.gather(
207+
*(dirs_for(n) for n in numbers), return_exceptions=True
208+
)
180209

181210
counts: dict[str, int] = {}
211+
skipped = 0
182212
for dirs in results:
213+
if isinstance(dirs, BestEffortError):
214+
skipped += 1
215+
continue
216+
if isinstance(dirs, BaseException):
217+
raise dirs
183218
for directory in dirs:
184219
counts[directory] = counts.get(directory, 0) + 1
220+
if skipped:
221+
_log(f" ...{skipped} PR(s) skipped (API unavailable); ranking partial.")
185222
ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
186223
return ranked[:limit]
187224

@@ -199,12 +236,23 @@ async def refresh_checkboxes(
199236
]
200237
if pending:
201238
_log(f"Checking {len(pending)} open tracker row(s) for resolution...")
202-
states = dict(
203-
zip(
204-
pending,
205-
await asyncio.gather(*(pr_state(client, sem, n) for n in pending)),
206-
)
239+
# ``return_exceptions`` keeps one rate-limited row from cancelling the rest:
240+
# a row we couldn't resolve is simply left unchanged (treated as ``None``).
241+
resolved = await asyncio.gather(
242+
*(pr_state(client, sem, n) for n in pending), return_exceptions=True
207243
)
244+
states: dict[int, str | None] = {}
245+
unresolved = 0
246+
for number, result in zip(pending, resolved):
247+
if isinstance(result, BestEffortError):
248+
unresolved += 1
249+
states[number] = None
250+
elif isinstance(result, BaseException):
251+
raise result
252+
else:
253+
states[number] = result
254+
if unresolved:
255+
_log(f" ...{unresolved} row(s) left unchanged (API unavailable).")
208256

209257
updated = 0
210258
out: list[str] = []
@@ -225,13 +273,23 @@ async def refresh_checkboxes(
225273
async def build_stats_block(client: httpx2.AsyncClient, sem: asyncio.Semaphore) -> str:
226274
_log("Collecting open issue/PR counts...")
227275
awaiting_query = f'repo:{REPO} is:pr is:open label:"{AWAITING_LABEL}"'
276+
277+
async def _count_or_none(query: str) -> int | None:
278+
try:
279+
return await _search_count(client, sem, query)
280+
except BestEffortError:
281+
return None
282+
228283
open_issues, open_prs, awaiting = await asyncio.gather(
229-
_search_count(client, sem, f"repo:{REPO} is:issue is:open"),
230-
_search_count(client, sem, f"repo:{REPO} is:pr is:open"),
231-
_search_count(client, sem, awaiting_query),
284+
_count_or_none(f"repo:{REPO} is:issue is:open"),
285+
_count_or_none(f"repo:{REPO} is:pr is:open"),
286+
_count_or_none(awaiting_query),
232287
)
233288
today = dt.datetime.now(dt.UTC).date().isoformat()
234289

290+
def _fmt(value: int | None) -> str:
291+
return str(value) if value is not None else "unavailable (rate limited)"
292+
235293
lines = [
236294
STATS_HEADER,
237295
"",
@@ -240,22 +298,30 @@ async def build_stats_block(client: httpx2.AsyncClient, sem: asyncio.Semaphore)
240298
f"`scripts/hacktoberfest_prep_update.py` on {today} (UTC)._"
241299
),
242300
"",
243-
f"- **Open issues:** {open_issues}",
244-
f"- **Open pull requests:** {open_prs}",
245-
f"- **Open PRs labelled `{AWAITING_LABEL}`:** {awaiting}",
301+
f"- **Open issues:** {_fmt(open_issues)}",
302+
f"- **Open pull requests:** {_fmt(open_prs)}",
303+
f"- **Open PRs labelled `{AWAITING_LABEL}`:** {_fmt(awaiting)}",
246304
"",
247305
(
248306
"**Top three directories to work on** (most open pull requests "
249307
f"labelled `{AWAITING_LABEL}`):"
250308
),
251309
"",
252310
]
253-
if top_dirs := await top_awaiting_directories(client, sem):
254-
for rank, (directory, count) in enumerate(top_dirs, start=1):
255-
plural = "PR" if count == 1 else "PRs"
256-
lines.append(f"{rank}. `{directory}/` — {count} awaiting-reviews {plural}")
311+
try:
312+
top_dirs = await top_awaiting_directories(client, sem)
313+
except BestEffortError:
314+
top_dirs = []
315+
lines.append("_Directory ranking unavailable this run (rate limited)._")
257316
else:
258-
lines.append("_No open `awaiting reviews` pull requests found._")
317+
if top_dirs:
318+
for rank, (directory, count) in enumerate(top_dirs, start=1):
319+
plural = "PR" if count == 1 else "PRs"
320+
lines.append(
321+
f"{rank}. `{directory}/` — {count} awaiting-reviews {plural}"
322+
)
323+
else:
324+
lines.append("_No open `awaiting reviews` pull requests found._")
259325
lines.append("")
260326
return "\n".join(lines)
261327

0 commit comments

Comments
 (0)