From 92d368d1648603dab8253dbfe5f7bcafa4f86d1e Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Sat, 29 Aug 2026 20:57:48 +1000 Subject: [PATCH] feat(publisher): persist compile manifest as manifest.json blob Upload the compiled TemplateManifest alongside template.html and metadata.json so deployed templates retain provenance after CI logs expire, without changing the send path or existing stdout log line. Co-authored-by: Cursor --- docs/guides/template-publishing.md | 21 ++-- .../post-kit-publisher/src/publish.spec.ts | 106 ++++++++++++------ packages/post-kit-publisher/src/publish.ts | 7 ++ 3 files changed, 92 insertions(+), 42 deletions(-) diff --git a/docs/guides/template-publishing.md b/docs/guides/template-publishing.md index 4a7c8be..e2837a3 100644 --- a/docs/guides/template-publishing.md +++ b/docs/guides/template-publishing.md @@ -16,11 +16,12 @@ content/email-templates// (consumer repo, reviewed via PR) ▼ post-kit-publish │ compile every template (fail-fast) - │ upload template.html + metadata.json + │ upload template.html + metadata.json + manifest.json ▼ Azure Blob Storage tenants/{tenant}/{environment}/templates/{key}/template.html tenants/{tenant}/{environment}/templates/{key}/metadata.json + tenants/{tenant}/{environment}/templates/{key}/manifest.json │ ▼ PostKit API (BlobTemplateStore) at send time @@ -31,21 +32,27 @@ A copy-pasteable workflow implementing the CI step is in ## Blob layout -The publisher writes, and the API reads, exactly two blobs per template: +The publisher writes three blobs per template. The API send path reads only +the first two: ```text tenants/{tenant}/{environment}/templates/{templateKey}/template.html tenants/{tenant}/{environment}/templates/{templateKey}/metadata.json +tenants/{tenant}/{environment}/templates/{templateKey}/manifest.json ``` `template.html` is uploaded as `text/html; charset=utf-8` and still contains the `{{variable}}` placeholders; `metadata.json` is uploaded as `application/json; charset=utf-8` and is the compiled metadata, which the API -re-validates on load. Uploads overwrite whatever is already at those paths. - -The compile manifest (including the SHA-256 `contentHash`) is **not** stored as -a blob. It is emitted as one JSON line per template on the publisher's stdout, -which is where CI logs preserve it: +re-validates on load. `manifest.json` is uploaded as +`application/json; charset=utf-8` and holds the compile manifest +(`contentHash`, `compiledAt`, `sourceCommit`, and related fields). It is +operational metadata for provenance and change detection — `BlobTemplateStore` +does not read it, and templates published before this blob existed still load +and send unchanged. Uploads overwrite whatever is already at those paths. + +The publisher also emits one JSON line per template on stdout (unchanged for +log-scraping compatibility): ```json { diff --git a/packages/post-kit-publisher/src/publish.spec.ts b/packages/post-kit-publisher/src/publish.spec.ts index ae3b653..3d3044f 100644 --- a/packages/post-kit-publisher/src/publish.spec.ts +++ b/packages/post-kit-publisher/src/publish.spec.ts @@ -107,54 +107,90 @@ describe('publishTemplates', () => { assert.equal(uploads, 0); }); - it('uploads template.html and metadata.json for a valid fixture', async () => { + it('uploads template.html, metadata.json, and manifest.json for a valid fixture', async () => { const root = await mkdtemp(join(tmpdir(), 'post-kit-publish-')); await cp(join(FIXTURES, 'marketing.contact-us'), join(root, 'marketing.contact-us'), { recursive: true, }); - const uploaded = new Map(); - const client = makeFakeClient((path, body) => { - uploaded.set(path, body); + const uploaded = new Map(); + const client = makeFakeClient((path, body, headers) => { + uploaded.set(path, { body, contentType: headers?.blobContentType }); }); - const result = await publishTemplatesWithClient( - { - templatesDir: root, - tenant: 'inkads', - environment: 'production', - storageAccount: 'ssdpostkitstprodae', - container: 'templates', - commit: 'abc123', - }, - client, - ); - - assert.deepEqual(result.published, ['marketing.contact-us']); - assert.equal(result.failed.length, 0); - assert.ok( - uploaded.has('tenants/inkads/production/templates/marketing.contact-us/template.html'), - ); - assert.ok( - uploaded.has('tenants/inkads/production/templates/marketing.contact-us/metadata.json'), - ); - const meta = JSON.parse( - uploaded.get('tenants/inkads/production/templates/marketing.contact-us/metadata.json')!, - ); - assert.equal(meta.key, 'marketing.contact-us'); - const html = uploaded.get( - 'tenants/inkads/production/templates/marketing.contact-us/template.html', - )!; - assert.ok(html.includes(' 0); + const logs: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => { + logs.push(args.map(String).join(' ')); + }; + + try { + const result = await publishTemplatesWithClient( + { + templatesDir: root, + tenant: 'inkads', + environment: 'production', + storageAccount: 'ssdpostkitstprodae', + container: 'templates', + commit: 'abc123', + }, + client, + ); + + assert.deepEqual(result.published, ['marketing.contact-us']); + assert.equal(result.failed.length, 0); + + const base = 'tenants/inkads/production/templates/marketing.contact-us'; + assert.ok(uploaded.has(`${base}/template.html`)); + assert.ok(uploaded.has(`${base}/metadata.json`)); + assert.ok(uploaded.has(`${base}/manifest.json`)); + + const meta = JSON.parse(uploaded.get(`${base}/metadata.json`)!.body); + assert.equal(meta.key, 'marketing.contact-us'); + + const manifest = JSON.parse(uploaded.get(`${base}/manifest.json`)!.body); + assert.equal(manifest.key, 'marketing.contact-us'); + assert.equal(manifest.sourceCommit, 'abc123'); + assert.ok(manifest.contentHash.length > 0); + assert.ok(manifest.compiledAt.length > 0); + assert.equal( + uploaded.get(`${base}/manifest.json`)!.contentType, + 'application/json; charset=utf-8', + ); + + const html = uploaded.get(`${base}/template.html`)!.body; + assert.ok(html.includes(' 0); + + assert.equal(logs.length, 1); + const stdoutLine = JSON.parse(logs[0]!); + assert.equal(stdoutLine.key, 'marketing.contact-us'); + assert.equal(stdoutLine.contentHash, manifest.contentHash); + assert.equal(stdoutLine.templateHtml, `${base}/template.html`); + assert.equal(stdoutLine.metadataJson, `${base}/metadata.json`); + assert.equal(Object.keys(stdoutLine).length, 4); + } finally { + console.log = originalLog; + } }); }); -function makeFakeClient(onUpload: (path: string, body: string) => void): BlobServiceClient { +interface UploadedBlob { + body: string; + contentType?: string; +} + +function makeFakeClient( + onUpload: (path: string, body: string, headers?: { blobContentType?: string }) => void, +): BlobServiceClient { const getBlockBlobClient = (blobPath: string): BlockBlobClient => ({ - upload: async (body: string | Buffer) => { + upload: async ( + body: string | Buffer, + _length: number, + options?: { blobHTTPHeaders?: { blobContentType?: string } }, + ) => { const text = typeof body === 'string' ? body : body.toString('utf8'); - onUpload(blobPath, text); + onUpload(blobPath, text, options?.blobHTTPHeaders); return {}; }, }) as unknown as BlockBlobClient; diff --git a/packages/post-kit-publisher/src/publish.ts b/packages/post-kit-publisher/src/publish.ts index e718bf4..9c61da5 100644 --- a/packages/post-kit-publisher/src/publish.ts +++ b/packages/post-kit-publisher/src/publish.ts @@ -109,6 +109,7 @@ async function runPublish( const base = blobBasePath(options.tenant, options.environment, key); const htmlPath = `${base}/template.html`; const metaPath = `${base}/metadata.json`; + const manifestPath = `${base}/manifest.json`; await containerClient .getBlockBlobClient(htmlPath) @@ -121,6 +122,12 @@ async function runPublish( .upload(metadataJson, Buffer.byteLength(metadataJson, 'utf8'), { blobHTTPHeaders: { blobContentType: 'application/json; charset=utf-8' }, }); + const manifestJson = JSON.stringify(entry.compiled.manifest, null, 2); + await containerClient + .getBlockBlobClient(manifestPath) + .upload(manifestJson, Buffer.byteLength(manifestJson, 'utf8'), { + blobHTTPHeaders: { blobContentType: 'application/json; charset=utf-8' }, + }); published.push(key); console.log(