Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 36 additions & 42 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,54 +2,51 @@ name: Docker image

# Build + publish the production image to GHCR (#1381 deliverable 3).
#
# Triggers (deliberately tag-only — see "Why no main / PR triggers"
# below):
# Triggers:
# - push to *.*.* tag → :<version> + :latest + :<major> + :<major>.<minor>
# - workflow_dispatch → manual rerun, dispatched against a tag
# ref to rebuild a published release
# (e.g. if a publish failed midway).
# Dispatching from a non-tag ref is a
# - push to main → :main (rolling tip; not :latest)
# - workflow_dispatch → manual rerun. Dispatch against a tag
# ref to rebuild a published release, or
# against main to refresh :main.
# Dispatching from any other ref is a
# no-op for tagging — metadata-action
# emits an empty tag set and the publish
# step fails loudly.
#
# IMAGE_NAME is ${{ github.repository }}, so this workflow publishes
# to ghcr.io/<owner>/<repo> (ghcr.io/srcdslab/sourcebans-pp here).
#
# Multi-arch build via docker/build-push-action + buildx + qemu. Both
# linux/amd64 and linux/arm64 are produced and pushed under a single
# manifest list, so `docker pull ghcr.io/sbpp/sourcebans-pp:latest` on
# either an Apple Silicon dev machine or a typical x86_64 VPS resolves
# to the right image without operator awareness.
# manifest list, so `docker pull ghcr.io/srcdslab/sourcebans-pp:latest`
# on either an Apple Silicon dev machine or a typical x86_64 VPS
# resolves to the right image without operator awareness.
#
# Signed via Sigstore cosign (keyless / OIDC). The ID-token permission
# below is what enables the keyless signing flow: cosign requests an
# OIDC token from GitHub's issuer, exchanges it with Fulcio for a
# short-lived signing cert, signs the image's manifest, and records
# the signature into Rekor (the public transparency log). Verifiers
# can `cosign verify ghcr.io/sbpp/sourcebans-pp:<tag>
# --certificate-identity-regexp=https://github.com/sbpp/sourcebans-pp/...
# can `cosign verify ghcr.io/srcdslab/sourcebans-pp:<tag>
# --certificate-identity-regexp=https://github.com/srcdslab/sourcebans-pp/...
# --certificate-oidc-issuer=https://token.actions.githubusercontent.com`
# without any pre-shared key.
#
# Why no main / PR triggers:
# Why no PR triggers:
# Multi-arch (amd64 + qemu-emulated arm64) image builds are the most
# expensive job in this repo's CI matrix — roughly 8-15 minutes per
# run. Pre-fix this workflow ran on every push to main AND every PR
# touching a long path filter, which on a busy week burned through a
# disproportionate share of the project's free Actions minutes for
# images that nobody pulls (the floating `:main` and per-commit
# `:sha-<short>` tags were nominally documented as "bleeding edge"
# but had no real consumers; self-hosters all pin to released semver
# tags per the docs). The image surface is small + stable: changes
# that affect the runtime contract (Dockerfile, entrypoint, schema
# files, init bootstrap, health.php, trust-proxy + telemetry hooks)
# are always shipped behind a release tag, so verifying-at-tag is
# both sufficient and well-aligned with when self-hosters actually
# pull a new image. Contributors who edit the Dockerfile / entrypoint
# locally are expected to run the literal `docker buildx build`
# command from the AGENTS.md "Quality gates" table to verify before
# opening a PR.
# run. PRs still do not pay that cost. Push-to-main publishes a
# floating `:main` tag so operators who want the tip of this repo
# can pull it without waiting for a semver cut. `:latest` stays
# bound to `*.*.*` tags only. Contributors who edit the Dockerfile /
# entrypoint locally are expected to run the literal
# `docker buildx build` command from the AGENTS.md "Quality gates"
# table to verify before opening a PR.

on:
push:
branches:
- main
tags:
- '*.*.*'
workflow_dispatch:
Expand Down Expand Up @@ -97,15 +94,13 @@ jobs:

