Releases: MorganKryze/cairn
Release list
v1.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.
v1.10.0
Your monitor can now tell the difference between "cairn is up" and "cairn is serving your site".
✨ Features
-
A readiness probe that tells the truth.
/readyzanswers503while no valid configuration has ever loaded, and200once your site is being served. Until now a container that booted on a broken or empty config served the getting-started page and still answered200 okon/healthz: the uptime monitor sat green while visitors read "Almost there"./healthzdeliberately keeps its old meaning. It is the liveness signal,200whenever the process is up whatever the config says, because cairn serves a getting-started page rather than dying and a probe that killed it for that would undo the point. So point a KuberneteslivenessProbeand the container healthcheck at/healthz, and your uptime monitor at/readyz. A reload that fails after a good boot keeps the last working pages and stays ready, which is the honest answer: serving, just not the newest edit. See Docker Compose.
📚 Documentation
Added after this release was cut, and live on main now. None of it changes the binary.
- A Kubernetes page, which was missing. cairn is one stateless container with no database and no volume, so it fits well: a ConfigMap, a Deployment, a Service. The page explains why liveness uses
/healthzwhile readiness uses/readyz, and what does not fit in a ConfigMap. Editing the ConfigMap reloads the site without a rollout, because cairn polls its config directory instead of using inotify, which is exactly what survives kubelet atomically repointing the..datasymlink. - The comparison was rewritten to answer the question a reader actually arrives with. Every project in that space draws a grid of links in cards, so it now concedes the resemblance and puts the difference where it belongs: who the grid is drawn for. It also states plainly that
FROM scratchis stricter than distroless, since distroless removes the distribution but keeps a userland, while cairn's image holds two files, the binary and a CA bundle. - The image was advertised heavier than it is.
~14 MBwas quoted in the README, the FAQ and the comparison. Measured on the published image: 9.6 MB on amd64, 9.0 MB on arm64. Corrected everywhere to under 10 MB.
🧹 Internal
- The README now says you can try cairn with nothing at all.
docker run --rm -p 8080:8080 ghcr.io/morgankryze/cairn:stablehas always given a running page that explains the next step, but the Quickstart opened with a compose file and a volume, so readers assumed setup was required. - Two claims pulled back to what has been verified. The accessibility line now states what was measured and says plainly that no real screen reader has been used yet, with an invitation to report what breaks. The languages page says that English and French are written by someone who speaks them and the other five are careful but unreviewed, which makes a correction a one-line pull request.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.10.0This release also moves 1.10, 1, stable and latest.
v1.9.0
The footer can now name the exact build it is running, and the project no longer leans on Docker Hub.
✨ Features
-
show_versionputs the running build in the footer. Setshow_version: trueinsite.yamland the credit gains the version beside it. What it shows depends on where the binary came from: a tagged release names its number and links to the release notes, a build offmainnames its commit and links to it, a localgo buildsaysdevand links nowhere, since there is no public page for it. Off by default, so nothing moves for anyone who does not ask. It exists for the moment someone reports a problem: the page itself says what it is running, and points at the exact source. See Site.The untagged image used to stamp the literal string
unstable, which named nothing; it now carries its commit. The stamp lives inside the credit, socredit: falsestill takes the whole line away rather than leaving an orphan version in an empty footer.
🧹 Internal
- The demo no longer pulls from Docker Hub. Anonymous pulls there share a rate limit across the whole CI runner pool, which took the demo job down twice in one day. The three project images now come from their maintainers' own ghcr.io builds, and the two official images that exist only on Docker Hub come through Google's public mirror. No account, no secret, and it works for pull requests from forks. Verified with a cold bring-up, every cached image removed first.
- Continuous integration runs on pull requests, which it did not before: a contribution is now built, vetted, tested and scanned before it lands, while the publishing steps stay gated to pushes so a fork needs no credentials.
mainis protected accordingly. - The README hero is regenerated from a command. It had drifted twenty-three commits behind, still showing the pre-mobile layout and the old search, because it was rebuilt by hand each time.
just shotsnow writes both files from the running demo, and the release template says to run it. Playwright installs into a gitignored folder on demand, so the repository still carries no npm manifest. - The demo bring-up retries five times before giving up, and tears down a half-started stack between attempts.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.9.0This release also moves 1.9, 1, stable and latest.
v1.8.0
cairn now fits where your domain has room, reads properly on a screen reader, and hands you the receipts for what you pulled.
✨ Features
- Serve cairn from a sub-path. Some organisations hand out paths rather than subdomains.
-base-path /cairnputs the whole site underexample.org/cairn/and cairn carries the prefix through every link, redirect, asset and cookie itself, then strips it back off, so the proxy in front needs no rewriting rule: point it at cairn and stop there. A domain, a subdomain and a sub-subdomain worked already and are unchanged./healthzstill answers at the root, so container healthchecks keep working untouched. See Reverse proxies. - Right-to-left languages lay out the right way round. Set an Arabic, Hebrew, Persian or Urdu locale and cairn writes
dir="rtl"; the stylesheet is written with logical properties, so the whole page mirrors rather than being patched side by side. The interface words are still yours to translate withstrings, since none of the seven built-in languages is right-to-left. See Languages. - Search results reach screen readers. Filtering the cards is a visual change, and a visual change alone tells a screen-reader user nothing. Typing now announces how many services matched and which one Enter would open, in all seven languages, and the arrow keys re-announce as you move. Two new overridable strings,
search.oneandsearch.many.
🐛 Fixes
- A misbehaving Gatus can no longer exhaust the server. The status response is read through a bounded reader: a fast endless stream now fails instead of growing in memory, which the five-second timeout alone did not prevent.
- Slow and idle clients are dropped. The server gained
WriteTimeoutandIdleTimeoutalongside the existing header timeout, so a client that never reads its answer stops holding a connection. - HSTS is sent, but only over HTTPS. It follows the same rule as the cookies: a plain-HTTP LAN deployment must not be stranded by a header it cannot honour.
🧹 Internal
- The page is a third lighter: 186 KB down to 132 KB. The display font was cut from 118 KB to 62 KB by pinning the two variable axes the stylesheet never varies, keeping optical sizing intact. The rebuild recipe is in the reference.
- Releases are signed and attestable. The image carries a keyless cosign signature, SLSA provenance and an SBOM; the binaries carry their own attestation and a
checksums.txt. The verification commands are two lines. - CI runs on pull requests, which it did not before, so a contribution is built, vetted, tested and scanned before it lands. Publishing steps are gated to pushes, so a fork PR needs no credentials.
- Every GitHub action is pinned to a commit rather than a movable tag, and each job asks only for the permissions it writes with.
- Coverage is 83%, up from 76%, with the config-reload and status-poll loops now under test; the CI floor moved from 70% to 80%.
- The demo makes zero third-party requests. It serves its own icons instead of the CDN, so the page proving cairn's air-gap story no longer contradicts it in the network tab.
- A
CODE_OF_CONDUCT.md, and the README finally states the accessibility work it had already earned.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.8.0This release also moves 1.8, 1, stable and latest.
v1.7.4
The match you meant leads the row.
✨ Improvements
- Search sorts the best match to the front. When several cards match, they
reorder within their category by how well they fit, exact name over prefix
over substring over keyword, so the one you meant sits first, top-left of its
row, and is the one Enter opens. Ties keep their original order, and clearing
the box puts everything back.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.7.4This release also moves 1.7, 1, stable and latest.
v1.7.3
Type, and Enter opens the one you meant.
✨ Improvements
- Search answers to the keyboard. As you type, the strongest match is
highlighted (exact name > prefix > name > keyword), the up and down arrows
move the selection, and Enter opens it, so a full name is a two-key trip to
the service. Escape clears the box. On a phone the keyboard's action key now
reads "go". - The selected card breathes. Its halo pulses out like the online dot
rather than sitting frozen, so the pick is easy to catch at a glance. The
pulse steps aside when reduced motion is requested; the accent border stays.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.7.3This release also moves 1.7, 1, stable and latest.
v1.7.2
Type a service's name and you get that service.
✨ Improvements
- Search prefers names. When your query matches a service name, the results
are just those services, no longer every card that happens to mention the
name in its description or tags. Type a full name and you land on the one
thing you meant. Keyword search still reaches descriptions and hidden tags
when no name matches, and it stays accent-insensitive. - The mobile story is now told up front. The README calls out the layout
that reflows to one hand, the header that steps aside as you scroll, and the
burger that keeps search within thumb's reach.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.7.2This release also moves 1.7, 1, stable and latest.
v1.7.1
cairn now works properly on a phone.
✨ Features
- A mobile-friendly layout. The header links fold into a burger while the search stays beside it, and the whole bar hides on the way down the page and slides back on the way up. A back-to-top button appears once you have scrolled, tap targets are finger-sized, the open menu closes on scroll, and the footer is a single clean column with the cairn mark tucked beside "powered by". The category trail becomes a wrapping chip row, or a compact "jump to" select once there are more than seven categories.
📦 Image
docker pull ghcr.io/morgankryze/cairn:1.7.1This release also moves 1.7, 1, stable and latest.