Repository navigation
feat(ci): add a reusable workflow that gates a kind's tree and suites - #24
Merged
Merged
Conversation
`.github/workflows/kind.yml` (`on: workflow_call`) takes a kind's fixture root and three commands -- the suite, the second implementation and the mutation gate -- and runs, as steps of one job named `conformance`: - the case-tree validator over the fixture root; - `tools/conform.py` for each implementation. It appends `--report-json` and passes only when the cases the report says ran are exactly the case directories under the root and none failed. An empty or missing tree, a runner pointed elsewhere or at a subtree, a missing report, or a report its exit code contradicts is exit 2; - the mutation gate, which must exit 0. A blank command is refused. One job, so a consumer's required check is the single, stable `<job id> / conformance`. The workflow fetches kindkit's tools at the commit the caller pinned, read from the run's `referenced_workflows`, because `github.job_workflow_sha` is empty in a called workflow. kindkit's check.yml calls it on the toy kv tree, with a runner (`tests/run_kv.py`), a second kv implementation (`kvkind.Alt`) and a kv mutation gate (`tests/kv_mutants.py`). `tools/conform.py` is under the mutation gate.
The suite steps compared case ids only. A runner that fell back to its own default tree, or read a same-named copy elsewhere, passed over a fixture root it never opened. And the mutation step accepted any command that exits 0. - kindkit.cli writes the absolute `root` it read into the report, and takes the report path from KINDKIT_REPORT_JSON when no --report-json is given, refusing a file already there. - tools/conform.py runs the command as written with that variable set, so a trailing newline or comment no longer separates the runner from its report path. It requires the report's root to be the fixture root, and with --differs-from refuses a second implementation run by the suite's own command. - kindkit.gate writes its verdicts, the baseline's case ids and its root to KINDKIT_GATE_REPORT when set; Verdict carries the root probe_command reads. tools/check_gate.py requires that report: a kill, no hole, and a baseline that ran exactly the named tree. - kind.yml requests actions: read for the referenced_workflows lookup, so the calling job must grant it; check.yml's kind job does. - README: the report's root and environment path, what each step requires, the check name's prefix, and how to adopt the workflow where `conformance` is already a required check. The "wrote no report" mutation now yields a false pass the test catches by assertion, instead of a crash.
…rkflow # Conflicts: # tools/mutation_gate.py
With the report's root now compared, the empty- and missing-tree tests were refused by the root check first, and the gate mutation that skips the tree check survived. Those tests now hand a report naming the same root, so only the tree check can refuse it; check_gate.py gets the same test shape and a mutation of its own. A cli mutation whose pattern the environment-path change had made stale is updated.
…rkflow # Conflicts: # tools/mutation_gate.py
A report's `root` was accepted as any string, and conform.py resolved it against its own working directory. A runner that changed directory and reported "kvcopy" passed against a different, broken ./kvcopy. `_parse_report` now refuses a root that is not absolute. The every-case-ran check was reachable only behind the root check, so a mutation emptying its list of unrun cases survived the suite. A test now hands conform.py a report from the right root with fewer cases. The gate report also records `source`, the file the gate mutated, and check_gate.py prints it. The README documents the gate report's keys, the absolute root, and that the mutation step cannot tell which file was mutated.
CameronBrooks11
added a commit
to kindspec/rowspec
that referenced
this pull request
Oct 8, 2026
## What Moves rowspec's kindkit dev-dependency pin from `bf716e3` (the commit `v0.1.0` names) to `8e21b558c85d5612009f3a1d69a3a68bc0191637`. That is kindkit's `main` after its reusable workflow landed (kindspec/kindkit#24). kindkit has no tag newer than `v0.1.0`, so the pin is a commit, as before. This is the first of two PRs. The second makes rowspec's CI call `kind.yml`, and that workflow reads three things `bf716e3` does not have: - the `root` key in the runner's `--report-json`; - `KINDKIT_REPORT_JSON`, the runner's report path taken from the environment; - `KINDKIT_GATE_REPORT`, where `gate()` writes its verdicts. Changed files: `pyproject.toml` (pin and its comment), `uv.lock`, and `CHANGELOG.md` (Unreleased). ## No rowspec code changes `conformance/mutants.py` already probes through `kindkit.probe_command`. At the new pin that call carries the report's `root` into `Verdict.root`, and `gate()` writes the gate report itself when `KINDKIT_GATE_REPORT` is set. So the probe needs no edit to satisfy the workflow's contract. Shown below by running kindkit's own workflow tools against this branch. ## Measured Each recipe ran on `origin/main` (old pin) and on this branch, from the same worktree, with output captured to files and diffed: ``` recipe old pin new pin diff check exit 0 exit 0 identical conform exit 0 exit 0 identical 0 failure(s) across 410 case(s) conform-alt exit 0 exit 0 identical 0 failure(s) across 410 case(s) mutants exit 0 exit 0 identical 73 killed, 0 survived, 2 equivalent, 0 stale, 0 broken test exit 0 exit 0 wall time only ``` The only differing line, from `diff before/test.log after/test.log`: ``` < 72 passed, 1 skipped in 133.35s (0:02:13) > 72 passed, 1 skipped in 135.44s (0:02:15) ``` `mutants` is identical line for line, killing cases included. The kit's new per-write mtime stamp and the stricter `gitmerge` changed no verdict. ### The workflow's contract, run locally These are kindkit's `tools/` at `8e21b55`, run from this branch's root, as `kind.yml` runs them: ``` $ python3 $K/tools/validate_case_tree.py conformance/cases 0 invalid case(s) across 410 case(s) in 1 tree(s) exit 0 $ python3 $K/tools/conform.py conformance/cases "cd conformance && uv run python run_cases.py" 0 failure(s) across all 410 case(s) under 'conformance/cases' $ python3 $K/tools/conform.py --differs-from "cd conformance && uv run python run_cases.py" \ conformance/cases "cd conformance && uv run python run_cases.py rowspec_alt.table" 0 failure(s) across all 410 case(s) under 'conformance/cases' $ python3 $K/tools/check_gate.py conformance/cases "cd conformance && uv run python mutants.py" 73 killed, 0 survived, 2 equivalent, 0 stale, 0 broken the gate killed 73 mutant(s) of '<worktree>/reference/rowspec/table.py' over all 410 case(s) exit 0 ``` `check_gate.py` passing means three things. The gate wrote a format-1 report. Its baseline `root` resolved to `conformance/cases`. Its `ran` set was exactly the 410 case directories. The same `conform.py` run against `origin/main` (old pin) has no verdict. The old runner ignores `KINDKIT_REPORT_JSON` and writes no report: ``` NO VERDICT: the suite wrote no report at $KINDKIT_REPORT_JSON exit 2 ``` ## Not in this PR There is no CI change, so the required checks are the same four and no ruleset edit is needed to merge this. Refs kindspec/kindkit#4 Refs kindspec/kindkit#3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
.github/workflows/kind.ymlis a reusable workflow (on: workflow_call). Its inputs, all required:fixture-root,suite,second-implementationandmutants. It runs as one job namedconformance, and its four gates are steps withif: always(). Each gate runs even when an earlier gate is red. The setup steps before them are not like that: if one fails, the gates still run and fail too, for want of kindkit's tools.tools/validate_case_tree.py <root>tools/conform.py <root> <suite>rootas the fixture root, it ran exactly the case directories under it, and none failedtools/conform.py --differs-from <suite> <root> <second-implementation>tools/check_gate.py <root> <mutants>kindkit.gate's own report exists, killed ≥1 mutant, has no SURVIVED, STALE, BOGUS or BROKEN mutant, and its baseline ran exactly the tree under that rootHow a verdict is tied to the tree the workflow names:
kindkit.cliwrites the absoluterootit read into its report. A report whoserootis not absolute is refused as malformed (round 2).kindkit.clitakes the report path fromKINDKIT_REPORT_JSONwhen--report-jsonis not given, and refuses a file already at that path.Verdictcarries therootthatprobe_commandreads from the report.kindkit.gatewrites its verdicts, the baseline's case ids and its root toKINDKIT_GATE_REPORTwhen that is set. It creates the file exclusively, so a second gate in the same command raises.conform.pyandcheck_gate.pyrun the caller's command as written, with those variables set. Nothing is appended to the command, so a trailing newline (suite: |), acd, or a trailing# commentcannot separate the runner from its report path.Why one job. A ruleset matches a required check by name. A called job reports as
<calling job's name, else its id> / conformance, so a consumer has exactly one name to keep stable.How it finds its own tools. Inside a called workflow,
github.job_workflow_shais empty (measured). The workflow reads its own commit from the run'sreferenced_workflowsand fetches kindkit'stools/there. That read needsactions: read, which the workflow requests, so the calling job must grantcontents: readandactions: read. kindkit'scheck.ymlkindjob does.Exercised in kindkit's CI by the
kindjob, which calls the workflow on the toy kv tree with:tests/run_kv.pyas the runner;kvkind.Altas the second implementation, which shares no source fragment withGood;tests/kv_mutants.pyas the gate: 3 mutants ofGood, run throughkindkit.gateandprobe_command.The README has a new section, "Using the workflow". It covers what each step requires, the check name, the permissions, and how to adopt the workflow where
conformanceis already a required check (§3.2): which steps stay in the adopter's own job, when to add the new context and when to swap it in, and to edit the ruleset just before merging, not in the change.Testing
Local, on the current head, with
main(#21) merged in:There are 30 mutations on
tools/conform.py,tools/check_gate.py, and the newroot, environment-path and gate-report code inkindkit/. For each one, I applied it with the gate's hash-checkedapplyand ran every test it lists on its own, in a separate pytest process. Every listed test exited 1, and each file restored to its original hash.Review findings, each shown red first
F1, the root was never checked. These tests were red on the previous head:
test_a_runner_that_falls_back_to_its_default_tree_is_no_verdict: the reviewer's case.conform.py <broken copy> "…run_kv.py … Good"returned0.test_a_runner_that_read_a_same_named_tree_elsewhere_is_no_verdict: also returned0.test_the_broken_copy_is_a_failure_when_the_runner_reads_it, shows the copy really is broken: a run that reads it exits 1.All of these now exit 2 as expected. A report with no
rootis also exit 2.Commands with a trailing newline or comment were red on the previous head:
NO VERDICT: the suite exited 127andNO VERDICT: the suite wrote no --report-json file. Both are now0.F2, the mutation step accepted any exit 0.
check_gate.pyhas 21 tests.true,"", blank, and a command that exits 0 without a gate are all exit 2. A report with a hole or with nothing killed is exit 1. A report with no root, another root, fewer cases or an unknown format is exit 2. The real kv gate over a same-named copy is exit 2.F3, a second implementation run by the suite's command.
conform(..., differs_from=command)raisedTypeErrorbefore this change; now it is exit 2, also for a respaced copy of the command.The "wrote no report" mutation. It is reworked so that its mutant returns exit 0, a false pass the test catches by assertion, not a
FileNotFoundError.The tree check was hidden behind the root check. The full gate reported
an empty or missing tree is left to the suite to judgeas SURVIVED once the root check existed: the tests were being refused by the root check first. The tests now hand a report naming the same root, so only the tree check can refuse it.check_gate.pygot the same test shape and its own mutation.On CI
Green, PR run on
3e426b7:kind / conformancepassed. kindkit was fetched at the PR's commit, both suites ran all 6 cases undertests/fixtures/kv, and the linethe gate killed 3 mutant(s) over all 6 case(s)appears in the log.Red, earlier head: throwaway branches
tmp/kind-red-broken-case(run 37685697020) andtmp/kind-red-inputs(run 37685723625). A broken kv case, a tree with no cases, a missing tree, a suite run on a subtree, a failing second implementation, a suite with no report, blankmutants, and a failing gate each went red at exactly the expected steps.This head: throwaway branch
tmp/kind-red-review, runs 37690440366 and 37690440235.fixture-root: tests/fixtures/kv-copy, one case broken)the suite read '…/tests/fixtures/kv', not '…/tests/fixtures/kv-copy'(and the same for the gate)mutants: "true"the command exited 0 and no gate wrote $KINDKIT_GATE_REPORTthe second implementation's command is the suite's command# comment; caller job withname: a named callera named caller / conformancecontents: read(separate workflow file)startup_failure: the run never startsAll throwaway branches are deleted (
git ls-remote --heads origin | grep -c tmp/gives0).Against rowspec, without changing rowspec
In a scratch clone of rowspec
main(2a89617), I installed this branch's kindkit into the clone's venv and ran this branch's tools with rowspec's own commands:On the earlier head, a broken rowspec fixture gave the same red verdict through
conform.pyas rowspec's runner run directly:1 failure(s)in each.rowspec's current pin,
a26f8f8, has neither therootkey norKINDKIT_REPORT_JSON.git show a26f8f8:kindkit/runner.py | grep -c '"root"'gives0, and the same forcli.py. So adopting the workflow in rowspec includes bumping that pin.Limits, stated in the README
probe_command, or setVerdict(root=...)itself, and one command may run only onegate()call.referenced_workflowsread has not been tried on a private repository.Not done here, deliberately
kind / conformanceis not a required check on kindkit. Adding it is a ruleset change for the owner.After merging
mainwith #19 (merge commit3d6f94b):just checkall checks passed;just test202 passed;just mutants(throwaway detached worktree) 137 mutations, 137 caught, 0 survived, 0 broken. CI green.Round 2 of review (head
9951ee2, withmainmerged in, which brought #20, #22 and #23)rootwas accepted. Before this change,_parse_reportchecked only thatrootis a string, andconform.pyresolved it against its own working directory. The reviewer's runner didcd good && runner kvcopyand reported"root": "kvcopy". That passed against a broken./kvcopy. New testtest_a_report_whose_root_is_relative_is_no_verdictrebuilds that shape and was red on3d6f94b:assert 0 == 2._parse_reportnow refuses a non-absoluterootwith'root' 'kvcopy' is not an absolute path; unit testtest_a_report_whose_root_is_relative_is_refused. The README contract now says the root is absolute. New gate mutation "a relative root is accepted, to be resolved by whoever reads it"; both listed tests fail on their own under it.test_a_report_from_the_right_root_that_ran_only_some_cases_is_no_verdicthandsconform.pya report naming the same root with 2 of the 6 cases. It is now listed by "a suite that ran a different set of cases than the tree passes", and by a new mutation "a case of the tree that did not run goes unnoticed" (sorted(tree - ran)→[]). Run on its own, each mutation fails that test.source, the file mutated, andcheck_gate.pyprints it. On CI:the gate killed 3 mutant(s) of '/home/runner/work/kindkit/kindkit/tests/kvkind.py' over all 6 case(s).kindkit_gate_report,ok,killed,survived,equivalent,stale,bogus,broken,ran,rootandsource.Gates at
9951ee2:CI on the PR:
checkandkind / conformancepass; mergeable stateclean.Refs #4. Every bullet is met on kindkit's side. "rowspec's existing workflows can call it and produce the same verdicts" is shown above against rowspec, but not yet by rowspec's own workflows.
Refs #3. "Validate the schema against all 410 cases in CI" becomes true when rowspec calls this workflow. Until then it holds for kindkit's toy tree in CI, and for rowspec's 410 cases only locally (above).