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 asclienttoworkflow_dispatch, and it must exactly match theAUTO_DEPLOY_ENVIRONMENTSentry 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.
| Variable | Value |
|---|---|
WIF_PROVIDER | the provider resource name from §1's describe output |
WIF_SERVICE_ACCOUNT | <DEPLOY_SA>@<GCP_PROJECT_ID>.iam.gserviceaccount.com |
EVENT_SLUG | lowercase identifier, e.g. cjs2027 |
EVENT_FIREBASE_PROJECT_ID | <GCP_PROJECT_ID> |
EVENT_FIREBASE_REGION | us-central1 unless the project's functions live elsewhere |
EVENT_HOSTING_SITE | the Firebase Hosting site ID (Console → Hosting) |
EVENT_PUBLIC_URL | canonical origin, e.g. https://summit.example.org |
EVENT_STORAGE_BUCKET | the project's default Storage bucket |
EVENT_ALLOWED_ORIGINS | comma-separated CORS origins |
EVENT_EMAIL_PROVIDER | postmark | webhook. console is valid only when the GitHub Environment name is exactly demo |
EVENT_TICKETING_PROVIDER | eventbrite | stripe | manual | none |
EVENT_TICKETING_EVENT_ID | Eventbrite event ID or stable internal event ID for Stripe; required for either provider |
EVENT_STRIPE_ACCOUNT_ID | organizer-owned acct_… account; required when the ticketing provider is stripe |
EVENT_STRIPE_MODE | explicit test or live when using Stripe; begin onboarding in test |
EVENT_OPERATOR_NOTIFIER | webhook | email | none |
EVENT_APP_CHECK_ENFORCED | false by default; set the App Check site key before changing this to true |
EVENT_OTP_SEND_CEILING_PER_HOUR | positive 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_ID | from Firebase Console → Project settings → your web app's SDK config |
VITE_FIREBASE_MEASUREMENT_ID | optional; Firebase omits it when Google Analytics is disabled, and Eventrunner does not use it |
VITE_FIREBASE_APP_CHECK_SITE_KEY | reCAPTCHA v3 site key; set this before enabling App Check enforcement |
EVENT_SITE_PUBLISHER_ENABLED | false by default. true provisions the site-publisher Cloud Run job — do §9 first |
EVENT_PUBLISHER_SERVICE_ACCOUNT | required when the publisher is enabled; the job's own runtime identity (§9) |
EVENT_FUNCTIONS_SERVICE_ACCOUNT | optional; overrides the default compute service account that receives run.invoker (§9) |
EVENT_AIRTABLE_ENABLED | false 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:
| Secret | Required when |
|---|---|
EMAIL_PROVIDER_API_KEY | EVENT_EMAIL_PROVIDER = postmark (server token) |
EMAIL_WEBHOOK_BASIC_AUTH | EVENT_EMAIL_PROVIDER = postmark and delivery-event ingest is enabled |
EMAIL_WEBHOOK_URL, EMAIL_WEBHOOK_SECRET | EVENT_EMAIL_PROVIDER = webhook |
TICKETING_API_TOKEN | EVENT_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_SECRET | eventbrite 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_SECRET | EVENT_OPERATOR_NOTIFIER = webhook |
AIRTABLE_API_TOKEN | EVENT_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, sofunctions:fooalso selectsfoo-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 theworkloadIdentityUserbinding from §2, and its member ends in.../attribute.repository_and_ref/<GH_ORG>/<GH_REPO>#refs/heads/main— notattribute.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 thegoogle-github-actions/authstep with a token-exchangepermission_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_dispatchrun ofdeploy.ymlwithclient: <CLIENT_ENV>frommainshould reach thesmokejob and pass — it OPTIONS-preflights every endpoint in.github/smoke-endpoints.jsonagainsthttps://<EVENT_FIREBASE_REGION>-<EVENT_FIREBASE_PROJECT_ID>.cloudfunctions.netand GETsEVENT_PUBLIC_URL.Between
hostingandsmoke, thepostjob redeploysupdatesMetaandrouteMeta(functions/src/public/og.cjs) alone. Both self-fetch the deployed hostingindex.htmlas 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, androuteMetais 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'spoststep (§8.1), deferred at the M2 deploy PR pendingfunctions/src/public/og.cjslanding —smoke's OPTIONS-preflight of both functions now runs against the freshly-redeployed instances, not the ones from thefunctionsjob earlier in the same run.What
routeMetacosts, 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 toindex.html. Three things bound that. The response carriesCache-Control: public, max-age=0, s-maxage=300, must-revalidate, so the Hosting CDN — not the function — answers repeat traffic for the same URL, whilemax-age=0, must-revalidatekeeps a reader's own browser from holding, or falling back to, a shell that names asset files a later deploy removed:max-age=0alone still lets a cache serve a stale copy under pressure. There is deliberately nostale-while-revalidatefor 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 iscore/config.cjs's shared 5-minute container cache, whichs-maxagematches: 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.phpand 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.routeMetasetsmaxInstancesto 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.svgunfurls with an empty frame, which a reader takes for a broken link rather than a plain one.routeMetatherefore skips any SVG candidate, wherever it comes from (theconfig/theme.logos.ogDefaultslot orconfig/event.seo.defaultOgImagePath, whose seeded value is that SVG), and points atbranding/og-default.png, rendered byscripts/dev/build-og-placeholder.mjsand 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.routeMetadoes 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 unsetEVENT_PUBLIC_URL— serves that template with the event-level tags and nonoindex, 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: checkEVENT_PUBLIC_URLand that hosting is actually serving/index.html.firebase.json's rewrite order is load-bearing./updates/**goes toupdatesMetafirst; 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 torouteMeta. 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 theheadersblock below it. Hosting serves an existing static file before it applies any rewrite, which is what keeps/assets/**,/branding/**, androuteMeta's own self-fetch of/index.htmlfrom re-entering the function.scripts/deploy-workflow.test.cjsasserts 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, settingX-Robots-Tag: noindex. The shell they serve is one file for every route, so it cannot carry a per-route robots meta the way arouteMetaresponse 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 tous-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), whilefirebase.jsonis one file shared by every client's deploy — the same shape of problemEVENT_HOSTING_SITEis (target names, not literal site ids), and the same fix: thehostingjob's "Set the function rewrite regions for this project" step patches the working copy offirebase.jsonwithjqimmediately beforefirebase deploy --only hosting:sitereads it, defaulting tous-central1whenEVENT_FIREBASE_REGIONis 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 keepsus-central1as 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 showsfirebase-schedule-endExpiredStaffAccess-<EVENT_FIREBASE_REGION>, set toevery 1 minutes, and it must beENABLED. 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 findfirebase-schedule-runScheduledReminders-<EVENT_FIREBASE_REGION>, set toevery 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/authfails withpermission_deniedon a run you expected to succeed (dispatched frommain), the attribute condition (§1) or the repository+ref binding (§2) is the first thing to re-check — copy the exactrepositoryandrefclaims 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(ordatabase: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.cjsreadsconfig/*and the published CMS collections, andpublish-site.cjswrites its terminal status back to thecmsPublishQueuerow. 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.cjsonly ever runsfirebase deploy --only hosting:site, and no role here would let it do otherwise.storage.objectViewer— branding assets underEVENT_STORAGE_BUCKETthat 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:
| Variable | Value |
|---|---|
EVENT_SITE_PUBLISHER_ENABLED | true |
EVENT_PUBLISHER_SERVICE_ACCOUNT | site-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:
| Exit | Meaning |
|---|---|
0 | published |
1 | an unexpected error in the entrypoint itself |
2 | the job's environment is invalid — nothing ran; compare gcloud run jobs describe against §3 |
3 | generate-content.cjs failed — usually datastore.user missing, or config/event absent |
4 | the vite build failed — a missing VITE_* value, or the task ran out of memory |
5 | firebase deploy --only hosting failed — usually firebasehosting.admin missing |
6 | writing 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— therun.invokergrant is missing or was applied to the wrong identity. Re-check the runtime account against §3'sRUNTIME_SA_EMAIL.Cloud Run responded 404— the job does not exist in that region. CheckEVENT_FIREBASE_REGIONmatches 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 onmainand let the deploy rebuild the image. - Rolling back the code is a deploy. Reverting on
mainrebuilds 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/.
| Address | Path base | Where 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#
- DNS. Add
CNAME docs → centercoopmedia.github.ioforeventrunner.org. On Cloudflare, set the record to DNS only (grey cloud). If the record is proxied, GitHub cannot issue the certificate. Wait untildig +short docs.eventrunner.org CNAMEreturnscentercoopmedia.github.io.. - Optional: verify the domain. In the organization settings, go to Pages → Verified domains
and verify
eventrunner.org. This prevents a domain takeover. - Variable. Go to Settings → Secrets and variables → Actions → Variables. Add the repository
variable
PAGES_BASE_URLwith the valuehttps://docs.eventrunner.org/. Until step 4, a Pages run fails at the address check and deploys nothing. The live site does not change. - Custom domain. Go to Settings → Pages → Custom domain. Enter
docs.eventrunner.organd save. With a workflow-deployed site, GitHub ignores aCNAMEfile 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. - 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. - 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:
- In
scripts/lib/pages-site.cjs, setDEFAULT_PAGES_BASE_URL = 'https://docs.eventrunner.org/'. This is the one line to change. - Run
node scripts/build-pages.cjs. It rendersdocs/docs/again and moves the landing pages to the new address. Commit the output. - Update the tests that name the default address, and the links to the github.io address in
README.md,docs/handbook/,RELEASING.md, andscripts/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. - After that pull request is merged, you can delete the
PAGES_BASE_URLvariable.
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