Deploy runbook

One-time setup an operator does per client deployment, before the first workflow_dispatch run of deploy.yml. Everything here is provisioned once; deploy-client.yml (spec docs/adr/0001-event-platform-v1.md §8.1) then runs unattended. There is no service-account key anywhere in this flow — auth is Workload Identity Federation (WIF), so nothing here is a secret an operator has to store, rotate manually, or worry about leaking from a runner log.

Replace every <...> placeholder below. Nothing here is a value you leave as written.

0. Naming#

Pick, once:

  • <GCP_PROJECT_ID> — the Firebase/GCP project for this client (create it first — §2 below covers Firebase-specific prerequisites).
  • <GH_ORG>/<GH_REPO> — this repository, e.g. your-org/run-of-show.
  • <CLIENT_ENV> — the GitHub Environment name for this client, e.g. cjs2027. This is the value operators pass as client to workflow_dispatch, and it must exactly match the AUTO_DEPLOY_ENVIRONMENTS entry if this client also auto-deploys on push (§4).
  • <DEPLOY_SA> — the deploy service account's short name, e.g. deploy-cjs2027.

1. Workload Identity Federation#

One pool can be shared across every client deployment in this GCP project (or organization); create it once, then add one provider (or reuse the existing one) and one service account per client.

gcloud config set project <GCP_PROJECT_ID>

          # One-time per GCP project — skip if it already exists.
          gcloud iam workload-identity-pools create "github-actions" \
            --location="global" \
            --display-name="GitHub Actions"

          gcloud iam workload-identity-pools providers create-oidc "github" \
            --location="global" \
            --workload-identity-pool="github-actions" \
            --display-name="GitHub OIDC" \
            --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner,attribute.ref=assertion.ref,attribute.repository_and_ref=assertion.repository + '#' + assertion.ref" \
            --attribute-condition="assertion.repository_owner == '<GH_ORG>'" \
            --issuer-uri="https://token.actions.githubusercontent.com"
          

--attribute-condition is the guardrail: without it, any GitHub Actions workflow from any repo that can forge a matching subject claim could request a token. Scoping it to repository_owner is enough at the pool level because the per-client principal binding below (§2) does the real narrowing — down to one repository and one ref (refs/heads/main) together. attribute.repository_and_ref is a mapped custom attribute for exactly that: IAM principalSet bindings match on a single attribute value, and a repository-only or ref-only binding cannot be combined with a second one (multiple principalSet members on a role binding are OR'd, not AND'd) — so a repository+ref compound value is the only way to express "this repo, this branch, nothing else" as one condition.

