A backup is a directory <stamp>-<kind> under Backups:Root (/var/lib/privapub/backups, 2770, files 0640), written as .partial and renamed once whole: a manifest (host, build, newest migration, each collection's count, size, sha256 and indexes, what was left out and why, the media list), each collection as gzipped canonical Extended JSON read raw, and hard links to the files of untrashed media rows (copies where a link can't be made). On a replica set every collection is read in one snapshot session. Never in a backup: the statistics salt, jobs, recovery codes, sessions, the maintenance lock and the configuration's copy with its SMTP password. One backup or restore at a time (MaintenanceLock, a heartbeat document), and the janitor purges nothing meanwhile. BackupScheduler backs up nightly at 03:30 UTC (or at once after missing a night); rotation keeps 7 daily, 4 weekly, 3 pre-deploy and 3 pre-restore backups. CLI: admin backup [--kind] [--db-only], admin backups, admin backup verify; these run before migrations, so the deploy's own pre-deploy backup, which replaces mongodump, is of the database as the live build left it. EntityMaps.Warm runs once under a lock, since test hosts now boot side by side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
159 lines
9.4 KiB
YAML
159 lines
9.4 KiB
YAML
name: Deploy
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
push:
|
|
tags:
|
|
- 'v*'
|
|
|
|
env:
|
|
UNIT: privapub
|
|
WEB_ROOT: /var/www/privapub.thepra.dev
|
|
BACKUPS: /var/backups/privapub.thepra.dev
|
|
LOCAL_URL: http://127.0.0.1:6970
|
|
PUBLIC_URL: https://privapub.thepra.dev
|
|
SERVER_BACKUPS: /var/lib/privapub/backups
|
|
|
|
jobs:
|
|
site:
|
|
name: privapub.thepra.dev
|
|
runs-on: build
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- name: Resolve the build identity
|
|
run: |
|
|
echo "BUILD_COMMIT=$(echo "$GITHUB_SHA" | cut -c1-8)" >> "$GITHUB_ENV"
|
|
echo "BUILD_REF=${GITHUB_REF_NAME:-master}" >> "$GITHUB_ENV"
|
|
echo "BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"
|
|
|
|
- name: Test (unit and integration, on a throwaway mongod)
|
|
run: tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln -c Release
|
|
|
|
- name: Publish
|
|
run: |
|
|
rm -rf "$GITHUB_WORKSPACE/publish"
|
|
dotnet publish PrivaPub/PrivaPub.csproj -c Release -r linux-x64 --self-contained true \
|
|
-o "$GITHUB_WORKSPACE/publish" \
|
|
-p:BuildCommit="$BUILD_COMMIT" -p:BuildRef="$BUILD_REF" -p:BuildTimeUtc="$BUILD_TIME"
|
|
|
|
- name: Assert the publish actually produced a build
|
|
run: |
|
|
P="$GITHUB_WORKSPACE/publish"
|
|
for f in PrivaPub PrivaPub.dll PrivaPub.ClientModels.dll appsettings.Production.json Data/languagesNative.json; do
|
|
[ -e "$P/$f" ] || { echo "::error::publish output is missing $f"; exit 1; }
|
|
done
|
|
grep -q '"includedFrameworks"' "$P/PrivaPub.runtimeconfig.json" \
|
|
|| { echo "::error::publish is not self-contained"; exit 1; }
|
|
age=$(( $(date +%s) - $(stat -c %Y "$P/PrivaPub.dll") ))
|
|
[ "$age" -lt 3600 ] || { echo "::error::PrivaPub.dll is ${age}s old - the publish reused a stale artifact"; exit 1; }
|
|
echo "publish OK, $(du -sh "$P" | cut -f1)"
|
|
|
|
- name: Snapshot the live directory
|
|
run: |
|
|
STAMP=$(date +%Y%m%d-%H%M%S)
|
|
rsync -a "$WEB_ROOT/" "$BACKUPS/site-$STAMP/"
|
|
echo "SNAPSHOT=$BACKUPS/site-$STAMP" >> "$GITHUB_ENV"
|
|
ls -1dt "$BACKUPS"/site-* 2>/dev/null | tail -n +4 | xargs -r rm -rf || true
|
|
|
|
# The server's own backup (PrivaPub admin backup, owner decision 2026-10-07) of the database as the live build left it:
|
|
# the new build's command runs before its migrations. The media are listed, not linked (the runner may not link
|
|
# www-data's files; the nightly backups hold them). Never the statistics salt, which must not outlive its day (owner
|
|
# rule), nor the configuration's copy with its secrets. No deploy while a restore waits for its boot.
|
|
- name: Back up the database
|
|
run: |
|
|
[ ! -e "$SERVER_BACKUPS/restore.json" ] || { echo "::error::a restore is pending or running ($SERVER_BACKUPS/restore.json): deploy after it"; exit 1; }
|
|
out=$(cd "$GITHUB_WORKSPACE/publish" && ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin backup --kind pre-deploy --db-only)
|
|
echo "$out"
|
|
id=$(echo "$out" | grep -oE '^[0-9]{8}-[0-9]{6}-pre-deploy' | tail -1)
|
|
[ -d "$SERVER_BACKUPS/$id" ] || { echo "::error::the backup was not made"; exit 1; }
|
|
(cd "$GITHUB_WORKSPACE/publish" && ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin backup verify "$id")
|
|
if [ -e "$SERVER_BACKUPS/$id/db/InteractionSalt.jsonl.gz" ]; then
|
|
echo "::error::the backup holds the statistics salt"
|
|
exit 1
|
|
fi
|
|
echo "BACKUP_ID=$id" >> "$GITHUB_ENV"
|
|
echo "::notice::database backed up as $id"
|
|
|
|
- name: Stop, sync, start
|
|
run: |
|
|
sudo systemctl stop "$UNIT"
|
|
rsync -a --delete --exclude 'appsettings.Development.json' "$GITHUB_WORKSPACE/publish/" "$WEB_ROOT/"
|
|
chgrp www-data "$WEB_ROOT/appsettings.Production.json"
|
|
chmod 640 "$WEB_ROOT/appsettings.Production.json"
|
|
chmod +x "$WEB_ROOT/PrivaPub"
|
|
sudo systemctl start "$UNIT"
|
|
|
|
- name: Health check, roll back on failure
|
|
run: |
|
|
ok=0
|
|
for i in $(seq 1 40); do
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' "$LOCAL_URL/build.json" || true)
|
|
if [ "$code" = "200" ]; then ok=1; break; fi
|
|
sleep 3
|
|
done
|
|
if [ "$ok" != "1" ]; then
|
|
echo "::error::the site did not come back healthy - rolling back to $SNAPSHOT"
|
|
sudo systemctl stop "$UNIT"
|
|
rsync -a --delete "$SNAPSHOT/" "$WEB_ROOT/"
|
|
sudo systemctl start "$UNIT" || true
|
|
exit 1
|
|
fi
|
|
|
|
- name: Verify what is being served
|
|
run: |
|
|
served=$(curl -fsS "$PUBLIC_URL/build.json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))")
|
|
[ "$served" = "$BUILD_COMMIT" ] || { echo "::error::served build is '$served', expected '$BUILD_COMMIT'"; exit 1; }
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/privapub")
|
|
[ "$code" = "200" ] || { echo "::error::the instance actor answered $code"; exit 1; }
|
|
# SecureMode (owner decision 2026-10-04): an unsigned reader gets 401 from a persona, a browser the public page
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: application/activity+json' "$PUBLIC_URL/peasants/thepra")
|
|
[ "$code" = "401" ] || { echo "::error::an unsigned GET of a persona answered $code, not 401: SecureMode is off"; exit 1; }
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Accept: text/html' "$PUBLIC_URL/peasants/thepra")
|
|
[ "$code" = "302" ] || { echo "::error::a browser asking for a persona got $code, not a redirect"; exit 1; }
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/.well-known/nodeinfo")
|
|
[ "$code" = "200" ] || { echo "::error::nodeinfo answered $code"; exit 1; }
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' "$PUBLIC_URL/swagger/index.html")
|
|
[ "$code" = "404" ] || { echo "::error::swagger answered $code in production"; exit 1; }
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' --data '{"junk":' "$PUBLIC_URL/human-centipede")
|
|
[ "$code" = "400" ] || { echo "::error::a junk inbox POST answered $code"; exit 1; }
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/activity+json' \
|
|
--data '{"type":"Follow","actor":"https://example.org/users/x","object":"'"$PUBLIC_URL"'/peasants/privapub"}' "$PUBLIC_URL/human-centipede")
|
|
[ "$code" = "401" ] || { echo "::error::an unsigned inbox POST answered $code"; exit 1; }
|
|
tools/smoke/mastodon-api.sh "$PUBLIC_URL"
|
|
open=$(curl -fsS "$PUBLIC_URL/nodeinfo/2.1" | python3 -c "import json,sys; print(json.load(sys.stdin)['openRegistrations'])")
|
|
enabled=$(curl -fsS "$PUBLIC_URL/api/v2/instance" | python3 -c "import json,sys; print(json.load(sys.stdin)['registrations']['enabled'])")
|
|
[ "$open" = "$enabled" ] || { echo "::error::NodeInfo says registrations are $open, the instance API $enabled"; exit 1; }
|
|
curl -fsS "$PUBLIC_URL/stargazing" | grep -q 'The crawler is <strong>on</strong>' \
|
|
|| { echo "::error::/stargazing does not say the crawler is on"; exit 1; }
|
|
echo "::notice::serving $served"
|
|
|
|
# @thepra is the deploy's own persona (owner decision, 2026-10-04): the server's CLI makes or keeps it, undiscoverable,
|
|
# and gives its root a new password on every deploy, so no secret is stored and nobody has to create anything.
|
|
- name: Sign in as @thepra and check the signed-in API
|
|
run: |
|
|
creds=$(cd "$WEB_ROOT" && ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin smoke thepra | grep -E '^deploy-smoke [^ ]+$' | tail -1)
|
|
[ -n "$creds" ] || { echo "::error::the smoke persona could not be prepared"; exit 1; }
|
|
read -r login password <<< "$creds"
|
|
echo "::add-mask::$password"
|
|
read -r token cid cs <<< "$(tools/smoke/oauth.sh "$PUBLIC_URL" "$login" "$password" thepra read)"
|
|
echo "::add-mask::$token"
|
|
tools/smoke/mastodon-api.sh "$PUBLIC_URL" "$token"
|
|
discoverable=$(curl -fsS -H "Authorization: Bearer $token" "$PUBLIC_URL/api/v1/accounts/verify_credentials" | python3 -c "import json,sys; print(json.load(sys.stdin).get('discoverable'))")
|
|
[ "$discoverable" = "False" ] || { echo "::error::@thepra is discoverable"; exit 1; }
|
|
curl -fsS -o /dev/null -X POST "$PUBLIC_URL/oauth/revoke" --data-urlencode "token=$token" \
|
|
--data-urlencode "client_id=$cid" --data-urlencode "client_secret=$cs"
|
|
echo "::notice::signed in as @thepra"
|
|
|
|
# The server fetches DB-IP Lite itself (GeoUpdater) about 30 s after it starts and then daily; the deploy only checks.
|
|
- name: Geolocation databases are current
|
|
run: |
|
|
for kind in city asn; do
|
|
db="/var/lib/privapub/geo/dbip-$kind-lite.mmdb"
|
|
for i in $(seq 1 60); do [ -s "$db" ] && break; sleep 10; done
|
|
[ -s "$db" ] || { echo "::error::$db is missing: the server has not fetched DB-IP Lite"; exit 1; }
|
|
days=$(( ($(date +%s) - $(stat -c %Y "$db")) / 86400 ))
|
|
[ "$days" -le 40 ] || { echo "::error::$db was installed $days days ago"; exit 1; }
|
|
done
|
|
curl -fsS "$PUBLIC_URL/stargazing" | grep -o 'DB-IP Lite [0-9-]*' | head -1 | sed 's/^/::notice::locating with /'
|