Skip to content

Commit 41fae67

Browse files
committed
fix(showcase): tighten verify-matrix drift guard + fail-loud boundaries; fix stale comment + flaky test
Closing hardening pass on the showcase deploy-gate's verify-matrix resolver. The 7-agent review confirmed the gate is correct; this commit fixes the residual rough edges. - showcase_deploy.yml: correct the false §3 ok-non-empty comment. The empty-intersection case can coexist with redeploy_red=false (every redeploy succeeded, just none probe-eligible) — that's a correctly-green run, not a red one. - showcase_deploy.yml: tighten the summary.json shape guard to catch PARTIAL drift (TOTAL>0 && WITH_STATUS<TOTAL). The previous all-or- nothing TOTAL>0 && WITH_STATUS==0 check silently dropped drifted rows on a mixed summary. Validated locally on mixed/normal/empty/ total-drift jq samples. - resolve-verify-matrix.ts: add asSupportedEventName narrowing helper + use it in the CLI. Replaces the unchecked `as` cast — type system and runtime now tell one story. Resolver's internal eventName guard becomes defense-in-depth for direct (test) callers. - resolve-verify-matrix.ts: make the workflow_run boundary total — summaryPresent MUST be exactly "true"/"false". Any other value (including "" from a step-id-rename wiring break) throws now instead of silently emitting has_services=false. - resolve-verify-matrix.ts: drop the try/catch around fileURLToPath(import.meta.url) in `invokedDirectly`. The catch used to swallow ESM-interop failures and silently no-op the CLI (exit 0, no GITHUB_OUTPUT write → verify skipped = false-green). - resolve-verify-matrix.ts: reword parseSsotServices JSDoc to distinguish schema-drift from truncation (the two are different failure modes, not one conflated story). - showcase_build.yml: comment addendum on the redeploy-summary upload — swapping the guard to `if: always()` would red the legitimate services=='' path (no summary written), trading the already-closed false-green for a false-red on every non-buildable push. - resolve-verify-matrix.cli.test.ts: switch to spawnSync so stderr is captured on both zero and non-zero exit (execFileSync only exposes stderr on throw). Hard-code two stable probe-eligible names ("aimock", "harness") for the sorted-CSV test rather than picking probe[0]/probe[1] off the live SSOT — the prior test was tautological (already-sorted in, sorted out) and would silently pass if the resolver did nothing. - resolve-verify-matrix.cli.test.ts: add CLI coverage for the dropped-token ::warning:: path (FIX 3 — the entire drift-detection contract had zero CLI coverage), the unexpected-EVENT_NAME error (FIX 5), and the workflow_run-summary_present total boundary (FIX 7, both "" and "True" inputs). - resolve-verify-matrix.test.ts: add unit coverage for the new workflow_run summaryPresent boundary (empty + "True" + the workflow_dispatch ignores-summaryPresent regression). Red-green: 6 tests RED before code changes (FIX 3 warning, FIX 5 unknown EVENT_NAME, FIX 7 unit + CLI ×2 for "" and "True"); 79 tests GREEN after. Validation: 4 vitest files / 79 tests passing; 87/87 ruby specs passing; actionlint findings unchanged vs integration baseline (8 → 8, identical diff); yaml.safe_load OK on both workflows.
1 parent aafafa5 commit 41fae67

5 files changed

Lines changed: 284 additions & 72 deletions

File tree

.github/workflows/showcase_build.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,13 @@ jobs:
609609
# gating, and `if-no-files-found: error` on `always()` then trips
610610
# because summary.json was never written. So neither relaxation
611611
# alone opens a false-green window.
612+
#
613+
# However, swapping the guard to `if: always()` would ALSO red the
614+
# legitimate `services == ''` (nothing-to-redeploy) path — no
615+
# summary.json is written there either, so `if-no-files-found:
616+
# error` would trip on every push that didn't redeploy anything.
617+
# Net effect: trades the (already-closed) false-green risk for a
618+
# false-red on every non-buildable push. Don't do it.
612619
if: steps.changed.outputs.services != ''
613620
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
614621
with:

