Skip to content

Commit f703e69

Browse files
authored
fix(showcase): add Railway image-ref drift assertion to catch atest corruption (CopilotKit#4118)
## Incident On 2026-04-21, 18 production Railway showcase services were found with malformed image refs of the form `ghcr.io/copilotkit/showcase-<slug>atest` — the `:` before `latest` was dropped, so Docker treated `...atest` as the tag. Railway dutifully tried to pull an image that doesn't exist on GHCR, so every affected service began failing to redeploy. The underlying data has been cleaned up, but the corruption did NOT come from committed code — it was introduced by an out-of-band MCP/manual API mutation. Nothing in the repo would have caught this, and nothing prevents it from happening again. ## Current state - 41 showcase services now have correct image refs (verified live against Railway in this PR's local run). - No source-controlled guardrail exists today to catch future drift. ## New assertion `showcase/scripts/verify-railway-image-refs.ts` — queries Railway GraphQL for every service in the CopilotKit Showcase project (`6f8c6bff-...`) and asserts each service's image source matches: ``` ghcr.io/copilotkit/<service-name>:latest ``` The regex `^ghcr\.io\/copilotkit\/showcase-[a-z0-9-]+:latest$` covers every service class — `showcase-<slug>`, `showcase-starter-<slug>`, `showcase-aimock`, `showcase-pocketbase`, `showcase-ops`. The script additionally requires the image's trailing path segment to match the Railway service name exactly (identity invariant: service name ↔ image name). On violation the script prints the service name, current image, expected shape, and reason — then exits 1. On success: `✓ N services verified`, exit 0. ## Wire-up Added a `verify-image-refs` job to `.github/workflows/showcase_deploy.yml` that runs in parallel with `check-lockfile` and before the deploy matrix. If drift is detected, the workflow aborts before any build starts. The `notify` job classifies a verify failure distinctly so Slack alerts say "Railway image-ref drift detected" rather than a generic pre-build failure. Uses the existing `RAILWAY_TOKEN` secret already plumbed into the deploy step — no new secrets needed. ## Local verification Normal run (all 41 services correctly configured): ``` ✓ 41 services verified ``` Simulated-corruption cases (unit-tested the exported `validateImage`): - `showcase-mastra` → `ghcr.io/copilotkit/showcase-mastraatest` → **rejected** (the exact incident shape) - `showcase-mastra` → `ghcr.io/copilotkit/showcase-crewai-crews:latest` → rejected (service↔image mismatch) - `showcase-mastra` → `ghcr.io/copilotkit/showcase-mastra` → rejected (no tag) - `showcase-mastra` → `docker.io/copilotkit/showcase-mastra:latest` → rejected (wrong registry) - `showcase-mastra` → `ghcr.io/copilotkit/showcase-mastra:v1` → rejected (wrong tag) - `showcase-mastra` → `null` → rejected (no source) - Plus 3 valid cases (`showcase-mastra`, `showcase-starter-agno`, `showcase-aimock` all at `:latest`) → accepted. All 9/9 behave as expected. ## Test plan - [x] `npx tsx showcase/scripts/verify-railway-image-refs.ts` against current Railway state → `✓ 41 services verified` - [x] Simulated-corruption validation → `9/9` rejection/acceptance behaviour correct - [x] `tsc --noEmit` clean on `showcase/scripts` - [x] `prettier --check` passes on both modified files - [x] `pnpm --filter=@copilotkit/showcase-scripts test` → 21 files / 1079 tests pass (no regressions from pre-existing suite) - [x] YAML parse of `showcase_deploy.yml` succeeds - [ ] CI workflow run on this PR includes the new `verify-image-refs` job and it goes green against current Railway state
2 parents 611a92f + 7dd930c commit f703e69

2 files changed

Lines changed: 242 additions & 4 deletions

File tree

.github/workflows/showcase_deploy.yml

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,30 @@ jobs:
218218
node-version: 22.x
219219
- run: pnpm install --frozen-lockfile --ignore-scripts
220220

221+
# Drift assertion — fails the workflow BEFORE the build matrix fans out
222+
# if any Railway showcase service has a malformed image ref. On 2026-04-21
223+
# 18 services were found with `ghcr.io/copilotkit/showcase-<slug>atest`
224+
# (missing `:` before `latest`) after an out-of-band API mutation; since
225+
# no committed code touched those refs, only a live-Railway assertion can
226+
# catch that class of corruption. Runs unconditionally on every deploy
227+
# run so a drifted env can never silently ship.
228+
verify-image-refs:
229+
needs: [detect-changes]
230+
if: needs.detect-changes.outputs.has_changes == 'true'
231+
runs-on: ubuntu-latest
232+
timeout-minutes: 3
233+
steps:
234+
- uses: actions/checkout@v4
235+
- uses: actions/setup-node@v4
236+
with:
237+
node-version: 22.x
238+
- name: Verify Railway image refs
239+
env:
240+
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
241+
run: npx tsx showcase/scripts/verify-railway-image-refs.ts
242+
221243
build:
222-
needs: [detect-changes, check-lockfile]
244+
needs: [detect-changes, check-lockfile, verify-image-refs]
223245
if: needs.detect-changes.outputs.has_changes == 'true'
224246
runs-on: depot-ubuntu-24.04-4
225247
timeout-minutes: ${{ matrix.service.timeout }}
@@ -465,7 +487,7 @@ jobs:
465487
exit 1
466488
467489
notify:
468-
needs: [detect-changes, check-lockfile, build]
490+
needs: [detect-changes, check-lockfile, verify-image-refs, build]
469491
if: always()
470492
permissions:
471493
contents: read
@@ -637,6 +659,7 @@ jobs:
637659
URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
638660
DETECT="${{ needs.detect-changes.result }}"
639661
LOCKFILE="${{ needs.check-lockfile.result }}"
662+
VERIFY="${{ needs.verify-image-refs.result }}"
640663
BUILD="${{ needs.build.result }}"
641664
COUNT="${{ steps.summary.outputs.count }}"
642665
NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
@@ -704,7 +727,7 @@ jobs:
704727
# has happened yet. Safe to stay silent — any newer run (or the
705728
# manual re-trigger) will redo all work from scratch. Don't touch
706729
# state either way — indeterminate outcome.
707-
if [ "$DETECT" = "cancelled" ] || [ "$LOCKFILE" = "cancelled" ]; then
730+
if [ "$DETECT" = "cancelled" ] || [ "$LOCKFILE" = "cancelled" ] || [ "$VERIFY" = "cancelled" ]; then
708731
echo "Pre-build stage cancelled — newer run will redo all work, skipping Slack notification"
709732
echo "should_post=false" >> "$GITHUB_OUTPUT"
710733
echo "update_state=false" >> "$GITHUB_OUTPUT"
@@ -728,7 +751,14 @@ jobs:
728751
fi
729752
730753
# Classify the terminal outcome.
731-
if [ "$DETECT" = "failure" ] || [ "$LOCKFILE" = "failure" ]; then
754+
if [ "$VERIFY" = "failure" ]; then
755+
# Drift assertion fired — one or more Railway services have a
756+
# malformed image ref. This is actionable: check the run log
757+
# for the per-service violation list (service name, current
758+
# image, expected shape).
759+
OUTCOME="failure"
760+
MSG=":x: *Showcase deploy*: FAILED — Railway image-ref drift detected (inspect run log for affected services)"
761+
elif [ "$DETECT" = "failure" ] || [ "$LOCKFILE" = "failure" ]; then
732762
OUTCOME="failure"
733763
MSG=":x: *Showcase deploy*: FAILED (pre-build check)"
734764
elif [ "$BUILD" = "success" ]; then
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
#!/usr/bin/env npx tsx
2+
/**
3+
* verify-railway-image-refs.ts — Drift assertion for Railway showcase image refs.
4+
*
5+
* Fetches every service in the CopilotKit Showcase project and validates that
6+
* the configured Docker image reference matches the canonical GHCR form:
7+
* ghcr.io/copilotkit/<service-name>:latest
8+
*
9+
* Backstory: on 2026-04-21, 18 production services were found with malformed
10+
* image refs of the form `ghcr.io/copilotkit/showcase-<slug>atest` (missing
11+
* the `:` before `latest`, so Docker treats `...atest` as the tag). The root
12+
* cause was an out-of-band MCP/manual mutation — no committed code touched
13+
* these refs. This script exists so any future corruption, regardless of
14+
* source, fails loudly and early in CI before a bad deploy goes out.
15+
*
16+
* Usage:
17+
* npx tsx showcase/scripts/verify-railway-image-refs.ts
18+
*
19+
* Requires: RAILWAY_TOKEN env var or ~/.railway/config.json
20+
* Exit: 0 when every service matches the canonical shape, 1 on any violation.
21+
*/
22+
23+
import fs from "fs";
24+
import path from "path";
25+
import { fileURLToPath } from "url";
26+
27+
const RAILWAY_API = "https://backboard.railway.com/graphql/v2";
28+
29+
const SHOWCASE = {
30+
projectId: "6f8c6bff-a80d-4f8f-b78d-50b32bcf4479",
31+
environmentId: "b14919f4-6417-429f-848d-c6ae2201e04f",
32+
};
33+
34+
// Canonical shape: ghcr.io/copilotkit/<name>:latest where <name> is the
35+
// service name itself. This single pattern covers showcase-<slug>,
36+
// showcase-starter-<slug>, showcase-aimock, showcase-pocketbase,
37+
// showcase-ops, and any future showcase-* service. Enforcing identity
38+
// between Railway service name and image name (modulo the ghcr.io/copilotkit/
39+
// prefix and :latest tag) is the invariant — if these ever drift apart we
40+
// want to know.
41+
const IMAGE_SHAPE = /^ghcr\.io\/copilotkit\/showcase-[a-z0-9-]+:latest$/;
42+
43+
function getToken(): string {
44+
if (process.env.RAILWAY_TOKEN) return process.env.RAILWAY_TOKEN;
45+
const configPath = path.join(
46+
process.env.HOME || "~",
47+
".railway",
48+
"config.json",
49+
);
50+
if (fs.existsSync(configPath)) {
51+
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
52+
if (config?.user?.token) return config.user.token;
53+
}
54+
console.error(
55+
"No Railway token found. Set RAILWAY_TOKEN or run `railway login`.",
56+
);
57+
process.exit(1);
58+
}
59+
60+
async function railwayGql<T = unknown>(
61+
query: string,
62+
variables: Record<string, unknown> = {},
63+
): Promise<T> {
64+
const token = getToken();
65+
const res = await fetch(RAILWAY_API, {
66+
method: "POST",
67+
headers: {
68+
Authorization: `Bearer ${token}`,
69+
"Content-Type": "application/json",
70+
},
71+
body: JSON.stringify({ query, variables }),
72+
});
73+
if (!res.ok) {
74+
throw new Error(`Railway API error: ${res.status} ${await res.text()}`);
75+
}
76+
const json = (await res.json()) as {
77+
data?: T;
78+
errors?: Array<{ message: string }>;
79+
};
80+
if (json.errors?.length) {
81+
throw new Error(
82+
`Railway GraphQL errors:\n${json.errors.map((e) => ` - ${e.message}`).join("\n")}`,
83+
);
84+
}
85+
return json.data as T;
86+
}
87+
88+
interface ProjectServicesWithInstances {
89+
project: {
90+
services: {
91+
edges: Array<{
92+
node: {
93+
id: string;
94+
name: string;
95+
serviceInstances: {
96+
edges: Array<{
97+
node: {
98+
environmentId: string;
99+
source: { image: string | null } | null;
100+
};
101+
}>;
102+
};
103+
};
104+
}>;
105+
};
106+
};
107+
}
108+
109+
interface Violation {
110+
service: string;
111+
image: string | null;
112+
reason: string;
113+
}
114+
115+
export function validateImage(
116+
serviceName: string,
117+
image: string | null,
118+
): Violation | null {
119+
if (!image) {
120+
return {
121+
service: serviceName,
122+
image,
123+
reason:
124+
"no image source configured (expected a Docker image, not a repo)",
125+
};
126+
}
127+
if (!IMAGE_SHAPE.test(image)) {
128+
return {
129+
service: serviceName,
130+
image,
131+
reason: `does not match canonical shape ^ghcr\\.io/copilotkit/showcase-[a-z0-9-]+:latest$`,
132+
};
133+
}
134+
const expected = `ghcr.io/copilotkit/${serviceName}:latest`;
135+
if (image !== expected) {
136+
return {
137+
service: serviceName,
138+
image,
139+
reason: `image name mismatches service name (expected exactly ${expected})`,
140+
};
141+
}
142+
return null;
143+
}
144+
145+
async function main(): Promise<void> {
146+
const data = await railwayGql<ProjectServicesWithInstances>(
147+
`query project($id: String!) {
148+
project(id: $id) {
149+
services {
150+
edges { node {
151+
id
152+
name
153+
serviceInstances {
154+
edges { node { environmentId source { image } } }
155+
}
156+
} }
157+
}
158+
}
159+
}`,
160+
{ id: SHOWCASE.projectId },
161+
);
162+
163+
const services = data.project.services.edges
164+
.map((e) => e.node)
165+
.filter((s) => s.name.startsWith("showcase-"));
166+
167+
const violations: Violation[] = [];
168+
let checked = 0;
169+
for (const svc of services) {
170+
const instance = svc.serviceInstances.edges.find(
171+
(e) => e.node.environmentId === SHOWCASE.environmentId,
172+
);
173+
const image = instance?.node.source?.image ?? null;
174+
checked++;
175+
const v = validateImage(svc.name, image);
176+
if (v) violations.push(v);
177+
}
178+
179+
if (violations.length > 0) {
180+
console.error(
181+
`\n✗ Railway image-ref drift detected (${violations.length}/${checked} services)\n`,
182+
);
183+
console.error(
184+
`Expected shape: ghcr.io/copilotkit/<service-name>:latest` +
185+
` (note the ':' before 'latest')\n`,
186+
);
187+
for (const v of violations) {
188+
console.error(` ${v.service}`);
189+
console.error(` current: ${v.image ?? "<unset>"}`);
190+
console.error(` expected: ghcr.io/copilotkit/${v.service}:latest`);
191+
console.error(` reason: ${v.reason}`);
192+
}
193+
console.error(
194+
`\nFix via Railway dashboard or the showcase deploy-to-railway script.` +
195+
` A common past cause was ':' dropped from ':latest' by an out-of-band API mutation.\n`,
196+
);
197+
process.exit(1);
198+
}
199+
200+
console.log(`✓ ${checked} services verified`);
201+
}
202+
203+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
204+
main().catch((e) => {
205+
console.error(e);
206+
process.exit(1);
207+
});
208+
}

0 commit comments

Comments
 (0)