CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
extension / lint (push) Successful in 21s
CI and images / frontend-build (push) Successful in 21s
CI and images / backend-lint-and-test (push) Successful in 31s
CI and images / integration (push) Successful in 2m10s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m51s
CI and images / smoke-web (push) Successful in 57s
CI and images / promote (push) Skipped
Operator, 2026-09-23: *"tighten the gate so :dev can't publish on red tests"*, then *"I don't want failing builds to publish anywhere going forward."* Run 7348 is the worked example. The backend unit lane went red on `2f8f0bc` and `build-web` pushed `:dev` in the same minute, because the lanes and the build were SEPARATE WORKFLOWS on the same push trigger. Neither could see the other's verdict. `:dev` was a "it built" signal, never a "it passed" one, and nothing about that was visible from either run. Two workflows cannot express the gate. A `needs:` edge only exists inside one graph. So `ci.yml`'s five lanes move into `build.yml` and `ci.yml` is deleted; `sign-extension`, `build-web` and `build-agent` now need all five. Nothing here is a new mechanism — it is the same edge that has gated `promote` since milestone 362 step 4, and it keeps that step's hardest-won property: **not running is not the same as passing.** `needs` treats a SKIPPED dependency as unsatisfied, so a lane that silently skips itself blocks the publish exactly as a failing one does. Run 5290 is why that is worth stating. Scope, said plainly rather than implied: - Gated: every image tag (`:dev`, `:latest`, `:c-<sha>`), the weekly base refresh, and the `ext-<version>` signed-XPI release asset — `sign-extension` publishes too, so it is gated with the rest. - Not gated, deliberately: `extension.yml` publishes nothing, and `release.yml` runs on a `v*` tag, generates notes rather than an artifact, and its commit already went through main's gated build. - `pull_request` (Renovate bumps into `dev`) comes across with the lanes. Its runs are the lanes and nothing else, via an `if:` on each publishing job rather than an inference from the `needs` chain. The cost, accepted knowingly: this workflow queues per branch and never cancels, so on two pushes in quick succession the second's lint feedback waits out the first's build. A slower red beats a fast red that ships. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
16 KiB
16 KiB
CI Requirements — FabledCurator
Spec: https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md
Runtime image
git.fabledsword.com/bvandeusen/ci-python:3.14
Image deps used
- python 3.14
- ruff (analyzer for
backend/,tests/,alembic/,agent/,scripts/) - node (frontend job:
npm install+ vitest + vite build) - docker CLI + buildx (
.forgejo/workflows/build.yml: build-web, build-ml, build-agent — Fabled-Git registry push, andimagetools inspect/createfor the reuse path)
Secondary runtime image
node:24-bookworm-slim — .forgejo/workflows/extension.yml only.
.forgejo/workflows/release.yml runs on ci-python:3.14 like everything else
and installs nothing: it needs git and stdlib python, and builds no image.
The extension lane is the one job that does NOT run on ci-python:3.14: it
needs a current Node for web-ext and vitest and nothing Python at all. Kept
on the upstream slim image rather than adding a Node toolchain to ci-python,
per docs/process.md's "add deps to the image when used by >1 project".
Per-job tool installs
pip install -r requirements.txt pytest pytest-asyncio— inbackend-lint-and-testandintegrationjobsnpm install --no-audit --no-fund— infrontend-buildjobnpm install --no-audit --no-fund— inextension.yml'slintjob (web-ext + vitest)unzip— inextension.yml's "Verify XPI contents" step, installed via apt only when absent (node:24-bookworm-slimmay or may not carry it). Debian package, ~2s. Not worth baking into a shared image for a single consumer, perdocs/process.md's ">1 project" rule.
Notes
- Integration wall time ~3 min, dominated by pgvector container start + the
pip installstep (~30-45s on cold cache) + alembic + 300+ integration tests. - The
pip installin two jobs is intentional and perdocs/process.md's "add deps to image when used by >1 project" rule: FC alone is one Python project, so the deps live inrequirements.txtand install per-job. Reconsider when a second Fabled-family Python backend lands. - Integration uses Fabled-Git Actions
services:+ socket-discovered bridge IPs becauseact_runner(swarm-runner v0.6+) puts services on the default bridge with no embedded DNS. The pattern is documented in the rulebook'sfabled-git.md"CI philosophy" section and FC'sbuild.ymlintegration lane is the canonical example. - No
package-lock.jsonis tracked yet (FC'sfeedback_no_local_runsmemory bansnpm installlocally). Usingnpm installrather thannpm ciuntil a lockfile lands. - No
imagemagick/pandocper-job installs needed. extension/'s vitest specs loadlib/*.jsby evaluating the real file as a classic script (test/helpers/loadLib.js) rather than addingmodule.exportsshims to production code — the libs ship asbackground.scripts, not ES modules, so the specs exercise exactly the bytes packaged into the XPI.extension/scripts/packaging.shis the single definition of what ships inside the XPI. Three consumers read from it rather than keeping their own copy: web-ext's--ignore-files(extension/package.json), thegit logpathspec inside the script's own version derivation, andscripts/artifacts.sh, which appends the extension's set to web's because the web image bundles the signed XPI. Hand-kept copies of that one fact is what allowed issue #2397, soextension/test/version.spec.jsasserts no workflow has reintroduced a literal:(exclude)extension/….- Packaged and version-relevant are two different sets (#3156).
scripts/is excluded from the XPI and is NOT excluded from the version derivation, becausepackaging.shdecides the version string stamped into the packagedmanifest.json. The membership test is "can changing this file change the published bytes?", not "is this file copied in?" — which is why the script keeps two lists rather than one. - The shipped extension version is derived, not committed. It is the commit
TIME of the newest packaged-extension change, rendered
YYYY.M.D.HHMMUTC (family rules 148/149 — never a commit count, which orders by branch rather than by recency).build.yml'ssign-extensioncomputes it and stamps it intoextension/manifest.json+package.jsonin the working tree before signing; the stamp is never committed. The version in the repo is wholly inert — since milestone 318 step 8 there is no hand-set MAJOR.MINOR either. - The extension is the one artifact that does not zero-pad, and that is not a
drift (#3138). Mozilla's grammar for AMO is
^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$— a segment is the single digit0or starts 1-9, and there are at most four.2026.08.29.0201is rejected;2026.8.29.201is the same value one character narrower per segment, and rule 148 defines comparison as numeric per segment, so nothing is reordered.build.yml'sextension-versionlane asserts the derived string against that exact regex, plus aYYYY.M.D.HHMMshape check that would catch a regression to the pre-3181.0.<minutes>— which AMO would accept and which orders below everything already signed. Checking here is the whole point: AMO 409s on re-signing, so a version it rejects is burned and cannot be reused.scripts/artifacts.sh version extensiondelegates topackaging.shso the two cannot answer differently. - Every job that derives anything checks out with
fetch-depth: 0— all four publishingbuild.ymljobs, itsextension-versionandbackend-lint-and-testlanes (fortests/test_artifact_paths.pyandtest_artifact_identity.py), andrelease.yml, which additionally walks the tag graph. A depth-1 clone sees one commit and derives a wrong, too-low value rather than failing, so the full-history checkout is load-bearing rather than incidental. scripts/artifacts.shis the same shape one level up: one definition per artifact of what it is built from, and the two values derived from it.revision(12 hex of the newest commit touching that set) andversion(YYYY.MM.DD.HHMMUTC, rule 148). Three artifacts, three independent answers, so a push touching onlyagent/leaves web and the extension alone.tests/test_artifact_paths.pyreads each Dockerfile and asserts every COPY source is covered, so adding a COPY without updating the script fails CI.- A file that DECIDES an artifact's identity belongs in its set even though it
is copied into nothing —
packaging.shfor the extension and web (#3156), andartifacts.shitself for web (#3202), which decides theFC_VERSIONbaked into that image. Only web needs the second entry: every artifact stamps a revision, but a revision has a backstop (a changed derivation stops matching the published label and forces a rebuild) and a version has none, because nothing compares it to anything.tests/test_artifact_paths.py'sDERIVERStable is the guard. - Builds are skipped when the content is already published. Each image
carries its revision as an
fc.revisionLABEL, andbuild.ymlreads that label back off the moving channel tag (imagetools inspect --format). Equal to the derived revision means the bytes are already published, so the job repoints the remaining tags at the existing manifest instead of rebuilding. Two things this depends on: an inspect that errors for ANY reason reads as a MISS so no needed build is ever skipped, and the repoint must EXCLUDE the source tag —imagetools createwraps its source in a manifest index, and config labels do not resolve through an index, so writing the channel tag from itself destroys the label the next run reads (#3183). - The build pushes exactly ONE tag — the channel's — and every other tag is
written registry-side afterwards (#3190). buildx on this runner pushes the
first tag to the registry and then re-pushes the rest through the docker
driver, out of a local image store that a registry-direct build never fills;
it fails intermittently with
tag does not exist. Ondevthat only reddens a job, but onmainit silently skips:c-<sha>while:latestpublishes fine — a missing rollback tag has no consumer that fails, so nothing but the red job would notice until somebody needs to roll back.imagetools createhas no local store to be absent from, and it is the code the reuse path already ran, so both paths now share one proven route. The cost::c-<sha>is an index rather than a plain image, sofc.revisiondoes not resolve through it — nothing reads it there, and the index names the same manifest. - The image builds run on a
docker-containerbuildx builder, andprovenance/sbomare explicitly OFF (milestone 326 step 1). The builder is what makes a registry layer cache possible at all — the defaultdockerdriver cannot export one (#3114) — and it is #3190's leading suspect, since it is the driver that resolves image metadata against a local store a registry-direct push never fills. The attestation flags are load-bearing, not tidiness: on the container driverbuild-push-action@v5defaultsprovenanceto true when pushing, an attestation manifest makes the pushed tag a manifest INDEX, and config labels do not resolve through an index — so leaving them on would make every push readfc.revision=<none>, miss, and rebuild forever with every lane green. Same failure as #3183, different door. These jobs run inside a container against a mounted docker socket, so the buildkit container is a sibling rather than a child. - All three images import and export a registry layer cache
(
<image>:buildcache,mode=max). This is not an optimisation bolted onto the driver change — it is the other half of it. Thedocker-containerdriver gets a fresh buildkit instance per job and therefore has no local layer store at all, where the olddockerdriver at least reused whatever the runner's dockerd happened to hold. Measured on run 4896, the first builds after the driver moved: web 3m44s (was 2m23s), ml 3m49s (was 3m20s), agent 11m12s (was 9m26s) — every one slower. (mlwas its own build then; #4311 retired it once it became the same bytes as web under a second name.) A:buildcachetag is read by every build that runs, is one moving ref per image, holds cache blobs rather than a shippable artifact, and is overwritten in place, so it is not a return of the per-version tags milestone 318 withdrew (#3114). build.ymlaccepts aworkflow_dispatchwithforce_build, which bypasses the reuse check for all three images. It exists because skip-if-exists makes its own build path untestable:agent/has not changed since 2026-07-17, so the agent build has not run in six weeks and cannot be exercised on demand — and #3190 lives on exactly that path. Editingbuild.ymldoes not force a build either, deliberately: the workflow is not shipped bytes and is in no artifact's path set. The flag is read throughgithub.event.inputsinto an env var rather than interpolated into a run block, and it is checked inside the reuse step so that one decision drives both the build and the repoint.- A weekly
schedulerebuilds all three images against fresh base layers (Sunday 06:00 UTC, milestone 326 step 4, #3154). Skip-if-exists is keyed on OUR source, so an artifact whose source stops moving stops picking up base updates —agent/has not changed since 2026-07-17 and would otherwise serve that day'snvidia/cudalayers forever. Four things make it work:- It builds
main, not the branch that triggered it. Forgejo registers a cron from the DEFAULT branch (devhere), so a scheduled run arrives withgithub.refon dev. The ref is decided once in a top-levelenv: BUILD_REFthat every checkout in the file takes, rather than per job — otherwisesign-extensionwould derive dev's extension version whilebuild-webbundled main's, and the release download would 404 on a version that exists perfectly well. Every job then ASSERTS its checkout ismainbefore doing anything, becauseenvinsidewith:is not a context this runner is known to evaluate — if it silently resolved to empty, checkout would fall back to the triggering ref and the refresh would publish dev's source to:latestwith every lane green. - It publishes only
:latest.:c-<sha>for main's HEAD already names the bytes that commit built; re-pushing it over refreshed layers would break the one tag rule 145 makes immutable, and it is the rollback unit. The repoint step needs no schedule case for this — the tag list is the channel tag alone, so SOURCE is the only entry, it is excluded as always, and the step correctly does nothing. :latestand:c-<sha>therefore diverge between a refresh and the nextmainpush, by design. They re-converge on that push: it hits reuse (a refresh does not movefc.revision, because it does not touch the source), and the repoint writes the NEW:c-<sha>from the refreshed:latest. The push path needed no change for this, because the repoint already excluded the source tag — the same rule that keeps the label readable also keeps a refresh from being undone.pull: trueon the scheduled path only is the mechanism: a moved base tag changes theFROMlayer's cache key and everything above it rebuilds. It did not always make the unmoved case free. Measured on the first real fire (run 4934, 2026-08-30): every content step reportedCACHEDand the bases resolved to unchanged digests, yet all three:latesttags got a NEW manifest digest, because buildkit stamps a fresh image config per run and republishes identical layers under it — so:latestwas rewritten weekly whether or not anything changed, and a digest change stopped meaning anything (#3265).SOURCE_DATE_EPOCHis what makes it free. Set on each build step fromartifacts.sh epoch <artifact>— the unix timestamp of the same commitrevisionandversionname, so all three are views of onenewest()lookup and cannot drift into disagreeing. With the config'screatedfield and history timestamps pinned to the content rather than to the wall clock, identical source produces an identical manifest digest and the push is a registry no-op. That restores the property the whole scheme rests on: a channel tag's digest changes when, and only when, its content does. Separately not caught: a Debian package update inside theapt-get installlayer while the base tag stands still — a lag rather than a hole, since the official python/cuda images rebuild with those updates baked in.
- It builds
FC_CHANNELandFC_VERSIONare build args, not runtime settings.build.ymlpasses them to the web image only — the ml and agent images have nothing to report them to./api/healthreturns both, the foot of Settings renders them, and/api/extension/manifestreports the channel beside the extension version so an install can be traced to a channel. With no version image tags, that self-report is the ONLY answer to "which build is this?" — which is why a missing version rendersunknownrather than a blank: an empty footer reads as "no version", a different and false claim. Both are declared LAST in the Dockerfile on purpose: an ARG invalidates every layer below it, and these are the values that differ between the dev and main builds of identical source, so placing them earlier would stop the two channels ever sharing a cachedpip install. Empty by default — a local build then reports nothing rather than claiming a channel it is not on.- The channel is never folded into the version. A
-devsuffix makes the extension's per-segmentparseIntcomparator read that segment as 0, so every dev build compares equal to every other — issue #2993 exactly (rule 149).frontend/test/systemBuild.spec.jspins the rendered version to the bare number. - Callers MUST
set -fbefore substituting the script's output. Without it the shell expandstest/**against the working tree and silently narrows the pattern to whatever files exist at that moment — a failure that looks like nothing until dev files start appearing in the XPI.test/version.spec.jsasserts every--ignore-filesconsumer sets it, and that no consumer has quietly reinstated a hardcoded list.