Skip to content

Commit 07d8936

Browse files
authored
ci(docker): tag-only trigger for prod image build (#1392)
Drop `push: branches: [main]` and the `pull_request:` block from docker-image.yml so the workflow only fires on `*.*.*` tag pushes and manual `workflow_dispatch` reruns. Saves Actions minutes — the multi-arch (amd64 + qemu-emulated arm64) build is the most expensive job in the matrix and was burning ~8-15 minutes per main-push and per qualifying PR for floating `:main` / `:sha-<short>` images that self-hosters don't pull (the docs steer everyone to released semver tags). The image surface is small + stable, runtime-affecting changes always ship behind a release tag, and verifying-at-tag is sufficient. Trade-off the project explicitly accepts: a broken Dockerfile / entrypoint that landed via a green PR isn't caught by CI until the next tag push. AGENTS.md documents this — contributors who edit any file the runtime image bakes in MUST run the local `docker buildx build --platform linux/amd64,linux/arm64 -f docker/Dockerfile.prod .` command before opening the PR; the local command IS the per-PR gate. Companion cleanups so nothing else lies about the trigger surface: - Drop the `type=ref,event=branch` / `event=pr` / `type=sha` rules from docker/metadata-action — those produced the now-defunct `:main` and `:sha-<short>` floating tags that won't exist anymore. - Drop the `if: github.event_name != 'pull_request'` gates from the GHCR-login, cosign-installer, and cosign-sign steps — there is no PR trigger for them to short-circuit. `push: true` on the build-push step too (was conditional, no longer needs to be). - Update the operator-facing tag table in `docs/src/content/docs/getting-started/quickstart-docker.mdx` to drop the `:main` and `:sha-<short>` rows + add a paragraph pointing self-hosters who want a yet-unreleased fix at a local `docker buildx build`. - Update the Dockerfile's OCI-labels comment to reference workflow_dispatch / local builds instead of the gone tag patterns. - Update AGENTS.md: - Quality gates table heading: "seven gates on every PR" → "six gates on every PR"; the seventh row is annotated as release-only with the contributor-side responsibility. - "Keep the docs in sync" row for the prod-docker cluster spells out that the gate is release-only and the local command is the per-PR check. - "Prod Docker image specifics" block rewritten end-to-end around the tag-only contract — drops the dead path-filter paragraph + the verify-only-PR-build paragraph + the now- unnecessary cosign-PR-exemption paragraph. Stacked on #1391 because the workflow file lives on that branch.
1 parent ba3c887 commit 07d8936

4 files changed

Lines changed: 100 additions & 109 deletions

File tree

.github/workflows/docker-image.yml

Lines changed: 46 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@ name: Docker image
22

33
# Build + publish the production image to GHCR (#1381 deliverable 3).
44
#
5-
# Triggers:
6-
# - push to main → :main + :sha-<short>
5+
# Triggers (deliberately tag-only — see "Why no main / PR triggers"
6+
# below):
77
# - push to *.*.* tag → :<version> + :latest + :<major> + :<major>.<minor>
8-
# - workflow_dispatch → manual rerun (same tagging logic; useful
9-
# if a release publish failed midway)
8+
# - workflow_dispatch → manual rerun, dispatched against a tag
9+
# ref to rebuild a published release
10+
# (e.g. if a publish failed midway).
11+
# Dispatching from a non-tag ref is a
12+
# no-op for tagging — metadata-action
13+
# emits an empty tag set and the publish
14+
# step fails loudly.
1015
#
1116
# Multi-arch build via docker/build-push-action + buildx + qemu. Both
1217
# linux/amd64 and linux/arm64 are produced and pushed under a single
@@ -24,61 +29,29 @@ name: Docker image
2429
# --certificate-oidc-issuer=https://token.actions.githubusercontent.com`
2530
# without any pre-shared key.
2631
#
27-
# Path filter scope (PR trigger only — see MED-6 below for the
28-
# push-trigger rationale):
29-
# - docker/Dockerfile.prod, docker/php/prod-*, docker/apache/sbpp-prod.conf,
30-
# docker/php/sb-db.php — every file the runtime image bakes in
31-
# - web/health.php — the file the HEALTHCHECK pings
32-
# - web/init.php / web/init-recovery.php — the SBPP_CONFIG_PATH
33-
# plumbing + install-guard contract the entrypoint relies on
34-
# - web/install/includes/sql/** — schema and seed data the entrypoint
35-
# loads on first-boot install
36-
# - web/includes/Auth/Host.php — the SBPP_TRUSTED_PROXIES gate the
37-
# entrypoint's Apache config feeds (CRIT-4)
38-
# - web/includes/Telemetry/Telemetry.php — the SBPP_SKIP_TELEMETRY
39-
# hook health.php depends on (MED-4)
40-
# - composer.json/lock under web/ — vendor dependency churn
41-
# - .github/workflows/docker-image.yml — touch-the-workflow trigger
42-
#
43-
# MED-6 of the #1381 review: pre-fix, the `push:` block specified
44-
# `branches`, `tags`, AND `paths` at the same level. GitHub Actions
45-
# AND-combines them — a tag push only fires when the underlying
46-
# commit ALSO touches one of the listed paths. Release tags are
47-
# typically attached to commits that already shipped through `main`
48-
# (so the paths filter masked the prod-image surface change), which
49-
# means a release-tag push could silently NOT build the image and
50-
# `:latest` would lag behind `:main` indefinitely.
51-
#
52-
# Fix: drop the `paths` filter from `push:` entirely. Every push to
53-
# `main` AND every release-tag push triggers a full multi-arch build.
54-
# Action minutes for tag pushes are unconditional (correct for a
55-
# release surface); main pushes are rare (PRs aggregate via squash
56-
# merges) so the per-merge cost is acceptable. The `pull_request:`
57-
# block keeps its paths filter — PRs that don't touch the image
58-
# surface still don't rebuild, preserving the per-PR action-minute
59-
# optimisation that originally motivated the path filter.
32+
# Why no main / PR triggers:
33+
# Multi-arch (amd64 + qemu-emulated arm64) image builds are the most
34+
# expensive job in this repo's CI matrix — roughly 8-15 minutes per
35+
# run. Pre-fix this workflow ran on every push to main AND every PR
36+
# touching a long path filter, which on a busy week burned through a
37+
# disproportionate share of the project's free Actions minutes for
38+
# images that nobody pulls (the floating `:main` and per-commit
39+
# `:sha-<short>` tags were nominally documented as "bleeding edge"
40+
# but had no real consumers; self-hosters all pin to released semver
41+
# tags per the docs). The image surface is small + stable: changes
42+
# that affect the runtime contract (Dockerfile, entrypoint, schema
43+
# files, init bootstrap, health.php, trust-proxy + telemetry hooks)
44+
# are always shipped behind a release tag, so verifying-at-tag is
45+
# both sufficient and well-aligned with when self-hosters actually
46+
# pull a new image. Contributors who edit the Dockerfile / entrypoint
47+
# locally are expected to run the literal `docker buildx build`
48+
# command from the AGENTS.md "Quality gates" table to verify before
49+
# opening a PR.
6050

6151
on:
6252
push:
63-
branches:
64-
- main
6553
tags:
6654
- '*.*.*'
67-
pull_request:
68-
paths:
69-
- 'docker/Dockerfile.prod'
70-
- 'docker/php/prod-*'
71-
- 'docker/php/sb-db.php'
72-
- 'docker/apache/sbpp-prod.conf'
73-
- 'web/composer.json'
74-
- 'web/composer.lock'
75-
- 'web/health.php'
76-
- 'web/init.php'
77-
- 'web/init-recovery.php'
78-
- 'web/install/includes/sql/**'
79-
- 'web/includes/Auth/Host.php'
80-
- 'web/includes/Telemetry/Telemetry.php'
81-
- '.github/workflows/docker-image.yml'
8255
workflow_dispatch:
8356

8457
# `packages: write` — push to GHCR.
@@ -103,8 +76,7 @@ jobs:
10376
# buildx is the multi-platform driver. qemu provides the cross-arch
10477
# emulation that lets the amd64 GitHub-hosted runner produce an
10578
# arm64 image. The cost is roughly +2x build time on the arm64
106-
# leg vs native; acceptable for the publish cadence (per-tag +
107-
# main pushes only).
79+
# leg vs native; acceptable for the release-only publish cadence.
10880
- name: Set up QEMU
10981
uses: docker/setup-qemu-action@v3
11082
with:
@@ -115,37 +87,35 @@ jobs:
11587

11688
# GHCR push needs the actor's PAT — for actions/github-token, the
11789
# token's `packages: write` permission is granted by the job-level
118-
# `permissions:` block above. Pull-request builds (which never push)
119-
# skip login.
90+
# `permissions:` block above.
12091
- name: Log in to GHCR
121-
if: github.event_name != 'pull_request'
12292
uses: docker/login-action@v3
12393
with:
12494
registry: ${{ env.REGISTRY }}
12595
username: ${{ github.actor }}
12696
password: ${{ secrets.GITHUB_TOKEN }}
12797

12898
# docker/metadata-action computes the tag set from the trigger:
129-
# - main push → :main, :sha-<short>
13099
# - X.Y.Z tag → :X.Y.Z, :X.Y, :X, :latest
131-
# - PR → :pr-<num> (kept locally; not pushed)
132-
# - workflow_dispatch → :main (treated like a main rerun)
100+
# - workflow_dispatch → mirrors whatever ref it was dispatched
101+
# against (typically a tag ref to
102+
# rebuild a published release; a non-tag
103+
# dispatch produces an empty tag set
104+
# and the publish step fails loudly).
133105
#
134-
# The `:latest` tag is gated on `type=semver,pattern={{version}}`
135-
# so PR / main / dispatch builds don't accidentally claim it.
106+
# The `:latest` tag is gated on `startsWith(github.ref, 'refs/tags/')`
107+
# — a workflow_dispatch from a non-tag ref can't accidentally
108+
# claim it.
136109
- name: Compute image metadata (tags + labels)
137110
id: meta
138111
uses: docker/metadata-action@v5
139112
with:
140113
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
141114
tags: |
142-
type=ref,event=branch
143-
type=ref,event=pr
144115
type=semver,pattern={{version}}
145116
type=semver,pattern={{major}}.{{minor}}
146117
type=semver,pattern={{major}}
147118
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
148-
type=sha,prefix=sha-,format=short
149119
labels: |
150120
org.opencontainers.image.title=SourceBans++
151121
org.opencontainers.image.description=Self-hostable admin / ban / comms management for the Source engine — production image.
@@ -160,41 +130,36 @@ jobs:
160130
# are persisted in the GitHub Actions cache between runs — buildx
161131
# keys the cache by the Dockerfile + the build context's hash, so
162132
# a Composer-only change won't bust the apt-install layer of the
163-
# builder stage.
164-
#
165-
# `push: ${{ github.event_name != 'pull_request' }}` means PR
166-
# builds verify-build-only (build runs, but the image stays local
167-
# to the runner — no GHCR write). Main / tag / dispatch builds
168-
# publish.
133+
# builder stage. (Cache hit rate is naturally low on the tag-only
134+
# trigger — release tags are rare — but the cost of populating
135+
# the cache on a release build is amortised across the next
136+
# workflow_dispatch rerun for that tag.)
169137
- name: Build + push
170138
id: build
171139
uses: docker/build-push-action@v6
172140
with:
173141
context: .
174142
file: docker/Dockerfile.prod
175143
platforms: linux/amd64,linux/arm64
176-
push: ${{ github.event_name != 'pull_request' }}
144+
push: true
177145
tags: ${{ steps.meta.outputs.tags }}
178146
labels: ${{ steps.meta.outputs.labels }}
179147
cache-from: type=gha
180148
cache-to: type=gha,mode=max
181149
provenance: true
182150
sbom: true
183151

184-
# Cosign keyless signing — runs only when we actually pushed.
185-
# Each tag the manifest carries gets its own signature recorded
186-
# into Rekor. The `cosign sign --yes <ref>@<digest>` form is the
187-
# documented best-practice (signs the immutable digest, not the
188-
# mutable tag — so a future re-tag doesn't invalidate the
152+
# Cosign keyless signing. Each tag the manifest carries gets its
153+
# own signature recorded into Rekor. The `cosign sign --yes <ref>@<digest>`
154+
# form is the documented best-practice (signs the immutable digest,
155+
# not the mutable tag — so a future re-tag doesn't invalidate the
189156
# signature).
190157
- name: Install cosign
191-
if: github.event_name != 'pull_request'
192158
uses: sigstore/cosign-installer@v3
193159
with:
194160
cosign-release: 'v2.4.1'
195161

196162
- name: Sign image with cosign (keyless)
197-
if: github.event_name != 'pull_request'
198163
env:
199164
# NIT-1 of the #1381 review: `COSIGN_EXPERIMENTAL=1` was the
200165
# gate for keyless signing back when it was experimental

AGENTS.md

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ code change — never as a follow-up. CI doesn't gate this; it's on you.
5757
| Change the local dev stack (Docker, db-init, env vars) | `docker/README.md` first, link from `ARCHITECTURE.md` if it changes the dev mental model |
5858
| Edit user-facing install/quickstart | `docs/src/content/docs/getting-started/quickstart.mdx` (tarball flow) OR `quickstart-docker.mdx` (Docker flow). Keep the `<Tabs syncKey="install-path">` arms in `overview.mdx` + `prerequisites.mdx` consistent across the two paths (the README is a tiny landing page that links to docs — don't grow it back into a manual). |
5959
| Add or change a wizard step (page handler / View / template / shared helper) | `AGENTS.md` (Install wizard convention block) + the "Edit a step of the install wizard" row in "Where to find what" |
60-
| Touch `docker/Dockerfile.prod`, `docker/php/prod-*`, `docker/apache/sbpp-prod.conf`, `docker-compose.prod.yml`, `.env.example.prod`, `docker/caddy/Caddyfile.example`, or `web/health.php` | `AGENTS.md` (Quality gates: `docker-image.yml` row; "Where to find what": the "Build / extend the production Docker image" + "Deploy / configure the production Docker stack" rows) + `docs/src/content/docs/getting-started/quickstart-docker.mdx` (the operator-facing doc) + `docker/README.md` (dev-vs-prod pointer). The `Plugin build specifics` block in AGENTS.md has a sibling `Prod Docker image specifics` block — keep them in sync with the workflow's path filter / tag mapping / sign step. |
60+
| Touch `docker/Dockerfile.prod`, `docker/php/prod-*`, `docker/apache/sbpp-prod.conf`, `docker-compose.prod.yml`, `.env.example.prod`, `docker/caddy/Caddyfile.example`, or `web/health.php` | `AGENTS.md` (Quality gates: `docker-image.yml` row; "Where to find what": the "Build / extend the production Docker image" + "Deploy / configure the production Docker stack" rows) + `docs/src/content/docs/getting-started/quickstart-docker.mdx` (the operator-facing doc) + `docker/README.md` (dev-vs-prod pointer). The `Plugin build specifics` block in AGENTS.md has a sibling `Prod Docker image specifics` block — keep them in sync with the workflow's tag mapping / sign step. The Docker-image gate is **release-only** (fires on `*.*.*` tag pushes + manual `workflow_dispatch` reruns); contributors who edit the Dockerfile / entrypoint MUST run the `docker buildx build` command from the Quality gates row locally before opening the PR — there is no per-PR CI gate to catch a broken image build. |
6161
| Change a user-facing install / upgrade / troubleshooting flow (PHP or SourceMod version requirements, installer wizard steps, `config.php` behavior, `web/updater/` runner output, plugin `databases.cfg` / `sourcebans.cfg` shape, error messages a self-hoster will see) | The relevant page under `docs/src/content/docs/` (the Starlight site published at sbpp.github.io). |
6262
| Add or remove a config knob a self-hoster sets (`config.php` keys, `databases.cfg` fields, plugin convars users tune) | `docs/` page that documents that knob, plus the matching `docs/src/content/docs/updating/*.mdx` page if it's a breaking change between releases |
6363
| Ship a new feature with a self-hoster-visible setup step (Discord forwarder, demos, theming, etc.) | New page or section under the right `docs/` group + sidebar entry in `docs/astro.config.mjs` |
@@ -181,7 +181,10 @@ services:
181181

182182
## Quality gates
183183

184-
CI runs seven gates on every PR. Match them locally before opening one.
184+
CI runs six gates on every PR. Match them locally before opening one.
185+
A seventh gate (the production Docker image) runs **only on release
186+
tag pushes** — see the row's note + the `Prod Docker image specifics`
187+
block below for the rationale and the contributor-side responsibility.
185188

186189
| Gate | Local | CI workflow |
187190
| -------------- | ------------------------------------ | ---------------------- |
@@ -191,7 +194,7 @@ CI runs seven gates on every PR. Match them locally before opening one.
191194
| API contract | `./sbpp.sh composer api-contract` | `api-contract.yml` |
192195
| Playwright E2E | `./sbpp.sh e2e` | `e2e.yml` |
193196
| Plugin build | `(cd game/addons/sourcemod/scripting && spcomp -i include sbpp_*.sp)` | `plugin-build.yml` |
194-
| Prod Docker image | `docker buildx build --platform linux/amd64,linux/arm64 -f docker/Dockerfile.prod .` | `docker-image.yml` |
197+
| Prod Docker image (release-only) | `docker buildx build --platform linux/amd64,linux/arm64 -f docker/Dockerfile.prod .` | `docker-image.yml` (tag pushes + `workflow_dispatch` only — no per-PR run; contributor MUST run the local command before merging Dockerfile / entrypoint changes) |
195198

196199
PHPStan specifics:
197200

@@ -340,41 +343,57 @@ Plugin build specifics:
340343

341344
Prod Docker image specifics:
342345

343-
- Path-filtered to `docker/Dockerfile.prod`, `docker/php/prod-*`,
344-
`docker/apache/sbpp-prod.conf`, `web/composer.{json,lock}`,
345-
`web/health.php`, `web/init.php`, `web/init-recovery.php`, and
346-
the workflow file itself. A web/-only PR that doesn't touch those
347-
files skips the gate (the surface changes constantly; rebuilding
348-
the prod image on every PR would burn action minutes for changes
349-
the next release cycle picks up anyway). Main and tag pushes
350-
always rebuild.
346+
- **Trigger surface is deliberately narrow: tag pushes + manual
347+
`workflow_dispatch` only.** No `push: branches: [main]`, no
348+
`pull_request:`. A multi-arch (amd64 + qemu-emulated arm64) build
349+
is the most expensive job in this repo's CI matrix (~8-15 minutes
350+
per run); pre-narrow this workflow ran on every push to main AND
351+
every PR touching a long path filter, burning a disproportionate
352+
share of the project's free Actions minutes for floating `:main` /
353+
`:sha-<short>` tags that nobody pulls (self-hosters all pin to
354+
released semver tags per the docs). The image surface is small +
355+
stable — runtime-affecting changes (Dockerfile, entrypoint, schema
356+
files, init bootstrap, health.php, trust-proxy + telemetry hooks)
357+
always ship behind a release tag, so verifying-at-tag is sufficient
358+
AND well-aligned with when self-hosters actually pull a new image.
359+
The trade-off the project explicitly accepts: a Dockerfile /
360+
entrypoint regression that landed via a green PR isn't caught by
361+
CI until the next `*.*.*` tag push. **Contributors who edit any
362+
file the runtime image bakes in (Dockerfile, entrypoint, php.ini,
363+
Apache conf, sb-db.php, health.php, schema files, init bootstrap,
364+
Auth/Host.php, Telemetry.php) MUST run the literal `docker buildx
365+
build --platform linux/amd64,linux/arm64 -f docker/Dockerfile.prod .`
366+
command from the Quality gates table locally before opening the
367+
PR — the local command IS the per-PR gate.**
351368
- Multi-arch (linux/amd64 + linux/arm64) via `docker/setup-qemu-action@v3`
352369
+ `docker/setup-buildx-action@v3`. The arm64 leg runs under qemu
353370
on the amd64 GitHub-hosted runner — roughly 2x build time vs
354-
native, acceptable for the publish cadence.
355-
- PR builds are **verify-only** — `push: ${{ github.event_name != 'pull_request' }}`
356-
in `build-push-action` runs the build to verify-it-builds without
357-
pushing to GHCR. Main / tag / dispatch pushes publish + sign.
358-
- Tag mapping via `docker/metadata-action@v5`: `main` push →
359-
`:main` + `:sha-<short>`; `X.Y.Z` tag → `:X.Y.Z` + `:X.Y` + `:X`
360-
+ `:latest`. `:latest` is gated on `startsWith(github.ref, 'refs/tags/')`
361-
so main pushes can't accidentally claim it.
371+
native, acceptable for the release-only publish cadence.
372+
- Tag mapping via `docker/metadata-action@v5`: `X.Y.Z` tag → `:X.Y.Z`
373+
+ `:X.Y` + `:X` + `:latest`. `:latest` is gated on
374+
`startsWith(github.ref, 'refs/tags/')` so a `workflow_dispatch`
375+
from a non-tag ref can't accidentally claim it. There are no
376+
rolling `:main` / `:sha-<short>` tags — see the table in
377+
`docs/src/content/docs/getting-started/quickstart-docker.mdx`
378+
for the operator-facing tag list. A `workflow_dispatch` from a
379+
non-tag ref produces an empty tag set and `docker push` fails
380+
loudly (the documented "rebuilding a non-released ref isn't a
381+
meaningful operation" gate).
362382
- Sigstore cosign signs each tag against the immutable digest
363383
(`<image>@<digest>`, not the mutable `<image>:<tag>`) in keyless
364384
/ OIDC mode. The job-level `id-token: write` permission is what
365385
enables the OIDC token request; without it cosign can't get a
366386
Fulcio cert and signing fails closed. Verifiers pin both the
367387
identity (workflow path) and the issuer (GitHub Actions OIDC
368388
endpoint) — see `docs/src/content/docs/getting-started/quickstart-docker.mdx`
369-
for the canonical `cosign verify` command.
370-
- The cosign step is gated on `github.event_name != 'pull_request'`
371-
for the same reason as the push step — PR builds never publish,
372-
so there's nothing to sign.
389+
for the canonical `cosign verify` command. The cosign step is
390+
always-on (no PR exemption is needed — PRs don't trigger the
391+
workflow at all under the tag-only contract).
373392
- No local `./sbpp.sh` wrapper. The dev stack doesn't ship a way
374393
to invoke `docker buildx` from inside the dev container itself
375394
(no Docker-in-Docker), and the prod image build is a host-side
376-
workflow. Contributors who want to verify a local build run the
377-
literal command from the gates table.
395+
workflow. Contributors verify a local build with the literal
396+
command from the gates table.
378397

379398
## Conventions
380399

0 commit comments

Comments
 (0)