Rationale
Eight sessions worked on this repository in one afternoon and almost nothing reached the public. The site served version 0.3.1 with no changelog while master carried three releases' worth of change, seven pull requests stood open, and an integration branch cut at 17:52 was stale by 18:00. None of it was a disagreement about the work. Every part of it was a mechanical failure that nobody could see from inside their own branch, and each was measured rather than argued:
- The conflicts were not conflicts. Two pull requests were marked
CONFLICTINGon the same ten files, and ten of the ten weremerge=derived. Not one was a source file. This repository hasscripts/merge-derivedand it resolves them; GitHub cannot run a custom merge driver, so the server reports conflicts that exist on nobody's machine. A local merge of master took one of those pull requests to zero conflicts with nothing resolved by hand. A previous integration here went from thirty-three conflicts to zero the same way. - The driver is per clone. A worktree that never ran
just derive-merge-driverfights those same files locally too, which is why they appeared to fight everyone. - The toolchain is per machine. A derive builds the executable, so a clone with no pinned
Rust toolchain refuses every
prs repair --applyafter the merge, namingcargoand nothing else.majordomus doctorchecks it (derive-toolchain); install it with rustup before repairing or deriving there. - A batch costs more than its conflicts. Every conflicted merge needs a full
just derivebefore the pre-commit hook accepts it, and any source fix made after that derive invalidates it. One session ran three derives for one merge. - The public site drifted from the repository. At 17:46 UTC it served a commit from an
unmerged branch — the result of
site-deploy --any-ref, which documents itself as a preview, pointed at production by two different sessions, this one included. - A merge broke the publication and nothing said so. A change to a document's shape that was never rendered locally shipped a page the site generator could not build, the pages run failed, and the public site silently stayed on the previous commit.
- Green and not-yet-reported looked identical. No validation run on master completed for over four hours while master carried two defects that broke every branch merging it.
Required behaviour
- Land often and small. A branch merges to master as soon as it is green, one at a time. An integration branch that batches many is a last resort for a backlog that already exists, is declared as such, and is not the pattern afterwards.
- Merge master locally, then push, before asking for review. Run
just derive-merge-driveronce per worktree. Resolve source conflicts, fix the build, then derive once, then commit — a derive before the last source fix is a derive done twice. A derived file is never resolved by hand: it is regenerated, because a hand-resolved derived file passes the merge and failsderive-checkone commit later. The server's view does not change until the local merge is pushed.majordomus prs repair <n>(it also takes the branch name) is that whole gesture as one command of the integrator, in a scratch worktree that never touches the branch's own. It is eligible only when the integrator's classification says the pull request lacks master and its merge conflicts on no path outside themerge=derivedset; it refuses and names any authored conflict rather than deciding it. Without--applyit only says what it would do. With it, it holds the integration lease, records the act on the trail first, merges the trunk in where the driver exists, derives, commits and pushes the branch leased on the head it observed, so a branch somebody pushed to meanwhile is refused. It exists because a ten-minute manual gesture performed several times a day is one that gets skipped under pressure, and a branch nobody brings master into is a branch somebody merges without reading. - A mechanical union is wrong across generations. Taking both sides is right for a list that gained entries and wrong when one side is an older generation of the same code: folding one branch that way reintroduced a second match arm built against a previous shape, and seven compile errors with it.
- Publish master and only master.
scripts/site-deploy --any-refis a local preview. The commit gh-pages names is an ancestor of master, always. - The merger checks what the public is served. Equality is not the test — a merge
landing during a deploy window leaves the published commit an ancestor rather than the
head, with nothing wrong. The merger confirms, within a bounded wait, that the site is
serving the head they merged, using
scripts/pages verify --url … --commit "$(git rev-parse …)"rather than a second comparison written by hand, and never an abbreviated SHA: that comparison is string equality against forty characters, so a short one burns the whole timeout and then reports a mismatch that is not one. A failure names both the commit expected and the commit served. - The deploy belongs to the merge. One owner per merge, so that two sessions do not publish over one another.
- A shape change is rendered before it is merged.
scripts/site-build && scripts/site-checkon the head, before the merge, whenever a change touches a document's shape or the templates that render it. The pages workflow does not re-run those checks, so nothing between the merge and the public catches it. - A merge that changes the public contract carries its version. Landing without the bump is why the published site named a version three releases behind the code.
Failure behaviour
scripts/ci/pages-check, registered as the pages-live gate, decides the published half.
Four things, because publication has failed in four ways here: the commit gh-pages names must
be an ancestor of master; master must not have moved past it, on a path that can change the
site, for longer than the deploy window; the commit the live pages name must be the one
gh-pages published — the failure a token-authored push causes, where no workflow starts at
all and every check on the tree stays green; and GitHub's own build of gh-pages must not have
errored, which is the half of publishing this repository does not own and which no workflow
of ours goes red for.
The second of those is clause 5 made mechanical, and it is the one that was missing. Ancestry
alone was a one-way check: a publication that is on master is on master however old it is, so
on 2026-09-10 the gate said ok all afternoon while the site sat hours behind. The window is
what separates the deploy in flight that clause 5 excuses from the deploy that is not coming;
it is thirty minutes, sized from a twenty-five-minute Actions queue observed that day, and a
commit that cannot change the site owes no publication at all — the paths that can are
scripts/pages paths, which is also the publication workflow's own trigger.
It runs in the full plan beside installer-live, never in a path-triggered one: a
live-network gate on every push fails collectively when an origin is slow, and a gate that
cries wolf is waived.
The rest is decided by review and by the record on the shared server's board. A gate cannot tell that a branch waited for a batch, and inventing one would be a worse cure than the disease.
Verification
test/cases/104_published_site.sh holds scripts/ci/pages-check to what it claims, against
fixture repositories rather than the network: a publication from the trunk is accepted, one
from a branch that is not on it is refused with the cause and the remedy named, a trunk that
has moved past the publication is accepted while the deploy is young and refused once it is
older than the window — both over one fixture, so the judgement is the age and not the tree —
a deploy commit that does not say where it came from is refused as unauditable rather than
guessed at, a measurement the gate could not make is reported as a note and never as an ok,
and the gate carries neither a URL nor an identity path of its own — both are read from
the declarations that already hold them. The case was proved non-vacuous by removing the
refusal from the gate and watching it fail.
The live half is not simulated: a case standing up an HTTP server would be testing its own
fixture, and one reaching the real site would fail whenever the network did. --offline is
the seam, and the case asserts the seam announces itself rather than skipping silently.
test/cases/97_pages_fast_path.sh holds the publication path itself, including its
concurrency: the pages- group serialises the runs and nothing cancels the deploy in flight
(cancel-in-progress: false; cancelling it starved publication on 2026-09-12), and the run
states whether it pushed gh-pages before it ended, because a cancelled run is not a failure
and fourteen of the seventeen cancelled runs of 2026-09-09/10 had already published. The
derived-merge-driver behaviour is test/cases/57_derived_merge_driver.sh, and
test/cases/911_repair_decides_and_moves_nothing.sh and
test/cases/912_repair_acts_under_the_lease_and_the_trail.sh hold majordomus prs repair to
clause 2, against a scripted forge and a local bare origin. A pull request conflicting only
on derived files is eligible and one conflicting on an authored file is refused with the
files named; the default dry run reaches no forge, writes no trail and moves no ref; an
applied repair records its attempt before it pushes, holds the lease while it works, pushes
a merge of the trunk leased on the observed head and never touches master; and a branch that
moved meanwhile is refused and left as its author pushed it. The Rust tests of
integration::repair hold the same decisions and the order of the trail without a
repository.