name: Build CI Toolchain Image # Builds the shared CI toolchain image (.NET 10 SDK + Node 22 + prod-identical ffmpeg) and # pushes it to the Gitea container registry (ersatztv#390). The toolchain jobs in # docker-build.yml consume it via `container:`, pinned to an immutable :. # # push to MAIN touching docker/ci/** -> : + :latest # workflow_dispatch on main -> : of main's HEAD + :latest # workflow_dispatch on a branch -> : of that branch's HEAD ONLY (never :latest) # schedule (weekly) -> picks up base-image security updates # # Deliberately separate from docker-build.yml: this image changes rarely (a Dockerfile edit or # the weekly cron), while docker-build.yml runs on every push/PR. Coupling them would rebuild a # ~2GB toolchain image on every commit. # # ROLLOUT NOTE: the jobs pin an immutable :, never :latest — a broken toolchain image would # otherwise block every converted job the moment it was pushed. Bumping the toolchain is a deliberate # two-step, and BOTH steps land in the SAME PR: publish (push the docker/ci commit as branch HEAD, # dispatch this workflow on that branch), then commit the pin bump in docker-build.yml. Merging first # is not available: a PR that changes docker/ci/** without moving the pin turns `ci-image-pin` red, # and the merge-consent hook reads the COMBINED commit status, so it will not auto-grant. That much # predates ersatztv#744 — what #744 changed is how the publish half is performed. # See docs/ci-cd.md -> "Publishing from a branch is a dispatch, not a push". # # Like docker-build.yml: the Gitea registry is HTTP-only, so BuildKit needs the inline # `http = true` config (it does not inherit the host daemon's insecure-registries setting). on: # Publishing from a branch is a DELIBERATE act, not a side effect of pushing (ersatztv#744). # Gitea resolves a `push` workflow's definition from the pushed branch, so an unfiltered `push` # trigger ran this file's own YAML — attacker-supplied, unreviewed, with no status check in the # loop — on a docker-capable runner holding the credential that writes `ersatztv:prod` and the # `ersatztv-ci:` five `container:` jobs execute. # # BE PRECISE ABOUT WHAT THIS BUYS, because the mechanism cuts both ways: the filter below is read # from the pushed ref like everything else in this file, so a branch that DELETES it re-enables # the route. What closes is the DRIVE-BY case — an ordinary push of a legitimate `docker/ci` # change publishing an image nobody asked for, with no deliberate act anywhere. This is NOT a # boundary against a malicious or compromised writer and must not be cited as one. That class was # probed and ACCEPTED in ersatztv#853 (`ci.workflow-dispatch-ref-unrestricted`): Gitea 1.27.1 cannot # restrict `workflow_dispatch` by ref, and restricting it would close nothing anyway: # docker-build.yml's head-resolved `pull_request:` runs attacker-authored YAML, which reaches every # secret in the store — so it covers renovate.yml's RENOVATE_TOKEN too, without dispatching # renovate.yml at all. Only the DISPATCH third is settled; the `v*` tag push and the PR route # itself remain open in ersatztv#885. `workflow_dispatch` is loaded from the ref it is dispatched # on, exactly as the `branches:` filter below is loaded from the pushed ref, and is the deliberate # publish path (docs/ci-cd.md -> "CI toolchain image"). # # A `v*` tag push does not match this trigger either: there is no `tags:` key, and a `branches:` # filter is compared against a branch ref. The exact matcher semantics are not probed here; the # observable claim is the one that matters — a release cut no longer republishes the toolchain # image as a side effect. # # `.gitea/workflows/ci-image.yml` is NOT in `paths:`, and it left `ci-image-pin`'s `expected` in # the same change. That pairing is a DECIDED TRADEOFF, not a necessity: keeping it works, because # the dispatch above can publish the ci-image.yml commit itself and the pin then matches. The # price is what decided it — that route charges a full ~2GB publish plus a five-pin bump for # EVERY edit to this file, comments included, and a rebase charges it again. The cost of the side # taken is stated here and in ci-cd.md: a change to HOW the image is built that lives only in # this file no longer republishes on its own, so pair it with a `docker/ci/**` edit. # # `paths:` here and `ci-image-pin`'s `expected` pathspec in pr-checks.yml MUST name the same # sources. Since the shared self-reference went, `scripts/tests/test_ci_image_paths_pin_agreement.py` # is what holds them together: it derives BOTH lists from these two workflows and compares them for # set equality (ersatztv#855). The two are written in different glob dialects, so it models exactly # one pair of spellings — `/**` here against the pathspec `` — and REFUSES anything else # rather than canonicalising a pattern space whose spellings the two consumers treat differently. # Change this list and that guard goes red until the pathspec follows; write it any other way and # it goes red asking for the new shape to be modelled. workflow_dispatch: push: branches: [main] paths: - 'docker/ci/**' schedule: # Mondays 05:00 UTC. Gitea registers `schedule` only from the default branch (main). # # What this cron does and does NOT do — it does **not** update any running job. The jobs in # docker-build.yml pin an immutable : (deliberately), so a rebuilt image is consumed only # when a human bumps that pin. Its actual value is twofold: # 1. a weekly CANARY — catches "the toolchain image no longer builds" (a NodeSource/apt/base # change) at a time of our choosing, rather than when you next need to bump the pin; # 2. it leaves a freshly-patched :latest so the next pin bump starts from a current base. # `no-cache` on this path is what makes both real: with the shared :buildcache, the # `apt-get update && apt-get install` layer would restore from cache and re-fetch nothing. - cron: '0 5 * * 1' # Serialize per ref: concurrent builds would race on the shared :buildcache tag. # No cancel-in-progress — a half-pushed toolchain image is worse than a redundant build. concurrency: group: ersatztv-ci-image-${{ github.ref }} cancel-in-progress: false env: REGISTRY: 192.168.1.95:3000 CI_IMAGE: 192.168.1.95:3000/timothy/ersatztv-ci # Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted # (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here # is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on. # Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full # rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope". # This workflow's registry pushes authenticate with the scoped REGISTRY_* PAT # (`ci.actions-credential-scoping`), so the injected GITEA_TOKEN serves only its single # `actions/checkout`. This file was the one workflow #748 could not originally reach: editing it # re-pointed `ci-image-pin`'s `expected` at the editing commit and reddened a BLOCKING job, and its # own `paths:` made the edit publish an image. ersatztv#744 took this path out of both # (`ci.toolchain-image-publish-is-a-dispatch`), so the exemption that briefly existed here is DELETED # rather than documented — which is what ersatztv#835 asked for. permissions: code: read jobs: build: name: Build & push CI image # Moved off `small` with docker-build.yml's `build` (server-management#639). Being # "docker-only" made it look lightweight, but it is a full buildx of the .NET # toolchain image — the heaviest thing that ran in that lane. `small` is now # git-only and capped at 1g per job, which would OOM this build. # # Rare trigger (main pushes touching docker/ci, a weekly cron, and the occasional # branch dispatch), so it costs the ubuntu-latest lane almost nothing, and # ci-runner (.127) runs no prod workload. runs-on: ubuntu-latest env: CI_JOB_ROLE: none steps: - name: Checkout uses: actions/checkout@v4 with: # ersatztv#746's convention, applied here once #744 removed the reason it was skipped: # without it the action leaves a write-capable Authorization header in .git/config for # every later step. Nothing here pushes with git — the only git call is the # `rev-parse --short HEAD` below — and the repo is public, so the clone needs no # credential of its own. Guarded for every workflow by # scripts/tests/test_workflow_persist_credentials.py (ersatztv#835). persist-credentials: false # only docker/ci/Dockerfile is needed; no git describe/log here fetch-depth: 1 - name: Compute tags id: meta run: | set -euo pipefail SHORT=$(git rev-parse --short HEAD) # Always publish the immutable : — that is what docker-build.yml pins. TAGS=("${CI_IMAGE}:${SHORT}") # :latest is a convenience/floating pointer for humans and the weekly rebuild; jobs must # never consume it. Only main may move it — and since #744 the `push` trigger is # main-only, so on that path the branch check is satisfied by construction. It is now the # SOLE protection on the one event that never exercised it before: a `workflow_dispatch` # selects any ref, and the branch-side publish path documented in ci-cd.md runs exactly # that. Do not simplify this away on the reasoning that the trigger is already main-only. if [ "${GITHUB_REF}" = "refs/heads/main" ]; then TAGS+=("${CI_IMAGE}:latest") fi echo "short=${SHORT}" >> "$GITHUB_OUTPUT" { echo "tags<<__EOT__" printf '%s\n' "${TAGS[@]}" echo "__EOT__" } >> "$GITHUB_OUTPUT" printf 'tag: %s\n' "${TAGS[@]}" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: buildkitd-config-inline: | [registry."192.168.1.95:3000"] http = true - name: Login to Gitea registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_PASSWORD }} - name: Build and push uses: docker/build-push-action@v6 with: context: . file: ./docker/ci/Dockerfile platforms: linux/amd64 push: true provenance: false tags: ${{ steps.meta.outputs.tags }} # The scheduled rebuild must bypass the cache or it is pointless: `mode=max` buildcache # would restore the `apt-get update && apt-get install` layer verbatim and pull in none of # the base updates the cron exists to collect. Push-triggered builds keep the cache. no-cache: ${{ github.event_name == 'schedule' }} cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv-ci:buildcache cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv-ci:buildcache,mode=max,ignore-error=true # The Dockerfile's own build-time smoke test (dotnet --info, node, ffmpeg, ...) already ran # inside the build. This re-checks the *pushed* artifact end-to-end: that the registry copy # pulls and its toolchain runs, which is exactly what `container:` will do on every job. - name: Verify the pushed image run: | set -euo pipefail IMG="${CI_IMAGE}:${{ steps.meta.outputs.short }}" echo "Pulling ${IMG}" docker pull "$IMG" docker run --rm --entrypoint /bin/bash "$IMG" -euxc ' dotnet --version dotnet ef --version node --version ffmpeg -version | head -1 git --version python3 --version # reportgenerator --version exits 1 ("No report files specified"); probe the shim. command -v reportgenerator ' echo "CI image OK. Pin this in .gitea/workflows/docker-build.yml -> CI_IMAGE_REF:" echo " ${IMG}"