Add the release pipeline with a tag-based manual gate and Prod stack (#89)
All checks were successful
CD / Build and push images (push) Successful in 1m5s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m6s
CD / Promote to Int (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 3m33s
CI / Build container images (push) Has been skipped
Release / Build release images and notes (push) Successful in 1m1s
Prod deploy / Deploy the released images to Prod (push) Successful in 14s
CI / Auth e2e pack (push) Successful in 5m31s
CI / Import/export fidelity gate (push) Successful in 47s

Pushing vX.Y.Z builds the four semver images and publishes a Gitea
release whose notes list the changes since the previous release with a
migration call-out derived from the migrations diff (the repo is
trunk-based — commit subjects stand in for PR titles). Deploying to Prod
is a separate human act: pushing prod-vX.Y.Z-<suffix> — Gitea 1.22 has
no environment approvals, so the tag push is the gate — verifies the
release images exist, pins TAG in the Prod .env, restarts the stack, and
waits for readiness; rollbacks are new suffix tags on the previous
release. The Prod stage is provisioned on ONE (ports 8120-8122, secrets
generated on the host, full backup profile); deploy/go-live.md carries
the executed mechanics and the operator checklist that blocks the DNS
switch (DNS, Caddy block, wizard/SMTP, legal texts, monitors, Prod
drill, BASEL mirror).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Fable 5 2026-07-12 00:17:13 +02:00
parent 92c71c3f12
commit 28f05e270d
3 changed files with 187 additions and 0 deletions

View File

@ -0,0 +1,61 @@
# Prod deploy behind the manual gate (issue #89): after reviewing a
# published release vX.Y.Z, a human pushes `prod-vX.Y.Z-<suffix>` — that
# tag push IS the approval (Gitea 1.22 has no environment approvals; move
# to a real environment gate on 1.23+). The suffix keeps tags unique, so
# re-deploys and rollbacks are just new tags pointing at the same release:
# git tag prod-v0.1.0-initial && git push origin prod-v0.1.0-initial
# git tag prod-v0.1.0-rollback1 && git push origin prod-v0.1.0-rollback1
name: Prod deploy
on:
push:
tags: ['prod-v*']
env:
IMAGE_BASE: gitea.101010.cloud/stwaidele/dorfteich
DEPLOY_HOST: one.101010.cloud
jobs:
deploy-prod:
name: Deploy the released images to Prod
runs-on: ubuntu-latest
steps:
- name: Resolve the release version from the tag
run: |
VERSION=$(echo "$GITHUB_REF_NAME" | grep -o 'v[0-9]\+\.[0-9]\+\.[0-9]\+' | head -n1)
[ -n "$VERSION" ] || { echo "tag $GITHUB_REF_NAME carries no vX.Y.Z version" >&2; exit 1; }
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "deploying release $VERSION"
- name: Verify the release images exist (never deploy unbuilt tags)
run: |
printf '%s' "${{ secrets.REGISTRY_TOKEN }}" | tr -d '[:space:]' | docker login gitea.101010.cloud -u fable-5 --password-stdin
for app in web api collab backup; do
docker buildx imagetools inspect $IMAGE_BASE-$app:$VERSION > /dev/null
done
- name: Set up SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY_TEST }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "${{ secrets.DEPLOY_HOST_KEY }}" > ~/.ssh/known_hosts
- name: Pin the version and restart the Prod stack
run: |
ssh deploy@$DEPLOY_HOST "cd /home/DOCKER/dorfteich-prod \
&& sed -i 's/^TAG=.*/TAG=$VERSION/' .env \
&& docker compose pull --quiet && docker compose up -d --remove-orphans \
&& docker compose ps"
- name: Wait for Prod readiness
run: |
for i in $(seq 1 45); do
body=$(ssh deploy@$DEPLOY_HOST 'curl -s http://127.0.0.1:8121/api/v1/readyz' || true)
case "$body" in
*'"database","status":"ok"'*) echo "readyz: $body"; exit 0 ;;
esac
sleep 4
done
echo "Prod did not become ready" >&2; exit 1

View File

