What will you build?
A repository whose main branch cannot go red silently. The checks are the ones Tutorial 5 left you with,tools/duet verify and its neighbours; this page adds nothing to them and puts them where they run on every push. parity/mutations.json gains three rows, each an exact-string edit that seeds a defect into a reducer, and tools/duet mutate proves that the recordings catch every one. The third row survives on the tree you open, which is the exercise: the corpus does not pin the guard it deletes until you record the leaf that does. Expect about ninety minutes, most of it waiting for the first workflow run.
You will have at the end:
- The lanes report for the tree, and a table of the ten commands the checks are made of with what each proves.
.github/workflows/parity.ymlin a repository of your own:checks,appleandandroidgreen on a push,mutateon a nightly schedule with its receipt on the run’s summary page.parity/mutations.jsonwith three rows, andtools/duet mutateending with3/3 caught.- One new recording,
root.late-sign-in-inert, 73 fixtures on the tree, and the exercise stub gone.
Where do you start?
Opentutorial6-start from the duet-tutorials repository. It is Tutorial 5’s finished tree plus one failing test, the closing exercise, and it resolves Duet 0.7.0, duet-tools 0.24.0, duet-services 0.11.1 and the KSP mock processor 0.2.1 with Tutorial 5’s toolchain. Run the checks once before you edit anything:
Tutorial6ExerciseSurvivingMutationTest in the root module; that is the exercise, and everything else is green. TUTORIAL_SKIP_STUBS=1 tools/duet verify leaves the stub out and ends with duet verify: PASS.
The steps
Read the lanes the manifest derives
parity/manifest.yaml: one entry per feature naming its implementation file, its state, action and effect types, the scenario its recordings are compiled from, and the recordings that pin it. The root’s entry reads:tools/duet lanes prints what the duet tool derives from the file and does not guess at anything else:kotlin: path and none declares a swift: one; the Swift side is reached through the Apple boundary instead, which is a script and not a lane. The protocol lane is not part of verify, so it is a step of its own. And the last block is a list of modules verify makes no claim about: the two apps, the backend, the ports, the runner and the Swift packages. Each of those needs its own step, or its tests never run anywhere.Know what each command proves
verify refuses to run a lane while a meta-check fails, so a manifest error is reported alone, and record --check after verify tells drift apart from a wrong reducer. You saw the shape of a verify red in Tutorial 1’s exercise, where deleting the arming guard failed one recording at one step; the drill below produces that red on purpose, many times.Run every check once, locally
PASS line or with nothing, which for Gradle is the same. The tutorials repository wraps exactly this sequence in scripts/run-tree.sh, and its CI runs that script; the workflow you write next is the same sequence with a runner around each part.Put the checks in a workflow
tutorial6-complete carries .github/workflows/parity.yml, a workflow for a repository shaped like this tree. GitHub reads workflows at a repository’s root, so the file does nothing where it sits inside the tutorials repository; it is the file you copy. Its header states the split:checks job is the first eight commands with a comment above each. Two steps carry the shape you would otherwise get wrong. The recording check is scoped per feature and per chain, never unscoped, because an unscoped check refuses on a tree whose feature declares both lanes, and the scoped form checks the same fixtures on any tree:android job runs on ubuntu, because the hosted ubuntu image ships the Android SDK and the macOS image does not; it is the whole reason the workflow has two runners:apple job is the boundary lane and the iOS build behind a cache of the Kotlin/Native toolchain, keyed on the version catalog and saved even when the lane is red so the next run is warm. The mutate job is the subject of the last two steps.To run it, make the tree a repository of its own and push it:Write the first mutation rows
parity/mutations.json lists edits that each seed one defect into a reducer, and tools/duet mutate applies them one at a time, runs verify, and requires it to go red. A row that leaves verify green has found a behavior no recording pins. The first two rows target guards you know: the splash’s arming guard from Tutorial 1, and the root’s hold on the phase while the session is unknown, from Tutorial 3:old must match the named file exactly once; new replaces it; seeds is the defect in one sentence, and the drill prints it beside the verdict. The first row turns the guard into a condition that is never true, so every Appeared arms a new net:next.awaitingAuth from the root’s AuthChanged arm, so the session’s first report would move the phase with the splash still on screen:splash.repeat-appear-inert is the recording that caught this one; you deleted the same guard by hand in Tutorial 1 and read the same red.Run the table, and read the row that survives
SignIn arm ignores a Completed that arrives when the gate is not the mounted child. The row compares the wrong phase, so a completion under main would be accepted and would replace the session:Completed while the gate is up, so a reducer that accepts it under main passes them all. The receipt table is the drill’s output and the reason the mutate job in the workflow writes it to the run’s summary page:
The tutorials repository's nightly run on the branch that added tutorial6-complete: the tree matrix and the mutate job green, the drill's first run on a hosted runner; Duet 0.7.0, duet-tools 0.24.0.
What you now have
- The lanes report read, and the list of modules verify makes no claim about, each with its own step in the workflow.
- Ten commands, each ending in its own pass line, run in one order locally and in the same order by the workflow.
- A repository of your own whose
checks,appleandandroidjobs are green on a push, with the Kotlin/Native toolchain cached between runs. - A mutation table with three rows and the drill’s receipt on a nightly run’s summary page; two rows caught, one surviving until the recording below exists.
Exercise: record the leaf that catches the surviving row
tutorial6-start carries Tutorial6ExerciseSurvivingMutationTest, a failing placeholder in the root module. The surviving row deletes the guard that keeps a late sign-in completion inert, and no recording sends one. Add a branch to the root scenario that brings main up and then delivers a Completed from a gate that is gone:
root.late-sign-in-inert in the manifest under the root’s fixtures and in the root’s feature spec, add its replay to the golden test, and delete the stub:
tools/duet record --feature root, then tools/duet verify, which replays 73 fixtures, then the drill:
tutorial6-complete carries the recording, the table and the workflow. A mutation that survives has told you exactly which recording is missing, and the recording you add is a test the drill will keep checking on every nightly run.
Common questions
Why is the drill not part of verify?
Why is the drill not part of verify?
What if a row survives and no recording can catch it?
What if a row survives and no recording can catch it?
Completed after the splash phase. Without it, a late completion re-enters the phase the session already implies, which is the phase the root is in, with no effect, so every recording passes with the guard gone and no recording can be written that fails. The guard stays as a statement of intent; the table holds only rows whose defect a recording can see.Why does the workflow file inside tutorial6-complete not run?
Why does the workflow file inside tutorial6-complete not run?
.github/workflows/ directory only. The tutorials repository is many trees in one repository, so the file in tutorial6-complete/.github/workflows/ is inert there; the repository’s own workflows run scripts/run-tree.sh over every tree, which runs the same ten commands. In a repository of your own the tree is the root, and the file runs as written.Why two runners instead of one?
Why two runners instead of one?
Why must old match exactly once?
Why must old match exactly once?
old no longer matches, or matches twice, would either seed nothing or seed the wrong site, and either way the verdict would be meaningless; the drill fails on it as a configuration error, so the table sweeps itself when a reducer is refactored. Recordings do not have this problem, which is why a row is a negative control for the recordings and not a replacement for them.Why does the drill restore bytes instead of running git checkout?
Why does the drill restore bytes instead of running git checkout?
git checkout line before every run.Sources and further reading
- The duet-tools repository — the verbs the checks are made of,
lanes,verify,record --check,mocks --check,protocol-runandmutate, andcontracts/manifest.md, the grammar the lanes derive from. - The duet-tutorials repository —
scripts/run-tree.sh, the same commands as one script, and.github/workflows/trees.ymlandnightly.yml, which run it over every tree. - GitHub Actions: Workflow syntax —
on.schedule,concurrency,permissionsand theifexpressions the jobs use. - GitHub Actions: Adding a job summary —
$GITHUB_STEP_SUMMARY, where the drill’s table lands. - GitHub Actions: Caching dependencies — the restore and save steps around the Kotlin/Native toolchain.
- The Duet glossary — the checks, lane, mutation drill and golden recording.