diff --git a/dag/gunbc/fleet/fleet_revision_acceptance.dag b/dag/gunbc/fleet/fleet_revision_acceptance.dag index 0f437a47391..7d18cfb865a 100644 --- a/dag/gunbc/fleet/fleet_revision_acceptance.dag +++ b/dag/gunbc/fleet/fleet_revision_acceptance.dag @@ -3,7 +3,8 @@ module gunbc.fleet_revision_acceptance import std.types { String, NonEmptyStr, Bool, Int, List } import extdeps.git.object_store { GitObjectId, git_object_id_from_untagged_hex, git_object_id_eq, git_object_id_wire_hex } import extdeps.github.workflow_runs { - WorkflowRun, WorkflowRunList, WorkflowRunConclusion, Success, Cancelled, Skipped, Completed, + WorkflowRun, WorkflowRunList, WorkflowRunConclusion, Success, Failure, Neutral, Cancelled, Skipped, + TimedOut, ActionRequired, StartupFailure, Completed, Queued, InProgress, Waiting, Requested, Pending, } import extdeps.github.actions { github_event_name_merge_group } import extdeps.github.push_event { PushRefUpdate } @@ -435,20 +436,27 @@ fn run_is_merge_group(run: WorkflowRun) -> Bool { run.event == github_event_name_merge_group } +// A run carries a verdict when GitHub states a terminal outcome for it: Success, Failure, +// TimedOut, ActionRequired and StartupFailure say what happened. Neutral, Cancelled and Skipped +// report that no verdict was rendered -- the run did not judge the revision -- so they are not +// verdicts, and an unconcluded floor refuses as RequiredCiMergeGroupRunUnconcluded. A run that +// has not completed (Queued, InProgress, Waiting, Requested, Pending) has rendered nothing yet. +// Every status and every conclusion is named so an addition to either cannot inherit +// verdict-carrying without an arm. fn merge_group_run_carries_verdict(run: WorkflowRun) -> Bool { match run.status { Completed => match run.conclusion { Present { value: c } => match c { - Cancelled => false - Skipped => false Success => true Failure => true - Neutral => true TimedOut => true ActionRequired => true StartupFailure => true + Neutral => false + Cancelled => false + Skipped => false } none => false } @@ -465,13 +473,7 @@ fn merge_group_run_succeeded(run: WorkflowRun) -> Bool { Present { value: c } => match c { Success => true - Failure => false - Cancelled => false - Neutral => false - Skipped => false - TimedOut => false - ActionRequired => false - StartupFailure => false + _ => false } none => false } diff --git a/dag/gunbc/runner/runner_throughput_qualification_route.dag b/dag/gunbc/runner/runner_throughput_qualification_route.dag index 92b7cc0e19d..f817e86d527 100644 --- a/dag/gunbc/runner/runner_throughput_qualification_route.dag +++ b/dag/gunbc/runner/runner_throughput_qualification_route.dag @@ -340,7 +340,9 @@ fn route_control_plane_write_count(route: List) -> Int { ) } -// EVERY BMC WRITE IS UNDER THE APPROVAL GATE, by declaration identity. +// EVERY BMC WRITE IS UNDER THE APPROVAL GATE, by declaration identity. The arms are named, not +// wildcarded: a stage effect added later must state here what the gate audit reads from it, or the +// route stops compiling -- a fresh write-shaped effect may not inherit the audit's pass. fn route_bmc_writes_are_gated(route: List) -> Bool { route |> all(s => match stage_effect(stage: s) { @@ -392,6 +394,8 @@ fn route_deregisters_what_it_registered(route: List) -> Bool && (registered |> all(u => deregistered |> any(d => d == u))) } +// Only a dispatch stage is asked to name the attempt; the other four stages say so by name, not +// by wildcard, so a stage added later must state that here rather than inherit the vacuous pass. fn route_dispatch_selector_names_the_attempt(route: List, attempt: NonEmptyStr) -> Bool { route |> all(s => match s { diff --git a/dag/test/claim/fleet/fleet_desired_merge_queue_admission_witness_test.dag b/dag/test/claim/fleet/fleet_desired_merge_queue_admission_witness_test.dag index 9f6848fba1c..1319a1e2bf4 100644 --- a/dag/test/claim/fleet/fleet_desired_merge_queue_admission_witness_test.dag +++ b/dag/test/claim/fleet/fleet_desired_merge_queue_admission_witness_test.dag @@ -5,7 +5,7 @@ import std.contract_identity { ContractEpoch } import extdeps.git.object_store { GitObjectId, git_object_id_from_untagged_hex, git_object_id_eq } import extdeps.github.workflow_runs { WorkflowRun, WorkflowRunList, WorkflowRunStatus, WorkflowRunConclusion, - Completed, InProgress, Queued, Success, Failure, Cancelled, TimedOut, + Completed, InProgress, Queued, Success, Failure, Cancelled, Neutral, TimedOut, } import extdeps.github.push_event { PushRefUpdate, push_ref_update_from_event_json, PushRefUpdateObserved, PushRefUpdateMemberAbsent } import gunbc.generated_artifact { WitnessFloorYamlArtifact, artifact_path } @@ -118,6 +118,26 @@ test fn a_cancelled_run_beside_a_success_admits() -> Bool { admits(push: main_push(after: sha_r), rs: runs(rs: [mg(id: 11, conclusion: Cancelled), mg(id: 12, conclusion: Success)])) } +// NO VERDICT RENDERED IS NOT A FAILED VERDICT. A neutral conclusion reports that the workflow did +// not apply to this revision; it carries no verdict, so a floor of only-neutral runs is +// UNCONCLUDED -- not "the floor ran and did not succeed", which is a different refusal and a +// different operator action. merge_group_run_carries_verdict names all eight conclusions so a +// conclusion added later cannot inherit verdict-carrying without an arm. +test fn a_floor_of_neutral_runs_refuses_as_unconcluded_not_not_success() -> Bool { + match ci_on_r(rs: runs(rs: [mg(id: 11, conclusion: Neutral)])) { + Present { value: RequiredCiRefused { cause: RequiredCiMergeGroupRunUnconcluded { revision: _, run_ids: ids } } } => + ids == [11] + _ => false + } +} + +// A neutral run beside a success neither contradicts it nor blocks the entry: not applicable is +// not a competing verdict, and the refusal it caused before the classification named all eight +// conclusions read as two runs disagreeing about one revision. +test fn a_neutral_run_beside_a_success_admits() -> Bool { + admits(push: main_push(after: sha_r), rs: runs(rs: [mg(id: 11, conclusion: Neutral), mg(id: 12, conclusion: Success)])) +} + // THE CONTRADICTION. Nothing picks one: the refusal carries both id sets so an operator reads both logs. test fn a_success_and_a_failure_on_one_revision_refuse_carrying_both_run_ids() -> Bool { match ci_on_r(rs: runs(rs: [mg(id: 11, conclusion: Success), mg(id: 12, conclusion: Failure)])) {