@ -0,0 +1,65 @@
# Release build (ADR 0014, issue #89): pushing a semver tag `vX.Y.Z` builds
# and pushes the immutable release images and publishes a Gitea release
# whose notes list the changes since the previous release, with a call-out
# when the release contains database migrations (the `migration` marker the
# update guide promises). Deploying to Prod is a SEPARATE, manual step:
# after reviewing the release, push a `prod-vX.Y.Z-<suffix>` tag
# (prod-deploy.yml) — that tag push is the manual approval gate, since
# Gitea 1.22 has no environment approvals (revisit on 1.23+).
name: Release
on:
push:
tags: ['v*.*.*']
env:
IMAGE_BASE: gitea.101010.cloud/stwaidele/dorfteich
jobs:
build-release:
name: Build release images and notes
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Log in to the Gitea registry
run: printf '%s' "${{ secrets.REGISTRY_TOKEN }}" | tr -d '[:space:]' | docker login gitea.101010.cloud -u fable-5 --password-stdin
- name: Build and push semver images
run: |
TAG=${GITHUB_REF_NAME}
for app in web api collab backup; do
docker build -f apps/$app/Dockerfile --build-arg APP_VERSION=$TAG \
-t $IMAGE_BASE-$app:$TAG .
docker push $IMAGE_BASE-$app:$TAG
done
- name: Generate release notes and publish the release
run: |
TAG=${GITHUB_REF_NAME}
PREV=$(git tag --list 'v*.*.*' --sort=-v:refname | grep -vx "$TAG" | head -n1 || true)
RANGE=${PREV:+$PREV..}$TAG
{
echo "## Changes since ${PREV:-the beginning}"
echo
git log --no-merges --pretty='- %s' $RANGE
echo
if git diff --name-only ${PREV:-$(git hash-object -t tree /dev/null)} $TAG -- apps/api/prisma/migrations/ | grep -q .; then
echo '> ⚠️ **migration** — this release applies database migrations automatically at api start. Downgrade window: one minor release (docs/self-hosting).'
else
echo '_No database migrations in this release._'
fi
} > notes.md
TAG=$TAG docker run --rm -i -e TAG node:22.15-alpine node -e \
'const fs=require("fs");const body=fs.readFileSync(0,"utf8");process.stdout.write(JSON.stringify({tag_name:process.env.TAG,name:process.env.TAG,body}))' \
< notes.md > release.json
curl -sf -X POST \
-H "Authorization: token ${{ github.token }}" \
-H 'Content-Type: application/json' \
--data @release.json \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" \
> /dev/null && echo "release $TAG published"

61
deploy/go-live.md Normal file
View File

@ -0,0 +1,61 @@
# Go-live checklist — dorfteich.online (issue #89)
The release pipeline and the Prod stack are ready; going live is the
operator's call. Executed items carry their date; unchecked items block
the DNS switch.
## Release mechanics (in place)
- [x] 2026-07-12 — **Release workflow**: pushing `vX.Y.Z` builds the four
semver images and publishes a Gitea release whose notes list the
changes since the previous release and call out database
migrations. (Notes derive from commit subjects — the repo is
trunk-based without PRs; the issue's "PR titles" have no source
here, documented deviation.)
- [x] 2026-07-12 — **Manual gate + Prod deploy**: pushing
`prod-vX.Y.Z-<suffix>` (the human approval — Gitea 1.22 has no
environment gates; revisit on 1.23+) verifies the release images
exist, pins `TAG` in the Prod `.env`, pulls, restarts, and waits
for readiness. Re-deploys/rollbacks are new suffix tags on the
target release.
- [x] 2026-07-12 — **Prod stack provisioned** on ONE
(`/home/DOCKER/dorfteich-prod/`, ports 81208122, secrets generated
on the host, full backup profile: 30 d retention, failure mail).
The host decision "ONE" is the working default — recorded here; if
the owner picks different iron at go-live, the stack directory
moves per the restore runbook's relocation procedure.
- [x] 2026-07-12 — **Test release walked the full gate**: `v0.1.0` built
and published → `prod-v0.1.0-initial` deployed → readyz green.
- [x] 2026-07-12 — **Rollback tested on Prod**: `v0.1.1` deployed, then
`prod-v0.1.0-rollback1` returned the stack to `v0.1.0`, readyz
green (one-release downgrade window per docs/self-hosting).
## Operator items (block the DNS switch)
- [ ] **Prod host decision confirmed** (working default: ONE, where
Test/Int and the registry already live — one host, no BASEL yet).
- [ ] **DNS**: point `dorfteich.online` at ONE (today it still points at
the old VPS `188.245.116.44`).
- [ ] **Caddy**: activate the prepared `dorfteich.online` block in
`/etc/caddy/Caddyfile` on ONE (ports 81208122) after DNS,
`systemctl reload caddy`, verify the Let's Encrypt certificate —
this also closes the #88 item "ACME exercised on a real domain".
- [ ] **First-run setup**: run the wizard on the fresh instance (or set
the `SETUP_ADMIN_*` preseed in the Prod `.env` before first boot) —
creates the Site Admin.
- [ ] **Prod SMTP**: configure a production relay (wizard step or `.env`);
the Prod `.env` ships without SMTP on purpose.
- [ ] **Legal texts** (#82): paste the real dorfteich.online imprint and
privacy policy in Administration → Legal pages.
- [ ] **Monitors** (#85): create the Prod monitor set from
`deploy/monitoring.md` in Uptime-Kuma, with alerting; verify one
test alert fires.
- [ ] **Backups verified on Prod** (#87): switch the drill's
`DRILL_SOURCE_VOLUME` to `dorfteich-prod_backups` and run one
on-demand drill (`drill-*` tag) green.
- [ ] **Off-host mirror** (#84): blocked on the ONE→BASEL WireGuard
tunnel (Handoff-Wireguard.md) — going live without it is a
conscious, temporary risk acceptance (local 30 d backups only).
- [ ] Optional hygiene: a dedicated `DEPLOY_SSH_KEY_PROD` secret (the
workflows currently reuse the host-wide deploy key stored as
`DEPLOY_SSH_KEY_TEST`).