Development Conventions - ehharvey/homelab-ops GitHub Wiki
Working conventions established while building the bootstrap CLI (issue #1). Not architecture โ see Architecture.md/Roadmap.md for that โ this page is about how we work: branching, PRs, Go project layout, and tooling.
Design docs (Architecture.md, Roadmap.md, Decisions.md) live under docs/ in this repo โ that is now the canonical source. This reverses an earlier decision to keep them in the GitHub wiki instead: the wiki repo is a separate clone with no PR review, so doc changes bypassed the normal review flow that code changes go through, and keeping a second working tree (../wiki, auto-cloned by the devcontainer) in sync by hand was friction for no real benefit. Docs now get the same PR review as code, in the same checkout, with no separate clone required.
A GitHub Action (.github/workflows/sync-wiki.yml) pushes docs/*.md to the wiki repo automatically on every push to main that touches docs/**. The wiki is a read-only mirror โ never edit pages there directly; any hand-edit will be silently overwritten (or just diverge unnoticed) on the next sync, since the sync only pushes from docs/ and never pulls wiki changes back. To change doc content, edit docs/ and open a PR like normal.
Code-level docs (READMEs, package comments) stay in the main repo as usual โ that part hasn't changed.
A decision or an issue body always reads as the current rule. Changes don't stack.
Stacked changes were the most likely source of a wrong implementation. Decisions.md ยง25 had three addenda, each overriding parts of the body or of the addendum before it. #101's body was a stack of dated scope notes, newest on top, and its handoff prompt had to list by hand which parts to ignore. Every reader, human or Claude, had to reconcile the layers, and paid in tokens to read the dead ones.
When a decision changes:
- Rewrite the rule in place. Edit the body so it states what holds now. Don't append an addendum that overrides it.
- Fold findings in where they apply. What building something settled (a verified behaviour, a narrowed rule) goes into the part of the body it refines.
- Mark what was replaced as superseded, with a link. A short pointer where the old rule stood is enough, e.g. "(first chosen with an epoch fence; see History)". Another decision that a change supersedes says so the same way, as ยง25 does for ยง17's lease.
-
Keep the history in a closing
### Historysubsection. One dated entry per change, with its issue or PR: what changed and, for a reversal, why. It's a record, not a rule, so a reader after the current rule can stop before it. - Evidence can stay as its own subsection (a spike's measurements, say) when the body cites it. Label it as evidence, not as an addendum.
-
Cite the section, not the addendum (
ยง25, not "ยง25's #212 addendum"), so citations survive the next change.
When an issue's scope changes: edit the body so it states the current spec, and post the change as a comment: a dated note saying what changed and why. The body is what an implementer (or a handoff prompt) reads; the comments are the history.
Sections written before this convention may still end in addenda. The newest one wins where they conflict; fold them in place the next time the section changes.
- One branch per issue, named
eharvey/#<issue-number>(e.g.eharvey/#1,eharvey/#6). - Work lands via PR, not direct pushes to
main. - PR body includes
Closes #<issue-number>so merging auto-closes the issue โ don't close issues manually. -
One issue, one commit (added 2026-07-18, see #119).
mainmerges PRs with rebase only โ never squash โ so a branch with N commits lands as N commits. The branch itself has to be the single commit.
Landing finished work is two steps (scripts/ship.sh, scripts/lgtm.sh):
-
make shipโ pushes and opens the PR withgh pr create --fill. Stops there. CI runs while you read the diff. Run it again after amending the commit (e.g. for review feedback) to re-ship to the open PR (#273): it force-pushes with a lease on the PR's head SHA, so it refuses to overwrite anything pushed since it read the PR, and it leaves the PR's title and body alone (/address-feedbacksyncs them from the commit). It refuses a closed or merged PR. -
make lgtmโ enables auto-merge on the current branch's PR, so it merges once the required checks pass.make lgtm PR=<n>for a PR opened another way.
Splitting them (see #125) is what keeps a PR from merging before anyone has looked at it. It's a speed bump, not an enforced control: a real approval gate isn't available to a solo dev, since GitHub won't let you approve your own PR and requiring one review would deadlock every PR. required_approving_review_count is therefore 0 and make lgtm is the honour-system stand-in.
lgtm runs locally rather than as a workflow reacting to an "lgtm" comment on purpose: a merge enabled with GITHUB_TOKEN is attributed to github-actions[bot], and GITHUB_TOKEN-driven pushes don't trigger further workflow runs โ which would silently stop sync-wiki.yml mirroring docs/**. A local gh keeps the merge attributed to a real user, so push-triggered workflows on main keep firing.
Three things enforce the one-commit shape, in order of how fast they tell you:
-
.githooks/pre-pushrefuses a multi-commit push locally. Install once withmake hooks(the devcontainer does it on start via.devcontainer/scripts/4-install-git-hooks.sh). - The
one-commitjob in.github/workflows/pr-shape.ymlfails the PR. -
one-commitis a required status check onmain, so a failing PR can't merge.
To squash a branch that got away from you:
git fetch origin main
git reset --soft FETCH_HEAD
git commit -c HEAD@{1}
make shipWhy one commit rather than squash-merging: squash-vs-rebase was a per-PR judgment call with no right answer (#119), and rebase-only removes the decision while keeping history linear. The cost is moving the squash upstream, to the branch, where a force-push is still cheap and the commit message is still editable.
Write these two sections in the commit message body, not the GitHub web form. gh pr create --fill (what make ship uses) takes the PR title from the commit subject and the PR body verbatim from the commit message body, so a well-formed commit satisfies this convention with nothing retyped โ and the rationale stays in git, readable offline and greppable via git log.
PRs use two sections:
## Plan
<What this PR does and why, with enough rationale that a reviewer doesn't
have to re-derive design decisions โ e.g. why an algorithm/library/default
was chosen, what alternatives were ruled out and why.>
## Test plan
- [x] <concrete check, e.g. specific test command or manual verification step>
- [x] ...Keep the "why" in the commit message, not in code comments โ code comments should only explain non-obvious invariants, not the history of how a feature came to be.
When a Roadmap.md checklist item is completed, check it off in the same commit as the work it's documenting, and annotate which issue closed it:
- [x] Offline self-signed cert/key generation: DONE; see #1This keeps Roadmap.md as a live, skimmable status board rather than a static spec.
The check-off used to be required to be its own commit. That was dropped for the one-issue-one-commit rule above (2026-07-18, see #119) โ it was the only structural reason a compliant PR was ever two commits (it's why #87 was). The see #N annotation already carries the traceability the separate commit was standing in for.
Milestones are named after the phase alone, no separate "Sprint N" counter (changed 2026-07-03): milestones/issues used to read Sprint N โ Phase M: ..., but sprint numbers were 1-indexed against 0-indexed phases and every sprint has mapped 1:1 to exactly one phase โ so the sprint counter carried no information beyond "which phase, off by one" (Sprint 3 did Phase 2's work). GitHub milestones are now just Phase N: <name>, matching Roadmap.md's headings; the Sprint planning issue template is phase_planning.yml ([Phase]: issues). Older closed issues that predate this ([Sprint]: N) were retitled to match; only issue bodies/comments referencing the old scheme are left as historical record.
Established by the bootstrap CLI (internal/cert, cmd/bootstrap):
- One binary per
cmd/<binary>/directory, withmain.gojust wiring upos.Exitaround acmd.Execute()call. - Cobra-based CLIs keep their command tree under
cmd/<binary>/cmd/: aroot.godefining the root command, and one file per subcommand (e.g.gen_cert.go) that self-registers viainit()callingrootCmd.AddCommand(...)โ soroot.gonever needs to know about every subcommand that exists. - Core logic (anything with real behavior worth unit testing, e.g. crypto, parsing, rendering) lives in
internal/<package>/, decoupled from cobra/CLI concerns entirely โ it takes plain Go types in, returns plain Go types out. This is what makes it reusable later (e.g. the web app importing the same cert-generation logic the bootstrap CLI uses) without dragging in CLI flag-parsing. - Module path:
github.com/ehharvey/homelab-opsโ everything (bootstrap CLI, and eventually the web app) lives in one module/repo per the Roadmap, sointernal/packages are shared freely across binaries in this repo.
Prefer stdlib over vendoring a big dependency for a small piece of logic. When the bootstrap CLI needed self-signed cert generation, Incus itself exports the exact same logic (github.com/lxc/incus/v7/shared/tls.GenerateMemCert) โ but importing it would pull in that module's full daemon dependency tree (~1000 lines of unrelated go.sum, including things like AWS SDK and dqlite) for one ~40-line helper. We replicated Incus's cert shape (ECDSA P-384, 10-year validity, ExtKeyUsageClientAuth) using only crypto/x509 instead. Default to this judgment call: copy a small, stable algorithm rather than import a large module for it, especially when the upstream package isn't designed as a stable public API (Incus's own module has already bumped v6โv7).
Established by internal/config (the Network/Instance parser):
-
Library:
gopkg.in/yaml.v3, notsigs.k8s.io/yaml. Same "small, stable dependency" judgment call as the cert library decision above โyaml.v3is native-YAML-tag-based and has no transitive deps beyond its own test-onlygopkg.in/check.v1, vs.sigs.k8s.io/yaml's JSON-tag-based round-trip approach and larger k8s-adjacent dependency surface, for a feature that doesn't need any other k8s tooling. -
Discriminated multi-doc decode pattern: decode each YAML document into a
yaml.Nodefirst, peek itskind:field via a tiny one-fielddiscriminatorstruct, then decode the node into the matched concrete type (NetworkorInstance). This is the standard way to do k8s-style polymorphic decoding withyaml.v3โ a singleDecodecall can't pick its target type before seeing the data. The second decode is strict (see the unknown-field note below), whichyaml.Node.Decodecan't do โKnownFieldsis ayaml.Decoderoption the node-decode path bypasses โ so the node is re-marshalled and run back through ayaml.DecoderwithKnownFields(true), with thekinddiscriminator inlined (,inline) alongside the concrete type so the required field isn't itself flagged as unknown. Reuse this pattern rather than reinventing it when the web app's config-sync module (Roadmap Phase 1) parses the same multi-doc format. -
No cardinality enforcement at the parse layer.
config.Parsecollects everyNetwork/Instancedocument it finds into slices โ zero, one, or many of each โ without erroring on count. The bootstrap CLI's effective single-node behavior in 0.x comes from the fleet YAML files we hand-author only containing one of each, not from a parser-level restriction. This keeps the parser reusable as-is once the web app needs to handle a whole fleet's worth of documents, instead of needing to relax a 0.x-era restriction later. -
Unknown or misspelled fields are parse errors, not silent drops. Decoding is strict (
KnownFields(true), see above): a typo'd key โstatc_ipforstatic_ipโ fails the parse instead of leaving the field zero-valued. These configs provision real hardware, so a droppedstatic_ipsilently becoming a DHCP fallback is exactly the failure that must surface loudly rather than at boot. A missing or unrecognizedkind:is likewise always an error; there's no silent skip of unrecognized documents. - Diagnostic-only CLI subcommands are an acceptable judgment call even before a real consumer exists:
bootstrap parse-config --file <path>only prints a summary of what was parsed (no seed rendering yet), added specifically to satisfy a "done when the CLI can read X" criterion without building a half-finished renderer to justify the command's existence.
Decided in Decisions.md ยง Validation approach (#47): roll our own, as a stdlib net/netip parse/validate split โ no validation library (go-playground/validator/zog both evaluated and rejected there). Conventions for implementing it (#46):
-
Parse stays structural + syntactic; semantics are a separate pass.
config.Parsekeeps doing only multi-doc decode + strict unknown-field detection โ it stays cardinality-agnostic and reusable, per the YAML-parsing note above. A separateconfig.Validate(Config) Issuesdoes the cross-field semantic checks (gateway-โ-CIDR, range-โ-CIDR, static-ip-โ-CIDR-and-โ-range, DNS-are-IPs, name-non-empty). Don't fold semantics back intoParse. -
Typed fields, not strings.
config.Network/config.Instancefields move tonet/netiptypes:CIDR โ netip.Prefix,Gateway/StaticIP โ netip.Addr,DNS โ []netip.Addr,DHCPExcludedRange โa smallRangetype with a customUnmarshalTextthat splits on-into twonetip.Addr.MACstays astringโnet.HardwareAddrhas no stdlibTextUnmarshaler. -
Why typed parsing is "free" here โ the non-obvious part:
gopkg.in/yaml.v3honorsencoding.TextUnmarshaler, andnetip.Addr/netip.Prefiximplement it, so they unmarshal directly from the synced YAML (and JSON, for the store/API) with syntactic validation happening at parse time. Optional fields need no wrapper type: an empty (gateway: "") or omitted value both land as a zeronetip.Addr{}withIsValid()==falseand no error โ distinguishable from a set value viaIsValid(). This premise is pinned byExampleNetwork_yamlUnmarshalininternal/config(anExampletest with an// Output:block, runnable viago test ./internal/config -run Example): its assertions fail CI if a futureyaml.v3bump ever stops honoringTextUnmarshaleror starts erroring on empty strings โ don't assume either (it doesn't, and they don't). -
Validation returns a value, not behaviour. The output type is
Issue{Path, Message}(e.g.Path: "networks[0].gateway"), with anIssuesslice that satisfieserror/has anEmpty(). This is deliberately the shape a library likezogproduces (issue.Path/issue.Message): it's the entire seam that lets validation be re-driven by a library later if an untrusted-input surface ever appears, without changing callers. Do not build aValidatorinterface/registry abstraction for that โ the value-returning function is the whole hedge. -
Where
Validateruns. As an explicit pass insideserver.SyncOnce(internal/server/server.go), right besideipam.Assign, classified under a newErrValidatethat maps to HTTP 502 โ bad upstream repo content, the same stage-classification rationale as the existingErrIPAM. -
Consolidate, don't add a fourth copy.
ipam.ValidateStaticIP/ExcludedRangeBoundsandinternal/seed's re-validation collapse into the typed model โnetipis a comparable value usable as a map key (net.IPis not), so ipam's hand-rolledcompareIP/nextIP/broadcastAddrbyte-twiddling reduces tonetip's.Next()/.Compare()/prefix math, andconfigdiff'sreflect.DeepEqualkeeps working (with==now also available).
Established by internal/seed (the install/network/applications seed renderer) and internal/third_party/incusos (vendored upstream types):
-
lxc/incus-osis checked out as a sparse git submodule atthird_party/incus-os, pinned to a specific commit, sparse-checked-out to justincus-osd/api(root files likeCOPYINGcome along for free under cone-mode sparse-checkout). This is the explicit, git-tracked pointer to which upstream commit our seed format matches โ bump it viagit -C third_party/incus-os checkout <new-sha>+git add third_party/incus-os. -
scripts/vendor-incusos.sh(make vendor-incusos) copies the small set of files we actually need from the submodule intointernal/third_party/incusos/, stamping each with the submodule's current commit SHA and an Apache-2.0 attribution header. This is the layer our Go code actually imports โ the submodule by itself is just a reference checkout, not somethinggo buildtouches. The only hand-edit the script makes is rewritingseed/network.go's import path fromgithub.com/lxc/incus-os/incus-osd/apito our local vendored path; every other file is byte-for-byte upstream. -
Nested under
internal/, not top-levelthird_party/: Go'sinternal/import boundary stops this vendored copy from ever becoming a stable dependency surface for code outside this module โ it's plumbing forinternal/seed, not a public API. Thethird_partypath segment underneath still flags "this code isn't ours" to a reader. (The top-levelthird_party/incus-ossubmodule is the raw upstream checkout;internal/third_party/incusosis the Go package actually compiled โ two different things that happen to share a name fragment.) -
Why vendor instead of
go get-inggithub.com/lxc/incus-os/incus-osd/api/seeddirectly: theincus-osdmodule'sgo.modpulls intailscale.com, the fullincus/v7tree, FuturFusion's modules, etc. (~30 direct deps) for module-graph resolution, even though the seed/network structs themselves only import Go stdlib. Same "small, stable dependency" judgment call as the cert-generation precedent above โ copy the small self-contained piece rather than import the module it happens to live in. -
Why vendor instead of hand-authoring our own equivalent structs: there's no canonical example
install.yaml/network.yamlin upstream's docs to check our format against (confirmed againstdoc/reference/seed.md) โ the structs are the reference format. Using the real upstream types means our rendered YAML matches by construction instead of by careful transcription. -
internal/seed.Renderis where single-instance/single-network cardinality enforcement lives (disk/NIC must be"single", exactly one matchingNetworkperInstance) โ consistent with the note in the YAML-parsing section above thatconfig.Parseitself stays cardinality-agnostic and this is deferred to whatever consumesConfignext.
Follow-up TODO (not yet done): internal/cert predates this vendoring approach and currently hand-replicates Incus's cert shape via stdlib crypto/x509 rather than vendoring Incus's own cert-generation source. Worth revisiting whether the same "vendor a small, license-permissive, self-contained file" reasoning should apply there too.
Established by internal/seed's incus.yaml rendering and scripts/validate/node-boots-and-trusts-bootstrap-cert.sh:
-
Import
lxc/incus/v7/shared/apidirectly instead of vendoring it. Unlikeinstall.yaml/network.yaml(vendored intointernal/third_party/incusos, see issue #3 above),incus.yaml'sInitPreseed/CertificatesPosttypes are imported straight fromgithub.com/lxc/incus/v7/shared/api. Confirmed viago list -depsthat this specific subpackage pulls in only stdlib + one yaml module โ not theclient/incus-osddependency trees this repo otherwise avoids. The "vendor a small piece instead of importing a big module" judgment call (see the cert-generation and issue #3 precedents above) only applies when the import would otherwise be heavy; when it isn't, importing the real upstream type directly is simpler and keeps us byte-for-byte correct against Incus's actual API shape. -
scripts/vendor-incusos.shpinsgo.mod'slxc/incus/v7version to whatever theincus-osdsubmodule itself depends on (reads it out of the submodule's owngo.mod, thengo gets that exact version), rather than letting the two version pins drift independently. This runs automatically as part of the existing.github/workflows/bump-incusos.yml, so a submodule bump can't silently leave ourInitPreseedusage out of sync with what a realincus-osdbuild expects. -
Certificatemust be base64-encoded raw DER, not PEM โ Incus'sCertificatesPost.Certificatefield expects that exact encoding (per itsCertificatePut/CertificatesPostshape), sorenderIncusPreseedstrips the PEM wrapper (encoding/pem.Decode) before base64-encoding the raw bytes. Getting this wrong fails silently at the YAML level (it's still a valid string) and only surfaces as a real Incus node refusing to trust the cert. -
Validation scripts should drive the real stack, not just check that files exist or that unit tests pass.
scripts/validate/node-boots-and-trusts-bootstrap-cert.shdrives the full pipeline โgen-certโrender-seedโbuild-imageโ boot a real Incus VM off the produced.imgโ poll the node's static IP over HTTPS with the cert โ as one end-to-end check that proves both install success and cert trust simultaneously. This caught real bugs a file-existence check would have missed (see below); prefer this pattern whenever there's a real artifact or service in the loop. Requires an IncusOS base image:INCUSOS_BASE_IMAGEdefaults to the version pinned inscripts/incusos-base.versiononcemake incusos-basehas fetched, verified and cached it (#296;scripts/validate/README.mdยง The IncusOS base image), and that section skips gracefully when neither the variable nor the cached pinned image is there. A real VM boot is one instance of this principle, not the only one โscripts/validate/store-retains-synced-fleet.sh/sync-warns-on-config-diff.shapply the same idea to the web app: drive the realdocker composestack against a real git remote, pushing a second commit to it to prove store-replace and diff behavior, rather than relying ongo testalone. -
Real-VM testing surfaced bugs invisible to unit tests โ worth the cost of running it before considering an issue genuinely done:
flasher.go'sseedFileslist wasn't updated whenincus.yamlwas added, so the cert preseed was silently dropped from every built.img(the actual reason cert trust never worked, despiteincus.yamlrendering correctly in isolation); IncusOS requires a network interface'sNameto be set or it's left unconfigured at boot; a static IP with no default route leaves a node unable to reach anything off-subnet (now an explicitRendererror ifNetwork.Gatewayis unset and an instance has astatic_ip). None of these were catchable by testing the seed-rendering logic alone โ only booting a real seeded image surfaced them.
Established by internal/configsync and internal/store:
-
github.com/go-git/go-gitfor config sync, not shelling out to agitbinary. Cloning in-process (into an in-memory filesystem viago-billy'smemfs, seeinternal/configsync/sync.go) means the deployed binary/distroless image has nogiton$PATHto depend on โ consistent with the no-k8s, minimal-deployment goal from #18. -
modernc.org/sqlitefor the local store, notmattn/go-sqlite3. It's a pure-Go, CGO-free driver, which keepsCGO_ENABLED=0builds and the distrolessDockerfileworking without a C toolchain in the image โ same "small, stable, dependency-light" judgment call as the cert/YAML precedents above, just applied to a runtime dependency instead of a vendored algorithm.store.Opendefaults to:memory:(non-persistent, scoped to the process) unlessSTORE_PATHis set to a file.
-
golangci-lintconfig lives in.golangci.ymlat the repo root. Start fromdefault: standardand add linters deliberately rather than maximal strictness โ e.g.gosecis non-negotiable for any package handling key material, but noisy style linters (cyclop,dupl,funlen,wsl, etc.) are left off a young codebase to avoid PR friction. -
Makefiletargets:build,test(-race -cover),lint,lint-docs,validate,validate-hardware,fmt,tidy,clean,hooks,ship,lgtm. Plain Make โ no justfile/task-runner, since Make is already in the devcontainer base image. -
make validateruns the validation suite's unattended subset (scripts/validate/, ~2.5 minutes, Docker only);make validate-hardwareruns the Incus/VM subset, which boots real VMs and needs the host. Both go throughscripts/validate/run.sh, which discovers scripts from their ownVALIDATE_GROUPdeclarations rather than a list โ so a new script is covered without editing anything central, the same reasoning aslint-mermaid.shglobbingdocs/*.md. An unmet prerequisite is a skip with its own exit code, not a failure: seescripts/validate/README.mdanddocs/Decisions.mdยง20. - CI (
.github/workflows/ci.yml) runs four parallel jobs on push/PR tomain:build-test(go build+go test),lint(golangci-lint-action),docker-smoke(builds the web image, provesflasher-toolexecutes inside the distroless final stage and the app serves/healthz, then, on a push tomainonly, publishes it toghcr.io/ehharvey/homelab-ops/webas the commit SHA pluslatest, #240), andagent-image(the same for the agent image, #102). The publish is a step gated byif:, not a separate job, so the checks that precede it gate it and the job still reports a status on PRs..github/workflows/pr-shape.ymladds a fifth check,one-commit, on PRs only. -
build-test,lint,docker-smoke, andone-commitare required status checks onmain(added 2026-07-18, see #119) โ before that they were advisory and a PR with red CI was mergeable. Two constraints on that ruleset worth knowing before editing it:-
strict_required_status_checks_policyis on (#264), so a PR merges only after CI has run against currentmain. That's compatible with the one-commit rule only because branches are rebased, never merge-updated. The "Update branch" button's default mode adds a merge commit to the branch, which makes it two commits and failsone-commit, so don't use it; use "Update with rebase", or rebase locally. Strict mode on its own would leave every other auto-merge PRBEHINDeach time one lands..github/workflows/auto-rebase.yml(scripts/auto-rebase.sh) rebases them on every push tomain, and whenmake lgtmenables auto-merge. It touches only PRs that have auto-merge enabled, are based onmainand come from this repo; a PR still being worked on mustn't be force-pushed under its author, and stacked PRs belong tomake restack(#254). A conflicting PR gets one comment per head SHA telling you to rebase locally, and nothing else. The rebase runs with theISSUE264_REBASE_BOT_PATsecret, a fine-grained PAT, rather thanGITHUB_TOKEN, because aGITHUB_TOKENpush triggers no workflows and the rebased PR's required checks would never run. After a rebase on GitHub, your local branch no longer matches the remote (#263). -
docs.yml'smermaidjob must not be added to the required list. It's path-filtered todocs/**, and a workflow skipped by a path filter reports no status at all, so requiring it would leave every non-docs PR pending forever. (A job skipped by anif:condition reportsskipped, which does satisfy the rule โ the distinction is easy to get wrong.)
-
-
make lintrunsgolangci-lintvia the officialgolangci/golangci-lintDocker image (pinned to a specific tag in theMakefile'sLINT_IMAGEvar) rather than a locally installed binary โ no toolchain setup needed beyond Docker, and the version is pinned in one place instead of relying on whatever happens to be on a contributor's$PATH. It runs underflockonlint.lockin the shared git dir, so runs from different worktrees go one at a time (#282): each fresh container re-downloads modules and peaks near 3 GiB, and four at once each took 335 s instead of 104 s and pushed the host into swap. Withoutflock(macOS) or outside a git checkout it runs unlocked. -
make lint-docsprovesdocs/'s mermaid diagrams actually parse (scripts/lint-mermaid.sh, run in CI by.github/workflows/docs.ymlwhendocs/**changes). It followsmake lint's shape exactly โ the officialmermaid-cliimage, pinned in one place, no local toolchain beyond Docker, so a Go repo gains nopackage.json. This exists because reviewing a diagram's source in a PR diff tells you nothing about whether it renders:docs/AppManager.md's sequence diagram showed a parse error instead of a diagram on GitHub from #105 until #111, and #106 reviewed and corrected that very diagram's logic in between without anyone noticing it wasn't displaying. Same lesson asCLAUDE.md's issue-#5 note (a passing test suite that still shipped a silently dropped seed file), applied to docs.
Anything an offline tool generates locally (certs, keys, seeds, images) goes under a single gitignored directory (e.g. bootstrap-output/) rather than scattered paths โ one .gitignore entry covers all of it, and it's much harder to accidentally commit a private key.