Skip to content

Commit de643ec

Browse files
authored
fix(showcase): add showcase-harness-worker to Railway SSOT (railway-envs.ts) (CopilotKit#5280)
## Summary The pool-fleet cutover manually created a new staging-only Railway service `showcase-harness-worker` (HARNESS_ROLE=worker, 2 replicas) and flipped the existing `harness` service to HARNESS_ROLE=control-plane. The new service was **not in the SSOT** (`showcase/scripts/railway-envs.ts`), so `verify-railway-image-refs.ts` failed the "Showcase: Build & Push" workflow on every push to main: ``` ✗ [railway] showcase-harness-worker reason: Railway service "showcase-harness-worker" is not in the SSOT. ``` Because the `build` job `needs: [verify-image-refs]`, that gate failure **skipped the harness build** — meaning recent harness fixes (e.g. CopilotKit#5279's chat-rung probe) never rebuilt to staging. The secondary `Aggregate build results` ENOENT failure was a downstream consequence of the skipped build. This PR adds `showcase-harness-worker` to `SERVICES`, matching the live Railway service: - **`ciBuilt: false`** — the worker runs the SAME `showcase-harness` GHCR image the existing harness build slot produces; there is no separate worker build slot. - **`gateIgnore: true` / `gateValidated: false`** — the worker is staging-only (no prod serviceInstance) and domain-less (it pulls jobs from the control-plane queue rather than serving HTTP), so it does not fit the symmetric dual-env / public-domain shape the image-ref gate validates. `gateIgnore` clears the "untracked Railway service" failure (any SSOT entry counts as known) **without** triggering a false "missing from prod" failure (`findMissingServices` only checks `gateValidated: true` entries). - **`repoNameOverride` → `showcase-harness`** so the image-ref shape resolves correctly. - **probe disabled in both envs** — no externally-reachable health endpoint. The existing `harness` (now control-plane) entry is unchanged and remains correct (it still builds + serves the `showcase-harness` image on its public domain). Also regenerates `railway-envs.generated.json` and updates the SSOT-count / gate-coverage test invariants (27 → 28 services; the worker is the sole intentional `gateIgnore`/`gateValidated:false` entry). ## Proof Against live Railway (CLI auth): - Before: `✗ Railway image-ref drift detected (... 1 untracked Railway services ...)` - After: `✓ 54 env-scoped instances verified (1 skipped)` — worker is the 1 skipped (gateIgnore), 0 untracked. - `emit-railway-envs-json.ts --check`: up to date. ## Test plan - [x] `verify-railway-image-refs.ts` passes against live Railway (0 untracked) - [x] `emit-railway-envs-json.ts --check` passes (generated JSON regenerated) - [x] Full `showcase/scripts` vitest suite: 1772 passed (49 files) - [ ] CI green on "Showcase: Build & Push" after merge (the gate failure that skipped the harness build is removed)
2 parents 6fa5860 + 3a56eca commit de643ec

5 files changed

Lines changed: 100 additions & 9 deletions

File tree

showcase/scripts/__tests__/emit-railway-envs-json.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe("emit-railway-envs-json", () => {
2525
expect(json.projectId).toBe("6f8c6bff-a80d-4f8f-b78d-50b32bcf4479");
2626
expect(json.envIds.staging).toBe("8edfef02-ea09-4a20-8689-261f21cc2849");
2727
expect(json.envIds.prod).toBe("b14919f4-6417-429f-848d-c6ae2201e04f");
28-
expect(json.services.length).toBe(27);
28+
expect(json.services.length).toBe(28);
2929
const docs = json.services.find((s: { name: string }) => s.name === "docs");
3030
expect(docs.domains.staging).toBe("docs.staging.copilotkit.ai");
3131
expect(docs.domains.prod).toBe("docs.copilotkit.ai");

showcase/scripts/__tests__/verify-railway-image-refs.test.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,18 @@ import type { ServiceEntry } from "../railway-envs";
2121

2222
describe("ServiceEntry gateIgnore field", () => {
2323
it("is optional on the type and defaults to falsy when unset", () => {
24-
// Every real SSOT entry has gateIgnore unset (undefined / falsy).
24+
// Every real SSOT entry has gateIgnore unset (undefined / falsy),
25+
// EXCEPT the staging-only `showcase-harness-worker` pool-fleet worker,
26+
// which is deliberately gateIgnore:true (no prod instance, no public
27+
// domain — it does not fit the symmetric dual-env shape the gate
28+
// validates). See its SSOT entry in railway-envs.ts for the rationale.
29+
const GATE_IGNORED = new Set(["showcase-harness-worker"]);
2530
for (const [name, entry] of Object.entries(SERVICES)) {
2631
const gi = (entry as ServiceEntry).gateIgnore;
32+
if (GATE_IGNORED.has(name)) {
33+
expect(gi, `${name} gateIgnore`).toBe(true);
34+
continue;
35+
}
2736
expect(gi === undefined || gi === false, `${name} gateIgnore`).toBe(true);
2837
}
2938
});
@@ -214,7 +223,7 @@ describe("summarizeFailures", () => {
214223
});
215224
});
216225

217-
describe("WS-C: all 27 services gateValidated, with correct overrides", () => {
226+
describe("WS-C: all gate-managed services gateValidated, with correct overrides", () => {
218227
const FIVE_NEW = [
219228
["dashboard", "showcase-shell-dashboard"],
220229
["docs", "showcase-shell-docs"],
@@ -223,13 +232,19 @@ describe("WS-C: all 27 services gateValidated, with correct overrides", () => {
223232
["harness", "showcase-harness"],
224233
] as const;
225234

226-
it("has 27 services in the SSOT", () => {
227-
expect(Object.keys(SERVICES)).toHaveLength(27);
235+
it("has 28 services in the SSOT", () => {
236+
expect(Object.keys(SERVICES)).toHaveLength(28);
228237
});
229238

230-
it("marks every service gateValidated (no Phase-2 holdouts)", () => {
239+
it("marks every gate-managed service gateValidated (no Phase-2 holdouts)", () => {
240+
// The staging-only `showcase-harness-worker` is the sole intentional
241+
// gateValidated:false entry (it is gateIgnore:true — no prod instance,
242+
// no public domain). Every OTHER service must be gateValidated:true.
243+
const GATE_IGNORED = new Set(["showcase-harness-worker"]);
231244
const unvalidated = Object.entries(SERVICES)
232-
.filter(([, entry]) => !entry.gateValidated)
245+
.filter(
246+
([name, entry]) => !entry.gateValidated && !GATE_IGNORED.has(name),
247+
)
233248
.map(([name]) => name);
234249
expect(unvalidated).toEqual([]);
235250
});

showcase/scripts/railway-envs.generated.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,27 @@
285285
"driver": "agent"
286286
}
287287
},
288+
{
289+
"name": "showcase-harness-worker",
290+
"serviceId": "c2aa8a0b-350e-4b76-8541-3012dfac41d0",
291+
"prodInstanceId": "c2aa8a0b-350e-4b76-8541-3012dfac41d0",
292+
"stagingInstanceId": "362c1e37-5f40-45f2-ac7b-0e5adac565f8",
293+
"ciBuilt": false,
294+
"gateValidated": false,
295+
"repoNameOverride": {
296+
"prod": "showcase-harness",
297+
"staging": "showcase-harness"
298+
},
299+
"domains": {
300+
"staging": "harness-staging-2ee4.up.railway.app",
301+
"prod": "showcase-harness-production.up.railway.app"
302+
},
303+
"probe": {
304+
"staging": false,
305+
"prod": false,
306+
"driver": "harness"
307+
}
308+
},
288309
{
289310
"name": "showcase-langgraph-fastapi",
290311
"serviceId": "06cccb5c-59f4-46b5-8adc-7113e77011a4",

showcase/scripts/railway-envs.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ describe("railway-envs SSOT", () => {
5454
expect(ENV_IDS.staging).toBe(STAGING_ENV_ID);
5555
});
5656

57-
it("contains exactly 27 services", () => {
57+
it("contains exactly 28 services", () => {
5858
const names = listServiceNames();
59-
expect(names.length).toBe(27);
59+
expect(names.length).toBe(28);
6060
});
6161

6262
it("contains the expected canonical services", () => {

showcase/scripts/railway-envs.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,61 @@ export const SERVICES: Record<
277277
},
278278
probe: { staging: true, prod: true, driver: "harness" },
279279
},
280+
"showcase-harness-worker": {
281+
serviceId: "c2aa8a0b-350e-4b76-8541-3012dfac41d0",
282+
// STAGING-ONLY worker (pool-fleet cutover). There is no prod
283+
// serviceInstance — the pool-fleet runs in staging only for now.
284+
// The schema requires a distinct, valid prod UUID per entry, so we
285+
// mirror the env-independent serviceId here as a non-functional
286+
// placeholder; it is never dereferenced because (a) gateIgnore skips
287+
// both gate directions, (b) ciBuilt:false keeps it out of the default
288+
// CI_BUILT_SERVICES redeploy scope, and (c) prodInstanceId would only
289+
// be read by an explicit `redeploy-env.ts prod --services
290+
// showcase-harness-worker`, which is never invoked for a staging-only
291+
// service. If/when a prod worker is provisioned, replace this with the
292+
// real prod serviceInstance ID and flip gateIgnore off.
293+
prodInstanceId: "c2aa8a0b-350e-4b76-8541-3012dfac41d0",
294+
stagingInstanceId: "362c1e37-5f40-45f2-ac7b-0e5adac565f8",
295+
// The worker runs the SAME `showcase-harness` GHCR image that the
296+
// existing `harness` (control-plane) service runs — it is NOT a
297+
// separately-built image. The single `showcase-harness` build slot in
298+
// showcase_build.yml produces the image both services consume; there
299+
// is no `showcase-harness-worker` build slot. Hence ciBuilt:false
300+
// (no dedicated build) and a repoNameOverride to `showcase-harness`
301+
// so the image-ref shape (`ghcr.io/copilotkit/showcase-harness:latest`)
302+
// resolves correctly if the gate ever validates it.
303+
ciBuilt: false,
304+
// gateIgnore: deliberately-untracked for the image-ref gate. The
305+
// worker is staging-only (no prod instance) and domain-less (it pulls
306+
// jobs from the control-plane queue rather than serving HTTP), so it
307+
// does not fit the symmetric dual-env / public-domain shape the gate
308+
// validates. Listing it here (with gateIgnore) is what clears the
309+
// "untracked Railway service" failure — findUntrackedServices treats
310+
// any SSOT entry as known — WITHOUT triggering a false "missing from
311+
// prod" failure from findMissingServices (which only checks
312+
// gateValidated:true entries).
313+
gateValidated: false,
314+
gateIgnore: true,
315+
repoNameOverride: {
316+
prod: "showcase-harness",
317+
staging: "showcase-harness",
318+
},
319+
// No public domain on Railway (queue worker, not HTTP-exposed). The
320+
// schema requires both domains be set; we point both at the
321+
// control-plane harness staging/prod hosts purely to satisfy the
322+
// no-scheme domain invariant. domainFor() is never called for this
323+
// service because its probe is disabled in both envs (below) and
324+
// verify-deploy skips probe:false services.
325+
domains: {
326+
staging: "harness-staging-2ee4.up.railway.app",
327+
prod: "showcase-harness-production.up.railway.app",
328+
},
329+
// probe disabled in BOTH envs: the worker has no health endpoint that
330+
// verify-deploy's `harness` driver can hit from outside (it has no
331+
// public domain). Its liveness is covered by the control-plane harness
332+
// probe and the Railway-internal /health healthcheck.
333+
probe: { staging: false, prod: false, driver: "harness" },
334+
},
280335
pocketbase: {
281336
serviceId: "ba11e854-d695-4738-9a45-2b0776788824",
282337
prodInstanceId: "1ee376e2-13f2-4464-801e-d0aa0bf76532",

0 commit comments

Comments
 (0)