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(