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
11 changes: 5 additions & 6 deletions docs/guides/template-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
35 changes: 29 additions & 6 deletions docs/guides/template-publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ content/email-templates/<key>/ (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
Expand Down Expand Up @@ -85,12 +86,11 @@ Every flag the CLI implements:
| `--storage-account <name>` | yes | Azure Storage account **name** (not a URL, not a connection string). Must match `/^[a-z0-9]{3,24}$/`. The endpoint `https://<name>.blob.core.windows.net` is derived from it. |
| `--container <name>` | yes | Blob container holding the `tenants/…` prefix. |
| `--commit <sha>` | 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
Expand All @@ -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
Expand All @@ -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 <storage-account-name> --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
Expand Down
26 changes: 24 additions & 2 deletions packages/post-kit-publisher/src/bin/post-kit-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ function usage(): never {
--environment <development|staging|production> \\
--storage-account <name> \\
--container <name> \\
[--commit <sha>]`);
[--commit <sha>] \\
[--dry-run] \\
[--prune]`);
process.exit(2);
}

Expand All @@ -30,6 +32,8 @@ async function main(): Promise<void> {
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();
Expand All @@ -42,6 +46,8 @@ async function main(): Promise<void> {
storageAccount,
container,
commit,
dryRun,
prune,
});

if (result.failed.length > 0) {
Expand All @@ -51,7 +57,23 @@ async function main(): Promise<void> {
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) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/post-kit-publisher/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@ export {
assertSafeTemplateKey,
assertSafeStorageAccount,
blobBasePath,
templatesPrefix,
isScopedTemplateBlob,
templateKeyFromBlobPath,
} from './path-safety';
37 changes: 36 additions & 1 deletion packages/post-kit-publisher/src/path-safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('/'));
}
Loading
Loading