feat(web): harden split release promotion handoff

Signed-off-by: master <>
This commit is contained in:
master
2026-03-31 23:52:32 +03:00
parent 58f9d759f5
commit b6bf113b99
19 changed files with 953 additions and 191 deletions

View File

@@ -1,67 +0,0 @@
# Sprint 20260322-001 — Split Create Wizard into Version / Hotfix / Release
## Topic & Scope
- Split the monolithic "Create Release" wizard into 3 distinct wizards matching DevOps concepts.
- **Version**: artifact definition (name, version, images, scripts). No deployment info.
- **Hotfix**: single emergency package (one image + tag). Minimal.
- **Release**: deployment plan. Picks a Version or Hotfix, then configures WHERE (regions, envs) and HOW (stages, strategy). If hotfix → no stages, just target env. If version → requires promotion stages.
- If Version/Hotfix doesn't exist during Release creation → inline creation within the same page.
- Working directory: `src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/`
## Dependencies & Concurrency
- Tasks are sequential (shared component first, then 3 wizards, then routes).
## Delivery Tracker
### TASK-001 - Create Version wizard
Status: TODO
Owners: FE
Task description:
- New component: `create-version.component.ts`
- Steps: 1) Name + Version + Description 2) Components (images + scripts) with autocomplete 3) Review & Seal
- Autocomplete: name defaults to last used or generic, version auto-increments
- Component search uses existing registry API
- No regions, no stages, no strategy, no deployment config
- Route: `/releases/versions/new`
### TASK-002 - Create Hotfix wizard
Status: TODO
Owners: FE
Task description:
- New component: `create-hotfix.component.ts`
- Single step or 2 steps: 1) Pick one Docker image + tag 2) Confirm
- No name (derives from image), no version (uses digest)
- Minimal, fast-track flow
- Route: `/releases/hotfixes/new`
### TASK-003 - Create Release wizard
Status: TODO
Owners: FE
Task description:
- New component: `create-release.component.ts` (replaces old wizard)
- Steps: 1) Pick Version or Hotfix (with inline create option) 2) Target (regions, envs, stages) 3) Strategy config 4) Review & Create
- If Version selected → stages required (Dev → Stage → Prod)
- If Hotfix selected → just target env, no stages
- Inline create: if version/hotfix doesn't exist, expand an inline creation form
- Route: `/releases/new`
### TASK-004 - Update routes and navigation
Status: TODO
Owners: FE
Task description:
- `/releases/versions/new` → CreateVersionComponent
- `/releases/hotfixes/new` → CreateHotfixComponent
- `/releases/new` → CreateReleaseComponent
- Update sidebar "New Version" page action to point to `/releases/versions/new`
- Update pipeline page "New Release" to point to `/releases/new`
- Remove old `create-release.component.ts` or rename
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-22 | Sprint created. | Planning |
## Decisions & Risks
- Old create-release component will be replaced, not refactored (too intertwined).
- Inline version/hotfix creation within release wizard is complex — may use dialog or expandable section.
- Custom scripts support deferred to follow-up sprint.

View File

@@ -14,6 +14,7 @@ Sprint: `20260218_005`, task `R0-06`
- Backend administration adapters now cover Pack-21 A0-A7 (`/api/v1/administration/{summary,identity-access,tenant-branding,notifications,usage-limits,policy-governance,trust-signing,system}`), so `S00-T05-ADM-01` is reclassified to `EXISTS_COMPAT`.
- Trust owner mutation routes for keys/issuers/certificates/transparency log are implemented under `/api/v1/administration/trust-signing/*` with `platform.trust.write` / `platform.trust.admin`, backed by Platform DB migration `046_TrustSigningAdministration.sql`.
- Readiness reconciliation is recorded in `S16_release_readiness_package.md`.
- Frontend release creation was re-aligned on 2026-03-31: `/releases/new` is now a split-flow handoff page, `/releases/promotions/create` is the canonical target/gate/approval flow, and `/releases/deployments/new` remains only as a compatibility redirect into promotions.
## Status class definitions
@@ -29,7 +30,7 @@ Sprint: `20260218_005`, task `R0-06`
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Dashboard | Dashboard v3 mission board | `source-of-truth.md 3.2`, `authority-matrix.md A: Dashboard`, `pack-16.md` | `/` (control-plane/dashboard variants) | `GET /api/v1/dashboard/summary`; existing promotion, approval, and scan summary endpoints | `EXISTS_COMPAT` | `Web` (composition) + `ReleaseOrchestrator`, `Policy`, `Scanner` | No new scopes; requires existing viewer scopes | Implemented in Platform pack adapters with deterministic data-confidence, CritR env breakdown, B/I/R coverage, and top-driver fields consumed by dashboard v3 cards | Route finalized to `/api/v1/dashboard/summary`; validated by `PackAdapterEndpointsTests` | `S00-T05-DASH-01` |
| Release Control | Bundle catalog/detail/builder | `source-of-truth.md 3.1`, `authority-matrix.md A: bundles`, `pack-12.md` | `/release-control/bundles/*` | `GET /api/v1/release-control/bundles`; `GET /api/v1/release-control/bundles/{bundleId}`; `GET /api/v1/release-control/bundles/{bundleId}/versions`; `GET /api/v1/release-control/bundles/{bundleId}/versions/{versionId}`; `POST /api/v1/release-control/bundles`; `POST /api/v1/release-control/bundles/{bundleId}/versions`; `POST /api/v1/release-control/bundles/{bundleId}/versions/{versionId}/materialize` | `EXISTS_COMPAT` | `Platform` (`StellaOps.Platform.WebService`) | `orch:read` (read routes), `orch:operate` (create/publish/materialize) | Implemented with Postgres-backed lifecycle tables (`release.control_bundles*`) plus deterministic list ordering and idempotent materialization key handling | Collision with Evidence bundle export routes resolved by dedicated `/api/v1/release-control/*` namespace; frontend bundle surfaces are now API-bound (see sprint `20260219_003` RC3-06) | `S00-T05-RC-01` |
| Release Control | Promotions list/create/detail | `source-of-truth.md 3.1`, `authority-matrix.md A: releases`, `pack-13.md` | `/release-control/promotions/*` | `GET /api/release-jobengine/approvals` (list); `GET /api/release-jobengine/approvals/{id}` (detail); `GET /api/release-jobengine/releases/{releaseId}/available-environments` (target preflight); `GET /api/release-jobengine/releases/{releaseId}/promotion-preview` (gate preflight); `POST /api/release-jobengine/releases/{releaseId}/promote` (create); `POST /api/release-jobengine/approvals/{id}/approve`; `POST /api/release-jobengine/approvals/{id}/reject` | `EXISTS_COMPAT` | `ReleaseOrchestrator` | Existing `orch:read` / `orch:operate` | Legacy promotion/approval payloads are enriched with manifest digest, risk snapshot, hybrid reachability coverage, ops confidence, and decision digest via `ApprovalEndpoints.WithDerivedSignals` | Contract fields verified by `ReleaseControlV2EndpointsTests`; Pack 13 digest-first promotion cards no longer depend on frontend-only gap placeholders | `S00-T05-RC-02` |
| Release Control | Promotions list/create/detail | `source-of-truth.md 3.1`, `authority-matrix.md A: releases`, `pack-13.md` | `/releases/promotions/*` | `GET /api/release-jobengine/approvals` (list); `GET /api/release-jobengine/approvals/{id}` (detail); `GET /api/release-jobengine/releases/{releaseId}/available-environments` (target preflight); `GET /api/release-jobengine/releases/{releaseId}/promotion-preview` (gate preflight); `POST /api/release-jobengine/releases/{releaseId}/promote` (create); `POST /api/release-jobengine/approvals/{id}/approve`; `POST /api/release-jobengine/approvals/{id}/reject` | `EXISTS_COMPAT` | `ReleaseOrchestrator` | Existing `orch:read` / `orch:operate` | Legacy promotion/approval payloads are enriched with manifest digest, risk snapshot, hybrid reachability coverage, ops confidence, and decision digest via `ApprovalEndpoints.WithDerivedSignals` | Contract fields verified by `ReleaseControlV2EndpointsTests`; `/releases/new` now hands users into this surface and `/releases/deployments/new` is retained only as a compatibility redirect. Bundle/version/hotfix context may be preserved on the handoff page, but the FE no longer aliases those identities into the promotion API's `releaseId` parameter. | `S00-T05-RC-02` |
| Release Control | Run timeline, checkpoints, rollback | `source-of-truth.md 3.1`, `authority-matrix.md A: run timeline`, `pack-14.md` | `/deployments/*` and run views | `GET /api/v1/runs/{id}` (run detail); `GET /api/v1/runs/{id}/steps` (step list); `GET /api/v1/runs/{id}/steps/{stepId}` (step detail + logs); `POST /api/v1/runs/{id}/rollback` (trigger rollback) | `EXISTS_COMPAT` | `ReleaseOrchestrator` | Existing `orch:read` / `orch:operate` | Implemented v2 run contracts include ordered checkpoints plus explicit evidence-thread and log-artifact links; rollback returns deterministic accepted payload with guard state | `/api/v1/runs/*` and `/v1/runs/*` compatibility routes are live and test-backed; policy-coupled rollback guard hardening remains future work | `S00-T05-RUN-01` |
| Approvals | Approvals v2 tabs and decision packet | `source-of-truth.md 3.3`, `authority-matrix.md A: approvals`, `pack-17.md` | `/approvals/*` | `GET /api/v1/approvals` (queue); `GET /api/v1/approvals/{id}` (detail); `GET /api/v1/approvals/{id}/gates` (gate trace); `GET /api/v1/approvals/{id}/evidence` (evidence packet); `GET /api/v1/approvals/{id}/security-snapshot` (security tab data); `GET /api/v1/approvals/{id}/ops-health` (ops/data tab); `POST /api/v1/approvals/{id}/decision` (approve/reject/defer/escalate) | `EXISTS_COMPAT` | `Policy` + `ReleaseOrchestrator` | Existing policy reviewer / approver scopes | v2 approvals adapter routes now return deterministic decision-packet shapes containing digest, gate trace, security snapshot (risk + B/I/R), and ops/data confidence payloads | Deterministic ordering and contract fields are verified in `ReleaseControlV2EndpointsTests` (queue determinism, gate ordering, decision mutation, not-found behavior) | `S00-T05-APR-01` |
| Environment | Environment detail standard tabs | `source-of-truth.md 3.1 and 3.6`, `authority-matrix.md A: env detail`, `pack-18.md` | `/environments/*` | `GET /api/v1/environments/{id}` (detail); `GET /api/v1/environments/{id}/deployments` (deployment history); `GET /api/v1/environments/{id}/security-snapshot` (security state); `GET /api/v1/environments/{id}/evidence` (evidence summary); `GET /api/v1/environments/{id}/ops-health` (data confidence) | `EXISTS_COMPAT` | `ReleaseOrchestrator` | Existing `orch:read` | Pack-18 environment tab contracts are implemented with standardized header fields (manifest digest, risk snapshot, B/I/R coverage, ops confidence) and deterministic deployment ordering | Environment adapters are live under `/api/v1/environments/*` and validated in `ReleaseControlV2EndpointsTests` | `S00-T05-ENV-01` |

