Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Part of the [Hangar](https://github.com/jfillman/hangar) platform. Brand files:
Tabs, in the order of a change's lifecycle: Overview, Pull Requests, Pipelines, Deployments, Releases, Topology,
Images, SLOs, Notifications, App Configuration, Glidepath. Plus fleet dashboards (Ops Wall, Fleet Grid).

- **Release Records**: structured release history, persisted as git commits, with compare and export; every release gets a tree name (ASPEN, BIRCH...), and a live **Next release** draft shows scope, open PRs, linked issues, candidate images and readiness before anything reaches Flight, with release notes the team writes; release modes (Hand-flown, Assisted) with a sign-off on the production release PR
- **Release Records**: structured release history, persisted as git commits, with compare and export; every release gets a tree name (ASPEN, BIRCH...), and a live **Next release** draft shows scope, open PRs, linked issues, candidate images and readiness before anything reaches Flight, with release notes the team writes; release modes (Hand-flown, Assisted) with a sign-off on the production release PR; each record's outcome (clean, rolled back) written back, a changelog, opt-in GitHub Releases, scheduled releases with milestones and Jira links, and a fleet release calendar
- **Release matrix**: which release is live in which environment, along the app's real promotion order
- **Deployments ("Ground Control")**: plain-language troubleshooting, the six-step delivery path per environment, Argo CD Refresh, Sync, Force sync and Terminate, Rollout controls (resume, pause, abort, retry, restart, promote), Roll back
- **App Configuration and Glidepath**: edit environment values and `cicd.yaml` through a real GitOps PR, never a direct commit
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Tabs, in the order of a change's lifecycle: **Overview**, **Pull Requests**, **P
**Releases**, **Topology**, **Images**, **SLOs**, **Notifications**, **App Configuration**, **Glidepath**. Plus a
fleet-wide dashboard.

- **Release Records**: structured release history, persisted as git commits, with compare and export; every release gets a tree name (ASPEN, BIRCH...), and a live **Next release** draft shows scope, open PRs, linked issues, candidate images and readiness before anything reaches Flight, with release notes the team writes; release modes (Hand-flown, Assisted) with a sign-off on the production release PR
- **Release Records**: structured release history, persisted as git commits, with compare and export; every release gets a tree name (ASPEN, BIRCH...), and a live **Next release** draft shows scope, open PRs, linked issues, candidate images and readiness before anything reaches Flight, with release notes the team writes; release modes (Hand-flown, Assisted) with a sign-off on the production release PR; each record's outcome (clean, rolled back) written back, a changelog, opt-in GitHub Releases, scheduled releases with milestones and Jira links, and a fleet release calendar
- **Release matrix**: which release is live in which environment, along the app's real promotion order
- **Deployments ("Ground Control")**: plain-language troubleshooting, the six-step delivery path per environment, Argo CD Refresh, Sync, Force sync and Terminate, Rollout controls (resume, pause, abort, retry, restart, promote), Roll back
- **Pull requests and checks**: the PRs in flight and their gates
Expand Down
45 changes: 45 additions & 0 deletions docs/release-records.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,51 @@ in Compare and when opened. The list also uses the guardrail results frozen in e
the opened record falls back to. Approvals added to a record's human context
after release are shown as acknowledgements and earn nothing.

## Outcome (what happened after it shipped)

Each record gets an **outcome**, decided in its sign-off environment from Glidepath's release-tracking records:

| Outcome | When |
|---|---|
| **clean** | It ran healthy there and a newer release replaced it, or it stayed healthy for the whole 7-day bake window. |
| **rolled back** | A rollback release replaced it. The record names the release it rolled back to and the reason. |
| **replaced before healthy** | A newer release replaced it there before it ever went healthy. |
| **stopped before production** | It never reached production, and every environment it did reach moved on. |

Until it is final, a record shows its live state (rolling out, or *baking · day 3 of 7*). Once final, Tower's
backend writes the outcome into the record with one PR per release (branch `release-record-outcome-<app>-*`,
excluded from the release gates like the record PRs themselves), together with the production sign-off when the
record was written before that PR merged (an app with staging before production). Glidepath sweeps its tracking
records after 14 days, so releases older than that, and cloud targets, have no outcome. SLO burn during the bake
window is not measured yet: kind-prod's Prometheus is off, so there is no production metric to read.

The record's score is now called **readiness**: how ready the release was when it shipped (guardrails, signed
and attested, sign-offs). The old "+5, no incidents" points are gone; Tower never tracked incidents, so every
release got them. What happened afterwards is the outcome, shown beside the score, not folded into it.

