Skip to content

Commit 2dc322b

Browse files
[ci]: harden best-effort Buildkite cancellation
1 parent 9703f1e commit 2dc322b

2 files changed

Lines changed: 105 additions & 20 deletions

File tree

.github/workflows/ci-trigger-full-suite.yml

Lines changed: 66 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -44,37 +44,83 @@ jobs:
4444
continue-on-error: true
4545
if: steps.check.outputs.has_ready == 'true'
4646
env:
47+
BK_ORG: ${{ vars.BUILDKITE_ORG_SLUG }}
48+
BK_PIPELINE: ${{ vars.BUILDKITE_PIPELINE_SLUG }}
4749
BUILDKITE_API_TOKEN: ${{ secrets.BUILDKITE_API_TOKEN }}
4850
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
4951
PR_NUMBER: ${{ github.event.pull_request.number }}
5052
run: |
51-
# `curl` without --fail-with-body treats an HTTP error as success and
52-
# pipes the error body onward. Buildkite answers a rate limit or an
53-
# unauthorized read with an object such as {"message": "Not Found"},
54-
# and `.[]` over an object yields its values, so `.env` then runs
55-
# against a string and jq exits non-zero.
56-
if ! response=$(curl -sS --get --fail-with-body \
53+
set -euo pipefail
54+
response_file=$(mktemp)
55+
builds_file=$(mktemp)
56+
trap 'rm -f "$response_file" "$builds_file"' EXIT
57+
58+
if [[ ! "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
59+
echo "::warning::Invalid pull request number; stale Buildkite builds may continue."
60+
exit 1
61+
fi
62+
63+
if ! curl -sS --fail-with-body --get \
5764
-H "Authorization: Bearer $BUILDKITE_API_TOKEN" \
5865
--data-urlencode "branch=$PR_BRANCH" \
59-
--data-urlencode "state=running,scheduled" \
60-
"https://api.buildkite.com/v2/organizations/${{ vars.BUILDKITE_ORG_SLUG }}/pipelines/${{ vars.BUILDKITE_PIPELINE_SLUG }}/builds" 2>&1); then
61-
echo "::warning::Could not list Buildkite builds, skipping cancellation: $response"
62-
exit 0
66+
--data-urlencode "state[]=running" \
67+
--data-urlencode "state[]=scheduled" \
68+
--data-urlencode "exclude_jobs=true" \
69+
--data-urlencode "exclude_pipeline=true" \
70+
--output "$response_file" \
71+
"https://api.buildkite.com/v2/organizations/${BK_ORG}/pipelines/${BK_PIPELINE}/builds"; then
72+
echo "::warning::Could not list Buildkite builds; stale merge-gate builds may continue."
73+
exit 1
74+
fi
75+
76+
if ! jq -e '
77+
if type != "array" then false
78+
else all(.[];
79+
if type != "object" then false
80+
else
81+
(.number | if type == "number" then . > 0 and floor == . else false end)
82+
and (
83+
(.env? | if . == null then {} else . end) as $env
84+
| if ($env | type) != "object" then false
85+
else
86+
($env.TEST_SCOPE? | . == null or type == "string")
87+
and ($env.PR_NUMBER? | . == null or type == "string")
88+
end
89+
)
90+
end
91+
)
92+
end
93+
' "$response_file" >/dev/null 2>&1; then
94+
# Do not print the response body: it is remote data and may contain
95+
# multiline values that would be interpreted as workflow commands.
96+
echo "::warning::Buildkite returned an invalid build list; stale merge-gate builds may continue."
97+
exit 1
6398
fi
6499
65100
# Match both branch and PR number: forks can reuse the same branch name.
66-
if ! builds=$(printf '%s' "$response" | jq -r --arg pr_number "$PR_NUMBER" \
67-
'if type == "array" then .[] else empty end
68-
| select((.env.TEST_SCOPE? == "merge") and (.env.PR_NUMBER? == $pr_number))
69-
| .number' 2>&1); then
70-
echo "::warning::Could not parse the Buildkite build list, skipping cancellation: $builds"
71-
exit 0
101+
if ! jq -r --arg pr_number "$PR_NUMBER" '
102+
.[]
103+
| select((.env.TEST_SCOPE? == "merge") and (.env.PR_NUMBER? == $pr_number))
104+
| .number
105+
' "$response_file" > "$builds_file"; then
106+
echo "::warning::Could not select stale Buildkite builds; stale merge-gate builds may continue."
107+
exit 1
72108
fi
73-
for build_num in $builds; do
109+
110+
cancellation_failed=0
111+
while IFS= read -r build_num; do
74112
echo "Cancelling Buildkite build #$build_num"
75-
curl -sS -X PUT -H "Authorization: Bearer $BUILDKITE_API_TOKEN" \
76-
"https://api.buildkite.com/v2/organizations/${{ vars.BUILDKITE_ORG_SLUG }}/pipelines/${{ vars.BUILDKITE_PIPELINE_SLUG }}/builds/${build_num}/cancel"
77-
done
113+
if ! curl -sS --fail-with-body -o /dev/null -X PUT \
114+
-H "Authorization: Bearer $BUILDKITE_API_TOKEN" \
115+
"https://api.buildkite.com/v2/organizations/${BK_ORG}/pipelines/${BK_PIPELINE}/builds/${build_num}/cancel"; then
116+
echo "::warning::Could not cancel Buildkite build #$build_num; trying remaining builds."
117+
cancellation_failed=1
118+
fi
119+
done < "$builds_file"
120+
121+
if (( cancellation_failed != 0 )); then
122+
exit 1
123+
fi
78124
79125
# Check out the immutable BASE SHA: pull_request_target must never run a
80126
# planner or gate script from the untrusted PR head.

fastvideo/tests/contract/test_ci_test_collection.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,45 @@ def test_merge_comment_has_one_change_aware_trigger_path():
164164
assert "__FASTVIDEO_CI_PLAN_ALL__" in ready_workflow
165165

166166

167+
def test_merge_gate_buildkite_cancellation_is_best_effort_and_strict():
168+
workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/ci-trigger-full-suite.yml").read_text())
169+
steps = workflow["jobs"]["trigger"]["steps"]
170+
cancel_step = next(step for step in steps if step.get("name") == "Cancel previous Buildkite builds")
171+
trigger_step = next(step for step in steps if step.get("name") == "Trigger Buildkite merge gate")
172+
cancel_script = cancel_step["run"]
173+
174+
# Stale-build cleanup is best effort; creating the replacement gate remains
175+
# a hard failure so merge protection can never pass without a test build.
176+
assert cancel_step["continue-on-error"] is True
177+
assert trigger_step.get("continue-on-error") is not True
178+
assert "curl -sS --fail-with-body -X POST" in trigger_step["run"]
179+
180+
assert cancel_script.splitlines()[0] == "set -euo pipefail"
181+
assert "curl -sS --fail-with-body --get" in cancel_script
182+
assert cancel_script.count('--data-urlencode "state[]=running"') == 1
183+
assert cancel_script.count('--data-urlencode "state[]=scheduled"') == 1
184+
assert cancel_script.count('--data-urlencode "exclude_jobs=true"') == 1
185+
assert cancel_script.count('--data-urlencode "exclude_pipeline=true"') == 1
186+
assert 'state=running,scheduled' not in cancel_script
187+
188+
# Only a validated array of build records can reach the URL construction.
189+
assert 'if type != "array" then false' in cancel_script
190+
assert 'if type != "object" then false' in cancel_script
191+
assert 'if type == "number" then . > 0 and floor == .' in cancel_script
192+
assert 'if ($env | type) != "object" then false' in cancel_script
193+
assert '$env.TEST_SCOPE?' in cancel_script
194+
assert '$env.PR_NUMBER?' in cancel_script
195+
196+
# API bodies are kept out of annotations, every cancellation is checked,
197+
# and one failure does not stop attempts for the remaining build numbers.
198+
assert '--output "$response_file"' in cancel_script
199+
assert 'cat "$response_file"' not in cancel_script
200+
assert "if ! curl -sS --fail-with-body -o /dev/null -X PUT" in cancel_script
201+
assert "cancellation_failed=1" in cancel_script
202+
assert cancel_script.index("cancellation_failed=1") < cancel_script.index('done < "$builds_file"')
203+
assert cancel_script.index('done < "$builds_file"') < cancel_script.index("if (( cancellation_failed != 0 ))")
204+
205+
167206
def test_full_ssim_has_a_weekly_slurm_schedule():
168207
workflow = (REPO_ROOT / ".github/workflows/ci-scheduled-ssim.yml").read_text()
169208
ssim_step = next(step for step in _pipeline_steps() if step["key"] == "ssim")

0 commit comments

Comments
 (0)