8.7 KiB
CI/CD
Gitea Actions (.gitea/workflows/) runs the pipeline; the test job runs
ci.sh — the exact gate you run locally:
| Workflow | Trigger | Does |
|---|---|---|
ci.yml |
push, any branch except main |
the full gate (bash ci.sh, a no-op on a docs-only branch), then build + push the app image |
release.yml |
push of a vX.Y.Z tag, or manual |
check the tag against HOST_API_VERSION, re-tag that commit's image as X.Y.Z, X.Y, latest (plus X once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger |
mirror.yml |
push to main or any tag, or manual |
force-push main + tags (pruning deleted ones) to the GitHub mirror |
registry-cleanup.yml |
nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
renovate.yml |
nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged |
main is not re-tested on push — its commits are meant to arrive already green from a
gated branch, so the status check to gate a merge on is CI / full-gate (push).
Merge gate on main (Gitea branch protection + repo merge settings, set via the API —
no repo files involved): direct pushes are blocked, changes land via PR only, the
CI / full-gate (push) status must be green (admins included), and the only merge style is
fast-forward-only — history stays linear and main's head is the exact commit hash of
the merged branch, which is why the branch's push-triggered status carries over.
Container images — after a green gate, ci.yml builds the app image and pushes it as
gitea.larvit.se/larvit/plainpages:<full commit hash>. Because merges are fast-forward-only, the
image for any main commit already exists from that commit's branch gate — build once, promote by
re-tagging. The build runs inside the required gate, so a missing/expired token or a registry outage
blocks all merges until restored; use a non-expiring token or track its expiry. Hash tags
accumulate one image per gated push, so nightly registry-cleanup.yml prunes them
(registry-cleanup/cleanup.ts defines what survives). Don't add a
pattern-based org cleanup rule for this package — its age/count heuristics can't see branch heads or
release tags and would delete images the workflow protects.
Releases — pushing a semver git tag (git tag v1.2.3 && git push origin v1.2.3) runs
release.yml, which pulls that commit's hash image and re-tags it 1.2.3, 1.2, latest and —
once the major reaches 1 — 1; nothing is rebuilt, so the released image is byte-identical to the
gated one. While the major is 0 the bare-major tag is skipped, because a 0.x minor is a contract
break and a moving :0 would carry one. It fails loud if no
hash image exists — release tags must point at a commit that went through the gate. The same four
tags sync to Docker Hub, releases only.
The contract check guards the tag before anything is published, refusing one
whose major.minor disagrees with HOST_API_VERSION and naming the value to set.
The Docker Hub overview is published by a separate publish-overview job from
release-tooling/dockerhub-overview.md.tmpl, with
{{VERSION}} rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
stale. The same job sets the page's short description. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
quick start stays a topology CI has actually run.
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
outage leaves the promotion green and the images untouched; and the page has its own door — run the
workflow manually with an overview_version input to republish it without cutting a release. That
input goes through the same contract check as a tag: a non-semver value, or one whose major.minor
disagrees with the tree being published, is refused. It uses
the same DOCKERHUB_TOKEN the image push uses, which is why that token needs the delete scope.
GitHub mirror — github.com/larvit/plainpages is
read-only; after every merge mirror.yml force-pushes main and all tags, overwriting any drift.
Tags go with --prune, so deleting one here deletes it there on the next run, and a tag created on
GitHub is swept away — cut releases on Gitea, never on the mirror. Branches other than main match
no refspec and are left alone.
Dependency updates — renovate.yml runs Renovate nightly
(self-hosted, this repo only) against renovate.json, bumping npm deps, Docker base
images, Gitea action versions, and the image tags pinned inside workflow run: steps (a custom regex
manager, so nothing pinned drifts unmanaged). Version-locked sets move together in one PR — the Ory
images, and the Playwright runner + its browser image — and every bump keeps the exact semver pin
exact. Each PR runs the normal gate on its renovate/* branch and automerges once
CI / full-gate (push) is green; only a red gate needs a human.
Auto-release on dependency updates — a second job in renovate.yml (auto-release) cuts one
vX.Y.Z tag per run covering the renovate-bot commits merged to main since the last tag, and
skips when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a
trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a
Release-Bump: <updateType> trailer onto the updates that reach a running Plainpages — the rules in
renovate.json name them — and
release-tooling/next-version.ts turns the highest one into the next
version; pre-1.0 it never auto-crosses into 1.0.0. Because the contract version is the release
version, an update big enough to reach a minor stops the job rather than tagging: bump
HOST_API_VERSION in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency major, since
nextVersion shifts it down to a 0.x minor. updateType rates the dependency's own jump,
so the trailer is an allowlist: an update outside those rules carries none and rides the next patch
release instead of escalating it. It is tag-only: the tag hands off to
release.yml, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by
the built-in Actions token wouldn't trigger it). HOST_API_VERSION is never touched here.
One-time CI setup
| Actions var / secret | Value |
|---|---|
DOCKER_REGISTRY_USER (var) + DOCKER_REGISTRY_TOKEN (secret) |
A Gitea account with package write in the larvit org, and its access token with read:package + write:package. Reused by registry-cleanup.yml. |
DOCKERHUB_USER (var) + DOCKERHUB_TOKEN (secret) |
The public larvit/plainpages Docker Hub repo, and a read/write/delete token scoped to that repository (an org access token, or one on a dedicated account — an account-wide PAT reaches every repo under it, and delete is destructive). Delete is what publishing the overview needs; pushing images alone would not. |
MIRROR_GITHUB_TOKEN (secret) |
A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its main must not block force-pushes and must carry no tag protection, which would reject the prune. |
RENOVATE_TOKEN (secret) |
The shared renovate@larvit.se bot's Gitea PAT, with write access to this repo. |
RENOVATE_GITHUB_TOKEN (secret) |
A scopeless (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. |
Gitea rejects secret names prefixed GITEA_/GITHUB_. Each job fails loud until its secret exists.
The container package is org-owned, so it lists under larvit/-/packages — link it to the repo's
Packages tab once: POST /api/v1/packages/larvit/container/plainpages/-/link/plainpages.
The runner — register one
act_runner in host mode labelled docker-host
(labels: ["docker-host:host"]) on a machine with Docker Engine + Compose, git, and Node +
github.com access. Runs must never overlap — the e2e stacks use fixed compose project names, the
devstack suite uses host networking, and the workflows share the Docker daemon's registry login — so
keep exactly one runner at capacity 1, with host ports 3000/4433 free.