Skip to content

Latest commit

 

History

History
202 lines (154 loc) · 10 KB

File metadata and controls

202 lines (154 loc) · 10 KB

CI/CD

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/*.yml is GENERATED. Never hand-edit it — the next generation silently overwrites your change. Each file carries an <auto-generated> header saying so.

The workflows

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
Loading

build.yml — the required check

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.

Every workflow reports the same check name

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.

Why there are no path filters on the gate

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.

publish-develop.yml — the trunk channel

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. ConcurrencyCancelInProgress stays at its default false. A cancelled push can leave :develop pointing at a half-written manifest list, which is worse than the channel running a few minutes behind the branch.
  • develop, not latest. latest is conventionally the newest stable image, and it is what a compose file falls back to when a tag is omitted — so naming the trunk channel latest would silently upgrade people who never asked for it. Anyone on :develop typed the name of a branch, which says exactly what they are getting.

The tag pipeline

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:

  1. AssertTagged — HEAD is exactly a tag. A release built from an untagged commit is a release whose contents are not the tag it claims.
  2. AssertReleasableRef (new with GitFlow) — the tag is reachable from main or a support/* line. Under GitFlow the trunk is never tagged for release; without this check that rule is documentation only, and breaking it is silent — a v* tag on develop would publish real images and a real release from unstabilised code.
  3. AssertImagesPublishedForThisCommit — the sibling publish-ghcr run 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.

The release guard

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
Loading

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.

Environments hold the credentials

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.

Regenerating

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 GitHubActions

Commit the regenerated YAML with the attribute change — a diff where the two disagree is a workflow that will be undone by whoever regenerates next.

Running CI locally

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 nothing

ReleaseBundle is the safe rehearsal for a release: it produces the compose file, the env template and the notes into .artifacts/release/ without touching GitHub.

Gotchas

  • fetch-depth: 0 everywhere. The release targets ask git about tags and branch reachability; a shallow clone answers wrongly rather than failing.
  • Test needs Docker runningInfrastructure.Tests starts a Postgres container via Testcontainers.
  • The compose file is generated from the Aspire AppHost, not hand-written. Editing .artifacts/compose/docker-compose.yaml is pointless; change AppHost/Program.cs and regenerate (DR-003).