* ci(claude): constrain automation triggers and bound run cost (L-16)
The Claude Code workflow consumes a metered credential, and the repository
stated nothing about who may spend it or for how long. Whatever downstream
behaviour happens to hold, an invariant this repository depends on should be
asserted and tested here, not inherited from a pinned dependency that a routine
version bump can re-derive.
Three controls, in the one workflow that spends:
- **Authorization.** The job condition now requires the actor to be on an
explicit maintainer allowlist as well as the trigger text to mention the bot.
An allowlist rather than an association check: this repository has exactly one
collaborator, the term is unambiguous to read and to review, and it matches
the actor-term pattern `ci.yml` already uses to exclude Dependabot. Adding a
login is a one-line edit, which is the honest cost.
- **Duration.** `timeout-minutes: 30`, in the band every other long-running job
here uses. Without it the job inherits GitHub's 360-minute default — the wrong
ceiling for metered work, and the only job in the repository that lacked one.
- **Fan-out.** A `concurrency` group keyed on the issue or pull request number
with `cancel-in-progress: true`, so repeated triggers on one thread collapse
into a single run instead of running in parallel. Exactly one of
`github.event.issue.number` and `github.event.pull_request.number` is present
per triggering event, so the key is stable across all four.
The `permissions:` block and the checkout are deliberately untouched. The
permissions are already minimal and the checkout takes no `ref:`, so it reads
the base branch rather than proposed code — both correct, and rewriting either
would be churn.
`scripts/check-workflow-guards.mjs` keeps all three from silently regressing.
Modelled on `scripts/check-doc-counts.mjs`: same `--selftest`-then-assert shape,
same dependency-free approach. It is text-level rather than YAML-parsed on
purpose — the root has no YAML parser, and adding a dependency to assert that a
file contains a `timeout-minutes` key would be a poor trade. That limit is
stated in the file: these are presence-and-shape checks, not semantics.
It runs from `CHECK_HYGIENE` in `scripts/run.mjs`, so it is reachable as
`npm run check:hygiene` locally and executes inside `Repository Hygiene`, which
is already a pinned required check on `dev`. No new CI job and no new pin — the
guard is blocking from the moment it lands.
`actionlint` cannot do this job. It validates expression syntax, action inputs
and runner labels; a job condition is valid input to it whatever the condition
admits, and it has no notion of cost at all. The two tools are complementary
and both now run.
Also corrects the record: `docs/plans/b1-repository-foundation-2026-08-25.md`
claimed impact was bounded by read-only content permissions. The workflow's own
token block is least-privilege, but that is not the only identity a run can
hold, so the claim was narrower than the truth and is now stated accurately.
And `docs/security.md` gains the private-coordination section that two planning
documents already cite it for. The citation pointed at a policy that was not
written down; it now says what stays private, that the rule covers the
repository's own automation and settings rather than only product code, and
that a commit message on a public repository is a disclosure channel.
Verified: both directions, per guard. `node scripts/check-workflow-guards.mjs`
exits 0 on the current tree and reports four guards present. Deleting the
`timeout-minutes` line makes it exit 1 naming that guard and the invariant to
restore; replacing the actor term with `true` makes it exit 1 naming that one;
restoring each returns exit 0. `--selftest` passes eight assertions covering
every guard's absence, a commented-out guard (which must not count), and the two
shapes that must not trip it — any positive timeout value, and any concurrency
key. `npm run check:hygiene` passes with prettier, shellcheck, actionlint and
both new steps running for real; actionlint accepts the edited workflow.
Not included: the workflow's `permissions:` block and checkout step, per above.
No change to the action version or its inputs. `ci.yml`, `release.yml` and
`load-baseline.yml` are outside this item — none is reachable the same way, and
each already carries per-job least-privilege permissions, and where relevant a
timeout and a concurrency group. `METERED` in the new script lists one workflow
because one workflow spends; a second entry is a one-line change when that
changes.
Refs RL-22, L-16
* feat(intake): structured bug form, and route ideas to Discussions (RL-21)
Both issue templates were Markdown with front matter, so nothing they collected
was structured, required, or validated. A reporter could submit the form
untouched. The Environment block was three bullets with `Windows 11` prefilled
as the OS — the single most common answer, pre-filled, on a project that ships
Windows and Linux builds and an ARM64 client.
And `feature_request.md` existed at all, which is the direct violation: BPR-100
says Issues is the bug tracker and Discussions hosts support, ideas and
community feedback. A feature-request template routes ideas into Issues by
construction.
Done:
- `bug_report.md` → `bug_report.yml`, a real issue form. Six fields are
`validations: required` — what happened, steps to reproduce, component, OS,
architecture, deployment mode — because those six are what turns a report into
something reproducible. The rest are optional on purpose; a form that demands
everything gets abandoned.
- `feature_request.md` deleted. Nothing in the tree referenced either template
by filename, so this breaks no link, script, or workflow.
- `config.yml` gains three routed destinations and keeps `blank_issues_enabled:
false` — which is what makes the routing hold, since a blank issue bypasses
every form and every warning on one.
The new environment fields are drawn from what this project actually ships, not
from a generic template:
- **Architecture** x64 / ARM64, with the note that ARM64 is the Linux desktop
client today and there is no ARM64 server release.
- **Deployment mode** covering the six paths `docs/deployment.md` documents —
prebuilt binary on either OS, from source, Docker/Compose, systemd, Windows
service.
- **TLS mode** matching `tls.mode`'s four values exactly, `off` quoted so YAML
does not read it as boolean false.
- **Network topology** — direct, port forward, reverse proxy, Tailscale — because
voice bugs in particular bifurcate hard on this, and the reverse-proxy path
cannot carry the WebRTC UDP range at all.
- **Separate client and server versions.** They are obtained differently and can
legitimately differ. The server field says where to look — admin panel or the
startup banner — and explicitly tolerates "unknown", because the version is
deliberately absent from the unauthenticated `/health` endpoint as
anti-fingerprinting hardening, so a non-admin reporter genuinely cannot get it.
- **Client webview**, WebView2 or WebKitGTK. No "PWA" option: no PWA exists, B1
excludes browser and PWA work, and BPR-092 forbids presenting unavailable
behaviour as functional. The field is diagnostic today regardless — the desktop
client renders through the OS webview, and that already drives real bug classes.
Every public template now carries the disclosure warning BPR-101 asks for, and
the security contact link is first in the chooser, above the Discussions links.
Four files, 189 insertions, 58 deletions.
Verified: both files parse as YAML, and the form was checked against the issue
form schema rather than only for parseability — 13 body elements, 12 unique ids
with no collisions, every non-markdown element carrying an id and a label, every
dropdown carrying options, and the markdown block carrying neither an id nor
validations (both of which GitHub rejects). `config.yml` has
`blank_issues_enabled: false` and four contact links each with exactly
name/url/about. `npm run check:hygiene` passes.
The gap that verification leaves, stated plainly: nothing in this repository
validates issue-form schema. Prettier confirms the YAML parses and actionlint
does not read `.github/ISSUE_TEMPLATE/` at all, so a file that is valid YAML but
an invalid form disappears from the "New issue" chooser silently. The checks
above are a local stand-in, not the real gate. The live chooser needs a look
after merge — which BPR-100's closure evidence ("dry-run submissions reach the
intended destination") requires in any case.
Not included: the Discussions `?category=` slugs are written as `q-a` and
`ideas`, GitHub's defaults. If this repository's categories were renamed, a
wrong slug drops the user on the category picker rather than erroring — confirm
against the live Discussions tab before relying on them. No PR-template or
documentation changes here; those are the next commit. L-15 is not closed by
this commit alone: BPR-100 names six surfaces and three of them are docs.
Refs RL-21, L-15
* docs(intake): route contributors, and state the security path (RL-21)
The previous commit fixed the forms. This is the half BPR-100 and BPR-102
actually ask for and the B1 plan's bullet does not mention: their closure
evidence names repository navigation, support links and contribution docs
alongside the issue forms, so a `.github/`-only change cannot satisfy either.
Three gaps, each verified rather than assumed:
**Discussions was invisible.** The only link to it anywhere in the tree was
inside `.github/ISSUE_TEMPLATE/config.yml` — the new-issue chooser. So "route
ideas and feedback to Discussions" worked for exactly one audience: people who
had already decided to file an issue. `README.md` and `docs/README.md` now each
carry the routing, so it is reachable from the two pages a newcomer actually
lands on.
**`docs/contributing.md` never mentioned security reporting.** Five files
carry the "never a public issue" rule — the root `README.md`, `CONTRIBUTING.md`,
`SECURITY.md`, `docs/security.md`, `CLAUDE.md` — and every one of them delegates
the full process to `docs/contributing.md`, which is also the document BPR-102's
evidence row sends a fresh contributor to. It said nothing about it. It now has
a routing table and a security section that says the thing that actually matters
on a public repository: the PR description, the commits and the branch name are
disclosure channels, so a fix for a vulnerability describes the control it adds
and nothing else.
**The README contradicted the issue chooser.** The banner said "there's no
support" while the chooser offered a link named "Community Support". Both were
defensible in isolation and together they told a user two different things
before they had read anything else. The banner now says the honest version — no
support *commitment* — and a "Getting Help and Reporting Problems" table names
the right destination for each kind of message without promising a response.
Also in the PR template, which the audit's remedy names as "PR guidance":
- The Test Plan asked for `npm test` / `go test ./...` / `npx tsc --noEmit`.
Those predate B1-4's root facade; `npm run check` is the entry point CI gates
on and the one `CONTRIBUTING.md` and `README.md` now tell people to run.
- A generated-files checkbox naming all five, since CI fails on drift and a
hand-edited generated file is the failure that wastes a cycle.
- A `Not included:` prompt, because `docs/contributing.md` makes a written
deferral a required commit element and the template asked for it nowhere.
- The disclosure warning BPR-101 wants on public templates.
Two stale claims fixed while in these files: `docs/contributing.md` said "ten
status checks are required" three lines from a section that says twelve, and
`docs/plans/README.md` still read "B1-0 done, B1-1 next" six phases later — in
the index that declares itself the authority over plan headers.
Five files, 70 insertions, 12 deletions.
Verified: `git grep "ten status checks"` returns nothing.
`node scripts/check-doc-counts.mjs` still agrees on 21 claims across 8 watched
documents — `docs/plans/README.md` and `README.md` are both watched, so a
count claim broken by these edits would have failed here.
`npm run check:hygiene` passes with prettier, shellcheck, actionlint and the
workflow-guard check all running.
One nearby claim checked and deliberately left: `docs/contributing.md` also says
"four of the ten" a hundred lines later. That is four of ten *CI steps keying on
a cache-dependency-path*, not required checks — correct in context, and changing
it would have been a wrong fix to a right-looking grep hit.
Not included: L-15 is **not** closed. BPR-100's closure evidence requires
dry-run submissions that reach the intended destination, and BPR-102's requires
a fresh Windows and Linux contributor to follow these docs and land a passing
sample change. Neither is a file edit. BPR-101 additionally wants a tabletop
report proving private receipt, triage, advisory and coordinated disclosure —
no such artifact exists in the tree, and this commit does not create one.
`CODE_OF_CONDUCT.md` and `GOVERNANCE.md` do not exist in this repository; adding
them is community-health scope, not RL-21's, and neither is named by the audit
row or the register row.
Refs RL-21, L-15
* ci(release): require exact-SHA gate evidence before publishing (RL-16)
A tag push starts `release.yml` and nothing else — `ci.yml` has no `tags:`
trigger. And `release.yml` re-runs none of the required checks: it verifies the
version, builds, boot-smokes and signs, which is a different question from
"did the gate pass on this commit". So a tag could publish from a commit whose
CI was red, and nothing would notice.
It already has. `v1.2.0-alpha.3` published from `fb04a579`, whose CI run
concluded **failure** — `Server Build & Test (windows-latest)`, the race and
coverage step. The Release run on the same commit went green and shipped. That
is R-09 demonstrated rather than hypothesised, and it is the fixture this commit
is verified against.
The obvious fix — re-run the test suite inside `release.yml` — is the wrong one.
It would double the tag-time cost, still not cover the checks that run in other
workflows (CodeQL's three `Analyze` jobs exist in no workflow file at all), and
answer a weaker question: "does it pass now" rather than "did the gate pass on
this commit". The evidence already exists; nothing was reading it.
Done:
- `scripts/verify-gate-evidence.mjs` resolves the tagged SHA's check runs and
asserts every required context is present and `success`. `skipped` and
`neutral` are not success — a required check that skipped on the tagged commit
proves nothing about it — and a still-`in_progress` check is called out as
unfinished rather than treated as absent. Where a context reported more than
once, the latest attempt decides, in both directions.
- The required set is **parsed out of `b0-dev-branch-protection.sh`**, not
restated. Pinning a thirteenth check cannot leave this gate behind, and a
change to that file's shape fails the self-test rather than silently
weakening the gate.
- A `gate-evidence` job in `release.yml` that `verify-versions` needs. Every
build job already needs `verify-versions` and both publishers need those, so
one edge gates the whole graph — including the GHCR push, which today can
mutate `:latest` before `publish` has run at all.
- `permissions: checks: read` and nothing else.
It is a script rather than a `run:` block because of the rule in the `ci-check`
skill: a step that exists only in `release.yml` first executes at tag time, so
its own bugs surface on the release. `Server/scripts/docker-smoke.sh` is the
worked example — one script, two call sites. Here the second call site is
`--selftest`, run by `ci.yml`'s docs-consistency job on every pull request.
`docs/plans/b1-release-tag-protection.sh` covers the half a workflow file
cannot express: a ruleset on `refs/tags/v*` blocking update and deletion, and a
`release` environment with a required reviewer. **NOT APPLIED** — both are
repository-settings writes this session cannot make. Run
`bash docs/plans/b1-release-tag-protection.sh` when you want them.
Deliberately **no `environment: release` key** in `release.yml` yet. The key is
PR-landable, but naming an environment that does not exist stalls the next
release; the script says to add it after creating the environment, and says why.
Verified: both directions, on real data rather than only fixtures. Feeding the
actual check runs from `fb04a579` — the commit alpha.3 shipped from — through
`evaluate` returns **NOT RELEASABLE**, naming `Server Build & Test
(windows-latest): failure` first. Feeding PR #1418's real check runs on
`8875238` returns **RELEASABLE**, and correctly ignores the red
`github-advanced-security` result because it is not a pinned context — the gate
tracks the required set, not "everything is green". `--selftest` passes 12
assertions covering a missing check, a failure, an unfinished run, `skipped`,
`neutral`, both re-run orderings, an unrequired extra, and a commit with no
checks at all. `bash -n` and `shellcheck` are clean on the new script and both
its heredocs parse as JSON. `npm run check:hygiene` passes with actionlint over
both edited workflows.
The module gained a direct-invocation guard so it can be imported and tested
without reaching the network — compared against `argv[1]` rather than
`import.meta.main`, which needs Node 24.2 against an engines floor of `>=24`
and would silently no-op on 24.0.
Not included: the network path itself is exercised only at tag time. The
self-test covers the decision logic and the required-set parsing, which is where
the bugs live; a live API call needs a token this environment does not have.
R-09's "protected release approval" limb stays open until the settings script is
run — the register phases R-09 **B1/B10**, so that half is B10's. `release.yml`'s
version stamping, both signing keys, the fail-closed minisign verify,
`checksums.sha256`'s bare filenames, both cold-boot smokes and the `git archive`
source snapshot are untouched; the remedy says to retain them and this commit
only adds an edge in front of them.
Refs RL-16, R-09
* docs(plans): record B1 progress through B1-7
B1-6 (#1418) merged and B1-7 is this branch, so the header and the plan index
both move on. B1-8 — the platform contract map — is next, and it is documentation
only: it records the browser-neutral contract folders and their owners, and moves
no native behaviour. Adapter extraction stays B7.
Verified: `node scripts/check-doc-counts.mjs` still agrees on 21 claims across 8
watched documents, both edited files among them; prettier clean.
Refs R-08
---------
Co-authored-by: Claude <noreply@anthropic.com>
22 KiB
Contributing
How to set up the development environment and contribute to OwnCord.
Development Setup
Prerequisites
| Platform | Server | Client |
|---|---|---|
| Windows 10+ x64 | ✅ | ✅ |
| Linux x64 | ✅ | ✅ |
| Linux ARM64 | ✅ | ✅ (CI only) |
- Go 1.26+ (server)
- Node.js 24+ (client) — pinned in
Client/.nvmrc;engine-strictmakes a wrong major a hard failure, not a warning - Rust / Cargo (Tauri client — not needed for server-only work)
- Docker + Compose v2 (optional — alternative to building the server locally)
Available Commands
Root facade — one entry point
From the repository root. These orchestrate the per-stack commands below; they
are a convenience, not a replacement. Nothing here needs make, and everything
works the same on Windows, macOS and Linux.
| Command | Description |
|---|---|
npm run bootstrap |
npm ci in all three package roots |
npm run check |
Everything CI gates on: server, client, Rust |
npm run check:server |
Server only — build variants, vet, race, deadlock, lint, generated-output drift |
npm run check:client |
Client only — typecheck, lint, format, unit + integration tests |
npm run check:rust |
Tauri backend — cargo test --lib and clippy |
npm run check:docs |
Fail if a watched document contradicts the ledger's finding counts, or the ledger fails to render |
npm run format |
Prettier over the client, gofmt -w over the server |
npm run generate |
Regenerate protocol constants and the sqlc query layer |
npm run release:preflight |
check plus a client production build |
node scripts/run.mjs --list |
Print the exact command every task runs, and where |
Tools CI installs but you may not have — golangci-lint, sqlc — are skipped
with a printed reason rather than failing the run.
Working on the server only? You never need Node. The facade prints each command it runs and the directory it runs it in; those are the commands in the next section, and using them directly is equally correct.
Server (Go)
| Command | Description |
|---|---|
go build -o chatserver.exe -ldflags "-s -w" . |
Build server binary (Windows) |
CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w" . |
Build server binary (Linux) |
go build -tags otel . |
Build with OpenTelemetry SDK (requires go get first — see Phase B) |
go build -tags wazero . |
Build with Wazero plugin runtime (requires go get first — see Phase C) |
go test ./... |
Run all server tests |
go test ./... -cover |
Run server tests with coverage |
go test -race ./... |
Run server tests with race detection |
Make targets (run from Server/):
| Command | Description |
|---|---|
make test |
Run the test suite the way CI does (-race, 20 min timeout) |
make test-deadlock |
Run the deadlock-detection pass CI also runs (-tags deadlock) |
make cover |
Per-package coverage (what CI uploads) + a function summary |
make cover-all |
Cross-package coverage — the honest number (also lists 0.0% functions) |
make sqlc-install |
Install the pinned sqlc version into $GOBIN |
make sqlc-generate |
Regenerate the type-safe Go query layer (db/dbgen/, SQLite engine) |
make sqlc-verify |
Fail if the committed dbgen output is stale (used by CI) |
make protocol-generate |
Regenerate the WS message-type constants (Go + TS) from protocol/schema.json |
make protocol-verify |
Fail if the committed protocol constants are stale (used by CI) |
make otel-up |
Start Jaeger (traces) + Prometheus (metrics) via Docker for local OTel development |
make otel-down |
Stop and remove the OTel dev containers |
Client (Tauri v2)
Build & dev
| Command | Description |
|---|---|
npm run dev |
Start Vite dev server with hot reload |
npm run build |
TypeScript check + Vite production build |
npm run tauri dev |
Launch Tauri app in dev mode |
npm run tauri build |
Build release installer (NSIS on Windows, AppImage+deb on Linux) |
Tests
| Command | Description |
|---|---|
npm test |
Run all tests (vitest) |
npm run test:unit |
Unit tests only |
npm run test:integration |
Integration tests only |
npm run test:contract |
Cross-component contract tests only |
npm run test:e2e |
Playwright E2E (mocked Tauri) |
npm run test:e2e:native |
Playwright E2E (real Tauri exe + CDP) |
npm run test:e2e:admin |
Playwright E2E (real Go server + SPA) |
npm run test:e2e:prod |
Playwright E2E (prod build) |
npm run test:e2e:ui |
Playwright UI mode |
npm run test:watch |
Vitest watch mode |
npm run test:coverage |
Coverage report |
npm run test:mutate |
Stryker mutation testing |
npm run test:mutate:dry |
Stryker dry-run (no mutations applied) |
npm run test:browser |
Vitest browser-mode tests |
Type checking, linting & formatting
| Command | Description |
|---|---|
npm run typecheck |
Full typecheck (all sources) |
npm run typecheck:build |
Typecheck build config only |
npm run lint |
oxlint + ESLint check (src/) |
npm run lint:fix |
ESLint auto-fix |
npm run lint:ox |
oxlint only (fast correctness checks) |
npm run format |
Prettier format (src/ + tests/) |
npm run format:check |
Prettier check only (no writes) |
npm run knip |
Dead code and unused export detection |
Git hooks (recommended)
Committed hooks in .githooks/ catch the most common CI failures locally. Enable once per clone (from the repo root):
npm run hooks:install # = git config core.hooksPath .githooks
| Hook | What it runs |
|---|---|
pre-commit |
gofmt + go vet (when Go files staged), oxlint + prettier + tsc --noEmit (when client TS staged), sqlc-verify / protocol-verify (when their inputs staged) |
pre-push |
Server build in all build-tag variants, client typecheck + type-aware ESLint. Set OWNCORD_PREPUSH_TESTS=1 to also run go test -race ./... |
Bypass with --no-verify or OWNCORD_SKIP_HOOKS=1 when needed — CI still enforces everything.
Neither hook needs make, and neither needs Node for the Go checks.
core.hooksPath is exclusive. Once set, Git resolves every hook against
.githooks/ and never looks in .git/hooks/ again. .githooks/ holds only
pre-commit and pre-push, so hooks:install silently disables any other
hook you installed there (post-commit, post-checkout, ...). Nothing warns
you. Put it under .githooks/ instead (untracked, so it stays yours), or skip
hooks:install and use npm run check before pushing.
Plugin Development
Plugins are WASM modules loaded at runtime when the server is built with -tags wazero.
The plugin ABI is experimental and carries no compatibility promise. The subsystem is disabled twice over — it compiles only under
-tags wazero(Server/plugin/sandbox_default.go), andplugins.enableddefaults tofalse(Server/config/config.go) — and the five exported functions may change or be removed in any release without a deprecation period.
See Server/plugin/examples/hello/README.md
for the ABI, the build command, and the pinned TinyGo/Go/Binaryen versions. That
file is the single source of truth for the plugin toolchain — this page used to
carry a second copy of the version table, and the two had already drifted apart
in wording.
The example's .wasm is not checked in: TinyGo embeds absolute host paths from
the building machine and offers no -trimpath, so its output is not
byte-reproducible and no CI job can verify it. Build it locally from the source
beside it.
Reporting problems, and where things go
Bugs, questions and vulnerabilities have three different destinations, and the difference matters most for the third.
| Kind | Where |
|---|---|
| A reproducible bug | Issues — the form asks for the environment detail needed to reproduce it |
| A question about setup or usage | Discussions → Q&A |
| An idea or feature suggestion | Discussions → Ideas — not an issue |
| A security vulnerability | Private advisory |
Security reporting
Never open a public issue, pull request, or discussion for a security bug. Use private security reporting; SECURITY.md is the canonical policy and states the response timeline, and docs/security.md says what stays private and for how long.
This repository is public, so a commit message, a PR description and a branch name are all disclosure channels. If you are fixing something you believe is a security problem, say so in the private advisory first and let the fix be coordinated — do not describe the weakness in the public change that repairs it. The same applies to weaknesses in the repository's own automation and settings, not only to bugs in the server or client.
Branch and PR model
This section is the single source of truth for the branch model. Everywhere
else -- the root README.md, CLAUDE.md, the PR template -- summarises it and
links here rather than restating it.
dev-- the integration branch. All contributions targetdev.main-- releases only.devis merged tomainfor a release, and release tags are cut frommain.
dev is protected and PR-only: direct pushes are rejected, twelve status checks
are required, required_approving_review_count is 0, and the rule is enforced
on admins. So a PR is self-mergeable once CI is green, but no commit reaches
dev without CI having run on it. Settings and rationale live in
docs/plans/b0-dev-branch-protection.sh.
Two consequences worth knowing before you open a PR:
- The Docker and Tauri Full Build jobs are gated on
mainand report as skipped on a PR intodev. That is expected, not a failure. - Squash merge, and a conventional commit subject on the squashed commit.
Branch Naming
feature/<name>-- new featuresfix/<name>-- bug fixesdocs/<name>-- documentation changes
Commit Format
Use conventional commits:
feat: add thread support to channels
fix: prevent duplicate WebSocket connections
refactor: extract permission checks into middleware
docs: update quick-start guide
test: add integration tests for invite flow
chore: bump Go dependencies
perf: cache role permissions in memory
ci: add lint step to GitHub Actions
For anything non-trivial the body carries the reasoning, not a restatement of
the diff: what was wrong, why the obvious fix is wrong, what was done, concrete
numbers, and a Verified: paragraph proving both directions — that the defect
was present before and is absent after.
End with an explicit Not included: line naming adjacent scope you
deliberately left out, and why. A written deferral is a deliverable: it is what
separates considered-and-declined from silently-missed, and it means adjacent
work you spotted mid-change does not have to become either scope creep or a
blocking question. Put it in the commit that noticed it.
Pull Request Process
See Branch and PR model above for what to branch from and target.
- Branch from
dev - Open the PR against
dev - All twelve required checks must pass --
devis protected, so a red PR cannot merge - Request code review
- Squash merge, conventional commit subject
Testing
The client suite enforces 70% coverage thresholds in vitest.config.ts;
the Go suite has deliberately no floor (T-2026-07-25-19) — use make cover-all
to see the honest cross-package number. Follow a test-driven workflow and never
lower a threshold to make a change fit.
Tiers
| Tier | Command | CI job | Blocking |
|---|---|---|---|
Client/tests/unit |
npm run test:unit |
Client Unit Tests | yes |
Client/tests/integration |
npm run test:integration |
Client Unit Tests | yes |
Client/tests/contract |
npm run test:contract |
Client Unit Tests | yes |
Client/tests/browser |
npm run test:browser |
— | no |
Client/tests/e2e |
npm run test:e2e |
Client E2E (Playwright) | yes |
Client/tests/e2e @parity |
— | Client E2E (parity subset, blocking) | yes |
Client/tests/e2e/native |
npm run test:e2e:native |
— | no |
Client/tests/e2e/admin |
npm run test:e2e:admin |
Admin Panel E2E (real server, non-blocking) | no |
Server/**/*_test.go |
make test |
Server Build & Test | yes |
Client/src-tauri |
cargo test --lib |
Rust Unit Tests | yes |
npm test — not npm run test:unit — is what CI runs and what
npm run check:client invokes, so it is the command that covers
tests/contract.
What belongs in tests/contract
A test is a contract test when its assertions read, import or execute an
artifact owned by a different top-level component (Server/, Client/, root
protocol/) than the one its runner lives in. A comment referencing the other
side does not count.
- Placement follows capability, not ownership. A contract test lives in the
tier whose runtime can execute or parse the artifact. If the owning component
can execute it, it stays in that component's own suite —
Server/updater/tauri_key_contract_test.goreadsClient/src-tauri/tauri.conf.jsonand stays in Go, because Go parses JSON fine and the assertion is about a server constant. - Ownership is declared in the name, never in the directory. The file name
and the top-level
describe/Testname must name the owned artifact's path. - A contract test may only live in a blocking tier. A non-blocking job is
not coverage.
Admin Panel E2Eiscontinue-on-error: true(.github/workflows/ci.yml), so it is ineligible however well it fits topically — until it graduates. Client/is one component: its TypeScript frontend and its thin Rust backend insrc-tauri/are the same side of the boundary, so atests/unittest that readssrc-tauri/tauri.conf.jsonis an ordinary unit test. The same goes for a Go test reading its own package's embedded assets (Server/admin/perm_grid_test.go).
E2E is runtime coupling rather than artifact coupling; it stays in tests/e2e.
If a tier ever gains a runner of its own, model its anti-vacuity guard on
Server/invariants/invariants_test.go — it fails loudly when a configured scope
resolves to nothing, rather than passing on an empty set.
Code Style
- TypeScript: See Client Architecture
- Go:
gofmt+golangci-lint, standard library preferred - Rust:
cargo fmt+cargo clippy, minimal code (native APIs only)
Dependency Policy
The policy behind what the lockfiles already enforce (decided 2026-08-05, closing audit findings 2026-04-07 #8 / DC-11):
-
Lockfiles are authoritative.
package-lock.json,go.sumandCargo.lockpin every transitive dependency; CI installs only from them (npm ci, module/registry verification — never a barenpm installin CI or hooks).package.jsonkeeps ordinary caret ranges: exact-pinning it would duplicate what the lockfile does while making every security patch a manual edit. -
Upgrades arrive as reviewed PRs, not ambient drift. Dependabot runs weekly per ecosystem (
.github/dependabot.yml) with semver-major updates ignored across the board — majors are adopted deliberately, by a human, reading the changelog. Peer-coupled groups (vitest/@vitest/*,@stryker-mutator/*) update as one PR so exact peer pins cannot wedge. -
Security gates run on every PR:
npm audit --omit=dev --audit-level=high(shipped deps only — dev-tooling advisories are triaged in the workflow comment instead of blocking on unfixable pins),govulncheckfor Go,cargo auditfor Rust, andkniprefuses unused client dependencies outright. -
Version skew is pinned at the toolchain level too:
Client/.nvmrc, everyactions/setup-nodein CI, and anenginesblock in all threepackage.jsonfiles say Node 24 — withengine-strict=truein each package's.npmrc, so a wrong major fails the install instead of warning.Server/sqlc.versionpins sqlc, Go pins viago.mod(GOTOOLCHAIN=auto), and GitHub Actions are SHA-pinned with Dependabot bumping the pins. The one deliberate exception is the plugin toolchain: TinyGo and Binaryen are documented rather than file-pinned, because no gate installs them and nothing would read the pin — the example plugin's README is their single source of truth. -
Three package roots, not an npm workspace — measured 2026-08-26 (npm 11.17, Node 26), not decided on principle. Making
/,/Clientand/tools/mcp-introspectnpm workspaces buys one 298 KB lockfile instead of three (17 KB / 253 KB / 42 KB) and dedupes 614 resolved packages to 582 — 32 packages, 5.2%. Client install time is unchanged: 5642 ms against 5667 ms. The things you would expect to break do not:npm ciinsideClient/still exits 0,npm run <script>still resolves the hoisted binaries (npm prepends every ancestornode_modules/.bintoPATH), andengine-strictstill fails the install on a wrong Node major. The costs that are real:- Ten CI steps key on
cache-dependency-path: Client/package-lock.json— six inci.yml, four in the tag-only, CI-ungatedrelease.yml. That file stops existing, and four of the ten have no gate that would catch it. - Repository Hygiene installs root-only on purpose (prettier is all it
needs). Under workspaces that grows 970 ms → 6172 ms and 39 → 318
packages, unless every call site gains
--workspaces=false— the mitigation works (1112 ms, 38 packages) but has to be remembered forever. - One lockfile puts all three npm Dependabot groups back into the same file.
They rewrite three disjoint files today; undoing that reinstates the
merge-then-rebase-then-re-run-CI storm the grouping comment at the top of
.github/dependabot.ymlexists to prevent.
Thirty-two deduped packages does not pay for that. The roots stay separate,
npm run bootstrapinstalls all three, and Dependabot covers all three. - Ten CI steps key on