Owner decision 2026-10-07: production's mongod becomes a one-member replica set (rs0), so that a backup can read every collection at one instant (a snapshot read session), which a standalone mongod can't. The unit runs mongod with --replSet rs0 and a 990 MB oplog; setup.sh converts it once, idempotently (PrivaPub stopped, mongod restarted, rs.initiate, the primary awaited, PrivaPub started); every connection string says directConnection=true, which works against the standalone too, so this code can deploy before the conversion. The CI's throwaway mongod and the pasture's are replica sets as well, so the snapshot path is what the tests exercise; MongoTopology tells which a mongod is, and TopologyTests holds the test mongod to it. The suite passes on it (906). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
155 lines
8.8 KiB
YAML
155 lines
8.8 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
|
|
MONGO_URI: mongodb://127.0.0.1:27022/?directConnection=true
|
|
MONGO_DB: PrivaPub
|
|
|
|
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
|
|
|
|
# without the statistics salt, which must not outlive its day (owner rule: it is destroyed at each rollup, and a dump
|
|
# kept for days would let the day's actor hashes be reversed)
|
|
- name: Dump the database
|
|
run: |
|
|
DUMP="$BACKUPS/mongo-$(date +%Y%m%d-%H%M%S).archive.gz"
|
|
mongodump --quiet --uri "$MONGO_URI" --db "$MONGO_DB" --excludeCollection=InteractionSalt --gzip --archive="$DUMP"
|
|
if mongorestore --gzip --archive="$DUMP" --dryRun -v 2>&1 | grep -q "InteractionSalt"; then
|
|
rm -f "$DUMP"
|
|
echo "::error::the dump holds the statistics salt"
|
|
exit 1
|
|
fi
|
|
echo "::notice::database dumped to $DUMP ($(du -h "$DUMP" | cut -f1))"
|
|
ls -1t "$BACKUPS"/mongo-*.archive.gz 2>/dev/null | tail -n +8 | xargs -r rm -f || true
|
|
|
|
- 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 /'
|