**GitHub Release (opt-in per release).** Tick "Publish a GitHub Release" in the draft, and once the release is
healthy in production Tower creates `v<version>` at the image's commit in the app repo, named after the release
(`CEDAR · v1.9.0`), with the draft's notes as the body. It never moves a tag: if `v<version>` already tags another
commit (the version was not bumped), it publishes nothing and the record says why.

**Changelog.** "Changelog" beside the Release records title lists every committed release's notes, newest first.

## Planning a release

- **Target date** and **milestone** in the draft make it a scheduled release. The draft card then lists the
milestone's open issues as **Planned**, and readiness gains *Target date* (late shows as a to-do) and *Planned
issues closed*.
- **Jira**: set `tower.jira.baseUrl` in app-config and Jira keys (`BAG-12`) in PR titles, bodies and branch names
link to it, next to the GitHub issues.
- **Release calendar** on the Ops Wall: every app's next release by name, target date (late ones marked), owner,
note count, what is on its way to production, and how its last release ended.
- **Announcements**: when a release PR to an app's sign-off environment opens ("CEDAR is ready for prod") and when
it merges ("CEDAR released to prod", with who signed off). They are Backstage notifications, in Tower's Activity
feed under Releases; no Slack integration is configured.
- **Autoland eligibility** under the mode bar: what an app would need before Autoland could land releases on its
own (tests that report results, a canary rollout in production, production metrics, at least five recorded
outcomes with under 15% rolled back, and an average readiness of 80). Display only; Autoland is not available yet.

## Release names

Every release gets a tree name, in order per app, the way hurricanes are named: ASPEN, BIRCH, CEDAR, DOGWOOD...
Expand Down
109 changes: 109 additions & 0 deletions src/AutolandEligibility.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import type { Theme } from '@material-ui/core/styles';
import { fontMono, useHangarTokens, type HangarTokens } from './brand/tokens';
import { displayScore, type ReleaseRecord } from './useReleaseRecords';
import type { ReadinessState } from './nextRelease';
import { Hint, TextLink } from './ui';

// Autoland eligibility (Release Record R4). Autoland, the mode where releases land in production with
// no human, is not available yet; this says what an app would need before it could be, from its own
// release history, the way a team earns an exemption from change review by meeting set criteria.
// Display only: it changes nothing about how the app releases.

export interface EligibilityItem {
id: string;
label: string;
state: ReadinessState;
detail: string;
}

const RECENT = 5;

export function autolandEligibility(records: ReleaseRecord[], signoffEnv?: string): EligibilityItem[] {
const recent = [...records].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)).slice(0, RECENT);
const closed = records.filter(r => r.outcome?.result);
const rolledBack = closed.filter(r => r.outcome?.result === 'rolled-back').length;
const items: EligibilityItem[] = [];

const withTests = recent.filter(r => r.testResults.length > 0).length;
items.push({
id: 'tests',
label: 'Tests report results',
state: recent.length > 0 && withTests === recent.length ? 'ok' : 'todo',
detail: `${withTests} of the last ${recent.length} releases carry per-test results`,
});

const prod = recent[0]?.deployments.find(d => d.env.toLowerCase() === (signoffEnv ?? '').toLowerCase());
items.push({
id: 'canary',
label: 'Canary rollout in production',
state: prod?.rolloutStrategy === 'canary' ? 'ok' : 'todo',
detail: prod?.rolloutStrategy ? `${signoffEnv} rolls out with ${prod.rolloutStrategy}` : `No rollout strategy seen on ${signoffEnv ?? 'production'}`,
});

items.push({
id: 'metrics',
label: 'Production metrics',
state: 'todo',
detail: "Judging a canary and the bake window needs production metrics; kind-prod's Prometheus is off",
});

