What runs, when, and why it's shaped this way.
The governing principle: the build is defined in C#, not in YAML. Every workflow in this
repository is generated from a [GitHubActions] attribute in
build/Build.CI.GitHubActions.cs, and every step that
actually does something invokes a Fallout target. A workflow provisions a
runner and routes a channel; it never contains logic. That's why the same commands work identically
on a laptop and on a runner — and why you can rehearse a release locally before trusting CI with
it.
.github/workflows/*.ymlis GENERATED. Never hand-edit it — the next generation silently overwrites your change. Each file carries an<auto-generated>header saying so.
| File | Trigger | Invokes | Environment |
|---|---|---|---|
build.yml |
push to develop/main; PR into develop, main, release/*, hotfix/*, support/* |
Test — the required check |
— |
publish-develop.yml |
push to develop (non-docs) |
PushDevelop — rolling :develop images |
ghcr |
publish-ghcr.yml |
v* tag |
PushGhcr — versioned multi-arch images |
ghcr |
publish-release.yml |
v* tag |
GitHubRelease — compose bundle + notes |
github-release |
publish-dockerhub.yml |
dispatch only | PushDockerHub — mirror |
dockerhub |
flowchart LR
PR["PR into develop / main /<br/>release/* / hotfix/* / support/*"] --> B["build.yml<br/>Test"]
B --> C(["ubuntu-latest ✓"])
D["Push to develop"] --> B
D --> E["publish-develop.yml<br/>PushDevelop"]
E --> ET["ghcr.io/…:develop"]
T["Tag v* on main"] --> G["publish-ghcr.yml<br/>PushGhcr"]
T --> R["publish-release.yml<br/>GitHubRelease"]
G --> GT["ghcr.io/…:0.3.0"]
R -->|waits for publish-ghcr| REL["GitHub release<br/>compose + env template"]
M(["manual dispatch"]) -.-> DH["publish-dockerhub.yml"]
style C fill:#2d6a4f,color:#fff
style ET fill:#1d4e6f,color:#fff
style GT fill:#2d6a4f,color:#fff
style REL fill:#2d6a4f,color:#fff
style DH fill:#7f4f24,color:#fff
Branch protection requires a status check named ubuntu-latest. That's the job name, not the
workflow name — protection keys on jobs, which is why the job is named after the image.
It runs Test, which compiles in Release and runs everything except Category=Live. The live
ARD/ZDF tests are excluded on purpose: they hit real broadcasters, drift, and rate-limit, so a gate
that included them would fail for reasons that have nothing to do with the PR. Run them deliberately
with ./build.sh TestLive.
The generator names a job after its runner image, so all five workflows produce a check called
ubuntu-latest. There is no job-name property in Fallout 10.4 to change that.
In practice it only shows up in one place. A PR from a working branch has a head SHA that only the
gate ran on, so its check list is clean. A release PR from develop has a head SHA that
publish-develop also ran on, so the PR lists the gate's check and the edge publish's, both under
the same name — and GitHub requires every check with that name to pass.
That is a defensible thing to be blocked by (don't cut a release from a commit whose images
wouldn't publish), so it stays. If it ever stops being defensible, GetJobs on
GitHubActionsAttribute is protected virtual — a subclass carrying a JobName is the fix, and
the required-check context in the ruleset moves with it.
Excluding **/*.md looks like free savings and is a trap. The job name is the required status
check, so a docs-only PR would sit forever waiting on a check that never fires. The only fix is a
second workflow that reports the same context on the exact inverse path set — which is a
hand-written YAML file the generator cannot emit, and two path lists that must stay perfect
complements forever.
The extension repo pays that price because its gate packages a VSIX. Ours is a five-minute
dotnet test. We pay the five minutes.
publish-develop.yml does filter paths, and safely: it is not a required check, so a skipped run
blocks nothing.
Every non-docs push to develop rebuilds the six service images for linux/amd64 and
linux/arm64 and pushes them to GHCR under :develop, replacing what was there. See
releasing.md for how to run it.
Two deliberate choices worth knowing when reading the attribute:
- Concurrency queues, never cancels.
ConcurrencyCancelInProgressstays at its defaultfalse. A cancelled push can leave:developpointing at a half-written manifest list, which is worse than the channel running a few minutes behind the branch. develop, notlatest.latestis conventionally the newest stable image, and it is what a compose file falls back to when a tag is omitted — so naming the trunk channellatestwould silently upgrade people who never asked for it. Anyone on:developtyped the name of a branch, which says exactly what they are getting.
A v* tag fires publish-ghcr and publish-release at the same moment. They are not independent:
GitHubRelease asserts three things before it publishes, and each one is a failure that was
actually observed rather than a hypothetical:
AssertTagged— HEAD is exactly a tag. A release built from an untagged commit is a release whose contents are not the tag it claims.AssertReleasableRef(new with GitFlow) — the tag is reachable frommainor asupport/*line. Under GitFlow the trunk is never tagged for release; without this check that rule is documentation only, and breaking it is silent — av*tag ondevelopwould publish real images and a real release from unstabilised code.AssertImagesPublishedForThisCommit— the siblingpublish-ghcrrun for this exact SHA has concluded successfully, and the manifests are readable. Asking the registry whether the tag exists is a different and weaker question: re-tagging a released version starts both workflows in the same second, the previous publish's images are already under that tag, and the release goes out pointing at stale images. That is exactly what happened on the v0.1.0 re-tag.
The wait is 30 minutes with a 20-second poll. Off CI there is no sibling run to wait on, so it falls back to the existence check and says so in the log — a local release is a deliberate act.
flowchart TD
T["v* tag pushed"] --> A{"HEAD is<br/>exactly a tag?"}
A -->|No| X1["fail"]
A -->|Yes| B{"Reachable from main<br/>or support/*?"}
B -->|"No — e.g. tagged develop"| X2["fail:<br/>releases come from main"]
B -->|Yes| C{"publish-ghcr for<br/>this SHA succeeded?"}
C -->|No / timeout| X3["fail:<br/>won't ship stale images"]
C -->|Yes| D["Create the release"]
style X1 fill:#7f1d1d,color:#fff
style X2 fill:#7f1d1d,color:#fff
style X3 fill:#7f1d1d,color:#fff
style D fill:#2d6a4f,color:#fff
The guard fetches refs/heads/main and refs/heads/support/* before asking, because a tag build
checks out a detached HEAD and the runner may know no branch refs at all — an unfetched containment
check would report "not on main" for a tag that plainly is.
Each publish workflow is bound to a GitHub environment of the same name as its destination
(ghcr, dockerhub, github-release). The environment is what holds that destination's
credentials, so a token for one registry is never in scope for a push to another — and an
environment can later carry protection rules (required reviewers, tag filters) without any of that
leaking into the build definition.
Adding a registry is one more attribute plus an environment holding REGISTRY_USER and
REGISTRY_PASSWORD. No target changes.
None of ghcr, github-release or dockerhub currently has an approval rule, so a tag ships without
a human in the loop. If that ever stops being the right trade, add required reviewers to the environment —
not a condition in the build.
After changing any [GitHubActions] attribute:
./build.sh --generate-configuration GitHubActions_build --host GitHubActions
./build.sh --generate-configuration GitHubActions_publish-develop --host GitHubActions
./build.sh --generate-configuration GitHubActions_publish-ghcr --host GitHubActions
./build.sh --generate-configuration GitHubActions_publish-release --host GitHubActions
./build.sh --generate-configuration GitHubActions_publish-dockerhub --host GitHubActionsCommit the regenerated YAML with the attribute change — a diff where the two disagree is a workflow that will be undone by whoever regenerates next.
There is no separate CI script. The runner invokes the same targets you do:
./build.sh Test # exactly what the PR gate runs
./build.sh TestLive # the live ARD/ZDF tests the gate skips
./build.sh --help # every target and parameter
./build.sh --plan # the execution graph, rendered as HTML
./build.sh Compose # generate docker-compose.yaml + .env from the AppHost
./build.sh ReleaseBundle # build the release artefacts; publishes nothingReleaseBundle is the safe rehearsal for a release: it produces the compose file, the env template
and the notes into .artifacts/release/ without touching GitHub.
fetch-depth: 0everywhere. The release targets ask git about tags and branch reachability; a shallow clone answers wrongly rather than failing.Testneeds Docker running —Infrastructure.Testsstarts a Postgres container via Testcontainers.- The compose file is generated from the Aspire AppHost, not hand-written. Editing
.artifacts/compose/docker-compose.yamlis pointless; changeAppHost/Program.csand regenerate (DR-003).