Tower Release Matrix
Tower · Releases tab · open design question

What's the central actor of Releases — the environment, or the release?

Raised mid-session on 2026-09-08 after live feedback against checkout-api: today's Releases tab renders one card per environment. That's great for "is prod healthy right now" and bad for "did v2.3.9 actually reach prod, and when." Worked through below using checkout-api's real cicd.yaml pipeline — dev → test → staging → pre‑prod → prod — and its real image-tag convention, <version>-<7-char-sha>.

1

Environment-centric — today's tab

Fleet view only

One card per real (cluster, namespace) environment, ordered by promotionOrder. Each card is self-contained: current image, health, a supply-chain strip, a Promote button when there's somewhere to go.

RELEASES · checkout-api
Dev
healthy
v2.4.0-a91f3c2
deployed 14m ago
Test
healthy
v2.3.9-77bd110
deployed 2h ago
Promote → Staging
Staging
healthy
v2.3.9-77bd110
deployed 2h ago
Pre‑prod
healthy
v2.3.8-114ffa2
deployed 1d ago
Prod
healthy
v2.3.8-114ffa2
deployed 1d ago
×Nothing here says v2.3.9 is the same build in Test and Staging, or that it's one hop from Prod. Each card only knows about itself — the "is this release done rolling out" question has to be reconstructed by eye, card to card.
2

Release-centric — the alternative raised live

Audit trail only

An image is built once (one row) and tracked as it moves. Good for "when did this build reach prod" — a real question, e.g. after an incident. Bad for "what's live right now," which now means scanning every row for whichever one currently touches each environment.

RELEASES · checkout-api · by build
v2.4.0-a91f3c2
built 14m ago
Dev · 14m → Test → Staging → Pre‑prod → Prod
v2.3.9-77bd110
built 2h ago
Dev · 2h → Test · 2h → Staging · 2h → PR open → Pre‑prod → Prod
v2.3.8-114ffa2
built 1d ago
Dev · 1d → Test · 1d → Staging · 1d → Pre‑prod · 1d → Prod · 1d
v2.3.7-c02d9e1
built 6d ago
superseded everywhere
×To answer "what's in Prod right now," scan down until a row's trail reaches the Prod chip. Fine with 4 rows; not fine once a busy app has 30 releases in its history.
3

The release matrix — both axes at once

Recommended

Rows are releases (newest first), columns are environments in promotionOrder. Read a column down for current fleet state; read a row across for one build's full journey. This is the same shape Octopus Deploy and Spinnaker converged on for exactly this ambiguity — it isn't a compromise between the two views above, it's both of them at once. Click the pending cell below.

RELEASES · checkout-api · matrix
DevTestStagingPre‑prodProd
v2.4.0-a91f3c2
✓
·
·
·
·
v2.3.9-77bd110
✓
✓
✓
↻
·
v2.3.8-114ffa2
✓
✓
✓
✓
✓
v2.3.7-c02d9e1
·
·
·
·
·
v2.3.9-77bd110 → Pre‑prod PR #482 open close ×
✓
Build
✓
SAST /
SBOM
✓
Sign +
Attest
✓
Rekor
5
Pre‑prod
deploy

This is ReleaseCard.tsx + PipelineFlow.tsx unchanged — today's per-env card becomes the cell's expand target instead of a permanent top-level card. Nothing about the supply-chain drawer, cosign badges, or the Promote dialog needs to be rebuilt.

Side by side

Same three questions the team keeps actually asking, checked against each shape.

1 · Env cards2 · Release rows3 · Matrix
What's live in Prod right now? Direct — read the Prod card Scan every row for the one reaching Prod Direct — read the Prod column
Did v2.3.9 ever reach Prod, and when? Not answerable without opening deploy history per env Direct — read that row Direct — read that row
Promote click target Button on the env card (image is implicit) Button on the row (target env is implicit) The exact empty cell — image and target env both explicit
New backend calls required — (shipped) None — same deployHistory, grouped by image None — same deployHistory + gitopsPrs, regrouped
Cost to build from today's tab — High — the whole tab's structure changes Additive — cards move from top-level to cell-expand
Recommendation

Build the matrix as Releases' primary view. Nothing built so far gets thrown away.

Every card component that exists today — ReleaseCard, PipelineFlow, the cosign/SLSA badges, PromoteDialog — keeps working unmodified as the thing a matrix cell expands into. The only new work is reshaping data that's already being fetched:

releases = groupBy(imageTag, environments.flatMap(env => env.deployHistory.map(h => ({...h, env: env.env})))) // newest-introduced-in-dev first; a row is "current" if its image // matches environments[0].image (today's dev card, unchanged)

One real tightening this forces, worth doing regardless of which view ships: gitopsPrForEnv() today matches a PR by target env alone. A matrix cell needs to know which release a pending PR is for, not just which env — parse the image tag back out of the PR title's already-present "... @ <imageTag>" suffix and match on both, so a cell only lights up amber when it's this row's release that's pending.

  1. Add the grouping above to useReleaseContext.ts as a derived releases[] alongside the existing environments[] — additive, nothing existing changes shape.
  2. Render the matrix in ReleasesTab.tsx; wire each filled/pending cell's click to the existing ReleaseCard expand, keyed by (imageTag, env) instead of just env.
  3. Tighten gitopsPrForEnv to also match the parsed image tag, per above.
  4. Live-verify against checkout-api's real history before calling today's env-card layout retired.
This is a real, separate decision — same standing as the app-wide theme repaint was before this session. Flagging it as settled here only in the sense that the matrix subsumes the environment view rather than replacing it with something worse; the actual build is still a "next," not a "done."