setup-node still downloaded although the image carried a perfect /opt/hostedtoolcache/node/26.8.2/x64/ tree. actions/tool-cache accepts a cached tool only when the sibling marker <version>/<arch>.complete exists (tc.find() tests it explicitly); a bare directory is ignored, so the probe fell through to the download. The marker is what tc.cacheDir() writes after installing a tool, so the baked entry must create it too. Job-container diagnostics also confirmed the path was never in question: RUNNER_TOOL_CACHE=/opt/hostedtoolcache, no mount over it, node -v from the baked path prints v26.8.2. CONTRIBUTING records both invariants — the marker, and the fact that a Dockerfile change keeps the same tag, which forcePull=false can hide.
17 KiB
Contributing
This document is for maintainers and contributors working on the project itself. End-user documentation is in README.md. The machine entry point for AI coding agents is AGENTS.md; keep this file as the prose home for the rules below so agents and humans don't diverge.
Rules the tools don't enforce
CI and review will bounce these even though npm run check and the linters don't catch them. They're the high-frequency things a contributor (or an agent) reaches for by default:
- Source imports use
.tsextensions, never.js.node --strip-typesonly resolves the.tsform at test time; "Pre-fixing" an import to.jsbreaks the inner loop. (rationale: README § Tooling decisions) - A new
npm runscript must reuse an existing prefix (create:/check:/fix:/test:/watch:/maintain:/publish:/setup:). If none fits, that's a signal the script doesn't belong in the pipeline — not a reason to invent a new prefix. If it genuinely does belong, add the prefix to both lists here in the same commit; an undocumented prefix becomes invisible and quietly accrues members. (see Script prefix convention) oxlint-disabledirectives live in source, not.oxlintrc.json. The trade-off must sit next to the code it silences. This is a human last-resort convention; agents must not add these — see AGENTS.md § Never do. (rationale: README § Tooling decisions)- Don't put slow / network / whole-project scans in
checkor pre-commit. Advisory scans are not correctness gates; they belong undermaintain:. (see Feedback tiers and Script prefix convention) - New work starts with
npm run create:branch, never a hand-writtengit switch -c/git checkout -b. The command carries the branch precondition; branching around it skips the clean-tree, current-mainand green-baseline checks, and the skip is invisible until a failure can no longer be attributed. (see Branching model) - Work is merged back with
npm run create:finish, never a hand-writtengit merge. The command carries the merge-side preconditions (clean tree, currentmain, afeature//fix//chore/branch) and runsnpm run verifyafter the merge, so a merge cannot land unverified. (see Branching model) - There is no local
npm run publish, andpublish:publint/publish:attwdon't go incheck. (see Publishing workflow)
Editor configuration
.editorconfig is for editor compatibility, not a gate — it is a sane fallback for the files oxfmt does not format (shell scripts, dotfiles, LICENSE, the commit-message template, and git's COMMIT_EDITMSG buffer). Where both apply, .oxfmtrc.json is authoritative: oxfmt is the formatter, and the overlapping .editorconfig keys only keep non-oxfmt editors close to the formatted result.
Commit messages
Gitmoji subject, imperative mood, 50/72 wrapping. The template is commit-message-template; run npm run setup:git-commit-message once after cloning to register it as git's commit.template (or npm run setup to run every one-time clone step).
Examples from history: :sparkles: Add watch tier with watch:test child, :recycle: Move type-aware config to .oxlintrc.json; use source-level disable directives, :memo: Restore unique maintainer content as CONTRIBUTING.md. The body explains what and why, not how; link issues with Resolves #....
Script prefix convention
Script names in package.json use a prefix that signals when the script is intended to run. A <prefix>:<name> script is implicitly aggregated by a <prefix> script (if one exists) and run by the corresponding lefthook hook or CI step. Picking the right prefix documents the script's intended lifecycle:
create:*— front doors of the repo's own workflow; these mutate git state rather than the source.create:branchopens a unit of work (asserts a clean tree, a currentmainand a green baseline before it branches),create:finishcloses the branch half (merges the current unit of work intomainand verifies the result),create:releasecloses the release half (maintainer-only). No barecreateaggregator on purpose — seepublish:*for the precedent.check:*— read-only verification; never modifies files. Aggregated bynpm run check.fix:*— mutating counterpart of acheck:*script. Aggregated bynpm run fix; the diff is the review surface.test:*— test scripts.testis the canonical entry point (check:tsc+ unit tests);test:unitskips the typecheck for fast local iteration;test:ciadds c8 coverage.watch:*— long-running watchers for the manual inner dev loop. Aggregated bywatch; currently a single child (watch:test), and a futurewatch:oxlint/watch:tscwould run concurrently under that umbrella.maintain:*— advisory repo-maintenance scans: read-only, but whole-project and/or network-bound, so never a correctness gate. Aggregated bynpm run maintain.publish:*— validates the publishable artifact (e.g.dist/) rather than the source, so it needs a fresh build. (see Rules the tools don't enforce and Publishing workflow)setup:*— one-time configuration of a fresh clone; mutates the local environment (git config, editor settings) rather than the repo source, so it is never part of a hook or CI step. Aggregated bynpm run setup(the umbrella), run once after cloning.
A new script should pick the prefix that matches its lifecycle, not invent a new one. If no existing prefix fits, that's a signal the script doesn't belong in the standard pipeline. When it genuinely does belong, a new prefix is allowed — but it enters both lists in this file in the same commit as its first member, otherwise the rule "reuse an existing prefix" silently develops an exception (the old use: prefix was exactly that; it is now retired into setup:).
Separately, some top-level scripts are bare (no prefix): the entry points that either run a single tool (build, clean) or aggregate a prefix:* family (check, fix, test, watch, maintain, setup), plus one convenience that composes across tiers: verify — composing check + test:unit into one whole-project correctness gate (it deliberately uses test:unit rather than test because check already runs check:tsc, so the type checker runs exactly once). Bare commands are how you invoke a tier; the prefix:* scripts are what those tiers are made of.
Feedback tiers
The tools are organized into a feedback ladder. Each tier catches different things at different costs; the rule of thumb is "earlier tiers fire more often, faster tiers catch less, slower tiers are more thorough":
| Tier | When | What it runs | Time |
|---|---|---|---|
npm run watch |
manual | watch:test — re-runs tests on file save |
~0.1s |
| Pre-commit (auto) | on stage | tsc + oxlint + oxfmt + cspell (staged files only) | ~1.3s |
| Pre-push (auto) | on push | npm test (full tsc + unit tests) |
~3.5s |
npm run check |
manual | Correctness gates: tsc + oxlint + oxfmt + cspell (whole project) | ~3s |
npm run verify |
manual | Definition of done: npm run check + unit tests, one shot |
~6s |
npm run fix |
manual | Auto-resolve fixable issues (lint, format) | ~3s |
npm run maintain |
manual / CI (advisory) | maintain:knip + maintain:outdated (whole-project + network scans) |
~10s |
| CI build (auto) | on push to main / tag |
build job (build + correctness + packaging) — see .gitea/workflows/ci.yml |
~30s+ |
| CI maintain (auto, non-blocking) | on push to main |
npm run maintain — reports, never fails the build |
~10s |
| CI publish (auto) | on tag | Gitea release page (body from CHANGELOG) + publish:publint + publish:attw, then npm publish |
~15s |
Why these splits?
watch:*is a manual tier, not a hook. The developer starts it on demand (it has to be killed with Ctrl-C) and it runs in a dedicated terminal pane. It sits as the earliest tier in the feedback ladder, catching failures the moment a file is saved — before staging, before commit.check:tsc,check:oxlint,check:oxfmt,check:cspellare in pre-commit because they are fast (~0.2–0.5s each), fully offline, and naturally scope to staged files via theLEFTHOOK_FILESenv var convention. They give instant feedback on what you typed.test(and thetscit includes) is in pre-push because it runs the whole test suite across the whole project. The pre-commitLEFTHOOK_FILESconvention doesn't apply to the test runner, so pre-commit isn't the right home. Pre-push runs after all commits are made but before the push leaves the machine, catching regressions that span multiple commits.
Before pushing
Run npm run verify — the one-shot correctness gate in the table above. Run npm run maintain only on a maintenance / update-deps branch.
Testing discipline (type-driven)
For this library the types are the feature — narrowing, exhaustive() returns, the Matcher<T> contract — so a runtime-only test loop would verify the wrong thing. New behavior follows type-driven development (in Edwin Brady's sense): treat the type as the plan for a program, and use the compiler and type checker as your assistant, guiding you to a complete program that satisfies the type (idris-lang.org). Here that plan is the expectTypeOf assertion, written first. The loop is type → red → green → refactor:
- Type — write the compile-time expectation first (
expectTypeOf(...).toEqualTypeOf<…>()) and letnpm run check:tscfail on the type. The type error is the spec you want to hit before the runtime logic exists. - Red — add the matching runtime assertion (
assert.*) sonpm run test:unitnow fails on behavior. - Green — implement in
src/*.tsuntil both the type check and the test pass. - Refactor — with the type system and the tests as the safety net, then
npm run verifyas the definition-of-done gate.
This is why every test in the suite pairs an expectTypeOf(...) with an assert.* — keep them together. Type-first is also enforced structurally: npm test runs check:tsc before the test runner, so a wrong type can never be papered over by a passing assertion. Per AGENTS.md § Never do, reach green honestly — fix the types so both the type check and the runtime assertion pass, never suppress the ones you can't make pass.
Branching model
GitHub Flow (single-developer). Every change — feature, fix, refactor — branches off main and is merged back via a local commit (no PR workflow on Gitea yet). Collaborative review via Gitea UI is not in place — Gitea is the lab; when something is tested and ready for production it will be promoted to GitHub.
- Base branch:
main - Branch naming:
feature/<desc>/fix/<desc>/chore/<desc> - Starting work:
npm run create:branch -- <prefix>/<desc>. It refuses, without changing anything, unless the working tree is clean (untracked files included), no merge/rebase/cherry-pick is in progress,mainmatches its upstream, andnpm run testis green onmain— so a later failure is always attributable to your edits. The prefix is still your call, inferred from the task; the script validates it rather than guessing it. - Merging:
npm run create:finish(on the branch). It asserts the same clean-tree / no-operation / current-mainpreconditions, fast-forwards a stalemain(a true divergence is refused), merges the branch--no-ff, runsnpm run verify, and deletes the branch only after the merge is green. The push is deliberately left tocreate:release, so the merge stays local and reviewable — read the diff yourself before finishing. - CI runs
npm run check+npm run test:cion every push tomain— this is the authoritative gate. The one exception: a push headed by a release commit (:rocket: Release x.y.z) skips the fullbuild/maintainjobs, becausecreate:releasepushes the tag for that exact commit right after and the tag run is the authoritative one (seerelease-gatein .gitea/workflows/ci.yml). - Releases are NOT triggered by pushes. Only the maintainer triggers a release (see Publishing workflow).
CI runner image
The build / maintain / publish jobs run in gitea.e1nsnull.de/tmu/act-ci:<version> (docker/Dockerfile) — the runner's default act image with the Node distribution overlaid at the exact /opt/hostedtoolcache layout actions/setup-node probes before downloading, so no job pays the ~50 MB fetch. The image tag MUST equal the exact version pinned in .node-version; release-gate uses no Node and stays on the default image. The script is deliberately NOT an npm run script: building requires a docker daemon and registry credentials, so it belongs to no feedback tier — per Script prefix convention, no existing prefix fits and that is the signal.
Bumping Node is one coordinated change, committed as a unit:
- Edit
.node-versionto the new exactx.y.z— floats like26resolve to the latest patch at runtime and silently bust the baked entry;scripts/runner-image.shrefuses them. docker login gitea.e1nsnull.de(user + package/access token), then./scripts/runner-image.sh --push— it reads the version from.node-versionand builds/pushes<IMAGE_REPO>:<version>.- Repoint the three
container.imagetags in .gitea/workflows/ci.yml to the same version.
Skipping step 2 fails CI at image pull; skipping step 3 silently reverts to the per-job download.
Two invariants the image must satisfy for the probe to hit, both easy to break:
- The
x64.completemarker.actions/tool-cacheaccepts a cached tool only when<version>/<arch>.completeexists next to the directory (tc.find()checks it); a plausible-lookingnode/<version>/x64/alone is ignored and the download happens anyway. See the comment in docker/Dockerfile. - Tag freshness. The tag encodes only the Node version, so a Dockerfile change (like the marker above) produces new content under an unchanged tag.
act_runnerskips the pull when a tag of that name already exists locally (forcePull=falsein the job log), so the runner must either force-pull (force_pullundercontainer:in itsconfig.yaml, if the installed version has it) or have the tag removed on the runner host (docker rmi gitea.e1nsnull.de/tmu/act-ci:<version>) after any image change. Symptom of getting this wrong: CI keeps running the previous image while the registry shows the new digest.
Publishing workflow
Publishing is CI-only by policy. Local npm publish is not supported. The maintainer triggers releases from main:
- All intended changes are merged to
mainand passing CI. - The maintainer runs
npm run create:release. VS Code opensCHANGELOG.mdto finalize the[Unreleased]notes; because pubv refuses a dirty tree, any edit is committed first (then folded into the release commit), and pubv's interactive prompt suggests a version from those notes — the maintainer confirms or edits it. scripts/release.shcreates a single release commit (graduated changelog + package.json bump, amended into one commit), tags it, and pushes everything to Gitea.- CI fires on both pushes: the
publishjob runs on the tag (build+ publish-tier checks + release page +npm publish), while the branch run'srelease-gatejob recognizes the release commit and skipsbuild/maintain— the tag verifies the identical SHA, so no work is duplicated. The job graph lives in .gitea/workflows/ci.yml — keep that file, not this list, as the source of truth. The publish-tier checks must pass before the artifact is published. Thepublishjob also creates the Gitea release page from the matching Keep-a-Changelog section (scripts/release-notes.sh); it runs beforenpm publishso a broken page fails CI without consuming a version, andnpm publishstays the last step.