const cfr = closed.length > 0 ? rolledBack / closed.length : undefined;
items.push({
id: 'outcomes',
label: 'Clean release history',
state: closed.length >= RECENT && cfr !== undefined && cfr < 0.15 ? 'ok' : 'todo',
detail:
closed.length >= RECENT
? `${rolledBack} of ${closed.length} recorded releases rolled back (${Math.round((cfr ?? 0) * 100)}%, needs under 15%)`
: `${closed.length} recorded outcome${closed.length === 1 ? '' : 's'}; needs at least ${RECENT}`,
});

const avg = recent.length ? Math.round(recent.reduce((n, r) => n + displayScore(r), 0) / recent.length) : 0;
items.push({
id: 'readiness',
label: 'High readiness',
state: recent.length > 0 && avg >= 80 ? 'ok' : 'todo',
detail: `Average readiness ${avg} over the last ${recent.length} releases (needs 80)`,
});
return items;
}

const useStyles = makeStyles<Theme, { t: HangarTokens }>(() => ({
wrap: { marginBottom: 12, fontSize: 12.5 },
list: { display: 'flex', flexDirection: 'column', gap: 4, marginTop: 6 },
item: { display: 'flex', gap: 8, alignItems: 'baseline' },
mark: { fontFamily: fontMono, width: 14, textAlign: 'center' },
name: { color: ({ t }) => t.textHi },
detail: { color: ({ t }) => t.textFaint },
}));

