feat: add build-aware compose stack updates (#1561)

Detect services with build: in the update preview and run compose build --pull
plus pull --ignore-buildable when Update is triggered on those stacks, while
keeping the existing pull-only path for image-only stacks.
This commit is contained in:
Anso
2026-07-05 04:45:45 -04:00
committed by GitHub
parent 4077546492
commit f2b5c68d84
15 changed files with 450 additions and 76 deletions
@@ -23,16 +23,21 @@ import StackAnatomyPanel from './StackAnatomyPanel';
const COMPOSE = 'services:\n web:\n image: nginx:1.25\n';
function previewBody(hasUpdate: boolean) {
function previewBody(hasUpdate: boolean, buildServices: string[] = []) {
const hasBuild = buildServices.length > 0;
return {
build_services: buildServices,
summary: {
has_update: hasUpdate,
primary_image: 'nginx',
current_tag: '1.25',
next_tag: '1.26',
semver_bump: 'minor',
update_kind: hasUpdate ? 'tag' : 'none',
blocked: false,
blocked_reason: null,
has_build_services: hasBuild,
rebuild_available: hasBuild,
},
changelog: null,
};
@@ -86,6 +91,21 @@ describe('StackAnatomyPanel update banner', () => {
expect(onApply).toHaveBeenCalledTimes(1);
});
it('shows Rebuild & Update for build-only stacks', async () => {
vi.mocked(apiFetch).mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/update-preview')) return jsonRes(previewBody(false, ['app']));
if (url.includes('/scan-status')) return jsonRes({ status: 'ok' });
return jsonRes(null, false);
});
render(panel(false));
expect(await screen.findByTestId('update-available-banner')).toBeInTheDocument();
expect(screen.getByText(/Rebuild available/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Rebuild & Update' })).toBeInTheDocument();
});
it('disables the apply button and shows progress while applying', async () => {
const onApply = vi.fn();
const { rerender } = render(panel(false, onApply));
+38 -11
View File
@@ -39,6 +39,7 @@ interface StackAnatomyPanelProps {
}
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
type UpdateKind = 'tag' | 'digest' | 'none';
interface UpdatePreviewSummary {
has_update: boolean;
@@ -46,12 +47,16 @@ interface UpdatePreviewSummary {
current_tag: string | null;
next_tag: string | null;
semver_bump: SemverBump;
update_kind?: UpdateKind;
blocked: boolean;
blocked_reason: string | null;
has_build_services: boolean;
rebuild_available: boolean;
}
interface UpdatePreview {
summary: UpdatePreviewSummary;
build_services?: string[];
changelog: string | null;
}
@@ -335,6 +340,10 @@ export default function StackAnatomyPanel({
const bump = updatePreview?.summary.semver_bump ?? 'none';
const hasUpdate = Boolean(updatePreview?.summary.has_update);
const hasBuildServices = Boolean(updatePreview?.summary.has_build_services);
const rebuildAvailable = Boolean(updatePreview?.summary.rebuild_available);
const showUpdateBanner = hasUpdate || rebuildAvailable;
const updateKind = updatePreview?.summary.update_kind ?? 'none';
const blocked = Boolean(updatePreview?.summary.blocked);
const bannerSeverity: 'danger' | 'warn' | 'ok' = bump === 'major' || blocked
? 'danger'
@@ -352,13 +361,29 @@ export default function StackAnatomyPanel({
const bumpLabel = bump === 'none' || bump === 'unknown' ? '' : `${bump}`;
const bannerLeadIn = blocked
? 'review required'
: bump === 'patch'
? 'safe to apply'
: bump === 'minor'
? 'review recommended'
: bump === 'major'
? 'breaking changes possible'
: '';
: hasUpdate && updateKind === 'digest'
? 'same-tag digest rebuild'
: hasUpdate && hasBuildServices
? 'registry update + local rebuild'
: rebuildAvailable && !hasUpdate
? 'local build / rebuild required'
: bump === 'patch'
? 'safe to apply'
: bump === 'minor'
? 'review recommended'
: bump === 'major'
? 'breaking changes possible'
: '';
const buildServiceNames = updatePreview?.build_services ?? [];
const buildHint = hasBuildServices
? `Rebuilds ${buildServiceNames.length} local build service${buildServiceNames.length === 1 ? '' : 's'} from Dockerfile context; may take longer and needs network access for base images.`
: '';
const gitRebuildHint = hasBuildServices && activeGitSource
? 'After applying Git source changes, use Rebuild & Update to deploy the updated source.'
: '';
const applyLabel = hasBuildServices
? (applying ? 'rebuilding...' : 'Rebuild & Update')
: (applying ? 'applying...' : 'apply');
return (
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
@@ -529,13 +554,13 @@ export default function StackAnatomyPanel({
</Row>
</>
)}
{hasUpdate && updatePreview && (
{showUpdateBanner && updatePreview && (
<div data-testid="update-available-banner" className={cn('mt-3 mb-3 rounded-lg border p-3', bannerTone)}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-mono text-xs uppercase tracking-wide">
Update available
{updatePreview.summary.current_tag && updatePreview.summary.next_tag && (
{hasBuildServices && !hasUpdate ? 'Rebuild available' : 'Update available'}
{updatePreview.summary.current_tag && updatePreview.summary.next_tag && hasUpdate && (
<span className="text-foreground">
{' · '}
<span className="text-stat-subtitle">{updatePreview.summary.current_tag}</span>
@@ -548,6 +573,8 @@ export default function StackAnatomyPanel({
{[
bumpLabel,
bannerLeadIn,
buildHint,
gitRebuildHint,
updatePreview.changelog ? updatePreview.changelog.split(/[.\n]/)[0] : '',
].filter(Boolean).join(' · ')}
</div>
@@ -565,7 +592,7 @@ export default function StackAnatomyPanel({
onClick={onApplyUpdate}
>
<Rocket className={cn('h-3 w-3', applying && 'animate-pulse')} strokeWidth={1.5} />
{applying ? 'applying...' : 'apply'}
{applyLabel}
</Button>
)}
</div>