This closes a real gap, not a theoretical one. Binding only on attribute.repository (an earlier draft of this runbook did exactly that) lets any workflow run in this repository — including one dispatched by a collaborator against a feature branch via gh workflow run deploy.yml --ref <branch> -f client=<env> — impersonate the deploy service account and ship that branch's code with the client's real credentials. An in-workflow ref check (deploy-client.yml's guard job) is necessary but not sufficient on its own: it runs as code from the ref being deployed, so a workflow-modifying commit on that same branch could remove or bypass the check before GCP is ever asked. The principalSet condition is enforced by Google's IAM outside the runner and outside the repository's control entirely, which is why it is the primary control and the in-workflow check is defense-in-depth only.

Capture the full provider resource name — every client's WIF_PROVIDER environment variable (§3) is the same value:

gcloud iam workload-identity-pools providers describe "github" \
            --location="global" \
            --workload-identity-pool="github-actions" \
            --format="value(name)"
          # projects/<PROJECT_NUMBER>/locations/global/workloadIdentityPools/github-actions/providers/github
          

2. Per-client service account#

Firebase project prerequisites, first (once per client GCP project):

gcloud services enable \
            firebase.googleapis.com \
            cloudfunctions.googleapis.com \
            cloudbuild.googleapis.com \
            run.googleapis.com \
            firestore.googleapis.com \
            secretmanager.googleapis.com \
            cloudscheduler.googleapis.com \
            --project=<GCP_PROJECT_ID>

          # If the project has never had Firebase added:
          #   https://console.firebase.google.com/ → Add project → select <GCP_PROJECT_ID>
          # Then, in the console: enable Firestore (Native mode), enable Storage, add a
          # Hosting site (or use the default one — its ID is EVENT_HOSTING_SITE, §3).
          

Now the deploy principal:

gcloud iam service-accounts create "<DEPLOY_SA>" \
            --project=<GCP_PROJECT_ID> \
            --display-name="Event Runner deploy (<CLIENT_ENV>)"

          DEPLOY_SA_EMAIL="<DEPLOY_SA>@<GCP_PROJECT_ID>.iam.gserviceaccount.com"

          for role in \
            roles/firebasehosting.admin \
            roles/firebaserules.admin \
            roles/datastore.indexAdmin \
            roles/storage.admin \
            roles/cloudfunctions.developer \
            roles/run.developer \
            roles/iam.serviceAccountUser \
            roles/secretmanager.secretAccessor \
            roles/cloudbuild.builds.editor \
            roles/artifactregistry.writer \
            roles/serviceusage.serviceUsageConsumer \
            roles/datastore.user \
            roles/cloudscheduler.admin
          do
            gcloud projects add-iam-policy-binding <GCP_PROJECT_ID> \
              --member="serviceAccount:${DEPLOY_SA_EMAIL}" \
              --role="${role}" \
              --condition=None
          done
          

Notes on that list: cloudfunctions.developer + run.developer + iam.serviceAccountUser + cloudbuild.builds.editor + artifactregistry.writer are what firebase deploy --only functions needs end to end (Cloud Functions v2 builds through Cloud Build and Artifact Registry, and the deploying principal must be allowed to act as the runtime service account). secretmanager.secretAccessor is required only once the environment actually uses a secret (EMAIL_PROVIDER_API_KEY etc., spec §2.1) — grant it up front so onboarding a provider later is a config change, not an IAM change. This binding is only what the deploying identity needs to reference a secret while wiring up defineSecret at deploy time — it does NOT cover the deployed function actually reading the secret's value at runtime. That is a separate binding, on the project's default compute service account, done per secret in §2's Secret Manager step below; skip it and every defineSecret-backed function fails at cold start or at read, not at deploy. datastore.user is what generate-content.cjs needs to read Firestore at deploy time (spec §8.6); datastore.indexAdmin is separate and is what deploys firestore.indexes.json. cloudscheduler.admin is what firebase deploy --only functions needs to create/update the Cloud Scheduler job behind cleanupExpiredAuthChallenges (an onSchedule v2 function, functions/src/auth/otp.cjs) — every deployment has at least this one scheduled function, so the role is not conditional the way secretmanager.secretAccessor is. The same role creates the job behind endExpiredStaffAccess (functions/src/admin/access.cjs, every minute, issue #331). It needs no secret. It reads config/bootstrap once a minute and writes only when a staff grant's end time has passed. The same role creates the job behind runScheduledReminders (functions/src/reminders/run.cjs, every 60 minutes, issue #518). That function binds the mail provider's secrets, and the operator webhook's when EVENT_OPERATOR_NOTIFIER=webhook, so the secret bindings in §2 cover it. It sends nothing until staff approve a reminder schedule in the admin. Scheduled attendee reminders go on the announcement stream: finish the Postmark Broadcasts stream (docs/POSTMARK_PROVISIONING.md, section 2a), set config/providers.email.announcementStream, and send a test announcement before staff turn the attendee schedule on. Until then the admin refuses to approve it and says why.

Bind the deploy service account so only this repository, on refs/heads/main, may impersonate it — both conditions in one principalSet, via the combined attribute mapped in §1:

gcloud iam service-accounts add-iam-policy-binding "${DEPLOY_SA_EMAIL}" \
            --project=<GCP_PROJECT_ID> \
            --role="roles/iam.workloadIdentityUser" \
            --member="principalSet://iam.googleapis.com/projects/<PROJECT_NUMBER>/locations/global/workloadIdentityPools/github-actions/attribute.repository_and_ref/<GH_ORG>/<GH_REPO>#refs/heads/main"
          

Verify it — the policy should show exactly one member, and it must end in #refs/heads/main:

gcloud iam service-accounts get-iam-policy "${DEPLOY_SA_EMAIL}" --project=<GCP_PROJECT_ID> \
            --format="json(bindings)"
          

This is the actual enforcement. It holds regardless of what any workflow file in the repository says, because Google's OIDC token exchange evaluates the ref claim GitHub put in the token — not anything the workflow run controls — before minting a credential. A workflow_dispatch run against a feature branch (--ref some-branch) presents a token whose repository_and_ref attribute does not match <GH_ORG>/<GH_REPO>#refs/heads/main, so google-github-actions/auth fails at the token exchange with permission_denied — before deploy-client.yml runs a single deploy step, and even if that workflow's own guard job ref check had been edited or removed on that branch. workflow_dispatch from main itself is unaffected: its ref is refs/heads/main, which matches.

Second layer: a GitHub-side deployment branch rule. Belt-and-suspenders, and cheap to set up — repo Settings → Environments → <CLIENT_ENV> → Deployment branches and tags → Selected branches and tags → add main only. This makes GitHub itself refuse to start any job whose environment: is <CLIENT_ENV> unless the run's ref is main, independent of both the WIF condition and the in-workflow check. Combine it with Required reviewers on the same screen for any client where a human should see a dispatch before it runs.

Storage rules and the account document. storage.rules calls firestore.exists on a profile photo write. Grant this role once per project, before the first deploy in §5. The deploy job does not grant it.

PROJECT_NUMBER="$(gcloud projects describe <GCP_PROJECT_ID> --format='value(projectNumber)')"
          gcloud projects add-iam-policy-binding <GCP_PROJECT_ID> \
            --member="serviceAccount:service-${PROJECT_NUMBER}@gcp-sa-firebasestorage.iam.gserviceaccount.com" \
            --role="roles/firebaserules.firestoreServiceAgent" \
            --condition=None
          

Storage triggers. mediaImageSizes and mediaImageSizesCleanup run on Cloud Storage events (issue #333). Eventarc delivers those events through Pub/Sub, so the Cloud Storage service agent must publish to Pub/Sub. The deploy service account cannot grant that role. Grant it once per project, before the first deploy that has these triggers:

GCS_AGENT="$(gcloud storage service-agent --project=<GCP_PROJECT_ID>)"
          gcloud projects add-iam-policy-binding <GCP_PROJECT_ID> \
            --member="serviceAccount:${GCS_AGENT}" \
            --role="roles/pubsub.publisher" \
            --condition=None
          

Without it, the functions deploy fails to create these two triggers. The bucket in EVENT_STORAGE_BUCKET must be in the same location as EVENT_FIREBASE_REGION, or a multi-region that holds it (for example US for us-central1). Eventarc refuses a storage trigger in any other region.

3. GitHub Environment#

Create the environment (repo Settings → Environments → New environment → name it exactly <CLIENT_ENV>), then set its variables and secrets. Every value below is a GitHub Environment variable (vars.*), not a secret — none of it is a credential; the credential-shaped value (the WIF provider resource name, the service account email) is a Google-side ACL check, not a bearer token, and none of the Tier A values are confidential either.

VariableValue
WIF_PROVIDERthe provider resource name from §1's describe output
WIF_SERVICE_ACCOUNT<DEPLOY_SA>@<GCP_PROJECT_ID>.iam.gserviceaccount.com
EVENT_SLUGlowercase identifier, e.g. cjs2027
EVENT_FIREBASE_PROJECT_ID<GCP_PROJECT_ID>
EVENT_FIREBASE_REGIONus-central1 unless the project's functions live elsewhere
EVENT_HOSTING_SITEthe Firebase Hosting site ID (Console → Hosting)
EVENT_PUBLIC_URLcanonical origin, e.g. https://summit.example.org
EVENT_STORAGE_BUCKETthe project's default Storage bucket
EVENT_ALLOWED_ORIGINScomma-separated CORS origins
EVENT_EMAIL_PROVIDERpostmark | webhook. console is valid only when the GitHub Environment name is exactly demo
EVENT_TICKETING_PROVIDEReventbrite | stripe | manual | none
EVENT_TICKETING_EVENT_IDEventbrite event ID or stable internal event ID for Stripe; required for either provider
EVENT_STRIPE_ACCOUNT_IDorganizer-owned acct_… account; required when the ticketing provider is stripe
EVENT_STRIPE_MODEexplicit test or live when using Stripe; begin onboarding in test
EVENT_OPERATOR_NOTIFIERwebhook | email | none
EVENT_APP_CHECK_ENFORCEDfalse by default; set the App Check site key before changing this to true
EVENT_OTP_SEND_CEILING_PER_HOURpositive integer; defaults to 500
VITE_FIREBASE_API_KEY, VITE_FIREBASE_AUTH_DOMAIN, VITE_FIREBASE_PROJECT_ID, VITE_FIREBASE_STORAGE_BUCKET, VITE_FIREBASE_MESSAGING_SENDER_ID, VITE_FIREBASE_APP_IDfrom Firebase Console → Project settings → your web app's SDK config
VITE_FIREBASE_MEASUREMENT_IDoptional; Firebase omits it when Google Analytics is disabled, and Eventrunner does not use it
VITE_FIREBASE_APP_CHECK_SITE_KEYreCAPTCHA v3 site key; set this before enabling App Check enforcement
EVENT_SITE_PUBLISHER_ENABLEDfalse by default. true provisions the site-publisher Cloud Run job — do §9 first
EVENT_PUBLISHER_SERVICE_ACCOUNTrequired when the publisher is enabled; the job's own runtime identity (§9)
EVENT_FUNCTIONS_SERVICE_ACCOUNToptional; overrides the default compute service account that receives run.invoker (§9)
EVENT_AIRTABLE_ENABLEDfalse by default. true lets staff refresh session pitches from Airtable and binds the AIRTABLE_API_TOKEN secret. Create the secret first (session pitches)

App Check activation requires two successful deploys. First set VITE_FIREBASE_APP_CHECK_SITE_KEY and wait for that deploy to finish. Then set EVENT_APP_CHECK_ENFORCED=true and deploy again. Do not enable both settings in the first deploy because Functions deploys before the new web bundle.

Full list, with descriptions: .env.example.

The only secrets deploy-client.yml reads are the provider-specific ones from spec §2.1/§8.2 — set them here too, as environment secrets (Settings → Environments → <CLIENT_ENV> → Secrets), only the rows that apply to the providers selected above:

SecretRequired when
EMAIL_PROVIDER_API_KEYEVENT_EMAIL_PROVIDER = postmark (server token)
EMAIL_WEBHOOK_BASIC_AUTHEVENT_EMAIL_PROVIDER = postmark and delivery-event ingest is enabled
EMAIL_WEBHOOK_URL, EMAIL_WEBHOOK_SECRETEVENT_EMAIL_PROVIDER = webhook
TICKETING_API_TOKENEVENT_TICKETING_PROVIDER = eventbrite or stripe; Stripe requires a secret or restricted key matching EVENT_STRIPE_MODE. With Stripe, invoiceAdmin binds it too, for invoice pay links. manual and none use no ticketing API token
TICKETING_WEBHOOK_SECRETeventbrite or stripe; for Eventbrite, generate a random value of at least 32 characters (openssl rand -hex 32). Eventbrite does not sign deliveries, so register-ticketing-webhook.cjs puts this secret in the webhook URL. For Stripe, use the signing secret generated for this deployed endpoint in the configured mode. manual and none use no webhook secret
OPERATOR_WEBHOOK_URL, OPERATOR_WEBHOOK_SECRETEVENT_OPERATOR_NOTIFIER = webhook
AIRTABLE_API_TOKENEVENT_AIRTABLE_ENABLED = true. An Airtable personal access token with only the data.records:read scope and only the pitch base. Store it in Secret Manager only

There is deliberately no FIREBASE_SERVICE_ACCOUNT secret in this list — WIF (§1–2) replaces the service-account-key deploy credential entirely.

There is also deliberately no EMAIL_ACCOUNT_API_KEY here: it's the Postmark account token, consumed only by the operator-run scripts/verify-sender-domain.cjs, not by any deployed function — none of the defineSecret lists below bind it. It stays in operator-controlled storage and is never provisioned into a client's GitHub Environment or Secret Manager; see docs/POSTMARK_PROVISIONING.md §3.

These provider secrets are consumed by the deployed Cloud Functions through defineSecret (Secret Manager), not baked into the functions env file the functions job writes — create them in Secret Manager once, before the first functions deploy for this client:

echo -n "<the actual API key>" | gcloud secrets create EMAIL_PROVIDER_API_KEY \
            --project=<GCP_PROJECT_ID> --data-file=- --replication-policy=automatic
          # repeat per secret this client's provider selection actually needs
          

Then bind secretAccessor on each secret to the function's RUNTIME service account — not the deploy service account. These are two different identities with two different jobs: ${DEPLOY_SA_EMAIL} (§2, above) only needs to reference the secret while deploying; the function's runtime identity is what actually reads the secret's value on every invocation, and it is a separate principal that ${DEPLOY_SA_EMAIL}'s own secretmanager.secretAccessor grant does not cover. Cloud Functions v2 runs as the project's default compute service account unless a function explicitly sets serviceAccount (this repo's functions do not), so:

PROJECT_NUMBER=$(gcloud projects describe <GCP_PROJECT_ID> --format="value(projectNumber)")
          RUNTIME_SA_EMAIL="${PROJECT_NUMBER}-compute@developer.gserviceaccount.com"

          for secret in EMAIL_PROVIDER_API_KEY; do   # repeat the list for every secret this client uses
            gcloud secrets add-iam-policy-binding "${secret}" \
              --project=<GCP_PROJECT_ID> \
              --member="serviceAccount:${RUNTIME_SA_EMAIL}" \
              --role="roles/secretmanager.secretAccessor"
          done
          

Skipping this step is a deploy-succeeds-runtime-fails trap: firebase deploy --only functions does not read the secret's value, so the deploy itself can succeed with this binding missing, and the function then fails at cold start (or at the first read, depending on defineSecret vs. lazy access) with a Secret Manager permission error — which surfaces as a broken client feature, not a red CI run.

(Optional) restrict who can approve deploys to this environment: Settings → Environments → <CLIENT_ENV> → Deployment protection rules → Required reviewers.

4. AUTO_DEPLOY_ENVIRONMENTS (push auto-deploy)#

Repository-level, not per-environment (repo Settings → Secrets and variables → Actions → Variables → New repository variable):

Name:  AUTO_DEPLOY_ENVIRONMENTS
          Value: cjs2027,summit2026
          

A comma-separated list of <CLIENT_ENV> names. Every push to main fans out to each one (spec §8.1) — deploy.yml's resolve job parses this (scripts/lib/deploy-matrix.cjs), and a client absent from the list only ever deploys via workflow_dispatch. Leave it unset (or empty) while a client is new and you want every deploy to be a manual, watched dispatch; add it once the client is comfortable auto-deploying on merge.

5. First deploy (provisioning a fresh project)#

A brand-new project has no config/event Firestore document yet — generate-content.cjs (the content job) throws "run scripts/init-event.cjs first" against one, on purpose (spec §8.6). The content job runs before any operator has ever had the chance to run init-event.cjs, so the first dispatch against a fresh project has to skip it. Two dispatches, in order:

Step 1 — bootstrap dispatch. Deploys only rules/indexes/storage (provision) and functions; skips content/build/hosting/post/smoke entirely.

Actions tab → Deploy → Run workflow
            client: <CLIENT_ENV>
            provision: true
            bootstrap: true
          

deploy-client.yml's provision job deploys firestore:rules, firestore:indexes, and storage on every run regardless of the provision input (spec §8.1) — that input exists for operator intent and log clarity, not as a gate. The Storage rules role in §2 is required before this dispatch. bootstrap: true is the actual gate: it is what skips content (and, transitively, build/hosting/post/smoke) on this run. A dispatch run always deploys functions regardless of bootstrap, because a fresh project has none yet and gating that on a paths filter would provision an empty, broken deployment (spec §8.1).

The first Functions deploy contains event handlers with retry enabled. Firebase CLI requires an explicit acceptance for a new retry policy, even in non-interactive mode. --force gives that acceptance, but it also accepts function deletion and unsafe trigger migration. So scripts/deploy-functions.cjs passes --force in two narrow cases only.

Before each Functions deploy, the script reads the complete Functions inventory through the pinned firebase-tools backend used by functions:list. It rejects missing regional availability or any unreachable region. A failed or partial inventory stops the deploy.

Case 1: verified-empty bootstrap. On a bootstrap run, the script passes --force to the full --only functions deploy only when the inventory reports no Functions. For that initial deploy, --force also accepts Firebase CLI's default Artifact Registry cleanup policy: container images older than one day are deleted. This is build-artifact retention only; it does not change deployed Functions or event data.

Case 2: new retry-enabled handlers. On every other run, the script also discovers the local Functions with the pinned firebase-tools runtime, the same discovery that firebase deploy runs. A discovery failure stops the deploy. A retry-enabled handler is new when the inventory has no Function with the same id in the same region. For new handlers only, the script runs one targeted deploy first and logs the names it creates:

npx firebase deploy --only functions:<id>[,functions:<id>...] \
            --project <GCP_PROJECT_ID> --non-interactive --force
          

Then it runs the normal full deploy without --force. The handlers now exist with retry enabled, so Firebase does not ask again. Firebase continues to stop for function deletion, unsafe trigger migration, and other protected changes. The script refuses the targeted deploy, and deploys nothing, in these cases:

  • A Function with the same id exists in another region. That is a migration, not a new handler.
  • A selector also matches a deployed Function. Firebase matches functions:<id> against the dash-separated parts of each id, so functions:foo also selects foo-bar.
  • A selector also matches a local Function that is not a new retry handler.
  • A retry setting or a retry handler's region is not a literal value.

With these checks, the targeted plan contains creates only. --force cannot approve deletion or unsafe migration of an existing Function. The targeted deploy can also accept other prompts for the new handlers: their min-instances cost, an API they need, and a missing Artifact Registry cleanup policy. A client deployment that already deploys without --force already has a cleanup policy.

Retry-enabled event handlers can retry failed executions for up to seven days, so they must remain idempotent. Review every new retry handler for idempotency before it merges.

Retry enabled on an existing Function. Case 2 covers handlers that do not exist yet. If a change turns on retry for a Function that is already deployed, the full deploy still stops at the retry-policy prompt. Do not add blanket --force to the workflow. Deploy that one Function by name with --force from a clean checkout of the reviewed commit, with the same deploy credentials and the same functions/.env.<GCP_PROJECT_ID> content as the workflow. Do this only when the same change does not also change the Function's trigger. Then rerun the workflow.

Recover a partial first Functions deploy. If the first bootstrap creates some Functions and then fails, read the failed run and repair its original API, permission, quota, or provider-configuration failure. Keep the commit, EVENT_FIREBASE_REGION, and GitHub Environment provider settings unchanged, and rerun the same bootstrap: true dispatch. The rerun sees a nonempty inventory, so it does not force the full deploy. Case 2 creates the absent retry handlers first, and the full deploy then runs without --force.

Check after the full deploy. After the full deploy exits 0, the script discovers the local Functions, if it has not yet, and reads the complete inventory again. Each local Function must be there, with the same id, in each of its regions. If a Function is not there, or its region is not a literal value, the step fails and names the Function. firebase-tools 15.22.4 can exit 0 when a create gets a quota error, before the create is done, and it then prints no summary. Run 39329645906 did this to downloadEventPackage. This check makes such a run fail.

Quota errors (HTTP 429). A large deploy can exceed the Cloud Functions limit "Per project mutation requests per minute per region". The log then shows Quota Exceeded and failed to update function or failed to create function lines. Firebase CLI retries these, and most later show Successful update operation or Successful create operation. If the step fails, or it names a Function that is not deployed, wait a minute and run the same deploy again. Do not change the commit. The rerun skips each Function that is active and unchanged, so it sends fewer requests and creates the Functions that are not there. Then run the smoke test again.

To read the complete inventory from an operator's machine, use a clean checkout with Node 22 and npm ci complete:

EVENT_FIREBASE_PROJECT_ID=<GCP_PROJECT_ID> node - <<'NODE'
          const { readCompleteInventory } = require('./scripts/deploy-functions.cjs');
          readCompleteInventory(process.env.EVENT_FIREBASE_PROJECT_ID).then((functions) => {
            const rows = functions
              .map(({ id, region, platform }) => ({ id, region, platform }))
              .sort((a, b) => `${a.region}/${a.id}`.localeCompare(`${b.region}/${b.id}`));
            process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
          }).catch((error) => {
            console.error(error.message);
            process.exitCode = 1;
          });
          NODE
          

Upgrade order for unpublished counts. Pause CMS saves, publishes, deletes, and seed jobs during the first upgrade. Deploy Firestore rules and all Functions, then wait for the deploy and any older in-flight CMS requests to finish. Before deploying the count-reading UI or resuming edits, make an authenticated staff-admin POST to cmsEnsurePendingCounts with JSON body {"force":true}. Require HTTP 200 with {"ok":true} and verify cmsMeta/pending against the Unpublished changes page. This recounts all six dirty draft collections in one transaction, including any drift caused by old and new function versions overlapping during deployment. Never initialize an existing site's count to zero. The same forced request repairs a count that is too high after an out-of-band data edit.

Ordinary requests to cmsEnsurePendingCounts with {} leave a valid summary unchanged. Draft saves, publishes, and deletes also prepare a missing or invalid summary before changing the count. Run only the upgraded init-event.cjs or seed-demo-event.cjs after the deploy; each prepares the count before its first CMS write and uses the atomic save and publish paths. Existing unpublished edits remain counted, and a seed dry run remains read-only.

Step 2 — seed content, from an operator's machine, once the bootstrap dispatch succeeds:

export FIRESTORE_EMULATOR_HOST=  # unset — this targets the real project
          export EVENT_FIREBASE_PROJECT_ID=<GCP_PROJECT_ID>
          export GOOGLE_APPLICATION_CREDENTIALS=<path to your own gcloud ADC, for this one-time step>
          node scripts/init-event.cjs --answers client-answers.json --admin ops@example.org
          node scripts/init-event.cjs --check
          

init-event.cjs runs from an operator's machine (or a follow-up dispatch of a future provisioning workflow), not from deploy-client.yml — it is the one-time content bootstrap, not a repeatable deploy step. See scripts/README.md. --admin seeds the operator tier; --staff (repeatable) seeds staff, who run content, schedule, speakers, and attendees but not branding, features, access, or deployment settings. Both can be granted later from admin Settings → Access. A re-run of init never removes a grant made there, and it never puts back an address removed there: on a re-run the answers file's lists are not re-applied, and only --admin and --staff add.

Step 3 — normal dispatch, now that config/event exists:

Actions tab → Deploy → Run workflow
            client: <CLIENT_ENV>
            provision: false
            bootstrap: false   (the default — leave it unchecked)
          

This run's content job succeeds, and build/hosting/post/smoke deploy the live site. From here on, either dispatch this way again for any change, or add <CLIENT_ENV> to AUTO_DEPLOY_ENVIRONMENTS (§4) so a push to main deploys it automatically — bootstrap is never true on a push run, so a client already past step 3 never needs it again.

6. Verifying the setup#

  • gcloud iam service-accounts get-iam-policy "${DEPLOY_SA_EMAIL}" --project=<GCP_PROJECT_ID> --format="json(bindings)" shows the workloadIdentityUser binding from §2, and its member ends in .../attribute.repository_and_ref/<GH_ORG>/<GH_REPO>#refs/heads/main — not attribute.repository/<GH_ORG>/<GH_REPO> alone. A repository-only binding is the gap this runbook now closes: it lets any dispatched run on any branch of this repository impersonate the deploy service account.

  • Confirm the negative case as well: gh workflow run deploy.yml --ref <some-non-main-branch> -f client=<CLIENT_ENV> must fail at the google-github-actions/auth step with a token-exchange permission_denied — that is the WIF condition doing its job. It should also fail earlier, at "Waiting for review" or immediately, if the deployment branch rule (§2) is set, since GitHub itself won't start a job against <CLIENT_ENV> from that ref.

  • A workflow_dispatch run of deploy.yml with client: <CLIENT_ENV> from main should reach the smoke job and pass — it OPTIONS-preflights every endpoint in .github/smoke-endpoints.json against https://<EVENT_FIREBASE_REGION>-<EVENT_FIREBASE_PROJECT_ID>.cloudfunctions.net and GETs EVENT_PUBLIC_URL.

  • Between hosting and smoke, the post job redeploys updatesMeta and routeMeta (functions/src/public/og.cjs) alone. Both self-fetch the deployed hosting index.html as their SSR template and hold it per container (issue #27; M7 issue 4). That self-fetch carries its own three-second deadline (TEMPLATE_FETCH_TIMEOUT_MS), so a hosting connection that stalls rather than fails costs a reader three seconds and the held copy, not the function's whole timeout. A container that cold-started before THIS run's hosting deploy is holding the previous build's asset references, and routeMeta is the catch-all rewrite, so for it that stale copy is not only a bad crawl: it is the first HTML every visitor gets, naming Vite-hashed asset files that this deploy has already replaced. The held copy is revalidated against hosting's ETag every ten seconds (TEMPLATE_REVALIDATE_FLOOR_MS), so it corrects itself quickly on its own; redeploying here makes the changeover immediate instead. Anyone who deploys hosting by hand, outside this workflow, gets the ten-second correction and can redeploy these two functions to skip it. This was the ADR's post step (§8.1), deferred at the M2 deploy PR pending functions/src/public/og.cjs landing — smoke's OPTIONS-preflight of both functions now runs against the freshly-redeployed instances, not the ones from the functions job earlier in the same run.

  • What routeMeta costs, and the cache header that bounds it. Every public route that is not a static file now reaches a function on its first uncached load, rather than being rewritten straight to index.html. Three things bound that. The response carries Cache-Control: public, max-age=0, s-maxage=300, must-revalidate, so the Hosting CDN — not the function — answers repeat traffic for the same URL, while max-age=0, must-revalidate keeps a reader's own browser from holding, or falling back to, a shell that names asset files a later deploy removed: max-age=0 alone still lets a cache serve a stale copy under pressure. There is deliberately no stale-while-revalidate for the same reason. The template is the revalidated per-container copy above, so a warm container transfers no body between deploys. And the config read is core/config.cjs's shared 5-minute container cache, which s-maxage matches: a publish reaches search and social previews within that window instead of at the next cold start, and a hosting deploy clears the CDN. If a client reports a page preview that will not update, five minutes is the number to wait before looking for a real fault. The one cost the CDN does not absorb is scanner traffic: requests for addresses that do not exist (/wp-login.php and its friends) each miss the cache and reach the function, where they cost one Firestore page lookup and return the plain shell. That is a line on the functions invocation graph. routeMeta sets maxInstances to 20 so that traffic stays bounded. The rewrite stays, because this function is the front door. App Check stays off this function. A crawler and a first visit have no App Check token.

  • The card image a deployment falls back to is a PNG, not the SVG placeholder. The Open Graph and Twitter crawlers fetch the image themselves and do not reliably decode SVG, so a card built on branding/og-default.svg unfurls with an empty frame, which a reader takes for a broken link rather than a plain one. routeMeta therefore skips any SVG candidate, wherever it comes from (the config/theme.logos.ogDefault slot or config/event.seo.defaultOgImagePath, whose seeded value is that SVG), and points at branding/og-default.png, rendered by scripts/dev/build-og-placeholder.mjs and shipped in the web build. An operator who uploads a PNG or a JPEG through the Branding tab gets theirs instead. The tags state the image type always and the size only for the bundled card, which is the only one anything here has measured. The SVG stays in the bundle: the app renders it in-page, where SVG is fine.

  • routeMeta does not answer 5xx once it has ever fetched a template. The site's whole front door is this function, so each read it makes degrades on its own: a hosting request that fails or answers non-2xx serves the held template, and a config read that fails — or an unset EVENT_PUBLIC_URL — serves that template with the event-level tags and no noindex, because a read that failed is not a page that does not exist. Only a container that has never fetched a template at all answers 500. A run of 500s from this function therefore means the very first self-fetch is failing: check EVENT_PUBLIC_URL and that hosting is actually serving /index.html.

  • firebase.json's rewrite order is load-bearing. /updates/** goes to updatesMeta first; then the account and staff routes (/signin, /profile, /attendees, /attendees/**, /schedule/mine, /speaker/**, /ticket/**, /admin, /admin/**) are rewritten straight to /index.html, because they carry nothing a crawler should index and spending a function invocation to say so is waste; then the ** catch-all goes to routeMeta. Adding a public route needs nothing here — the catch-all already covers it. Adding an authenticated one means adding it to that middle block, above the catch-all, and to the headers block below it. Hosting serves an existing static file before it applies any rewrite, which is what keeps /assets/**, /branding/**, and routeMeta's own self-fetch of /index.html from re-entering the function. scripts/deploy-workflow.test.cjs asserts the whole arrangement, so a rewrite added out of order fails the suite rather than a deploy.

  • Those same private sources appear again under hosting.headers, setting X-Robots-Tag: noindex. The shell they serve is one file for every route, so it cannot carry a per-route robots meta the way a routeMeta response can; hosting states it in the response header instead, which crawlers read the same way. Without it, the one part of the site with no server-set tags would be the part that most needs them.

  • Both hosting rewrites name their function's Cloud Functions region in object form ("function": { "functionId": "...", "region": "..." }) — the shorthand string form ("function": "routeMeta") silently defaults to us-central1, which would route every non-default-region client's public requests at a backend that does not exist there (a 404 from Hosting, not from the function). Region is per-client (EVENT_FIREBASE_REGION), while firebase.json is one file shared by every client's deploy — the same shape of problem EVENT_HOSTING_SITE is (target names, not literal site ids), and the same fix: the hosting job's "Set the function rewrite regions for this project" step patches the working copy of firebase.json with jq immediately before firebase deploy --only hosting:site reads it, defaulting to us-central1 when EVENT_FIREBASE_REGION is unset (matching every other region default in this pipeline). It patches every object-form rewrite, so a rewrite added later cannot be left behind on the placeholder. Nothing is committed back — the committed file keeps us-central1 as its placeholder, correct for that value and every client that doesn't override the region. The site publisher applies the same patch in code (patchHostingRegion, scripts/publish-site.cjs).

  • The staff end-time job exists. After the first deploy that includes endExpiredStaffAccess, the same listing shows firebase-schedule-endExpiredStaffAccess-<EVENT_FIREBASE_REGION>, set to every 1 minutes, and it must be ENABLED. A paused job does not reopen an ended grant: the server and the rules still refuse it on every request. It only means a browser tab opened before the end time can keep reading until it reconnects.

  • The reminder scheduler job exists. After the first deploy that includes runScheduledReminders, list the Cloud Scheduler jobs and find firebase-schedule-runScheduledReminders-<EVENT_FIREBASE_REGION>, set to every 60 minutes:

    gcloud scheduler jobs list --project=<GCP_PROJECT_ID> --location=<EVENT_FIREBASE_REGION> \
                --format="table(name,schedule,state)"
              

    The job must be ENABLED. A paused or missing job means no scheduled reminder goes out, and the admin's Scheduled reminders page then shows a last run older than an hour while a schedule is on. The job runs every hour on every deployment; a run with no schedule turned on reads one document and returns.

  • If google-github-actions/auth fails with permission_denied on a run you expected to succeed (dispatched from main), the attribute condition (§1) or the repository+ref binding (§2) is the first thing to re-check — copy the exact repository and ref claims GitHub sent from the failed run's log against what the binding names.

Archiving an event with ticket sales#

Pause new sales with the catalog's salesPaused setting while payment, held-capacity, refund, dispute, or reconciliation work remains. Keep the event writable until those obligations are settled and the existing ticket_sync_queue has no unresolved work. Then enable config/event.historicalDemo to freeze the archive.

The archive flag stops native payment reconciliation, webhook queuing, and ticket synchronization. These paths read the persisted policy before provider work and include it in transactions that update orders, payment attempts, inventory, tickets, delivery claims, and queued work. Freezing with an open payment or hold can therefore leave its last local state unchanged even when Stripe later reports a payment or refund.

If an event was frozen before its obligations were settled, an authorized operator must restore its writable policy and reconcile the existing orders before freezing it again. Keep the original provider, account, payment attempts, and inventory records throughout recovery; deleting a hold or making a replacement order does not establish the original payment's outcome.

Importing users or data with the Firebase CLI#

The pinned firebase-tools 15.22.4 reads firebase auth:import and firebase database:import files through stream-json 1.9.1. That version has open advisories: crafted JSON can stall the CLI or change the prototype of a parsed object. There is no fix we can take until the CLI moves past 15.22.4 (see scripts/ci/audit-exceptions.json).

  • Only import a file you made yourself with firebase auth:export (or database:get) from one of this operator's own projects, and that has stayed on your machine or in your storage since.
  • Never import a file from a client, a vendor, an email attachment or any other third party. To bring in users from outside, turn their data into a new export file with your own tools first, and check it before you import it.

7. Rotating access#

There is no key to rotate. To revoke a client's deploy access, remove the IAM policy binding from §2 (gcloud iam service-accounts remove-iam-policy-binding) or delete the GitHub Environment; both take effect on the next run, immediately.

8. Rolling back a bad deploy#

deploy-client.yml deploys hosting, functions, rules/indexes, and content on every run — a bad deploy is rarely just one of those. Roll back the piece that's actually broken; rolling back everything when only one component regressed reintroduces whatever the other components' good deploys fixed.

Hosting#

Firebase Hosting keeps prior releases. Fastest path is the Firebase Console — Hosting → the site → release history → Rollback on the last-known-good release; it's a Console action against EVENT_HOSTING_SITE, not a re-run of CI. From the CLI, with the same credentials an operator uses for one-off scripts (not the deploy service account — this is a manual, watched action):

npx firebase hosting:clone <SITE_ID>@<GOOD_VERSION_ID> <SITE_ID>:live --project <GCP_PROJECT_ID>
          

Find <GOOD_VERSION_ID> in the Console's release history. The Firebase CLI has no command that lists releases, and hosting:clone takes a version as <SITE_ID>@<VERSION_ID>, not as a channel. This affects the served static bundle only — it does not touch functions, rules, or content.

Functions#

There is no one-click functions rollback the way Hosting has one; the fix is to redeploy the prior good ref. Dispatch deploy.yml (workflow_dispatch, client: <CLIENT_ENV>) from main at the last-known-good commit — either revert forward on main and dispatch normally, or, if you need the exact prior artifact right now, git checkout <good-sha> and dispatch the deploy workflow with that ref (gh workflow run deploy.yml --ref <good-sha> -f client=<CLIENT_ENV>). Recall §1–2: the WIF binding only authorizes refs/heads/main, so a dispatch against an arbitrary SHA or tag that isn't reachable from main's current HEAD, or isn't itself on refs/heads/main, fails at the token-exchange step — this is a control, not a bug, and it means the practical rollback path is "revert on main," not "dispatch an old tag directly." Read §5's step ordering again if this is a fresh-enough project that bootstrap semantics could matter.

Firestore rules and indexes#

provision deploys firestore:rules, firestore:indexes, and storage on every run (§5) — a rules regression rolls back the same way functions do: revert on main, redeploy. Two things that don't roll back cleanly:

  • A tightened rule that already blocked a write nobody needed rolls back fine — nothing was lost by the tightening.
  • An index removed or narrowed takes real time to rebuild once restored — Firestore composite index builds are not instant, and any query depending on the removed index fails (not degrades) until the rebuild finishes. Budget for that gap; it is not the same shape of "instant" as a Hosting rollback.

Content snapshot implications#

content (generate-content.cjs) regenerates apps/web/src/generated/* from live Firestore at deploy time — rolling back hosting alone serves the previous build's generated snapshot, which was correct for the config/content at that previous deploy's time, not for whatever's in Firestore now. If a client has edited and published content since the bad deploy, a hosting-only rollback can visibly regress their content even though nothing about their content itself broke. When content has moved since the deploy you're rolling back from, prefer rolling forward (fix and redeploy) over rolling back, or expect to re-publish the affected pages after the rollback.

If this client has the site publisher enabled (§9), a hosting-only rollback does not hold. The next CMS publish invokes the site-publisher Cloud Run job, which rebuilds from its own image — the one the last deploy pushed, i.e. the code you just rolled hosting back from — and redeploys hosting over the rollback. So "re-publish the affected pages after the rollback", above, is the one thing that will silently undo it. To hold a hosting rollback on a publisher-enabled client, either set EVENT_SITE_PUBLISHER_ENABLED=false and deploy before rolling back, or roll the code back properly (revert on main, deploy) so the rebuilt image carries the good code. §9.3 has the detail.

When NOT to roll back#

Schema-forward migrations. A deploy that changed the shape of a config/* document, added a required field a running function now depends on, or changed what a Firestore trigger writes is not safely undone by redeploying old function code against the new data shape (or vice versa) — the two can disagree about what a document should look like, and that disagreement is a worse failure mode than the original bug. If the deploy you want to roll back included a migration, roll forward with a fix instead: write the small forward-fixing change, dispatch it, and treat the rollback tooling above as being for deploys that were behaviorally wrong, not ones that changed a data contract other code now depends on.

Custom domain and readiness notes#

A custom-domain rollback (attaching a different Hosting site, or reverting a EVENT_PUBLIC_URL/authorized-domains change) does not take effect immediately even after the Hosting/Auth Console steps are redone — DNS propagation and certificate issuance/reissuance can take from minutes to about a day (§0–3, and docs/CLIENT_ONBOARDING.md §3 item 3). Re-run node scripts/init-event.cjs --check after a domain change of any kind rather than assuming the readiness table is still accurate; the sender-domain and Auth rows in particular are operator-attested and do not re-verify themselves on a timer. See issue #66 for the fuller per-client-subdomain (client.eventrunner.org) provisioning flow this repo will eventually build on top of this same Hosting custom-domain mechanism — the wildcard-vs-per-client CNAME strategy is out of scope here and tracked there, not in this runbook. The Cloud Run site-publisher has since landed (#36) and is documented in §9; it is orthogonal to per-client subdomains, reaching the job as ordinary per-client environment with no domain coupling, so nothing in §9 waits on #66.

9. Site publisher (optional, per client)#

What it does. The frontend reads CMS content from Firestore at runtime, so a publish is already live for human visitors the moment cmsPublish commits (spec §2.4, §8.4). What lags is the static snapshot in the deployed bundle — the first paint, and the only thing a crawler that does not run JavaScript ever sees. The site-publisher Cloud Run job closes that gap on demand: after a successful publish, cmsPublish starts one execution, which runs generate-content.cjs against this project's published collections, rebuilds the bundle with this client's VITE_* values, and runs firebase deploy --only hosting. Entirely inside the client's own project, under the job's own service account — no GitHub token, no repository_dispatch, no cross-project credential (spec §8.4 phase 5; the container is publisher/Dockerfile, the logic is scripts/publish-site.cjs).

Sitemap, robots, and manifest do not wait for this job. scripts/publish-site.cjs writes sitemap.xml, robots.txt, and the web manifest from a live Firestore read, but this job runs only when a CMS publish triggers it — a fresh deployment, and every ordinary deploy-client.yml run, never runs it at all. That workflow's build job instead runs scripts/write-site-files.cjs right after npm run build, from the SAME generated snapshot the build just used (never the committed demo copy), through the same builders in scripts/lib/site-manifest.cjs — so a client's site never goes without these files between its first deploy and its first content publish, whether or not the site publisher is enabled at all.

App icons. Both paths also write two raster app icons beside the manifest, branding/app-icon-192.png and branding/app-icon-512.png (#218). When the square icon slot (config/theme.logos.mark) names an uploaded square PNG, the job reads it from EVENT_STORAGE_BUCKET over its public download URL and resamples it. branding/ is public read in storage.rules, so neither the publisher nor the build job needs an IAM change or a credential for this. Any other slot value ships the neutral placeholder icons, and the job log names the reason on a line that starts app icons: neutral placeholder:. An icon problem never fails the job.

It is optional. With EVENT_SITE_PUBLISHER_ENABLED unset or false, nothing below exists, the publisher deploy job is skipped, EVENT_SITE_PUBLISHER_JOB is never written into the functions env, and cmsPublish skips the invoke without writing anything. That is the phase 2–4 behavior: publishes work, and the crawler snapshot refreshes at the next deploy.

9.1 One-time setup per client#

Enable the two APIs the job needs (run.googleapis.com is already in §2's list):

gcloud services enable \
            run.googleapis.com \
            artifactregistry.googleapis.com \
            --project=<GCP_PROJECT_ID>
          

Create the job's runtime service account. This is a different identity from the deploy service account (§2) and from the functions runtime account (§3). It is the account the job runs as, and its grants are the entire blast radius of a compromised publisher image:

gcloud iam service-accounts create site-publisher \
            --project=<GCP_PROJECT_ID> \
            --display-name="Event Runner site publisher (<CLIENT_ENV>)"

          PUBLISHER_SA_EMAIL="site-publisher@<GCP_PROJECT_ID>.iam.gserviceaccount.com"

          for role in \
            roles/datastore.user \
            roles/firebasehosting.admin \
            roles/storage.objectViewer \
            roles/serviceusage.serviceUsageConsumer
          do
            gcloud projects add-iam-policy-binding <GCP_PROJECT_ID> \
              --member="serviceAccount:${PUBLISHER_SA_EMAIL}" \
              --role="${role}" \
              --condition=None
          done
          

Why exactly those four, and nothing more:

  • datastore.user — generate-content.cjs reads config/* and the published CMS collections, and publish-site.cjs writes its terminal status back to the cmsPublishQueue row. Read-only (datastore.viewer) is not enough for that status write.
  • firebasehosting.admin — the one thing the job exists to do. It cannot deploy functions, rules, or indexes: publish-site.cjs only ever runs firebase deploy --only hosting:site, and no role here would let it do otherwise.
  • storage.objectViewer — branding assets under EVENT_STORAGE_BUCKET that the snapshot references. Viewer, not admin: the job never writes to Storage.
  • serviceUsageConsumer — required for the Firebase CLI's API calls to bill against this project.

Notably absent: no run.admin (the job does not manage itself), no artifactregistry role (it does not push images; the deploy service account does that), no iam.serviceAccountUser, and no secretmanager.secretAccessor — the job needs no secrets.

The deploy service account (§2) already carries artifactregistry.writer and run.developer, which is what the publisher job needs to push the image and create the Cloud Run job. It also needs iam.serviceAccountUser on the publisher account in order to deploy a job that runs as it — §2's project-level iam.serviceAccountUser covers this, but the narrower binding is better:

gcloud iam service-accounts add-iam-policy-binding "${PUBLISHER_SA_EMAIL}" \
            --project=<GCP_PROJECT_ID> \
            --member="serviceAccount:${DEPLOY_SA_EMAIL}" \
            --role="roles/iam.serviceAccountUser"
          

Then set the two GitHub Environment variables from §3:

VariableValue
EVENT_SITE_PUBLISHER_ENABLEDtrue
EVENT_PUBLISHER_SERVICE_ACCOUNTsite-publisher@<GCP_PROJECT_ID>.iam.gserviceaccount.com

validateDeployEnv fails the deploy if the flag is true and the account is unset — there is deliberately no default, because a blank would hand the job whatever identity Cloud Run picks.

The next deploy from main provisions everything else automatically: the run-of-show Artifact Registry repository in this project, the image (tagged with the commit SHA), the site-publisher Cloud Run job with this client's environment, and roles/run.invoker for the functions runtime account on that one job. Nothing is granted project-wide.

Ordering on the first enable. The publisher job and the functions job run independently in the same workflow run, so for a short window the deployed cmsPublish may know the job's name before the job exists. A publish in that window still succeeds — the invoke fails soft, records publisher.invoke.status: 'failed' on the queue row, and raises an operator alert. Re-publish after the run completes.

9.2 Verifying it#

# The job exists, runs as the right identity, and carries this client's env.
          gcloud run jobs describe site-publisher \
            --region=<EVENT_FIREBASE_REGION> --project=<GCP_PROJECT_ID> \
            --format="yaml(spec.template.template.serviceAccountName, spec.template.template.containers[0].image)"

          # run.invoker is bound on THE JOB, to the functions runtime account only.
          gcloud run jobs get-iam-policy site-publisher \
            --region=<EVENT_FIREBASE_REGION> --project=<GCP_PROJECT_ID> --format="json(bindings)"

          # End to end, without going through the CMS: start one execution by hand.
          gcloud run jobs execute site-publisher --wait \
            --region=<EVENT_FIREBASE_REGION> --project=<GCP_PROJECT_ID>
          

A hand-started execution takes no PUBLISH_QUEUE_ID, so it publishes the site and writes no status row — which makes it the safe way to test the container itself.

Then the real path: make a trivial CMS edit, publish it, and check that cmsGetPublishQueue shows the row with publisher.invoke.status: 'invoked' followed by publisher.status: 'done', and that the deployed HTML source (not the rendered page — curl -s <EVENT_PUBLIC_URL> | grep) carries the new content.

The exit code names the stage, so triage rarely needs the log:

ExitMeaning
0published
1an unexpected error in the entrypoint itself
2the job's environment is invalid — nothing ran; compare gcloud run jobs describe against §3
3generate-content.cjs failed — usually datastore.user missing, or config/event absent
4the vite build failed — a missing VITE_* value, or the task ran out of memory
5firebase deploy --only hosting failed — usually firebasehosting.admin missing
6writing sitemap.xml, robots.txt, or the web manifest failed — usually config/event or cmsPages unreadable; runs after the build, before hosting deploys

Common failures on the invoke side, from the queue row's publisher.invoke.error:

  • Cloud Run responded 403 — the run.invoker grant is missing or was applied to the wrong identity. Re-check the runtime account against §3's RUNTIME_SA_EMAIL.
  • Cloud Run responded 404 — the job does not exist in that region. Check EVENT_FIREBASE_REGION matches where the deploy created it.

9.3 Interaction with rollback#

§8 is the rollback procedure; this is only what changes once a client has a publisher. The publisher deploys hosting from whatever commit built its image, not from the currently deployed bundle, which turns §8's "Hosting" rollback from a durable action into a temporary one. Two consequences worth knowing before an incident:

  • Rolling back hosting alone does not stick. A Firebase Hosting rollback in the console is undone by the next CMS publish, because the job rebuilds from its image and redeploys. To hold a rollback, either disable the publisher (EVENT_SITE_PUBLISHER_ENABLED=false, then deploy) or roll the code back properly — revert on main and let the deploy rebuild the image.
  • Rolling back the code is a deploy. Reverting on main rebuilds and repushes the image under the new commit SHA and updates the job, so the next publish uses the rolled-back code. The old image stays in Artifact Registry under its own SHA tag; nothing is overwritten.

9.3.1 The publisher does not redeploy the SSR meta functions#

deploy-client.yml's post job redeploys updatesMeta and routeMeta after every hosting deploy, for the template reason in §6. The publisher job does not, and cannot — its service account holds firebasehosting.admin and nothing else, on purpose (§9.1). So a CMS publish changes the hosting release without restarting a single function container.

That is survivable rather than an outage because the held template is revalidated against hosting's ETag rather than trusted for a fixed span: past TEMPLATE_REVALIDATE_FLOOR_MS (ten seconds, functions/src/public/og.cjs) the next request revalidates, the changed build answers 200 with the new body, and the container is current from then on. The exposure is that ten-second floor, not the five minutes a flat cache would have cost.

If a client does report a blank page straight after a publish, that window is the first thing to rule out — wait ten seconds and reload. Redeploying the two functions (firebase deploy --only functions:updatesMeta,functions:routeMeta) clears it immediately.

Stranded rows are not an incident. If neither cmsPublish nor the job reports a result, the cleanupStrandedPublishRows sweep (functions/src/maintenance/cleanup.cjs, every 30 minutes) marks the row failed after 90 minutes and alerts once — a failed publish row is resumable from the CMS, and a failed publisher row just means the snapshot is stale until the next publish or deploy.

9.4 Cost#

The job runs on demand — once per CMS publish, plus whatever an operator starts by hand. There is no idle cost: a Cloud Run job bills only while an execution is running, and it has no minimum instances and no request-serving footprint between executions. One execution is a few minutes of 2 vCPU / 4 GiB, so a normal editing day costs cents. What accumulates instead is Artifact Registry storage, one image per deployed commit — prune old tags if a client's project has years of them:

gcloud artifacts docker images list \
            "<EVENT_FIREBASE_REGION>-docker.pkg.dev/<GCP_PROJECT_ID>/run-of-show/site-publisher" \
            --project=<GCP_PROJECT_ID>
          

The alternative — putting the snapshot refresh on a nightly schedule — is what phases 2–4 specified and costs a build a day whether or not anything changed (spec §8.4, §10 Q7). Nothing in this repo implements it: the refresh went straight from "next deploy" to this on-demand job.

10. Project site on GitHub Pages#

This section is not per client. It covers the project's own site: the landing page, the documentation under docs/docs/, and the static demo under docs/demo/. .github/workflows/pages.yml builds the site and deploys it on every push to main.

One value sets the address. scripts/lib/pages-site.cjs holds the public address of the site: an https origin and a path base. scripts/build-pages.cjs, scripts/build-demo.cjs, and scripts/check-docs.cjs read it. It sets the canonical and social card URLs, every root-relative link, the demo's Vite base, and how the link checker maps a path to a file under docs/.

AddressPath baseWhere it comes from
https://centercoopmedia.github.io/eventrunner//eventrunner/the committed default
https://docs.eventrunner.org//the PAGES_BASE_URL repository variable, after the switch below

The committed docs/docs/ and the landing pages (docs/index.html, docs/videos/index.html) are built for the committed default. When the PAGES_BASE_URL repository variable is set, the workflow renders the pages again for that address before it builds the demo and checks the links. The workflow commits nothing.

The workflow does not deploy to a wrong address. Before it deploys, the workflow compares the address with the URL that Pages reports. If they are not the same, the run fails and the site that is live now stays live. The message names both addresses.

The address and the domain change together. With a base of /, every link points at the domain root. While the site is served at the github.io project path, those links break. A site built for /eventrunner/ breaks in the same way when it is served from the custom domain. Do the steps below in this order.

10.1 Switch the site to docs.eventrunner.org#

  1. DNS. Add CNAME docs → centercoopmedia.github.io for eventrunner.org. On Cloudflare, set the record to DNS only (grey cloud). If the record is proxied, GitHub cannot issue the certificate. Wait until dig +short docs.eventrunner.org CNAME returns centercoopmedia.github.io..
  2. Optional: verify the domain. In the organization settings, go to Pages → Verified domains and verify eventrunner.org. This prevents a domain takeover.
  3. Variable. Go to Settings → Secrets and variables → Actions → Variables. Add the repository variable PAGES_BASE_URL with the value https://docs.eventrunner.org/. Until step 4, a Pages run fails at the address check and deploys nothing. The live site does not change.
  4. Custom domain. Go to Settings → Pages → Custom domain. Enter docs.eventrunner.org and save. With a workflow-deployed site, GitHub ignores a CNAME file in the artifact, so this setting is the only place for the domain. From now until step 5 ends, the live site has broken links, so do step 5 immediately. The DNS check can finish while step 5 runs.
  5. Deploy. Go to Actions → Pages → Run workflow, on main. The run renders the site for the new address, checks every link, compares the address with the Pages URL, and deploys.
  6. HTTPS. When the DNS check passes and the certificate is issued, turn on "Enforce HTTPS" in Settings → Pages.

To check the result:

curl -sI https://docs.eventrunner.org/ | head -1           # 200
          curl -sI https://docs.eventrunner.org/demo/ | head -1      # 200
          curl -sI https://docs.eventrunner.org/docs/ | head -1      # 200
          curl -sI https://centercoopmedia.github.io/eventrunner/ | grep -i '^location'   # the new host
          curl -s https://docs.eventrunner.org/ | grep canonical     # https://docs.eventrunner.org/
          

Open a documentation page and the demo, and make sure that the styles, fonts, favicon, and links load from the new host.

Later, in a pull request. When the domain works, make the custom domain the committed default:

  1. In scripts/lib/pages-site.cjs, set DEFAULT_PAGES_BASE_URL = 'https://docs.eventrunner.org/'. This is the one line to change.
  2. Run node scripts/build-pages.cjs. It renders docs/docs/ again and moves the landing pages to the new address. Commit the output.
  3. Update the tests that name the default address, and the links to the github.io address in README.md, docs/handbook/, RELEASING.md, and scripts/README.md. The links still work before this step, because GitHub redirects the github.io address to the custom domain, and the generated pages already send a reader to the configured address.
  4. After that pull request is merged, you can delete the PAGES_BASE_URL variable.

To go back to the github.io address, delete the custom domain in Settings → Pages. Then delete the PAGES_BASE_URL variable, or set it to https://centercoopmedia.github.io/eventrunner/. Run the Pages workflow again.

To see what an address changes, without writing a file:

PAGES_BASE_URL=https://docs.eventrunner.org/ node scripts/build-pages.cjs --check   # shows what would change
          node scripts/lib/pages-site.cjs                                                    # prints the address in use
          

This page is generated from docs/DEPLOY_RUNBOOK.md. Edit the Markdown, not the HTML.