# docker/metadata-action computes the tag set from the trigger:
# - X.Y.Z tag → :X.Y.Z, :X.Y, :X, :latest
# - workflow_dispatch → mirrors whatever ref it was dispatched
# against (typically a tag ref to
# rebuild a published release; a non-tag
# dispatch produces an empty tag set
# and the publish step fails loudly).
# - push / dispatch on main → :main
# - workflow_dispatch on a tag ref → same as the tag push
# - workflow_dispatch on any other ref → empty tag set,
# publish step fails loudly.
#
# The `:latest` tag is gated on `startsWith(github.ref, 'refs/tags/')`
# — a workflow_dispatch from a non-tag ref can't accidentally
# claim it.
# — a push or workflow_dispatch from main cannot claim it.
- name: Compute image metadata (tags + labels)
id: meta
uses: docker/metadata-action@v5
Expand All @@ -116,12 +111,13 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=raw,value=main,enable=${{ github.ref == 'refs/heads/main' }}
labels: |
org.opencontainers.image.title=SourceBans++
org.opencontainers.image.description=Self-hostable admin / ban / comms management for the Source engine production image.
org.opencontainers.image.url=https://sbpp.github.io
org.opencontainers.image.source=https://github.com/sbpp/sourcebans-pp
org.opencontainers.image.documentation=https://sbpp.github.io/getting-started/quickstart-docker/
org.opencontainers.image.description=Self-hostable admin / ban / comms management for the Source engine, production image.
org.opencontainers.image.url=https://github.com/${{ github.repository }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.documentation=https://github.com/${{ github.repository }}/tree/main/docs
org.opencontainers.image.licenses=Elastic-2.0
org.opencontainers.image.vendor=SourceBans++ Dev Team
org.opencontainers.image.revision=${{ github.sha }}
Expand All @@ -130,10 +126,8 @@ jobs:
# are persisted in the GitHub Actions cache between runs — buildx
# keys the cache by the Dockerfile + the build context's hash, so
# a Composer-only change won't bust the apt-install layer of the
# builder stage. (Cache hit rate is naturally low on the tag-only
# trigger — release tags are rare — but the cost of populating
# the cache on a release build is amortised across the next
# workflow_dispatch rerun for that tag.)
# builder stage. Main-branch publishes raise the cache hit
# rate; tag rebuilds still amortise across workflow_dispatch.
- name: Build + push
id: build
uses: docker/build-push-action@v6
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/docs-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
# stays out of the way of PRs that don't touch the docs site.
#
# Why it exists: catches `npm run build` failures (broken Markdown,
# missing assets, busted Starlight config) BEFORE the deploy trigger
# fires on main. Pair gate with docs-deploy-trigger.yml.
# missing assets, busted Starlight config) on PRs and merges that
# touch docs/. This repo has no Pages sibling; docs-deploy-trigger.yml
# is a documented no-op.

name: docs-build

