Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions docs/guides/template-publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ content/email-templates/<key>/ (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
Expand All @@ -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
{
Expand Down
106 changes: 71 additions & 35 deletions packages/post-kit-publisher/src/publish.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
const client = makeFakeClient((path, body) => {
uploaded.set(path, body);
const uploaded = new Map<string, UploadedBlob>();
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('<html') || html.includes('<!DOCTYPE') || html.length > 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('<html') || html.includes('<!DOCTYPE') || html.length > 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;
Expand Down
7 changes: 7 additions & 0 deletions packages/post-kit-publisher/src/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down
Loading