From ddf6551f662c0dc5857dee91e425f7c0453bcc42 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:39:31 -0500 Subject: [PATCH 1/2] feat(pr-require-checks): ignore gate alias jobs in run-shape reads The required gate job is being renamed: it will display as "CI Status", and until the org ci-gate ruleset requires that context, an alias job named "ci-status" needs the gate, runs if: always() and exits with its result. That job runs on contract-only events too, which breaks three reads in aggregate-results: - job_shape counts it as a job that ran, so a contract-only sibling reads as a full run in flight and yield-to-full-run fails as superseded; - the sibling re-run's contract shape needs exactly one failed job, so a failed contract-only run whose alias also failed is never re-run; - a carried success written before the rename names a gate job by the old name, which gate_step_outcome no longer finds, so every open pull request's carried success is discarded until its next push. The new alias-jobs input lists those names; job_shape and the re-run shape ignore them, and the gate step is also looked up under them. With the input empty nothing changes. Tests cover each read with and without the input. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VQb9Q2ceZLXhTR4d1vkTCe --- .../aggregate-results/action.yml | 13 +++++ .../aggregate-results/run.sh | 15 ++++-- .../aggregate-results/run.test.sh | 47 +++++++++++++++++++ README.md | 8 ++++ 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/.github/actions/pr-require-checks/aggregate-results/action.yml b/.github/actions/pr-require-checks/aggregate-results/action.yml index f49a4778..5bbe075f 100644 --- a/.github/actions/pr-require-checks/aggregate-results/action.yml +++ b/.github/actions/pr-require-checks/aggregate-results/action.yml @@ -254,6 +254,18 @@ inputs: write lands, and a job concluding `success` was not cancelled or timed out. Any other conclusion with no steps listed fails closed. default: '' + alias-jobs: + description: >- + Newline- or space-separated display names of jobs that only mirror the + gate's conclusion under an older check name while a required-check + rename rolls out (a job that needs the gate and exits with its result). + Such a job runs on contract-only events too, so the run-shape reads + ignore it: a run whose only other non-skipped job is an alias still + reads as contract-only, and a failed contract-only run with a failed + alias is still re-run by `rerun-contract-only-siblings`. A carried + `success` written by a run from before the rename, whose gate job had + the alias's name, still finds its gate step under that name. + default: '' rate-limit-wait-seconds: description: >- Ceiling, in seconds, on the time this step spends waiting out GitHub API @@ -308,6 +320,7 @@ runs: YIELD_TO_FULL_RUN: ${{ inputs.yield-to-full-run }} YIELD_WAIT_SECONDS: ${{ inputs.yield-wait-seconds }} STEP_NAME: ${{ inputs.step-name }} + ALIAS_JOBS: ${{ inputs.alias-jobs }} JOB_CHECK_RUN_ID: ${{ job.check_run_id }} GH_TOKEN: ${{ inputs.token }} REPOSITORY: ${{ inputs.repository }} diff --git a/.github/actions/pr-require-checks/aggregate-results/run.sh b/.github/actions/pr-require-checks/aggregate-results/run.sh index f4235eb9..25b367a3 100755 --- a/.github/actions/pr-require-checks/aggregate-results/run.sh +++ b/.github/actions/pr-require-checks/aggregate-results/run.sh @@ -492,6 +492,9 @@ verify_carried_writer() { # branches), and every later `resolve_own_gate` answers from what that lookup # found, so the run spends one retry budget in all. own_gate_job="" +# Jobs that only mirror the gate under an older check name (`alias-jobs`), as +# a JSON array of names. The run-shape reads ignore them. +alias_jobs_json="$(printf '%s' "${ALIAS_JOBS:-}" | tr -s ' \t\r\n' '\n' | jq -R . | jq -sc 'map(select(length > 0))')" own_gate_job_id="" own_gate_step="" own_gate_endpoint="" @@ -758,9 +761,9 @@ verify_writer_step() { # contract step can fail it over a true lanes `success`, and only the step # tells the two apart. gate_step_outcome() { - jq -r --arg job "$own_gate_job" --arg step "$own_gate_step" \ + jq -r --arg job "$own_gate_job" --arg step "$own_gate_step" --argjson aliases "$alias_jobs_json" \ '[ .jobs[]? | select(.name == $job) ] as $gates - | [ $gates[] | .steps[]? | select(.name == $step) ] + | [ .jobs[]? | select(.name == $job or (.name as $n | $aliases | index($n))) | .steps[]? | select(.name == $step) ] | if length == 1 then .[0] | (if .status == "completed" then (.conclusion // "") else "running" end) elif length > 1 then "ambiguous" elif ($gates | length) == 1 and $gates[0].status == "completed" and $gates[0].conclusion == "success" and ($gates[0].steps // [] | length) == 0 then "success" @@ -777,10 +780,12 @@ gate_step_outcome() { # lane still ran its change-detection job, so it reads `full`; # unknown anything else: fewer jobs listed than `total_count`, nothing # but the gate listed, or a body jq cannot parse. +# Jobs named in `alias-jobs` are left out of the shape: they mirror the gate. # Each caller reads `unknown` the way that fails closed for it. job_shape() { - jq -r --arg gate "$2" \ - '(.total_count // 0) as $total | [ .jobs[]? ] as $jobs | ($jobs | map(select(.conclusion != "skipped") | .name)) as $ran + jq -r --arg gate "$2" --argjson aliases "$alias_jobs_json" \ + '(.total_count // 0) as $total | [ .jobs[]? ] as $jobs + | ($jobs | map(select(.conclusion != "skipped" and (.name as $n | $aliases | index($n) | not)) | .name)) as $ran | if ($jobs | length) < $total then "unknown" elif ($jobs | length) > 1 and $ran == [$gate] then "contract" elif any($ran[]; . != $gate) then "full" @@ -1666,7 +1671,7 @@ rerun_failed_contract_only_siblings() { warn_actions_failed "repos/${REPOSITORY}/actions/runs/${id}/jobs" 'actions: write' "Not re-running run ${id}." continue fi - contract_shaped="$(jq -r '[ .jobs[]? | .conclusion ] | ((map(select(. != "skipped")) == ["failure"]) and (length > 1))' <"$gh_stdout")" + contract_shaped="$(jq -r --argjson aliases "$alias_jobs_json" '[ .jobs[]? | select(.name as $n | $aliases | index($n) | not) | .conclusion ] | ((map(select(. != "skipped")) == ["failure"]) and (length > 1))' <"$gh_stdout")" if [[ "$contract_shaped" != true ]]; then echo "Not re-running failed run ${id} on ${SHA}: its latest attempt is not contract-only (more than its gate ran, or the gate did not fail)." continue diff --git a/.github/actions/pr-require-checks/aggregate-results/run.test.sh b/.github/actions/pr-require-checks/aggregate-results/run.test.sh index cf23663d..e0dd3f96 100755 --- a/.github/actions/pr-require-checks/aggregate-results/run.test.sh +++ b/.github/actions/pr-require-checks/aggregate-results/run.test.sh @@ -1699,6 +1699,19 @@ clear_rerun_fixtures() { "$fixtures"/POST_repos_melodic-software_ci-workflows_actions_runs_* } +# A failed contract-only run's alias job fails with its gate. Without +# alias-jobs two failed jobs do not read as contract-only and the run stays red. +echo 'case: a full-mode success re-runs a failed contract-only sibling whose alias failed too, given alias-jobs' +clear_run_fixtures +clear_rerun_fixtures +current_run +workflow_runs "[$(completed_run 5000 failure)]" +printf '%s' '{"jobs":[{"name":"ci-status","conclusion":"failure"},{"name":"lint","conclusion":"skipped"},{"name":"old-gate","conclusion":"failure"}]}' >"$fixtures/GET_repos_melodic-software_ci-workflows_actions_runs_5000_jobs.json" +run_case 0 'success success' pass false true RERUN_CONTRACT_ONLY_SIBLINGS=true ALIAS_JOBS='old-gate' +expect_gh_call "POST repos/${repository}/actions/runs/5000/rerun-failed-jobs" +run_case 0 'success success' pass false true RERUN_CONTRACT_ONLY_SIBLINGS=true +expect_no_gh_call 'actions/runs/5000/rerun-failed-jobs' + # 5000 and 4242 (this run, completed only in this fixture) are contract-only # shaped: one failed gate, every other job skipped. 5100 is a failed full run, # 5500 a failed single-job run, and 5200, 5300 and 5400 did not fail. @@ -1908,6 +1921,40 @@ run_case 0 'skipped skipped' pass true true YIELD_TO_FULL_RUN=true expect_log "Carried forward: ci-lanes is success on ${sha}" expect_no_sleep +# A job that mirrors the gate under an older check name runs on a +# contract-only event too. Without alias-jobs it reads as a lane that ran, so +# a contract-only sibling would count as a full run in flight. +contract_only_jobs_with_alias='[{"name":"lint","conclusion":"skipped"},{"name":"test","conclusion":"skipped"},{"name":"ci-status","conclusion":null},{"name":"old-gate","conclusion":null}]' +echo 'case: yield ignores a contract-only sibling whose alias job is listed, given alias-jobs' +reset_yield_fixtures +status_list "[$(full_run_success 100)]" +workflow_runs "[$(run_entry 4300 in_progress 2026-09-05T12:00:31Z),$(run_entry 4242 in_progress 2026-09-05T12:00:30Z)]" +jobs_raw 4300 "$contract_only_jobs_with_alias" +run_case 0 'skipped skipped' pass true true YIELD_TO_FULL_RUN=true ALIAS_JOBS='old-gate' +expect_log "Carried forward: ci-lanes is success on ${sha}" + +# A success a full run wrote before the gate job was renamed names its gate +# job by the old name, which alias-jobs now carries. Without looking under that +# name too, every open pull request's carried success is discarded until a push. +echo 'case: a success written under the gate job'"'"'s old name is carried, given alias-jobs' +reset_yield_fixtures +status_list "[$(full_run_success 100)]" +printf '{"jobs":[{"name":"lint","status":"completed","conclusion":"success","steps":[]},%s]}' "$(writer_gate_job success | sed 's/"name":"ci-status"/"name":"old-gate"/')" \ + >"$fixtures/GET_repos_melodic-software_ci-workflows_actions_runs_4100_jobs.json" +run_case 0 'skipped skipped' pass true true ALIAS_JOBS='old-gate' +expect_log "Carried forward: ci-lanes is success on ${sha}" +run_case 1 'skipped skipped' pass true true +expect_no_log "Carried forward: ci-lanes is success on ${sha}" +install_step_fixtures + +echo 'case: without alias-jobs the same sibling counts as a full run in flight' +reset_yield_fixtures +status_list "[$(full_run_success 100)]" +workflow_runs "[$(run_entry 4300 in_progress 2026-09-05T12:00:31Z),$(run_entry 4242 in_progress 2026-09-05T12:00:30Z)]" +jobs_raw 4300 "$contract_only_jobs_with_alias" +run_case 1 'skipped skipped' pass true true YIELD_TO_FULL_RUN=true +expect_log '::error::superseded by full run https://github.com/melodic-software/ci-workflows/actions/runs/4300' + # Without the gate-name check, a full run whose first job has finished and whose # lanes were skipped, before its gate job exists, has the contract-only shape # and the older success would carry across it. diff --git a/README.md b/README.md index fe972556..04e3aa36 100644 --- a/README.md +++ b/README.md @@ -444,6 +444,14 @@ consumer to audit it. longer for a runner. A polling contract-only run (a wait above `0` without yield) reads the success itself, so nothing waits for it, and neither does a holding one (`yield-wait-seconds` above `0`). + - `alias-jobs` on the `ci-status` step, while a repository renames its + required gate job: the display names of jobs that only mirror the gate's + conclusion under the old check name (a job that needs the gate, runs + `if: always()` and exits with its result). Such a job runs on every + event, so without this input a contract-only run reads as a full run, + and a failed contract-only run whose alias failed too is not re-run. A + carried `success` written before the rename is also found under the + old gate name. Remove the input with the alias job. A red contract-only check does not hold a merge in the meantime: GitHub's docs do not say how several same-name check runs on one SHA are judged, and From 16d7d494b261716ade000feb56e9a4a28b13ecc8 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:45:38 -0500 Subject: [PATCH 2/2] fix(pr-require-checks): read pre-rename runs and in-flight aliases Review fixes for #1072: - job_shape dropped every alias job, so a contract-only run from before the rename, whose only job that ran is the old gate (now an alias name), read as unknown instead of contract. A run with no job under the current gate name now takes the alias name as its gate. - The re-run wait counted an in-flight sibling's alias job, so a contract-only sibling whose gate had failed while its alias still ran read as a full run, the wait ended, and it was never re-run. The count now leaves aliases out, except in a pre-rename run, where the alias is the gate; with this run's gate name unresolved it leaves them out, which can only lengthen the wait. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VQb9Q2ceZLXhTR4d1vkTCe --- .../aggregate-results/run.sh | 19 +++++++++++--- .../aggregate-results/run.test.sh | 25 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/.github/actions/pr-require-checks/aggregate-results/run.sh b/.github/actions/pr-require-checks/aggregate-results/run.sh index 25b367a3..d194dffc 100755 --- a/.github/actions/pr-require-checks/aggregate-results/run.sh +++ b/.github/actions/pr-require-checks/aggregate-results/run.sh @@ -781,14 +781,18 @@ gate_step_outcome() { # unknown anything else: fewer jobs listed than `total_count`, nothing # but the gate listed, or a body jq cannot parse. # Jobs named in `alias-jobs` are left out of the shape: they mirror the gate. +# A run with no job named predates the rename, so there the +# alias name is the gate. # Each caller reads `unknown` the way that fails closed for it. job_shape() { jq -r --arg gate "$2" --argjson aliases "$alias_jobs_json" \ '(.total_count // 0) as $total | [ .jobs[]? ] as $jobs - | ($jobs | map(select(.conclusion != "skipped" and (.name as $n | $aliases | index($n) | not)) | .name)) as $ran + | (if any($jobs[]; .name == $gate) then {gate: $gate, ignore: $aliases} + else {gate: (first($jobs[] | .name | select(. as $n | $aliases | index($n))) // $gate), ignore: []} end) as $shape + | ($jobs | map(select(.conclusion != "skipped" and (.name as $n | $shape.ignore | index($n) | not)) | .name)) as $ran | if ($jobs | length) < $total then "unknown" - elif ($jobs | length) > 1 and $ran == [$gate] then "contract" - elif any($ran[]; . != $gate) then "full" + elif ($jobs | length) > 1 and $ran == [$shape.gate] then "contract" + elif any($ran[]; . != $shape.gate) then "full" else "unknown" end' \ <"$1" 2>/dev/null || echo unknown } @@ -1639,7 +1643,14 @@ rerun_failed_contract_only_siblings() { # The next poll is this read's retry, so `gh_api` sends it once. # shellcheck disable=SC2310 # gh_api handles its own errexit; an unreadable sibling is waited for. if GH_API_RETRY_ERRORS=false gh_api GET "repos/${REPOSITORY}/actions/runs/${id}/jobs?filter=latest&per_page=100"; then - ran="$(jq -r '[ .jobs[]? | select(.status == "in_progress" or ((.conclusion // "skipped") != "skipped")) ] | length' <"$gh_stdout")" || ran=0 + # An alias job mirrors the gate, so it is not a second job that ran; + # in a run from before the rename it is the gate itself. With this + # run's gate name unresolved, aliases are left out, which can only + # make a sibling wait longer, never skip its re-run. + ran="$(jq -r --arg gate "$own_gate_job" --argjson aliases "$alias_jobs_json" \ + '[ .jobs[]? ] as $jobs | (if $gate == "" or any($jobs[]; .name == $gate) then $aliases else [] end) as $ignore + | [ $jobs[] | select(.name as $n | $ignore | index($n) | not) + | select(.status == "in_progress" or ((.conclusion // "skipped") != "skipped")) ] | length' <"$gh_stdout")" || ran=0 fi if [[ ! "$ran" =~ ^[0-9]+$ || "$ran" -lt 2 ]]; then waiting="${waiting}${waiting:+ }${id}" diff --git a/.github/actions/pr-require-checks/aggregate-results/run.test.sh b/.github/actions/pr-require-checks/aggregate-results/run.test.sh index e0dd3f96..3ac358ef 100755 --- a/.github/actions/pr-require-checks/aggregate-results/run.test.sh +++ b/.github/actions/pr-require-checks/aggregate-results/run.test.sh @@ -1947,6 +1947,17 @@ run_case 1 'skipped skipped' pass true true expect_no_log "Carried forward: ci-lanes is success on ${sha}" install_step_fixtures +# A contract-only sibling from before the rename has no job under the new gate +# name: its gate carries the name alias-jobs now lists. Dropping that job +# would leave nothing that ran and the shape unknown. +echo 'case: yield reads a pre-rename contract-only sibling, gated under the alias name, as contract-only' +reset_yield_fixtures +status_list "[$(full_run_success 100)]" +workflow_runs "[$(run_entry 4300 in_progress 2026-09-05T12:00:31Z),$(run_entry 4242 in_progress 2026-09-05T12:00:30Z)]" +jobs_raw 4300 '[{"name":"lint","conclusion":"skipped"},{"name":"test","conclusion":"skipped"},{"name":"old-gate","conclusion":null}]' +run_case 0 'skipped skipped' pass true true YIELD_TO_FULL_RUN=true ALIAS_JOBS='old-gate' +expect_log "Carried forward: ci-lanes is success on ${sha}" + echo 'case: without alias-jobs the same sibling counts as a full run in flight' reset_yield_fixtures status_list "[$(full_run_success 100)]" @@ -2573,6 +2584,20 @@ expect_rerun_after_last_listing 5000 expect_rerun_after_last_listing 5300 expect_log "Re-ran 2 failed contract-only run(s) on ${sha}." +# A contract-only sibling whose gate already failed while its alias job still +# runs. Without leaving the alias out of the in-flight count, the two jobs read +# as a full run, the wait ends, and the sibling is never re-run. +echo 'case: the in-flight wait leaves an alias job out of a contract-only sibling'"'"'s count, given alias-jobs' +reset_yield_fixtures +workflow_runs_on_call 1 "[$(completed_run 5000 failure),$(run_entry 5300 in_progress 2026-09-05T12:00:40Z)]" +workflow_runs "[$(completed_run 5000 failure),$(completed_run 5300 failure)]" +jobs_for 5000 failure skipped +printf '{"jobs":%s}' '[{"name":"lint","status":"completed","conclusion":"skipped"},{"name":"ci-status","status":"completed","conclusion":"failure"},{"name":"old-gate","status":"in_progress","conclusion":null}]' >"$fixtures/GET_repos_melodic-software_ci-workflows_actions_runs_5300_jobs.1.json" +jobs_for 5300 failure skipped +run_case 0 'success success' pass false true RERUN_CONTRACT_ONLY_SIBLINGS=true YIELD_TO_FULL_RUN=true ALIAS_JOBS='old-gate' +expect_log "Waiting 10s for in-flight run(s) 5300 on ${sha} to finish before re-running failed contract-only runs." +expect_gh_call "POST repos/${repository}/actions/runs/5300/rerun-failed-jobs" + # A carry-forward wait of 0 reads once and never polls, so it has the same race. echo 'case: with a carry-forward wait of 0 a full-mode success also waits for an in-flight contract-only sibling' reset_yield_fixtures