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 e2837a3..f70d458 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 + manifest.json + │ optional: --prune retired keys ▼ Azure Blob Storage tenants/{tenant}/{environment}/templates/{key}/template.html @@ -85,12 +86,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 @@ -104,9 +104,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 @@ -116,6 +117,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 3d3044f..a41a753 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', () => { @@ -114,8 +153,14 @@ describe('publishTemplates', () => { }); const uploaded = new Map(); - const client = makeFakeClient((path, body, headers) => { - uploaded.set(path, { body, contentType: headers?.blobContentType }); + let listCalls = 0; + const client = makeFakeClient({ + onUpload: (path, body, headers) => { + uploaded.set(path, { body, contentType: headers?.blobContentType }); + }, + onList: () => { + listCalls += 1; + }, }); const logs: string[] = []; @@ -139,6 +184,7 @@ describe('publishTemplates', () => { assert.deepEqual(result.published, ['marketing.contact-us']); assert.equal(result.failed.length, 0); + assert.equal(listCalls, 0); const base = 'tenants/inkads/production/templates/marketing.contact-us'; assert.ok(uploaded.has(`${base}/template.html`)); @@ -172,6 +218,275 @@ describe('publishTemplates', () => { console.log = originalLog; } }); + + 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 () => { + 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); + }); }); interface UploadedBlob { @@ -179,9 +494,22 @@ interface UploadedBlob { contentType?: string; } +interface FakeClientOptions { + blobs?: Map; + onUpload: (path: string, body: string, headers?: { blobContentType?: string }) => void; + onDelete?: (path: string) => void; + onList?: () => void; +} + function makeFakeClient( - onUpload: (path: string, body: string, headers?: { blobContentType?: string }) => void, + options: + | FakeClientOptions + | ((path: string, body: string, headers?: { blobContentType?: 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 ( @@ -190,13 +518,40 @@ function makeFakeClient( options?: { blobHTTPHeaders?: { blobContentType?: string } }, ) => { const text = typeof body === 'string' ? body : body.toString('utf8'); - onUpload(blobPath, text, options?.blobHTTPHeaders); + opts.onUpload(blobPath, text, options?.blobHTTPHeaders); + 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]() { + opts.onList?.(); + 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 9c61da5..295f093 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,61 @@ 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 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[] = []; + 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) { @@ -140,7 +212,57 @@ 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); + const blobPaths = await listScopedBlobsForTemplateKey(containerClient, prefix, base); + for (const blobPath of blobPaths) { + await containerClient.getBlockBlobClient(blobPath).deleteIfExists(); + } + 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, +): 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 {