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 (
<>
-