`job.status` for a matrix slot is success|failure|cancelled, but the
per-slot writer laundered cancelled into `skipped` before publishing its
result, so a slot killed by `timeout-minutes` became indistinguishable
from one that legitimately never built.
That erased the only signal that could tell a partially-cancelled fleet
build from a clean one. GitHub's status functions cannot recover it:
`cancelled()` is documented as "returns true if the workflow was
canceled" (workflow-scoped, and FALSE for a leg-only cancel), and a
cancelled ancestor is not a FAILED ancestor so `failure()` is false too.
Add `cancelled` to the BuildOutcome contract, add `cancelledSet()`, and
have the aggregator publish `any_cancelled` + `cancelled_services`
alongside `any_success`. `successSet` still excludes cancelled slots, so
the redeploy intersection is unchanged — a slot that pushed no image
still cannot enter the redeploy CSV.
- Reject empty/whitespace-only service in parseBuildOutputs,
mergeBuildResultFiles, and buildResultArtifactName (was only
buildResultArtifactName, and only for length===0).
- mergeBuildResultFiles now throws on duplicate service names across
slots so an upstream dispatch-name collision surfaces instead of
letting a failure+success pair spuriously look like a success in
successSet (fail-loud discipline).
- Derive BuildOutcome union and VALID_STATUSES set from a single
BUILD_OUTCOMES as-const tuple plus a compile-time exhaustiveness
assignment so they cannot drift.
- Extract validateServiceBuildResult shared validator used by both
parseBuildOutputs (context: 'entry[i]') and mergeBuildResultFiles
(context: 'slot[i]') — one set of rules, one error format, both
now include the offending index.
- Trim shouldRedeployStaging JSDoc to what/why only (dropped the
external-caller enumeration) and note in the module header that
the single-result.json-per-slot invariant is enforced workflow-side,
not by the parser. shouldRedeployStaging([]) === false behavior
unchanged.
Tests: 16 -> 23 passing, all new red-then-green; tsc 0.
Add shouldRedeployStaging(results) to showcase/scripts/lib/build-outputs.ts.
Returns true iff at least one service finished as 'success'. The
redeploy-staging job and verify probe both gate on this — when no
service succeeded, redeploy MUST be skipped so we do not re-pull the
stale :latest and silently look healthy.
Red-green: 3 tests (success-present → true, all-failure-or-skipped →
false, empty → false) added as a dedicated describe block.
Per plan-E E-5a/E-5b.
Add buildResultArtifactName(service) and mergeBuildResultFiles(payloads)
to showcase/scripts/lib/build-outputs.ts so each matrix slot in
showcase_build.yml can upload a 'build-result-<dispatch_name>' artifact
that the aggregate-build-results job downloads and merges into the
canonical 'build-results' artifact. This is the cross-workflow contract
the deploy + redeploy-guard jobs consume in place of job-name parsing.
Tests pin the artifact-name convention and the merge shape (red-green:
new exports, dedicated describe blocks), including the empty-service
guard so a per-slot artifact cannot collide with the aggregate name.
Per plan-E E-4c/E-4d.