diff --git a/backend/src/__tests__/update-preview-service.test.ts b/backend/src/__tests__/update-preview-service.test.ts index d8a9ffde..2e601305 100644 --- a/backend/src/__tests__/update-preview-service.test.ts +++ b/backend/src/__tests__/update-preview-service.test.ts @@ -224,4 +224,23 @@ describe('buildSummary', () => { expect(preview.summary.semver_bump).toBe('major'); expect(preview.summary.blocked).toBe(true); }); + + it('reports update_kind="tag" when at least one image has a strictly newer tag', () => { + const images = [ + baseImage({ service: 'web', has_update: true, semver_bump: 'patch', next_tag: '1.0.1', current_tag: '1.0.0' }), + ]; + expect(buildSummary('stacky', images).summary.update_kind).toBe('tag'); + }); + + it('reports update_kind="digest" when only same-tag rebuilds are available', () => { + const images = [ + baseImage({ service: 'web', has_update: true, semver_bump: 'patch', next_tag: '10.11', current_tag: '10.11', image: 'redis:10.11' }), + ]; + expect(buildSummary('stacky', images).summary.update_kind).toBe('digest'); + }); + + it('reports update_kind="none" when nothing has an update', () => { + const images = [baseImage({ service: 'clean', has_update: false })]; + expect(buildSummary('stacky', images).summary.update_kind).toBe('none'); + }); }); diff --git a/backend/src/services/UpdatePreviewService.ts b/backend/src/services/UpdatePreviewService.ts index 11b5dad4..30d1e90a 100644 --- a/backend/src/services/UpdatePreviewService.ts +++ b/backend/src/services/UpdatePreviewService.ts @@ -24,12 +24,21 @@ export interface UpdatePreviewImage { semver_bump: SemverBump; } +export type UpdateKind = 'tag' | 'digest' | 'none'; + export interface UpdatePreviewSummary { has_update: boolean; primary_image: string | null; current_tag: string | null; next_tag: string | null; semver_bump: SemverBump; + /** + * Distinguishes a "new tag is available" update from a "same tag, new + * digest" rebuild. The UI renders the rebuild case differently because + * showing "10.11 -> 10.11" reads as a bug even though it is technically + * accurate (the tag did not change, only the immutable digest behind it). + */ + update_kind: UpdateKind; blocked: boolean; blocked_reason: string | null; } @@ -208,6 +217,14 @@ export function buildSummary(stackName: string, images: UpdatePreviewImage[]): U 'none', ); const blocked = overallBump === 'major'; + // 'tag' means at least one image has a strictly newer tag; 'digest' means + // the only updates available are same-tag rebuilds (digest changed); 'none' + // means there is nothing to apply. + const updateKind: UpdateKind = !hasUpdate + ? 'none' + : updated.some(i => i.next_tag !== null && i.next_tag !== i.current_tag) + ? 'tag' + : 'digest'; return { stack_name: stackName, images, @@ -217,6 +234,7 @@ export function buildSummary(stackName: string, images: UpdatePreviewImage[]): U current_tag: primary ? primary.current_tag : null, next_tag: primary ? primary.next_tag : null, semver_bump: overallBump, + update_kind: updateKind, blocked, blocked_reason: blocked ? 'Major version jumps require human review before applying.' : null, }, diff --git a/frontend/src/components/AutoUpdateReadinessView.tsx b/frontend/src/components/AutoUpdateReadinessView.tsx index c2fa4b51..2c65760b 100644 --- a/frontend/src/components/AutoUpdateReadinessView.tsx +++ b/frontend/src/components/AutoUpdateReadinessView.tsx @@ -19,6 +19,8 @@ interface UpdatePreviewImage { semver_bump: SemverBump; } +type UpdateKind = 'tag' | 'digest' | 'none'; + interface UpdatePreview { stack_name: string; images: UpdatePreviewImage[]; @@ -28,6 +30,7 @@ interface UpdatePreview { current_tag: string | null; next_tag: string | null; semver_bump: SemverBump; + update_kind: UpdateKind; blocked: boolean; blocked_reason: string | null; }; @@ -160,10 +163,19 @@ function StackReadinessCard({ const blockedReason = p.summary.blocked_reason; return ( <> - + {p.summary.update_kind === 'digest' ? ( +
+ {p.summary.current_tag} + + Rebuild available + +
+ ) : ( + + )}
{p.summary.primary_image ?? '-'}