Skip to content

ADR 0008 — A build service: answering the build without a CI fleet ​

  • Status: Proposed
  • Date: 2026-09-29
  • Context repos: cratly.io site (this repo), editor, scavold, tools

Problem ​

ADR 0007 names the build as one of four things cratly asks of a host, and lets it be answered apart from the other three. So far it has only one answer: the CI of the git host, running a pipeline template. Measured, that answer costs more than the work it does.

Rendering a site takes seconds on a single core. Everything around it is what takes time: waiting for a free runner, starting a job container, installing the adapter and the static-site generator, packing the result as an artifact and unpacking it again in the deploy job. Installing dominates, and it scales with the number of cores rather than with the size of the site. A warm package cache in the build image shortens it — see the pipeline reference — but does not remove it.

The figures that follow from that:

  • On a server inside a customer's network, a single-board computer with four cores, a build takes up to a minute. Keeping the site's whole workflow inside that network is deliberate: it is the stronger protection against unauthorised access, and it is a reason customers choose cratly. Whatever answers the build has to run there too.
  • On a shared runner with twelve threads, a build takes about thirteen seconds — as the best case. The same runner serves every other pipeline, and a site build queued behind them has waited half an hour.
  • A runner kept for site builds alone is another rented server, either with fewer cores or at the price of the shared one again. Buying cores to speed up an installation that need not happen is paying for the wrong thing.

Someone used to an online CMS reads a minute between saving and seeing as a broken product, and half an hour as an outage.

Decision ​

A build service answers the build concept of ADR 0007. It is one program, run by us for the sites we host and on the customer's own server for a site kept inside its network. It runs on amd64 and arm64 alike and needs no container runtime.

It is a build of cratly sites, not a general CI: it is allowed to know what it builds, and it is fast because it does.

It keeps ready what a CI job installs ​

  • Environments, keyed by lockfile. A site's dependencies are installed once into an environment identified by the hash of its lockfile, and linked into every later build of any site with the same lockfile. A new adapter release costs one slow build — or none, where its environment is prepared when the release is published. An environment no current lockfile refers to is removed.
  • A mirror of each site's repository. A build fetches the one new commit, not the tree.
  • A cache per site that outlives the build. Work that depends only on its input — an image variant is the obvious one — is done once. The service offers the directory; an adapter uses it when it is there and works without it.
  • A fresh process per build. Loading the renderer costs a second; a long-lived one would carry state from one build into the next, which VitePress demonstrably does (vuejs/vitepress#5193).

How a site is built — which command, where the output lands — is a profile the service holds per adapter, the same knowledge the tools images hold today. The site's content does not name it.

A build runs untrusted code, and is confined accordingly ​

Rendering executes the site's configuration, its components and any script embedded in a page. Whoever may change the content may therefore run code on the build host. Each build runs confined:

  • as an unprivileged user of its own, with only the working copy writable and the environment read-only;
  • without network — the environment is complete, so a build has nothing to fetch, and nothing it reads can leave;
  • without secrets — credentials for publishing belong to the service, which uses them after the build has ended;
  • with limits on memory and time.

Installing an environment is a separate step: it has network, uses the frozen lockfile, and runs no dependency's install scripts unless the adapter's profile trusts it. A licensed package from a private registry — see the pipeline reference — is fetched in this step with a registry credential the service holds; no build ever sees that credential.

It is told about changes and reads them with its own key ​

  • The git host notifies the service of every push, with a shared secret. The service builds the newest state of each branch and drops states that were superseded while they waited.
  • It reads with a deploy key: read-only, bound to no person, and without an expiry date.
  • It writes nothing back to the git host. Reporting a status there would need a token with write access, and that token would expire and need replacing. The editor reads the status from the service instead.

It publishes the default branch and keeps the drafts ​

  • The default branch is published directly by the service: written into a local directory the web server serves, or transferred to the target host. The deploy strategies of the pipeline templates apply unchanged.
  • Every other branch is kept as a preview, its latest successful build as one archive with the layout of today's CI artifact: the site, and beside it .cratly/build-report.json. The editor previews it exactly as it previews an artifact now. The report is also available on its own, so that showing a finding does not mean downloading the site.
  • A preview is removed when the host reports the branch deleted, and after a period without builds otherwise.
  • The service keeps the promises of the pipeline templates. A content problem in the report fails the build of a branch under review and only warns on the default branch (Build reports); a committed .cratly/sections.json that differs from what the build produces fails it.

The editor asks it as the person signed in ​

The editor calls the service with the OIDC identity of whoever is signed in, issued by the tree's host. The service verifies it with the issuer's published keys, without asking the git host and without ever holding a token that could act there. Which people may see which site's builds is the service's own configuration, expressed in the identity's claims.

What this does not decide ​

  • Whether GitLab CI goes. It stays for what it does well — linting and testing on developer commits — and it remains the build for any site without a service.
  • Serving previews straight from the service. A built site refers to its assets from the root, so each preview would need an origin of its own, and the login would have to travel by cookie or signed address, because a frame cannot send a header. The editor's preview origin already solves that; the archive feeds it.
  • Which token the editor presents. An OIDC identity token is addressed to the client that asked for it — the editor — and says so in its audience. Handing it on to the service as proof of identity is common practice, but not what the token was issued for, and a service accepting it has to accept tokens addressed to someone else. The cleaner answers are a token issued for the service itself — the service registered as an audience of its own, or a token exchange (RFC 8693) at the issuer — and both depend on what the tree's issuer supports, which is yet to be checked for each of them. Settled with the first implementation, against the issuers it has to work with. Either way the service never receives a token that could act at the git host.
  • The service's own interface in detail. It follows from the build concept of ADR 0007 and belongs in a reference page once the editor is written against that contract.

Consequences ​

  • A new repository holds the service. Its releases are what a customer server installs.
  • The editor needs the build concept of ADR 0007 before it can use a second implementation of it: status, log, retry, report and preview archive behind one seam, of which today's pipeline calls become the GitLab implementation.
  • The editor signs in with OIDC. The login it already performs against GitLab asks for the openid scope in addition, and the identity token that comes back is what the service verifies.
  • Scavold gains a persistent cache for image variants, used when the service or a CI job provides a directory for it.
  • tools keeps its images for CI, and the same seed lists define the service's environments. One list per adapter, two consumers.

See also ​