CONTRIBUTING.md
Rama main · réplica de lectura del repositorio original.
# 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 <tag>`, which compares every response against that tag, and
`verify <version>`, 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.
A pull request runs only the checks its files concern, and the others report
as skipped. A change under `docs/` or to the README runs none, any
file under `src/`, templates and golden files included, runs the linter, the
tests, the browser suite and the image, a script under `scripts/` runs the
browser suite and the image, and `charts/` runs the chart job. Touching a
workflow runs everything. CodeQL runs on every pull request.
`.github/changes.sh` holds the list, `just changes-test` checks it.
## 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).