Skip to content

Commit fc2d8f9

Browse files
Copilotwallstop
andauthored
fix(ci): harden polling timeout bounds and stale-head wait coverage
Agent-Logs-Url: https://github.com/wallstop/fortress-rollback/sessions/29c2fe6c-90ea-431c-ab05-f80a559e330f Co-authored-by: wallstop <[email protected]>
1 parent e776920 commit fc2d8f9

3 files changed

Lines changed: 163 additions & 7 deletions

File tree

scripts/ci/docker-network-tests.sh

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ run_test() {
141141
# Wait for containers to exit (poll their status)
142142
local elapsed=0
143143
local poll_interval=2
144+
local remaining
145+
local sleep_for
144146
while [ $elapsed -lt $wait_timeout ]; do
145147
# Check if both containers have exited
146148
local peer1_running peer2_running
@@ -151,8 +153,14 @@ run_test() {
151153
break
152154
fi
153155

154-
sleep $poll_interval
155-
elapsed=$((elapsed + poll_interval))
156+
remaining=$((wait_timeout - elapsed))
157+
sleep_for=$poll_interval
158+
if [ "$sleep_for" -gt "$remaining" ]; then
159+
sleep_for=$remaining
160+
fi
161+
162+
sleep "$sleep_for"
163+
elapsed=$((elapsed + sleep_for))
156164
done
157165

158166
# Collect logs from both containers

scripts/ci/enable-dependabot-automerge.sh

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ required_checks_count() {
6060

6161
wait_for_required_checks() {
6262
local elapsed=0
63+
local remaining
64+
local sleep_for
6365
local required_count
6466

6567
while ((elapsed <= REQUIRED_CHECKS_APPEAR_TIMEOUT_SECONDS)); do
@@ -82,12 +84,18 @@ wait_for_required_checks() {
8284
return 0
8385
fi
8486

85-
if ((elapsed == REQUIRED_CHECKS_APPEAR_TIMEOUT_SECONDS)); then
87+
remaining=$((REQUIRED_CHECKS_APPEAR_TIMEOUT_SECONDS - elapsed))
88+
if ((remaining <= 0)); then
8689
break
8790
fi
8891

89-
sleep "$REQUIRED_CHECKS_POLL_INTERVAL_SECONDS"
90-
elapsed=$((elapsed + REQUIRED_CHECKS_POLL_INTERVAL_SECONDS))
92+
sleep_for="$REQUIRED_CHECKS_POLL_INTERVAL_SECONDS"
93+
if ((sleep_for > remaining)); then
94+
sleep_for="$remaining"
95+
fi
96+
97+
sleep "$sleep_for"
98+
elapsed=$((elapsed + sleep_for))
9199
done
92100

93101
echo "No required checks detected for PR within timeout; refusing to enable auto-merge." >&2

scripts/tests/test_enable_dependabot_automerge.py

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,33 @@ def _write_stub_gh(path: Path) -> None:
2424
shift
2525
shift || true
2626
27+
next_sequence_value() {
28+
local sequence="$1"
29+
local default_value="$2"
30+
local counter_key="$3"
31+
local state_dir="${GH_STATE_DIR:?GH_STATE_DIR is required}"
32+
local index_file="$state_dir/$counter_key.idx"
33+
local index=0
34+
35+
if [[ -z "$sequence" ]]; then
36+
printf '%s\\n' "$default_value"
37+
return
38+
fi
39+
40+
if [[ -f "$index_file" ]]; then
41+
index="$(cat "$index_file")"
42+
fi
43+
44+
IFS=',' read -r -a values <<< "$sequence"
45+
if ((index < ${#values[@]})); then
46+
printf '%s\\n' "${values[$index]}"
47+
else
48+
printf '%s\\n' "${values[$((${#values[@]} - 1))]}"
49+
fi
50+
51+
printf '%s\\n' $((index + 1)) > "$index_file"
52+
}
53+
2754
if [[ "$cmd" == "pr" && "$subcmd" == "view" ]]; then
2855
jq_expr=""
2956
while [[ $# -gt 0 ]]; do
@@ -37,7 +64,7 @@ def _write_stub_gh(path: Path) -> None:
3764
".state") printf '%s\\n' "${GH_PR_STATE:-OPEN}" ;;
3865
".isDraft") printf '%s\\n' "${GH_PR_DRAFT:-false}" ;;
3966
".autoMergeRequest != null") printf '%s\\n' "${GH_PR_AUTO_MERGE:-false}" ;;
40-
".headRefOid") printf '%s\\n' "${GH_PR_HEAD_OID:-head-sha}" ;;
67+
".headRefOid") next_sequence_value "${GH_PR_HEAD_OID_SEQUENCE:-}" "${GH_PR_HEAD_OID:-head-sha}" "head_ref_oid" ;;
4168
*) exit 1 ;;
4269
esac
4370
exit 0
@@ -79,7 +106,7 @@ def _write_stub_gh(path: Path) -> None:
79106
if [[ "$cmd" == "pr" && "$subcmd" == "checks" ]]; then
80107
printf '%s\\n' "pr checks $*" >> "${GH_LOG_PATH:?GH_LOG_PATH is required}"
81108
if [[ "$*" == *"--json name --jq length"* ]]; then
82-
printf '%s\\n' "${GH_REQUIRED_CHECKS_COUNT:-1}"
109+
next_sequence_value "${GH_REQUIRED_CHECKS_COUNT_SEQUENCE:-}" "${GH_REQUIRED_CHECKS_COUNT:-1}" "required_checks_count"
83110
exit 0
84111
fi
85112
if [[ "$*" == *"--watch"* ]]; then
@@ -108,6 +135,7 @@ def _run_script(tmp_path: Path, extra_env: dict[str, str]) -> subprocess.Complet
108135
"GITHUB_REPOSITORY": "wallstop/fortress-rollback",
109136
"GH_TOKEN": "fake-token",
110137
"GH_LOG_PATH": str(log_path),
138+
"GH_STATE_DIR": str(tmp_path),
111139
"REQUIRED_CHECKS_APPEAR_TIMEOUT_SECONDS": "0",
112140
"REQUIRED_CHECKS_POLL_INTERVAL_SECONDS": "1",
113141
"REQUIRED_CHECKS_WATCH_INTERVAL_SECONDS": "1",
@@ -213,3 +241,115 @@ def test_fails_when_required_checks_fail(tmp_path: Path) -> None:
213241
assert "--json name --jq length" in log_lines[0]
214242
assert "--watch" in log_lines[1]
215243
assert "pr merge" not in "\n".join(log_lines)
244+
245+
246+
def test_waits_for_required_checks_to_appear_then_merges(tmp_path: Path) -> None:
247+
result = _run_script(
248+
tmp_path,
249+
{
250+
"GH_REQUIRED_CHECKS_COUNT_SEQUENCE": "0,0,1",
251+
"GH_MERGE_SUCCESS_FLAG": "--squash",
252+
"GH_ALLOW_SQUASH": "true",
253+
"GH_ALLOW_REBASE": "false",
254+
"GH_ALLOW_MERGE": "false",
255+
"REQUIRED_CHECKS_APPEAR_TIMEOUT_SECONDS": "3",
256+
"REQUIRED_CHECKS_POLL_INTERVAL_SECONDS": "1",
257+
},
258+
)
259+
assert result.returncode == 0
260+
261+
log_lines = (tmp_path / "gh.log").read_text(encoding="utf-8").splitlines()
262+
assert len(log_lines) == 5
263+
assert "--json name --jq length" in log_lines[0]
264+
assert "--json name --jq length" in log_lines[1]
265+
assert "--json name --jq length" in log_lines[2]
266+
assert "--watch" in log_lines[3]
267+
assert "--squash" in log_lines[4]
268+
269+
270+
def test_skips_when_head_becomes_stale_while_waiting(tmp_path: Path) -> None:
271+
result = _run_script(
272+
tmp_path,
273+
{
274+
"GH_REQUIRED_CHECKS_COUNT_SEQUENCE": "0,0,1",
275+
"GH_PR_HEAD_OID_SEQUENCE": "head-sha,head-sha,new-head-sha",
276+
"GH_ALLOW_SQUASH": "true",
277+
"GH_ALLOW_REBASE": "false",
278+
"GH_ALLOW_MERGE": "false",
279+
"REQUIRED_CHECKS_APPEAR_TIMEOUT_SECONDS": "3",
280+
"REQUIRED_CHECKS_POLL_INTERVAL_SECONDS": "1",
281+
},
282+
)
283+
assert result.returncode == 0
284+
assert "PR head moved while waiting for required checks" in result.stdout
285+
286+
log_lines = (tmp_path / "gh.log").read_text(encoding="utf-8").splitlines()
287+
assert len(log_lines) == 1
288+
assert "--json name --jq length" in log_lines[0]
289+
assert "--watch" not in log_lines[0]
290+
assert "pr merge" not in log_lines[0]
291+
292+
293+
def test_skips_when_head_becomes_stale_after_checks_appear(tmp_path: Path) -> None:
294+
result = _run_script(
295+
tmp_path,
296+
{
297+
"GH_REQUIRED_CHECKS_COUNT_SEQUENCE": "1",
298+
"GH_PR_HEAD_OID_SEQUENCE": "head-sha,head-sha,new-head-sha",
299+
"GH_ALLOW_SQUASH": "true",
300+
"GH_ALLOW_REBASE": "false",
301+
"GH_ALLOW_MERGE": "false",
302+
},
303+
)
304+
assert result.returncode == 0
305+
assert "PR head moved after required checks appeared" in result.stdout
306+
307+
log_lines = (tmp_path / "gh.log").read_text(encoding="utf-8").splitlines()
308+
assert len(log_lines) == 1
309+
assert "--json name --jq length" in log_lines[0]
310+
assert "--watch" not in log_lines[0]
311+
assert "pr merge" not in log_lines[0]
312+
313+
314+
def test_skips_when_head_becomes_stale_after_checks_complete(tmp_path: Path) -> None:
315+
result = _run_script(
316+
tmp_path,
317+
{
318+
"GH_REQUIRED_CHECKS_COUNT_SEQUENCE": "1",
319+
"GH_PR_HEAD_OID_SEQUENCE": "head-sha,head-sha,head-sha,new-head-sha",
320+
"GH_ALLOW_SQUASH": "true",
321+
"GH_ALLOW_REBASE": "false",
322+
"GH_ALLOW_MERGE": "false",
323+
},
324+
)
325+
assert result.returncode == 0
326+
assert "PR head moved after required checks completed" in result.stdout
327+
328+
log_lines = (tmp_path / "gh.log").read_text(encoding="utf-8").splitlines()
329+
assert len(log_lines) == 2
330+
assert "--json name --jq length" in log_lines[0]
331+
assert "--watch" in log_lines[1]
332+
assert "pr merge" not in "\n".join(log_lines)
333+
334+
335+
def test_caps_poll_sleep_to_remaining_timeout(tmp_path: Path) -> None:
336+
result = _run_script(
337+
tmp_path,
338+
{
339+
"GH_REQUIRED_CHECKS_COUNT_SEQUENCE": "0,1",
340+
"GH_MERGE_SUCCESS_FLAG": "--squash",
341+
"GH_ALLOW_SQUASH": "true",
342+
"GH_ALLOW_REBASE": "false",
343+
"GH_ALLOW_MERGE": "false",
344+
"REQUIRED_CHECKS_APPEAR_TIMEOUT_SECONDS": "1",
345+
"REQUIRED_CHECKS_POLL_INTERVAL_SECONDS": "10",
346+
},
347+
)
348+
assert result.returncode == 0
349+
350+
log_lines = (tmp_path / "gh.log").read_text(encoding="utf-8").splitlines()
351+
assert len(log_lines) == 4
352+
assert "--json name --jq length" in log_lines[0]
353+
assert "--json name --jq length" in log_lines[1]
354+
assert "--watch" in log_lines[2]
355+
assert "--squash" in log_lines[3]

0 commit comments

Comments
 (0)