Expand Down
94 changes: 13 additions & 81 deletions .github/workflows/docs-deploy-trigger.yml
Original file line number Diff line number Diff line change
@@ -1,39 +1,14 @@
# docs-deploy-trigger — fire a repository_dispatch into sbpp.github.io
# whenever main moves under docs/, kicking the Pages deploy in the
# sibling repo.
# docs-deploy-trigger — intentionally a no-op in this repo.
#
# Cadence: only on push to main with a docs/** path filter. PRs use
# docs-build.yml to validate; this workflow is the production trigger.
# The sbpp copy of this file dispatches `docs-changed` into
# sbpp/sbpp.github.io so Pages publishes the Starlight site. This
# repo has no sibling Pages repo and must not fire that dispatch
# (it would require a PAT into someone else's org).
#
# Required repo configuration BEFORE this workflow does anything (one-time
# cutover step):
#
# - Create a fine-grained PAT scoped to `sbpp/sbpp.github.io` only,
# with the `Actions: Read and write` repository permission. (Classic
# PATs work too, but the fine-grained variant is strictly narrower
# and the right default.) Max expiry is one year — set a calendar
# reminder to rotate.
# - Repo SECRET `DOCS_DEPLOY_PAT` = the token value.
#
# Until `DOCS_DEPLOY_PAT` is set, the dispatch step below is skipped via
# a precheck step that reads the secret into `env:` (where `secrets.*` IS
# allowed), tests for presence in shell, and emits a step output the
# dispatch step gates on. Every push to `docs/**` shows up as a green
# run with the dispatch step marked "Skipped", instead of red-failing on
# a missing credential. This stops the original anti-pattern
# (#1339-followup) where the dispatch hard-erred and an operator who
# hasn't done the cutover yet sees a stream of confusing failures.
#
# The naive shape (`if: secrets.DOCS_DEPLOY_PAT != ''` on the dispatch
# step itself) does NOT work: `secrets.*` is unavailable in `if:` at
# every scope (workflow / job / step) per the GitHub Actions context
# table, and the parser rejects the workflow file with
# "Unrecognized named-value: 'secrets'" before any job runs — the run
# fails red on every push including dependabot branches, defeating the
# whole point of the guard.
#
# The deploy shell in sbpp.github.io also has a `workflow_dispatch`
# trigger as a manual fallback while the PAT is pending.
# docs-build.yml still validates `npm run build` on PRs and merges
# that touch docs/. To publish a docs site later, replace this file
# with a real deploy (GitHub Pages from docs/, or a sibling repo)
# rather than restoring the sbpp/sbpp.github.io dispatch.

name: docs-deploy-trigger

Expand All @@ -45,55 +20,12 @@ on:
- 'docs/**'
- '.github/workflows/docs-deploy-trigger.yml'

# Allow at most one in-flight trigger at a time; if a second push lands
# while the first is still running, queue the second and skip any
# intermediate runs. The dispatched workflow in sbpp.github.io is
# itself idempotent (it always builds from sourcebans-pp@main), so
# coalescing here is safe.
concurrency:
group: docs-deploy-trigger
cancel-in-progress: false

jobs:
trigger:
name: Dispatch docs-changed event
skip:
name: No sibling Pages deploy
runs-on: ubuntu-24.04
permissions: {}

steps:
# `secrets.*` isn't available in `if:` at any scope, so we can't
# gate the dispatch step directly on the PAT being configured.
# Read the secret into the precheck step's `env:` (where
# `secrets.*` IS allowed), test for presence in shell, and emit
# a `configured=true|false` step output. The dispatch step then
# gates on `steps.pat.outputs.configured == 'true'` — `steps.*`
# IS available in `if:`, so the gate works and the dispatch step
# cleanly shows as "Skipped" until the secret is set.
- name: Check whether DOCS_DEPLOY_PAT is configured
id: pat
env:
DOCS_DEPLOY_PAT: ${{ secrets.DOCS_DEPLOY_PAT }}
run: |
if [ -n "$DOCS_DEPLOY_PAT" ]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
else
echo "configured=false" >> "$GITHUB_OUTPUT"
echo "::notice title=docs-deploy-trigger::DOCS_DEPLOY_PAT is unset; skipping repository_dispatch into sbpp.github.io. Configure the secret to enable automatic Pages deploys (the deploy shell still has a manual workflow_dispatch trigger as a fallback)."
fi

# The dispatched workflow in sbpp.github.io listens for
# `event_type: docs-changed`. The client_payload carries the
# commit SHA and ref so the deploy job can pin its sourcebans-pp
# checkout to the exact commit that fired the dispatch (race
# guard for back-to-back pushes).
- name: Dispatch repository_dispatch into sbpp.github.io
if: steps.pat.outputs.configured == 'true'
env:
GH_TOKEN: ${{ secrets.DOCS_DEPLOY_PAT }}
- name: Skip
run: |
gh api repos/sbpp/sbpp.github.io/dispatches \
--method POST \
--field event_type=docs-changed \
--field 'client_payload[source_repo]=${{ github.repository }}' \
--field 'client_payload[source_sha]=${{ github.sha }}' \
--field 'client_payload[source_ref]=${{ github.ref }}'
echo "::notice title=docs-deploy-trigger::No Pages sibling is configured. Docs sources live under docs/; docs-build.yml still validates the Astro build."
Loading
Loading