Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 26s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m46s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m4s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m16s
`ci-image.yml`'s `on.push.paths` decides which pushes to `main` publish a toolchain image; `ci-image-pin`'s `git log` pathspec decides what the pin must name. #744 removed the shared self-reference that kept them in step, leaving the agreement carried by three prose comments, and divergence is silent and green in the dangerous direction. The guard derives both lists from the workflow documents and compares them for set equality in both directions. The comparison is deliberately narrow: it accepts a publish entry spelled exactly `<dir>/**` against a pathspec entry spelled exactly `<dir>`, segments restricted to `[A-Za-z0-9._-]`, and raises on every other spelling rather than deciding what that spelling would have selected. That narrowness is the substance. Measured against Gitea 1.27.1's own in-tree compiler (`modules/actions/workflowpattern` -> `modules/glob.CompileWorkflow`) and real git: a bare `docker/ci` in `paths:` compiles to an anchored `^docker/ci` and selects none of the directory's contents while the git pathspec `docker/ci` selects all of them; `<file>/**` matches nothing while the pathspec `<file>` tracks the file; a leading `/` is literal to Gitea while git refuses it outright. A canonicaliser mapping the two dialects onto one string form was built twice and defeated twice, each repair surfacing another spelling, so it was deleted rather than extended per `testing.verification-code-needs-its-own-proof`. The guard also asserts from the git index that each named path really is a directory, since `<file>/**` and the pathspec `<file>` spell the same string; takes the pathspec from the `git log` assignment rather than any `git log` in the job; refuses a `<<` token on a code line (a herestring excluded) and a second bare `--`; and treats an absent and an empty `paths:` alike, because Gitea's `Skip` returns false on an empty sequence, so `paths: []` filters nothing and every push publishes. The docstring states the boundaries rather than implying coverage: the guard compares the pathspec the pin job writes and does not establish that the staleness comparison consumes it, and a descendant whose path below `<dir>` contains a newline is matched by the git pathspec but not by the publish pattern. Verified by nine independent cold-review rounds, none of which found a false green; the last fuzzed 27,720 publish/pathspec pairs against a port of the deployed compiler and real `git ls-files`. fixes #855
216 lines
12 KiB
YAML
216 lines
12 KiB
YAML
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 :<sha>.
|
|
#
|
|
# push to MAIN touching docker/ci/** -> :<short-sha> + :latest
|
|
# workflow_dispatch on main -> :<short-sha> of main's HEAD + :latest
|
|
# workflow_dispatch on a branch -> :<short-sha> 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 :<sha>, 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:<sha>` 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 — `<dir>/**` here against the pathspec `<dir>` — 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 :<sha> (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 :<sha> — 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}"
|