Fleet CI for jlt-commons: build every project against one jolt version and report the result in a single table.
Each project pins the jolt it builds against. When a new jolt ships, the question worth answering is not "does my project still build" but "does anything in the fleet break", and answering it one repository at a time is how you find out days late.
jolt v0.8.0 is the case that prompted this. It published on 2026-09-01 and
reversed jolt.ffi/write's last two arguments
(jolt-lang/jolt#802). Both
spellings are plain integers, so an older or newer runtime than the code
expects writes to the wrong address and reports nothing. Two projects were
floating to latest at the time and silently moved onto it.
fan-out does not build anything itself. Every project's own ci.yml already
knows how to build it, and each accepts a jolt-version dispatch input that
overrides its pinned JOLT_VERSION for a single run. So fan-out dispatches
those five workflows, waits, and renders the answers:
## jolt `0.8.0` across jlt-commons
| project | result | run |
|-----------------|--------|--------|
| `glitter` | pass | [run] |
| `glitter-gl` | pass | [run] |
| `glitter-uikit` | pass | [run] |
| `raygui-jlt` | pass | [run] |
| `raylib-jlt` | pass | [run] |
| `raylib-ios` | pass | [run] |
All 6 projects build against jolt `0.8.0`.
Reusing each project's real gate matters. raygui-jlt needs raylib natives,
glitter-uikit needs AppKit and GTK4, glitter-gl needs a GL context under
Xvfb. raylib-ios is the odd one: its real target is a phone, which no runner
can be, so what it contributes here is its pure scene namespaces run under jolt
on Linux. That still catches a jolt divergence, which is what this is for.
Two steps, and the second is not in this repository.
- Add it to
PROJECTSin.github/workflows/fanout.yml, and give the project aci.ymlthat takes ajolt-versioninput and actually builds or tests under jolt. A project whose CI never touches jolt will report a green here that means nothing, which is worse than being absent. - Add the repository to the
jlt-commons-ciGitHub App installation. The App is scoped to selected repositories, and the token the fan-out mints reaches only those. A project inPROJECTSbut outside the installation fails at dispatch with a 404 that reads like a missing workflow. This is a settings change, athttps://github.com/organizations/jlt-commons/settings/installations, and no API call in this repository can do it. A central rebuild of all that would rot, and would test something other than what a contributor hits.
canary runs fan-out against the jolt versions this fleet cares about, opens
an issue when they break and closes it when a later run passes, so an open
canary issue means "broken right now" rather than "broke once". Two versions:
| pinned | what the projects pin today, vars.JOLT_PIN, default 0.8.1 |
| latest | jolt's newest published release |
They are the same while the fleet is current, and the run collapses to one build. They diverge the moment jolt ships a release nobody has adopted, which is the window worth watching.
It ticks hourly and two gates decide whether a tick does anything:
- interval —
vars.CANARY_INTERVAL_HOURS, default1. A cron cannot be a variable, GitHub requires a literal, so the schedule ticks hourly and this decides how often a tick is allowed to do work. Measured from the last run that actually built something, since a gated tick still finishes as a successful run and would otherwise reset the clock every hour. - change — a version set that already passed is not retested. Held in the Actions cache, so there is nothing to commit back and no extra permission.
A manual dispatch ignores both.
Both rows are releases, and jolt's main is testable too. This section
used to say testing an unreleased jolt would mean building it from source
against Chez on two operating systems. That stopped being true on 2026-09-02,
when jolt-lang/jolt#824 merged and
jolt began publishing a rolling vnightly prerelease from main. Every project
already takes a jolt-version input, so:
gh workflow run fanout.yml --repo jlt-commons/ci-builds -f jolt-version=nightly
is the whole thing, no code anywhere. Run 33691047983 did exactly that and all five passed.
nightly is now a third row in the canary matrix, behind a freshness check
that is the reason the row can be trusted:
| pinned | what the projects pin today, vars.JOLT_PIN, default 0.8.1 |
| latest | jolt's newest published release |
| nightly | jolt's vnightly prerelease, when it is fresh enough |
jolt publishes the nightly by staging a draft, deleting the old release, then
moving the tag, and only after its own downstream-library gates pass. So a
lagging library withholds the new artifact while the previous vnightly stays
up and installs perfectly. jolt's very first nightly run published nothing at
all for exactly that reason, with three library suites failing on main against
the :jolt/provides change that v0.8.1 later announced.
Left alone, a green nightly row could not distinguish "main is healthy" from
"main broke on Tuesday and we are still testing Tuesday's binary". So the canary
reads the release's published_at, skips the row when the artifact is older
than vars.NIGHTLY_MAX_AGE_HOURS (default 48), and stamps the age on every
result. A skipped row says so in the run log and in the issue body. It never
passes quietly.
Fresh is not sufficient on its own. jolt tags vnightly at whatever main was
when the nightly job ran, and that is often the release tag itself. On
2026-09-02 vnightly and v0.8.1 both pointed at 9b768395, identical and
three hours old, while main had already moved 7 commits past both. That SHA
named upstream's line as it stood that day. jolt force-pushed main on
2026-09-03 and moved both 0.8.x tags to byte-identical commits, so it no
longer resolves there, which is the reason the canary compares tags at run time
rather than trusting an id written down earlier. A row like
that is fresh, green, and tells you nothing the release row did not already say.
So the canary compares the nightly's commit against the release rows' commits
and skips it as redundant when they match, rather than rendering one build as
two answers.
The published_at also goes into the change-gate cache key. The literal string
nightly is the same today as yesterday, so without it the first green would
cache a key every later run recomputes, and the nightly row would never run
again however far main moved.
The two release rows need none of this, because a published release is immutable once it exists.
Try a candidate jolt across everything. Actions → fan-out → Run workflow, and give it a version (blank means jolt's latest release). Nothing is committed anywhere; each project builds at its own HEAD with the version you named.
Try one project. Dispatch that project's own ci.yml with the
jolt-version input. Same mechanism, one repository.
Adopt a new jolt. Run fan-out against it, then, when green, move each
project's JOLT_VERSION pin in one reviewed commit per repository. Moving a
pin IS the cutover commit for a breaking jolt change, which is the point of
pinning rather than floating.
Every project carries a workflow-level pin:
env:
JOLT_VERSION: ${{ inputs.jolt-version || '0.8.1' }}A floating latest lets a release published overnight turn CI red on a tree
nobody touched, which is the same reason clj-kondo, clojure-lsp and babashka
are pinned in those workflows.
Note what :jolt/min-version in a project's deps.edn does not do. jolt
honours that key only from
#804, which is the direct child
of #802, so every runtime old enough to have the old ffi/write order is also
too old to read the key and ignores it. Measured against the released v0.7.29
with a 0.8.0 floor present: it ran rather than refusing, dropped two test
namespaces, and still reported zero failures on half the suite. The pin here is
what actually selects the runtime; the floor is for the next break, and to
state the requirement for consumers. See
jolt-lang/jolt#811.
Runs authenticate as the jlt-commons-ci GitHub App, not a personal access token. The App is owned by the organization, mints a token that lives one hour and reaches exactly six repositories, and is what lets someone trigger a fleet build without borrowing another person's identity. A PAT would attribute every dispatched run to whoever created it and would die with their access.
APP_ID and APP_PRIVATE_KEY are repository secrets here. Repository rather
than organization secrets because this is a free organization, where org
secrets cover public repositories only, and because the credential's blast
radius should be the one repository that needs it.
scripts/setup-app.sh walks the whole thing: create the App, set its
permissions, install it, store the credentials in pass, create this
repository, and set its secrets. It is the script that actually created the
current setup, corrected afterwards for two things it got wrong the first time.
bash scripts/setup-app.sh
It is idempotent enough to re-run: stage 1 asks whether the App already exists and skips creation if so, and stage 5 detects this repository and moves on. You would need it to rotate the private key, to rebuild the App after an uninstall, or to set the same thing up for another organization.
Two things it records that are easy to get wrong:
- The workflows authenticate with the App's Client ID, not its App ID.
create-github-app-token@v3deprecates theapp-idinput, so a setup that only captures the App ID produces credentials the workflows cannot use. passauto-commits every entry it writes but never pushes. The credentials are not backed up untilgit -C ~/dev/b12n-pass push.
- Give it a
workflow_dispatchinput namedjolt-versionand a workflow-levelJOLT_VERSIONpin that reads it. - Add the repository to the jlt-commons-ci App installation.
- Add its name to
PROJECTSin.github/workflows/fanout.yml.
- The canary polls rather than being told. A
repository_dispatchfromjolt-lang/jolton release would be the better signal, but jolt is a different organization where we are read-only, so that change is not ours to make. Worth asking about. glitter-glhas a shim a single pin cannot fully exercise.glitter-gl.ffi-compatresolvesffi/write's argument order at load and has two branches; a run pinned to one jolt only ever takes one of them. A two-entry matrix over an old and a new jolt would cover both.PROJECTSis a hardcoded list, so a new repository is invisible until someone edits the workflow. Deliberate for now: discovery by topic or by listing the App's installation would be quieter but would also silently change what the canary covers.
Copyright (c) 2026 Burin Choomnuan.
Distributed under the Eclipse Public License 2.0. The full text is in
LICENSE. SPDX identifier: EPL-2.0.