CI image pipeline — topology
The image workflows are split so that each deployable artifact's workflow completion is that artifact's "ready to redeploy" signal, and nothing a deploy doesn't need ever sits on that signal. A CD watcher (an agent today, a CD workflow tomorrow) keys on the completion of the workflow that owns the image it redeploys.
What deploys from what
| Target | Image it tracks | Built by | Signal = completion of |
|---|---|---|---|
| server pods, prod | iterion@sha256:… — the digest of the signed iterion:vX.Y.Z | image.yml (tag → multi-arch + signed) | image.yml on the tag |
| runner pods, prod | iterion-runner-devbox@sha256:… — the digest of :vX.Y.Z | release-images.yml (tag → multi-arch + signed) | release-images.yml |
server pods, a :edge dev install | iterion:edge (rolling main tag) | image.yml (main → amd64) | image.yml on main |
runner pods, a :edge dev install | iterion-runner-devbox:edge (rolling main tag) | runner-image.yml (main → amd64) | runner-image.yml |
Prod pins every image by digest in its GitOps values (server, runner and the sandbox: auto image alike), so a prod deploy is a digest bump committed there, never a tag that moves under a running ReplicaSet — see pinning the image. The :edge rows are what an install tracking main redeploys from.
Sandbox images (iterion-sandbox-*) are pulled at bot-run time, so no rollout waits on them: a :edge install takes them from sandbox-images.yml, while prod's sandbox: auto digest is bumped from release-images.yml alongside the runner's.
⚠️ Signal-contract change — a redeploy watcher must be reconfigured. Before this split,
image.ymlbuilt the server AND the runner, so its completion meant both were ready. Nowimage.ymlcompletes when only the server is ready; the runner:edgeis published ~2-3 min later byrunner-image.yml. A CD/agent that redeploys a:edgerunner must key onrunner-image.ymlcompletion, notimage.yml— otherwise it redeploys a one-cycle-stale runner. Server redeploy keys onimage.ymlas before. A prod bump keys on the release tag's workflows instead (image.ymlfor the server digest,release-images.ymlfor the runner and sandbox digests).Minor knock-on:
trivy.ymlimage-scan runs onimage.ymlcompletion and scansiterion-sandbox-*:edge, which the fresh refold republishes just after — so those sandbox scans lag by one cycle (non-blocking; the tool bases change only onsandbox/**, so the content is near-always identical).
Workflows
image.yml server `iterion` push main (amd64 :edge) · tags (multi-arch :vX.Y.Z, signed) · PR
└─ completion = SERVER redeploy signal
│ workflow_run (main, success)
├─▶ runner-image.yml runner `iterion-runner-devbox:edge` (amd64) └─ completion = RUNNER redeploy signal
└─▶ sandbox-images.yml (finalize only, bases skipped) refold sandbox :edge with the fresh binary
sandbox-images.yml bases + finalizes push sandbox/** (bases→finalize) · workflow_run(image.yml, main) (finalize) · PR
release-images.yml runner + sandbox @ :vX.Y.Z push tags v* (await image.yml's server, then multi-arch + signed)Why the splits
- Server is amd64-only on main (
:edge): OVH prod is amd64 (12/12 nodes) and:edgeis an ephemeral dev tag, so the main build is never gated by the slower arm64 leg. On a tag the server is multi-arch + signed in-line — arm64 IS the release artifact, releases are infrequent, and the release digest is what prod pins. - Runner has its own workflow so the server signal never waits on it (it bakes the server binary via
COPY --from, i.e. it can only run after the server image). It isworkflow_run-triggered offimage.ymlon main. - Sandbox finalizes re-run on the same
workflow_run(fresh binary onto the unchanged tool bases) and onsandbox/**pushes (fresh tools). Never on the deploy path. - release-images.yml builds the
:vX.Y.Zrunner + sandbox tags thatimage.ymlno longer builds on a tag. It is tag-push-triggered (sogithub.refis the tag → correct checkout + semver tag naming), and itsawait-serverjob polls untilimage.ymlhas publishediterion:vX.Y.Zbefore itCOPYs the binary — so the two workflows never race to publish the server tag.
workflow_run notes
workflow_runtriggers use the workflow definition from the default branch and fire for everyimage.ymlcompletion (main, tag, PR). The downstreamprepjobs guard toconclusion == success && event == 'push' && head_branch == 'main'so only a successful main build refreshes the runner / sandbox:edge; tags go throughrelease-images.yml, PRs publish nothing.- No cycles: nothing re-triggers
image.yml, so theimage.yml → {runner, sandbox}fan-out terminates.
The docs site can freeze silently — check for a stuck pages run
docs.yml serialises on concurrency: group: pages with cancel-in-progress: false, so one run that never starts blocks the group forever: every later run waits behind it as pending, and each new push collapses the previous pending one to cancelled. The run list then shows an unbroken column of cancelled and no failure anywhere — nothing is red, the site simply stops updating.
Measured 2026-08-30: a docs run queued since 2026-08-12 held the group for 18 days. Every docs change in between — including a dead link that fails the build — never reached the site. Cancelling the zombie freed the queue immediately and the next run deployed.
The list view hides it, because a run queued weeks ago is far down the page. Ask the API for what is actually stuck, not what ran recently:
gh api "repos/SocialGouv/iterion/actions/runs?status=queued&per_page=20" \
--jq '.workflow_runs[] | "\(.id) \(.name) \(.created_at)"'
gh run cancel <id> # its Pages deploy already happened; the leftover job is a zombieA quicker tell, when you only want to know whether the site is current: gh api repos/SocialGouv/iterion/deployments --jq '[.[]|select(.environment=="github-pages")][0].created_at' — if that date is not recent, the group is stuck.
