From 2cb1b8754158a3eae0b81aa753c273569e510d60 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Thu, 16 Jul 2026 23:11:45 +0300 Subject: [PATCH] fix(cdn): skip content-less builds so the manifest never outruns the bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A webhook build for a push that changes no content models (a pure code push) still ran the full pipeline: it re-uploaded `_manifest.json` with the new commit SHA but skipped the locale-bundle block (which only runs when a model is affected). That left `_manifest.json.commitSha` ahead of every `_bundle/*.json`, and CDN consumers that key content freshness off the manifest rendered stale/empty content until a manual full rebuild re-aligned the two — reproduced on staging (Lanista/collabers), where every 22-file code-only webhook build stranded content and forced an 88-file manual rebuild, while 28-file content builds were always fine. The manifest tracks the CONTENT version, so a content-less push must not bump it. Early-return a 0-file no-op from executeCDNBuild when a selective build resolves zero affected models. Manual rebuilds (fullRebuild) and config/model-def changes never reach this branch, so they are unaffected; the same push also stops wasting a build cycle. --- server/utils/cdn-builder.ts | 24 +++++++++ tests/unit/cdn-builder.test.ts | 91 ++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/server/utils/cdn-builder.ts b/server/utils/cdn-builder.ts index c2693e6..cb9c5fb 100644 --- a/server/utils/cdn-builder.ts +++ b/server/utils/cdn-builder.ts @@ -231,6 +231,30 @@ export async function executeCDNBuild(options: BuildOptions): Promise m.id)) + // A selective build whose diff touches no content models (a pure code + // push: only app/, server/, etc.) must be a true no-op. Uploading the + // manifest here would advance `_manifest.json.commitSha` to the code + // commit while the bundle block below is skipped (targetModels empty) — + // leaving `_manifest.json.commitSha` ahead of every `_bundle/*.json`. + // Consumers that key content freshness off the manifest then read stale/ + // empty content until a full rebuild re-aligns them. The manifest tracks + // the CONTENT version, so a content-less push must not bump it. + // fullRebuild (manual trigger) and config/model-def changes never reach + // here: the former skips the `else` branch above, the latter make + // getAffectedModels non-empty. + if (!options.fullRebuild && options.changedPaths?.length && targetModels.length === 0) { + return { + projectId, + buildId, + commitSha, + filesUploaded: 0, + filesDeleted: 0, + totalSizeBytes: 0, + changedModels: [], + durationMs: Date.now() - start, + } + } + // 4. Upload manifest progress({ phase: 'upload', message: 'Uploading manifest...', current: 0, total: targetModels.length }) const manifest = { diff --git a/tests/unit/cdn-builder.test.ts b/tests/unit/cdn-builder.test.ts index 563026e..8002310 100644 --- a/tests/unit/cdn-builder.test.ts +++ b/tests/unit/cdn-builder.test.ts @@ -484,6 +484,97 @@ describe('cdn builder', () => { expect(bundle.paths['content/team/en.json']).toEqual({ m1: { name: 'Existing member' } }) }) + // Regression: a selective build whose diff touches zero content models (a + // pure code push — app/, server/, …) must be a true no-op. Uploading the + // manifest would advance `_manifest.json.commitSha` past every + // `_bundle/*.json` (the bundle block is skipped when no models change), + // leaving the manifest ahead of the bundle until a full rebuild re-aligns + // them — the divergence that stranded content in CDN consumers. + it('is a no-op when a selective build touches no content models', async () => { + const files = { + '.contentrain/config.json': JSON.stringify({ + stack: 'nuxt', + locales: { default: 'en', supported: ['en'] }, + domains: ['marketing'], + }), + '.contentrain/models/faq.json': JSON.stringify({ + id: 'faq', + name: 'FAQ', + kind: 'collection', + domain: 'marketing', + i18n: true, + fields: {}, + }), + '.contentrain/content/marketing/faq/en.json': JSON.stringify({ a1: { question: 'Q' } }), + } + const { provider, objects } = createCDNProvider() + + // Seed a pre-existing bundle + manifest at an OLD commit, as a healthy + // full rebuild would have left them. The no-op must not touch either. + objects.set('proj:_manifest.json', JSON.stringify({ version: '1', commitSha: 'old' })) + objects.set('proj:_bundle/en.json', JSON.stringify({ version: '1', commitSha: 'old', paths: {} })) + + const result = await executeCDNBuild({ + projectId: 'proj', + buildId: 'b', + git: createGitProvider(files), + cdn: provider, + contentRoot: '', + commitSha: 'newcode', + branch: 'main', + changedPaths: ['app/pages/index.vue', 'server/api/foo.ts'], + }) + + expect(result.error).toBeUndefined() + expect(result.filesUploaded).toBe(0) + expect(result.filesDeleted).toBe(0) + expect(result.changedModels).toEqual([]) + // Nothing written or deleted — manifest/bundle stay at the old commit. + expect(provider.putObject).not.toHaveBeenCalled() + expect(provider.deleteObject).not.toHaveBeenCalled() + expect(JSON.parse(objects.get('proj:_manifest.json') ?? '{}').commitSha).toBe('old') + expect(JSON.parse(objects.get('proj:_bundle/en.json') ?? '{}').commitSha).toBe('old') + }) + + // Counter-case: a config change reaching the same selective path must still + // full-build (getAffectedModels returns every model), so the no-op guard + // never suppresses a real content-affecting build. + it('still builds when a selective diff includes the config file', async () => { + const files = { + '.contentrain/config.json': JSON.stringify({ + stack: 'nuxt', + locales: { default: 'en', supported: ['en'] }, + domains: ['marketing'], + }), + '.contentrain/models/faq.json': JSON.stringify({ + id: 'faq', + name: 'FAQ', + kind: 'collection', + domain: 'marketing', + i18n: true, + fields: {}, + }), + '.contentrain/content/marketing/faq/en.json': JSON.stringify({ a1: { question: 'Q' } }), + } + const { provider, objects } = createCDNProvider() + + const result = await executeCDNBuild({ + projectId: 'proj', + buildId: 'b', + git: createGitProvider(files), + cdn: provider, + contentRoot: '', + commitSha: 'newcfg', + branch: 'main', + changedPaths: ['.contentrain/config.json'], + }) + + expect(result.error).toBeUndefined() + expect(result.filesUploaded).toBeGreaterThan(0) + expect(result.changedModels).toEqual(['faq']) + expect(JSON.parse(objects.get('proj:_manifest.json') ?? '{}').commitSha).toBe('newcfg') + }) + // Regression: for i18n:false content, meta lives under the DEFAULT locale // (MCP's contract), which is NOT necessarily supported[0]. The builder used // `locales[0]` (= supported[0]) to resolve the meta path, so when