From 211b3d2c0186185bc59cead1ddc12f0c1ae0687e Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Sat, 29 Aug 2026 20:58:46 +1000 Subject: [PATCH 1/2] Add dry-run and opt-in prune to post-kit-publish. Retired templates can be removed from storage when publishing the full template set, with scoped deletes and a dry-run mode to preview adds, updates, and deletions before any write. Closes #68 Co-authored-by: Cursor --- docs/guides/template-authoring.md | 11 +- docs/guides/template-publishing.md | 35 +- .../src/bin/post-kit-publish.ts | 26 +- packages/post-kit-publisher/src/index.ts | 3 + .../post-kit-publisher/src/path-safety.ts | 37 ++- .../post-kit-publisher/src/publish.spec.ts | 314 +++++++++++++++++- packages/post-kit-publisher/src/publish.ts | 111 ++++++- 7 files changed, 516 insertions(+), 21 deletions(-) diff --git a/docs/guides/template-authoring.md b/docs/guides/template-authoring.md index d96b092..2141341 100644 --- a/docs/guides/template-authoring.md +++ b/docs/guides/template-authoring.md @@ -260,9 +260,8 @@ These are limitations of the current implementation, not of this guide: - **The compiler ships no CLI binary.** Local validation requires the small script above; there is no `post-kit-compile` command. -- **The publisher has no dry-run or validate-only flag.** `post-kit-publish` - either publishes or fails; use the compiler script to validate without - touching storage. -- **Deleting a template directory does not delete the published blobs.** The - publisher only uploads; removing a template from the repository leaves its - artifacts in place, and the send endpoint will keep serving them. +- **Use `--dry-run` on `post-kit-publish` to validate the change set without + touching storage** (see [`template-publishing.md`](./template-publishing.md)). +- **Removing a template from source does not delete published blobs unless you + pass `--prune`.** Without prune, retired templates remain sendable until you + publish again with `--prune` (see the publishing guide). diff --git a/docs/guides/template-publishing.md b/docs/guides/template-publishing.md index 4a7c8be..fa8eaf4 100644 --- a/docs/guides/template-publishing.md +++ b/docs/guides/template-publishing.md @@ -17,6 +17,7 @@ content/email-templates// (consumer repo, reviewed via PR) post-kit-publish │ compile every template (fail-fast) │ upload template.html + metadata.json + │ optional: --prune retired keys ▼ Azure Blob Storage tenants/{tenant}/{environment}/templates/{key}/template.html @@ -78,12 +79,11 @@ Every flag the CLI implements: | `--storage-account ` | yes | Azure Storage account **name** (not a URL, not a connection string). Must match `/^[a-z0-9]{3,24}$/`. The endpoint `https://.blob.core.windows.net` is derived from it. | | `--container ` | yes | Blob container holding the `tenants/…` prefix. | | `--commit ` | no | Recorded as the compiled manifest's `sourceCommit`. Omitted, it defaults to an empty string. Pass `${{ github.sha }}` in CI so every artifact is traceable to the source revision it was built from. | +| `--dry-run` | no | Compile every template and print the full change set (adds, updates, and — when combined with `--prune` — deletions) as JSON lines on stdout. Performs no uploads and no deletes. Exit 1 if any template fails to compile, same as a normal run. | +| `--prune` | no | After a successful upload pass, delete blobs for template keys that exist in storage under `tenants/{tenant}/{environment}/templates/` but are absent from the compiled set. **Off by default** — a publish of a subset must not remove sibling templates. Each pruned key is reported as one JSON line on stdout with `"action": "delete"`. Scoped strictly to the tenant/environment templates prefix; never deletes outside it. | | `--help`, `-h` | no | Print usage and exit 2. | -There are no other flags. In particular there is **no** dry-run, no -validate-only, no per-template selector, no delete/prune, and no way to supply -credentials on the command line. Missing any required flag prints usage and -exits 2. +Missing any required flag prints usage and exits 2. Credentials cannot be supplied on the command line. Exit codes: `0` on success, `1` when any template failed (or an unexpected error was thrown), `2` for a usage error. Successful runs print a summary line @@ -97,9 +97,10 @@ Publishing is all-or-nothing per run: files. A missing file throws immediately, before any compilation. 2. It then compiles every template. Compile failures and duplicate keys are collected rather than thrown. -3. **If any template failed, nothing is uploaded at all** — the run returns an +3. **If any template failed, nothing is uploaded or pruned** — the run returns an empty published list and the CLI exits 1. -4. Only when every template compiled cleanly does the upload loop start. +4. Only when every template compiled cleanly does the upload loop start (and, + when `--prune` is set, the delete pass after uploads). Uploads themselves are not transactional: once step 4 begins, a failure mid-loop leaves earlier templates already written. Re-running a fixed publish @@ -109,6 +110,28 @@ Practical consequence: a single broken template blocks publication of its siblings. Validate on the pull request (see the compiler script in the authoring guide) so this never surfaces at publish time. +## Dry run and retiring templates + +Use `--dry-run` before a production publish to see what would change without +writing anything: + +```bash +pnpm exec post-kit-publish --templates ./content/email-templates \ + --tenant acme --environment production \ + --storage-account --container templates \ + --dry-run --prune +``` + +Stdout receives one JSON line per add, update, or (with `--prune`) delete. +Adds and updates include `contentHash`; deletes use `"action": "delete"`. +Stderr prints a short summary (for example `Dry run: 1 update(s), 1 delete(s)`). + +When a template directory is removed from source, its blobs remain in storage +until you publish again **with `--prune`**. Prune is opt-in so a workflow that +publishes only part of the tree cannot silently delete the rest. Only pass +`--prune` when the `--templates` directory is the full authoritative set for +that tenant and environment. + ## Environments and promotion `development`, `staging`, and `production` are separate blob prefixes under the diff --git a/packages/post-kit-publisher/src/bin/post-kit-publish.ts b/packages/post-kit-publisher/src/bin/post-kit-publish.ts index 58d55aa..8fc2e20 100644 --- a/packages/post-kit-publisher/src/bin/post-kit-publish.ts +++ b/packages/post-kit-publisher/src/bin/post-kit-publish.ts @@ -10,7 +10,9 @@ function usage(): never { --environment \\ --storage-account \\ --container \\ - [--commit ]`); + [--commit ] \\ + [--dry-run] \\ + [--prune]`); process.exit(2); } @@ -30,6 +32,8 @@ async function main(): Promise { const storageAccount = readFlag(argv, '--storage-account'); const container = readFlag(argv, '--container'); const commit = readFlag(argv, '--commit'); + const dryRun = argv.includes('--dry-run'); + const prune = argv.includes('--prune'); if (!templates || !tenant || !environment || !storageAccount || !container) { usage(); @@ -42,6 +46,8 @@ async function main(): Promise { storageAccount, container, commit, + dryRun, + prune, }); if (result.failed.length > 0) { @@ -51,7 +57,23 @@ async function main(): Promise { process.exit(1); } - console.error(`Published ${result.published.length} template(s): ${result.published.join(', ')}`); + if (dryRun) { + const parts = [ + result.added.length ? `${result.added.length} add(s)` : null, + result.updated.length ? `${result.updated.length} update(s)` : null, + result.deleted.length ? `${result.deleted.length} delete(s)` : null, + ].filter(Boolean); + console.error(`Dry run: ${parts.length ? parts.join(', ') : 'no changes'}`); + return; + } + + const summary = [ + `Published ${result.published.length} template(s): ${result.published.join(', ')}`, + ]; + if (result.deleted.length > 0) { + summary.push(`Pruned ${result.deleted.length} template(s): ${result.deleted.join(', ')}`); + } + console.error(summary.join('; ')); } main().catch((err: unknown) => { diff --git a/packages/post-kit-publisher/src/index.ts b/packages/post-kit-publisher/src/index.ts index 658fee7..cc9e566 100644 --- a/packages/post-kit-publisher/src/index.ts +++ b/packages/post-kit-publisher/src/index.ts @@ -5,4 +5,7 @@ export { assertSafeTemplateKey, assertSafeStorageAccount, blobBasePath, + templatesPrefix, + isScopedTemplateBlob, + templateKeyFromBlobPath, } from './path-safety'; diff --git a/packages/post-kit-publisher/src/path-safety.ts b/packages/post-kit-publisher/src/path-safety.ts index 183c49b..55b7d54 100644 --- a/packages/post-kit-publisher/src/path-safety.ts +++ b/packages/post-kit-publisher/src/path-safety.ts @@ -51,5 +51,40 @@ export function blobBasePath( assertSafeTenantId(tenant); assertSafeEnvironment(environment); assertSafeTemplateKey(templateKey); - return `tenants/${tenant}/${environment}/templates/${templateKey}`; + return `${templatesPrefix(tenant, environment)}/${templateKey}`; +} + +/** Blob prefix for all templates of a tenant/environment (no trailing slash). */ +export function templatesPrefix(tenant: string, environment: TenantEnvironment): string { + assertSafeTenantId(tenant); + assertSafeEnvironment(environment); + return `tenants/${tenant}/${environment}/templates`; +} + +/** True when `blobPath` is a blob under `templatesPrefix` for a single template key. */ +export function isScopedTemplateBlob(blobPath: string, prefix: string): boolean { + if (!blobPath.startsWith(`${prefix}/`)) { + return false; + } + const rest = blobPath.slice(prefix.length + 1); + const slash = rest.indexOf('/'); + if (slash === -1) { + return false; + } + const key = rest.slice(0, slash); + try { + assertSafeTemplateKey(key); + return true; + } catch { + return false; + } +} + +/** Extract the template key from a blob under `templatesPrefix`, or undefined if out of scope. */ +export function templateKeyFromBlobPath(blobPath: string, prefix: string): string | undefined { + if (!isScopedTemplateBlob(blobPath, prefix)) { + return undefined; + } + const rest = blobPath.slice(prefix.length + 1); + return rest.slice(0, rest.indexOf('/')); } diff --git a/packages/post-kit-publisher/src/publish.spec.ts b/packages/post-kit-publisher/src/publish.spec.ts index ae3b653..eeec227 100644 --- a/packages/post-kit-publisher/src/publish.spec.ts +++ b/packages/post-kit-publisher/src/publish.spec.ts @@ -10,6 +10,9 @@ import { assertSafeTenantId, assertSafeTemplateKey, blobBasePath, + templatesPrefix, + isScopedTemplateBlob, + templateKeyFromBlobPath, } from './path-safety'; import { publishTemplatesWithClient } from './publish'; @@ -48,6 +51,42 @@ describe('path safety', () => { 'tenants/inkads/production/templates/marketing.contact-us', ); }); + + it('builds the templates prefix for listing and prune scope', () => { + assert.equal(templatesPrefix('inkads', 'production'), 'tenants/inkads/production/templates'); + }); + + it('scopes template blobs under the templates prefix', () => { + const prefix = templatesPrefix('inkads', 'production'); + assert.equal( + isScopedTemplateBlob( + 'tenants/inkads/production/templates/marketing.contact-us/template.html', + prefix, + ), + true, + ); + assert.equal( + isScopedTemplateBlob( + 'tenants/inkads/production/other/marketing.contact-us/template.html', + prefix, + ), + false, + ); + assert.equal( + isScopedTemplateBlob( + 'tenants/other/production/templates/marketing.contact-us/template.html', + prefix, + ), + false, + ); + assert.equal( + templateKeyFromBlobPath( + 'tenants/inkads/production/templates/marketing.contact-us/metadata.json', + prefix, + ), + 'marketing.contact-us', + ); + }); }); describe('publishTemplates', () => { @@ -147,20 +186,289 @@ describe('publishTemplates', () => { )!; assert.ok(html.includes(' 0); }); + + it('does not prune by default when storage has extra template keys', 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 prefix = 'tenants/inkads/production/templates'; + const blobs = new Map([ + [`${prefix}/marketing.contact-us/template.html`, ''], + [`${prefix}/marketing.contact-us/metadata.json`, '{}'], + [`${prefix}/retired.welcome/template.html`, 'old'], + [`${prefix}/retired.welcome/metadata.json`, '{}'], + ]); + const deleted: string[] = []; + const client = makeFakeClient({ + blobs, + onUpload: () => {}, + onDelete: (path) => deleted.push(path), + }); + + const result = await publishTemplatesWithClient( + { + templatesDir: root, + tenant: 'inkads', + environment: 'production', + storageAccount: 'ssdpostkitstprodae', + container: 'templates', + }, + client, + ); + + assert.deepEqual(result.published, ['marketing.contact-us']); + assert.deepEqual(result.deleted, []); + assert.equal(deleted.length, 0); + assert.ok(blobs.has(`${prefix}/retired.welcome/template.html`)); + }); + + it('prunes blobs for keys absent from the compiled set', 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 prefix = 'tenants/inkads/production/templates'; + const blobs = new Map([ + [`${prefix}/marketing.contact-us/template.html`, ''], + [`${prefix}/marketing.contact-us/metadata.json`, '{}'], + [`${prefix}/retired.welcome/template.html`, 'old'], + [`${prefix}/retired.welcome/metadata.json`, '{}'], + ]); + const deleted: string[] = []; + const client = makeFakeClient({ + blobs, + onUpload: () => {}, + onDelete: (path) => deleted.push(path), + }); + + const result = await publishTemplatesWithClient( + { + templatesDir: root, + tenant: 'inkads', + environment: 'production', + storageAccount: 'ssdpostkitstprodae', + container: 'templates', + prune: true, + }, + client, + ); + + assert.deepEqual(result.published, ['marketing.contact-us']); + assert.deepEqual(result.deleted, ['retired.welcome']); + assert.ok(deleted.includes(`${prefix}/retired.welcome/template.html`)); + assert.ok(deleted.includes(`${prefix}/retired.welcome/metadata.json`)); + assert.ok(!blobs.has(`${prefix}/retired.welcome/template.html`)); + }); + + it('does not prune blobs outside the tenant/environment templates prefix', 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 prefix = 'tenants/inkads/production/templates'; + const blobs = new Map([ + [`${prefix}/marketing.contact-us/template.html`, ''], + [`${prefix}/marketing.contact-us/metadata.json`, '{}'], + ['tenants/other/production/templates/retired.welcome/template.html', 'other'], + ['tenants/inkads/staging/templates/retired.welcome/template.html', 'staging'], + ['tenants/inkads/production/other/retired.welcome/template.html', 'wrong'], + ]); + const deleted: string[] = []; + const client = makeFakeClient({ + blobs, + onUpload: () => {}, + onDelete: (path) => deleted.push(path), + }); + + await publishTemplatesWithClient( + { + templatesDir: root, + tenant: 'inkads', + environment: 'production', + storageAccount: 'ssdpostkitstprodae', + container: 'templates', + prune: true, + }, + client, + ); + + assert.equal(deleted.length, 0); + assert.ok(blobs.has('tenants/other/production/templates/retired.welcome/template.html')); + assert.ok(blobs.has('tenants/inkads/staging/templates/retired.welcome/template.html')); + assert.ok(blobs.has('tenants/inkads/production/other/retired.welcome/template.html')); + }); + + it('dry-run reports adds, updates, and deletions without writes or deletes', 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 prefix = 'tenants/inkads/production/templates'; + const blobs = new Map([ + [`${prefix}/marketing.contact-us/template.html`, 'old'], + [`${prefix}/marketing.contact-us/metadata.json`, '{}'], + [`${prefix}/retired.welcome/template.html`, 'gone'], + [`${prefix}/retired.welcome/metadata.json`, '{}'], + ]); + let uploads = 0; + const deleted: string[] = []; + const client = makeFakeClient({ + blobs, + onUpload: () => { + uploads += 1; + }, + onDelete: (path) => deleted.push(path), + }); + + const result = await publishTemplatesWithClient( + { + templatesDir: root, + tenant: 'inkads', + environment: 'production', + storageAccount: 'ssdpostkitstprodae', + container: 'templates', + dryRun: true, + prune: true, + }, + client, + ); + + assert.equal(result.published.length, 0); + assert.deepEqual(result.added, []); + assert.deepEqual(result.updated, ['marketing.contact-us']); + assert.deepEqual(result.deleted, ['retired.welcome']); + assert.equal(uploads, 0); + assert.equal(deleted.length, 0); + assert.ok(blobs.has(`${prefix}/retired.welcome/template.html`)); + }); + + it('dry-run reports a new template as an add', 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 blobs = new Map(); + let uploads = 0; + const client = makeFakeClient({ + blobs, + onUpload: () => { + uploads += 1; + }, + onDelete: () => {}, + }); + + const result = await publishTemplatesWithClient( + { + templatesDir: root, + tenant: 'inkads', + environment: 'production', + storageAccount: 'ssdpostkitstprodae', + container: 'templates', + dryRun: true, + }, + client, + ); + + assert.deepEqual(result.added, ['marketing.contact-us']); + assert.deepEqual(result.updated, []); + assert.deepEqual(result.deleted, []); + assert.equal(uploads, 0); + }); + + it('does not prune when compilation fails', async () => { + const root = await mkdtemp(join(tmpdir(), 'post-kit-publish-')); + await cp(join(FIXTURES, 'malformed-metadata'), join(root, 'bad'), { recursive: true }); + await writeFile( + join(root, 'bad', 'template.json'), + JSON.stringify({ root: { type: 'EmailLayout', data: { childrenIds: [] } } }), + ); + await writeFile(join(root, 'bad', 'preview.json'), '{}'); + + const prefix = 'tenants/inkads/development/templates'; + const blobs = new Map([ + [`${prefix}/retired.welcome/template.html`, 'old'], + ]); + const deleted: string[] = []; + const client = makeFakeClient({ + blobs, + onUpload: () => {}, + onDelete: (path) => deleted.push(path), + }); + + const result = await publishTemplatesWithClient( + { + templatesDir: root, + tenant: 'inkads', + environment: 'development', + storageAccount: 'ssdpostkitstprodae', + container: 'templates', + prune: true, + }, + client, + ); + + assert.equal(result.published.length, 0); + assert.ok(result.failed.length >= 1); + assert.equal(deleted.length, 0); + }); }); -function makeFakeClient(onUpload: (path: string, body: string) => void): BlobServiceClient { +interface FakeClientOptions { + blobs?: Map; + onUpload: (path: string, body: string) => void; + onDelete?: (path: string) => void; +} + +function makeFakeClient( + options: FakeClientOptions | ((path: string, body: string) => void), +): BlobServiceClient { + const opts: FakeClientOptions = + typeof options === 'function' ? { onUpload: options, blobs: new Map() } : options; + const blobs = opts.blobs ?? new Map(); + const getBlockBlobClient = (blobPath: string): BlockBlobClient => ({ upload: async (body: string | Buffer) => { const text = typeof body === 'string' ? body : body.toString('utf8'); - onUpload(blobPath, text); + opts.onUpload(blobPath, text); + blobs.set(blobPath, text); return {}; }, + delete: async () => { + opts.onDelete?.(blobPath); + blobs.delete(blobPath); + return {}; + }, + deleteIfExists: async () => { + if (blobs.has(blobPath)) { + opts.onDelete?.(blobPath); + blobs.delete(blobPath); + return { succeeded: true }; + } + return { succeeded: false }; + }, }) as unknown as BlockBlobClient; const getContainerClient = (): ContainerClient => - ({ getBlockBlobClient }) as unknown as ContainerClient; + ({ + getBlockBlobClient, + listBlobsFlat: (listOptions?: { prefix?: string }) => ({ + async *[Symbol.asyncIterator]() { + const prefix = listOptions?.prefix ?? ''; + for (const name of [...blobs.keys()].sort()) { + if (name.startsWith(prefix)) { + yield { name }; + } + } + }, + }), + }) as unknown as ContainerClient; return { getContainerClient } as unknown as BlobServiceClient; } diff --git a/packages/post-kit-publisher/src/publish.ts b/packages/post-kit-publisher/src/publish.ts index e718bf4..3c27f26 100644 --- a/packages/post-kit-publisher/src/publish.ts +++ b/packages/post-kit-publisher/src/publish.ts @@ -10,6 +10,9 @@ import { assertSafeTenantId, assertSafeTemplateKey, blobBasePath, + isScopedTemplateBlob, + templateKeyFromBlobPath, + templatesPrefix, } from './path-safety'; export interface PublishOptions { @@ -21,11 +24,21 @@ export interface PublishOptions { container: string; /** Passed through to TemplateManifest.sourceCommit. */ commit?: string; + /** Report the change set without uploading or deleting. */ + dryRun?: boolean; + /** Delete blobs for template keys absent from the compiled set (opt-in). */ + prune?: boolean; } export interface PublishResult { published: string[]; failed: Array<{ key: string; error: string }>; + /** Template keys that would be or were created in storage. */ + added: string[]; + /** Template keys that would be or were overwritten in storage. */ + updated: string[]; + /** Template keys removed (or that would be removed with prune + dry-run). */ + deleted: string[]; } /** Internal batch row — not part of the public package API. */ @@ -37,7 +50,7 @@ interface CompiledEntry { /** * Compile every template under `templatesDir`, then upload artifacts. * - * Fail-fast for publishing: if any compile fails, nothing is uploaded. + * Fail-fast for publishing: if any compile fails, nothing is uploaded or pruned. * Storage auth always uses `DefaultAzureCredential` (Managed Identity / az login). */ export async function publishTemplates(options: PublishOptions): Promise { @@ -70,6 +83,14 @@ async function runPublish( options: PublishOptions, client: BlobServiceClient, ): Promise { + const emptyResult = (): PublishResult => ({ + published: [], + failed: [], + added: [], + updated: [], + deleted: [], + }); + const entries = await listTemplateDirs(options.templatesDir); const compiled: CompiledEntry[] = []; const failed: PublishResult['failed'] = []; @@ -98,10 +119,58 @@ async function runPublish( } if (failed.length > 0) { - return { published: [], failed }; + return { ...emptyResult(), failed }; } const containerClient = client.getContainerClient(options.container); + const prefix = templatesPrefix(options.tenant, options.environment); + const existingKeys = await listStoredTemplateKeys(containerClient, prefix); + const compiledKeys = new Set(compiled.map((e) => e.compiled.metadata.key)); + + const added: string[] = []; + const updated: string[] = []; + for (const entry of compiled) { + const key = entry.compiled.metadata.key; + if (existingKeys.has(key)) { + updated.push(key); + } else { + added.push(key); + } + } + + const keysToDelete = options.prune + ? [...existingKeys].filter((key) => !compiledKeys.has(key)).sort() + : []; + + if (options.dryRun) { + for (const entry of compiled) { + const key = entry.compiled.metadata.key; + const base = blobBasePath(options.tenant, options.environment, key); + const action = existingKeys.has(key) ? 'update' : 'add'; + console.log( + JSON.stringify({ + action, + key, + contentHash: entry.compiled.manifest.contentHash, + templateHtml: `${base}/template.html`, + metadataJson: `${base}/metadata.json`, + }), + ); + } + for (const key of keysToDelete) { + const base = blobBasePath(options.tenant, options.environment, key); + console.log( + JSON.stringify({ + action: 'delete', + key, + templateHtml: `${base}/template.html`, + metadataJson: `${base}/metadata.json`, + }), + ); + } + return { published: [], failed: [], added, updated, deleted: keysToDelete }; + } + const published: string[] = []; for (const entry of compiled) { @@ -133,7 +202,43 @@ async function runPublish( ); } - return { published, failed: [] }; + const deleted: string[] = []; + if (options.prune) { + for (const key of keysToDelete) { + const base = blobBasePath(options.tenant, options.environment, key); + for (const blobPath of [`${base}/template.html`, `${base}/metadata.json`]) { + if (!isScopedTemplateBlob(blobPath, prefix)) { + continue; + } + await containerClient.getBlockBlobClient(blobPath).deleteIfExists(); + } + deleted.push(key); + console.log( + JSON.stringify({ + action: 'delete', + key, + templateHtml: `${base}/template.html`, + metadataJson: `${base}/metadata.json`, + }), + ); + } + } + + return { published, failed: [], added, updated, deleted }; +} + +async function listStoredTemplateKeys( + containerClient: ReturnType, + prefix: string, +): Promise> { + const keys = new Set(); + for await (const blob of containerClient.listBlobsFlat({ prefix: `${prefix}/` })) { + const key = templateKeyFromBlobPath(blob.name, prefix); + if (key) { + keys.add(key); + } + } + return keys; } async function listTemplateDirs(templatesDir: string): Promise { From a89de81e65ec72573779504ec832f54a242cca85 Mon Sep 17 00:00:00 2001 From: Pato Perpetua Date: Sun, 30 Aug 2026 13:36:42 +1000 Subject: [PATCH 2/2] fix(publisher): skip blob listing unless dry-run or prune Addresses CodeRabbit review on #79: preserve write-only publish path, enumerate and delete all scoped blobs when pruning retired keys. Co-authored-by: Cursor --- .../post-kit-publisher/src/publish.spec.ts | 51 ++++++++++++++++++- packages/post-kit-publisher/src/publish.ts | 45 +++++++++++----- 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/packages/post-kit-publisher/src/publish.spec.ts b/packages/post-kit-publisher/src/publish.spec.ts index eeec227..473cec3 100644 --- a/packages/post-kit-publisher/src/publish.spec.ts +++ b/packages/post-kit-publisher/src/publish.spec.ts @@ -153,8 +153,14 @@ describe('publishTemplates', () => { }); const uploaded = new Map(); - const client = makeFakeClient((path, body) => { - uploaded.set(path, body); + let listCalls = 0; + const client = makeFakeClient({ + onUpload: (path, body) => { + uploaded.set(path, body); + }, + onList: () => { + listCalls += 1; + }, }); const result = await publishTemplatesWithClient( @@ -185,6 +191,45 @@ describe('publishTemplates', () => { 'tenants/inkads/production/templates/marketing.contact-us/template.html', )!; assert.ok(html.includes(' 0); + assert.equal(listCalls, 0); + }); + + it('prunes all scoped blobs for a retired template key, including stale files', 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 prefix = 'tenants/inkads/production/templates'; + const blobs = new Map([ + [`${prefix}/marketing.contact-us/template.html`, ''], + [`${prefix}/marketing.contact-us/metadata.json`, '{}'], + [`${prefix}/retired.welcome/template.html`, 'old'], + [`${prefix}/retired.welcome/metadata.json`, '{}'], + [`${prefix}/retired.welcome/preview.json`, '{}'], + ]); + const deleted: string[] = []; + const client = makeFakeClient({ + blobs, + onUpload: () => {}, + onDelete: (path) => deleted.push(path), + }); + + const result = await publishTemplatesWithClient( + { + templatesDir: root, + tenant: 'inkads', + environment: 'production', + storageAccount: 'ssdpostkitstprodae', + container: 'templates', + prune: true, + }, + client, + ); + + assert.deepEqual(result.deleted, ['retired.welcome']); + assert.ok(deleted.includes(`${prefix}/retired.welcome/preview.json`)); + assert.ok(!blobs.has(`${prefix}/retired.welcome/preview.json`)); }); it('does not prune by default when storage has extra template keys', async () => { @@ -423,6 +468,7 @@ interface FakeClientOptions { blobs?: Map; onUpload: (path: string, body: string) => void; onDelete?: (path: string) => void; + onList?: () => void; } function makeFakeClient( @@ -460,6 +506,7 @@ function makeFakeClient( getBlockBlobClient, listBlobsFlat: (listOptions?: { prefix?: string }) => ({ async *[Symbol.asyncIterator]() { + opts.onList?.(); const prefix = listOptions?.prefix ?? ''; for (const name of [...blobs.keys()].sort()) { if (name.startsWith(prefix)) { diff --git a/packages/post-kit-publisher/src/publish.ts b/packages/post-kit-publisher/src/publish.ts index 3c27f26..b7fbb2b 100644 --- a/packages/post-kit-publisher/src/publish.ts +++ b/packages/post-kit-publisher/src/publish.ts @@ -124,7 +124,10 @@ async function runPublish( const containerClient = client.getContainerClient(options.container); const prefix = templatesPrefix(options.tenant, options.environment); - const existingKeys = await listStoredTemplateKeys(containerClient, prefix); + const needsStorageListing = Boolean(options.dryRun || options.prune); + const existingKeys = needsStorageListing + ? await listStoredTemplateKeys(containerClient, prefix) + : new Set(); const compiledKeys = new Set(compiled.map((e) => e.compiled.metadata.key)); const added: string[] = []; @@ -206,27 +209,41 @@ async function runPublish( if (options.prune) { for (const key of keysToDelete) { const base = blobBasePath(options.tenant, options.environment, key); - for (const blobPath of [`${base}/template.html`, `${base}/metadata.json`]) { - if (!isScopedTemplateBlob(blobPath, prefix)) { - continue; - } + const blobPaths = await listScopedBlobsForTemplateKey(containerClient, prefix, base); + for (const blobPath of blobPaths) { await containerClient.getBlockBlobClient(blobPath).deleteIfExists(); } - deleted.push(key); - console.log( - JSON.stringify({ - action: 'delete', - key, - templateHtml: `${base}/template.html`, - metadataJson: `${base}/metadata.json`, - }), - ); + if (blobPaths.length > 0) { + deleted.push(key); + console.log( + JSON.stringify({ + action: 'delete', + key, + blobs: blobPaths, + }), + ); + } } } return { published, failed: [], added, updated, deleted }; } +async function listScopedBlobsForTemplateKey( + containerClient: ReturnType, + prefix: string, + templateBasePath: string, +): Promise { + const blobs: string[] = []; + for await (const blob of containerClient.listBlobsFlat({ prefix: `${templateBasePath}/` })) { + if (isScopedTemplateBlob(blob.name, prefix)) { + blobs.push(blob.name); + } + } + blobs.sort(); + return blobs; +} + async function listStoredTemplateKeys( containerClient: ReturnType, prefix: string,