21 KiB
Deploying main over the live release
Status: plan, not executed. Written 2026-11-09 by the heimdall Self run; revised the same day by the eureka Self run
against main 55b74275, from Libby's read of the first draft. Nothing here has been run. The deploy waits on a root
window, a verified database backup and a rehearsed rollback (Preconditions).
What moves
| Live | Target | |
|---|---|---|
| Release | /srv/bifrost/app/releases/20261010043032-67072b2 |
a new <UTC>-<sha7> directory |
| Revision | 67072b2 |
55b74275b0d86cdb0c5db01010380a90fbc2eb69 (main at revision; re-check with git ls-remote) |
| Distance | 147 commits; 67072b2 is an ancestor of main |
|
| Schema | up to the last pre-AddConceptVotes migration |
+ 20261010043937_AddConceptVotes, + 20261022163159_AddOpenIddict |
Both migrations only create tables and indexes (Up has no Drop*, AlterColumn or RenameColumn). The previous
binary therefore runs against the new schema, which is what makes the rollback below a symlink move, not a restore.
The deploy mechanism is gamecult-ops/scripts/deploy-bifrost-alpha.sh <sha40> and
runbooks/bifrost-alpha-deploy.md. There is no Idunn recipe for Bifrost; this is a manual, root-window deploy.
Bifrost #11 (__Host- cookies) and #17 (CI publish workflow deleted) are in main; this page (#15) is docs only and
changes no code. #11 renames every cookie Bifrost sets (BifrostCookies.cs: session, OAuth correlation, antiforgery,
voter, Heimdall attempt), so the deploy signs every member out once, drops sign-ins in flight at the restart, and
gives each returning anonymous voter a new device cookie. Say so to the members before the window. The data-protection
key ring is unchanged, so this is the only forced sign-out; the live check below tests it.
Configuration
Read against the live /srv/bifrost/env/bifrost.env before the window, not during it, by someone who can read it (mode
600, owner bifrost; the eureka Self run's account cannot, so no key below is marked present or absent here: the
column is filled in by the reader). Key names only; never paste values into a chat, a PR or a log. Name-only check:
sudo -n cut -d= -f1 /srv/bifrost/env/bifrost.env | sort.
Outside Development and Testing, StartupConfigurationValidator.Validate() refuses to start (an InvalidOperationException
naming every failure) unless all of these hold. Environment-variable spelling uses __ for :.
| Key (configuration name) | Rule | Present? |
|---|---|---|
GitHubOAuth:ClientId, GitHubOAuth:ClientSecret |
both non-empty | |
Host:PublicBaseUrl, Host:ExpectedHost |
both non-empty | |
AllowedHosts |
non-empty and no * (while Host:RequireStrictHostValidation is true, its default) |
|
ConnectionStrings:Bifrost |
non-empty | |
DataProtection:KeysPath |
non-empty | |
ConceptVotes:NetworkTagSecret |
non-empty and at least 32 characters (ConceptVoteOptions.MinNetworkTagSecretLength); a hard startup failure if missing or short (StartupConfigurationValidator.cs:104) |
|
ConceptVotes:AllowedOrigins |
each entry exactly https://host, no path, wildcard or trailing slash |
Conditional, only if the feature is switched on: GitHubApp settings when GitHubApp:EnableWebhookSync is true;
Heimdall:PatronSupportIntakeSecret while Heimdall:EnablePatronSupportIntake is true (its default); the Stripe keys
when Stripe:EnableCheckout is true. Two of these checks are satisfied by defaults once the one secret is present: Stripe
needs SecretKey, SuccessUrl and CancelUrl, and the last two default to https://velvet.gamecult.org/...
(StripeOptions.cs:15,17); Heimdall intake needs BaseUrl, AppSlug and the intake secret, and the first two default to
https://heimdall.gamecult.org and bifrost (HeimdallOptions.cs:7,9). Also checked at every start, in every environment: the ConceptVotes prefix lengths,
permits, windows and MaxTrackedKeys.
New since the live release, and not read by the validator:
Heimdall__AppSecret: required for Discord and Patreon sign-in. Without itHeimdallOptions.IsDiscordConfiguredandIsPatreonConfiguredare false and those sign-ins stop working (GitHub sign-in is unaffected); startup does not fail, so a missing key shows only at the live check. The value is Heimdall's app secret for slugbifrost(GC_ACCESS_APP_BIFROST_SHARED_SECRETon the Heimdall side). Bifrost sends it asx-heimdall-app-secret, redacted from logs.Bifrost__Oidc__*: optional. Absent, the OIDC issuer is not mapped and discovery answers 404. Present, it must be complete and the key files must exist, or startup stops. Unless the window includes standing up chat sign-in, leave it unset. Keys and files:docs/oidc-issuer.md.DataProtection:KeysPathkeeps its intent. Data Protection has a fixed application name (Bifrost,Program.cs), so a new release directory does not log members out; #11's cookie rename does (above). The live env points the keys path at/srv/bifrost/.aspnet/DataProtection-Keys(read 2026-11-09); confirm that again in the window, and that the new release reads it.Bootstrap__ApplyMigrationsOnStartup=truestays on, so the first start applies both migrations. That is the irreversible-looking step; the backup below is for it.
Preconditions
All must hold before step 1. A missing one means the window is not used.
-
Root window. Someone with root on Yggdrasil is present for the whole deploy and the 30 minutes after (installing the unit, reading
journalctl, restoring). The swarm brake is off for Bifrost, or the operator has said so. -
Verified database backup.
pg_dump -Fcas in the runbook section 8, taken inside the window to/srv/bifrost/backups/bifrost-pre-<sha12>-<UTC>.dump, root-owned, mode 0600, then verified. Thepostgresuser cannot read that file, so each check says who runs it:sudo -n pg_restore --list <dump> | headsucceeds, as root (it reads the file and touches no database).- The restore reads a postgres-readable copy, never the real dump: with
S=/var/tmp/bifrost-restoretest-<UTC>, root runssudo -n install -d -m 0700 -o postgres -g postgres $Sandsudo -n install -m 0400 -o postgres -g postgres <dump> $S/restore.dump. Thebifrostdatabase role has no CREATEDB, so create and drop the scratch database as the superuser:sudo -n -u postgres createdb -O bifrost bifrost_restoretest_<UTC>, thensudo -n -u postgres pg_restore --no-owner --role=bifrost -d bifrost_restoretest_<UTC> $S/restore.dump. The scratch member count must equal the live count. - Cleanup, on success and on every stop:
sudo -n -u postgres dropdb bifrost_restoretest_<UTC>, thensudo -n sh -c "shred -u $S/restore.dump && rmdir $S"; readbacktest ! -e $S && echo goneprintsgone. The real dump stays root-only and is kept.
An unrestored dump is not a backup.
-
Env, secrets and key-ring backup. The runbook's
bifrost-env-*.tar, plus the live ring/srv/bifrost/.aspnet/DataProtection-Keys(read from the host 2026-11-09:DataProtection__KeysPathinbifrost.envnames it; three key files at that reading, modes 600, ownerbifrost, dated 2026-04-22, 2026-07-22 and 2026-10-20). It lives outside/srv/bifrost/envand is not in that tar./srv/bifrost/data-protection-keysholds one older key (2026-07-15) that no setting points at: it is not the ring, and is left alone. Losing the ring signs everyone out and breaks every protected cookie and token;cp -ait beside the env backup and record its file list. -
Rollback rehearsal. Before the real deploy, on the same host. There is no
dotnet efon the host and this plan does not use it: the target applies its own migrations at start. Rehearse by starting the target once, then the previous release, on the scratch database, from a separate env file that touches nothing live:- a copy of
bifrost.envat/srv/bifrost/rehearsal/bifrost-rehearsal.env(directory and file owned bybifrost, modes 700 and 600; nothing under/root, whichbifrostcannot read), withConnectionStrings__Bifrostpointing atbifrost_rehearsal,DataProtection__KeysPathat a throwaway empty directory in the same place (not the live key ring, which the rehearsal must not write), andASPNETCORE_URLSset to a port that is not 5080 (e.g.http://127.0.0.1:5081). Delete the file and the directories afterwards. - Strip from that copy every key that could act on or sign in to something real, and every key the scratch run does
not need: all
Bifrost__Oidc__*,Bridge__LocalBridgeToken,Bootstrap__AdminGitHubLogins__*, the GitHub App, Stripe and Heimdall intake and app-secret keys. Stripping a key is not enough: startup validation refuses to start when a feature flag is on and its key is missing (StartupConfigurationValidator.cs:59-62,:69-73,:75-80), so setHeimdall__EnablePatronSupportIntake=false(it defaults to true and then needs the intake secret),GitHubApp__EnableWebhookSync=falseandStripe__EnableCheckout=falsein the rehearsal env. With them off the process starts without the secrets, and the rehearsal cannot reach Stripe, GitHub or Heimdall. The rehearsal checks that the process starts and migrates; there is no sign-in on 5081, because the GitHub OAuth app and Heimdall redirect to the public host. - start the target release from that file as
bifrost, with the file loaded by systemd, not by a shell:sudo -n systemd-run --wait --pty -p User=bifrost -p EnvironmentFile=/srv/bifrost/rehearsal/bifrost-rehearsal.env -p WorkingDirectory=<target-release> /usr/bin/dotnet <target-release>/Bifrost.Web.dll(detached with--unit bifrost-rehearsaland no--wait/--ptyif the check runs from another shell; stop it withsystemctl stop bifrost-rehearsal). The scratch database now has both migrations. Check it listens and thatcurl -s -o /dev/null -w '%{http_code}' -H 'Host: <Host:ExpectedHost of the live env>' http://127.0.0.1:5081/readyzanswers 200 (the app validates the host, so a bare request to127.0.0.1is refused), then stop it. - start the previous release (
/srv/bifrost/app/releases/20261010043032-67072b2) the same way on another spare port (e.g. 5082) against the same, now migrated, scratch database, with the sameHost:header;/readyzmust answer 200. This is the claim that the rollback is a symlink move; it is checked, not assumed.
- a copy of
-
Artifact. Published per runbook section 3:
git merge-base --is-ancestor <sha> origin/mainexits 0, publish from a detached worktree, tar, upload. Record the tarball's SHA-256 on both ends. Build and test on Yggdrasil's verify path, not a workstation. -
Env diff done. Every row of the Configuration table is marked present by the reader,
ConceptVotes__NetworkTagSecretincluded and at least 32 characters (a count, not the value), and theHeimdall__AppSecretline is inbifrost.env(mode 600, ownerbifrost) with Heimdall holding the same value. Nothing in "Configuration" is left unresolved. -
Quiet. No motion closing and no known vote in flight inside the window. Check the motions page. Members told that everyone is signed out once (see "What moves").
-
Watchdog.
bifrost-watchdog-notice.timerfires one minute after boot and one minute after each run (OnBootSec=1min,OnUnitActiveSec=1min; read 2026-11-09), and the oneshotbifrost-watchdog-notice.service, run asbifrost-notice, DMs the owner Idunn's operator incidents (systemctl cat bifrost-watchdog-notice.service). A deliberate restart or rollback that Idunn counts as an incident can page the owner during the window; tell them first. Leave the timer running: it is not part of the deploy and must not be stopped or edited.
Steps, in order
Record the time and the output line of each in the deploy record. Stop at the first failure and go to Rollback.
- Note the live state:
readlink -f /srv/bifrost/app/current(expect.../20261010043032-67072b2),systemctl is-active bifrost.service, the member count, the open motion count, and the log baseline: the service writes to files, not the journal (StandardOutput=append:), sojournalctl -u bifrost.serviceshows only systemd's own lines. Recordwc -l /srv/bifrost/log/bifrost.stdout.log /srv/bifrost/log/bifrost.stderr.log(at 2026-11-09 11:55Z: 4838 and 44925 lines; stderr last written 2026-10-18, stdout 2026-11-06) so the live check counts only the lines written after the restart. This is the "before" the live check compares to. - Take and verify the backups (Preconditions 2 and 3), then run the rollback rehearsal (Precondition 4).
- Make the env complete: add
Heimdall__AppSecretand, if the Configuration table found it absent or shorter than 32 characters,ConceptVotes__NetworkTagSecret(the target does not start without it) to/srv/bifrost/env/bifrost.env, after the timestamped backup, printing nothing. The secret is random, 32 or more characters, and is never echoed, logged or pasted into a chat. For example (openssl rand -hex 32gives 64 hex characters and nothing that needs quoting in a systemdEnvironmentFile):
The generated value is checked before anything is written: a failedsudo -n sh -c 'umask 077; cd /srv/bifrost/env || exit 1 if grep -Eq "^[[:space:]]*ConceptVotes__NetworkTagSecret=[^[:space:]]" bifrost.env; then echo "ConceptVotes__NetworkTagSecret already set, left unchanged" elif grep -Eq "^[[:space:]]*ConceptVotes__NetworkTagSecret=" bifrost.env; then echo "ConceptVotes__NetworkTagSecret present with an empty value: fix by hand, nothing appended"; exit 1 else v=$(openssl rand -hex 32) || { echo "openssl failed, nothing appended"; exit 1; } [ "${#v}" -ge 32 ] || { echo "generated value shorter than 32, nothing appended"; exit 1; } cp -a bifrost.env bifrost.env.pre-<UTC> && printf "\nConceptVotes__NetworkTagSecret=%s\n" "$v" >> bifrost.env fi && chown bifrost bifrost.env && chmod 600 bifrost.env'openssl, or a value under 32 characters, appends nothing. The guard makes a re-run safe: an existingConceptVotes__NetworkTagSecret=line, with or without leading spaces, is never overwritten or duplicated, and the skip message prints no value. A line with an empty (or whitespace-only) value counts as not set and is reported, not repaired: the command stops with a message and the operator fixes the line by hand (an empty value would otherwise sit beside a new one; systemd'sEnvironmentFiletakes the last duplicate, so do not leave two). A present line shorter than 32 characters is likewise not replaced by this command: fix it deliberately, with the rotation warning below in mind. A commented-out line (#) is not a key and does not count. (the same, with the value from the operator's own source, forHeimdall__AppSecret, which is not generated here). The file ends mode 600, ownerbifrost, and the backupbifrost.env.pre-<UTC>beside it keeps the old content. Re-run the name-only check after, and check the length by count only:sudo -n awk -F= '$1=="ConceptVotes__NetworkTagSecret"{print length($0)-length($1)-1}' /srv/bifrost/env/bifrost.envis 32 or more. Generate it once; do not rotate it casually. It is the key of the auditNetworkTagthatConceptAppraisalstores per vote (src/Bifrost.Web/Data/BifrostDbContext.cs:546). A different secret does not touch votes, the(SubjectId, VoterKey)uniqueness or sign-in, but tags already stored no longer match tags computed for the same network, soPurgeAsync(src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs:154, equality onNetworkTag) misses the old rows; only new casts and re-stanced rows carry the new tag. On this first deploy no vote row has a tag from the new code, so nothing is orphaned; after it, treat a change as a purge-affecting event. - Upload the tarball; compare its SHA-256 to the build's.
sudo -n bash /home/gamecultadmin/deploy-bifrost-alpha.sh <sha40>. It unpacks into<UTC>-<sha7>, writesREVISION, repointscurrent, restarts. The restart applies both migrations.- Watch the start in the real logs:
tail -n +<baseline+1> /srv/bifrost/log/bifrost.stdout.logand the same forbifrost.stderr.log, until the host reports listening on127.0.0.1:5080; noFailed executing DbCommand, noBifrost startup configuration is incomplete, noHeimdall ... refusedat start.journalctl -u bifrost.service -n 50 --no-pagershows only start, stop and restart events. A start that loops is a failure: roll back, do not wait. - Live check (next section).
- Record the deploy in
gamecult-opsinventory.md(Bifrost alpha live state): release directory, full sha, date.
Live check
Done from outside nginx, with a real browser session, within the window. A 200 on / is not a check.
cat /srv/bifrost/app/current/REVISIONis the target sha;systemctl is-active bifrost.serviceisactive.- Cookies (#11). After the first sign-in, the browser's cookie list shows the
__Host-bifrost_*names, each Secure with Path/and no Domain, and no oldbifrost_*name is set again. The old sessions are gone by design. - Login. Sign in with GitHub; then with Discord (exercises
Heimdall__AppSecret: the start must reach Heimdall and return to/auth/heimdall/callback). A Discord or Patreon sign-in that comes back refused or loops is the new-secret failure. Data Protection having kept its keys shows as nokey ... was not foundline (name not verified) in the stderr log after the restart; the old sessions do not survive either way (see "What moves"). - Vote read. The public concept-vote read endpoint answers with the existing tallies, and the tallies equal those noted in step 1 (the schema moved; the data must not).
- Persona picture. Open a Persona's page and confirm its picture loads and comes from the host, not a stale cache. A broken
image with a healthy
/is the finding this check exists for. Unknown: which app serves that picture.AvatarUrlappears atsrc/Bifrost.Web/Domain/BifrostModels.cs:261andsrc/Bifrost.Web/Program.cs:192, so the picture is probably an external avatar URL; unverified. Name the serving app and the page before the window, or drop the check. - Checkout page. Load one Stripe checkout page (start a checkout and stop at the Stripe-hosted page) and confirm it renders with the expected product and amount. Do not pay: no card is entered and no live payment is made.
- Member console, work board, motions and members pages load (runbook section 7).
- The error rate over five minutes: the number of lines added to
bifrost.stderr.logandbifrost.stdout.logsince the baseline in step 1 is small and stops growing; read what was added, not just the count.
Rollback
Rollback signs members out again (the old release reads the old cookie names); say so if it happens. Trigger: the start loops or migrates with errors, any live check fails, or the root window closes with a doubt open.
sudo -n ln -sfn /srv/bifrost/app/releases/20261010043032-67072b2 /srv/bifrost/app/current && sudo -n chown -h bifrost:bifrost /srv/bifrost/app/current && sudo -n systemctl restart bifrost.service.- Restore the previous env file if step 3 changed it (
bifrost.env.pre-<UTC>). - Re-run the "before" checks from step 1: revision,
active, counts equal. - The two new migrations stay applied. They add tables the old release ignores, as rehearsed in Precondition 4.
- Only if the schema or data is damaged (not merely new): the runbook section 8 restore, from the verified dump. This is
the last resort and needs the same root window. The
postgressuperuser cannot read the root-owned 0600 dump, so make a fresh postgres-readable copy as in Precondition 2 ($S/restore.dump, 0400, postgres-owned, new 0700 directory), runpg_restorefrom it, thenshred -uthe copy andrmdir $S; readbacktest ! -e $S. The dump itself stays root-only. - Leave the failed release directory for diagnosis; do not delete it in the window.
What this plan does not do
It does not deploy, does not add an Idunn recipe, and does not touch bifrost-watchdog-notice.timer. The
Idunn recipe is the owner of making this repeatable; until then each deploy follows this page and the runbook.
Host facts in this plan that no one re-verified in this revision: the key-ring file count, modes and dates (lines
~64, ~82-85), the stderr/stdout line counts and last-write dates (~135-136), the bifrost-watchdog-notice.timer
cadence and unit names (~122-124), and that bifrost cannot read /root (~91) were read from the host on 2026-11-09 by
an earlier pass or taken from the host's reports; this revision made no host read. The body also states these as fact
without a host check in this revision, and they are unverified too: the bifrost database role has no CREATEDB (~77),
there is no dotnet ef on the host (~87), logs go to files via StandardOutput=append: and the journal shows only
systemd's own lines (~134-136, 148-149), the script path /home/gamecultadmin/deploy-bifrost-alpha.sh (~144), and that
no Idunn recipe exists for Bifrost (~20). Treat each as unverified until checked in the window.