Platform-to-client promote runbook
Platform-to-client promote runbook
Section titled “Platform-to-client promote runbook”Status: Authoritative description of the manual promote mechanism by which a Platform-proven cocoindex ingestion configuration becomes a client’s pinned
vX.Y.Zdeployment (ID-127 BI-14). v1 is a documented manual runbook — there is no scripted promote. Owner: platform (Liam). Audience: whoever stands up the Platform rig, cuts a release, or promotes a proven config to a client instance.Build-once / config-per-environment. The promote artefact is the single immutable
kh-cocoindex-pipelineGHCR image (ghcr.io/<owner>/kh-cocoindex-pipeline, built off-host byonprem-deploy.yml, pinnedvX.Y.Zvia ID-113’stag-release). The same image moves Platform → client unchanged; only config differs per environment (DB DSN, source path, secrets, compose name-suffixes). Nothing is rebuilt at promote time.PI-5/PI-6 guard: Client identities NEVER enter the public
ai-solution-hub/canonicalrepo — every client reference uses the generic<client>token. The per-client pin record lives ONLY in the private docs-site crib (per-client-release-pins.md).Why this is standalone: the topology conventions (§1) and the config-delta template (§4) are promote-mechanism concerns. The per-client pin record and the upgrade history are owned by per-client-release-pins.md; this runbook cross-links to that crib (§5) and never edits it inline.
1. Topology conventions (BI-5)
Section titled “1. Topology conventions (BI-5)”The Platform standup establishes correct, extensible Coolify conventions so a future second client (or a second Platform environment) slots in by suffix substitution, not bespoke rework. State and follow these roles when promoting:
| Role | Meaning |
|---|---|
| project | The Coolify project is the extensibility unit. One project per environment (Platform, each client). A new environment = a new Coolify project, never a fork of an existing project’s resources. |
| server | The host the project’s resources run on (the IONOS VPS). Multiple projects may share one server; the project boundary — not the server — is the environment boundary. |
| source | Where corpus content originates: the local-fs simulated-source folder (local-fs-platform/corpus for Platform; local-fs-<client>/corpus per client) bridged to the on-prem named volume at /cocoindex-state/corpus. |
| destination | The Supabase database the pipeline writes (zjqbrdctesqvouboziae for Platform; the client’s own Supabase project ref per client). The destination DB is the tenant boundary. |
The ca-suffix-per-environment rule
Section titled “The ca-suffix-per-environment rule”A new environment is provisioned by suffix substitution, not by copying the
first-client layout. To add an environment <env> (platform, or a <client>
token):
- A new Coolify project for that environment.
- A
ca-<env>-suffixed app, router (TraefikHostrule), and named volume — e.g. appca-pipeline-<env>, router hostca-pipeline-<env>.<domain>, the corpus volume namespaced per-app so volumes never cross-contaminate. - A
docker-compose.<env>.yamlcut from the shared structural template (the cocoindex service block + the standard env keyset) — see §4 for the Platform → client delta. - A per-environment GitHub Actions environment holding that environment’s
COOLIFY_*secrets (COOLIFY_API_TOKEN,COOLIFY_BASE_URL,COOLIFY_APP_UUID). Per-env GH environments are how naming and secret-scoping interact: the deploy job resolves the right app UUID + token fromenvironment: <Env>rather than from a shared secret set (cf.onprem-deploy.yml’sdeploy-cocoindex-platform-stagingresolving the platform app UUID from theStagingGH environment).
The naming scheme uses ca/canonical (kh→ca, knowledge-hub→canonical) —
not the legacy kh/knowledge-hub first-client strings. The conventions are
written to generalise to N environments, not to “platform + one client”.
2. Prove on Platform
Section titled “2. Prove on Platform”Develop and test the whole config — compose + env keyset + corpus — on the
Platform rig at main before anything is cut or promoted. The promote artefact is
only as faithful as what was proven, so prove the full E2E path, not a subset:
-
Confirm a real ingest lands content: run the repeatable release gate, at this step and before §3 cuts the
vX.Y.Ztag:Terminal window bun run scripts/verify-platform-promotion-gate.ts --target=prod(
--target=stagingagainst the staging Platform DB). Only a config that passes the gate is a promote candidate. The gate selects the latest run WHEREpipeline_name = 'kh_canonical_pipeline' AND op_id IS NOT NULL— not a barestatus = 'completed', because governance crons emit emptycompletedheartbeats for other pipelines — then re-derives every assertion filtered by that run’sop_id:source_documents(the primary grain, covering both content-side files and feed articles),q_a_extractions, andreference_items(the feed/URL slice). The HEADLINE assertion iscontent_chunks(op_id=run) > 0and every chunk embedded — chunk embeddings live inrecord_embeddings(owner_kind='content_chunk'), not an inlinecontent_chunks.embeddingcolumn; id-131 has already dropped that column on Platform. Running the gate needs the operator to export the Platform DB credentials it resolves against:PLATFORM_PROD_URL+PLATFORM_PROD_SERVICE_ROLE_KEYfor--target=prod, or thePLATFORM_STAGING_*pair for--target=staging.The walk’s route-tag manifest
.kh-workspace-map.json(the corpus walk-guard- content/feed router the walk depends on) is a distinct artefact from the
app-side
form_template_requirementsPath-C requirement catalogue — the two are never the same thing, and the gate touches neither as a “forms” check.
- content/feed router the walk depends on) is a distinct artefact from the
app-side
-
Confirm the webhook records a run: the worker’s
PIPELINE_RUN_WEBHOOK_URLround-trip records apipeline_runsrow in the Platform DB via the Platform app’s/api/internal/pipeline-runs/recordendpoint (status-only, best-effort) — this is the samepipeline_runsrow the gate (bullet 1) selects its run from. -
Confirm the monitor reads idle: the datapath watch reports idle for the Platform pipeline’s intermittent/dev-time shape (an idle source or no recent walk reads as idle, not as a stall to page on).
Only a config that has passed all three on Platform is a candidate to promote.
3. Cut the release (ID-113)
Section titled “3. Cut the release (ID-113)”At a known-good main, mint the immutable release artefact. This is the ID-113
release-cut model — the full procedure (deploy-gate verification, semver-bump
choice, old-code-tolerant classification, AGPL discharge, crib changelog) lives
in per-client-release-pins.md §3.
The promote-relevant core is:
-
git tag vX.Y.Z <known-good-main-sha>and push. The tag is immutable (never retag; ship a fix as a new PATCH). -
Fast-forward the
release/vX.Y.ZVercel handle branch to the same commit. -
Re-tag the cocoindex image — alias the known-good
sha-<sha>image tovX.Y.Zwithout a rebuild via the shippedtag-releaseworkflow_dispatch(onprem-deploy.yml):Terminal window gh workflow run onprem-deploy.yml \--ref vX.Y.Z \--field job=tag-release \--field source_sha=<full-sha> \--field release_version=vX.Y.ZThe job pulls
sha-<sha>, tags itvX.Y.Z, and pushes — pure alias, no rebuild. ThisvX.Y.Zimage is the artefact every downstream environment pins.
4. Config-delta template (Platform → client)
Section titled “4. Config-delta template (Platform → client)”The promote is a config delta applied to a copy of the proven Platform config —
the image does not change. Apply, per target <client>:
Compose deltas
Section titled “Compose deltas”| Platform value | Client value |
|---|---|
app ca-pipeline-platform | ca-pipeline-<client> |
router host Host(ca-pipeline-platform.<domain>) | the client FQDN Host(ca-pipeline-<client>.<domain>) |
volume suffix …-platform | …-<client> |
compose file docker-compose.platform.yaml | docker-compose.<client>.yaml (cut from the same template) |
PullMD is RETIRED — every environment is cocoindex-only (verified 22/07/2026). No compose file (
deploy/coolify/docker-compose.{production,staging,platform,platform-staging}.yaml) carriespullmd/playwright/pullmd-data-*services, volumes, orPULLMD_*env any more — URL extraction is served in-process by the cocoindex worker’s/extract(trafilatura) path in ALL environments, client and Platform alike. The earlier framing (“a client RE-ADDS the PullMD trio if it ingests URLs via the legacy PullMD path”) is historical: there is no PullMD path to re-add, so the compose delta is pure suffix/env substitution with no service-set delta.
Env re-key
Section titled “Env re-key”Re-point these from Platform values to the client’s values (client DB ref + client FQDN):
| Key | Re-key action |
|---|---|
COCOINDEX_DB_DSN | Platform pooler DSN → the client’s Supabase pooler postgres DSN (read the aws-N prefix from the client dashboard; never the service-role JWT as a password). |
SUPABASE_* (SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, etc.) | Platform project → the client’s Supabase project. |
PIPELINE_RUN_WEBHOOK_URL | Platform app endpoint → the client app’s /api/internal/pipeline-runs/record. |
NEXT_PUBLIC_APP_URL | Platform FQDN → the client FQDN. |
PIPELINE_TRIGGER_SECRET | Minted fresh per client (not copied from Platform). The sole bearer for /walk, /producer-run, AND the webhook route since the {127.18} secret split (the legacy CRON_SECRET dual-accept was retired S457) — the SAME value must be set on the client’s pipeline app (Coolify env) and the client’s Vercel app env (the webhook route verifies it via verifyPipelineTriggerAuth, lib/cron-auth.ts). |
CRON_SECRET | Minted fresh per client (not copied from Platform). App-side only post-{127.18}: it gates the client app’s Vercel /api/cron/* routes — it no longer authenticates against the pipeline’s /walk. |
EXTRACT_API_TOKEN | Minted fresh per client (not copied from Platform). |
COCOINDEX_SOURCE_PATH stays /cocoindex-state/corpus (the per-app volume is
already environment-isolated by the suffix in §1).
Image pin
Section titled “Image pin”Pin the client’s COCOINDEX_IMAGE_TAG to the proven vX.Y.Z from §3 — the
same immutable image that ran on Platform. Never :latest, never a rolling
main sha. This is the build-once / config-per-environment payoff: the client runs
the exact bytes proven on Platform, differing only in config.
5. Pin + record
Section titled “5. Pin + record”Finalise the client pin and record it in the append-only crib:
-
Set the client Coolify
COCOINDEX_IMAGE_TAGtovX.Y.Z— PATCH in place (POST would create a duplicate key), then trigger a client Coolify deploy and confirm it completes:Terminal window curl --fail-with-body -sS \-X PATCH "${COOLIFY_BASE_URL}/api/v1/applications/${COOLIFY_APP_UUID}/envs" \-H "Authorization: Bearer ${COOLIFY_API_TOKEN}" \-H "Content-Type: application/json" \-d '{"key":"COCOINDEX_IMAGE_TAG","value":"vX.Y.Z","is_buildtime":true,"is_runtime":true}' -
Append the upgrade row to the ID-113 append-only crib — the per-client pin table (§1) and the append-only upgrade history (§2) in per-client-release-pins.md. Record at minimum: the
<client>token, from→to version, date (DD/MM/YYYY), and the migration delta.Cross-link only — do NOT edit that crib from here. The pin record and upgrade history are owned by per-client-release-pins.md (it is the single ops surface for per-client version state, ID-113 PI-7). This runbook describes the promote mechanism; the crib holds the per-client record. The full same-step crib-update procedure (append-only history columns, pin-table in-place update) is per-client-release-pins.md §4 step 8.
Related runbooks
Section titled “Related runbooks”- per-client-release-pins.md — the per-client pin table, the append-only upgrade history, the §3 cut-a-release procedure, and the §4 upgrade-a-client procedure. This runbook cross-links to it; it does not edit it.
- client-app-deploy.md — stand up a new client instance (Vercel project, Supabase project, branding seed, auth hook).