Skip to content

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.Z deployment (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-pipeline GHCR image (ghcr.io/<owner>/kh-cocoindex-pipeline, built off-host by onprem-deploy.yml, pinned vX.Y.Z via ID-113’s tag-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/canonical repo — 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.

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:

RoleMeaning
projectThe 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.
serverThe 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.
sourceWhere 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.
destinationThe Supabase database the pipeline writes (zjqbrdctesqvouboziae for Platform; the client’s own Supabase project ref per client). The destination DB is the tenant boundary.

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):

  1. A new Coolify project for that environment.
  2. A ca-<env>-suffixed app, router (Traefik Host rule), and named volume — e.g. app ca-pipeline-<env>, router host ca-pipeline-<env>.<domain>, the corpus volume namespaced per-app so volumes never cross-contaminate.
  3. A docker-compose.<env>.yaml cut from the shared structural template (the cocoindex service block + the standard env keyset) — see §4 for the Platform → client delta.
  4. 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 from environment: <Env> rather than from a shared secret set (cf. onprem-deploy.yml’s deploy-cocoindex-platform-staging resolving the platform app UUID from the Staging GH environment).

The naming scheme uses ca/canonical (khca, knowledge-hubcanonical) — not the legacy kh/knowledge-hub first-client strings. The conventions are written to generalise to N environments, not to “platform + one client”.

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:

  1. Confirm a real ingest lands content: run the repeatable release gate, at this step and before §3 cuts the vX.Y.Z tag:

    Terminal window
    bun run scripts/verify-platform-promotion-gate.ts --target=prod

    (--target=staging against the staging Platform DB). Only a config that passes the gate is a promote candidate. The gate selects the latest run WHERE pipeline_name = 'kh_canonical_pipeline' AND op_id IS NOT NULL — not a bare status = 'completed', because governance crons emit empty completed heartbeats for other pipelines — then re-derives every assertion filtered by that run’s op_id: source_documents (the primary grain, covering both content-side files and feed articles), q_a_extractions, and reference_items (the feed/URL slice). The HEADLINE assertion is content_chunks(op_id=run) > 0 and every chunk embedded — chunk embeddings live in record_embeddings (owner_kind='content_chunk'), not an inline content_chunks.embedding column; 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_KEY for --target=prod, or the PLATFORM_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_requirements Path-C requirement catalogue — the two are never the same thing, and the gate touches neither as a “forms” check.
  2. Confirm the webhook records a run: the worker’s PIPELINE_RUN_WEBHOOK_URL round-trip records a pipeline_runs row in the Platform DB via the Platform app’s /api/internal/pipeline-runs/record endpoint (status-only, best-effort) — this is the same pipeline_runs row the gate (bullet 1) selects its run from.

  3. 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.

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:

  1. git tag vX.Y.Z <known-good-main-sha> and push. The tag is immutable (never retag; ship a fix as a new PATCH).

  2. Fast-forward the release/vX.Y.Z Vercel handle branch to the same commit.

  3. Re-tag the cocoindex image — alias the known-good sha-<sha> image to vX.Y.Z without a rebuild via the shipped tag-release workflow_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.Z

    The job pulls sha-<sha>, tags it vX.Y.Z, and pushes — pure alias, no rebuild. This vX.Y.Z image 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>:

Platform valueClient value
app ca-pipeline-platformca-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.yamldocker-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) carries pullmd / playwright / pullmd-data-* services, volumes, or PULLMD_* 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.

Re-point these from Platform values to the client’s values (client DB ref + client FQDN):

KeyRe-key action
COCOINDEX_DB_DSNPlatform 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_URLPlatform app endpoint → the client app’s /api/internal/pipeline-runs/record.
NEXT_PUBLIC_APP_URLPlatform FQDN → the client FQDN.
PIPELINE_TRIGGER_SECRETMinted 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_SECRETMinted 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_TOKENMinted 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).

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.

Finalise the client pin and record it in the append-only crib:

  1. Set the client Coolify COCOINDEX_IMAGE_TAG to vX.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}'
  2. 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.

  • 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).