Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
05223c7
ci: a draft PR takes no runner slot; ready_for_review re-runs CI (D1,…
noahgift Oct 20, 2026
cda0933
ci: D1 guard also proves merge_group is untouched (3 mutations)
noahgift Oct 20, 2026
79eb769
fix(ci): mg-reuse wiring rows expect the D1 draft clause on x86-main …
noahgift Oct 20, 2026
6c6336f
fix(ci): draft-gate self-test trap guards the rm -rf target (SEC011)
noahgift Oct 20, 2026
889f8bb
fix(guard): check_ci_draft_gate used yq, which the clean-room runner …
noahgift Oct 20, 2026
d99d736
fix(guard): draft-gate check matches the exact guard forms, counts jo…
noahgift Oct 20, 2026
4fb0030
Merge remote-tracking branch 'origin/main' into wsweep-d1
noahgift Oct 20, 2026
1d04157
fix(guard): check_ci_draft_gate matches the whole if: value, anchors …
noahgift Oct 20, 2026
59c8a1b
fix(guard): check_ci_draft_gate is string equality per job against a …
noahgift Oct 20, 2026
c4f813f
test(ci): D1 draft gate as a parsed Rust invariant (serde-saphyr), 18…
noahgift Oct 20, 2026
bf3637a
merge: origin/main (Refs #5801)
noahgift Oct 21, 2026
b9c04b2
Merge remote-tracking branch 'origin/main' into wsweep-d1
noahgift Oct 21, 2026
787c85c
Merge remote-tracking branch 'origin/main' into wsweep-d1
noahgift Oct 21, 2026
6679b3b
Merge remote-tracking branch 'origin/main' into HEAD
noahgift Oct 21, 2026
1eee334
Merge remote-tracking branch 'origin/main' into m5801
Oct 22, 2026
dddbeb2
Merge remote-tracking branch 'origin/main' into m5801
Oct 22, 2026
e1c53aa
test(d1): align draft-gate CANON with main's draft != true form for x…
Oct 22, 2026
d69db65
ci(d1): x86-main-advisories uses the one draft != true form; CANON an…
Oct 22, 2026
b1de418
merge: origin/main (keep draft clause on x86-main-advisories, main's …
Oct 22, 2026
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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ jobs:
# The same case table on every event, so a change that breaks the key is RED on
# its own PR, not first in the queue. gate requires it.
mg-reuse-self-test:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: [self-hosted, Linux, clean-room]
timeout-minutes: 10
permissions:
Expand Down Expand Up @@ -315,7 +316,7 @@ jobs:
# no evidence for them. ci / gate and gate require this job whenever they reuse.
x86-main-advisories:
needs: [mg-reuse]
if: ${{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' }}
if: ${{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' && github.event.pull_request.draft != true }}
runs-on: [self-hosted, Linux, X64, clean-room]
timeout-minutes: 30
permissions:
Expand Down
172 changes: 172 additions & 0 deletions crates/aprender-core/tests/monorepo_invariants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1125,3 +1125,175 @@ fn falsify_mono_014_path_pins_name_their_target_version() {
[workspace.dependencies] entry instead): {stale:#?}"
);
}

// ---------------------------------------------------------------------------
// D1 (FLOW-010 runner-wait, #5801): a DRAFT pull request takes no runner slot.
//
// Parsed, not grepped: three rounds of shell patterns over ci.yml each accepted a padded guard.
// The workflow is read with the workspace YAML reader (`serde-saphyr`, the dev-dependency the
// contract tests use; `serde_yaml` is frozen to the modernbert test, #5208) and compared by
// equality: each job's `if:` scalar equals ONE canonical string, the job set equals JOBS, and
// `on.pull_request.types` equals the canonical list. A new job is RED until added here on purpose.
// The three aggregators stay `always()` so the required check names exist and FAIL on a draft
// (a skipped required check counts as passing on GitHub).
// ---------------------------------------------------------------------------

/// The one draft form the PR-only jobs use (main already has it on `x86-main` and the shard).
const DF: &str = "github.event.pull_request.draft != true";
const G_ALL: &str =
"${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}";
const CI_TYPES: [&str; 4] = ["opened", "synchronize", "reopened", "ready_for_review"];

/// (job, canonical `if:` scalar)
fn draft_gate_canon() -> Vec<(&'static str, String)> {
vec![
("mg-reuse", "github.event_name == 'push' && github.ref == 'refs/heads/main'".into()),
("mg-reuse-self-test", G_ALL.into()),
("x86-main", format!("${{{{ !cancelled() && needs.mg-reuse.outputs.x86 != '1' && {DF} }}}}")),
("x86-main-advisories", format!("${{{{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' && {DF} }}}}")),
("determinism", "${{ !cancelled() && needs.mg-reuse.outputs.det != '1' && github.event_name != 'pull_request' }}".into()),
("workspace-test-shard", format!("${{{{ {DF} }}}}")),
("workspace-test", "always()".into()),
("ci-gate", "always()".into()),
("gate", "always()".into()),
]
}

/// Every violation of the draft-gate rule in `ci_yml` (empty = the rule holds).
fn draft_gate_violations(ci_yml: &str) -> Vec<String> {
let doc: serde_json::Value = match serde_saphyr::from_str_with_options(
ci_yml,
serde_saphyr::options! { strict_booleans: true, merge_keys: serde_saphyr::MergeKeyPolicy::AsOrdinary, emit_comments: false, reject_non_finite_typeless_float: false },
) {
Ok(d) => d,
Err(e) => return vec![format!("ci.yml does not parse: {e}")],
};
let mut bad = Vec::new();
let types: Vec<&str> = doc["on"]["pull_request"]["types"]
.as_array()
.map(|a| a.iter().filter_map(|t| t.as_str()).collect())
.unwrap_or_default();
if types != CI_TYPES {
bad.push(format!(
"on.pull_request.types is {types:?}, want {CI_TYPES:?}"
));
}
let canon = draft_gate_canon();
let Some(jobs) = doc["jobs"].as_object() else {
bad.push("no `jobs:` mapping".into());
return bad;
};
for name in jobs.keys() {
if !canon.iter().any(|(j, _)| j == name) {
bad.push(format!(
"job `{name}` is not in the CANON list (add it with its draft behaviour)"
));
}
}
for (job, want) in &canon {
match jobs.get(*job) {
None => bad.push(format!("listed job `{job}` is missing")),
Some(j) => match j["if"].as_str() {
Some(got) if got == want => {}
got => bad.push(format!("job `{job}`: if: {got:?} != canonical {want:?}")),
},
}
}
bad
}

fn ci_yml_text() -> String {
std::fs::read_to_string(workspace_root().join(".github/workflows/ci.yml")).expect("read ci.yml")
}

/// `from` -> `to`, every occurrence; the mutant must differ from the source or the fixture is dead.
fn mutate(src: &str, from: &str, to: &str) -> String {
assert!(
src.contains(from),
"fixture anchor not found in ci.yml: {from:?}"
);
src.replace(from, to)
}

/// The ` if:` line of `job` replaced by `new` (None deletes it).
fn rewrite_if(src: &str, job: &str, new: Option<&str>) -> String {
let header = format!(" {job}:");
let mut out = Vec::new();
let (mut in_job, mut done) = (false, false);
for line in src.lines() {
if line.starts_with(" ") && !line.starts_with(" ") && line.trim_end().ends_with(':') {
in_job = line.trim_end() == header;
}
if in_job && !done && line.starts_with(" if:") {
done = true;
if let Some(n) = new {
out.push(n.to_string());
}
continue;
}
out.push(line.to_string());
}
assert!(done, "no `if:` found for job {job}");
out.join("\n") + "\n"
}

/// The 18 planted mutants: (label, mutated ci.yml). Each must be RED.
fn draft_gate_mutants(src: &str) -> Vec<(&'static str, String)> {
let x86_if =
format!(" if: ${{{{ !cancelled() && needs.mg-reuse.outputs.x86 != '1' && {DF} }}}}");
let gate_tail = src.split_once("\n gate:\n").expect("gate job").1;
let gate_mut = src.replacen(
gate_tail,
&gate_tail.replacen(" if: always()", " if: success()", 1),
1,
);
vec![
("m1 draft guard dropped", mutate(src, &format!(" && {DF}"), "")),
("m2 ready_for_review dropped", mutate(src, ", ready_for_review", "")),
("m3 guard unscoped", mutate(src, DF, "true")),
("m4 guard polarity flipped", mutate(src, &format!(" && {DF}"), " && github.event.pull_request.draft == true")),
("m5 aggregator not always()", gate_mut),
("m6 empty parse", "on:\n pull_request:\n types: [ready_for_review]\njobs:\n".to_string()),
("m7 DF guard padded with || true", mutate(src, "draft != true }}", "draft != true || true }}")),
("m7b G_ALL padded with || true", mutate(src, "!github.event.pull_request.draft }}", "!github.event.pull_request.draft || true }}")),
("m8 shard if = push || true", rewrite_if(src, "workspace-test-shard", Some(" if: ${{ github.event_name == 'push' || true }}"))),
("m9 ready_for_review_x", mutate(src, "ready_for_review]", "ready_for_review_x]")),
("m11 && true == DF", mutate(src, &format!(" && {DF}"), &format!(" && true == {DF}"))),
("m12 && !DF", mutate(src, &format!(" && {DF}"), &format!(" && !{DF}"))),
("m13 comment-suffixed header, no if", mutate(&rewrite_if(src, "workspace-test-shard", None), " workspace-test-shard:\n", " workspace-test-shard: # no guard\n")),
("m14 new job outside CANON", mutate(src, "\njobs:\n", "\njobs:\n extra-job: # unguarded\n runs-on: [self-hosted, Linux, clean-room]\n steps: []\n")),
("m15 || true then a comment", mutate(src, "draft != true }}\n", "draft != true || true }} # fine\n")),
("m16 more-indented || true line folds into the scalar", mutate(src, &format!("{x86_if}\n"), &format!("{x86_if}\n || true\n"))),
("m17 decoy types line, real list lacks ready_for_review", mutate(src, "types: [opened, synchronize, reopened, ready_for_review]", "types: [opened, synchronize, reopened]") + "# types: [ready_for_review]\n"),
("m18 G_ALL polarity flipped", mutate(src, "|| !github.event.pull_request.draft", "|| github.event.pull_request.draft")),
]
}

/// D1 / #5801: the real workflow satisfies the draft-gate rule.
#[test]
fn falsify_ci_draft_gate_real_workflow_holds() {
let bad = draft_gate_violations(&ci_yml_text());
assert!(
bad.is_empty(),
"ci.yml breaks the D1 draft-gate rule:\n{}",
bad.join("\n")
);
}

/// D1 / #5801: each of the 18 planted mutants is RED, so the rule cannot pass vacuously.
#[test]
fn falsify_ci_draft_gate_every_planted_mutant_is_red() {
let src = ci_yml_text();
let mutants = draft_gate_mutants(&src);
assert_eq!(mutants.len(), 18, "the fixture table lost a mutant");
for (label, text) in &mutants {
assert_ne!(
text, &src,
"mutant `{label}` is identical to ci.yml (dead fixture)"
);
assert!(
!draft_gate_violations(text).is_empty(),
"mutant `{label}` stayed GREEN"
);
}
}
2 changes: 1 addition & 1 deletion scripts/ci_mg_reuse.sh
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ STUB
fail=$((fail + 1)); echo "FAIL wiring: ci.yml runs mac-check, a LAB job (Q6 quorum, C324)"; fi
if grep -qF 'outputs.mac' <<<"$(job mg-reuse)"; then
fail=$((fail + 1)); echo "FAIL wiring: mg-reuse still exports mac, which no ci.yml job reads"; else pass=$((pass + 1)); fi
wire x86-main-advisories "if: \${{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' }}"
wire x86-main-advisories "if: \${{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' && github.event.pull_request.draft != true }}"
wire x86-main-advisories 'advisory_gate.sh"'
wire x86-main-advisories 'bash scripts/check_deny_exemptions_live.sh'
wire ci-gate 'MG-REUSE-VERDICT-BEGIN'
Expand Down
Loading