# Contributing Not every contribution is code. Opening an issue is one, and there are two templates waiting to guide you through it, [Bug report](https://github.com/MorganKryze/cairn/issues/new?template=bug_report.md) and [Feature idea](https://github.com/MorganKryze/cairn/issues/new?template=feature_idea.md): each asks the few things that turn a report into something actionable. ## Dev loop ```sh go run ./src/cmd/cairn -config example # http://localhost:8080, live-reloads example/ go test ./... docker compose -f docker/compose.yaml up --build # what CI and users get ``` The Docker build runs `go vet` and the test suite; if the image builds, it passed. With [just](https://just.systems) installed, `just` lists the shortcuts: `test`, `test-browser`, `coverage`, `lint`, `build`, `chart`, `demo`, `demo-rebuild`, `down`, `logs`, `shots`, `hooks`, `icons`, and for a release `sweep `, which compares every response against that tag, and `verify `, which reads back what the release actually published. Linting is [golangci-lint](https://golangci-lint.run) with a near-default config (`.golangci.yml`); CI runs it on every code push. Run `just hooks` once: it points git at `githooks/`, whose pre-commit runs gofmt, vet and the tests when Go files are staged, lints staged workflows, checks staged Markdown with [prettier](https://prettier.io), and enforces a couple of house style rules on the way. Every one of those tools is optional: the hook runs what it finds and skips what you have not installed, so a missing prettier costs you a nudge from a reviewer rather than a blocked commit. Formatting is checked, never applied behind your back; when it complains, it prints the `prettier --write` line to run. `.prettierrc.json` turns off formatting inside fenced code blocks, because the examples in `docs/` are the product and a formatter has no business rewriting the YAML someone is about to copy. ## Layout ```text src/cmd/cairn/ wiring only: flags, startup, hand off to the server src/internal/ config/ read and validate the YAML; depends on nothing render/ config to bytes; templates/ and assets/ embed here status/ the Gatus client server/ routes, handlers, probes, the two background loops check/ backs -check: validates like a boot would testutil/ the one helper shared by every package's tests docker/ Dockerfile (FROM scratch) and the hardened compose charts/cairn/ the Helm chart, same objects as docs/deployment/kubernetes.md example/ the config served by the dev loop and the docs scripts/ the browser tests and their fixtures, icons, screenshots docs/ GitHub-native Markdown, no generator ``` The dependency graph runs one way: `config` knows nothing, `render` and `status` know `config`, `server` knows all three, `main` wires them. Keep it that way. A test lives beside the package it exercises; the two that genuinely span a boundary use an external `_test` package rather than pulling a dependency backwards. ## Ground rules - **Scope.** cairn is a directory, not a control panel: no auth, widgets, Docker integration, metrics, admin UI, database or per-user state. PRs adding those will be declined kindly, with a pointer to [docs/comparison.md](docs/comparison.md). - **Config errors are product.** Anything a user can get wrong in YAML must fail with the file, the line, the problem and the expected shape. A test proves it. - **Docs are product.** A key without documentation, or a docs page that doesn't copy-paste-run, is a bug. Update `docs/` in the same PR. - **Dependencies.** The stdlib first. There are two: `yaml.v3`, and `goldmark` for the markdown, which replaced a hand-written parser whose failure mode was wrong HTML rather than literal text. A third needs the same argument: what it buys, what it costs in bytes, and why the standard library cannot. - **Accessibility and i18n are not optional.** New UI text goes through the strings table (both built-in locales), keyboard and contrast stay intact. ## Add your language The friendliest first contribution: cairn's interface text lives in `src/internal/config/locales.go`, one small block per language, two dozen short strings. Copy the English block, translate it, and open a pull request; a test checks completeness, so if `just test` passes, your table is done. Regional variants only need their own block when they genuinely differ (`pt-BR` already finds `pt`). ## Commits One imperative line, scoped, short: ```text config: merge multiple service files docs: quickstart readme and example config ``` No trailers, no bodies unless the _why_ genuinely needs a sentence. **On your first pull request the checks will sit there doing nothing**, and that is normal rather than something you did wrong: this repository asks a maintainer to approve workflow runs coming from a fork the first time, so nobody can spend its CI by opening a pull request. One click here starts them, and after that yours run on their own. ## The browser tests A handful of behaviours are what `go test` cannot reach: the Go tests assert the markup that ships, not what happens once someone types, clicks or scrolls. `just test-browser` drives them in a real browser and CI runs the same scripts. They pull nothing from a registry, so unlike the demo job they are safe to make a required check. `scripts/search.mjs` drives the search against `example/`. Anything you change in `search.js` belongs there, particularly the paths back to an empty query: clearing the field, Escape, and typing only spaces all have to return the full list rather than "no results". `scripts/a11y.mjs` drives what no markup assertion can see: the theme toggle's `aria-pressed`, the 3:1 boundary on the controls a visitor can operate, the mobile category swap with JavaScript on and with it off, and the burger menu surviving a scroll while the keyboard is inside it. The last two need a site `example/` cannot provide, so they run against `scripts/fixtures/many-categories/`: more than seven categories, which is where the chip row gives way to a select, and enough header links to raise the burger. On the home, detail, hosted and not-found pages of the demo's config and of German, at 390 and 1280px, in light, dark and dark by the toggle, it also Tabs through every stop and asks each for cairn's ring, 2px solid in `--accent-ink` (the Open button's is `--fg`, and the search field turns its box's border to `--accent-ink` instead) at 3:1 against what the ring sits on. German runs on a darker accent, `#8a4f7d`, whose raw ring measured under 3:1 in dark. It types into the search field on both homes and holds the border of the match the keyboard selects, the only sign of the focus while it stays in the field, to the same: `--accent-ink`, 3:1 against the card and the page. On the same pages it holds every painted text to 4.5:1 against what is really behind it, and the glyph or the frame of a control that shows no words to 3:1, and it checks that reduced motion stops the note's chevron and the screenshot's dimming. `scripts/status.mjs` drives the one thing a static page cannot do on its own: a status pill changing in a tab nobody reloaded. It runs against `scripts/fixtures/status/`, whose Gatus address is a port nothing listens on, and answers the refresh itself with cairn's own markup, so the test needs no second server and cannot go green on HTML it wrote itself. It uses Playwright's fake clock, so a five-second poll costs no seconds. `scripts/layout.mjs` drives where things land: no page pushed sideways at 360, 390, 1280 and 1440 px, in light, in dark from the system and in dark from the toggle; the header (a 34px mark, the rule and its accent diamond on the content column, a long site name on a row of its own on a phone, 44px controls whose names contain the words they show); the welcome note folded to one row on a phone (it opens with or without JavaScript, remembers nothing, prints whole and shows whole on a wide screen, and the first card starts within 420px at 390 by 844); the search field (the English and French placeholders whole on a wide screen, no language's field narrower than before, and on a phone no Cmd-K hint and an ellipsis where a placeholder is longer than the field); the card foot (the hosting word painted at rest, the foot on a row of its own under the icon and the text, every hosting and status word of a language on one row at 360, a second chip that wraps whole, a long name balanced with its lock after the last word, the demo's page and cards short at 390, hit areas that never overlap, and a tap within 21px of a linked flag's centre that follows the flag); the detail head on a phone (Open under the name at the tile's edge, then the pill and the state on one line, no divider opening a wrapped line) and a hosted page on a phone (no rule after the title, a 44px back link); the screenshots (on a phone a wide one 272px high in a strip that scrolls and fades on the side it continues, right to left too, one that fits filling the column; nothing scrolling on a wide screen; a 44px link to the whole image under the caption; dimmed in dark until hovered; whole when printed); every link and control on a home, detail, hosted and not-found page a 44 by 44px target that answers at the middle of each edge and overlaps no neighbour, the skip link focused, the leaving dialog, the open menus and the back-to-top button included (a link inside a sentence of the operator's text and the card name, whose link is the whole card, are the two exceptions); Arabic mirrored (the header's diamond at the column's right edge, the hosting flag at the card's right with the pill at its left, the detail line reading Open, pill, state from the right); every page at 360 with JavaScript off (nothing pushed sideways, the note's row opening and folding, no link without an `href`); and the layouts that have to stay as they are (a bare header, a card with nothing at its foot, the detail head on a wide screen, a service that is not open). It runs against the demo's own config (`demo/config`, served without the demo stack, so every pill reads unknown), against `scripts/fixtures/longname/`, a site name too long for one header row, against `scripts/fixtures/locales/`, every built-in language plus Arabic, which turns the page right to left, and for the targets against `example/`, `scripts/fixtures/many-categories/` and `scripts/fixtures/leave/` too. `foot-words.json` there lists every hosting and status word, and a Go test holds it to the strings table. **Prove a browser check red before you trust it**, by deleting the thing it guards and watching it fail. This is not ceremony here, it is the single most common way a check in this file turns out to be worthless, and it has happened three times: - a check that the dialog closes on Escape passed with its script deleted, because a dialog that never opened is already closed. It asserts the dialog is open before pressing anything now. - a check that a button "has a focus outline" passed with the rule deleted, because the browser paints its own. It asserts the ring cairn draws, `solid 2px`, against Chromium's `auto 1px`. - a check that three buttons shared a row compared their top edges, and failed on a layout that was correct: `align-items: center` centres controls of different heights, so their tops legitimately differ. It measures the row's height now. The pattern in all three: the assertion was satisfied by the platform, or by the absence of the feature, rather than by the feature. If deleting the code leaves the check green, the check is decoration. **Do not leave a server behind.** The recipe serves every config, on 8090 to 8095 and 8100 to 8102, and kills them on the way out, and the guard at the top refuses to start if something already answers. Servers you start by hand while measuring have no such guard, and a leaked one is not merely untidy: one bound to `:8080` with no `-addr` shadowed the demo through IPv6 for ten minutes while `127.0.0.1` reached the real thing, and a second that failed to take a port left the first answering, which quietly invalidated a measurement. Give every ad-hoc instance its own port and kill it in the same command. The CI job stays named `search` whatever else it grows to run: it is a required check on a protected branch, and a required check under a new name never runs, which leaves every pull request waiting for a job that no longer exists. ## The icons Every icon comes from one drawing, in `scripts/icons.mjs`. `just icons` regenerates the lot: the five files the binary embeds and serves, and the three in `docs/assets/brand/` that the icon collections ask for, cropped to the mark rather than to its padded square. Running it on an unchanged mark rewrites the same bytes, so a diff after `just icons` means the drawing moved. Change the mark there and nowhere else. The same shapes are inlined in `layout.tmpl` as the icon a service without one falls back to, and `TestTheMarkIsDrawnTheSameInBothPlaces` fails if that copy stops matching `favicon.svg`; it has drifted a whole release before. The script also checks what the manifest promises: the app icons are declared `maskable`, which tells Android nothing lives outside the middle 80% of the square. It measures the rendered ink and exits non-zero if a redrawn mark breaks that. ## Releasing `just shots` regenerates the README hero, light and dark, from the running demo. Run it before every release: the screenshot is the first thing anyone sees, and it goes stale quietly. Playwright installs itself into a gitignored `node_modules/` on demand, so nothing is added to the repository. **Compare against the tag, not against `main`.** Before cutting a version, build the previous tag in a worktree, serve the same configs on the same port with both binaries in turn, and diff every response with the `Date` header stripped. Then say in the notes what actually moved. That is not a formality. Comparing against `main` is comparing a thing with itself, since whatever you are about to release is already merged there, and doing it that way hid a dropped `.btn:focus-visible` for an hour. Done against the tag it has twice been the thing that found a regression no test had: that rule, and an `imagetools` copy that silently rewrapped a single-manifest image under a new digest. The chart needs nothing at release time. CI packages it with the tag as both `version` and `appVersion`, so chart 1.2.3 installs cairn 1.2.3 and the numbers in `Chart.yaml` only matter to someone installing from a checkout. Changing the chart means changing [docs/deployment/helm.md](docs/deployment/helm.md) in the same PR: `values.yaml` is a public API, and renaming a key breaks installs. ## Conduct Be decent, assume good faith, critique the work and not the person. The full version is in [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).