The sequenced playbook for moving codeforphilly.org from legacy laddr to the rewrite. Grew out of plans/cutover-prep.md; the account-side policy is specs/behaviors/account-migration.md.
Companion docs: cutover-announcement.md (Slack + email templates) and cutover-rollback.md (what to do if it goes sideways). For routine ops see deploy.md, runbook.md, secrets.md, legacy-credentials-import.md.
| GitOps repo | Namespace | Host | |
|---|---|---|---|
| Production (the rewrite) | cfp-live-cluster |
codeforphilly-ng |
https://next.codeforphilly.org before cutover; https://codeforphilly.org after |
| Legacy laddr | cfp-live-cluster |
code-for-philly |
https://codeforphilly.org before cutover; https://codeforphilly.live.k8s.phl.io throughout |
| Sandbox | cfp-sandbox-cluster |
codeforphilly-rewrite-sandbox |
https://next-v2.codeforphilly.org |
There is no separate staging environment; the sandbox plays that role. The
production pod is already running and serving real data at
next.codeforphilly.org — cutover changes which hostnames reach it, nothing
about the pod itself.
Cutover needs no DNS change. DNS for codeforphilly.org is managed in
CodeForPhilly/ops (tf/dns,
OpenTofu, Cloud DNS project openphl-1177). codeforphilly.org,
*.codeforphilly.org and *.live.k8s.phl.io all already resolve to the
live cluster's Envoy gateway. Which app answers for a hostname is decided
by the Gateway listeners in cfp-live-cluster/_gateways/, so T-0 is a
single commit in that repo. There is no TTL to lower and no propagation to
wait on.
| Role | Responsibility |
|---|---|
| Cutover lead | Owns the timeline; the only person who merges the T-0 commit. |
| Comms lead | Owns Slack + email announcements; on standby for member questions. |
| Engineering second | Runs the data refresh + rehearsal; co-pilot for cutover lead at T-0. |
| Slack workspace admin | Owns the SAML SSO settings in Slack (SSO optional, Test configuration, SSO URL update). |
| On-call | Watches pod logs and /api/health during T-0 → T+24h. |
These can be the same person at small org scale, but the role assignments should be explicit in the cutover Slack post.
| When | What | Reversible? |
|---|---|---|
| T-7 days | Announce; freeze write workflow on legacy site; Slack SSO to "optional" | Yes |
| T-3 days | Rehearsal: dry-run against sandbox, smoke prod at next.codeforphilly.org, Slack "Test configuration" |
Yes |
| T-1 day | Data refresh (import → merge → prune → push); credentials load; verify counts | Yes |
| T-0 | Gateway hostname move + CFP_SITE_HOST flip (one commit); Slack SSO URL update |
Point of no return when first new record lands |
| T+1h | Active monitoring; smoke-test public flows | Yes (revert) |
| T+24h | Post-cutover all-clear in Slack | Yes (revert) |
| T+7d | Reconciliation check | Information-only |
There is deliberately no T+90 claim-window mailout and no T+180
password purge. Per account-migration.md,
legacy password sign-in persists indefinitely with a link-GitHub nag on
/account; any future sunset is undated and needs its own spec change first.
-
Post the cutover announcement from cutover-announcement.md to
#announcementsand email all members via Postmark. -
Freeze legacy writes. Either put a banner on the legacy site asking members to hold off on edits, or flip a feature flag making it read-only. The point is to reduce the size of the "delta" between the T-1 refresh and the T-0 refresh.
-
Notify cluster operators that you'll be moving the apex hostname next week.
-
GitHub OAuth. The production app is the org-owned OAuth app "Code for Philly" (the laddr one was renamed "Code for Philly (legacy)"). Its callback is registered on the apex; GitHub accepts redirects to subdomains of the registered callback, which is why
next.codeforphilly.orgalready works against it. No edit is needed at cutover. Confirm the app still exists and its client ID matches the one incodeforphilly-secrets. -
Slack SAML — lockout insurance first. Slack SSO cannot be tested in a throwaway workspace (SAML is enterprise-only), so the real workspace is the only test bed. Have the Slack workspace admin set SSO to "optional" now. Members can then always fall back to email magic links, so a wrong SAML setting at any later step is an inconvenience, not a lockout.
-
Confirm the three SealedSecrets are present in
cfp-live-cluster/codeforphilly-ng.secrets/and decrypted in thecodeforphilly-ngnamespace — see secrets.md. Missing secrets at T-0 is the most common single failure mode.kubectl -n codeforphilly-ng get secret codeforphilly-secrets codeforphilly-saml codeforphilly-data-deploy-key
There is no staging cluster to rehearse the hostname move itself on; what you can rehearse is everything that happens behind the hostname.
-
Run the dry-run script with the sandbox as target:
npm run -w apps/api script:cutover-dry-run -- \ --source-host=codeforphilly.org \ --data-repo=/scratch/dry-run-data \ --target=https://next-v2.codeforphilly.org \ --json=/scratch/dry-run-T3.json
-
Review the JSON report:
stages.importmust betrue.stages.countDiffmust betrue(every sheet's imported count is within tolerance of the server's reportedtotal).stages.smokemust betrue(all probes return 2xx/3xx).
-
Repeat the smoke probes by hand against production at
https://next.codeforphilly.org—/api/health/ready,/api/people/<known-slug>,/api/projects/<known-slug>,/projects?ID=<n>(must 301 to the slug URL), a GitHub sign-in. -
Slack SAML. In Slack's SSO settings, point the IdP metadata at
https://next.codeforphilly.org/api/saml/slack/metadataand use Slack's "Test configuration" against it. The IdP entity ID it reports must behttps://codeforphilly.org/api/saml/slack/metadata— that value isSAML_ENTITY_ID's default and does not follow the host, so it is the same before and after cutover (specs/api/saml.md). The signing cert is the one carried over from laddr'ssaml2secret, so Slack keeps trusting the same cert. A test user's NameID must equal their pre-cutover Slack NameID byte-for-byte. -
File any anomalies, schedule a re-run before T-0 if anything fails.
If the dry-run reports unexpectedly low imported counts for any sheet, stop. Either the laddr JSON shape drifted (a new field broke Zod validation) or the importer needs an update. Don't proceed to T-0 with silently-dropped data.
The data repo (CodeForPhilly/codeforphilly-data) has no main. The
runtime-served branch is published; legacy-import holds the raw
importer snapshots. The refresh pipeline is always:
import → merge legacy-import into published → prune-spam → push
Pushing published fires the data repo's
.github/workflows/notify-deployments.yml, which POSTs the hot-reload
webhook on sandbox and prod; both pods rebuild in-memory state in place
with no restart (runbook.md#hot-reload-webhook).
The first full refresh through this pipeline ran 2026-09-09/10: 36,254
person records imported, 22,625 left on published after prune. Use
those as the order-of-magnitude sanity check for your counts.
-
Bare-clone the data repo — the importer matches the running pod's invariant (storage.md → "The data clone is bare") and
openPublicStorerejects a non-bare path:git clone --bare git@github.com:CodeForPhilly/codeforphilly-data.git /scratch/codeforphilly-data.git
-
Run the importer — with
--dry-runfirst:npm run -w apps/api script:import-laddr -- \ --source-host=codeforphilly.org \ --data-repo=/scratch/codeforphilly-data.git \ --branch=legacy-import \ --dry-run
-
Review the dry-run report. Warnings about slug normalization, missing tag namespaces, and skipped HTTP-only buzz URLs are expected; zod errors are not.
-
Run the importer without
--dry-run. This creates one snapshot commit onlegacy-import. Push it. -
Merge
legacy-importintopublishedwith gitsheets' sheet-aware merge (gitsheets ≥ 2.8). It merges records, not lines: a person changed on both sides is field-merged and written canonically, so the merged branch never mixes serializations. It runs directly against the bare clone, so no working tree is needed. Expect thousands of delete/modify conflicts: every previously-pruned spam person is absent onpublishedand present in the fresh snapshot.--prefer theirstakes the import's version of each one, and the prune step next removes them again. Run it from a codeforphilly-ng checkout (the CLI ships with thegitsheetsdependency):D=/scratch/codeforphilly-data.git # Preview: lists every conflict by path and kind, writes nothing npx gitsheets merge legacy-import --into published --dry-run --git-dir $D # Merge, taking the import's side of every conflict npx gitsheets merge legacy-import --into published --prefer theirs --git-dir $D \ --author-name "<you>" --author-email "<you>@users.noreply.codeforphilly.org" # The merge doesn't validate contracts; check every record before going on npx gitsheets contracts verify --git-dir $D --ref published
Read the preview before merging.
--preferresolves record-field, delete/modify and blob conflicts, but rename/directory conflicts are always reported and stop the merge: look at those individually. If conflicts show up outsidepeople/, look at them individually rather than relying on--prefer. The merge stays local to the bare clone (don't push to GitHub yet). -
Prune confident-spam from
publishedbefore it goes anywhere. The merge re-added the full raw import (spam included); the deployed pod cannot hold the unpruned set in memory, so this step is mandatory after every import/merge. See spam-detection.md → Applying spam decisions:npm run -w apps/api script:prune-spam -- \ --data-repo=/scratch/codeforphilly-data.git \ --evaluations-ref=spam-detection \ --branch=published \ --dry-run # review counts, then drop --dry-runNewly-imported accounts with no spam verdict yet are kept (the rule only removes confident spam), so an incomplete eval pass is safe — it just keeps more people than strictly necessary.
-
Push
publishedto GitHub.notify-deployments.ymlhot-reloads sandbox and prod. Watch the workflow run go green for both targets, then confirmhttps://next.codeforphilly.org/api/people/<slug>reflects a record you know changed upstream.
Emails and password hashes are not in the public import. They come from the laddr MySQL database via the credentials importer and are loaded onto the production pod's private-storage PVC — full procedure in legacy-credentials-import.md. In short:
- Export
Username, Email, Passwordfromemergence-site.peopleinside the laddr pod (Habitat mysql client) to a quoted CSV. - Run
script:import-laddr-credentialsagainst the bare clone'spublishedbranch to produceprofiles.jsonl+legacy-passwords.jsonl. It reads the clone'sHEAD, and neither the importer nor prune-spam movesHEADany more, so use a--branch publishedbare clone as legacy-credentials-import.md shows (orgit symbolic-ref HEAD refs/heads/publishedin the scratch clone). kubectl -n codeforphilly-ng cpboth files into the pod's/app/private-storage/, thenkubectl -n codeforphilly-ng rollout restart deploy/codeforphilly. Hot reload does not cover the private store; the restart is required.
The first production load ran 2026-09-10: 21,761 profiles/credentials.
Re-running after real users have signed in reverts their rehashed (argon2id) credentials to the legacy hashes — see the safety notes in that doc before repeating the load at T-0.
Production is pinned in cfp-live-cluster by two values that move
together: .holo/sources/codeforphilly-ng.toml (ref = "refs/tags/vX.Y.Z")
and images[].newTag in codeforphilly-ng/app/kustomization.yaml. Confirm
they match the release you intend to cut over on, and that the sandbox has
run that same tag for a while.
At T-0 the team is on a call together. Cutover lead at the keyboard;
engineering second has the runbook open and reads checks back. Legacy stays
reachable at https://codeforphilly.live.k8s.phl.io throughout, so a
maintenance page is optional — the switch itself is atomic per hostname.
-
0:00 — final delta. Re-run the T-1 public-data pipeline (import → merge → prune → push). UUIDs are read-forward from the previous snapshot's tree, so the diff between this commit and the T-1 commit is exactly the records that changed upstream since T-1. The push hot-reloads prod. Only re-run the credentials load if nobody has signed in at
next.codeforphilly.orgsince T-1 (or you accept the rehash reset). -
0:05 — the cutover commit. One commit in
cfp-live-cluster, on a branch, PR'd intomain:- In
_gateways/code-for-philly.yaml: remove thehttps-apex(codeforphilly.org) andhttps-www(www.codeforphilly.org) listeners and drop those hostnames from the legacy HTTPRoute. Leave thecodeforphilly.live.k8s.phl.iolistener so laddr stays reachable. - In
_gateways/codeforphilly-ng.yaml: add those two listeners (with theircertificateRefs) and hostnames to the rewrite's Gateway + HTTPRoute. Keep the existingnext.codeforphilly.orglistener — it is a permanent alias. - In
codeforphilly-ng/app/kustomization.yaml: change theCFP_SITE_HOSTpatch value fromnext.codeforphilly.orgtocodeforphilly.org. This drives the markdown renderer's internal/external link split, canonical URLs in email, and the SAML metadata'sSingleSignOnServicelocations.
Merge to
main. The "Build k8s-manifests" workflow projects toreleases/k8s-manifests, a bot opens a PR intodeploys/k8s-manifests, and merging that PR applies. The ConfigMap change rolls the pod (Recreatestrategy) — expect a short readiness gap while it re-clones and reloads. cert-manager issues certificates for the moved hostnames into thecodeforphilly-ngnamespace; allow a minute or two for ACME. - In
-
0:10 — verify. From several networks (lead's laptop, engineering second's tethered phone, a volunteer's home network), confirm
https://codeforphilly.organdhttps://www.codeforphilly.orgserve the rewrite with valid certificates:- Anonymous browse of
/projectsand/members(read paths). - A known legacy URL form like
/projects?ID=10— must 301 to the slug URL. - A team member signs in via GitHub OAuth; a member with a laddr account signs in with their legacy password.
https://next.codeforphilly.orgstill works (alias).https://codeforphilly.live.k8s.phl.iostill serves laddr.
- Anonymous browse of
-
0:15 — Slack SSO URL. The Slack workspace admin updates only the SSO URL in Slack's SAML settings from
https://next.codeforphilly.org/api/saml/slack/ssotohttps://codeforphilly.org/api/saml/slack/sso(or re-fetches the metadata from the apex — the entity ID and cert are unchanged). A team member signs out of Slack and back in via SSO. If it fails, SSO is "optional" so nobody is locked out; fix the URL rather than rolling back the gateway. -
0:20 — hot-reload target. In the data repo, switch the prod URL in
.github/workflows/notify-deployments.ymlfromhttps://next.codeforphilly.org/...tohttps://codeforphilly.org/api/_internal/reload-data. (The alias would keep working, but the workflow should name the canonical host.) -
0:25 — legacy site. If you put up a maintenance banner at T-7, it can stay; laddr is now reachable only at
codeforphilly.live.k8s.phl.ioand can remain read-only indefinitely.
Point of no return: the moment a real user creates a new record
on the rewrite at the apex (a project, an update, a buzz post, even just
their first sign-in writing a PrivateProfile), data starts to diverge
between the two systems. Up to that point, reverting the cutover commit
(cutover-rollback.md) is harmless. After it, a
rollback would discard those new records. Note that people have been able
to sign in at next.codeforphilly.org since before cutover — the rewrite's
data is already live and diverging from laddr's; "point of no return" here
is about writes that arrive because the apex moved.
Be honest about what exists: as of this writing there are no external uptime checks and no alerts webhook (monitoring.md describes the target state, not the current one). For the first hour after the hostname move, the cutover lead and engineering second watch by hand:
kubectl -n codeforphilly-ng logs deploy/codeforphilly -f— look for spikes inERRORorWARN.curl -sS https://codeforphilly.org/api/healthand.../api/health/readyevery few minutes (a shell loop is fine).kubectl -n codeforphilly-ng get pods -w— restarts or OOMKills.
Specific things to watch:
- OAuth callback failure rate. Should be ~0%. A 4xx spike here indicates a GitHub OAuth app issue (the callback URL itself should not need changing — see T-7).
- SAML assertion failure rate. Should be ~0% once the SSO URL is updated. A spike means Slack is rejecting our assertions — usually a NameID or SSO URL mismatch. Members still have magic-link fallback.
- Push daemon errors. Surface as
git push failedin logs; means the data repo's deploy key is wrong or rate-limited. - Hot-reload webhook. The next
publishedpush should log ahot-reloadline on the prod pod via the apex URL.
If any of these alarm: pause, triage, decide rollback vs forward-fix in under 15 minutes.
If everything is stable through the first day:
- Post a success message to
#announcementsfrom cutover-announcement.md. - Add a note to the rewrite README that legacy laddr is decommissioned
(or read-only at
codeforphilly.live.k8s.phl.io).
Re-run reconciliation:
npm run -w apps/api script:reconcileAfter a week of real-world use, any orphans or inconsistencies that show up are likely from a real bug (dual-write coordination, racing OAuth callbacks). File issues.
Open gap: the reconcile script needs both the public data and the
private store. The runtime image ships only dist/ (no tsx, no
scripts/), so it cannot run inside the prod pod as-is, and production
private data must not be copied to a laptop
(private-storage.md). Until the
script ships in the image or gets an in-pod entrypoint, reconciliation in
production is a manual, in-cluster exercise; treat this as a follow-up.
Nothing in this runbook retires legacy credentials. Migrated members keep
signing in with their laddr password for as long as they like; the only
nudge is the "Connect GitHub" banner on /account
(account-migration.md → The nag).
The spec tracks a coverage metric for future sunset planning; when the
numbers justify it, a separate spec change sets a date. Do not schedule a
mailout or a purge from this doc.
Before T-0 the cutover lead confirms each of these in writing in the cutover Slack thread:
- SealedSecrets present and decrypted in
codeforphilly-ng(codeforphilly-secrets,codeforphilly-saml,codeforphilly-data-deploy-key) — see secrets.md - GitHub OAuth app "Code for Philly" exists; client ID matches the sealed value
- Slack SSO set to optional; "Test configuration" passed against
the
next.codeforphilly.orgmetadata - Data-repo deploy key has write access to
codeforphilly-data -
notify-deployments.ymlrun green for both sandbox and prod on the lastpublishedpush - Credentials loaded on the prod PVC; a known legacy user can sign in
with their password at
next.codeforphilly.org - Sandbox dry-run from T-3 reports all stages passing
- Production pin (
.holo/sources/codeforphilly-ng.toml+codeforphilly-ng/app/kustomization.yaml) is the intended release - The cutover commit is drafted on a branch in
cfp-live-clusterand reviewed (listeners, HTTPRoute hostnames,CFP_SITE_HOST) - On-call schedule covers T-0 through T+24h
- Rollback procedure read end-to-end (see cutover-rollback.md)
- Monitoring: none wired. Acknowledged that the T+1h window is watched by hand; external checks + alerts are a post-cutover follow-up (monitoring.md)
- Certificate issuance on the moved listeners. The apex and
wwwTLS Secrets are namespaced, so cert-manager mints new ones incodeforphilly-ngafter the move. Let's Encrypt rate limits are generous for two names, but a wedged ACME challenge would leave the apex serving a bad cert — that is a revert trigger. - GitHub OAuth rate limits. A surge of users signing in within a cutover window could brush against GitHub's per-app rate limit (~5000/hr). Probably fine at CFP scale; worth monitoring.
- Newsletter email bounces. First OAuth sign-in refreshes
PrivateProfile.emailto GitHub primary. Users who never sign in keep their pre-cutover email and may bounce on newsletter sends. Acceptable; cleaned up by standard bounce handling. - Reconcile in production — see the open gap under T+7 days.