Skip to content
Like what we’re building? Star on GitHub

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 ​

TargetImage it tracksBuilt bySignal = completion of
server pods, proditerion@sha256:… — the digest of the signed iterion:vX.Y.Zimage.yml (tag → multi-arch + signed)image.yml on the tag
runner pods, proditerion-runner-devbox@sha256:… — the digest of :vX.Y.Zrelease-images.yml (tag → multi-arch + signed)release-images.yml
server pods, a :edge dev installiterion:edge (rolling main tag)image.yml (main → amd64)image.yml on main
runner pods, a :edge dev installiterion-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.yml built the server AND the runner, so its completion meant both were ready. Now image.yml completes when only the server is ready; the runner :edge is published ~2-3 min later by runner-image.yml. A CD/agent that redeploys a :edge runner must key on runner-image.yml completion, not image.yml — otherwise it redeploys a one-cycle-stale runner. Server redeploy keys on image.yml as before. A prod bump keys on the release tag's workflows instead (image.yml for the server digest, release-images.yml for the runner and sandbox digests).

Minor knock-on: trivy.yml image-scan runs on image.yml completion and scans iterion-sandbox-*:edge, which the fresh refold republishes just after — so those sandbox scans lag by one cycle (non-blocking; the tool bases change only on sandbox/**, 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 :edge is 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 is workflow_run-triggered off image.yml on main.
  • Sandbox finalizes re-run on the same workflow_run (fresh binary onto the unchanged tool bases) and on sandbox/** pushes (fresh tools). Never on the deploy path.
  • release-images.yml builds the :vX.Y.Z runner + sandbox tags that image.yml no longer builds on a tag. It is tag-push-triggered (so github.ref is the tag → correct checkout + semver tag naming), and its await-server job polls until image.yml has published iterion:vX.Y.Z before it COPYs the binary — so the two workflows never race to publish the server tag.

workflow_run notes ​

  • workflow_run triggers use the workflow definition from the default branch and fire for every image.yml completion (main, tag, PR). The downstream prep jobs guard to conclusion == success && event == 'push' && head_branch == 'main' so only a successful main build refreshes the runner / sandbox :edge; tags go through release-images.yml, PRs publish nothing.
  • No cycles: nothing re-triggers image.yml, so the image.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:

sh
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 zombie

A 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.