.github/workflows/showcase_deploy.yml

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,13 +154,17 @@ jobs:
154154
# silently yields empty → redeploy_red=false AND ok_services=""
155155
# → resolve-verify-matrix skips verify on a real unverified
156156
# redeploy = green CI on a broken release. Refuse the ambiguity:
157-
# if the file has entries but none carry an ok|error status,
158-
# fail loud here so enforce-redeploy-gate reds the workflow
159-
# (resolve-matrix.result == 'failure' fans into the gate).
157+
# if the file has entries but ANY entry is missing a valid
158+
# ok|error status (partial drift — some rows on the legacy schema,
159+
# some on the new one), fail loud here so enforce-redeploy-gate
160+
# reds the workflow (resolve-matrix.result == 'failure' fans into
161+
# the gate). The previous TOTAL>0 && WITH_STATUS==0 check was
162+
# all-or-nothing and silently dropped the drifted rows on a mixed
163+
# summary.
160164
TOTAL=$(jq 'length' "$SUMMARY")
161165
WITH_STATUS=$(jq '[.[] | select(.status == "ok" or .status == "error")] | length' "$SUMMARY")
162-
if [ "$TOTAL" -gt 0 ] && [ "$WITH_STATUS" -eq 0 ]; then
163-
echo "::error::summary.json has $TOTAL entries but none with status ok|error (schema drift?)"
166+
if [ "$TOTAL" -gt 0 ] && [ "$WITH_STATUS" -lt "$TOTAL" ]; then
167+
echo "::error::summary.json shape drift: $WITH_STATUS of $TOTAL entries have status ok|error"
164168
exit 1
165169
fi
166170
# Per spec §3: the workflow MUST turn red on any staging
@@ -203,9 +207,10 @@ jobs:
203207
# → intersect ok_services (SSOT key OR dispatchName aliases)
204208
# with probe.staging-eligible SSOT services. has_services
205209
# reflects CSV emptiness. When the intersection collapses
206-
# to empty (e.g. every ok service is non-probe-eligible),
207-
# verify is skipped and `enforce-redeploy-gate` reds the
208-
# workflow independently on redeploy_red=true.
210+
# to empty, verify is skipped; if there were per-service
211+
# errors, enforce-redeploy-gate reds the workflow — otherwise
212+
# the run is correctly green (every redeploy succeeded, just
213+
# none probe-eligible).
209214
run: npx tsx showcase/scripts/resolve-verify-matrix.ts
210215

211216
verify:

showcase/scripts/__tests__/resolve-verify-matrix.cli.test.ts

Lines changed: 140 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
* 3. workflow_dispatch + no summary → full probe-eligible set, has_services=true.
2424
* 4. workflow_dispatch + service=<unknown> → non-zero exit, stderr ::error::Unknown service.
2525
*/
26-
import { execFileSync } from "node:child_process";
26+
import { spawnSync } from "node:child_process";
2727
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
2828
import { tmpdir } from "node:os";
2929
import { dirname, join, resolve } from "node:path";
@@ -55,35 +55,22 @@ function runCli(env: Record<string, string>): SpawnResult {
5555
GITHUB_OUTPUT: outputPath,
5656
...env,
5757
};
58-
try {
59-
execFileSync("npx", ["tsx", SCRIPT], {
60-
cwd: REPO_ROOT,
61-
env: fullEnv,
62-
stdio: ["ignore", "pipe", "pipe"],
63-
});
64-
return {
65-
status: 0,
66-
stderr: "",
67-
output: readFileSync(outputPath, "utf-8"),
68-
};
69-
} catch (e) {
70-
// execFileSync throws on non-zero exit; the thrown error carries
71-
// `.status` and `.stderr` (Buffer when stdio captures).
72-
const err = e as {
73-
status?: number;
74-
stderr?: Buffer | string;
75-
};
76-
return {
77-
status: typeof err.status === "number" ? err.status : 1,
78-
stderr:
79-
typeof err.stderr === "string"
80-
? err.stderr
81-
: err.stderr
82-
? err.stderr.toString("utf-8")
83-
: "",
84-
output: readFileSync(outputPath, "utf-8"),
85-
};
86-
}
58+
// spawnSync (rather than execFileSync) so we capture stderr on BOTH
59+
// zero and non-zero exit. execFileSync exposes stderr only on throw,
60+
// which means the ::warning:: drift path (exit 0 + stderr text) is
61+
// invisible to the test harness. spawnSync returns a single object
62+
// regardless of status, so we inspect `status` and `stderr` directly.
63+
const res = spawnSync("npx", ["tsx", SCRIPT], {
64+
cwd: REPO_ROOT,
65+
env: fullEnv,
66+
stdio: ["ignore", "pipe", "pipe"],
67+
encoding: "utf-8",
68+
});
69+
return {
70+
status: typeof res.status === "number" ? res.status : 1,
71+
stderr: typeof res.stderr === "string" ? res.stderr : "",
72+
output: readFileSync(outputPath, "utf-8"),
73+
};
8774
}
8875

