From 584cda718238f379c014f686edd27b32069e52db Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 24 Apr 2026 23:37:32 -0400 Subject: [PATCH] fix(auto-update): label same-tag rebuilds as 'Rebuild available' instead of '10.11 -> 10.11' (#766) When a registry pushes a new build of an image at the same tag (digest changes, tag does not), the preview service set next_tag to the same string as current_tag and the readiness view rendered '10.11 -> 10.11', which reads as a UI bug. Add an update_kind field to UpdatePreviewSummary that distinguishes: - 'tag' - at least one image has a strictly newer tag - 'digest' - the only updates available are same-tag rebuilds - 'none' - nothing to apply The frontend now branches on update_kind and renders 'Rebuild available' next to the current tag for the digest case, leaving the version-arrow diff for genuine tag bumps. Three new buildSummary cases lock in the kind classification. --- .../__tests__/update-preview-service.test.ts | 19 ++++++++++++++++++ backend/src/services/UpdatePreviewService.ts | 18 +++++++++++++++++ .../components/AutoUpdateReadinessView.tsx | 20 +++++++++++++++---- 3 files changed, 53 insertions(+), 4 deletions(-) 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 ?? '-'}