Lessons
What setting up the repositories' builds and deploys taught, and then building craftz, for whoever changes them next. Each lesson says what happened and what to do about it. Lessons that belong to one repository stay there.
Most were learned before craftz, when one repository kept the rules and copied them into the rest, so some name the scripts it had, like pnpm align and pnpm new. Those become craftz's commands, and the lessons hold for them.
pnpm
- An exact pin with
onFail: errorbreaks on every pnpm release. A newer global pnpm refused to run withERR_PNPM_BAD_PM_VERSION.onFail: downloadmakes it fetch and run the pinned version instead. A range would also work, but lets machines run different versions. - npm's
latestfor pnpm runs behind its releases. pnpm 12.13.0 was out on GitHub whilelateststill pointed at 12.11.2. The pin is exact, so the rules can name any published version, whateverlatestsays. - pnpm 12.13.0 needs its install script. Its
pnpmfile is a placeholder the script swaps for the native binary, so an installer that skips scripts runs it through Node, or fails. Install the global pnpm from its GitHub release, as mise's default source does, not from npm. - npm's latest and pnpm's wait disagreed for a day. The survey called a version drift that pnpm's one-day wait wouldn't install yet, so nobody could fix it. drift now compares with the reference, not npm, and
pnpm alignmoves the reference with pnpm itself, so the wait is pnpm's rule and not a copy of it. - A catalog entry nothing uses is left alone. pnpm accepts one, but
pnpm update --latestnever moves it, sopnpm alignresolves those withpnpm add --lockfile-onlyin an empty folder with the reference's settings. - Some script names never run.
pnpm deploy,audit,publishandciare pnpm's own commands, so a script by one of those names is skipped. That's why the deploy script isship. npxrefuses to run wheredevEnginesnames pnpm. Usepnpm execfor an installed tool, andpnpm dlxfor one that isn't.- A list of exceptions to the wait needed an edit on every release. pnpm rewrote
minimumReleaseAgeExcludeon install, in a formvp checkrejects. The wait is on everywhere with@amitkaps/*excluded, a pattern pnpm keeps, so a release changes nothing. - A hand pin inside the wait is let in, not refused. Pinning the Cloudflare betas a day after their release made
pnpm installadd each new package tominimumReleaseAgeExclude, andversions-cooldownfailednew.test.ts. Checkpnpm view <name> timebefore pinning a beta. If a pin went in too soon, move it back and restorepnpm-lock.yamlfrommain, since the lockfile still holds the rejected versions. - Turning the wait back on failed a lockfile that had skipped it.
pnpm installpassed, but the frozen install CI runs checks each locked version against the wait. Runpnpm install --frozen-lockfilelocally before pushing a change to these settings.
Vite+
vp checkis the whole check. Type-aware lint withtypeCheck: truereports compiler errors too, so it replacestsc, with notypescriptdependency.vp check --fixwrites the fixes, so there's novp fix, and no separate lint and format steps.- The editor formatted with a different oxfmt from
check. User settings pointed VS Code's oxc extension at a global oxfmt and a deleted config. Without them, the extension findsvite-plusand runsvp fmt --lsp. So there's no global oxfmt to pin, and each repository commits the.vscode/settings Vite+ writes. - With no
fmtblock,vp fmtprints "No config found". An emptyfmt: {}keeps the defaults and drops the notice. - A framework needs steps around
vp check. SvelteKit generates types thatsvelte-kit syncandwrangler typeswrite first, and oxlint doesn't type-check.sveltefiles, sosvelte-checkruns after. The rules holdcheckto runningvp check, not to being only that. - Vite's default browsers were three years old.
baseline-widely-availableis a list frozen at each Vite major, and Vite+ 1.3.0 has May 2025's: Chrome 111 and Safari 16.4, from 2023. base built to it, so its CSS nesting was flattened, while prose used popover, which needs iOS 18.3. Usage data showed only the iPhones that a new iOS drops are held back for long, so the rules set a floor per engine instead, in Browsers, worked out from MDN's browser-compat-data. It has each Samsung Internet release's Chromium and which Firefox is the ESR, and it was a release ahead of caniuse. caniuse's usage counts Android Chrome as one version and leaves out in-app browsers, so read it next to a source that counts those. - Moving between toolchains needs a fresh lockfile. A lockfile keeps the optional peers it already resolved, so remove
node_modulesandpnpm-lock.yamlbefore installing.
The survey
The Cloudflare build can't see the other repositories. Workers Builds clones only this one, so the survey reads the others from GitHub's
main. An outage shows in the table rather than failing the build.Checking versions found drift the setup checks missed. prose asked for Node 24, had no Vite+ override, and two repositories were behind on markz. A version nobody looks at since it was set drifts, so the survey checks every shared one.
One script name ran two commands. prose reads
buildonly as its first argument, soprose . buildbuilt nothing in two repositories. The script isprosealone, and the survey checks it.Reading the domains found one the config didn't name. prose's domain was set only in the dashboard, where a deploy could drop it. Every config names its domains now.
A new column can make the table scroll. Look at the page at a laptop's width after adding a repository.
A rule said in two places drifts in one. The standard, as the rules were called then, said a rule in words and the survey said it again in a check's label, and the two disagreed. A rule now has an id, like markz's grammar gives its side rules. The rules state it once, as a list item that opens with the id, and
src/rules.tshas only the check and the fix. A test fails if either has an id the other doesn't, andpnpm driftprints the id with the command that prints the rule.A fix that sets the reference's value can run on every repository.
pnpm alignhad its own code to move the shared versions. Each version rule's fix now does it, reading the reference from the folderalignjust moved, and changes nothing in a repository already in line, soalignruns every fix without checking first.One check for several rules can't say which one drifted. The Package check held the rules' three bullets on packing, types and bundling, under one label. Each is a rule now, so
pnpm driftlinks the one that drifted, and the part that can be fixed, the fields inpackage.json, has a fix of its own.A fix that edits a file someone wrote anchors on what the rules fix. CI's Label step goes before
pnpm installand the trigger underpull_request, lines every repository has. A workflow laid out otherwise is left alone, and its check still drifts, so a fix never guesses.A pull request took five commands, and a wrong label failed after the push. Each change here was a commit,
verify, a push,gh pr createandgh pr merge --auto, and--label internalfailed in the rules' repository, which had no labels, once the branch was already pushed.pnpm prtakes the steps in every repository and checks the label against.github/release.ymlbefore it commits, andAGENTS.mdnames it rather than listing them.A generator and a fixer write the same files twice.
pnpm newbuilt the editor settings, the sharedAGENTS.mdsections, the release files and more, and the fixes wrote them again forpnpm align.newnow writes only what's a repository's own and runs the fixes for the rest, so a rule with a fix needs nothing added tonew. Its test still checks every kind against the survey, which shows what no fix writes yet.The first
pnpm alignby hand found two bugs. Its list of changed files read "ackage.json", since trimming git's output took the space that opens its first line, and thenslice(3)took the "p". And markz's timing tests failed when fiveverifys ran at once, then passed alone. Trim only the end of git's output, and runverifyone repository at a time.The dashboard stayed behind after align. It read every repository when the rules' site built, and that site rebuilt only when its own
mainchanges, so a run that moved the others left the page showing what they replaced. align now waits for its pull requests to merge, then starts that site's build withcf builds create. Waiting goes by each check's latest run, since adding a label starts a CI run that cancels the first, and a wait that read any CANCELLED as failure would stop on every package.One catch hid five drifts behind a wrong reason.
pnpm drifton a private repository's folder said its settings weren't checked, since it "isn't on GitHub". GitHub had it. Its Worker wasn't connected to the repository, so Cloudflare's build settings answered 404, and the catch around both checks took that error for GitHub's, dropping four merge settings with it. Catch only the error you expect, beside the call that throws it. A Worker with no build settings is now drift of its own.
GitHub and agents
- Bringing six repositories in line was a script nobody had written. Each move to a new tool took six hand-made pull requests, and drift only listed them.
pnpm alignmakes the edits, runsverifyand opens the pull requests, with GitHub's settings written in the same run. - Five repositories protected
mainfour ways. A ruleset is one JSON document, so it lives here andpnpm alignapplies it everywhere. - GitHub shows anyone a public repository's branch rules, but not its merge settings. The dashboard checks the rules, and
pnpm driftchecks the settings withgh's login. - GitHub's rate limit looked like drift. Logged out, 60 requests an hour turned every rule red with HTTP 403. drift uses
gh's login when it can, and a check the limit stopped says it wasn't made. - Dependabot duplicated what moves from here. It can't move the Vite+ override with Vite+, and every npm pull request it opened was closed. The survey checks the actions' versions, and Dependabot is gone.
- A lesson named a private repository. It reached the docs, a commit and a pull request before it was caught, and GitHub keeps the old text.
AGENTS.mdnow says never to name one, anywhere this repository reaches. - Copied rules drift. The
@proserules had started to differ across fourAGENTS.mdfiles. Each links to the one copy in prose's docs, and the survey checks the section word for word. - A pull request behind
mainwaited forever. Two open at once in one repository, and whichever merged second sat blocked, sincemainrequires an up-to-date branch and auto-merge doesn't update one. Turning that rule off would let two changes that pass alone breakmaintogether, and a merge queue needs an organisation. Sopnpm pr --waitand align's wait update a branch that falls behind. Use--waitwhen another pull request is open in the same repository. - A pull request read as behind its own merge. The first
--waitafter that change failed on a pull request that had merged. For about a second, the squash commit was onmainand the pull request was still open, so GitHub called it behind, and updating it failed with nothing to update. A failed update now means asking again, and the next look sees it merged.
Releases
- Three copies of one release workflow had drifted. Each staged differently, and only one had labels or a size budget. The workflow names no package now, so one file is copied everywhere and checked word for word.
- publint needn't be a separate step.
vp packruns it withpublint: { strict: true }and fails the build on a problem. It needspublintinstalled. - A summary in an annotated tag can't be reviewed. It's the description of the pull request that bumps the version, reviewed like any change, and the workflow reads it from the merged commit.
- Supporting an older Node than we build on took a workaround. pnpm won't run on a Node older than
devEngines, so testing on 24 meant switching Node after the install. With no users on 24,enginesbecame>=26. If a package gains some, that job is how to test it. - Pushing a tag by hand was the step that went wrong. A branch named
v0.4.0made the tag's push ambiguous. The workflow tags the merged commit itself, the bump branch isrelease-X.Y.Z, and npm's 2FA approval is the one step by hand.pnpm releaseopens the pull request with the owner's login, since GitHub runs no CI on one a workflow's token opens. - Bundling our own packages removes the release order. A package with markz as a dev dependency puts it in
dist/, so it depends on nothing at install and releases at any time. - A label checked at release time is checked too late. prose's v0.5.0 notes were all Other until the labels were added by hand. A package's
cifails without exactly one label, and everyAGENTS.mdsays which to pass topnpm pr, which stops without one.
New repositories
- A tool had to explain itself away. A repository of scripts has nothing to serve, but drift wanted a
devand abuild, and its docs said why it had none. A rule a repository argues against in its docs is the rules' gap, so a tool is now a kind of its own. - A generator copies the rules, so it's tested against it.
pnpm testruns the survey andverifyon whatpnpm newwrites. Its first run showed oxfmt formats arrays differently fromJSON.stringify, so the generator runsfixrather than copying oxfmt's rules. - Types for a package came from three places.
vp packneededtypescriptinstalled, orisolatedDeclarationsintsconfig.json, which the editor then applied to tests and config, and whichvp checkdoesn't check.dts: { generator: "oxc" }in the pack block applies it only to what's packed, and a missing type still fails the pack. - pagez was the first repository
pnpm newstarted. CI passed on the first push. Run it without--pushfirst, to read the files before they reach GitHub. A package's first push changespackage.json, so the release workflow stops for a package npm doesn't have yet.
Cloudflare
- An internal site leaks through
workers.dev. Access covers a domain, not a Worker, so itsworkers.devaddress and preview URLs serve the site to anyone. An internal site turns both off in its config, and the survey checks them. - A setting that marked a site internal kept a private repository from having a public one. Turning off
workers.devwas how a config said it was internal, so a private repository's public site couldn't turn it off, and the public sites served onworkers.devand preview URLs nobody used. Every site turns both off now, and Access alone says whether a site is internal. A fact that's true of every site can't also mark some of them. - Each Worker had its own compatibility date. They were set the day each config was written, and the static sites never needed them to differ. The date is now the workerd build that cf installs, so it moves with the betas, and it's never newer than the local runtime, which falls back to its own date and warns. Since 2026-08-04 the date also turns on Node.js compatibility, so no flag is needed.
privateinpackage.jsonalready meant something. It means "not on npm", and a tool can beprivatewith a public site. Whether a site is internal comes from the repository's visibility, and its config says so where it takes effect.- The Git integration deploys whether or not CI passed. A build command of
pnpm run verifyfails first, so nothing deploys. - The Worker's settings can be written with cf. cf's config has no field for the build commands, but
cf builds triggerssets them andNODE_VERSION, sopnpm alignwrites them. SetNODE_VERSIONon every Worker, since the integration reads pnpm's version from the repository but not Node's. It installs dependencies itself, so the build command doesn't. - Reconnecting a repository resets the Worker's build settings. The commands,
NODE_VERSION, the cache and previews went back to the defaults. Runpnpm alignafter any reconnect. - cf has no command to rename a Worker, but the dashboard does. Settings → General → Name keeps its ID, Git connection and domains. Rename it there first, then in the config, since a new name in the config deploys a new Worker.
- The config lists the Worker's domains. Change one in the dashboard and the config together, so a deploy can't put the old one back.
- cf needs
CLOUDFLARE_ACCOUNT_IDwhen the login has several accounts. drift and align pick the account named like the GitHub owner. Apnpm shipby hand doesn't, so set it there. - A prose site reaches cf through Vite. cf's config has no assets folder, so cf couldn't deploy one that Vite didn't build. Vite takes
.proseas itspublicDir, and the plugin withassetsOnlywrites it out forcf deploy --prebuilt. One site ran this way for two days before markz, prose and sitez moved off wrangler. - The plugin already did what an empty entry did. Vite won't build without an entry, so one site gave it an empty one. The plugin does the same when there's no
index.html. Read a workaround's reason against the tool's current release before copying it. - cf can't deploy SvelteKit yet. The adapter doesn't write cf's Build Output, so base stays on wrangler, with
wrangler.toml, since prose reads comments in TOML but not JSONC. - The Vite plugin's
betatag isn't the build to take. It can point at a build inside pnpm's wait. Take the newest2.0.0-beta.sha-…past the wait that depends on the same@cloudflare/configas cf, read withpnpm view <package>@<version> dependencies. - Void deploys a folder in one command, on more machinery than a folder needs. It installs 304 packages, a database toolkit and its own state. Tried as of void 1.1.11 and left, since Vite and the plugin already do the job.
- A build machine is slower than a laptop. A timing test in markz passed locally and in CI, then stopped a deploy on Cloudflare's machine, since the build command is
verify. A timing test takes the best of several runs, after warming up, on inputs big enough to measure.
Docs
- How Markdown wraps was left to each writer. At oxfmt's default
proseWrap: "preserve", most repositories put a paragraph on one line and markz hand-wrapped at about 100 characters, and nothing flagged the mix."never"waited on markz, since a{@section}line with text beneath it is part of the paragraph to plain Markdown, and the reformat joined it in. markz 0.5.0 warns about an element line without blank lines around it, and once every repository had fixed its lines,toolchain-wrapturned"never"on everywhere. It also stops padding a table wider than the print width, as Prettier does, so the rules' docs table lost its columns' alignment in the source, while narrower tables kept theirs. Before changing a formatter setting that reflows text, check what the reflow joins. - prose builds the last commit, not the working tree. A new doc is missing from the site until it's committed. Commit before building to see it, as the Cloudflare build does.
- A page from the build fits in prose as a doc with a hole in it. The dashboard was
docs/dashboard.md, with an empty{@section}for each table that a script fills afterprose build, so it has prose's bar and styles, with no renderer of its own. A section's id can't be a heading's, and markz warnsduplicate-idwhen it is. - A copy of a tool's layout breaks when the tool moves. The dashboard sized itself with prose's
--docked, which prose renamed. It keeps only what's its own now, and takes the rest, like the status colours, from prose's tokens. - A hand-written
.htmlpage ships its comments. Vite doesn't strip them, so such a page carries no prose. - A copy of the rules goes stale where it's copied. Development docs described settings the rules had moved past, and READMEs pinned versions.
development.mdlinks the rules for anything shared, and a plan lists only its own repository's steps. - prose's check found only text nobody meant to be right. Its warnings were on test corpora and fixtures that are wrong on purpose. Mark those
linguist-vendored, which prose skips. prose stylewas right about length and wrong about lists. On its first run here it made 68 suggestions. Every long summary, long sentence, parenthetical and semicolon was worth fixing, and splitting a long summary always read better. A colon that joins two clauses was too. But 24 of its 42 colons only introduced a list, which is fine. Take the length suggestions as they come, and read each colon before changing it. prose 0.7.1 took that advice and leaves a list's colon alone, so the docs have no suggestions now.- A doc that copied the versions went stale. The beta table in
development.mdlisted each pin and its status on a date, and align kept its own list of the betas. Both fell behind the code. The rules are docs, and a check holds the code to them, so a doc says what's allowed and why, never the value.versions-betanames the betas once, the check fails any other pre-release, andpackage.jsonalone has the builds.
Moving the rules into craftz
A copy is proven by the same output. The rules came from the repository that kept them, with small changes, so the test was running both drifts and comparing. On repositories that are in line, both print the same ticks, which shows little. So a clone of markz was made to drift on purpose, and both printed the same findings. Do the same when moving a check, so a change in behaviour shows up as a difference.
The reference has to pass the checks too. The old repository's files became reference/, and prose check reads them like any other file, since it has no way to leave a folder out. Their links pointed at that repository's layout, and AGENTS.md carried its own section. So links now point at craftz's files, and AGENTS.md keeps only the sections every repository shares. Keep what's in reference/ true where it stands, rather than marking it unchecked.
The rules are a doc, so they go in docs/. They first moved into the reference folder, next to the files they describe. But prose publishes docs/ in the site's nav, and a doc anywhere else is left out of it, as prose's usage and markz's grammar show. So the rules are docs/rules.md, and reference/ holds only files a repository has. A doc's name is a choice, and "standard" had been one. "Rules" matches the command that prints them.
A finding points at a command, not a page. drift linked each finding to the rule on a site, which an agent fetched and got a summary of. Now it prints see: craftz rules <id>, which prints the rule from the version installed, so the rule read is the one that was checked. It works like prose rules, so an agent that knows one knows the other.
A run that stays local finds what a read can't. align came over with a --local flag, and its first run on craftz turned up two faults that drift never showed. The releases-packing fix set files to ["dist"] and dropped docs/rules.md, which craftz rules reads, since drift doesn't check craftz. And pnpm 12 writes catalog: for a tool a catalog names, so the catalog's versions never moved. Now files starts with dist, and the scratch folder gets the settings without the catalog. Run --local on craftz after changing align or a fix.
What a command needs is decided when it's packed. craftz check bundles the rules, which read the browsers from browser-compat-data and the Workers' date from cf. Bundling those would make the package megabytes, and cf isn't installed where craftz is used. Both values change only when pnpm align moves their packages, so vp pack writes them in, and those packages stay out. Before a release, unpack pnpm pack's tarball and run the command from there, with no node_modules, since a test from source can't show what's missing.
A copied file keeps what nothing reads. The reference's package.json came over as the old repository's, with its name and its own scripts, like dashboard, which no rule reads. It looked like part of the rules. Now it holds only what a rule or pnpm new reads, and its name says it's the reference.
A fix that rewrites a file undoes the formatter. The first craftz fix on a repository in line changed .vscode/extensions.json. The fix wrote the JSON it parsed with JSON.stringify, which puts the formatter's one-line array on four lines. Now a JSON file is written only when its value changes. To prove a fix is idempotent, run craftz fix twice on each repository's main from git archive, since a local checkout can be behind and look like drift.
A claim about a tool is tested twice, with and without. craftz() rests on vp fmt and vp lint reading a plugin's config hook. A scratch project showed a hand-wrapped paragraph failing vp fmt --check and a type error failing vp lint with the plugin, and both passing without it. Only the run without the plugin shows that the plugin did it.
A file kept twice stays, while it's checked. scripts/pr.ts and scripts/gone.ts are in reference/ and in craftz's root, since craftz runs them like any repository. Having the rules read craftz's own copy would have saved one, but reference/ would then hold every file a repository matches but two. So both stay, and craftz check keeps them the same, until craftz pr and craftz gone make the files unneeded.
A type that's a combination of axes breaks on the next combination. "Package, site or tool" couldn't say what markz is, a package with a site, or what an internal site in a public repository is. Four axes, each read from its own place, describe every repository, and a new one needs no new type. When two kinds overlap, look for the separate questions they answer.
A deploy can be tested before there's anywhere to deploy to. cf deploy --prebuilt --dry-run reads the Build Output and stops before the upload, so craftz's site was checked before its Worker existed. Run it after vp build on any site whose deploy changes, before the first real one.
A word-for-word file changes for everyone at once, so it changes before the release. The AGENTS.md sections, pr.ts, gone.ts and the release files still linked the old repository's page, since they're shared word for word. They changed here before craftz's first release, so the first version a repository installs already points at craftz. A change later would need a release of its own, and a round of pull requests in every repository. Before a release, grep the reference for links to anywhere that isn't craftz.
What every repository changes for goes in before the first release. pr.ts and gone.ts were to become commands after the repositories moved. That would have changed scripts-pr, scripts-gone and the reference in a second release, and sent every repository a second round of pull requests. So they became craftz pr and craftz gone first. Before a release, ask what the next one would change in every repository, and move it into this one.
A rule that's missed is found by the next thing to use it. 0.1.0 went out without a rule that a repository depends on craftz, so new still wrote the fmt and lint blocks, and a repository could follow the rules without ever installing them. The question "why isn't new in this release?" found it. Before a release, start a repository with new and read what it writes against what the release is for.
A command reads the same way everywhere, or it's written wrong somewhere. AGENTS.md said pnpm prose rules next to pnpm exec craftz rules, since prose had a script and craftz didn't. The difference read as meaning something. A craftz script, which scripts-craftz checks, makes it pnpm craftz rules. When two tools are used the same way, they're run the same way.
"Once the repositories move" is a rule that hasn't been written. The rules said every site's ship would run craftz deploy once the repositories moved to craftz, but nothing checked it, so new still wrote cf deploy --prebuilt, and the first test repository came out on the old command. A sentence about the end state is a rule waiting for its id. Write the check before the release that the repositories move to.
A read straight after a write can be the old answer. new --push wrote craftz-test's branch rules and labels, then reported each as still off. They were there a minute later. GitHub answered the read-back from before the write. A read-back that drifts is tried again a few times before it's reported, so "still off" means the write didn't take.
What gets checked by hand twice is a command. Seeing craftz-test's first deploy took a scratch script to list the Worker's builds, another to read the log, and curl for the domain and workers.dev, each worked out again from cf's help. craftz status reads them all, and AGENTS.md points agents at it, so the next look is one command. When a check is written in a scratchpad a second time, make it a command.