8976
// Pull two real probe-eligible names off the actual SSOT so the test
@@ -122,22 +109,135 @@ describe("resolve-verify-matrix CLI (end-to-end against real SSOT)", () => {
122109
it(
123110
"workflow_run + summary_present + two real ok_services → sorted CSV + has_services=true",
124111
() => {
125-
const probe = realProbeEligibleNames();
126-
// Pick TWO probe-eligible names. Use a CSV order that is NOT
127-
// already sorted so the sort-on-intersection assertion is real.
128-
// probe[0] sorts before probe[1] (alphabetical); feed reversed.
129-
const picked = [probe[0], probe[1]];
130-
const reversedCsv = `${picked[1]},${picked[0]}`;
131-
const sortedCsv = picked.join(",");
112+
// Hard-code two real, stable probe-eligible SSOT names rather than
113+
// picking probe[0]/probe[1] off the live list. The earlier
114+
// probe-index version was tautological: it pulled sorted names from
115+
// the SSOT, reversed them, fed them back, and asserted the resolver
116+
// re-sorted to the same order — which would silently pass even on a
117+
// resolver that did nothing (because probe[0] < probe[1] is the
118+
// ALREADY-sorted SSOT order). Also: if the SSOT ever shrank to <2
119+
// probe-eligible entries, the test would silently assert
120+
// `services_csv=undefined,undefined`.
121+
//
122+
// `aimock` and `harness` are foundational infra services (not
123+
// integration slots), so they will not churn out of the SSOT. We
124+
// assert they're both still probe-eligible at runtime; if either
125+
// ever leaves, this test fails LOUD with a specific message rather
126+
// than silently degrading.
127+
const probe = new Set(realProbeEligibleNames());
128+
expect(probe.has("aimock")).toBe(true);
129+
expect(probe.has("harness")).toBe(true);
130+
// Feed unsorted so the sort-on-intersection assertion is real:
131+
// "harness,aimock" must come out as "aimock,harness".
132132
const r = runCli({
133133
EVENT_NAME: "workflow_run",
134134
SUMMARY_PRESENT: "true",
135-
OK_FROM_REDEPLOY: reversedCsv,
135+
OK_FROM_REDEPLOY: "harness,aimock",
136136
DISPATCH_SERVICE: "",
137137
});
138138
expect(r.status).toBe(0);
139-
expect(r.output).toBe(
140-
`services_csv=${sortedCsv}\nhas_services=true\n`,
139+
expect(r.output).toBe("services_csv=aimock,harness\nhas_services=true\n");
140+
},
141+
30_000,
142+
);
143+
144+
// -----------------------------------------------------------------------
145+
// FIX 3 — surface SSOT/build drift via ::warning::ok_services tokens
146+
// dropped (no SSOT match). The intersection logic already silently drops
147+
// unmatched tokens from the verify matrix; the WARNING is the entire
148+
// drift-detection contract operators rely on to notice that a redeploy
149+
// reported success for a service the SSOT has forgotten about (or vice
150+
// versa). Without coverage here it could silently regress to "no warning
151+
// emitted" and the gate would keep working while losing its early-warning
152+
// signal.
153+
// -----------------------------------------------------------------------
154+
it(
155+
"workflow_run + ok_services with bogus token → ::warning:: lists dropped tokens, real ones still verify",
156+
() => {
157+
const r = runCli({
158+
EVENT_NAME: "workflow_run",
159+
SUMMARY_PRESENT: "true",
160+
// `svc-bogus` is not in the SSOT under any spelling; `aimock` is
161+
// a real probe-eligible service. The verify CSV must drop the
162+
// bogus token AND the wrapper must `::warning::` so the dropped
163+
// token surfaces in the workflow log as an annotation.
164+
OK_FROM_REDEPLOY: "svc-bogus,aimock",
165+
DISPATCH_SERVICE: "",
166+
});
167+
expect(r.status).toBe(0);
168+
expect(r.output).toContain("services_csv=aimock\n");
169+
expect(r.output).toContain("has_services=true\n");
170+
expect(r.stderr).toMatch(
171+
/::warning::ok_services tokens dropped \(no SSOT match\): svc-bogus/,
172+
);
173+
},
174+
30_000,
175+
);
176+
177+
// -----------------------------------------------------------------------
178+
// FIX 5 — EVENT_NAME must be exactly 'workflow_run' or 'workflow_dispatch'.
179+
// The CLI used to do an unchecked `as` cast on EVENT_NAME, so a typo or
180+
// accidental new trigger ('push', 'schedule') would compile-pass at the
181+
// type layer and only fail deep inside the resolver. Make the boundary
182+
// total: a narrowing helper rejects unknown EVENT_NAME values up front,
183+
// matching the resolver's own runtime guard with a SINGLE consistent
184+
// story (type system + runtime agree).
185+
// -----------------------------------------------------------------------
186+
it(
187+
"EVENT_NAME=push → non-zero exit with ::error::resolve-verify-matrix: unexpected EVENT_NAME",
188+
() => {
189+
const r = runCli({
190+
EVENT_NAME: "push",
191+
SUMMARY_PRESENT: "",
192+
OK_FROM_REDEPLOY: "",
193+
DISPATCH_SERVICE: "",
194+
});
195+
expect(r.status).not.toBe(0);
196+
expect(r.stderr).toMatch(
197+
/::error::resolve-verify-matrix: unexpected EVENT_NAME 'push'/,
198+
);
199+
},
200+
30_000,
201+
);
202+
203+
// -----------------------------------------------------------------------
204+
// FIX 7 — workflow_run requires SUMMARY_PRESENT to be exactly "true" or
205+
// "false". The check-redeploy-summary step always sets one of those two
206+
// values, so any other input (including "" from a step-id wiring break,
207+
// or "True" from a case-typo) means the wiring is broken and the gate
208+
// would silently fall through to the intersection branch. Fail loud
209+
// rather than silently emitting has_services=false on a real redeploy.
210+
// workflow_dispatch ignores SUMMARY_PRESENT and must NOT trigger this.
211+
// -----------------------------------------------------------------------
212+
it(
213+
"EVENT_NAME=workflow_run + SUMMARY_PRESENT='' → non-zero exit with workflow_run requires summary_present",
214+
() => {
215+
const r = runCli({
216+
EVENT_NAME: "workflow_run",
217+
SUMMARY_PRESENT: "",
218+
OK_FROM_REDEPLOY: "",
219+
DISPATCH_SERVICE: "",
220+
});
221+
expect(r.status).not.toBe(0);
222+
expect(r.stderr).toMatch(
223+
/::error::resolve-verify-matrix: workflow_run requires summary_present in \{true,false\}, got ''/,
224+
);
225+
},
226+
30_000,
227+
);
228+
229+
it(
230+
"EVENT_NAME=workflow_run + SUMMARY_PRESENT='True' (case typo) → non-zero exit",
231+
() => {
232+
const r = runCli({
233+
EVENT_NAME: "workflow_run",
234+
SUMMARY_PRESENT: "True",
235+
OK_FROM_REDEPLOY: "",
236+
DISPATCH_SERVICE: "",
237+
});
238+
expect(r.status).not.toBe(0);
239+
expect(r.stderr).toMatch(
240+
/::error::resolve-verify-matrix: workflow_run requires summary_present in \{true,false\}, got 'True'/,
141241
);
142242
},
143243
30_000,

showcase/scripts/__tests__/resolve-verify-matrix.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,58 @@ describe("resolveVerifyMatrix", () => {
257257
}),
258258
).toThrow(/::error::resolve-verify-matrix: unexpected eventName 'schedule'/);
259259
});
260+
261+
// ---------------------------------------------------------------------
262+
// FIX 7 — make the workflow_run boundary total. summaryPresent MUST be
263+
// exactly "true" or "false" on workflow_run (check-redeploy-summary
264+
// always sets one of the two). Any other value (including the empty
265+
// string from a future step-id-rename wiring break, or "True" from a
266+
// case-typo) used to fall through to the intersection branch and
267+
// silently emit has_services=false — indistinguishable from a
268+
// legitimate skip. Throw instead. workflow_dispatch ignores
269+
// summaryPresent and must NOT throw.
270+
// ---------------------------------------------------------------------
271+
it("workflow_run + summaryPresent='' → throws (boundary is total)", () => {
272+
expect(() =>
273+
resolveVerifyMatrix({
274+
eventName: "workflow_run",
275+
summaryPresent: "",
276+
okFromRedeploy: "",
277+
dispatchService: "",
278+
ssotServices: fixtureServices,
279+
}),
280+
).toThrow(
281+
/::error::resolve-verify-matrix: workflow_run requires summary_present in \{true,false\}, got ''/,
282+
);
283+
});
284+
285+
it("workflow_run + summaryPresent='True' (case typo) → throws", () => {
286+
expect(() =>
287+
resolveVerifyMatrix({
288+
eventName: "workflow_run",
289+
summaryPresent: "True",
290+
okFromRedeploy: "",
291+
dispatchService: "",
292+
ssotServices: fixtureServices,
293+
}),
294+
).toThrow(
295+
/::error::resolve-verify-matrix: workflow_run requires summary_present in \{true,false\}, got 'True'/,
296+
);
297+
});
298+
299+
it("workflow_dispatch ignores summaryPresent (any value) — does NOT throw on empty", () => {
300+
// workflow_dispatch path never reads summaryPresent; it must keep
301+
// working even when the wrapper passes the default "".
302+
const out = resolveVerifyMatrix({
303+
eventName: "workflow_dispatch",
304+
summaryPresent: "",
305+
okFromRedeploy: "",
306+
dispatchService: "all",
307+
ssotServices: fixtureServices,
308+
});
309+
expect(out.servicesCsv).toBe("svc-a,svc-b,svc-c,svc-d");
310+
expect(out.hasServices).toBe(true);
311+
});
260312
});
261313

262314
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)