Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions mise-tasks/ci-local-parity
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,50 @@ for wf in "$workflows"/*.yml; do
grep -q '^ pull_request:' <<<"$triggers" || continue
checked=$((checked + 1))

job_names="$job_names
$(awk '
wf_job_names=$(awk '
/^jobs:/ { injobs = 1; next }
injobs && /^[a-z]/ { injobs = 0 }
injobs && /^ name:[[:space:]]/ { sub(/^ name:[[:space:]]*/, ""); print }
' "$wf")"
' "$wf")
job_names="$job_names
$wf_job_names"

# Property 11. A REQUIRED CHECK MUST BE ABLE TO BE SUPERSEDED (CLOUD-503).
#
# Omitting `types:` defaults to `[opened, synchronize, reopened]`. Every job
# here is draft-gated — property 1 above makes that universal — so a PR
# created as a draft mints a `skipped` run on `opened`, and with no
# `ready_for_review` there is no event left that could replace it. `land`
# readies before pushing and its push moves nothing whenever HEAD is already
# on the remote, so the ready event is the only one that can arrive;
# `checks-green` correctly refuses to read a skip as an answer, and the poll
# never terminates while the branch holds the fleet lease.
#
# Measured as a deadlock across two PRs at once, both fully green but for one
# such name, with `main` frozen so neither could rebase out of it.
#
# SCOPED TO WORKFLOWS PRODUCING A REQUIRED NAME, deliberately. A workflow
# whose checks nothing waits on may miss the ready event with no consequence,
# and demanding the key of every `pull_request` workflow would be a gate
# asserting more than the failure needs — the reason this sits beside the
# job-name collection rather than in the `want_names` comparison at the end,
# which has the names but no longer knows which file they came from.
produces_required=0
while IFS= read -r job_name; do
[ -n "$job_name" ] || continue
if grep -qxF "$job_name" <<<"$want_names"; then
produces_required=1
break
fi
done < <(printf '%s\n' "$wf_job_names" | sed '/^$/d' | base_names)
if [ "$produces_required" = 1 ]; then
# The `pull_request:` sub-block only. A `types:` belonging to another
# trigger says nothing about this one, and reading the whole `on:` block
# would let one satisfy the other.
pr_block=$(awk '/^ pull_request:/{p=1;next} /^ [A-Za-z_]+:/{p=0} p' <<<"$triggers")
grep -q 'ready_for_review' <<<"$pr_block" ||
problem "$wf produces a required check but its pull_request trigger omits \`ready_for_review\` — the draft-era \`opened\` run is a skip nothing can then supersede, so \`ci-wait\` polls it forever while the lease is held. Add: types: [opened, synchronize, reopened, ready_for_review] (CLOUD-503)."
fi

grep -q 'cancel-in-progress: true' "$wf" ||
problem "$wf triggers on pull_request but never supersedes its own runs — add a concurrency group with cancel-in-progress: true, or a lap pays out the run it just made obsolete."
Expand Down Expand Up @@ -380,4 +418,4 @@ while IFS= read -r name; do
done <<<"$have_names"

[ "$fail" -eq 0 ] || exit 1
echo "ci-local-parity: $checked pull_request workflow(s) — draft-gated, self-superseding, lease-gated before they spend, running nothing verify does not, asserting every needs: they wait on, and named in full by CI_REQUIRED_CHECKS; the release PR opens as a draft; all $all_seen workflow(s) declare a concurrency group and no two share a cron expression"
echo "ci-local-parity: $checked pull_request workflow(s) — draft-gated, self-superseding, lease-gated before they spend, running nothing verify does not, asserting every needs: they wait on, named in full by CI_REQUIRED_CHECKS, and subscribing to ready_for_review wherever a required check depends on it; the release PR opens as a draft; all $all_seen workflow(s) declare a concurrency group and no two share a cron expression"
35 changes: 32 additions & 3 deletions tests/ci-local-parity.bats
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,14 @@ workflow() {
local name="$1" guard="${2:- if: \${{ github.event.pull_request.draft == false }}}" \
conc="${3:-cancel-in-progress: true}" task="${4:-ci}" \
lease="${5:- - name: Landing lease precondition
run: ':'}"
run: ':'}" \
types="${6:-[opened, synchronize, reopened, ready_for_review]}"
cat >"$WF/$name.yml" <<-EOF
name: $name

on:
pull_request:
types: [opened, synchronize]
types: $types

concurrency:
group: $name-\${{ github.ref }}
Expand Down Expand Up @@ -92,6 +93,34 @@ scheduled() {
[[ "$output" == *"draft == false"* ]]
}

@test "a required check whose workflow cannot see ready_for_review is refused" {
# CLOUD-503. Omitting `ready_for_review` defaults the trigger to
# `[opened, synchronize, reopened]`. Every job is draft-gated, so the draft
# `opened` run is a `skipped` — and `land` readies before pushing, with
# nothing to push whenever HEAD is already on the remote. No event remains
# that could supersede the skip, `checks-green` refuses to read it as an
# answer, and the lease is held while the poll never ends.
workflow ci " if: \${{ github.event.pull_request.draft == false }}" \
"cancel-in-progress: true" ci " - name: Landing lease precondition
run: ':'" "[opened, synchronize, reopened]"
run "$GATE"
[ "$status" -eq 1 ]
[[ "$output" == *"omits \`ready_for_review\`"* ]]
}

@test "a workflow producing no required check may omit ready_for_review" {
# The other direction, and the reason the property is scoped rather than
# demanded of every pull_request workflow: nothing waits on `other`, so a
# skip nothing can supersede costs nobody a poll. A gate that asserted more
# than the failure needs would refuse this file for no reason.
printf 'CI_REQUIRED_CHECKS = "elsewhere"\n\n[tasks.ci]\nrun = "hk check --all"\n\n[tasks.verify]\ndepends = ["ci"]\n\n[tasks.elsewhere]\nrun = "true"\n' >"$MANIFEST"
workflow ci " if: \${{ github.event.pull_request.draft == false }}" \
"cancel-in-progress: true" ci " - name: Landing lease precondition
run: ':'" "[opened, synchronize, reopened]"
run "$GATE"
[[ "$output" != *"ready_for_review"* ]]
}

@test "a workflow that does not supersede its own runs is refused" {
# A lap rebases and pushes; without this the superseded SHA's run is paid
# out in full for a verdict nobody reads.
Expand Down Expand Up @@ -253,7 +282,7 @@ fanin() {

on:
pull_request:
types: [opened, synchronize]
types: [opened, synchronize, reopened, ready_for_review]

concurrency:
group: final-\${{ github.ref }}
Expand Down
Loading