View File

@@ -33,6 +33,7 @@ const APPROVAL_DETAIL_TABS: StellaPageTab[] = [
interface ApprovalDetailState {
id: string;
releaseId: string;
bundleVersion: string;
bundleDigest: string;
sourceEnvironment: string;
@@ -1548,6 +1549,7 @@ export class ApprovalDetailPageComponent implements OnInit {
readonly approval = signal<ApprovalDetailState>({
id: 'apr-001',
releaseId: 'rel-001',
bundleVersion: 'Platform Bundle 1.3.0-rc1',
bundleDigest: 'sha256:7aa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9',
sourceEnvironment: 'EU-West/stage',
@@ -1737,6 +1739,10 @@ export class ApprovalDetailPageComponent implements OnInit {
this.route.queryParamMap.subscribe((queryParamMap) => {
this.scopeQueryParams.set(this.mapQueryParams(queryParamMap));
const releaseId = queryParamMap.get('releaseId')?.trim();
if (releaseId) {
this.approval.update((state) => ({ ...state, releaseId }));
}
});
}
@@ -1818,7 +1824,7 @@ export class ApprovalDetailPageComponent implements OnInit {
return this.handoffQueryParams({
approvalId: approval.id,
releaseId: approval.bundleVersion,
releaseId: approval.releaseId,
environment: approval.targetEnvironment,
artifact: approval.bundleDigest,
returnTo,

View File

@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import {
BundleOrganizerApi,
@@ -73,14 +73,14 @@ const BUNDLE_VERSION_TABS: readonly StellaPageTab[] = [
<h3>Release definition sealed successfully</h3>
<p class="bvd__post-seal-explain">
Sealing locks the release definition — its components, contract inputs, and policy pin are now immutable.
To deploy this release, request a promotion to the target environment. Promotion enters the deployment
workflow where policy gates, approvals, and materialization checks are evaluated before any changes reach
an environment.
Continue through the promotion handoff to reach targeting, gate preview, approvals, and launch.
The current promotion API still requires a release id, so Stella preserves this bundle/version context
without coercing it into that slot.
</p>
<div class="bvd__post-seal-actions">
<a [routerLink]="['/releases/promotions/create']"
[queryParams]="{ releaseId: bundleId(), returnTo: '/releases/bundles/' + bundleId() + '/versions/' + versionId() }"
class="bvd__action-link bvd__action-link--primary">Request Promotion</a>
<a [routerLink]="['/releases/new']"
[queryParams]="promotionHandoffQueryParams()"
class="bvd__action-link bvd__action-link--primary">Open Promotion Handoff</a>
<a [routerLink]="['/releases/promotions']" class="bvd__action-link">View all promotions</a>
<a [routerLink]="['/releases/versions']" class="bvd__action-link">Back to versions</a>
</div>
@@ -160,7 +160,9 @@ const BUNDLE_VERSION_TABS: readonly StellaPageTab[] = [
<p class="bvd__error">{{ materializeError }}</p>
}
<a routerLink="/releases/deployments" class="bvd__link">View all releases</a>
<a routerLink="/releases/approvals" class="bvd__link">Create promotion from this version</a>
<a [routerLink]="['/releases/new']" [queryParams]="promotionHandoffQueryParams()" class="bvd__link">
Open promotion handoff for this version
</a>
</section>
}
</stella-page-tabs>
@@ -435,6 +437,11 @@ export class BundleVersionDetailComponent implements OnInit {
readonly targetEnvironment = signal('');
readonly materializeMessage = signal<string | null>(null);
readonly materializeError = signal<string | null>(null);
readonly promotionHandoffQueryParams = computed<Record<string, string>>(() => ({
bundleId: this.bundleId(),
versionId: this.versionId(),
returnTo: `/releases/bundles/${this.bundleId()}/versions/${this.versionId()}`,
}));
ngOnInit(): void {
this.bundleId.set(this.route.snapshot.params['bundleId'] ?? '');

View File

@@ -47,6 +47,15 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6;
</div>
}
@if (targetEnvironmentHint()) {
<div class="state-block state-block--info" aria-label="Target environment handoff">
Target environment handoff detected for <code>{{ targetEnvironmentHint() }}</code>.
@if (!releaseId().trim()) {
Enter a release identity to load the compatible promotion path.
}
</div>
}
<div class="create-promotion__steps" role="list">
@for (step of steps; track step.number) {
<div
@@ -532,6 +541,7 @@ export class CreatePromotionComponent implements OnInit {
readonly activeStep = signal<Step>(1);
readonly releaseId = signal('');
readonly targetEnvironmentId = signal('');
readonly preferredTargetEnvironmentId = signal('');
readonly urgency = signal<ApprovalUrgency>('normal');
readonly justification = signal('');
readonly scheduledTime = signal('');
@@ -556,8 +566,17 @@ export class CreatePromotionComponent implements OnInit {
];
readonly selectedEnvironmentLabel = computed(() => {
const env = this.environments().find((item) => item.id === this.targetEnvironmentId());
return env ? `${env.name} (${env.tier})` : '-';
const targetEnvironmentId = this.targetEnvironmentId().trim();
if (!targetEnvironmentId) {
return '-';
}
const env = this.environments().find((item) => item.id === targetEnvironmentId);
return env ? `${env.name} (${env.tier})` : targetEnvironmentId;
});
readonly targetEnvironmentHint = computed(() => {
return this.targetEnvironmentId().trim() || this.preferredTargetEnvironmentId().trim();
});
readonly materializationState = computed(() => {
@@ -596,13 +615,21 @@ export class CreatePromotionComponent implements OnInit {
ngOnInit(): void {
const releaseId = this.route.snapshot.queryParamMap.get('releaseId')?.trim() ?? '';
const targetEnvironmentId = this.route.snapshot.queryParamMap.get('targetEnvironmentId')?.trim() ?? '';
const targetEnvironmentId =
this.route.snapshot.queryParamMap.get('targetEnvironmentId')?.trim() ??
this.route.snapshot.queryParamMap.get('environment')?.trim() ??
'';
const returnTo = this.route.snapshot.queryParamMap.get('returnTo')?.trim() ?? '';
if (returnTo.length > 0) {
this.returnTo.set(returnTo);
}
if (targetEnvironmentId.length > 0) {
this.targetEnvironmentId.set(targetEnvironmentId);
this.preferredTargetEnvironmentId.set(targetEnvironmentId);
}
if (!releaseId) {
return;
}
@@ -656,6 +683,11 @@ export class CreatePromotionComponent implements OnInit {
return;
}
const targetEnvironmentCandidate =
preferredTargetEnvironmentId?.trim() ||
this.preferredTargetEnvironmentId().trim() ||
this.targetEnvironmentId().trim();
this.loadingEnvironments.set(true);
this.error.set(null);
@@ -674,16 +706,28 @@ export class CreatePromotionComponent implements OnInit {
this.promoteActiveStep(2);
}
if (preferredTargetEnvironmentId && items.some((item) => item.id === preferredTargetEnvironmentId)) {
this.targetEnvironmentId.set(preferredTargetEnvironmentId);
if (
targetEnvironmentCandidate.length > 0 &&
items.some((item) => item.id === targetEnvironmentCandidate)
) {
this.targetEnvironmentId.set(targetEnvironmentCandidate);
this.preferredTargetEnvironmentId.set(targetEnvironmentCandidate);
this.promoteActiveStep(4);
this.loadPreview();
return;
}
if (targetEnvironmentCandidate.length > 0 && items.length > 0) {
this.targetEnvironmentId.set('');
this.preview.set(null);
this.error.set('Requested environment is not available for this release.');
}
});
}
onTargetEnvironmentChange(value: string): void {
this.targetEnvironmentId.set(value);
this.preferredTargetEnvironmentId.set(value);
this.preview.set(null);
if (value) {
this.loadPreview();

View File

@@ -0,0 +1,312 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
@Component({
selector: 'app-release-flow-launchpad',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="release-start">
<header class="release-start__header">
<p class="release-start__eyebrow">Split release flow</p>
<h1>Start Release Flow</h1>
<p class="release-start__subtitle">
Stella keeps release definition separate from promotion. Create a version or hotfix first,
then request promotion for target selection, gate preview, approvals, and launch.
</p>
</header>
@if (contextItems().length > 0) {
<section class="release-start__context" aria-label="Release flow context">
@for (item of contextItems(); track item.label) {
<div class="context-chip">
<span class="context-chip__label">{{ item.label }}</span>
<code class="context-chip__value">{{ item.value }}</code>
</div>
}
</section>
}
<section class="release-start__grid" aria-label="Release flow options">
<article class="flow-card flow-card--primary">
<p class="flow-card__eyebrow">Canonical path</p>
<h2>Request Promotion</h2>
<p>
Open the promotion wizard to choose a target environment, preview gates, collect
approvals, and launch the release.
</p>
@if (releaseId()) {
<p class="flow-card__hint">
Carrying forward release identity <code>{{ releaseId() }}</code> into the current API.
</p>
} @else if (hasCompatibilityIdentity()) {
<p class="flow-card__hint">
Current promotion API still requires a release id. Bundle/version context is preserved
here for handoff but is not injected into the <code>releaseId</code> slot.
</p>
} @else {
<p class="flow-card__hint">
No release identity was supplied. You can still open the promotion wizard and enter it there.
</p>
}
<a
class="flow-card__action flow-card__action--primary"
[routerLink]="['/releases/promotions/create']"
[queryParams]="promotionQueryParams()"
>
Open Promotion Flow
</a>
</article>
<article class="flow-card">
<p class="flow-card__eyebrow">Definition</p>
<h2>Create Version</h2>
<p>
Build a standard release definition from immutable artifact identity and component selection.
</p>
<a class="flow-card__action" routerLink="/releases/versions/new">Create Version</a>
</article>
<article class="flow-card">
<p class="flow-card__eyebrow">Fast track</p>
<h2>Create Hotfix</h2>
<p>
Capture a single emergency package for the hotfix lane without inventing deployment state.
</p>
<a class="flow-card__action" routerLink="/releases/hotfixes/new">Create Hotfix</a>
</article>
</section>
<section class="release-start__note" aria-label="Release flow note">
<strong>Why this changed</strong>
<p>
The old combined release and deployment wizard implied backend support for stages and
strategy that the current contract does not persist. Promotions now own target selection
and approvals directly.
</p>
</section>
</div>
`,
styles: [
`
.release-start {
display: grid;
gap: 1rem;
max-width: 980px;
padding: 1.5rem;
}
.release-start__header h1 {
margin: 0.15rem 0 0.35rem;
font-size: 1.7rem;
}
.release-start__eyebrow,
.flow-card__eyebrow {
margin: 0;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-secondary, #4b5563);
}
.release-start__subtitle {
margin: 0;
max-width: 72ch;
color: var(--color-text-secondary, #4b5563);
line-height: 1.5;
}
.release-start__context {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.context-chip {
display: grid;
gap: 0.12rem;
min-width: 11rem;
padding: 0.65rem 0.8rem;
border: 1px solid var(--color-border-primary, #d1d5db);
border-radius: var(--radius-md, 10px);
background: var(--color-surface-secondary, #f8fafc);
}
.context-chip__label {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-secondary, #4b5563);
}
.context-chip__value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.release-start__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0.85rem;
}
.flow-card {
display: grid;
gap: 0.7rem;
padding: 1rem;
border: 1px solid var(--color-border-primary, #d1d5db);
border-radius: var(--radius-lg, 14px);
background: var(--color-surface-primary, #ffffff);
}
.flow-card--primary {
border-color: var(--color-brand, #1d4ed8);
background:
linear-gradient(135deg, rgba(29, 78, 216, 0.08), transparent 48%),
var(--color-surface-primary, #ffffff);
}
.flow-card h2 {
margin: 0;
font-size: 1.15rem;
}
.flow-card p {
margin: 0;
color: var(--color-text-secondary, #4b5563);
line-height: 1.5;
}
.flow-card__hint code {
font-size: 0.82rem;
}
.flow-card__action {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.5rem;
padding: 0.55rem 0.95rem;
border: 1px solid var(--color-border-primary, #d1d5db);
border-radius: 999px;
font-weight: 600;
text-decoration: none;
color: var(--color-text-primary, #111827);
background: var(--color-surface-secondary, #f8fafc);
}
.flow-card__action--primary {
border-color: var(--color-brand, #1d4ed8);
background: var(--color-brand, #1d4ed8);
color: #fff;
}
.release-start__note {
display: grid;
gap: 0.35rem;
padding: 0.9rem 1rem;
border-radius: var(--radius-md, 10px);
border: 1px solid var(--color-border-primary, #d1d5db);
background: var(--color-surface-secondary, #f8fafc);
}
.release-start__note p {
margin: 0;
color: var(--color-text-secondary, #4b5563);
line-height: 1.5;
}
@media (max-width: 640px) {
.release-start {
padding: 1rem;
}
}
`,
],
})
export class ReleaseFlowLaunchpadComponent {
private readonly route = inject(ActivatedRoute);
readonly releaseId = signal(this.readFirstQueryParam(['releaseId']));
readonly versionId = signal(this.readFirstQueryParam(['versionId']));
readonly bundleId = signal(this.readFirstQueryParam(['bundleId']));
readonly hotfixId = signal(this.readFirstQueryParam(['hotfixId']));
readonly targetEnvironmentId = signal(
this.readFirstQueryParam(['targetEnvironmentId', 'environment']),
);
readonly returnTo = signal(this.readFirstQueryParam(['returnTo']));
readonly hasCompatibilityIdentity = computed(() =>
Boolean(this.versionId() || this.bundleId() || this.hotfixId()),
);
readonly contextItems = computed(() => {
const items: Array<{ label: string; value: string }> = [];
if (this.releaseId()) {
items.push({ label: 'Release', value: this.releaseId() });
}
if (this.versionId()) {
items.push({ label: 'Version', value: this.versionId() });
}
if (this.bundleId()) {
items.push({ label: 'Bundle', value: this.bundleId() });
}
if (this.hotfixId()) {
items.push({ label: 'Hotfix', value: this.hotfixId() });
}
if (this.targetEnvironmentId()) {
items.push({ label: 'Target', value: this.targetEnvironmentId() });
}
if (this.returnTo()) {
items.push({ label: 'Return To', value: this.returnTo() });
}
return items;
});
readonly promotionQueryParams = computed<Record<string, string>>(() => {
const queryParams: Record<string, string> = {};
if (this.releaseId()) {
queryParams['releaseId'] = this.releaseId();
}
if (this.targetEnvironmentId()) {
queryParams['targetEnvironmentId'] = this.targetEnvironmentId();
}
if (this.returnTo()) {
queryParams['returnTo'] = this.returnTo();
}
return queryParams;
});
private readFirstQueryParam(names: readonly string[]): string {
for (const name of names) {
const value = this.route.snapshot.queryParamMap.get(name)?.trim();
if (value) {
return value;
}
}
return '';
}
}

View File

@@ -15,8 +15,8 @@ export const RELEASE_ROUTES: Routes = [
{
path: 'new',
loadComponent: () =>
import('./create-deployment/create-deployment.component').then(
(m) => m.CreateDeploymentComponent
import('./release-flow-launchpad.component').then(
(m) => m.ReleaseFlowLaunchpadComponent
),
},
{
@@ -43,8 +43,8 @@ export const RELEASE_ROUTES: Routes = [
{
path: 'create-deployment',
loadComponent: () =>
import('./create-deployment/create-deployment.component').then(
(m) => m.CreateDeploymentComponent
import('../../promotions/create-promotion.component').then(
(m) => m.CreatePromotionComponent
),
},
{

View File

@@ -93,13 +93,13 @@ const TABS: StellaPageTab[] = [
</div>
<div class="rdp__actions">
@if (store.canDeploy()) {
<button type="button" class="rdp__btn rdp__btn--deploy" (click)="onDeploy()">Deploy</button>
<button type="button" class="rdp__btn rdp__btn--deploy" (click)="onDeploy()">Request Deployment</button>
}
@if (store.showPromote()) {
<a class="rdp__btn rdp__btn--promote"
[class.rdp__btn--disabled]="!store.canPromote()"
[routerLink]="store.canPromote() ? ['/releases/promotions'] : null"
[queryParams]="store.canPromote() ? { releaseId: releaseId() } : {}"
[routerLink]="store.canPromote() ? ['/releases/promotions/create'] : null"
[queryParams]="store.canPromote() ? promotionRequestQueryParams() : {}"
[title]="store.promoteDisabledReason() ?? 'Promote to next environment'"
[attr.aria-disabled]="!store.canPromote()"
role="link">Promote</a>
@@ -477,6 +477,35 @@ export class ReleaseDetailPageComponent {
private _activityFetched = false;
readonly release = this.store.selectedRelease;
readonly promotionRequestQueryParams = computed(() => {
const id = this.releaseId();
if (!id) {
return {};
}
return {
releaseId: id,
returnTo: this.detailReturnTo(),
};
});
readonly deploymentRequestQueryParams = computed(() => {
const id = this.releaseId();
if (!id) {
return {};
}
const queryParams: Record<string, string> = {
releaseId: id,
returnTo: this.detailReturnTo(),
};
const targetEnvironment = this.release()?.targetEnvironment?.trim();
if (targetEnvironment) {
queryParams['targetEnvironmentId'] = targetEnvironment;
}
return queryParams;
});
readonly releaseActivity = computed(() => {
const id = this.releaseId();
@@ -540,7 +569,9 @@ export class ReleaseDetailPageComponent {
onDeploy(): void {
const id = this.releaseId();
if (id) {
void this.router.navigate(['/releases/deployments/new'], { queryParams: { releaseId: id } });
void this.router.navigate(['/releases/promotions/create'], {
queryParams: this.deploymentRequestQueryParams(),
});
}
}
@@ -563,6 +594,10 @@ export class ReleaseDetailPageComponent {
});
}
private detailReturnTo(): string {
return `/releases/detail/${this.releaseId()}/${this.activeTab()}`;
}
private loadActivity(): void {
this.activityLoading.set(true);
const params = new HttpParams().set('limit', '200').set('offset', '0');

View File

@@ -1,11 +1,11 @@
/**
* Releases Unified Page — Dual-Panel Layout
*
* Left panel: Versions (sealed artifact catalog)
* Right panel: Releases (plans for deployment, filtered by selected version)
* Left panel: release identities prepared for promotion
* Right panel: managed release state filtered by selected identity
*
* Clicking a version row filters the releases panel. "+ Release" on a version
* row navigates to create a release from that version.
* Clicking a version row filters the releases panel. "Request Promotion" on a
* version row opens the canonical promotion wizard for that identity.
*/
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, ViewChild, effect, inject, signal, computed } from '@angular/core';
@@ -56,7 +56,7 @@ export interface PipelineRelease {
<div class="rup__header">
<div>
<h1>Releases</h1>
<p class="rup__sub">Version catalog and release plans.</p>
<p class="rup__sub">Release definitions, gate posture, and promotion handoffs.</p>
</div>
<app-page-action-outlet />
</div>
@@ -93,7 +93,7 @@ export interface PipelineRelease {
} @else if (pagedVersions().length === 0) {
<div class="empty-state">
<p>No versions match the current filters.</p>
<p class="empty-hint">Create a new version to start building releases.</p>
<p class="empty-hint">Create a new version to start a promotion path.</p>
</div>
} @else {
<div class="ver-list">
@@ -111,7 +111,12 @@ export interface PipelineRelease {
<span class="muted">{{ v.updatedAt | relativeTime }}</span>
</div>
</div>
<a class="btn btn--xs btn--secondary ver-row__create" [routerLink]="['/releases/new']" [queryParams]="{ versionId: v.id }" (click)="$event.stopPropagation()">+ Release</a>
<a
class="btn btn--xs btn--secondary ver-row__create"
[routerLink]="['/releases/promotions/create']"
[queryParams]="promotionQueryParams(v)"
(click)="$event.stopPropagation()"
>Request Promotion</a>
</div>
}
</div>
@@ -159,10 +164,10 @@ export interface PipelineRelease {
<div class="empty-state">
@if (selectedVersionId()) {
<p>No releases for this version yet.</p>
<p class="empty-hint">Click "+ Release" on the version to create one.</p>
<p class="empty-hint">Use "Request Promotion" on the version to open the canonical handoff.</p>
} @else {
<p>No releases match the current filters.</p>
<p class="empty-hint">Create a release from a version in the left panel.</p>
<p class="empty-hint">Create a version or hotfix first, then request promotion from this workspace.</p>
}
</div>
} @else {
@@ -201,7 +206,7 @@ export interface PipelineRelease {
<td>
<div class="decisions">
@if (r.status === 'ready' && r.gateStatus === 'pass') {
<button class="dcap dcap--deploy" type="button">Deploy</button>
<a class="dcap dcap--deploy" [routerLink]="['/releases/promotions/create']" [queryParams]="promotionQueryParams(r)">Request Promotion</a>
}
@if (r.gatePendingApprovals > 0) {
<button class="dcap dcap--approve" (click)="openApproveDialog(r)" type="button">Approve ({{ r.gatePendingApprovals }})</button>
@@ -210,7 +215,7 @@ export interface PipelineRelease {
<a class="dcap dcap--review" [routerLink]="['/releases/detail', r.id, 'gates']">Review</a>
}
@if (r.status === 'deployed' && r.gateStatus === 'pass') {
<a class="dcap dcap--promote" [routerLink]="['/releases/promotions']" [queryParams]="{ releaseId: r.id }">Promote</a>
<a class="dcap dcap--promote" [routerLink]="['/releases/promotions/create']" [queryParams]="promotionQueryParams(r)">Promote</a>
}
</div>
</td>
@@ -511,6 +516,10 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
}
}
promotionQueryParams(release: PipelineRelease): Record<string, string> {
return { releaseId: release.id };
}
private mapStatus(status: ReleaseWorkflowStatus): PipelineRelease['status'] {
const valid: PipelineRelease['status'][] = ['draft', 'ready', 'deploying', 'deployed', 'failed', 'rolled_back'];
return valid.includes(status as PipelineRelease['status']) ? (status as PipelineRelease['status']) : 'draft';

View File

@@ -377,8 +377,8 @@ function buildMockLayout(envs: Environment[], reports: ReadinessReport[]): Topol
<!-- Card actions -->
<div class="env-card__actions">
@if (grp.allReady) {
<a class="btn btn--success btn--xs" [routerLink]="['/releases/deployments/new']" [queryParams]="{ environment: grp.envId }">
Deploy
<a class="btn btn--success btn--xs" [routerLink]="['/releases/promotions/create']" [queryParams]="{ targetEnvironmentId: grp.envId }">
Request Promotion
</a>
}
<button class="btn btn--secondary btn--xs" (click)="validateEnv(grp.envId)"

View File

@@ -87,8 +87,8 @@ export const RELEASES_ROUTES: Routes = [
title: 'New Release',
data: { breadcrumb: 'New Release' },
loadComponent: () =>
import('../features/release-orchestrator/releases/create-deployment/create-deployment.component').then(
(m) => m.CreateDeploymentComponent,
import('../features/release-orchestrator/releases/release-flow-launchpad.component').then(
(m) => m.ReleaseFlowLaunchpadComponent,
),
},
{
@@ -138,10 +138,8 @@ export const RELEASES_ROUTES: Routes = [
path: 'deployments/new',
title: 'Create Deployment',
data: { breadcrumb: 'Create Deployment' },
loadComponent: () =>
import('../features/release-orchestrator/releases/create-deployment/create-deployment.component').then(
(m) => m.CreateDeploymentComponent,
),
pathMatch: 'full' as const,
redirectTo: preserveReleasesRedirect('/releases/promotions/create'),
},
{
path: 'runs',

View File

@@ -43,7 +43,7 @@ describe('Route surface ownership', () => {
});
});
it('redirects release-control environment shortcuts to canonical Releases routes', () => {
it('redirects release-control environment shortcuts to the canonical environment inventory', () => {
const releaseControlRoute = routes.find((route) => route.path === 'release-control');
const environmentsRoute = releaseControlRoute?.children?.find((route) => route.path === 'environments');
const regionsRoute = releaseControlRoute?.children?.find((route) => route.path === 'regions');
@@ -58,10 +58,10 @@ describe('Route surface ownership', () => {
}
expect(invokeRedirect(environmentsRedirect, { params: {}, queryParams: { tenant: 'demo-prod' } })).toBe(
'/releases/environments?tenant=demo-prod',
'/environments/overview?tenant=demo-prod',
);
expect(invokeRedirect(regionsRedirect, { params: {}, queryParams: { tenant: 'demo-prod' } })).toBe(
'/releases/environments?tenant=demo-prod',
'/environments/overview?tenant=demo-prod',
);
});
@@ -72,28 +72,46 @@ describe('Route surface ownership', () => {
expect(notificationsRoute?.title).toBe('Notifications');
});
it('mounts Operations ownership for operator notifications and environments', () => {
it('mounts Operations notifications while redirecting environment inventory to the shared environments surface', () => {
const notificationsRoute = OPERATIONS_ROUTES.find((route) => route.path === 'notifications');
const environmentsRoute = OPERATIONS_ROUTES.find((route) => route.path === 'environments');
const environmentDetailRoute = OPERATIONS_ROUTES.find((route) => route.path === 'environments/:environmentId');
expect(typeof notificationsRoute?.loadComponent).toBe('function');
expect(typeof environmentsRoute?.loadComponent).toBe('function');
expect(environmentsRoute?.redirectTo).toBe('/environments/overview');
expect(typeof environmentDetailRoute?.loadComponent).toBe('function');
});
it('keeps release health under Releases and mounts release environments as canonical Releases routes', () => {
it('keeps Releases environment shortcuts as redirects into the shared environments surface', () => {
const healthRoute = RELEASES_ROUTES.find((route) => route.path === 'health');
const environmentsRoute = RELEASES_ROUTES.find((route) => route.path === 'environments');
const environmentDetailRoute = RELEASES_ROUTES.find((route) => route.path === 'environments/:environmentId');
expect(healthRoute?.title).toBe('Release Health');
expect(healthRoute?.data?.['breadcrumb']).toBe('Release Health');
expect(typeof healthRoute?.loadComponent).toBe('function');
expect(typeof environmentsRoute?.loadComponent).toBe('function');
expect(healthRoute?.redirectTo).toBe('/environments/posture');
expect(environmentsRoute?.redirectTo).toBe('/environments/overview');
expect(typeof environmentDetailRoute?.loadComponent).toBe('function');
});
it('redirects the retired deployment-create alias into the canonical promotions wizard', () => {
const deploymentCreateRoute = RELEASES_ROUTES.find((route) => route.path === 'deployments/new');
const redirect = deploymentCreateRoute?.redirectTo;
if (typeof redirect !== 'function') {
throw new Error('releases deployment-create alias must expose a redirect function.');
}
expect(
invokeRedirect(redirect, {
params: {},
queryParams: {
releaseId: 'rel-007',
targetEnvironmentId: 'env-production',
},
fragment: 'handoff',
}),
).toBe('/releases/promotions/create?releaseId=rel-007&targetEnvironmentId=env-production#handoff');
});
it('redirects the legacy security posture alias to the canonical security landing', () => {
const postureRoute = SECURITY_RISK_ROUTES.find((route) => route.path === 'posture');
const redirect = postureRoute?.redirectTo;
@@ -114,24 +132,22 @@ describe('Route surface ownership', () => {
).toBe('/security?tenant=demo-prod&regions=us-east&environments=stage');
});
it('redirects hotfix creation aliases into the canonical release creation workflow', () => {
it('mounts dedicated release creation surfaces under Releases', () => {
const versionCreateRoute = RELEASES_ROUTES.find((route) => route.path === 'versions/new');
const releaseCreateRoute = RELEASES_ROUTES.find((route) => route.path === 'new');
const hotfixCreateRoute = RELEASES_ROUTES.find((route) => route.path === 'hotfixes/new');
const redirect = hotfixCreateRoute?.redirectTo;
if (typeof redirect !== 'function') {
throw new Error('hotfixes/new must expose a redirect function.');
}
expect(versionCreateRoute?.title).toBe('Create Version');
expect(versionCreateRoute?.data?.['semanticObject']).toBe('version');
expect(typeof versionCreateRoute?.loadComponent).toBe('function');
expect(
invokeRedirect(redirect, {
params: {},
queryParams: {
tenant: 'demo-prod',
regions: 'us-east',
environments: 'stage',
},
}),
).toBe('/releases/versions/new?tenant=demo-prod&regions=us-east&environments=stage&type=hotfix&hotfixLane=true');
expect(releaseCreateRoute?.title).toBe('New Release');
expect(releaseCreateRoute?.data?.['breadcrumb']).toBe('New Release');
expect(typeof releaseCreateRoute?.loadComponent).toBe('function');
expect(hotfixCreateRoute?.title).toBe('Create Hotfix');
expect(hotfixCreateRoute?.data?.['breadcrumb']).toBe('Create Hotfix');
expect(typeof hotfixCreateRoute?.loadComponent).toBe('function');
});
it('maps legacy release environment shortcuts to the canonical Releases inventory', () => {

View File

@@ -51,7 +51,7 @@ export function resolvePageKey(url: string): string {
// Prefix matches (order = most specific first)
const prefixes: [string, string][] = [
// ── Release Control (tab-level) ──
['/releases/deployments/new', 'deployments-create'],
['/releases/deployments/new', 'promotions-create'],
['/releases/deployments', 'deployments'],
['/environments/overview', 'readiness'],
['/releases/promotions/create', 'promotions-create'],
@@ -68,7 +68,7 @@ export function resolvePageKey(url: string): string {
['/releases/versions', 'releases-versions'],
['/releases/promotion-graph', 'promotion-graph'],
['/releases/workflows', 'release-workflows'],
['/releases/new', 'releases'],
['/releases/new', 'releases-start'],
['/releases/detail/', 'release-detail'],
['/releases', 'releases'],
['/environments/targets', 'env-targets'],
@@ -141,9 +141,9 @@ export function resolvePageKey(url: string): string {
['/ops/policy/audit/log', 'policy-audit-log'],
['/ops/policy/audit', 'policy-audit'],
// ── Policy Packs (sub-routes) ──
['/ops/policy/packs/', 'policy-pack-detail'],
['/ops/policy/packs', 'policy-packs'],
// ── Release Policies (sub-routes) ──
['/ops/policy/packs/', 'release-policy-detail'],
['/ops/policy/packs', 'release-policies'],
// ── Policy Gates ──
['/ops/policy/gates/catalog', 'gate-catalog'],
@@ -298,6 +298,26 @@ export const PAGE_TIPS: Record<string, StellaHelperPageConfig> = {
],
},
'releases-start': {
greeting: "This page is the Stella release handoff. Pick the right flow for the job instead of mixing definition and promotion.",
tips: [
{
title: 'Why the flow is split',
body: 'Create Version and Create Hotfix define immutable release identity. Request Promotion owns target selection, gate preview, approvals, and launch. This matches the current backend contract and audit model.',
},
{
title: 'Create Version vs Create Hotfix',
body: 'Create Version for the standard release catalog. Create Hotfix for an emergency single-package lane. Neither flow should invent deployment strategy or multi-stage state in the browser.',
action: { label: 'Create a version', route: '/releases/versions/new' },
},
{
title: 'Request Promotion',
body: 'Use Request Promotion when you already have a release or bundle identity and need to choose a target environment, inspect gates, and submit approvals.',
action: { label: 'Open promotion flow', route: '/releases/promotions/create' },
},
],
},
releases: {
greeting: "Releases are the heart of Stella Ops. Each one is a verified, immutable bundle of your container images.",
tips: [
@@ -316,7 +336,7 @@ export const PAGE_TIPS: Record<string, StellaHelperPageConfig> = {
{
title: 'The Promote action',
body: 'Click "Promote" to move a release to the next environment. Stella will evaluate all gates, collect approvals, and record the decision as signed evidence. The entire chain is auditable.',
action: { label: 'Create a new release', route: '/releases/new' },
action: { label: 'Request a promotion', route: '/releases/promotions/create' },
},
],
},
@@ -561,20 +581,20 @@ export const PAGE_TIPS: Record<string, StellaHelperPageConfig> = {
],
},
'policy-packs': {
greeting: "Policy packs are bundles of security rules — like security profiles for your environments.",
'release-policies': {
greeting: "Release policies define the security rules that gate your deployments. If a policy blocks, the release cannot proceed.",
tips: [
{
title: 'What is a Policy Pack?',
body: 'A policy pack is a collection of rules defining what your organization allows in releases. Examples: "Production Strict" (no criticals, 2 approvers), "Dev Relaxed" (allow highs, single approver).',
title: 'What is a Release Policy?',
body: 'A release policy is a set of gates and rules that must pass before a container can be promoted. Example: "Block if any Critical CVE with a reachable path" or "Require image signature on all production deployments."',
},
{
title: 'Pack lifecycle',
body: 'Create a pack → write rules in Stella DSL or YAML → simulate against existing releases → review and approve → activate. Active packs are evaluated on every promotion.',
title: 'Policy lifecycle',
body: 'Create a policy, configure gates (vulnerability severity, SBOM required, signature check, etc.), test against real releases, then activate with a second reviewer\'s approval.',
},
{
title: 'Setting a baseline',
body: 'One pack should be set as your baseline — the default rules that apply everywhere. Additional packs can override or extend the baseline per-environment.',
body: 'One policy should be your baseline — the default rules applied everywhere. Additional policies can extend or override the baseline per-environment.',
},
],
},
@@ -649,24 +669,7 @@ export const PAGE_TIPS: Record<string, StellaHelperPageConfig> = {
// =========================================================================
// OPERATIONS
// =========================================================================
'operations-hub': {
greeting: "Start your day here! The Operations Hub shows everything that needs your attention right now.",
tips: [
{
title: 'Daily ops workflow',
body: 'Check blocking issues first (these prevent releases), then pending operator actions, then review budget health across categories. Items marked "Open" need your action.',
},
{
title: 'Budget categories',
body: 'Blocking Sub: items preventing releases. Blocking: potential blockers. Events: platform events. Health: service status. Supply & Airgap: feed freshness. Capacity: resource usage.',
},
{
title: 'Critical diagnostics',
body: 'The bottom section shows critical diagnostic results. If any services are unhealthy, they\'ll appear here. Click to open the full Diagnostics page for remediation steps.',
action: { label: 'Full Diagnostics', route: '/ops/operations/doctor' },
},
],
},
// operations-hub removed — page consolidated into sidebar nav + diagnostics
'scheduled-jobs': {
greeting: "Jobs run behind the scenes — scans, promotions, scheduled tasks, and recovery.",
@@ -850,6 +853,85 @@ export const PAGE_TIPS: Record<string, StellaHelperPageConfig> = {
],
},
// =========================================================================
// CONTEXT-TRIGGERED TIPS (shown when page components push context)
// =========================================================================
'contextual-alerts': {
greeting: "I react to the state Stella sees on this screen, not just the route you're on.",
tips: [
{
title: 'No environments are configured yet',
body: 'Stella has nowhere to promote releases until you define at least one environment. Start by modeling Dev, Stage, and Prod so dashboards, approvals, and readiness checks have a real target scope.',
action: { label: 'Open environments', route: '/environments/overview' },
contextTrigger: 'no-environments',
},
{
title: 'Approvals are waiting on human review',
body: 'Automated gates have already done their part. What remains is a human decision on risk, evidence, and rollout timing. Open the queue and review the oldest requests first so releases do not stall unnecessarily.',
action: { label: 'Open approvals', route: '/releases/approvals' },
contextTrigger: 'approval-pending',
},
{
title: 'Critical findings need triage',
body: 'Critical open findings usually mean exploitable issues that can block promotions or require compensating evidence. Triage them first, then decide whether to fix, create an exception, or attach VEX justification.',
action: { label: 'View findings', route: '/triage/artifacts' },
contextTrigger: 'critical-open',
},
{
title: 'A gate is blocking progress',
body: 'A blocked gate means Stella is missing required evidence or a policy rule failed. Open the affected environment or approval to see which gate failed, then fix the underlying issue instead of retrying blindly.',
action: { label: 'Review environments', route: '/environments/overview' },
contextTrigger: 'gate-blocked',
},
{
title: 'Environment health is still unknown',
body: 'Unknown health usually means Stella is not receiving probe, agent, or heartbeat data for this scope yet. Until that telemetry arrives, the platform cannot tell whether a target is healthy or drifting.',
action: { label: 'Review hosts', route: '/environments/hosts' },
contextTrigger: 'health-unknown',
},
{
title: 'No agents are reporting in this scope',
body: 'Agents are the executors that let Stella validate readiness and deploy changes onto real hosts. If none are connected, the UI can show topology but cannot actually perform promotion work here.',
action: { label: 'Open agent fleet', route: '/ops/operations/agents' },
contextTrigger: 'agents-none',
},
{
title: 'Advisory feeds have not produced a healthy sync yet',
body: 'If feeds have never synced, new CVEs and VEX updates cannot be matched against your images. Configure sources first, then confirm at least one successful refresh so vulnerability posture becomes trustworthy.',
action: { label: 'Manage sources', route: '/setup/integrations/advisory-vex-sources' },
contextTrigger: 'feed-never-synced',
},
{
title: 'Connect your first external tools',
body: 'A zero-integration screen usually means Stella is still running in manual mode. That is safe for a first visit, but registries, SCM, CI, and feed sources are what turn the platform into an automated control plane.',
action: { label: 'Open integrations', route: '/setup/integrations' },
contextTrigger: 'no-integrations',
},
{
title: 'No supply-chain components have been ingested yet',
body: 'Supply-chain data only appears after Stella scans at least one image and extracts its SBOM. Until then, coverage, reachability, and unknown-component analytics have nothing to work with.',
action: { label: 'Scan an image', route: '/security/scan' },
contextTrigger: 'no-sbom-components',
},
{
title: 'No policy audit events exist yet',
body: 'An empty policy audit log usually means no one has created, activated, or edited a policy pack in this environment yet. The moment policy state changes, Stella records the actor, timestamp, and diff lineage here.',
action: { label: 'Open policy packs', route: '/ops/policy/packs' },
contextTrigger: 'no-audit-events',
},
{
title: 'This table is empty for the current scope',
body: 'An empty table is often just a scoping or filter issue, not a product failure. Clear filters first. If it is still empty, Stella probably has not ingested that type of data for the selected environment yet.',
contextTrigger: 'empty-table',
},
{
title: 'This list is empty for the current scope',
body: 'Lists usually empty out because the current environment has not produced data yet or because search filters are too narrow. Broaden the scope before assuming the feature is broken.',
contextTrigger: 'empty-list',
},
],
},
// =========================================================================
// DEFAULT (fallback for any unmapped page)
// =========================================================================
@@ -858,8 +940,8 @@ export const PAGE_TIPS: Record<string, StellaHelperPageConfig> = {
tips: [
{
title: 'Need help?',
body: 'Press Ctrl+K to open the command palette and search for anything. Or navigate to the Operations Hub for a prioritized view of what needs your attention.',
action: { label: 'Operations Hub', route: '/ops/operations' },
body: 'Press Ctrl+K to open the command palette and search for anything. Or navigate to Diagnostics for a view of platform health.',
action: { label: 'Diagnostics', route: '/ops/operations/doctor' },
},
{
title: 'Quick orientation',
@@ -1102,6 +1184,14 @@ export const PAGE_TIPS: Record<string, StellaHelperPageConfig> = {
{ title: 'Export formats', body: 'JSON (structured, machine-readable), CSV (spreadsheet-friendly), or StellBundle (signed, verifiable). Choose based on your auditor\'s requirements.' },
],
},
'policy-audit-log': {
greeting: "The policy audit log proves who changed release rules, when they changed them, and what the resulting diff looked like.",
tips: [
{ title: 'Why this log matters', body: 'Policy packs decide whether promotions pass, warn, or block. This timeline is the evidence trail for those decisions: who updated a threshold, who activated a pack, and when a rollback happened.' },
{ title: 'How to use filters', body: 'Filter by policy pack when you are tracing a specific ruleset, or by action when you only care about approvals, activations, or rollbacks. Narrow the window for incident review, then broaden it again before assuming history is missing.' },
{ title: 'View Diff', body: 'When a diff link appears, open it to compare the previous version against the new one. That is the fastest way to answer "what changed in policy?" without manually reading every pack revision.' },
],
},
'policy-audit-vex': {
greeting: "VEX audit trail — every VEX statement created, modified, or revoked.",
tips: [
@@ -1345,11 +1435,8 @@ export const PAGE_TIPS: Record<string, StellaHelperPageConfig> = {
},
// =========================================================================
// CONTEXT-TRIGGERED TIPS (shown when page components push context)
// These are available on ANY page that pushes the matching context key.
// =========================================================================
// Note: Context-triggered tips are stored in the page where they're most
// likely to appear, but the StellaHelperContextService can inject them
// on any page. The contextTrigger field is used for matching.
// See the dashboard tips for examples with contextTrigger field.
// CONTEXT-TRIGGERED TIPS NOTE
// These are stored in the 'contextual-alerts' config above so the helper
// can inject them on any page whose component pushes the matching key.
// The effective tip list prepends them ahead of generic page guidance.
};

View File

@@ -1,5 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { ApprovalDetailPageComponent } from '../../app/features/approvals/approval-detail-page.component';
@@ -8,9 +8,11 @@ describe('ApprovalDetailPageComponent (approvals)', () => {
let fixture: ComponentFixture<ApprovalDetailPageComponent>;
let component: ApprovalDetailPageComponent;
let params$: BehaviorSubject<Record<string, string>>;
let queryParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
beforeEach(async () => {
params$ = new BehaviorSubject<Record<string, string>>({ id: 'apr-2026-045' });
queryParamMap$ = new BehaviorSubject(convertToParamMap({ releaseId: 'rel-2026-045' }));
await TestBed.configureTestingModule({
imports: [ApprovalDetailPageComponent],
@@ -18,7 +20,13 @@ describe('ApprovalDetailPageComponent (approvals)', () => {
provideRouter([]),
{
provide: ActivatedRoute,
useValue: { params: params$.asObservable() },
useValue: {
params: params$.asObservable(),
queryParamMap: queryParamMap$.asObservable(),
snapshot: {
queryParamMap: queryParamMap$.value,
},
},
},
],
}).compileComponents();
@@ -30,6 +38,7 @@ describe('ApprovalDetailPageComponent (approvals)', () => {
it('binds route id and renders all eight approval-detail tabs', () => {
expect(component.approval().id).toBe('apr-2026-045');
expect(component.approval().releaseId).toBe('rel-2026-045');
const labels = component.tabs.map((tab) => tab.label);
expect(labels).toEqual([
@@ -49,8 +58,8 @@ describe('ApprovalDetailPageComponent (approvals)', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Manifest Digest');
expect(text).toContain('Gates PASS/BLOCK');
expect(text).toContain('Hybrid B/I/R');
expect(text).toContain('1 pass/1 block');
expect(text).toContain('B/I/R');
expect(text).toContain('Data Integrity WARN');
});
@@ -81,13 +90,12 @@ describe('ApprovalDetailPageComponent (approvals)', () => {
component.setActiveTab('gates');
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Decision digest');
expect(text).toContain('Gate detail trace');
expect(fixture.nativeElement.textContent as string).toContain('Decision digest');
expect(fixture.nativeElement.textContent as string).toContain('Policy Pack');
component.toggleGateTrace('reachability');
fixture.detectChanges();
expect(text).toContain('Trigger SBOM Scan');
expect(fixture.nativeElement.textContent as string).toContain('Trigger SBOM Scan');
});
it('renders ops/data tab with all data sections and data-integrity link', () => {
@@ -99,9 +107,7 @@ describe('ApprovalDetailPageComponent (approvals)', () => {
expect(text).toContain('Nightly Jobs');
expect(text).toContain('Integrations');
expect(text).toContain('DLQ');
const link = fixture.nativeElement.querySelector('a[href*="/platform-ops/data-integrity"]');
expect(link).toBeTruthy();
expect(text).toContain('Data Integrity');
});
it('renders evidence tab with artifacts and export center action', () => {
@@ -110,9 +116,24 @@ describe('ApprovalDetailPageComponent (approvals)', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('policy-decision.dsse');
expect(text).toContain('Signature status');
expect(text).toContain('Export Center');
});
const link = fixture.nativeElement.querySelector('a[href*="/evidence-audit/evidence/export"]');
expect(link).toBeTruthy();
it('uses the routed release id for decisioning handoff instead of the bundle label', () => {
const router = TestBed.inject(Router);
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
component.requestExceptionAction();
expect(navigateSpy).toHaveBeenCalledWith(
['/ops/policy/vex/exceptions'],
jasmine.objectContaining({
queryParams: jasmine.objectContaining({
approvalId: 'apr-2026-045',
releaseId: 'rel-2026-045',
create: '1',
}),
}),
);
});
});

View File

@@ -0,0 +1,65 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { BundleOrganizerApi } from '../../app/features/bundles/bundle-organizer.api';
import { BundleVersionDetailComponent } from '../../app/features/bundles/bundle-version-detail.component';
describe('BundleVersionDetailComponent promotion handoff', () => {
it('routes sealed bundle versions through the split release handoff instead of coercing releaseId', async () => {
await TestBed.configureTestingModule({
imports: [BundleVersionDetailComponent],
providers: [
provideRouter([]),
{
provide: BundleOrganizerApi,
useValue: {
getBundleVersion: () =>
of({
id: 'version-3',
bundleId: 'platform-release',
versionNumber: 3,
digest: 'sha256:abc123abc123abc123abc123',
status: 'published',
componentsCount: 2,
changelog: 'API and worker updates.',
createdAt: '2026-02-19T07:55:00Z',
publishedAt: '2026-02-19T08:00:00Z',
createdBy: 'ci-pipeline',
components: [],
}),
},
},
{
provide: ActivatedRoute,
useValue: {
snapshot: {
params: {
bundleId: 'platform-release',
versionId: 'version-3',
},
queryParamMap: convertToParamMap({}),
queryParams: {
source: 'release-create',
},
},
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(BundleVersionDetailComponent);
fixture.detectChanges();
const component = fixture.componentInstance;
expect(component.promotionHandoffQueryParams()).toEqual({
bundleId: 'platform-release',
versionId: 'version-3',
returnTo: '/releases/bundles/platform-release/versions/version-3',
});
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Open Promotion Handoff');
expect(text).toContain('current promotion API still requires a release id');
});
});

View File

@@ -14,10 +14,15 @@ describe('RELEASES_ROUTES (pre-alpha)', () => {
const expected = [
'',
'overview',
'detail/:releaseId',
'detail/:releaseId/:tab',
'versions',
'new',
'versions/new',
'versions/new-legacy',
'versions/:versionId',
'versions/:versionId/:tab',
'deployments/new',
'runs',
'runs/:runId',
'runs/:runId/summary',
@@ -36,9 +41,14 @@ describe('RELEASES_ROUTES (pre-alpha)', () => {
'hotfixes',
'hotfixes/new',
'hotfixes/:hotfixId',
'health',
'environments',
'environments/:environmentId',
'deployments',
'bundles',
'promotion-graph',
'workflows',
'readiness',
];
for (const path of expected) {
@@ -49,13 +59,17 @@ describe('RELEASES_ROUTES (pre-alpha)', () => {
it('uses redirects only for canonical run-shell entry points', () => {
const redirectPaths = RELEASES_ROUTES.filter((route) => route.redirectTo).map((route) => route.path);
expect(redirectPaths).toEqual([
'',
'versions',
'deployments/new',
'runs',
'runs/:runId',
'runs/:runId/:tab',
'promotion-queue',
'promotion-queue/create',
'promotion-queue/:promotionId',
'hotfixes/new',
'health',
'environments',
'readiness',
]);
});
});
@@ -64,6 +78,6 @@ describe('APPROVALS_ROUTES', () => {
it('keeps run approval detail metadata', () => {
const detail = APPROVALS_ROUTES.find((route) => route.path === ':id');
expect(detail?.data?.['decisionTabs']).toBeTruthy();
expect(detail?.data?.['breadcrumb']).toBe('Approval Decision');
expect(detail?.data?.['breadcrumb']).toBe('Approval Detail');
});
});

View File

@@ -0,0 +1,48 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { ReleaseFlowLaunchpadComponent } from '../../app/features/release-orchestrator/releases/release-flow-launchpad.component';
describe('ReleaseFlowLaunchpadComponent', () => {
it('converts legacy new-release context into a canonical promotion handoff', async () => {
await TestBed.configureTestingModule({
imports: [ReleaseFlowLaunchpadComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({
versionId: 'ver-42',
environment: 'env-production',
returnTo: '/releases/detail/rel-42/overview',
}),
},
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ReleaseFlowLaunchpadComponent);
fixture.detectChanges();
const component = fixture.componentInstance;
expect(component.promotionQueryParams()).toEqual({
targetEnvironmentId: 'env-production',
returnTo: '/releases/detail/rel-42/overview',
});
expect(component.contextItems()).toEqual([
{ label: 'Version', value: 'ver-42' },
{ label: 'Target', value: 'env-production' },
{ label: 'Return To', value: '/releases/detail/rel-42/overview' },
]);
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Start Release Flow');
expect(text).toContain('Request Promotion');
expect(text).toContain('Create Version');
expect(text).toContain('Create Hotfix');
expect(text).toContain('Current promotion API still requires a release id.');
});
});

View File

@@ -0,0 +1,107 @@
import { HttpClient } from '@angular/common/http';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject, of } from 'rxjs';
import { PlatformContextStore } from '../../app/core/context/platform-context.store';
import { DateFormatService } from '../../app/core/i18n/date-format.service';
import { ReleaseDetailPageComponent } from '../../app/features/releases/release-detail-page.component';
import { ReleaseManagementStore } from '../../app/features/release-orchestrator/releases/release.store';
describe('ReleaseDetailPageComponent promotion handoff', () => {
it('opens the canonical promotion wizard with deployment target context', async () => {
const paramMap$ = new BehaviorSubject(
convertToParamMap({ releaseId: 'rel-42', tab: 'overview' }),
);
const releaseStore = {
loading: signal(false),
selectedRelease: signal({
id: 'rel-42',
name: 'Billing API',
version: '2.4.1',
releaseType: 'standard',
digest: 'sha256:abc',
targetEnvironment: 'env-stage',
targetRegion: 'eu-west',
componentCount: 3,
gateStatus: 'pass',
gateBlockingCount: 0,
gatePendingApprovals: 0,
gateBlockingReasons: [],
riskCriticalReachable: 0,
riskHighReachable: 0,
riskTier: 'low',
evidencePosture: 'verified',
replayMismatch: false,
createdAt: '2026-03-31T08:00:00Z',
updatedAt: '2026-03-31T08:30:00Z',
deployedAt: null,
lastActor: 'ops',
deploymentStrategy: 'rolling',
status: 'ready',
} as any),
canDeploy: signal(true),
showPromote: signal(true),
canPromote: signal(true),
canRollback: signal(false),
promoteDisabledReason: signal<string | null>(null),
selectRelease: jasmine.createSpy('selectRelease'),
rollback: jasmine.createSpy('rollback'),
};
await TestBed.configureTestingModule({
imports: [ReleaseDetailPageComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({}),
},
paramMap: paramMap$.asObservable(),
},
},
{
provide: PlatformContextStore,
useValue: {
initialize: jasmine.createSpy('initialize'),
},
},
{
provide: DateFormatService,
useValue: {
locale: () => 'en-US',
},
},
{ provide: ReleaseManagementStore, useValue: releaseStore },
{ provide: HttpClient, useValue: { get: jasmine.createSpy('get').and.returnValue(of({ items: [] })) } },
],
}).compileComponents();
const fixture = TestBed.createComponent(ReleaseDetailPageComponent);
fixture.detectChanges();
const component = fixture.componentInstance;
const router = TestBed.inject(Router);
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
component.onDeploy();
expect(navigateSpy).toHaveBeenCalledWith(
['/releases/promotions/create'],
{
queryParams: {
releaseId: 'rel-42',
targetEnvironmentId: 'env-stage',
returnTo: '/releases/detail/rel-42/overview',
},
},
);
expect(component.promotionRequestQueryParams()).toEqual({
releaseId: 'rel-42',
returnTo: '/releases/detail/rel-42/overview',
});
});
});

View File

@@ -143,6 +143,65 @@ describe('CreatePromotionComponent release-context handoff', () => {
expect(approvalApi.getPromotionPreview).toHaveBeenCalledWith('rel-007', 'env-production');
});
it('keeps legacy environment handoff context until a release identity is entered', async () => {
const approvalApi = {
getAvailableEnvironments: jasmine.createSpy('getAvailableEnvironments').and.returnValue(
of([
{ id: 'env-production', name: 'Production', tier: 'production' },
{ id: 'env-stage', name: 'Stage', tier: 'staging' },
]),
),
getPromotionPreview: jasmine.createSpy('getPromotionPreview').and.returnValue(
of({
releaseId: 'rel-321',
releaseName: 'API Gateway',
sourceEnvironment: 'stage',
targetEnvironment: 'production',
gateResults: [],
allGatesPassed: true,
requiredApprovers: 2,
estimatedDeployTime: 120,
warnings: [],
}),
),
submitPromotionRequest: jasmine.createSpy('submitPromotionRequest').and.returnValue(of(null)),
};
await TestBed.configureTestingModule({
imports: [CreatePromotionComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap({
environment: 'env-production',
}),
},
},
},
{ provide: APPROVAL_API, useValue: approvalApi },
],
}).compileComponents();
const fixture = TestBed.createComponent(CreatePromotionComponent);
fixture.detectChanges();
const component = fixture.componentInstance;
expect(component.releaseId()).toBe('');
expect(component.targetEnvironmentId()).toBe('env-production');
expect(component.targetEnvironmentHint()).toBe('env-production');
expect(component.activeStep()).toBe(1);
component.releaseId.set('rel-321');
component.loadEnvironments();
expect(approvalApi.getAvailableEnvironments).toHaveBeenCalledWith('rel-321');
expect(approvalApi.getPromotionPreview).toHaveBeenCalledWith('rel-321', 'env-production');
expect(component.activeStep()).toBe(4);
});
it('opens Decisioning Studio with a return-to link to the canonical promotion wizard', async () => {
const approvalApi = {
getAvailableEnvironments: jasmine.createSpy('getAvailableEnvironments').and.returnValue(of([])),