Align release create wizard with canonical bundle lifecycle

Wire orch:operate scope into console bootstrap so the browser token can
execute release-control actions. Replace the silent-redirect fallback
with the canonical createBundle → publishVersion → materialize flow and
surface truthful error messages on 403/409/503. Add focused Angular
tests and Playwright journey evidence for standard and hotfix paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-15 13:26:20 +02:00
parent 08390f0ca4
commit 27d27b1952
10 changed files with 769 additions and 24 deletions

View File

@@ -0,0 +1,303 @@
#!/usr/bin/env node
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webRoot = path.resolve(__dirname, '..');
const outputDirectory = path.join(webRoot, 'output', 'playwright');
const screenshotDirectory = path.join(outputDirectory, 'release-create-journey');
const statePath = path.join(outputDirectory, 'live-frontdoor-auth-state.json');
const reportPath = path.join(outputDirectory, 'live-frontdoor-auth-report.json');
const resultPath = path.join(outputDirectory, 'live-release-create-journey.json');
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
const expectedScope = {
tenant: 'demo-prod',
regions: 'us-east',
environments: 'stage',
timeWindow: '7d',
};
function buildScopedUrl(route) {
const url = new URL(route, baseUrl);
for (const [key, value] of Object.entries(expectedScope)) {
url.searchParams.set(key, value);
}
return url.toString();
}
function isStaticAsset(url) {
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url);
}
function isAbortedNavigation(message = '') {
return /aborted|net::err_abort/i.test(message);
}
function isIgnorableConsoleError(message = '') {
return /Failed to load resource: the server responded with a status of 404/i.test(message);
}
function routeMatches(url) {
return /^\/releases\/bundles\/[^/]+\/versions\/[^/]+$/i.test(new URL(url).pathname);
}
function collectScopeIssues(targetUrl, label) {
const parsed = new URL(targetUrl);
const issues = [];
for (const [key, expectedValue] of Object.entries(expectedScope)) {
const actualValue = parsed.searchParams.get(key);
if (actualValue !== expectedValue) {
issues.push(`${label} missing preserved scope ${key}=${expectedValue}; actual=${actualValue ?? '<null>'}`);
}
}
return issues;
}
async function headingText(page) {
return (await page.locator('h1').first().textContent().catch(() => '') ?? '').trim();
}
async function visibleAlerts(page) {
return page
.locator('[role="alert"], .error-banner, .warning-banner, .toast, .notification')
.evaluateAll((nodes) =>
nodes
.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim())
.filter(Boolean),
)
.catch(() => []);
}
async function waitForSearchResult(page) {
const queries = ['checkout-api', 'checkout', 'platform', 'api'];
for (const query of queries) {
await page.getByLabel('Search registry').fill(query);
const item = page.locator('.search-item').first();
const visible = await item.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true).catch(() => false);
if (visible) {
return query;
}
}
throw new Error('No release component search results appeared for any seed query.');
}
async function addFirstComponent(page) {
const query = await waitForSearchResult(page);
await page.locator('.search-item').first().click();
await page.locator('.digest-option').first().waitFor({ state: 'visible', timeout: 10_000 });
await page.locator('.digest-option').first().click();
await page.getByRole('button', { name: 'Add Component' }).click();
await page.getByText('Selected components (1)').waitFor({ state: 'visible', timeout: 10_000 });
return query;
}
async function continueWizard(page) {
await page.getByRole('button', { name: 'Continue' }).click();
}
async function fillCommonFields(page, { name, version, type }) {
await page.getByLabel('Release version name *').fill(name);
await page.getByLabel('Version *').fill(version);
await page.getByLabel('Release type *').selectOption(type);
await page.getByLabel('Description').fill(`Operator live journey for ${type} release create.`);
await continueWizard(page);
const searchQuery = await addFirstComponent(page);
await continueWizard(page);
await page.getByLabel('Config profile *').fill('prod-hardening-v5');
await page.getByLabel('Change ticket *').fill(`CHG-${version.replaceAll('.', '-')}`);
await continueWizard(page);
await page.locator('input[type="checkbox"]').last().check();
return searchQuery;
}
async function runJourney(page, responseEvents, label, route, type) {
const startedAt = Date.now();
const issues = [];
const journey = {
label,
route,
type,
initialUrl: null,
finalUrl: null,
heading: '',
searchQuery: null,
alerts: [],
responseErrors: [],
scopeIssues: [],
screenshotPath: null,
ok: false,
};
await page.goto(buildScopedUrl(route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
journey.initialUrl = page.url();
journey.heading = await headingText(page);
if (type === 'hotfix') {
const redirected = new URL(journey.initialUrl);
if (redirected.pathname !== '/releases/versions/new') {
issues.push(`hotfix create expected /releases/versions/new redirect but landed on ${journey.initialUrl}`);
}
if (redirected.searchParams.get('type') !== 'hotfix' || redirected.searchParams.get('hotfixLane') !== 'true') {
issues.push(`hotfix create missing type=hotfix or hotfixLane=true on redirect: ${journey.initialUrl}`);
}
}
if (!/Create Release Version/i.test(journey.heading)) {
issues.push(`expected Create Release Version heading but found "${journey.heading || '<empty>'}"`);
}
const suffix = `${Date.now()}-${label}`;
journey.searchQuery = await fillCommonFields(page, {
name: `QA ${label} ${suffix}`,
version: `2026.03.15.${type === 'standard' ? '151' : '152'}${Date.now().toString().slice(-2)}`,
type,
});
const submitPromise = page
.waitForURL((url) => routeMatches(url.toString()), { timeout: 45_000 })
.then(() => true)
.catch(() => false);
await page.getByRole('button', { name: /Seal Draft Release Version|Creating Release Version/i }).click();
const landed = await submitPromise;
journey.finalUrl = page.url();
journey.alerts = await visibleAlerts(page);
if (!landed || !routeMatches(journey.finalUrl)) {
issues.push(`expected canonical bundle version detail route but landed on ${journey.finalUrl}`);
}
journey.scopeIssues = collectScopeIssues(journey.finalUrl, `${label}-finalUrl`);
issues.push(...journey.scopeIssues);
const finalUrl = new URL(journey.finalUrl);
if (finalUrl.searchParams.get('source') !== 'release-create') {
issues.push(`expected source=release-create on final route; actual=${finalUrl.searchParams.get('source') ?? '<null>'}`);
}
if (finalUrl.searchParams.get('type') !== type) {
issues.push(`expected type=${type} on final route; actual=${finalUrl.searchParams.get('type') ?? '<null>'}`);
}
journey.responseErrors = responseEvents.filter(
(event) =>
event.timestamp >= startedAt &&
event.status >= 400 &&
/\/api\/v1\/release-control\/bundles(?:\/|$)|\/api\/v1\/releases(?:\/|$)/i.test(event.url),
);
if (journey.responseErrors.length > 0) {
issues.push(`${label} observed failing create-path responses: ${journey.responseErrors.map((event) => `${event.status} ${event.method} ${event.url}`).join(' | ')}`);
}
mkdirSync(screenshotDirectory, { recursive: true });
journey.screenshotPath = path.join(screenshotDirectory, `${label}.png`);
await page.screenshot({ path: journey.screenshotPath, fullPage: true }).catch(() => {});
journey.ok = issues.length === 0;
return { journey, issues };
}
async function main() {
mkdirSync(outputDirectory, { recursive: true });
const authReport = await authenticateFrontdoor({
baseUrl,
statePath,
reportPath,
headless: true,
});
const browser = await chromium.launch({
headless: true,
args: ['--disable-dev-shm-usage'],
});
const context = await createAuthenticatedContext(browser, authReport, { statePath });
const page = await context.newPage();
const responseEvents = [];
const runtimeIssues = [];
const failedChecks = [];
page.on('console', (message) => {
if (message.type() === 'error') {
const text = message.text();
if (isIgnorableConsoleError(text)) {
return;
}
runtimeIssues.push(`console:${text}`);
}
});
page.on('pageerror', (error) => {
runtimeIssues.push(`pageerror:${error instanceof Error ? error.message : String(error)}`);
});
page.on('requestfailed', (request) => {
const url = request.url();
const errorText = request.failure()?.errorText ?? 'unknown';
if (isStaticAsset(url) || isAbortedNavigation(errorText)) {
return;
}
runtimeIssues.push(`requestfailed:${request.method()}:${url}:${errorText}`);
});
page.on('response', (response) => {
const url = response.url();
if (isStaticAsset(url)) {
return;
}
responseEvents.push({
timestamp: Date.now(),
status: response.status(),
method: response.request().method(),
url,
page: page.url(),
});
});
const result = {
checkedAtUtc: new Date().toISOString(),
journeys: [],
failedCheckCount: 0,
failedChecks,
runtimeIssueCount: 0,
runtimeIssues,
};
try {
for (const scenario of [
{ label: 'standard-create', route: '/releases/versions/new?type=standard', type: 'standard' },
{ label: 'hotfix-create', route: '/releases/hotfixes/new', type: 'hotfix' },
]) {
const { journey, issues } = await runJourney(page, responseEvents, scenario.label, scenario.route, scenario.type);
result.journeys.push(journey);
failedChecks.push(...issues);
}
} finally {
result.failedCheckCount = failedChecks.length;
result.runtimeIssueCount = runtimeIssues.length;
writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
await context.close();
await browser.close();
}
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
if (result.failedCheckCount > 0 || result.runtimeIssueCount > 0) {
throw new Error(`release create journey failed: failedCheckCount=${result.failedCheckCount} runtimeIssueCount=${result.runtimeIssueCount}`);
}
}
main().catch((error) => {
process.stderr.write(`[live-release-create-journey] ${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});

View File

@@ -221,7 +221,6 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi {
};
return created;
}),
catchError(() => this.http.post<ManagedRelease>(this.legacyBaseUrl, request)),
);
}

View File

@@ -0,0 +1,239 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { signal } from '@angular/core';
import { of, throwError } from 'rxjs';
import { AUTH_SERVICE, type AuthService } from '../../../../core/auth/auth.service';
import { type RegistryImage } from '../../../../core/api/release-management.models';
import {
BundleOrganizerApi,
type ReleaseControlBundleDetailDto,
type ReleaseControlBundleVersionDetailDto,
} from '../../../bundles/bundle-organizer.api';
import { ReleaseManagementStore } from '../release.store';
import { CreateReleaseComponent } from './create-release.component';
describe('CreateReleaseComponent', () => {
let fixture: ComponentFixture<CreateReleaseComponent>;
let component: CreateReleaseComponent;
let router: Router;
let bundleApi: jasmine.SpyObj<BundleOrganizerApi>;
let auth: jasmine.SpyObj<AuthService>;
const searchResults = signal<RegistryImage[]>([]);
const bundleDetail: ReleaseControlBundleDetailDto = {
id: 'bundle-001',
slug: 'checkout-api',
name: 'Checkout API',
description: 'Release bundle',
totalVersions: 0,
latestVersionNumber: null,
latestVersionId: null,
latestVersionDigest: null,
latestPublishedAt: null,
createdAt: '2026-03-15T08:00:00Z',
updatedAt: '2026-03-15T08:00:00Z',
createdBy: 'admin',
};
const versionDetail: ReleaseControlBundleVersionDetailDto = {
id: 'version-001',
bundleId: 'bundle-001',
versionNumber: 1,
digest: 'sha256:1111111111111111111111111111111111111111111111111111111111111111',
status: 'published',
componentsCount: 1,
changelog: 'release version',
createdAt: '2026-03-15T08:05:00Z',
publishedAt: '2026-03-15T08:05:00Z',
createdBy: 'admin',
components: [
{
componentVersionId: 'checkout-api@2026.03.15.1',
componentName: 'checkout-api',
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
deployOrder: 10,
metadataJson: '{}',
},
],
};
async function configure(queryParams: Record<string, string> = {}) {
bundleApi = jasmine.createSpyObj<BundleOrganizerApi>('BundleOrganizerApi', [
'createBundle',
'listBundles',
'publishBundleVersion',
'materializeBundleVersion',
]);
bundleApi.createBundle.and.returnValue(of(bundleDetail));
bundleApi.listBundles.and.returnValue(of([bundleDetail]));
bundleApi.publishBundleVersion.and.returnValue(of(versionDetail));
bundleApi.materializeBundleVersion.and.returnValue(of({
runId: 'run-001',
bundleId: versionDetail.bundleId,
versionId: versionDetail.id,
status: 'queued',
targetEnvironment: 'us-prod',
reason: 'console_release_create',
requestedBy: 'admin',
idempotencyKey: 'draft-00000001',
requestedAt: '2026-03-15T08:05:30Z',
updatedAt: '2026-03-15T08:05:30Z',
}));
auth = jasmine.createSpyObj<AuthService>('AuthService', ['hasScope']);
auth.hasScope.and.returnValue(true);
await TestBed.configureTestingModule({
imports: [CreateReleaseComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
queryParamMap: convertToParamMap(queryParams),
},
},
},
{
provide: ReleaseManagementStore,
useValue: {
searchResults,
searchImages: jasmine.createSpy('searchImages'),
clearSearchResults: jasmine.createSpy('clearSearchResults'),
},
},
{ provide: BundleOrganizerApi, useValue: bundleApi },
{ provide: AUTH_SERVICE, useValue: auth },
],
}).compileComponents();
fixture = TestBed.createComponent(CreateReleaseComponent);
component = fixture.componentInstance;
router = TestBed.inject(Router);
spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
fixture.detectChanges();
}
beforeEach(() => {
TestBed.resetTestingModule();
searchResults.set([]);
});
function populateRequiredFields() {
component.form.name = 'Checkout API';
component.form.version = '2026.03.15.1';
component.form.targetEnvironment = 'us-prod';
component.contract.configProfile = 'prod-hardening-v5';
component.contract.changeTicket = 'CHG-2026-00315';
component.components = [
{
name: 'checkout-api',
imageRef: 'registry.example.local/stella/checkout-api',
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
tag: '2026.03.15.1',
version: '2026.03.15.1',
type: 'container',
},
];
component.step.set(4);
component.sealDraft = true;
}
it('requires at least one component before continuing from the components step', async () => {
await configure();
component.step.set(2);
component.form.name = 'Checkout API';
component.form.version = '2026.03.15.1';
expect(component.canContinueStep()).toBeFalse();
component.components = [
{
name: 'checkout-api',
imageRef: 'registry.example.local/stella/checkout-api',
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
version: '2026.03.15.1',
type: 'container',
},
];
expect(component.canContinueStep()).toBeTrue();
});
it('surfaces missing orch:operate scope instead of attempting publish', async () => {
await configure();
auth.hasScope.and.returnValue(false);
populateRequiredFields();
component.sealAndCreate();
expect(bundleApi.createBundle).not.toHaveBeenCalled();
expect(component.submitError()).toContain('orch:operate');
});
it('publishes through the canonical bundle lifecycle and navigates to the bundle version detail', async () => {
await configure();
populateRequiredFields();
component.sealAndCreate();
expect(bundleApi.createBundle).toHaveBeenCalledWith({
slug: 'checkout-api',
name: 'Checkout API',
description: jasmine.stringContaining('type=standard'),
});
expect(bundleApi.publishBundleVersion).toHaveBeenCalledWith('bundle-001', jasmine.objectContaining({
components: [jasmine.objectContaining({
componentName: 'checkout-api',
componentVersionId: 'checkout-api@2026.03.15.1',
})],
}));
expect(bundleApi.materializeBundleVersion).toHaveBeenCalledWith('bundle-001', 'version-001', jasmine.objectContaining({
targetEnvironment: 'us-prod',
reason: 'console_release_create',
}));
expect(router.navigate).toHaveBeenCalledWith(['/releases/bundles', 'bundle-001', 'versions', 'version-001'], {
queryParams: {
source: 'release-create',
type: 'standard',
returnTo: '/releases/versions',
},
queryParamsHandling: 'merge',
});
});
it('reuses an existing bundle on duplicate slug conflicts before publishing the version', async () => {
await configure({ type: 'hotfix' });
bundleApi.createBundle.and.returnValue(throwError(() => ({ status: 409 })));
bundleApi.listBundles.and.returnValue(of([
{
...bundleDetail,
id: 'bundle-duplicate',
slug: 'checkout-api',
},
]));
bundleApi.publishBundleVersion.and.returnValue(of({
...versionDetail,
id: 'version-duplicate',
bundleId: 'bundle-duplicate',
}));
populateRequiredFields();
component.form.releaseType = 'hotfix';
component.form.targetPathIntent = 'hotfix-prod';
component.sealAndCreate();
expect(bundleApi.listBundles).toHaveBeenCalledWith(200, 0);
expect(bundleApi.publishBundleVersion).toHaveBeenCalledWith('bundle-duplicate', jasmine.any(Object));
expect(router.navigate).toHaveBeenCalledWith(['/releases/bundles', 'bundle-duplicate', 'versions', 'version-duplicate'], {
queryParams: {
source: 'release-create',
type: 'hotfix',
returnTo: '/releases/versions',
},
queryParamsHandling: 'merge',
});
});
});

View File

@@ -1,9 +1,15 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { catchError, finalize, map, of, switchMap, throwError } from 'rxjs';
import { ReleaseManagementStore } from '../release.store';
import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from '../../../../core/api/release-management.models';
import { AUTH_SERVICE, type AuthService, StellaOpsScopes } from '../../../../core/auth/auth.service';
import {
BundleOrganizerApi,
type ReleaseControlBundleVersionDetailDto,
} from '../../../bundles/bundle-organizer.api';
@Component({
selector: 'app-create-release',
@@ -128,6 +134,7 @@ import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from
<h3>Selected components ({{ components.length }})</h3>
@if (components.length === 0) {
<p class="empty">No components added yet.</p>
<p class="validation-note">At least one component is required to publish a release version.</p>
} @else {
<ul class="component-list">
@for (component of components; track component.name + component.digest; let idx = $index) {
@@ -210,17 +217,25 @@ import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from
}
</section>
@if (submitError(); as submitError) {
<p class="wizard-error" role="alert">{{ submitError }}</p>
}
<footer class="wizard-actions">
<button type="button" class="btn-ghost" (click)="prevStep()" [disabled]="step() === 1">Back</button>
<button type="button" class="btn-ghost" (click)="prevStep()" [disabled]="step() === 1 || submitting()">Back</button>
<div class="spacer"></div>
@if (step() < 4) {
<button type="button" class="btn-primary" (click)="nextStep()" [disabled]="!canContinueStep()">
<button type="button" class="btn-primary" (click)="nextStep()" [disabled]="!canContinueStep() || submitting()">
Continue
</button>
} @else {
<button type="button" class="btn-primary" (click)="sealAndCreate()" [disabled]="!canSeal()">
Seal Draft Release Version
@if (submitting()) {
Creating Release Version...
} @else {
Seal Draft Release Version
}
</button>
}
</footer>
@@ -428,6 +443,20 @@ import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from
font-size: 0.8rem;
}
.validation-note,
.wizard-error {
margin: 0;
font-size: 0.78rem;
}
.validation-note {
color: var(--color-status-warning-text);
}
.wizard-error {
color: var(--color-status-error-text);
}
.review-card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
@@ -503,10 +532,14 @@ import { formatDigest, type AddComponentRequest, type DeploymentStrategy } from
export class CreateReleaseComponent implements OnInit {
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly auth = inject(AUTH_SERVICE) as AuthService;
private readonly bundleApi = inject(BundleOrganizerApi);
readonly store = inject(ReleaseManagementStore);
readonly step = signal(1);
sealDraft = false;
readonly submitError = signal<string | null>(null);
readonly submitting = signal(false);
readonly form = {
name: '',
@@ -552,6 +585,10 @@ export class CreateReleaseComponent implements OnInit {
&& Boolean(this.form.targetPathIntent.trim());
}
if (this.step() === 2) {
return this.components.length > 0;
}
if (this.step() === 3) {
return Boolean(this.contract.configProfile.trim()) && Boolean(this.contract.changeTicket.trim());
}
@@ -560,7 +597,7 @@ export class CreateReleaseComponent implements OnInit {
}
canSeal(): boolean {
return this.canContinueStep() && this.sealDraft;
return this.components.length > 0 && this.canContinueStep() && this.sealDraft && !this.submitting();
}
nextStep(): void {
@@ -629,6 +666,14 @@ export class CreateReleaseComponent implements OnInit {
return;
}
if (!this.auth.hasScope(StellaOpsScopes.ORCH_OPERATE)) {
this.submitError.set('Your current session is missing orch:operate. Refresh authentication after bootstrap scope changes complete.');
return;
}
this.submitError.set(null);
this.submitting.set(true);
const descriptionLines = [
this.form.description.trim(),
`type=${this.form.releaseType}`,
@@ -640,17 +685,122 @@ export class CreateReleaseComponent implements OnInit {
`draftIdentity=${this.draftIdentityPreview()}`,
].filter((item) => item.length > 0);
this.store.createRelease({
name: this.form.name.trim(),
version: this.form.version.trim(),
description: descriptionLines.join(' | '),
targetEnvironment: this.form.targetEnvironment.trim() || undefined,
deploymentStrategy: this.contract.deploymentStrategy,
});
const bundleSlug = this.toSlug(this.form.name.trim());
const bundleName = this.form.name.trim();
const bundleDescription = descriptionLines.join(' | ');
const publishRequest = {
changelog: bundleDescription,
components: this.toBundleComponents(),
};
void this.router.navigate(['/releases/versions'], {
queryParams: { type: this.form.releaseType },
});
this.createOrReuseBundle(bundleSlug, bundleName, bundleDescription)
.pipe(
switchMap((bundle) => this.bundleApi.publishBundleVersion(bundle.id, publishRequest)),
switchMap((version) => this.materializeIfRequested(version)),
finalize(() => this.submitting.set(false)),
)
.subscribe({
next: (version) => {
void this.router.navigate(['/releases/bundles', version.bundleId, 'versions', version.id], {
queryParams: {
source: 'release-create',
type: this.form.releaseType,
returnTo: '/releases/versions',
},
queryParamsHandling: 'merge',
});
},
error: (error) => {
this.submitError.set(this.mapCreateError(error));
},
});
}
private createOrReuseBundle(slug: string, name: string, description: string) {
return this.bundleApi.createBundle({
slug,
name,
description,
}).pipe(
catchError((error) => {
if (this.statusCodeOf(error) !== 409) {
return throwError(() => error);
}
return this.bundleApi.listBundles(200, 0).pipe(
map((bundles) => {
const existing = bundles.find((bundle) => bundle.slug === slug);
if (!existing) {
throw error;
}
return existing;
}),
);
}),
);
}
private toBundleComponents() {
return this.components.map((component) => ({
componentName: component.name,
componentVersionId: `${component.name}@${component.version}`,
imageDigest: component.digest,
deployOrder: 10,
metadataJson: JSON.stringify({
imageRef: component.imageRef,
tag: component.tag ?? null,
type: component.type,
}),
}));
}
private materializeIfRequested(version: ReleaseControlBundleVersionDetailDto) {
const targetEnvironment = this.form.targetEnvironment.trim();
if (!targetEnvironment) {
return of(version);
}
return this.bundleApi.materializeBundleVersion(version.bundleId, version.id, {
targetEnvironment,
reason: this.form.releaseType === 'hotfix' ? 'console_hotfix_create' : 'console_release_create',
idempotencyKey: this.draftIdentityPreview(),
}).pipe(
map(() => version),
);
}
private statusCodeOf(error: unknown): number | null {
if (!error || typeof error !== 'object' || !('status' in error)) {
return null;
}
const status = (error as { status?: unknown }).status;
return typeof status === 'number' ? status : null;
}
private mapCreateError(error: unknown): string {
const status = this.statusCodeOf(error);
if (status === 403) {
return 'Release creation requires orch:operate. The current session is not authorized to publish bundle versions.';
}
if (status === 409) {
return 'A release-control bundle with this slug already exists, but it could not be reused for this version.';
}
if (status === 503) {
return 'Release creation backend is currently unavailable. The draft was not created.';
}
return 'Failed to create release version via release-control publish and materialization.';
}
private toSlug(value: string): string {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || `release-${this.draftIdentityPreview()}`;
}
}

View File

@@ -190,7 +190,7 @@ export const RELEASES_ROUTES: Routes = [
{
path: 'health',
title: 'Release Health',
data: { breadcrumb: 'Health' },
data: { breadcrumb: 'Release Health' },
loadComponent: () =>
import('../features/topology/environment-posture-page.component').then(
(m) => m.EnvironmentPosturePageComponent,