CI: move to Gitea Actions; build and gate off the box, deploy over SSH
ci / checks (pull_request) Successful in 16s
ci / image (pull_request) Failing after 2m2s
ci / deploy (pull_request) Skipped
ci / swift (pull_request) Successful in 10m44s

.gitlab-ci.yml becomes .gitea/workflows/ci.yml on mac1/mac2 (amd64), with
the same checks: secret sweep, swift build and test on swift:6.1-noble,
section 8 conformance at the pinned e2e commit, doc-page hashes, the
configuration doc and package completeness (HEAD variants advisory).

The image is built with --build-arg FN_RELEASE=<sha> and the staging gate
(Scripts/ci-staging-gate.sh, same checks as staging-gate.sh) runs against
that image before it is pushed. On main, deploy/gitea-deploy.sh pulls the
gated image on the box, dumps the database, tags it flamenet-relay:prod,
checks the edge and key_log row count, rolls back to :previous on failure,
and recreates the test fleet. The self-host compose pulls from
git.extant2000.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-10-13 14:05:48 -04:00
co-authored by Claude Opus 5.5
parent 836d21dfb3
commit 93d0986a2b
7 changed files with 371 additions and 335 deletions
+146
View File
@@ -0,0 +1,146 @@
# flamenet-relay CI/CD on Gitea Actions (mac1/mac2, amd64).
#
# Replaces .gitlab-ci.yml (runner ovh-flamenetmessenger on the production
# box) and the build half of Scripts/staging-gate.sh, which compiled the relay
# on the box from a copied src/ tree. Now:
#
# test secret sweep, swift build + test (swift:6.1-noble, matching the
# Dockerfile), section 8 conformance at the pinned e2e commit,
# generated-page hashes, configuration doc, package completeness.
# conformance-head and doc-pages-head stay advisory.
# image docker build with FN_RELEASE=<sha>, then the staging gate
# (Scripts/ci-staging-gate.sh) against that exact image, then push
# :<short sha> and :latest to git.extant2000.com/extant2000/flamenet-relay.
# deploy main only: deploy/gitea-deploy.sh on the box pulls the gated
# image, tags it flamenet-relay:prod, checks the edge, rolls back
# to :previous on failure, then recreates the test fleet.
#
# Secrets never reach the image or CI: /srv/prod/messenger/.env on the box is
# the source of truth and nothing here writes it.
name: ci
on:
push:
branches: [main]
pull_request:
env:
REGISTRY: git.extant2000.com
IMAGE: git.extant2000.com/extant2000/flamenet-relay
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 20 }
- name: secret sweep
run: bash Scripts/secret-sweep.sh
- name: package complete
run: |
for f in Package.swift Dockerfile LICENSE .env.example; do test -f "$f" || { echo "missing $f"; exit 1; }; done
test -d Sources && test -d Tests
- name: configuration doc
run: python3 Scripts/check-configuration-doc.py
- name: e2e at the pin
run: |
bash Scripts/ci-fetch-e2e.sh /tmp/e2e
- name: doc pages
run: python3 Scripts/check-doc-pages.py --engine /tmp/e2e
- name: doc pages at e2e HEAD (advisory)
continue-on-error: true
run: |
bash Scripts/ci-fetch-e2e.sh /tmp/e2e-head HEAD
echo "pinned $(cat .e2e-ref)"
python3 Scripts/check-doc-pages.py --engine /tmp/e2e-head
swift:
runs-on: ubuntu-latest
container: swift:6.1-noble
steps:
- name: checkout
run: |
git clone -q "${{ gitea.server_url }}/${{ gitea.repository }}.git" src
cd src && git fetch -q origin "${{ gitea.sha }}" && git checkout -q "${{ gitea.sha }}"
- name: build
run: cd src && swift build
# swift-testing results print separately from XCTest's "Executed 0
# tests"; an empty run must not pass.
- name: test
run: |
cd src
swift test 2>&1 | tee /tmp/test.log
grep -qE 'Test run with [0-9]+ tests? .*passed' /tmp/test.log || { echo "no passing swift-testing run found"; exit 1; }
# Node 22 from nodejs.org (Noble ships 18, which lacks globalThis.crypto
# for ML-DSA). The swift image has no curl or xz, so those come from apt.
- name: node
run: |
apt-get update -qq && apt-get install -y -qq --no-install-recommends curl ca-certificates xz-utils >/dev/null
curl -fsSL https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.xz | tar xJ -C /opt
ln -sf /opt/node-v22.20.0-linux-x64/bin/node /usr/local/bin/node
node --version
- name: conformance (pinned e2e)
run: |
R="$PWD/src"
bash "$R/Scripts/ci-fetch-e2e.sh" /tmp/e2e
cd /tmp/e2e && FN_RELAY_DIR="$R" node test/relay.mjs
- name: conformance at e2e HEAD (advisory)
continue-on-error: true
run: |
R="$PWD/src"
bash "$R/Scripts/ci-fetch-e2e.sh" /tmp/e2e-head HEAD
cd /tmp/e2e-head && FN_RELAY_DIR="$R" node test/relay.mjs
image:
needs: [checks, swift]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: docker cli
run: |
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz | tar xz -C /tmp
install /tmp/docker/docker /usr/local/bin/docker
- name: build (amd64, FN_RELEASE=${{ gitea.sha }})
run: docker build --build-arg FN_RELEASE="$GITHUB_SHA" -t "$IMAGE:${GITHUB_SHA::8}" .
- name: staging gate
run: bash Scripts/ci-staging-gate.sh "$IMAGE:${GITHUB_SHA::8}"
- name: push
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
run: |
printf '%s' "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u extant2000git --password-stdin
docker tag "$IMAGE:${GITHUB_SHA::8}" "$IMAGE:latest"
for i in 1 2 3; do
docker push "$IMAGE:${GITHUB_SHA::8}" && docker push "$IMAGE:latest" && break
[ $i = 3 ] && exit 1; sleep 10
done
docker logout "$REGISTRY"
deploy:
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
needs: [image]
runs-on: ubuntu-latest
concurrency: deploy-relay
steps:
- uses: actions/checkout@v4
- name: ssh
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
printf '%s' "${{ secrets.OVH_DEPLOY_KEY_B64 }}" | base64 -d > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "${{ secrets.OVH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
ssh -o StrictHostKeyChecking=yes -o BatchMode=yes "${{ secrets.OVH_USER }}@${{ secrets.OVH_HOST }}" true
- name: deploy
run: |
H="${{ secrets.OVH_USER }}@${{ secrets.OVH_HOST }}"
STAGE="/tmp/relay-deploy-$GITHUB_SHA"
ssh -o BatchMode=yes "$H" "mkdir -p $STAGE"
scp -o BatchMode=yes deploy/gitea-deploy.sh "$H:$STAGE/"
printf '%s\n%s\n' extant2000git "${{ secrets.REGISTRY_TOKEN }}" | ssh -o BatchMode=yes "$H" "bash $STAGE/gitea-deploy.sh $GITHUB_SHA $STAGE"
- name: smoke test (public edge)
run: |
set -eu
for u in https://flamenetmessenger.com/health https://messenger.flamenet.io/health; do
b=$(curl -fsS -m 10 "$u"); echo "$u -> $b"; [ "$b" = ok ] || exit 1
done
for p in / /about /privacy /metadata /releases /es/; do
c=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "https://flamenetmessenger.com$p"); echo "$p $c"; [ "$c" = 200 ] || exit 1
done
-334
View File
@@ -1,334 +0,0 @@
# flamenet-relay, the server, standing on its own.
#
# This repository was an rsync extract of the iOS app's `server/` subtree
# until the 2026-08-28 split. It is standalone now, with its own history, and
# patches land here directly. Comments elsewhere still calling it a mirror are
# stale; this one is the correction.
#
# The pipeline builds the relay as the root of a package somebody can clone and
# run, which is the build a person self-hosting actually performs, and the one
# that breaks.
#
# The toolchain is pinned, and pinned to match `Dockerfile`. Both are on
# `swift:6.1-noble`. Not 6.0: postgres-kit 2.16.1 declares
# swift-tools-version 6.1 and a 6.0 toolchain refuses to resolve it. If one
# moves the other must, or CI passes and the image that ships does not build.
#
# An unpinned toolchain has broken every deploy in this account before. A
# compiler that changes underneath you fails in ways that look like your own
# regression.
stages: [test, package]
default:
interruptible: true
# `vps`, not the shared runners. Every job in this pipeline failed with
# `ci_quota_exceeded` on first run: GitLab.com's shared minutes are spent, and
# a public project does not get them for free on this plan. The `ovh-…` runner
# was already serving the iOS repository and is not locked to it, so it now
# serves all four.
#
# It is registered with `run_untagged = false`, so a job WITHOUT this tag is
# not picked up at all, it sits pending forever rather than failing, which is
# the more confusing outcome of the two. Do not remove the tag to "let it run
# anywhere".
tags: [vps]
variables:
GIT_DEPTH: "20"
.rules: &rules
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
# The repository is public, so this sweep is the last thing between a pasted
# key and the internet. Until 2026-09-17 it only ran when somebody remembered
# to, and it had been red for nine days without anyone noticing, because a
# formatter carried an allow marker off the line it covered. A check nobody
# runs protects nothing; here it runs on every merge request and every push
# to main, and it finishes in seconds.
secret-sweep:
<<: *rules
stage: test
image: alpine:3.20
script:
# GNU grep and findutils rather than busybox's, so the script behaves the
# way it does on the machine it was written on.
- apk add --no-cache bash git grep findutils >/dev/null
- bash Scripts/secret-sweep.sh
build:
<<: *rules
stage: test
image: swift:6.1-noble
cache:
key: "swift-$CI_COMMIT_REF_SLUG"
paths: [.build/]
script:
- swift build
timeout: 30 minutes
# These are swift-testing tests, and that is worth stating in the file
# somebody reads when the number looks wrong. `swift test` reports
# "Executed 0 tests" for the XCTest half and the swift-testing results appear
# separately, which reads as an empty suite and has already been misread as
# one. The grep below fails the job if the run does not report a passing suite,
# so a empty result cannot pass as a green tick.
test:
<<: *rules
stage: test
image: swift:6.1-noble
cache:
key: "swift-$CI_COMMIT_REF_SLUG"
paths: [.build/]
policy: pull
script:
- swift test 2>&1 | tee /tmp/test.log
- grep -qE 'Test run with [0-9]+ tests? .*passed' /tmp/test.log
|| { echo "no passing swift-testing run found, an empty suite is not a pass"; exit 1; }
timeout: 30 minutes
# The image a self-hoster actually runs, built the way this runner can.
#
# Proven, not guessed: docker-in-docker does NOT work here. The manual
# trial run on 2026-08-28 failed with "Cannot connect to the Docker daemon at
# tcp://docker:2375", the runner's executor is not privileged, so the dind
# service can never start a daemon, and a job written around one can never
# gate on this runner. Kaniko builds the same Dockerfile with no daemon and no
# privilege, so this is a real gate rather than a manual button.
#
# What kaniko cannot do is RUN the result, so the boot smoke-test the dind
# version attempted is lost, not hidden: this job proves the image
# builds, and whether the relay boots is proven by the deploy, which starts it
# and waits on its healthcheck. `--no-push` because publishing an image is a
# deployment decision, not a CI side effect.
# The published container image.
#
# This used to build with `--no-push`, which proved the Dockerfile
# compiled and published nothing. Self-hosting therefore meant compiling Swift
# on your own machine, several minutes and a large toolchain, on hardware that
# is often a small VPS or a Pi. That is the single biggest thing standing
# between this project and somebody actually running it.
#
# amd64 only. Kaniko builds for the architecture of the runner it lands
# on, and the `vps` runner is x86_64. That covers most self-hosted servers and
# NOT a Raspberry Pi or an ARM VPS, people on those still build from source,
# which is why `docker-compose.build.yml` stays supported rather than being
# treated as a legacy path. Do not describe these images as multi-arch.
#
# Pushes only from `main`. A merge request still builds, because the thing
# worth catching in review is a Dockerfile that does not compile, it just does
# not publish what it built.
image-build:
# Not on every merge request. On a branch this runs kaniko with
# `--no-push`: a full ten-minute image build whose output is discarded. It
# runs on the box that also serves production, and on 2026-09-08 that box
# reached load 36 with a 48-job queue while Postgres was recovering from a
# full disk. Ten minutes of throwaway CPU per MR is not free here.
#
# It is not pointless, though, which is why it survives on a condition
# rather than being deleted: it catches a Dockerfile that no longer builds
# BEFORE somebody deploys. So it still runs whenever the things that decide
# whether the image builds have changed.
#
# And it always runs on main, where it publishes `latest` and the sha,
# `changes:` is unreliable on a branch pipeline, and main is the ref whose
# image other people pull.
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- Dockerfile
- Package.swift
- Package.resolved
- .gitlab-ci.yml
stage: package
image:
name: gcr.io/kaniko-project/executor:v1.23.2-debug
entrypoint: [""]
script:
# $CI_JOB_TOKEN already carries write access to this project's own
# registry, so publishing needs no secret that somebody has to rotate.
- mkdir -p /kaniko/.docker
- |
echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(printf '%s:%s' "$CI_REGISTRY_USER" "$CI_JOB_TOKEN" | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json
- |
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
# Both tags every time: `latest` for people who just want it working,
# and the commit sha so an operator can pin, and can say exactly what
# they are running when something is wrong.
/kaniko/executor \
--context "$CI_PROJECT_DIR" \
--dockerfile "$CI_PROJECT_DIR/Dockerfile" \
--destination "$CI_REGISTRY_IMAGE:latest" \
--destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
else
/kaniko/executor \
--context "$CI_PROJECT_DIR" \
--dockerfile "$CI_PROJECT_DIR/Dockerfile" \
--no-push
fi
timeout: 40 minutes
# §8 conformance: the relay driven over real HTTP by the protocol's own
# harness.
#
# This job used to live in the app repository and died with the split,
# it needed the server and the harness in one tree, and they are two repos now.
# Losing it would mean the relay is only tested by its own unit suite, and the
# unit suite does not speak HTTP: relay.mjs is what makes §8 a gate rather than
# a claim. The harness already discovers the server via FN_RELAY_DIR, so it
# clones flamenet-e2e at HEAD and points back at this checkout.
#
# Pinned, and the previous comment here argued the opposite, so it is
# worth saying why it changed. It read: "Coupled to flamenet-e2e's main on
# purpose: the harness and the spec live there, and a relay tested against a
# pinned old harness is tested against a pinned old spec." That concern is
# real and is NOT abandoned; see `conformance-head` below, which still runs at
# HEAD and still shouts. What changed is that cloning HEAD in a *blocking* job
# makes a merge in another repository able to stop all work in this one.
#
# It did, three times in twelve hours (relay#56). The worst was a genuine
# deadlock: e2e began expecting the plaintext routes to be gone while `main`
# still had them, so every relay pipeline failed, including the one on the
# branch that removes them. Nothing in this repository could go green until
# that branch merged, and it could not merge because nothing could go green.
#
# Pinning makes that impossible by construction. Taking an e2e update becomes
# one atomic MR: bump `FN_E2E_REF`, regenerate the pages, and make whatever
# source change the new harness expects, which is exactly the shape relay!57
# had to be assembled into by hand.
conformance:
<<: *rules
stage: test
image: swift:6.1-noble
cache:
key: "swift-$CI_COMMIT_REF_SLUG"
paths: [.build/]
policy: pull
before_script:
- apt-get update -qq
- apt-get install -y -qq --no-install-recommends curl ca-certificates gnupg git
# Node 22, not the distribution's: Noble ships Node 18, where
# globalThis.crypto is not exposed, and ML-DSA signing reaches for it at
# run time.
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
- apt-get install -y -qq nodejs
script:
- swift build
- git clone https://gitlab.com/extant2000/flamenet-e2e.git /tmp/e2e
- cd /tmp/e2e && git checkout -q "$(cat "$CI_PROJECT_DIR/.e2e-ref")"
- FN_RELAY_DIR="$CI_PROJECT_DIR" node test/relay.mjs
timeout: 40 minutes
# What gets a package wrong is missing files, not failing logic.
# The generated pages are what people actually read. A source can be fixed
# and the page left stale, and nothing says so, /self-hosting was served for a
# day telling self-hosters to run `docker compose up -d` without the
# `docker network create web` that the fix a day earlier had added.
#
# Hashes, not a re-render. Running pandoc here and diffing would make this
# hostage to the pandoc version, a different release emits different HTML, the
# job fails where nothing is wrong, and the tempting "fix" is to commit whatever
# the CI image produced. The banner records a hash of the source it was built
# from, so this needs no pandoc at all: alpine + python3 + a shallow clone.
doc-pages:
<<: *rules
stage: test
image: alpine:3.20
script:
- apk add --no-cache python3 git >/dev/null
# Same pinned clone the conformance job uses, see `.e2e-ref`.
- git clone https://gitlab.com/extant2000/flamenet-e2e.git /tmp/e2e
- (cd /tmp/e2e && git checkout -q "$(cat "$CI_PROJECT_DIR/.e2e-ref")")
- python3 Scripts/check-doc-pages.py --engine /tmp/e2e
# docs/CONFIGURATION.md opens by saying it lists every variable the relay
# reads. An operator sets what it names on a machine holding other people's
# messages, so it is a claim and not a summary. Twenty-one were missing when
# this was written.
configuration-doc:
<<: *rules
stage: test
image: alpine:3.20
script:
- apk add --no-cache python3 >/dev/null
- python3 Scripts/check-configuration-doc.py
# The pin's conscience. Both of these run the same checks against
# flamenet-e2e's actual HEAD, and both are `allow_failure: true`. That is the
# whole design: being behind is LOUD and NON-BLOCKING, while being broken is
# still a hard stop.
#
# The concern the old HEAD-coupled comment raised, "a relay tested against
# a pinned old harness is tested against a pinned old spec", is answered here
# and nowhere else. Delete these and the pin quietly becomes permission to fall
# behind, which is the failure relay#46 spent a month cleaning up.
#
# A red advisory job means an update is waiting. See `.e2e-ref.md` for how to
# take one: bump, regenerate, and make the source change, in one MR.
conformance-head:
<<: *rules
stage: test
allow_failure: true
image: swift:6.1-noble
cache:
key: "swift-$CI_COMMIT_REF_SLUG"
paths: [.build/]
policy: pull
before_script:
- apt-get update -qq
- apt-get install -y -qq --no-install-recommends curl ca-certificates gnupg git
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
- apt-get install -y -qq nodejs
script:
- swift build
- git clone --depth 1 https://gitlab.com/extant2000/flamenet-e2e.git /tmp/e2e-head
- |
echo "pinned: $(cat .e2e-ref)"
echo "at HEAD: $(cd /tmp/e2e-head && git rev-parse HEAD)"
- cd /tmp/e2e-head && FN_RELAY_DIR="$CI_PROJECT_DIR" node test/relay.mjs
timeout: 40 minutes
doc-pages-head:
<<: *rules
stage: test
allow_failure: true
image: alpine:3.20
script:
- apk add --no-cache python3 git >/dev/null
- git clone https://gitlab.com/extant2000/flamenet-e2e.git /tmp/e2e-head
# Say WHY this fired, not only what to run. relay#56 records an hour
# spent blaming the runner for a red that a merge in another repository had
# caused, the message told you to regenerate and not why regenerating had
# suddenly become necessary. Naming the commits, the author and the subject
# turns "something is stale" into "Paul's e2e!30 moved SPEC.md".
- |
PINNED="$(cat .e2e-ref)"
HEAD_SHA="$(cd /tmp/e2e-head && git rev-parse HEAD)"
echo "pinned: $PINNED"
echo "at HEAD: $HEAD_SHA"
if [ "$PINNED" != "$HEAD_SHA" ]; then
echo ""
echo "flamenet-e2e has moved since this repo pinned it. Commits touching docs/:"
(cd /tmp/e2e-head && git log --oneline --no-merges "$PINNED..$HEAD_SHA" -- docs/ | sed 's/^/ /') || true
echo ""
echo "If the check below fails, THAT is why, a change in another"
echo "repository, not anything in this one. See .e2e-ref.md: bump the"
echo "pin, regenerate with Scripts/build-doc-pages.sh, and make the"
echo "source change, in one MR."
fi
- python3 Scripts/check-doc-pages.py --engine /tmp/e2e-head
complete:
<<: *rules
stage: test
image: alpine:3.20
script:
- test -f Package.swift || { echo "no Package.swift"; exit 1; }
- test -f Dockerfile || { echo "no Dockerfile, self-hosting needs it"; exit 1; }
- test -f LICENSE || { echo "no LICENSE"; exit 1; }
- test -f .env.example || { echo "no .env.example, every variable is undocumented without it"; exit 1; }
- test -d Sources || { echo "no Sources/"; exit 1; }
- test -d Tests || { echo "no Tests/, a tree that drops the tests looks tested"; exit 1; }
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Clone flamenet-e2e for CI and check out a commit (default: the .e2e-ref pin).
#
# Scripts/ci-fetch-e2e.sh <dir> [ref|HEAD]
#
# Gitea first. Until flamenet-e2e finishes its own move off GitLab the Gitea
# copy can lag the pin, so a missing commit falls back to the GitLab copy and
# says so. Drop the fallback once the e2e repository's GitLab project is archived.
set -euo pipefail
DIR="$1"
REF="${2:-$(cat "$(dirname "$0")/../.e2e-ref")}"
GITEA=https://git.extant2000.com/extant2000/flamenet-e2e.git
GITLAB=https://gitlab.com/extant2000/flamenet-e2e.git
export GIT_TERMINAL_PROMPT=0
rm -rf "$DIR"
git clone -q "$GITEA" "$DIR" 2>/dev/null || echo "WARNING: no readable flamenet-e2e on Gitea yet"
if [ ! -d "$DIR/.git" ] || { [ "$REF" != HEAD ] && ! git -C "$DIR" cat-file -e "$REF^{commit}" 2>/dev/null; }; then
echo "WARNING: e2e $REF is not on Gitea yet; using the GitLab copy"
rm -rf "$DIR"; git clone -q "$GITLAB" "$DIR"
fi
[ "$REF" = HEAD ] || git -C "$DIR" checkout -q "$REF"
echo "e2e at $(git -C "$DIR" rev-parse HEAD)"
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# The staging gate, run by the Gitea workflow on a build machine (mac1/mac2)
# against a candidate image, before the image is pushed or deployed.
#
# Scripts/ci-staging-gate.sh <image>
#
# Same checks as Scripts/staging-gate.sh: a throwaway Postgres and Redis, the
# candidate relay with Redis attached, signup, signin, landing, every public
# page (English table derived from Entrypoint.swift, plus the Spanish pages),
# then a 25 s stability window with no restart and no "Fatal error".
#
# The job container is not on the staging network, so every HTTP check runs
# from a curl container attached to it. Nothing here touches production; the
# deploy step (deploy/gitea-deploy.sh, on the box) does that.
set -euo pipefail
IMAGE="$1"
SRC="$(cd "$(dirname "$0")/.." && pwd)"
STACK="gate-${GITHUB_RUN_ID:-local}-$$"
NET="${STACK}-net"
WINDOW=25
CURL_IMAGE=curlimages/curl:8.11.1
cleanup() {
docker rm -f "${STACK}-relay" "${STACK}-db" "${STACK}-redis" >/dev/null 2>&1 || true
docker network rm "$NET" >/dev/null 2>&1 || true
}
trap cleanup EXIT
fail() { echo "GATE FAILED: $1"; echo "--- candidate log tail ---"; docker logs --tail 40 "${STACK}-relay" 2>&1 || true; exit 1; }
# http <path> [curl args...] -> prints the status code, from inside the network.
http() { local p="$1"; shift; docker run --rm --network "$NET" "$CURL_IMAGE" -s -m 5 -o /dev/null -w '%{http_code}' "$@" "http://${STACK}-relay:8080/${p}"; }
cleanup
docker network create "$NET" >/dev/null
docker run -d --name "${STACK}-db" --network "$NET" \
-e POSTGRES_DB=staging -e POSTGRES_USER=staging -e POSTGRES_PASSWORD=staging \
postgres:17-alpine >/dev/null
docker run -d --name "${STACK}-redis" --network "$NET" redis:7-alpine >/dev/null
echo "GATE: waiting for staging postgres"
for i in $(seq 1 45); do
docker exec "${STACK}-db" pg_isready -U staging >/dev/null 2>&1 && break
sleep 2
done
docker exec "${STACK}-db" pg_isready -U staging >/dev/null 2>&1 || fail "staging postgres never became ready"
# Throwaway issuer keypair, minted per run and never stored.
PRIV_PEM=$(openssl genpkey -algorithm ed25519 2>/dev/null)
PRIV=$(printf '%s\n' "$PRIV_PEM" | openssl pkey -outform DER 2>/dev/null | tail -c 32 | base64)
PUB=$(printf '%s\n' "$PRIV_PEM" | openssl pkey -pubout -outform DER 2>/dev/null | tail -c 32 | base64)
docker run -d --name "${STACK}-relay" --network "$NET" \
-e FN_POSTGRES_URL="postgres://staging:staging@${STACK}-db:5432/staging" \
-e FN_REDIS_URL="redis://${STACK}-redis:6379" \
-e FN_RELAY_AUDIENCE=staging-gate \
-e FN_ISSUER_PRIVATE_KEY="$PRIV" -e FN_ISSUER_PUBLIC_KEY="$PUB" \
-e FN_LOG_LEVEL=notice \
"$IMAGE" >/dev/null
echo "GATE: waiting for health"
UP=""
for i in $(seq 1 45); do
[ "$(http health)" = 200 ] && { UP=1; break; }
sleep 2
done
[ -n "$UP" ] || fail "candidate never answered /health within 90s"
echo "GATE: smoke, signup, signin, landing"
SU=$(http account/signup -X POST -H 'Content-Type: application/json' \
-d '{"username":"gatecheck","password":"gate-check-pass-1","email":"gate@staging.invalid"}')
[ "$SU" = 200 ] || fail "signup answered $SU"
SI=$(http account/signin -X POST -H 'Content-Type: application/json' \
-d '{"username":"gatecheck","password":"gate-check-pass-1"}')
[ "$SI" = 200 ] || fail "signin answered $SI"
[ "$(http '')" = 200 ] || fail "landing did not answer 200"
# Page list derived from the page table, as in staging-gate.sh (see the long
# comment there for why the pattern and the floor look the way they do).
PAGES=$(grep -oE '^ "[a-z-]+", "' "$SRC/Sources/FlamenetRelay/Entrypoint.swift" 2>/dev/null \
| grep -oE '"[a-z-]+"' | head -100 | tr -d '"' | sort -u || true)
PAGE_COUNT=$(echo "$PAGES" | wc -w | tr -d ' ')
[ "$PAGE_COUNT" -ge 15 ] || fail "derived only $PAGE_COUNT public pages from Entrypoint.swift; expected at least 15"
ES_PAGES=$(ls "$SRC/Resources/Views/es/" 2>/dev/null | sed 's/\.leaf$//' | grep -v '^landing$' | sed 's|^|es/|' | tr '\n' ' ')
ALL_PAGES="donate releases $PAGES es/ $ES_PAGES"
echo "GATE: rendering $(echo "$ALL_PAGES" | wc -w | tr -d ' ') public pages"
# One curl container for the whole list, not one per page.
BAD=$(docker run --rm --network "$NET" --entrypoint sh "$CURL_IMAGE" -c '
for p in '"$ALL_PAGES"'; do
c=$(curl -s -m 5 -o /dev/null -w "%{http_code}" "http://'"${STACK}"'-relay:8080/$p")
[ "$c" = 200 ] || echo "/$p=$c"
done')
[ -z "$BAD" ] || fail "pages not 200: $BAD"
echo "GATE: stability window (${WINDOW}s)"
STARTED=$(docker inspect -f '{{.State.StartedAt}}' "${STACK}-relay")
for i in $(seq 1 $((WINDOW / 5))); do
sleep 5
[ "$(http health)" = 200 ] || fail "health lapsed inside the window"
done
[ "$(docker inspect -f '{{.State.StartedAt}}' "${STACK}-relay")" = "$STARTED" ] || fail "container restarted inside the window"
[ "$(docker inspect -f '{{.RestartCount}}' "${STACK}-relay")" = 0 ] || fail "container restarted inside the window"
if docker logs "${STACK}-relay" 2>&1 | grep -q "Fatal error"; then fail "'Fatal error' in the candidate log"; fi
echo "GATE PASSED"
+11
View File
@@ -0,0 +1,11 @@
# Deploying the public relay
`.gitea/workflows/ci.yml` runs on Gitea Actions. Every change runs the tests,
conformance and doc checks, builds the image with `FN_RELEASE=<commit sha>` and
runs `Scripts/ci-staging-gate.sh` against it. On `main` the image goes to
`git.extant2000.com/extant2000/flamenet-relay` and `deploy/gitea-deploy.sh`
puts it live on the production box, rolling back to the previous image if the
edge does not answer. Nothing is compiled on the box. The box's compose file
and `.env` hold all configuration and secrets, and CI never writes them.
This file is not rendered into a public page; README.md is (relay.leaf).
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Production relay deploy. Runs ON the OVH box, called by
# .gitea/workflows/ci.yml over SSH after the staging gate passed on mac1/mac2:
#
# printf '%s\n%s\n' "$USER" "$TOKEN" | ssh ovh 'bash /tmp/relay-deploy-<sha>/gitea-deploy.sh <full sha> /tmp/relay-deploy-<sha>'
#
# Stdin carries the registry user and the org REGISTRY_TOKEN;
# they live in a throwaway DOCKER_CONFIG deleted on exit.
#
# Nothing is built here. The gated image is pulled from the Gitea registry and
# tagged flamenet-relay:prod, the tag /srv/prod/messenger/docker-compose.yaml
# and the relay1/2/3 test fleet (/srv/test/relays) run. The image that ran
# before is tagged flamenet-relay:previous, the one-step rollback point:
#
# docker tag flamenet-relay:previous flamenet-relay:prod
# cd /srv/prod/messenger && docker compose up -d --no-deps --no-build relay
#
# The box compose and .env are hand-maintained and stay the source of truth
# for configuration and secrets. This script reads neither.
set -euo pipefail
SHA="$1"
STAGE="$2"
TAG="${SHA:0:8}"
REG=git.extant2000.com
IMAGE="$REG/extant2000/flamenet-relay:$TAG"
DIR=/srv/prod/messenger
read -r REG_USER
read -r REG_TOKEN
export DOCKER_CONFIG
DOCKER_CONFIG="$(mktemp -d)"
trap 'rm -rf "$DOCKER_CONFIG" "$STAGE"' EXIT
printf '%s' "$REG_TOKEN" | docker login "$REG" -u "$REG_USER" --password-stdin >/dev/null
docker pull -q "$IMAGE"
rel="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$IMAGE" | sed -n 's/^FN_RELEASE=//p')"
[ "$rel" = "$SHA" ] || { echo "image FN_RELEASE=$rel, want $SHA"; exit 1; }
cd "$DIR"
if grep -qE '^\s+build: \./src' docker-compose.yaml; then
echo "DEPLOY: compose still builds the relay from ./src; refusing (remove the build line)"; exit 1
fi
# Pre-deploy backup: database dump plus the image id that ran.
stamp="$(date -u +%Y%m%d%H%M%S)"
B=/srv/prod/backups/relay-predeploy-$stamp
mkdir -p "$B" && chmod 700 "$B"
docker exec messenger-db-1 pg_dump -Fc -U flamenet flamenet > "$B/messenger.dump"
[ -s "$B/messenger.dump" ] || { echo "DEPLOY: empty pg_dump"; exit 1; }
docker image inspect -f '{{.Id}}' flamenet-relay:prod > "$B/image-id" 2>/dev/null || true
keylog() { docker exec messenger-db-1 psql -U flamenet -d flamenet -tAc 'select count(*) from key_log'; }
KL_BEFORE="$(keylog)"
echo "DEPLOY: backup $B, key_log rows $KL_BEFORE"
docker tag flamenet-relay:prod flamenet-relay:previous 2>/dev/null || echo "DEPLOY: no prod tag yet"
docker tag "$IMAGE" flamenet-relay:prod
docker compose up -d --no-deps --no-build relay >/dev/null
rollback() {
echo "DEPLOY FAILED ($1), rolling back to :previous"
docker tag flamenet-relay:previous flamenet-relay:prod
docker compose up -d --no-deps --no-build relay >/dev/null
exit 1
}
EDGE=""
deadline=$(( $(date +%s) + 90 ))
while [ "$(date +%s)" -lt "$deadline" ]; do
EDGE=$(curl -sk -m 5 --resolve flamenetmessenger.com:443:127.0.0.1 https://flamenetmessenger.com/health || true)
[ "$EDGE" = ok ] && break
sleep 2
done
[ "$EDGE" = ok ] || rollback "edge /health never answered ok"
running="$(docker inspect -f '{{.Image}}' messenger-relay-1)"
[ "$running" = "$(docker image inspect -f '{{.Id}}' "$IMAGE")" ] || rollback "messenger-relay-1 is not on $IMAGE"
KL_AFTER="$(keylog)"
[ "$KL_AFTER" -ge "$KL_BEFORE" ] || rollback "key_log shrank $KL_BEFORE -> $KL_AFTER"
echo "DEPLOY OK: $IMAGE as flamenet-relay:prod, edge ok, key_log $KL_BEFORE -> $KL_AFTER"
# Carry the test fleet with production (best effort, reported either way).
if [ -d /srv/test/relays ]; then
if (cd /srv/test/relays && docker compose up -d --no-build >/dev/null 2>&1); then
echo "FLEET OK, relay1/2/3 on the image production now runs"
else
echo "FLEET WARNING, the test fleet did not update"
fi
fi
+1 -1
View File
@@ -40,7 +40,7 @@ services:
# Pin this to a commit sha (`:abc1234`) rather than `:latest` if you
# want to choose when you move. `latest` is what a person wants the first
# time and rarely what they want on the tenth.
image: registry.gitlab.com/extant2000/flamenet-relay:latest
image: git.extant2000.com/extant2000/flamenet-relay:latest
restart: unless-stopped
depends_on:
db: