CI: move to Gitea Actions; build and gate off the box, deploy over SSH
.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:
co-authored by
Claude Opus 5.5
parent
836d21dfb3
commit
e630890dba
@@ -0,0 +1,145 @@
|
||||
# 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). Plain-http apt mirrors are unreachable from the runners.
|
||||
- name: node
|
||||
run: |
|
||||
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
@@ -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; }
|
||||
@@ -141,6 +141,16 @@ runs without a Swift toolchain. 60 assertions, including a path-traversal probe
|
||||
against the attachment id and a cross-implementation check that the coturn REST
|
||||
credential this mints matches one computed independently in Node.
|
||||
|
||||
## How the public relay is deployed
|
||||
|
||||
`.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.
|
||||
|
||||
## Contributing and security
|
||||
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md), building, testing, and the traps worth
|
||||
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/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
|
||||
rm -rf "$DIR"
|
||||
git clone -q "$GITEA" "$DIR"
|
||||
if [ "$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)"
|
||||
Executable
+103
@@ -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"
|
||||
Executable
+88
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user