Releases: MorganKryze/cairn
Release list
v1.16.1
Polish, all of it measured. Nothing to change in your config.
🐛 Fixes
Hovering a control on a card now fills it. The old treatment moved the border from 2.04:1 to 2.82:1 against the card and changed nothing else, which is invisible next to the card lifting three pixels at the same moment. There is a structural reason it has to be a fill: the card is one big link, so the pointer is already a hand everywhere on it and says nothing extra when it reaches a control.
The fill is --accent-ink, the accent already adjusted per theme to read against a card, which measures 6.82:1 in light and 5.47:1 in dark, with the mark on it at the same ratios. A solid accent would have measured 4.83:1 in light but 3.26:1 in dark, and a white mark on it would break for anyone who picks a pale accent. The rule covers all three controls a card can carry: the detail glyph, the status pill and a linked hosting flag.
The info glyph sat a pixel off centre, and only on some cards. Reported from a running page, reproduced, and the cause is worth writing down. A card grid lands on fractional track widths, 325.33px on the demo, so every card sits at its own subpixel phase: .438 for one, .766 for the next, .109 for the third. The ring is painted on the button's box edges and the glyph was centred inside it with a fractional gap of 6.406px, so the two rounded to different sides: the glyph read half a pixel left on one card and half a pixel right on the next.
Whole numbers on both sides is what stops it. A 16px glyph in a 26px box leaves 6px each side, and the measured disagreement goes from 1 device pixel to 0, on every card and every phase.
And the glyph itself was low. Its stem carries about three times the ink of its dot, so the mass sat below the circle's centre by 0.85% of the height. Measured by rendering it alone at 240px: 2.04px low, 0.04px after shifting both marks up by a fifth of a unit.
🧹 Internal
Three properties are held by the browser suite from now on, each proven to fail first: the hover fill and the mark on it, the whole-pixel gap between glyph and ring, and the fact that the check finds a control to hover at all rather than passing quietly on a page with none.
Two false leads are recorded in the pull request, because both looked convincing for a while: an integer glyph size and whole-pixel insets each appeared to make the centring worse, when what they were really moving was Playwright's element screenshot, which pads the image when the box lands on a fraction. The number followed the padding, not the glyph.
📚 Docs
The README gives each section and each feature an icon to land on.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.16.1This release also moves 1.16, 1, stable and latest. The chart is at oci://ghcr.io/morgankryze/charts/cairn version 1.16.1, signed the same way:
cosign verify ghcr.io/morgankryze/cairn:1.16.1 \
--certificate-identity-regexp '^https://github.com/MorganKryze/cairn/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comv1.16.0
Two controls on the page were too small to hit, measured rather than felt, and both are fixed. The chart ships a values file that installs a cairn with something on it.
✨ Features
A values file for the Helm chart. helm install with the defaults gives you a cairn serving its own getting-started page, which is right for a first install and shows you nothing about what the page looks like with services on it:
curl -O https://raw.githubusercontent.com/MorganKryze/cairn/main/charts/cairn/values-example.yaml
helm install cairn oci://ghcr.io/morgankryze/charts/cairn -f values-example.yaml
kubectl port-forward svc/cairn 8080:80Two locales, three ordered groups, six services, a welcome note and a legal page. Every service points at a public site, so nothing is dead on arrival, and nothing in the file is example-only syntax: replace the services and it is a real config.
It cannot rot quietly either. A test reads the same config: block the ConfigMap will carry, writes it out, and puts it through cairn's own loader and its own -check, then asserts the example stays exemplary: at least four services, two groups, two languages. It earned that on the first draft, where an unquoted comma inside a { } split a description in two, exactly the trap cairn's own docs warn about.
🐛 Fixes
The card's "Learn more" was 90 by 16. WCAG 2.2 asks 24 by 24 of anything you can click. It is a 28 by 28 glyph in the card's corner now, with the accessible name on the anchor, aria-label="Notepad, Learn more", because a glyph says nothing on its own and a page of identical "Learn more" links is a link list nobody can navigate. The card.more string is unchanged and still translated in all seven languages.
Nine variants were rendered on a running cairn and measured before choosing. The deciding measurement was one nobody had made: on a touch screen the host flag is unfurled, so a chip in the card foot squeezed it and "Self-hosted" came out as "Self-" at 320px, and "Sel" at 280px. Protecting the flag instead cut the status pill to "Onli". The corner is the one part of a card that belongs to nothing else.
The back link on a detail page was 51 by 23. It is a 72 by 33 chip now, wearing the same rounded outline the pills and the flag already wear, so a visitor meets one shape rather than three. Its arrow used to be a CSS ::before, which some screen readers read out before the word and which a stylesheet that fails to load takes with it; it is an inline svg marked as decoration now, and right-to-left mirrors that one glyph rather than swapping in a second character.
Both were measured on both themes and both are held by the browser suite from now on: 28 by 28 with the glyph at 5.87:1 and 6.15:1 against the card, 72 by 33 with its words at the same ratios against the chip, where the rules ask 3:1 and 4.5:1. Each check was proven to fail first: a 20px glyph, a --faint one, and the chip flattened back to a bare link.
🧹 Internal
Two existing tests moved with the markup, both toward something simpler. The language mark on a description that fell back to another language now sits on the description itself, rather than on an inner span that existed only because the words "Learn more" shared that element. And a card with no description renders no card-desc at all, where a detail link alone used to create one.
The four golden pages were regenerated for the first time since they were pinned, deliberately and with the diff read line by line: twenty lines on the home pages for the glyph, two on the detail pages for the arrow, and the CSP untouched, which is what a change to a card and a header should look like.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.16.0This release also moves 1.16, 1, stable and latest. The chart is at oci://ghcr.io/morgankryze/charts/cairn version 1.16.0, signed the same way:
cosign verify ghcr.io/morgankryze/cairn:1.16.0 \
--certificate-identity-regexp '^https://github.com/MorganKryze/cairn/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comv1.15.0
Ten monitors, not one. cairn reads Uptime Kuma, any status API shaped like a list of names and states, and Gatus exactly as before. Nothing in your config needs touching: a site that loads today loads identically after this, and the test that pins four rendered pages byte for byte stayed green through all six pull requests behind this release, without ever being regenerated.
✨ Features
status.provider names the monitor behind status.url. status.gatus keeps its own key and its own meaning, so every site running today is untouched and needs neither.
# site.yaml
status:
provider: kuma
url: https://kuma.example.org
slug: tools # the published status page, from its own URLUptime Kuma is read through the two endpoints a published status page answers without a login. Kuma keys its heartbeats by a numeric monitor id and puts the names in a second document, so the provider is a join, which is why no mapping could have expressed it. There is no cairn -emit-kuma and there cannot be one: Kuma has no config file and no setup API, which was measured rather than assumed, since standing up the instance this was tested against took a script driving a browser.
Anything else with a status API, through a mapping that says where to look:
status:
provider: json
url: https://www.githubstatus.com/api/v2/components.json
map:
list: components
key: name
state: status
up: [operational]
degraded: [degraded_performance, partial_outage]
maintenance: [under_maintenance]A path is a dotted walk and nothing more. The value lists are allow-lists, so a state in none of them reads as down: a vendor adding a word next year cannot make a broken service look green. unknown is the exception, read first, for the values that mean nobody is checking.
Ten monitors were read from a live instance rather than from a manual, five self-hosted and five hosted, each with the mapping it took and the count that came back: Gatus, Uptime Kuma, Cachet, Statping-ng, Upptime, Atlassian Statuspage, Instatus, UptimeRobot, Better Stack, StatusCake. Which monitors cairn reads also says what should work, and what cannot be read at all with the reason for each.
Two more pills. Degraded, amber, for a service that works but not well, and Maintenance, blue and square rather than round, for one that is off on purpose. Chosen by counting what the monitors actually declare rather than by taste: of eight surveyed, four distinguish degraded and four maintenance, while pending and paused are two ways of saying what the neutral pill already said.
Both labels ship in all seven built-in languages, and both dots were measured against the pill they sit in on each theme: degraded 4.29:1 and 5.95:1, maintenance 4.99:1 and 5.85:1, where WCAG 1.4.11 asks 3:1. The maintenance dot is square as well as blue, because blue against green is the pairing most often indistinguishable and the dot is nine pixels across.
Gatus reaches neither, since a Gatus result carries a pass or a fail and nothing else. That is what makes this release mechanically additive rather than carefully so.
status.token_file names a file holding a bearer token, never the token itself, which is the shape a Kubernetes Secret, a docker compose secret and a Vault agent all deliver. It is read on every poll, so a rotated token needs no restart, and it travels in an Authorization header rather than in a URL, which is what keeps it out of cairn's own poll-error log. status.token_scheme defaults to Bearer; OAuth and Basic work too.
A token file under /assets is refused at load. That directory is served to every visitor, verified rather than assumed, so a token there would be published rather than stored. status.ca under /assets stays legal, and the asymmetry is the point: a CA certificate is the public half of an authority.
🐛 Fixes
A paused monitor was drawn as an outage. Found by pointing cairn at a real UptimeRobot status page, where 5 of 15 monitors read paused and every one of them came out red. status.map.unknown names those values now and the service gets no pill at all, which is what cairn's neutral state has meant since the beginning.
A state that is not a word was unreadable. Statping-ng answers a boolean and Cachet a number. JSON has three scalar types and monitors use all three, so up: [1] and up: [true] now read as you would expect.
The unmonitored-services log named Gatus whatever was being polled, which sends a Kuma user looking for a Gatus they do not have. Found by reading the log of a real cairn pointed at a real Kuma.
🧹 Internal
State.Up bool became State.Level string with four values, and Fetch dispatches through a registry rather than doing one thing. The config package keeps its own list of provider names, since it imports nothing of cairn's, and a test in the status package fails the moment the two drift: without it, a name in one list and not the other is either an error nobody can fix or a config that loads clean and fails every poll.
The whole change was built behind a characterisation test landed first, on purpose, which pins four rendered pages and the CSP byte for byte. It was never regenerated, and it went red exactly once, deliberately, to prove it bites.
Every test in this release was proven to fail before the fix it covers, by patching that fix out and reading the red. Three of those proofs failed to fail on the first attempt and had to be rewritten, which is the point of doing them.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.15.0This release also moves 1.15, 1, stable and latest. The chart is at oci://ghcr.io/morgankryze/charts/cairn version 1.15.0, signed the same way:
cosign verify ghcr.io/morgankryze/cairn:1.15.0 \
--certificate-identity-regexp '^https://github.com/MorganKryze/cairn/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comv1.14.0
Two ways to make the status integration fit a network that is not the internet: give cairn the authority that signed your Gatus without a ConfigMap, and stop Gatus publishing the addresses it probes. Nothing in your config needs touching, and nothing that loaded before is refused now.
✨ Features
status.ca names a PEM bundle cairn verifies Gatus against. One key, the two shapes the rest of the config already takes:
# site.yaml
status:
gatus: https://status.internal
ca: /assets/ca.crt # a file in the mounted assets directory
# ca: http://pki.internal/ca.crt # or an address cairn fetches it fromThe bundle is added to the roots the image already ships, not swapped for them, so a Gatus with a publicly signed certificate keeps working and adding a bundle narrows nothing else. Until now the only answers were mounting a file over the image's own trust store, or status.insecure, which does not verify at all.
http:// is allowed on purpose. A bundle cannot be served over a certificate nobody trusts yet, so requiring https would rule out the case this exists for. It is not free, and it lands exactly where status.insecure lands: over http, whatever sits on the path to that address decides what cairn trusts for the poll. So it is loud in the same places, the startup log and every cairn -check:
warning: status.ca http://pki.internal/ca.crt is fetched over http, so whatever sits on the path to that address decides what cairn trusts for the poll, which is where status.insecure lands too (serve it over https, or mount it and point status.ca at an /assets path)-check also names a bundle that is not on disk, one that holds no certificate, and the case where status.ca and status.insecure are both set: verification is off then, so the bundle is never consulted and the site is the loose one rather than the strict one.
A fetch that fails is retried on the next poll rather than remembered, so a PKI host that comes up after cairn does is not a restart. A fetch that succeeds is cached, so the bundle is read once however long the process runs.
cairn -emit-gatus -hide-targets writes the Gatus ui block that keeps an endpoint's address off its own dashboard:
ui: { hide-url: true, hide-hostname: true, hide-port: true }All three, because they hide three different things, and because hide-url alone leaves the port. What is worth knowing: hide-url redacts the address inside the error text too, which is the leak that survives every test run against a healthy endpoint, since it only appears when a check fails.
Off by default. What -emit-gatus writes is the service's own public URL, the one already printed on its card, so hiding it by default would hide from the status page what the directory shows next to it. It earns its keep the moment you edit the emitted file to probe an address that is not the published one. The flag on its own refuses to start rather than doing nothing quietly.
None of this reaches cairn: the pills come from the endpoint name, its key and whether the last check passed, and no ui setting hides any of those.
🐛 Fixes
The air-gapped guide told operators to run Gatus on storage: { type: memory }. Gatus then loses every result when it restarts, its API answers with endpoints and nothing in them, and cairn reads that as "no results" and paints every pill Unknown until each endpoint has been checked again. The page now installs it with a claim and sqlite, with the reason written down, and the Gatus recipe says the same.
🧹 Internal
The /assets/… to file rule had grown three copies, one in the manifest measurement, one in -check, and nearly a third for the bundle. It lives in one place now, next to the mount path it resolves against.
Every test in this release was proven to fail before the fix it covers: the CA ignored, a bundle that would trust anything, a remembered failure, the missing cache, a PEM holding no certificate, a bundle absent from disk, hide-port dropped, and the flag ignored. One of them was proven to be worthless first: httptest.NewTLSServer hands every instance the same built-in certificate, so "the bundle still refuses everything else" was passing against two servers that shared one authority. The helper mints a real certificate per server now.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.14.0This release also moves 1.14, 1, stable and latest. The chart is at oci://ghcr.io/morgankryze/charts/cairn version 1.14.0, signed the same way:
cosign verify ghcr.io/morgankryze/cairn:1.14.0 \
--certificate-identity-regexp '^https://github.com/MorganKryze/cairn/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comv1.13.2
Two bugs in the status pills, both reported from a real deployment. Nothing in your config needs touching, and nothing is refused that was accepted before.
🐛 Fixes
The pill linked to a page Gatus does not serve. cairn built the endpoint URL out of its own category, so a service filed under documents linked to /endpoints/documents_pdf. That is right only for someone who ran -emit-gatus and kept its grouping. A Gatus config written by hand, with endpoints that are not grouped, serves /endpoints/_pdf, and the pill led to a page that does not exist.
Gatus reports the key of its own endpoint page alongside every status. cairn reads it now and links where Gatus says, so your endpoints can be grouped however you like, or not grouped at all. The old derived key survives as the fallback for the pills rendered before Gatus has answered, and for a Gatus too old to report one. Nothing to change: if you followed the recipe, you get the same URL you had.
A page left open never caught up. Start cairn before Gatus and every pill reads Unknown, which is correct. When Gatus came up, cairn learned it within the interval and every fresh visitor saw it, but the tab already open kept showing Unknown until someone reloaded. On a screen nobody touches, that is the whole feature failing quietly.
The poller was never the problem, and it is worth saying so: it recovers from a refused port, a container name that does not resolve yet, and a Gatus restarted underneath it, each one verified against a real Gatus. What did not recover is that cairn serves static HTML and nothing on the client ever asked again.
It does now, on the pages of a site that has a status.gatus. They refetch the page they are on at status.interval and swap the pills, nothing else. Three things about how it behaves:
- The request goes to cairn, never to Gatus, and never anywhere else. It is a conditional GET that comes back 304 most of the time.
- A tab in the background does not poll, and a pill your keyboard is on is left alone until you move off it.
- A site with no
status.gatusdoes not ship the script at all, and itsContent-Security-Policyis unchanged.
If you collect CSP reports, that is the one thing to expect: pages of a site with a status page now send connect-src 'self', which is what allows the request above. Everything else in the policy is what it was, default-src 'none' included.
🧹 Internal
A new browser test drives the swap against a fixture whose Gatus address is a port nothing listens on; it answers the refresh itself with cairn's own markup, so it needs no second server and cannot pass on HTML it wrote for itself. It runs in CI alongside the search and accessibility scripts.
Every test in this release was proven to fail before the fix it covers: the reported key, the escaped path segment, the pill slot, the missing script, the collapsed card foot, the focus guard, and the policy staying shut on a site with no status page.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.13.2This release also moves 1.13, 1, stable and latest. The chart is at oci://ghcr.io/morgankryze/charts/cairn version 1.13.2, signed the same way:
cosign verify ghcr.io/morgankryze/cairn:1.13.2 \
--certificate-identity-regexp '^https://github.com/MorganKryze/cairn/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comv1.13.1
One key, for the people whose internal Gatus answers with a certificate no public authority signed. Nothing else changes, and nothing in your config needs touching.
✨ Features
status.insecure stops cairn verifying the certificate Gatus presents. Gatus has had client.insecure per endpoint for a long time; cairn had no equivalent, so the only way out was mounting a CA bundle, which is not always the operator's to arrange.
# site.yaml
status:
gatus: https://status.internal
insecure: trueIt is a real hole and it is yours to open. With the check off, anything that can answer on that address decides what your pills say, and the day that certificate changes stops being visible. A CA bundle verifies instead of trusting, and Air-gapped still leads with it for that reason, on both sides of the connection.
So the flag will not let itself be forgotten. The startup log states it once, cairn -check warns on every run for as long as it is on and names the URL, and with no status.gatus it joins the keys that quietly do nothing and is reported as one.
🐛 Fixes
The status poll built a fresh http.Client on every interval, so a new connection pool each time and the previous one left to be collected with its idle sockets still open. One client each now, verifying and skipping, built once.
🧹 Internal
CodeQL flags InsecureSkipVerify as a high-severity finding, correctly: the code does disable certificate checking. The alert is dismissed with a written reason rather than suppressed inline or restructured out of the scanner's sight, so it stays visible in the security tab and GitHub reopens it if that code materially changes. The decision, and why loudness was chosen over silence, is in the pull request.
Tested against a server minting its own certificate rather than a mock, asserting both directions: verifying must fail, skipping must succeed and parse the same statuses.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.13.1This release also moves 1.13, 1, stable and latest. The chart is at oci://ghcr.io/morgankryze/charts/cairn version 1.13.1, signed the same way:
cosign verify ghcr.io/morgankryze/cairn:1.13.1 \
--certificate-identity-regexp '^https://github.com/MorganKryze/cairn/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comv1.13.0
A hardening release. Six weeks of audit findings, an accessibility pass, and seven config shapes that used to load quietly while doing nothing and are now refused by name.
⚠️ Breaking
Run this before you upgrade. It validates your existing config without touching what you are running:
docker run --rm -v ./config:/config ghcr.io/morgankryze/cairn:1.13.0 -checkExit 0 and the upgrade will load. Exit 1 prints the exact message, and Upgrading is a new page with a section per message and its fix. Add -v ./assets:/assets if you mount one.
Not one page that renders correctly today stops rendering. Every refused value was already doing nothing: a dead link, a dropped key, a broken image. What changed is that a silent defect became a named error.
| Refused | What it did before |
|---|---|
url: containing a path |
the prefix was counted twice, so canonical links and the whole sitemap pointed at example.org/cairn/cairn/ |
tel: in a header or footer link |
rendered, looked live, did nothing when clicked |
an icon on a footer entry |
accepted and dropped; -check already called it inert, and the JSON schema never listed it |
a footer entry missing label or url |
an empty href, which reloads the page the visitor is already on |
a service icon: that is not a slug |
could never resolve upstream, so the card showed a broken image |
theme.accent with 5 or 7 hex digits |
invalid CSS, which silently removed the accent and every focus ring drawn with it |
an absolute /media/… naming a file that is not there |
the bare spelling refused to boot, the absolute one served a 404 |
javascript:, vbscript: and data: are refused wherever a link goes, for the same reason as tel:: html/template writes only http, https, mailto and paths into an href, and blanks everything else. security.contact keeps tel:, because that file is plain text rather than markup and RFC 9116 names the scheme.
If you skip the check, what happens depends on how you run cairn. A running instance that reloads keeps its pages and stays ready. A Kubernetes rollout never completes, because readiness stays 503 and the old pod keeps serving. Compose, Podman or the bare binary restarted comes back healthy on liveness and serves the getting-started page instead of your site, with the reason in the log. That last one is what the command above is for.
✨ Features
/.well-known/security.txt, served once you setsecurity.contact.Expires,CanonicalandPreferred-Languagesare computed per request, which is the whole reason to serve the file rather than let one go stale in a folder.- gzip on the text responses, from a pooled compressor. Roughly 72 kB down to 22 kB on a first visit.
langanddiron text that fell back to another language. A field with no translation used to be emitted with no marker, so a screen reader read French with an English voice and a right-to-left fallback came out with its punctuation at the wrong end. A fully translated site emits none of it.- Upgrading, a page that answers each config refusal by its verbatim message.
cairn -checksays much more: half-translated titles, link labels and category names; astrings:key or alocales:entry covering only part of the site; an/assets/…reference that names no file; an icon whose declaredsizesthe file contradicts; a markdown image in prose that names no file. The checks that open files stay silent when no assets directory is mounted, so-checkon a laptop does not report everything as missing.
🐛 Fixes
- Search left non-matching cards on screen while announcing the right count. An author
displayrule was quietly overriding the browser's[hidden]. - A command injection in the script
-emit-iconswrites, which the documentation tells you to pipe intosh. A serviceicon:is now a slug or nothing. - A phone with JavaScript off lost its category navigation entirely above seven categories.
- A scroll closed the burger menu even when focus was inside it, collapsing the menu under anyone tabbing through it and dropping focus to the top of the page.
- The theme toggle now exposes
aria-pressed, so a screen reader can tell which way it is set. - The search box and the mobile jump-to select clear the 3:1 WCAG asks of a control's boundary, remeasured in a real browser on every pull request. Card outlines are more legible too, near 2:1 rather than a 1.2:1 hairline.
- Dot-prefixed paths are refused under
-assetsandmedia/. Pointing-assetsat a working copy used to publish.git/config. - COOP, CORP and HSTS reach the root probes under
-base-path, which used to answer bare. - The
ETagnames the encoding it was sent with, so a cache told to ignoreVarycannot hand gzip bytes to a client that cannot inflate them. - Three defects in the gzip path, including static files shipped compressed with no header to say so.
og:imagecarried the base path twice.- The config watcher missed a
media/file overwritten in place, and-healthcheckignored the host part of-addr. - A markdown link swallowed everything from the first bracket onward.
🧹 Internal
- A six-agent audit of the whole codebase, fixed across five pull requests, every fix carrying a test proven to fail before it landed. Coverage 90.0% to 92.5%.
- A browser test suite for the behaviours Go cannot reach: search, the theme toggle's state, the mobile category swap, the burger's scroll dismissal, and the contrast ratios, measured with transitions disabled so a slow runner and a fast laptop agree.
- URL schemes became an allow-list of what cairn can emit rather than a list of what is dangerous. A deny-list is only as current as the last time somebody thought about it.
- The release workflow publishes immutable, verifies the signature, and only then moves
stableandlatestonto a digest already proven. A prerelease tag moves nothing. - An air-gapped install guide, with and without a registry.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.13.0This release also moves 1.13, 1, stable and latest. The chart is at oci://ghcr.io/morgankryze/charts/cairn version 1.13.0, signed the same way:
cosign verify ghcr.io/morgankryze/cairn:1.13.0 \
--certificate-identity-regexp '^https://github.com/MorganKryze/cairn/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comv1.12.0
cairn installs with Helm from this release. Nothing on the page changes: this one is for the people running it on Kubernetes.
⎈ A Helm chart
helm install cairn oci://ghcr.io/morgankryze/charts/cairn --version 1.12.0 -f values.yamlThe chart ships as an OCI artifact next to the image, pushed and signed by the same workflow, so there is no repository to add and nothing new to trust.
The Kubernetes page has been enough for anyone templating their own cluster, right up to the Ingress it leaves as an exercise: host, class, TLS secret, annotations, the four things that differ per cluster and that a document cannot know. That is what the chart is for. Everything else it installs is the same ConfigMap, Deployment and Service that page already prints.
Your config goes into the values as block scalars, one entry per file, so it reaches cairn exactly as written instead of being folded into the values tree:
config:
site.yaml: |
title: Our tools
locales: [fr, en]
services.yaml: |
- id: pad
url: https://pad.example.org
icon: hedgedoc
name: { fr: Bloc-notes, en: Notepad }existingConfigMap opts out of that entirely, for anyone already managing the ConfigMap with kustomize or sops.
Two things the chart does deliberately:
- The chart version and the cairn version are the same number. Chart 1.12.0 installs cairn 1.12.0, so there is never a second version to reason about, and
image.tagis there if you want to break the pair apart. - No
checksum/configannotation. The standard trick rolls the pods whenever the ConfigMap changes. cairn polls its config directory and reloads by itself, so an edited value updates the ConfigMap and nothing restarts.
The security contexts are values rather than hardcoded, carrying the defaults from the hardened compose: non-root as 65534, read-only root filesystem, every capability dropped, RuntimeDefault seccomp. They are exposed for one reason only, which the file states: OpenShift assigns its own UID range and rejects a fixed runAsUser.
ingress.host is required once the Ingress is enabled, so a forgotten hostname is a sentence at install time rather than an Ingress object with an empty host.
Everything else, including every value in one table: Helm.
🧹 Internal
Every pull request now lints the chart and renders it twice, bare and with an Ingress under TLS, so a broken template cannot reach main. just chart runs the same three commands locally. The Kubernetes page says what it is for and links across, SECURITY.md gains the chart's cosign verify, and CONTRIBUTING gains the layout entry and what a release does with the chart version.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.12.0This release also moves 1.12, 1, stable and latest.
v1.11.0
cairn has a new mark, a complete icon set, and a way to show your services' status without sending anyone to a monitoring dashboard.
🪨 A new mark
Four stones instead of five, all of the same gauge, the top one set down at nine degrees.
The old drawing failed where it mattered most. At 16 pixels its five stones merged into a teal cone with nothing countable in it, and its stagger was about one unit on a 32 grid: too small to read as a choice, big enough to read as a misalignment. Measured on the actual 16×16 raster, the new mark is 51% partial pixels against the old one's 52%, so the tilt costs nothing.
The accent stays #247b7b. It was checked rather than kept by default: at 4.36:1 on the light background and 3.57:1 on the dark one, it was the only candidate tried that clears 3:1 on both, which is what lets a single favicon file serve both themes.
📱 The icon set is complete
cairn shipped one 180-pixel icon. Chromium offers "install this site" only when the manifest carries both a 192 and a 512, so no cairn site has ever been installable on Android, despite serving a manifest and declaring minimal-ui. Nothing failed and nothing warned.
Added, all generated from the one drawing by the same script: favicon.ico at 16/32/48 for the feed readers and link previewers that fetch /favicon.ico and never read the html, and icon-192.png and icon-512.png for the install prompt. /favicon.ico is now served at the root, and yields to the operator: a site with its own favicon gets a redirect to theirs rather than cairn's stones.
The two app icons are declared any maskable rather than shipped twice. The usual reason for a separate padded maskable file is that a full-bleed icon loses its edges to Android's crop; this mark is a narrow stack whose farthest pixel sits 190 from centre against a 205 safe radius. Measured, not assumed, and two files avoided.
🖼️ Your own icons, stated truthfully
The manifest used to stamp 180x180 on whatever favicon an operator supplied, whatever its real size. It was a guess published as a fact, so it is gone. Every field is now either established or absent:
- An svg favicon is declared
sizes: anyand serves the home screen the way cairn's own does. Previously an svg favicon left cairn's mark on your visitors' home screens: only a png was honoured. - A raster in
/assetsis measured when the config loads and declared at its real size. - A raster behind a URL carries no size. Measuring it would mean an outbound request, and cairn makes none.
For full control there is a new icons list in site.yaml:
icons:
- { src: /assets/brand-192.png, sizes: 192x192 }
- { src: /assets/brand-512.png, sizes: 512x512, purpose: any maskable }It drives the manifest and the iOS home screen alike, since iOS ignores the manifest and reads the apple-touch-icon link alone. cairn does not resize images: that would mean shipping a scaler for one rare path, and a badly resampled logo is worse than the one you drew.
🟢 Status without a destination
A new key, status.linked. Set it to false and the pills state the state and nothing else.
status:
gatus: http://gatus:8080 # internal, and staying that way
linked: falseThe pill has always been a link, and its target falls back to status.gatus when status.page is unset. That fallback is the problem: status.gatus is very often an internal address, precisely because the docs recommend polling over the internal network, so an operator who never set status.page has been sending visitors to a host their browser cannot resolve.
With linked: false the pill becomes a plain span rather than a disabled link: no target in the markup, no keyboard stop, no screen-reader label announcing a destination that is not there, and on a card the click falls through to the service like everywhere else. The state is untouched: Gatus is polled on the same cadence, and a service it does not monitor still gets no pill.
🐛 Fixes
- The search said "no results" on the way back to the full list. Clearing the box, pressing Escape, or typing only spaces all left the message on screen, and
#emptycarriesrole="status", so it was announced too. An empty box is not a search that found nothing. - A protocol-relative logo or icon came out pointing nowhere.
//cdn.example.org/logo.pngstarts with a slash but names another origin; treating it as a local path produced/cairn//cdn.example.org/logo.png. The two kinds of leading slash are now told apart in one place instead of four.
🧪 Internal
Coverage is 90.0% across 199 tests, against 89.6% and 128 in 1.10.1.
search.js was the one behaviour go test could not reach, which is exactly why the search bug shipped. just test-search and a new CI job now drive it in a real browser against the example config; it pulls nothing from a registry, so unlike the demo job it is safe to make a required check.
An unknown key is now answered with the keys of the entry that refused it, read off the struct instead of a hand-kept list. That list had already drifted: it never gained show_version, so a misspelt show_versions was answered with a set of suggestions that did not contain the right spelling. Service entries got no list at all, and nested entries were offered the top-level keys.
The mark was drawn twice, in favicon.svg and inline in layout.tmpl as the fallback icon for services without one. Redrawing one left the other on the old five stones. A test now compares the two and fails if they diverge.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.11.0Still two files, /cairn and a CA bundle, under 10 MB: the whole icon set adds about 11 kB. This release also moves 1.11, 1, stable and latest.
v1.10.1
Nothing changes for you. This one is the inside of the house: the code is now six packages instead of one, and the tests cover a good deal more of it.
🧹 Internal
-
One package per job. A flat
package mainof nine source files becameconfig(read and validate the YAML),render(turn it into bytes),status(Gatus),server(routes, handlers, probes, loops),check(backs-check), and acmd/cairnthat does nothing but wire them together. The dependency graph runs one way andmain.gowent from 514 lines and 27 functions to 90.Mapping every cross-file symbol first turned up three real cycles that a naive split would have hit: config validated link icons against a map living in the renderer,
-checkbuilds a model so it could not live in config, and the base-path helpers sat in main while the renderer needed them. Each was resolved rather than worked around. -
Coverage went from 82.7% to 89.6%, with 128 tests against 72, and the CI floor moved to 87%. The new tests are aimed where they earn their keep rather than at the number: every rejection
validateSitecan produce, so the error messages the README promises stay as precise as advertised; the route table, so a path cannot quietly disappear; the UI string fallback chain; malformed markdown links.What is left uncovered is 94 statements out of 879, and most of it is the process entry point and the two
for range time.Tickloops whose bodies were already extracted and tested. The demo job covers those end to end. -
A regression caught before it shipped. Writing those tests found that the friendly YAML error vocabulary was keyed on
main.Site,main.SitePageand so on, so splitting the packages had silently turned "found a text where a page entry was expected" into "where config.SitePage was expected": Go jargon in the face of someone editing a YAML file. Fixed, and made independent of the package name so a future move cannot break it again. -
Two shadowing bugs surfaced and were fixed along the way: a
statusparameter hid thestatuspackage, and the-healthcheckflag variable namedcheckhid thecheckpackage.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.10.1The artifact is byte-for-byte the same shape as 1.10.0: two files, /cairn and a CA bundle, under 10 MB. This release also moves 1.10, 1, stable and latest.