export function AutolandEligibility({ records, signoffEnv }: { records: ReleaseRecord[]; signoffEnv?: string }) {
const t = useHangarTokens();
const classes = useStyles({ t });
const [open, setOpen] = useState(false);
const items = autolandEligibility(records, signoffEnv);
const ok = items.filter(i => i.state === 'ok').length;
return (
<div className={classes.wrap}>
<TextLink expanded={open} onClick={() => setOpen(v => !v)}>
{`Autoland eligibility · ${ok} of ${items.length}`}
</TextLink>
{open && (
<div className={classes.list}>
{items.map(i => (
<div key={i.id} className={classes.item}>
<span className={classes.mark} style={{ color: i.state === 'ok' ? t.good : t.amberInk }}>
{i.state === 'ok' ? '✓' : '!'}
</span>
<span className={classes.name}>{i.label}</span>
<span className={classes.detail}>{i.detail}</span>
</div>
))}
<Hint>Autoland is not available yet. This says what this app would need first; it changes nothing today.</Hint>
</div>
)}
</div>
);
}
4 changes: 2 additions & 2 deletions src/NextReleaseCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ const next: NextRelease = {
scopeMayBeIncomplete: false,
incoming: [pr(221, 'feat: lost-bag claims', 'open')],
issues: [
{ number: 198, url: 'https://github.com/o/bags/issues/198', prNumber: 11, merged: true },
{ number: 203, url: 'https://github.com/o/bags/issues/203', prNumber: 221, merged: false },
{ label: '#198', url: 'https://github.com/o/bags/issues/198', prNumber: 11, merged: true },
{ label: '#203', url: 'https://github.com/o/bags/issues/203', prNumber: 221, merged: false },
],
changeCategories: { features: 1, fixes: 1, chores: 0 },
breaking: false,
Expand Down
30 changes: 28 additions & 2 deletions src/NextReleaseCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Button, Card, EmptyNote, Hint, SectionTitle, StatusChip, TextLink, Tile

const SCOPE_SHOWN = 6;
const INCOMING_SHOWN = 3;
const PLANNED_SHOWN = 4;

const useStyles = makeStyles<Theme, { t: HangarTokens }>(() => ({
// The kit Card, in the dashed amber of the record's Human context band.
Expand All @@ -37,6 +38,7 @@ const useStyles = makeStyles<Theme, { t: HangarTokens }>(() => ({
item: { display: 'flex', gap: 8, alignItems: 'baseline', fontSize: 12.5, padding: '2px 0', minWidth: 0 },
prTitle: { color: ({ t }) => t.textHi, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
faint: { color: ({ t }) => t.textFaint, fontSize: 12 },
planned: { borderBottom: ({ t }) => `1px solid ${t.lineSoft}`, paddingBottom: 8, marginBottom: 4, display: 'flex', flexDirection: 'column', gap: 4 },
sub: { borderTop: ({ t }) => `1px solid ${t.lineSoft}`, marginTop: 8, paddingTop: 8, display: 'flex', flexDirection: 'column', gap: 4 },
issues: { display: 'flex', flexWrap: 'wrap', gap: 8, fontFamily: fontMono, fontSize: 11, marginTop: 4 },
tag: { fontFamily: fontMono, fontSize: 11.5, color: ({ t }) => t.textHi },
Expand Down Expand Up @@ -120,6 +122,30 @@ export function NextReleaseCard({
>
<div className={classes.grid}>
<Tile className={classes.column}>
{next.planned?.milestone && (
<div className={classes.planned}>
<SectionTitle level={2}>
{`Planned · ${next.planned.milestone.title} · ${next.planned.issues.filter(i => i.state === 'closed').length} of ${next.planned.issues.length} closed`}
</SectionTitle>
{next.planned.issues
.filter(i => i.state === 'open')
.slice(0, PLANNED_SHOWN)
.map(i => (
<div key={i.number} className={classes.item}>
<TextLink href={i.url}>#{i.number}</TextLink>
<span className={classes.prTitle} title={i.title}>
{i.title}
</span>
{i.assignee && <span className={classes.faint}>{i.assignee}</span>}
</div>
))}
{next.planned.issues.filter(i => i.state === 'open').length > PLANNED_SHOWN && (
<span className={classes.faint}>
+ {next.planned.issues.filter(i => i.state === 'open').length - PLANNED_SHOWN} more open
</span>
)}
</div>
)}
<SectionTitle level={2}>
{`Scope · ${scope.length} PR${scope.length === 1 ? '' : 's'}`}
{next.changeCategories.features + next.changeCategories.fixes > 0 &&
Expand All @@ -145,7 +171,7 @@ export function NextReleaseCard({
<div className={classes.issues}>
<span className={classes.faint}>Closes</span>
{scopeIssues.map(i => (
<TextLink key={i.url} href={i.url}>#{i.number}</TextLink>
<TextLink key={i.url} href={i.url}>{i.label}</TextLink>
))}
</div>
)}
Expand All @@ -166,7 +192,7 @@ export function NextReleaseCard({
<div className={classes.issues}>
<span className={classes.faint}>Would close</span>
{incomingIssues.map(i => (
<TextLink key={i.url} href={i.url}>#{i.number}</TextLink>
<TextLink key={i.url} href={i.url}>{i.label}</TextLink>
))}
</div>
)}
Expand Down
6 changes: 5 additions & 1 deletion src/RecentActivityPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ const CATEGORY_TOPICS: Record<Exclude<Category, 'all'>, string[]> = {
// before this fix, silently filtered out by the old known-topics list.
// 'rolled-back' (a rollback release went healthy, ADR-0021 phase 4) and 'rollout' (Rollout actions, a skipped
// canary analysis, pod restarts) joined 2026-10-11: they were sent but never shown here.
releases: ['deploy', 'release', 'deploying', 'release-outcome', 'rolled-back', 'rollout'],
// 'release-record' (a release ready for / released to production) and 'release-signoff' (a Hand-flown release
// that shipped without a sign-off) joined 2026-10-14 (Release Record R2/R4, backstage releaseSignoffPoll.ts).
releases: ['deploy', 'release', 'deploying', 'release-outcome', 'rolled-back', 'rollout', 'release-record', 'release-signoff'],
environments: ['env', 'preview-env'],
config: ['config'],
slos: ['slo'],
Expand Down Expand Up @@ -174,6 +176,8 @@ const TOPIC_META: Record<string, TopicMeta> = {
images: { label: 'Cleanup', icon: ImageIcon },
'rolled-back': { label: 'Rollback', icon: ReleaseIcon },
rollout: { label: 'Rollout', icon: SyncIcon },
'release-record': { label: 'Release', icon: ReleaseIcon },
'release-signoff': { label: 'Sign-off', icon: ReleaseIcon },
// Break-glass debug sessions (phase C, G12): requests, approvals, opens, ends, glass broken, reviews.
breakglass: { label: 'Break-glass', icon: BreakglassIcon },
};
Expand Down
Loading
Loading