craftz

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: error breaks on every pnpm release. A newer global pnpm refused to run with ERR_PNPM_BAD_PM_VERSION. onFail: download makes it fetch and run the pinned version instead. A range would also work, but lets machines run different versions.
  • npm's latest for pnpm runs behind its releases. pnpm 12.13.0 was out on GitHub while latest still pointed at 12.11.2. The pin is exact, so the rules can name any published version, whatever latest says.
  • pnpm 12.13.0 needs its install script. Its pnpm file 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 align moves 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 --latest never moves it, so pnpm align resolves those with pnpm add --lockfile-only in an empty folder with the reference's settings.
  • Some script names never run. pnpm deploy, audit, publish and ci are pnpm's own commands, so a script by one of those names is skipped. That's why the deploy script is ship.
  • npx refuses to run where devEngines names pnpm. Use pnpm exec for an installed tool, and pnpm dlx for one that isn't.
  • A list of exceptions to the wait needed an edit on every release. pnpm rewrote minimumReleaseAgeExclude on install, in a form vp check rejects. 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 install add each new package to minimumReleaseAgeExclude, and versions-cooldown failed new.test.ts. Check pnpm view <name> time before pinning a beta. If a pin went in too soon, move it back and restore pnpm-lock.yaml from main, since the lockfile still holds the rejected versions.
  • Turning the wait back on failed a lockfile that had skipped it. pnpm install passed, but the frozen install CI runs checks each locked version against the wait. Run pnpm install --frozen-lockfile locally before pushing a change to these settings.

Vite+

  • vp check is the whole check. Type-aware lint with typeCheck: true reports compiler errors too, so it replaces tsc, with no typescript dependency. vp check --fix writes the fixes, so there's no vp 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 finds vite-plus and runs vp fmt --lsp. So there's no global oxfmt to pin, and each repository commits the .vscode/ settings Vite+ writes.
  • With no fmt block, vp fmt prints "No config found". An empty fmt: {} keeps the defaults and drops the notice.
  • A framework needs steps around vp check. SvelteKit generates types that svelte-kit sync and wrangler types write first, and oxlint doesn't type-check .svelte files, so svelte-check runs after. The rules hold check to running vp check, not to being only that.
  • Vite's default browsers were three years old. baseline-widely-available is 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_modules and pnpm-lock.yaml before 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 build only as its first argument, so prose . build built nothing in two repositories. The script is prose alone, 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.ts has only the check and the fix. A test fails if either has an id the other doesn't, and pnpm drift prints the id with the command that prints the rule.

  • A fix that sets the reference's value can run on every repository. pnpm align had its own code to move the shared versions. Each version rule's fix now does it, reading the reference from the folder align just moved, and changes nothing in a repository already in line, so align runs 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 drift links the one that drifted, and the part that can be fixed, the fields in package.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 install and the trigger under pull_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 create and gh pr merge --auto, and --label internal failed in the rules' repository, which had no labels, once the branch was already pushed. pnpm pr takes the steps in every repository and checks the label against .github/release.yml before it commits, and AGENTS.md names it rather than listing them.

  • A generator and a fixer write the same files twice. pnpm new built the editor settings, the shared AGENTS.md sections, the release files and more, and the fixes wrote them again for pnpm align. new now writes only what's a repository's own and runs the fixes for the rest, so a rule with a fix needs nothing added to new. Its test still checks every kind against the survey, which shows what no fix writes yet.

  • The first pnpm align by 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 then slice(3) took the "p". And markz's timing tests failed when five verifys ran at once, then passed alone. Trim only the end of git's output, and run verify one 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 main changes, 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 with cf 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 drift on 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 align makes the edits, runs verify and opens the pull requests, with GitHub's settings written in the same run.
  • Five repositories protected main four ways. A ruleset is one JSON document, so it lives here and pnpm align applies it everywhere.
  • GitHub shows anyone a public repository's branch rules, but not its merge settings. The dashboard checks the rules, and pnpm drift checks the settings with gh'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.md now says never to name one, anywhere this repository reaches.
  • Copied rules drift. The @prose rules had started to differ across four AGENTS.md files. Each links to the one copy in prose's docs, and the survey checks the section word for word.
  • A pull request behind main waited forever. Two open at once in one repository, and whichever merged second sat blocked, since main requires an up-to-date branch and auto-merge doesn't update one. Turning that rule off would let two changes that pass alone break main together, and a merge queue needs an organisation. So pnpm pr --wait and align's wait update a branch that falls behind. Use --wait when another pull request is open in the same repository.
  • A pull request read as behind its own merge. The first --wait after that change failed on a pull request that had merged. For about a second, the squash commit was on main and 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 pack runs it with publint: { strict: true } and fails the build on a problem. It needs publint installed.
  • 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, engines became >=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.0 made the tag's push ambiguous. The workflow tags the merged commit itself, the bump branch is release-X.Y.Z, and npm's 2FA approval is the one step by hand. pnpm release opens 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 ci fails without exactly one label, and every AGENTS.md says which to pass to pnpm 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 dev and a build, 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 test runs the survey and verify on what pnpm new writes. Its first run showed oxfmt formats arrays differently from JSON.stringify, so the generator runs fix rather than copying oxfmt's rules.
  • Types for a package came from three places. vp pack needed typescript installed, or isolatedDeclarations in tsconfig.json, which the editor then applied to tests and config, and which vp check doesn'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 new started. CI passed on the first push. Run it without --push first, to read the files before they reach GitHub. A package's first push changes package.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 its workers.dev address 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.dev was how a config said it was internal, so a private repository's public site couldn't turn it off, and the public sites served on workers.dev and 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.
  • private in package.json already meant something. It means "not on npm", and a tool can be private with 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 verify fails 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 triggers sets them and NODE_VERSION, so pnpm align writes them. Set NODE_VERSION on 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. Run pnpm align after 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_ID when the login has several accounts. drift and align pick the account named like the GitHub owner. A pnpm ship by 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 .prose as its publicDir, and the plugin with assetsOnly writes it out for cf 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 beta tag isn't the build to take. It can point at a build inside pnpm's wait. Take the newest 2.0.0-beta.sha-… past the wait that depends on the same @cloudflare/config as cf, read with pnpm 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-wrap turned "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 after prose 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 warns duplicate-id when 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 .html page 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.md links 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 style was 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.md listed 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-beta names the betas once, the check fails any other pre-release, and package.json alone 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.