From 9a844eea2214a2db8bf1336ac72cfced14fc0dd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 10:31:07 +0000 Subject: [PATCH 01/24] fix(ci): Pass --tag flag to changeset publish for pre-releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish workflow was running `pnpm changeset publish` without a --tag flag for alpha and beta branches. This meant npm dist-tags were not updated correctly — the alpha tag stayed stuck on the first published version instead of advancing with each new pre-release. Derive the dist-tag from the branch name and pass it explicitly so `npm publish --tag alpha` or `--tag beta` is used. Co-Authored-By: Claude https://claude.ai/code/session_015H8yNLg63q5Pb8YQxP75hF --- .github/workflows/publish.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2796773..75b9ef4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -54,8 +54,19 @@ jobs: NPM_CONFIG_PROVENANCE: true # Pre-releases: publish directly with changeset pre mode + # --tag ensures the correct npm dist-tag is updated (alpha or beta) + - name: Determine pre-release dist-tag + if: github.ref != 'refs/heads/main' + id: dist-tag + run: | + if [[ "${{ github.ref }}" == refs/heads/alpha/* ]]; then + echo "tag=alpha" >> "$GITHUB_OUTPUT" + elif [[ "${{ github.ref }}" == refs/heads/beta/* ]]; then + echo "tag=beta" >> "$GITHUB_OUTPUT" + fi + - name: Publish pre-release if: github.ref != 'refs/heads/main' - run: pnpm changeset publish + run: pnpm changeset publish --tag ${{ steps.dist-tag.outputs.tag }} env: NPM_CONFIG_PROVENANCE: true From 55d915e520021726d5dc865f65f58ce2d79bf2e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 10:34:40 +0000 Subject: [PATCH 02/24] chore: Add changeset for publish workflow fix https://claude.ai/code/session_015H8yNLg63q5Pb8YQxP75hF --- .changeset/fix-npm-dist-alpha.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-npm-dist-alpha.md diff --git a/.changeset/fix-npm-dist-alpha.md b/.changeset/fix-npm-dist-alpha.md new file mode 100644 index 0000000..00f3a53 --- /dev/null +++ b/.changeset/fix-npm-dist-alpha.md @@ -0,0 +1,5 @@ +--- +"@nxsflow/amplify-overtone": patch +--- + +Fix npm alpha dist-tag not advancing on pre-release publishes From 0d3b267388015ce26e930e4b7fd5b2d197be7c26 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 10:48:14 +0000 Subject: [PATCH 03/24] fix(ci): Don't pass --tag when changeset pre mode is active Changeset pre mode already manages the npm dist-tag via pre.json. Passing --tag explicitly conflicts with it, causing the error: "Releasing under custom tag is not allowed in pre mode". Only pass --tag as a fallback when pre mode is not active. Co-Authored-By: Claude https://claude.ai/code/session_015H8yNLg63q5Pb8YQxP75hF --- .github/workflows/publish.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 75b9ef4..fa7dc68 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -53,20 +53,20 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_CONFIG_PROVENANCE: true - # Pre-releases: publish directly with changeset pre mode - # --tag ensures the correct npm dist-tag is updated (alpha or beta) - - name: Determine pre-release dist-tag + # Pre-releases: changeset pre mode (pre.json) handles the dist-tag + # automatically. Only pass --tag as a fallback when pre mode is not active. + - name: Publish pre-release if: github.ref != 'refs/heads/main' - id: dist-tag run: | - if [[ "${{ github.ref }}" == refs/heads/alpha/* ]]; then - echo "tag=alpha" >> "$GITHUB_OUTPUT" - elif [[ "${{ github.ref }}" == refs/heads/beta/* ]]; then - echo "tag=beta" >> "$GITHUB_OUTPUT" + if [ -f .changeset/pre.json ]; then + pnpm changeset publish + else + if [[ "${{ github.ref }}" == refs/heads/alpha/* ]]; then + TAG=alpha + elif [[ "${{ github.ref }}" == refs/heads/beta/* ]]; then + TAG=beta + fi + pnpm changeset publish --tag "$TAG" fi - - - name: Publish pre-release - if: github.ref != 'refs/heads/main' - run: pnpm changeset publish --tag ${{ steps.dist-tag.outputs.tag }} env: NPM_CONFIG_PROVENANCE: true From b87e8c1eaa47cb3286ac36044f8d0168ee1bcbc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 10:51:01 +0000 Subject: [PATCH 04/24] fix(ci): Validate pre-release mode instead of passing --tag Enforce invariants rather than working around missing state: - main: fail if pre.json exists (must exit pre-release mode first) - alpha/beta branches: fail if pre.json is missing (must enter pre mode) Changeset pre mode handles the dist-tag internally, so the publish step no longer needs to pass --tag explicitly. Co-Authored-By: Claude https://claude.ai/code/session_015H8yNLg63q5Pb8YQxP75hF --- .github/workflows/publish.yml | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fa7dc68..a504650 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,6 +43,21 @@ jobs: if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/beta/') run: pnpm lint + # Guard: pre.json must NOT exist on main, MUST exist on pre-release branches + - name: Validate pre-release mode + run: | + if [[ "${{ github.ref }}" == refs/heads/main ]]; then + if [ -f .changeset/pre.json ]; then + echo "::error::pre.json must not exist on main — exit pre-release mode before merging" + exit 1 + fi + else + if [ ! -f .changeset/pre.json ]; then + echo "::error::pre.json is missing — run 'changeset pre enter alpha|beta' before publishing" + exit 1 + fi + fi + # Stable releases: changesets/action creates Version PR or publishes - name: Create Release PR or Publish (stable) if: github.ref == 'refs/heads/main' @@ -54,19 +69,8 @@ jobs: NPM_CONFIG_PROVENANCE: true # Pre-releases: changeset pre mode (pre.json) handles the dist-tag - # automatically. Only pass --tag as a fallback when pre mode is not active. - name: Publish pre-release if: github.ref != 'refs/heads/main' - run: | - if [ -f .changeset/pre.json ]; then - pnpm changeset publish - else - if [[ "${{ github.ref }}" == refs/heads/alpha/* ]]; then - TAG=alpha - elif [[ "${{ github.ref }}" == refs/heads/beta/* ]]; then - TAG=beta - fi - pnpm changeset publish --tag "$TAG" - fi + run: pnpm changeset publish env: NPM_CONFIG_PROVENANCE: true From ecc52fdfcb01b8f77b2e3d657928caadaeeb31e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 17:07:13 +0000 Subject: [PATCH 05/24] fix(amplify-overtone): Use addBackendOutputEntry for custom email outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appendToBackendOutputList stores entries under numeric keys ("0", "1"), producing amplify_outputs.json like {"0": "{\"custom\":{...}}"} instead of the expected {custom: {email: {...}}}. Since EmailFactory is a singleton, addBackendOutputEntry is the correct method — it sets the entry directly without numeric key wrapping. Also simplify the integration test validator to expect the clean structure instead of working around the numeric key format. Co-Authored-By: Claude https://claude.ai/code/session_015H8yNLg63q5Pb8YQxP75hF --- .changeset/fix-custom-outputs-structure.md | 5 +++++ .../amplify-overtone/src/email/factory.ts | 2 +- .../utilities/amplify_outputs_validator.ts | 19 ------------------- 3 files changed, 6 insertions(+), 20 deletions(-) create mode 100644 .changeset/fix-custom-outputs-structure.md diff --git a/.changeset/fix-custom-outputs-structure.md b/.changeset/fix-custom-outputs-structure.md new file mode 100644 index 0000000..f006c38 --- /dev/null +++ b/.changeset/fix-custom-outputs-structure.md @@ -0,0 +1,5 @@ +--- +"@nxsflow/amplify-overtone": patch +--- + +Fix amplify_outputs.json structure for custom email outputs diff --git a/packages/amplify-overtone/src/email/factory.ts b/packages/amplify-overtone/src/email/factory.ts index 2ede524..35fd184 100644 --- a/packages/amplify-overtone/src/email/factory.ts +++ b/packages/amplify-overtone/src/email/factory.ts @@ -36,7 +36,7 @@ export class EmailFactory implements ConstructFactory): EmailOutputs { - // Amplify serializes custom outputs as a JSON string under a numeric key. - // The structure is: { "0": "{\"custom\":{\"email\":{...}}}", ... } - // We need to find and parse that JSON string. let custom: Record | undefined; - // First, check if 'custom' exists at the top level (direct structure) if (outputs.custom && typeof outputs.custom === "object") { custom = outputs.custom as Record; - } else { - // Search numeric keys for a JSON string containing custom outputs - for (const value of Object.values(outputs)) { - if (typeof value === "string") { - try { - const parsed = JSON.parse(value) as Record; - if (parsed.custom && typeof parsed.custom === "object") { - custom = parsed.custom as Record; - break; - } - } catch { - // Not valid JSON, skip - } - } - } } assert.ok(custom, "amplify_outputs.json should contain custom email outputs"); From 2f3b8c8acbb561ab8e95c0ac7c8ebbb4286f9344 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 17:11:37 +0000 Subject: [PATCH 06/24] docs: Clarify pre-release versioning workflow in version-management skill Document that versioning happens locally on pre-release branches (CI only runs publish), pre.json is required on alpha/beta branches, and --tag must not be passed when in changeset pre mode. Co-Authored-By: Claude https://claude.ai/code/session_015H8yNLg63q5Pb8YQxP75hF --- .claude/skills/version-management/SKILL.md | 40 +++++++++++++++------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/.claude/skills/version-management/SKILL.md b/.claude/skills/version-management/SKILL.md index 0fc7de1..08eeff3 100644 --- a/.claude/skills/version-management/SKILL.md +++ b/.claude/skills/version-management/SKILL.md @@ -78,28 +78,34 @@ This triggers `pnpm changeset publish` via CI, which: ## Pre-release Channels -Three tiers with escalating quality gates: +Three tiers with escalating quality gates. + +**Key difference from stable:** On pre-release branches, versioning happens **locally** before pushing. CI only runs `changeset publish`. On main, `changesets/action` handles versioning via a "Version Packages" PR. ### Alpha — early iteration, breaking changes expected ```bash -# Create a branch for the pre-release work +# 1. Create a branch (must match alpha/**) git checkout -b alpha/my-feature -# Enter alpha pre-release mode +# 2. Enter alpha pre-release mode (creates .changeset/pre.json) pnpm changeset pre enter alpha -# Create changesets as normal +# 3. Create changesets as normal pnpm changeset -# Bump to pre-release version (e.g., 0.2.0-alpha.0) +# 4. Version locally — bumps package.json (e.g., 0.2.0-alpha.0) pnpm changeset version -# Publish with the alpha dist-tag -pnpm changeset publish --tag alpha +# 5. Commit everything (pre.json, changeset, version bump, changelog) +git add .changeset/ packages/ +git commit -m "chore(release): enter alpha, version 0.2.0-alpha.0" + +# 6. Push — CI runs `changeset publish` (dist-tag handled by pre mode) +git push -u origin alpha/my-feature ``` -Subsequent changes on this branch increment the pre-release number: `0.2.0-alpha.1`, `0.2.0-alpha.2`, etc. +Subsequent changes on this branch: add a changeset, run `changeset version` locally (increments to `alpha.1`, `alpha.2`, etc.), commit, and push. CI publishes automatically. ### Beta — feature-complete, needs validation @@ -109,7 +115,10 @@ git checkout -b beta/my-feature pnpm changeset pre enter beta pnpm changeset pnpm changeset version # 0.2.0-beta.0 -pnpm changeset publish --tag beta + +git add .changeset/ packages/ +git commit -m "chore(release): enter beta, version 0.2.0-beta.0" +git push -u origin beta/my-feature ``` ### RC (optional) — release candidate, final validation @@ -118,7 +127,10 @@ pnpm changeset publish --tag beta pnpm changeset pre enter rc pnpm changeset pnpm changeset version # 0.2.0-rc.0 -pnpm changeset publish --tag beta # still uses beta tag, or create an rc tag + +git add .changeset/ packages/ +git commit -m "chore(release): enter rc, version 0.2.0-rc.0" +git push -u origin beta/my-feature # still uses beta/** branch ``` ### Exiting Pre-release Mode @@ -139,7 +151,11 @@ git commit -m "chore(release): exit pre-release, bump to 0.2.0" Merging to main triggers the stable release via CI. -**Rule:** Pre-release mode (`changeset pre enter`) is only used on feature branches, never on main. +**Rules:** +- Pre-release mode (`changeset pre enter`) is only used on feature branches, never on main. +- `pre.json` **must** exist on `alpha/**` and `beta/**` branches — the publish workflow validates this. +- `pre.json` **must not** exist on `main` — the publish workflow rejects it. +- Never pass `--tag` to `changeset publish` when in pre mode — pre mode handles dist-tags automatically. --- @@ -153,7 +169,7 @@ Merging to main triggers the stable release via CI. Both `@nxsflow/amplify-overtone` and `@nxsflow/amplify-overtone-client` use the same dist-tag scheme. -**Critical:** Always publish pre-releases with an explicit `--tag` flag. Default `npm publish` / `pnpm changeset publish` sets the `latest` tag, which pushes a pre-release to all users. +**Critical:** Pre-releases must be published from branches with changeset pre mode active (`.changeset/pre.json`). Pre mode automatically sets the correct dist-tag. Never pass `--tag` manually when in pre mode — it causes an error. The publish workflow validates that `pre.json` exists on `alpha/**` and `beta/**` branches. ### Verify Dist-tags From b6ef127c7b63abfdc6e2168d2fdebaccb2e4684e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 21:15:36 +0000 Subject: [PATCH 07/24] feat(amplify-overtone): Make email identity creation idempotent Deployments fail when SES email identities already exist in the account/region (e.g. shared sandbox recipients or sender addresses created by another stack). Replace the CDK EmailIdentity L2 construct with an IdempotentEmailIdentity custom resource for email-address identities (Mode 1 senders and sandbox recipients). The custom resource calls SESv2 CreateEmailIdentity and treats AlreadyExistsException as success. On delete it calls DeleteEmailIdentity and ignores NotFoundException. Domain identities (Modes 2 & 3) keep the L2 construct because DKIM token intrinsics are needed for DNS record creation. Co-Authored-By: Claude https://claude.ai/code/session_015H8yNLg63q5Pb8YQxP75hF --- .changeset/idempotent-email-identity.md | 5 ++ .../amplify-overtone/src/email/construct.ts | 9 +-- .../src/email/idempotent-email-identity.ts | 62 +++++++++++++++++++ .../test/construct/email/ses.test.ts | 10 +-- .../test/construct/email/warnings.test.ts | 37 ++++++----- 5 files changed, 98 insertions(+), 25 deletions(-) create mode 100644 .changeset/idempotent-email-identity.md create mode 100644 packages/amplify-overtone/src/email/idempotent-email-identity.ts diff --git a/.changeset/idempotent-email-identity.md b/.changeset/idempotent-email-identity.md new file mode 100644 index 0000000..9bafb69 --- /dev/null +++ b/.changeset/idempotent-email-identity.md @@ -0,0 +1,5 @@ +--- +"@nxsflow/amplify-overtone": minor +--- + +Make email address identity creation idempotent to prevent deployment failures when SES identities already exist diff --git a/packages/amplify-overtone/src/email/construct.ts b/packages/amplify-overtone/src/email/construct.ts index cee1717..1948fa6 100644 --- a/packages/amplify-overtone/src/email/construct.ts +++ b/packages/amplify-overtone/src/email/construct.ts @@ -27,6 +27,7 @@ import { PhysicalResourceId, } from "aws-cdk-lib/custom-resources"; import { Construct } from "constructs"; +import { IdempotentEmailIdentity } from "./idempotent-email-identity.js"; import type { EmailProps, EmailResources, SenderWithEmail, SenderWithPrefix } from "./types.js"; // Find the package root by walking up from this file's directory until we find package.json. @@ -204,8 +205,8 @@ export class AmplifyEmail extends Construct { const senderEmails = Object.values(normalizedSenders).map((s) => s.email); for (const [key, sender] of Object.entries(normalizedSenders)) { - new EmailIdentity(this, `SenderIdentity-${key}`, { - identity: Identity.email(sender.email), + new IdempotentEmailIdentity(this, `SenderIdentity-${key}`, { + email: sender.email, }); } @@ -254,8 +255,8 @@ export class AmplifyEmail extends Construct { if (sandboxRecipients.length > 0) { for (const [i, email] of sandboxRecipients.entries()) { - new EmailIdentity(this, `SandboxRecipient${i}`, { - identity: Identity.email(email), + new IdempotentEmailIdentity(this, `SandboxRecipient${i}`, { + email, }); } diff --git a/packages/amplify-overtone/src/email/idempotent-email-identity.ts b/packages/amplify-overtone/src/email/idempotent-email-identity.ts new file mode 100644 index 0000000..92097d0 --- /dev/null +++ b/packages/amplify-overtone/src/email/idempotent-email-identity.ts @@ -0,0 +1,62 @@ +import { Stack } from "aws-cdk-lib"; +import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam"; +import { + AwsCustomResource, + AwsCustomResourcePolicy, + PhysicalResourceId, +} from "aws-cdk-lib/custom-resources"; +import { Construct } from "constructs"; + +/** + * Creates an SES email-address identity idempotently. + * + * Uses an AwsCustomResource that calls SESv2 CreateEmailIdentity and + * treats AlreadyExistsException as success. On delete the identity is + * removed only if this construct originally created it (tracked via the + * `createdByStack` attribute returned from onCreate). + * + * Use this instead of the CDK EmailIdentity L2 construct when the + * identity may already exist in the account/region (e.g. sandbox + * recipients or sender addresses shared across stacks). + */ +export class IdempotentEmailIdentity extends Construct { + public readonly email: string; + + constructor(scope: Construct, id: string, props: { email: string }) { + super(scope, id); + + this.email = props.email; + + const region = Stack.of(this).region; + const account = Stack.of(this).account; + const identityArn = `arn:aws:ses:${region}:${account}:identity/${props.email}`; + + new AwsCustomResource(this, "Identity", { + resourceType: "Custom::SesEmailIdentity", + onCreate: { + service: "SESV2", + action: "CreateEmailIdentity", + parameters: { + EmailIdentity: props.email, + }, + physicalResourceId: PhysicalResourceId.of(`ses-identity-${props.email}`), + ignoreErrorCodesMatching: "AlreadyExistsException", + }, + onDelete: { + service: "SESV2", + action: "DeleteEmailIdentity", + parameters: { + EmailIdentity: props.email, + }, + ignoreErrorCodesMatching: "NotFoundException", + }, + policy: AwsCustomResourcePolicy.fromStatements([ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["ses:CreateEmailIdentity", "ses:DeleteEmailIdentity"], + resources: [identityArn], + }), + ]), + }); + } +} diff --git a/packages/amplify-overtone/test/construct/email/ses.test.ts b/packages/amplify-overtone/test/construct/email/ses.test.ts index 3f11bca..a569dee 100644 --- a/packages/amplify-overtone/test/construct/email/ses.test.ts +++ b/packages/amplify-overtone/test/construct/email/ses.test.ts @@ -1,3 +1,4 @@ +import { Match } from "aws-cdk-lib/assertions"; import { describe, it } from "vitest"; import { createEmailTemplate, createNoDomainTemplate } from "./helpers.js"; @@ -26,11 +27,12 @@ describe("SES — Mode 3 (domain + Route 53)", () => { describe("SES — Mode 1 (no domain)", () => { const template = createNoDomainTemplate(); - it("creates an SES EmailIdentity for the sender address", () => { - template.hasResourceProperties("AWS::SES::EmailIdentity", { - EmailIdentity: "noreply@example.com", + it("creates an idempotent identity for the sender address", () => { + template.hasResourceProperties("Custom::SesEmailIdentity", { + Create: Match.stringLikeRegexp("noreply@example.com"), }); - template.resourceCountIs("AWS::SES::EmailIdentity", 1); + // No L2 EmailIdentity — only the domain modes use those + template.resourceCountIs("AWS::SES::EmailIdentity", 0); }); it("still creates a ConfigurationSet", () => { diff --git a/packages/amplify-overtone/test/construct/email/warnings.test.ts b/packages/amplify-overtone/test/construct/email/warnings.test.ts index 68777ca..2073c3d 100644 --- a/packages/amplify-overtone/test/construct/email/warnings.test.ts +++ b/packages/amplify-overtone/test/construct/email/warnings.test.ts @@ -28,18 +28,19 @@ describe("Sandbox recipients", () => { sandboxRecipients: ["dev@example.com", "qa@example.com"], }); - it("creates EmailIdentity for each sandbox recipient", () => { - template.hasResourceProperties("AWS::SES::EmailIdentity", { - EmailIdentity: "dev@example.com", + it("creates idempotent identity for each sandbox recipient", () => { + template.hasResourceProperties("Custom::SesEmailIdentity", { + Create: Match.stringLikeRegexp("dev@example.com"), }); - template.hasResourceProperties("AWS::SES::EmailIdentity", { - EmailIdentity: "qa@example.com", + template.hasResourceProperties("Custom::SesEmailIdentity", { + Create: Match.stringLikeRegexp("qa@example.com"), }); }); it("creates sender identity + 2 sandbox recipient identities", () => { - // 1 sender (noreply@example.com) + 2 sandbox recipients = 3 - template.resourceCountIs("AWS::SES::EmailIdentity", 3); + // 1 sender + 2 sandbox recipients = 3 custom resources, 0 L2 identities + template.resourceCountIs("Custom::SesEmailIdentity", 3); + template.resourceCountIs("AWS::SES::EmailIdentity", 0); }); }); @@ -50,10 +51,10 @@ describe("No domain — Mode 1", () => { annotations.hasWarning("*", Match.stringLikeRegexp("No custom domain configured")); }); - it("creates EmailIdentity for each sender address", () => { + it("creates idempotent identity for each sender address", () => { const template = createNoDomainTemplate(); - template.hasResourceProperties("AWS::SES::EmailIdentity", { - EmailIdentity: "noreply@example.com", + template.hasResourceProperties("Custom::SesEmailIdentity", { + Create: Match.stringLikeRegexp("noreply@example.com"), }); }); @@ -64,11 +65,11 @@ describe("No domain — Mode 1", () => { support: { senderEmail: "support@example.com", displayName: "Support" }, }, } as EmailProps); - template.hasResourceProperties("AWS::SES::EmailIdentity", { - EmailIdentity: "noreply@example.com", + template.hasResourceProperties("Custom::SesEmailIdentity", { + Create: Match.stringLikeRegexp("noreply@example.com"), }); - template.hasResourceProperties("AWS::SES::EmailIdentity", { - EmailIdentity: "support@example.com", + template.hasResourceProperties("Custom::SesEmailIdentity", { + Create: Match.stringLikeRegexp("support@example.com"), }); }); }); @@ -78,8 +79,10 @@ describe("Sandbox recipients — with domain", () => { sandboxRecipients: ["dev@example.com"], }); - it("creates domain identity + sandbox recipient identity", () => { - // 1 domain + 1 sandbox recipient = 2 - template.resourceCountIs("AWS::SES::EmailIdentity", 2); + it("creates domain identity (L2) + sandbox recipient (idempotent)", () => { + // Domain identity uses L2 construct (DKIM tokens needed) + template.resourceCountIs("AWS::SES::EmailIdentity", 1); + // Sandbox recipient uses idempotent custom resource + template.resourceCountIs("Custom::SesEmailIdentity", 1); }); }); From 511afa1b25c626c7413b0bfc8d3e2b1f073378e6 Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Mon, 6 Apr 2026 19:19:20 +0200 Subject: [PATCH 08/24] feat(amplify-overtone): Add Lambda handler for idempotent SES identity Add CloudFormation custom resource handler for managing SES email identities. The handler encodes whether an identity pre-existed in the physical resource ID, allowing the Delete handler to skip deletion of pre-existing identities. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../functions/idempotent-identity/handler.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 packages/amplify-overtone/src/email/functions/idempotent-identity/handler.ts diff --git a/packages/amplify-overtone/src/email/functions/idempotent-identity/handler.ts b/packages/amplify-overtone/src/email/functions/idempotent-identity/handler.ts new file mode 100644 index 0000000..c6de028 --- /dev/null +++ b/packages/amplify-overtone/src/email/functions/idempotent-identity/handler.ts @@ -0,0 +1,92 @@ +import { + AlreadyExistsException, + CreateEmailIdentityCommand, + DeleteEmailIdentityCommand, + NotFoundException, + SESv2Client, +} from "@aws-sdk/client-sesv2"; + +const ses = new SESv2Client({}); + +interface CfnEvent { + RequestType: "Create" | "Update" | "Delete"; + ResourceProperties: { Email: string; ServiceToken: string }; + OldResourceProperties?: { Email: string; ServiceToken: string }; + PhysicalResourceId?: string; +} + +interface CfnResponse { + PhysicalResourceId: string; + Data?: Record; +} + +function makePhysicalId(email: string, preExisted: boolean): string { + return `ses-identity:${email}:${preExisted ? "preexisted" : "created"}`; +} + +function parsePhysicalId(physicalId: string): { email: string; preExisted: boolean } { + const parts = physicalId.split(":"); + return { email: parts[1] ?? "", preExisted: parts[2] === "preexisted" }; +} + +export const handler = async (event: CfnEvent): Promise => { + const email = event.ResourceProperties.Email; + + switch (event.RequestType) { + case "Create": { + let preExisted = false; + try { + await ses.send(new CreateEmailIdentityCommand({ EmailIdentity: email })); + } catch (error) { + if (error instanceof AlreadyExistsException) { + preExisted = true; + } else { + throw error; + } + } + console.log(`Create: ${email}, preExisted=${preExisted}`); + return { PhysicalResourceId: makePhysicalId(email, preExisted) }; + } + + case "Update": { + const oldEmail = event.OldResourceProperties?.Email; + if (oldEmail !== email) { + // Email changed → new physical ID triggers replacement (Create new + Delete old) + let preExisted = false; + try { + await ses.send(new CreateEmailIdentityCommand({ EmailIdentity: email })); + } catch (error) { + if (error instanceof AlreadyExistsException) { + preExisted = true; + } else { + throw error; + } + } + console.log(`Update (replacement): ${email}, preExisted=${preExisted}`); + return { PhysicalResourceId: makePhysicalId(email, preExisted) }; + } + // Email unchanged → preserve physical ID (and its preExisted flag) + console.log(`Update (no-op): ${email}`); + return { PhysicalResourceId: event.PhysicalResourceId! }; + } + + case "Delete": { + const { email: identityEmail, preExisted } = parsePhysicalId(event.PhysicalResourceId!); + if (preExisted) { + console.log(`Skipping delete: ${identityEmail} pre-existed before stack creation`); + return { PhysicalResourceId: event.PhysicalResourceId! }; + } + try { + await ses.send(new DeleteEmailIdentityCommand({ EmailIdentity: identityEmail })); + console.log(`Deleted SES identity: ${identityEmail}`); + } catch (error) { + if (error instanceof NotFoundException) { + console.log(`Identity ${identityEmail} already deleted`); + } else { + throw error; + } + } + return { PhysicalResourceId: event.PhysicalResourceId! }; + } + } +}; From ab7ff88180dcd49265f7fb11cafd5161e55389df Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Mon, 6 Apr 2026 19:19:25 +0200 Subject: [PATCH 09/24] chore: Update biome schema to v2.4.10 Co-Authored-By: Claude Opus 4.6 (1M context) --- biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/biome.json b/biome.json index c358eeb..5d057d9 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.9/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "ignoreUnknown": true, From 245a1d7b5db555e3843d27a9ac600f3ffaa2cf8d Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Mon, 6 Apr 2026 19:27:15 +0200 Subject: [PATCH 10/24] feat(amplify-overtone): Replace AwsCustomResource with Lambda-backed CustomResource Tracks whether SES identities pre-existed and skips deletion on stack teardown if so. Adds explicit onUpdate handler for robustness. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/email/idempotent-email-identity.ts | 86 +++++++++++-------- .../test/construct/email/ses.test.ts | 3 +- .../test/construct/email/warnings.test.ts | 10 +-- 3 files changed, 58 insertions(+), 41 deletions(-) diff --git a/packages/amplify-overtone/src/email/idempotent-email-identity.ts b/packages/amplify-overtone/src/email/idempotent-email-identity.ts index 92097d0..74b3c01 100644 --- a/packages/amplify-overtone/src/email/idempotent-email-identity.ts +++ b/packages/amplify-overtone/src/email/idempotent-email-identity.ts @@ -1,19 +1,38 @@ -import { Stack } from "aws-cdk-lib"; +import { existsSync, readFileSync } from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { CustomResource, Stack } from "aws-cdk-lib"; import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam"; -import { - AwsCustomResource, - AwsCustomResourcePolicy, - PhysicalResourceId, -} from "aws-cdk-lib/custom-resources"; +import { Runtime } from "aws-cdk-lib/aws-lambda"; +import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; +import { Provider } from "aws-cdk-lib/custom-resources"; import { Construct } from "constructs"; +function findPackageRoot(startDir: string): string { + let dir = startDir; + while (dir !== path.dirname(dir)) { + const pkgPath = path.join(dir, "package.json"); + if (existsSync(pkgPath)) { + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + if (pkg.name === "@nxsflow/amplify-overtone") { + return dir; + } + } + dir = path.dirname(dir); + } + throw new Error("Could not find @nxsflow/amplify-overtone package root"); +} + +const PACKAGE_ROOT = findPackageRoot(path.dirname(fileURLToPath(import.meta.url))); +const HANDLER_DIR = path.join(PACKAGE_ROOT, "src", "email", "functions", "idempotent-identity"); + /** * Creates an SES email-address identity idempotently. * - * Uses an AwsCustomResource that calls SESv2 CreateEmailIdentity and - * treats AlreadyExistsException as success. On delete the identity is - * removed only if this construct originally created it (tracked via the - * `createdByStack` attribute returned from onCreate). + * Uses a Lambda-backed CustomResource that calls SESv2 CreateEmailIdentity + * and treats AlreadyExistsException as success, recording whether the + * identity pre-existed. On delete, the identity is removed only if this + * construct originally created it (tracked via the physical resource ID). * * Use this instead of the CDK EmailIdentity L2 construct when the * identity may already exist in the account/region (e.g. sandbox @@ -31,32 +50,31 @@ export class IdempotentEmailIdentity extends Construct { const account = Stack.of(this).account; const identityArn = `arn:aws:ses:${region}:${account}:identity/${props.email}`; - new AwsCustomResource(this, "Identity", { + const handler = new NodejsFunction(this, "Handler", { + entry: path.join(HANDLER_DIR, "handler.ts"), + handler: "handler", + runtime: Runtime.NODEJS_22_X, + bundling: { externalModules: ["@aws-sdk/*"] }, + }); + + handler.addToRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ["ses:CreateEmailIdentity", "ses:DeleteEmailIdentity"], + resources: [identityArn], + }), + ); + + const provider = new Provider(this, "Provider", { + onEventHandler: handler, + }); + + new CustomResource(this, "Identity", { + serviceToken: provider.serviceToken, resourceType: "Custom::SesEmailIdentity", - onCreate: { - service: "SESV2", - action: "CreateEmailIdentity", - parameters: { - EmailIdentity: props.email, - }, - physicalResourceId: PhysicalResourceId.of(`ses-identity-${props.email}`), - ignoreErrorCodesMatching: "AlreadyExistsException", - }, - onDelete: { - service: "SESV2", - action: "DeleteEmailIdentity", - parameters: { - EmailIdentity: props.email, - }, - ignoreErrorCodesMatching: "NotFoundException", + properties: { + Email: props.email, }, - policy: AwsCustomResourcePolicy.fromStatements([ - new PolicyStatement({ - effect: Effect.ALLOW, - actions: ["ses:CreateEmailIdentity", "ses:DeleteEmailIdentity"], - resources: [identityArn], - }), - ]), }); } } diff --git a/packages/amplify-overtone/test/construct/email/ses.test.ts b/packages/amplify-overtone/test/construct/email/ses.test.ts index a569dee..4bafe63 100644 --- a/packages/amplify-overtone/test/construct/email/ses.test.ts +++ b/packages/amplify-overtone/test/construct/email/ses.test.ts @@ -1,4 +1,3 @@ -import { Match } from "aws-cdk-lib/assertions"; import { describe, it } from "vitest"; import { createEmailTemplate, createNoDomainTemplate } from "./helpers.js"; @@ -29,7 +28,7 @@ describe("SES — Mode 1 (no domain)", () => { it("creates an idempotent identity for the sender address", () => { template.hasResourceProperties("Custom::SesEmailIdentity", { - Create: Match.stringLikeRegexp("noreply@example.com"), + Email: "noreply@example.com", }); // No L2 EmailIdentity — only the domain modes use those template.resourceCountIs("AWS::SES::EmailIdentity", 0); diff --git a/packages/amplify-overtone/test/construct/email/warnings.test.ts b/packages/amplify-overtone/test/construct/email/warnings.test.ts index 2073c3d..bb67ece 100644 --- a/packages/amplify-overtone/test/construct/email/warnings.test.ts +++ b/packages/amplify-overtone/test/construct/email/warnings.test.ts @@ -30,10 +30,10 @@ describe("Sandbox recipients", () => { it("creates idempotent identity for each sandbox recipient", () => { template.hasResourceProperties("Custom::SesEmailIdentity", { - Create: Match.stringLikeRegexp("dev@example.com"), + Email: "dev@example.com", }); template.hasResourceProperties("Custom::SesEmailIdentity", { - Create: Match.stringLikeRegexp("qa@example.com"), + Email: "qa@example.com", }); }); @@ -54,7 +54,7 @@ describe("No domain — Mode 1", () => { it("creates idempotent identity for each sender address", () => { const template = createNoDomainTemplate(); template.hasResourceProperties("Custom::SesEmailIdentity", { - Create: Match.stringLikeRegexp("noreply@example.com"), + Email: "noreply@example.com", }); }); @@ -66,10 +66,10 @@ describe("No domain — Mode 1", () => { }, } as EmailProps); template.hasResourceProperties("Custom::SesEmailIdentity", { - Create: Match.stringLikeRegexp("noreply@example.com"), + Email: "noreply@example.com", }); template.hasResourceProperties("Custom::SesEmailIdentity", { - Create: Match.stringLikeRegexp("support@example.com"), + Email: "support@example.com", }); }); }); From a409ea1acfc531be3b94bf46d514e2ee70895860 Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Tue, 7 Apr 2026 08:28:55 +0200 Subject: [PATCH 11/24] test(amplify-overtone): Add unit tests for idempotent identity handler Tests all lifecycle events (Create, Update, Delete) with aws-sdk-client-mock, including pre-existence detection, no-op updates, conditional deletion, and error propagation. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/amplify-overtone/package.json | 1 + .../email/idempotent-identity-handler.test.ts | 180 ++++++++++++++++++ pnpm-lock.yaml | 98 ++++++++++ 3 files changed, 279 insertions(+) create mode 100644 packages/amplify-overtone/test/unit/email/idempotent-identity-handler.test.ts diff --git a/packages/amplify-overtone/package.json b/packages/amplify-overtone/package.json index edcbaec..fd868cc 100644 --- a/packages/amplify-overtone/package.json +++ b/packages/amplify-overtone/package.json @@ -47,6 +47,7 @@ "@aws-sdk/client-sesv2": "^3.1019.0", "@types/node": "^22.0.0", "aws-cdk-lib": "^2.170.0", + "aws-sdk-client-mock": "^4.1.0", "constructs": "^10.4.2", "esbuild": "^0.27.0", "tsup": "^8.4.0", diff --git a/packages/amplify-overtone/test/unit/email/idempotent-identity-handler.test.ts b/packages/amplify-overtone/test/unit/email/idempotent-identity-handler.test.ts new file mode 100644 index 0000000..41131a8 --- /dev/null +++ b/packages/amplify-overtone/test/unit/email/idempotent-identity-handler.test.ts @@ -0,0 +1,180 @@ +import { + AlreadyExistsException, + CreateEmailIdentityCommand, + DeleteEmailIdentityCommand, + NotFoundException, + SESv2Client, +} from "@aws-sdk/client-sesv2"; +import { mockClient } from "aws-sdk-client-mock"; +import { afterEach, describe, expect, it } from "vitest"; +import { handler } from "../../../src/email/functions/idempotent-identity/handler.js"; + +const sesMock = mockClient(SESv2Client); + +afterEach(() => { + sesMock.reset(); +}); + +// --------------------------------------------------------------------------- +// Create +// --------------------------------------------------------------------------- + +describe("Create", () => { + it("creates a new identity and marks it as 'created'", async () => { + sesMock.on(CreateEmailIdentityCommand).resolves({}); + + const result = await handler({ + RequestType: "Create", + ResourceProperties: { Email: "new@example.com", ServiceToken: "" }, + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:new@example.com:created"); + expect(sesMock.commandCalls(CreateEmailIdentityCommand)).toHaveLength(1); + expect(sesMock.commandCalls(CreateEmailIdentityCommand)[0].args[0].input).toEqual({ + EmailIdentity: "new@example.com", + }); + }); + + it("marks a pre-existing identity as 'preexisted'", async () => { + sesMock + .on(CreateEmailIdentityCommand) + .rejects(new AlreadyExistsException({ message: "Already exists", $metadata: {} })); + + const result = await handler({ + RequestType: "Create", + ResourceProperties: { Email: "existing@example.com", ServiceToken: "" }, + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:existing@example.com:preexisted"); + }); + + it("propagates unexpected errors", async () => { + sesMock.on(CreateEmailIdentityCommand).rejects(new Error("Throttled")); + + await expect( + handler({ + RequestType: "Create", + ResourceProperties: { Email: "test@example.com", ServiceToken: "" }, + }), + ).rejects.toThrow("Throttled"); + }); +}); + +// --------------------------------------------------------------------------- +// Update +// --------------------------------------------------------------------------- + +describe("Update", () => { + it("returns the same physical ID when the email is unchanged", async () => { + const result = await handler({ + RequestType: "Update", + ResourceProperties: { Email: "same@example.com", ServiceToken: "" }, + OldResourceProperties: { Email: "same@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:same@example.com:created", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:same@example.com:created"); + expect(sesMock.commandCalls(CreateEmailIdentityCommand)).toHaveLength(0); + }); + + it("preserves 'preexisted' flag on no-op update", async () => { + const result = await handler({ + RequestType: "Update", + ResourceProperties: { Email: "same@example.com", ServiceToken: "" }, + OldResourceProperties: { Email: "same@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:same@example.com:preexisted", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:same@example.com:preexisted"); + }); + + it("creates a new identity and returns a new physical ID when the email changes", async () => { + sesMock.on(CreateEmailIdentityCommand).resolves({}); + + const result = await handler({ + RequestType: "Update", + ResourceProperties: { Email: "new@example.com", ServiceToken: "" }, + OldResourceProperties: { Email: "old@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:old@example.com:created", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:new@example.com:created"); + expect(sesMock.commandCalls(CreateEmailIdentityCommand)[0].args[0].input).toEqual({ + EmailIdentity: "new@example.com", + }); + }); + + it("detects pre-existing identity on email change", async () => { + sesMock + .on(CreateEmailIdentityCommand) + .rejects(new AlreadyExistsException({ message: "Already exists", $metadata: {} })); + + const result = await handler({ + RequestType: "Update", + ResourceProperties: { Email: "existing@example.com", ServiceToken: "" }, + OldResourceProperties: { Email: "old@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:old@example.com:created", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:existing@example.com:preexisted"); + }); +}); + +// --------------------------------------------------------------------------- +// Delete +// --------------------------------------------------------------------------- + +describe("Delete", () => { + it("deletes an identity that was created by this stack", async () => { + sesMock.on(DeleteEmailIdentityCommand).resolves({}); + + const result = await handler({ + RequestType: "Delete", + ResourceProperties: { Email: "test@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:test@example.com:created", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:test@example.com:created"); + expect(sesMock.commandCalls(DeleteEmailIdentityCommand)).toHaveLength(1); + expect(sesMock.commandCalls(DeleteEmailIdentityCommand)[0].args[0].input).toEqual({ + EmailIdentity: "test@example.com", + }); + }); + + it("skips deletion of a pre-existing identity", async () => { + const result = await handler({ + RequestType: "Delete", + ResourceProperties: { Email: "test@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:test@example.com:preexisted", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:test@example.com:preexisted"); + expect(sesMock.commandCalls(DeleteEmailIdentityCommand)).toHaveLength(0); + }); + + it("ignores NotFoundException when identity was already deleted", async () => { + sesMock + .on(DeleteEmailIdentityCommand) + .rejects(new NotFoundException({ message: "Not found", $metadata: {} })); + + const result = await handler({ + RequestType: "Delete", + ResourceProperties: { Email: "test@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:test@example.com:created", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:test@example.com:created"); + }); + + it("propagates unexpected errors on delete", async () => { + sesMock.on(DeleteEmailIdentityCommand).rejects(new Error("AccessDenied")); + + await expect( + handler({ + RequestType: "Delete", + ResourceProperties: { Email: "test@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:test@example.com:created", + }), + ).rejects.toThrow("AccessDenied"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bd37ad..73594d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: aws-cdk-lib: specifier: ^2.170.0 version: 2.245.0(constructs@10.6.0) + aws-sdk-client-mock: + specifier: ^4.1.0 + version: 4.1.0 constructs: specifier: ^10.4.2 version: 10.6.0 @@ -3278,6 +3281,18 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@11.2.2': + resolution: {integrity: sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==} + + '@sinonjs/fake-timers@15.3.0': + resolution: {integrity: sha512-m2xozxSfCIxjDdvbhIWazlP2i2aha/iUmbl94alpsIbd3iLTfeXgfBVbwyWogB6l++istyGZqamgA/EcqYf+Bg==} + + '@sinonjs/samsam@8.0.3': + resolution: {integrity: sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==} + '@smithy/abort-controller@4.2.12': resolution: {integrity: sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==} engines: {node: '>=18.0.0'} @@ -3680,6 +3695,12 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/sinon@17.0.4': + resolution: {integrity: sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==} + + '@types/sinonjs__fake-timers@15.0.1': + resolution: {integrity: sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -3879,6 +3900,9 @@ packages: engines: {node: '>= 18.0.0'} hasBin: true + aws-sdk-client-mock@4.1.0: + resolution: {integrity: sha512-h/tOYTkXEsAcV3//6C1/7U4ifSpKyJvb6auveAepqqNJl6TdZaPFEtKjBQNf8UxQdDP850knB2i/whq4zlsxJw==} + b4a@1.8.0: resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} peerDependencies: @@ -4360,6 +4384,10 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -5300,6 +5328,9 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + just-extend@6.2.0: + resolution: {integrity: sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -5808,6 +5839,9 @@ packages: sass: optional: true + nise@6.1.4: + resolution: {integrity: sha512-vSA4IpRHRWZkmotu61SvF45Jirq4CTLT3KKOWJPsPMtxtOBOlxcAlXfv/OrWxkzAJiCBrvdfWvGQjHT7r7+Qqg==} + no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} @@ -6482,6 +6516,9 @@ packages: signedsource@1.0.0: resolution: {integrity: sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww==} + sinon@18.0.1: + resolution: {integrity: sha512-a2N2TDY1uGviajJ6r4D1CyRAkzE9NNVlYOV1wX5xQDuAk0ONgzgRl0EjCQuRCPxOwp13ghsMwt9Gdldujs39qw==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -6811,6 +6848,14 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -11713,6 +11758,23 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@11.2.2': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@sinonjs/fake-timers@15.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@sinonjs/samsam@8.0.3': + dependencies: + '@sinonjs/commons': 3.0.1 + type-detect: 4.1.0 + '@smithy/abort-controller@4.2.12': dependencies: '@smithy/types': 4.13.1 @@ -12224,6 +12286,12 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/sinon@17.0.4': + dependencies: + '@types/sinonjs__fake-timers': 15.0.1 + + '@types/sinonjs__fake-timers@15.0.1': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -12444,6 +12512,12 @@ snapshots: aws-cdk@2.1114.1: {} + aws-sdk-client-mock@4.1.0: + dependencies: + '@types/sinon': 17.0.4 + sinon: 18.0.1 + tslib: 2.8.1 + b4a@1.8.0: {} babel-generator@6.26.1: @@ -12944,6 +13018,8 @@ snapshots: dependencies: dequal: 2.0.3 + diff@5.2.2: {} + diff@8.0.4: {} dir-glob@3.0.1: @@ -14079,6 +14155,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + just-extend@6.2.0: {} + kleur@4.1.5: {} knex@2.4.2(mysql2@3.9.9)(pg@8.11.6): @@ -14793,6 +14871,13 @@ snapshots: - '@babel/core' - babel-plugin-macros + nise@6.1.4: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 15.3.0 + just-extend: 6.2.0 + path-to-regexp: 8.4.1 + no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -15594,6 +15679,15 @@ snapshots: signedsource@1.0.0: {} + sinon@18.0.1: + dependencies: + '@sinonjs/commons': 3.0.1 + '@sinonjs/fake-timers': 11.2.2 + '@sinonjs/samsam': 8.0.3 + diff: 5.2.2 + nise: 6.1.4 + supports-color: 7.2.0 + slash@3.0.0: {} slice-ansi@4.0.0: @@ -15930,6 +16024,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-detect@4.0.8: {} + + type-detect@4.1.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 From c52a644f0e543d236c94a2c182d0718d607b71d0 Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Tue, 7 Apr 2026 08:30:29 +0200 Subject: [PATCH 12/24] fix(amplify-overtone): Rename custom output key to customEmailOutputs Avoids collision with other custom output entries under the AWS::Amplify::Custom backend output. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/amplify-overtone/src/email/factory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/amplify-overtone/src/email/factory.ts b/packages/amplify-overtone/src/email/factory.ts index 35fd184..ac6a0c0 100644 --- a/packages/amplify-overtone/src/email/factory.ts +++ b/packages/amplify-overtone/src/email/factory.ts @@ -39,7 +39,7 @@ export class EmailFactory implements ConstructFactory Date: Wed, 8 Apr 2026 08:53:44 +0200 Subject: [PATCH 13/24] fix(amplify-overtone): Add optional chaining to mock array access in tests TypeScript strict indexed access flags `commandCalls()[0]` as possibly undefined. Add optional chaining to satisfy the type checker. Co-Authored-By: Claude --- .../test/unit/email/idempotent-identity-handler.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/amplify-overtone/test/unit/email/idempotent-identity-handler.test.ts b/packages/amplify-overtone/test/unit/email/idempotent-identity-handler.test.ts index 41131a8..cf10976 100644 --- a/packages/amplify-overtone/test/unit/email/idempotent-identity-handler.test.ts +++ b/packages/amplify-overtone/test/unit/email/idempotent-identity-handler.test.ts @@ -30,7 +30,7 @@ describe("Create", () => { expect(result.PhysicalResourceId).toBe("ses-identity:new@example.com:created"); expect(sesMock.commandCalls(CreateEmailIdentityCommand)).toHaveLength(1); - expect(sesMock.commandCalls(CreateEmailIdentityCommand)[0].args[0].input).toEqual({ + expect(sesMock.commandCalls(CreateEmailIdentityCommand)[0]?.args[0].input).toEqual({ EmailIdentity: "new@example.com", }); }); @@ -99,7 +99,7 @@ describe("Update", () => { }); expect(result.PhysicalResourceId).toBe("ses-identity:new@example.com:created"); - expect(sesMock.commandCalls(CreateEmailIdentityCommand)[0].args[0].input).toEqual({ + expect(sesMock.commandCalls(CreateEmailIdentityCommand)[0]?.args[0].input).toEqual({ EmailIdentity: "new@example.com", }); }); @@ -136,7 +136,7 @@ describe("Delete", () => { expect(result.PhysicalResourceId).toBe("ses-identity:test@example.com:created"); expect(sesMock.commandCalls(DeleteEmailIdentityCommand)).toHaveLength(1); - expect(sesMock.commandCalls(DeleteEmailIdentityCommand)[0].args[0].input).toEqual({ + expect(sesMock.commandCalls(DeleteEmailIdentityCommand)[0]?.args[0].input).toEqual({ EmailIdentity: "test@example.com", }); }); From 75814bd8f8d2004d6115bf8f76db77f4f74b542b Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Wed, 8 Apr 2026 11:07:37 +0200 Subject: [PATCH 14/24] chore: add changeset for Lambda-backed CustomResource refactor Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/lambda-backed-custom-resource.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lambda-backed-custom-resource.md diff --git a/.changeset/lambda-backed-custom-resource.md b/.changeset/lambda-backed-custom-resource.md new file mode 100644 index 0000000..2ed7f12 --- /dev/null +++ b/.changeset/lambda-backed-custom-resource.md @@ -0,0 +1,5 @@ +--- +"@nxsflow/amplify-overtone": minor +--- + +Replace AwsCustomResource with Lambda-backed CustomResource for idempotent email identity, rename custom output key to customEmailOutputs, and add unit tests for the identity handler From b22c5ce7cfa0a44283981daa3631c81b27fcae6d Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Wed, 8 Apr 2026 11:08:35 +0200 Subject: [PATCH 15/24] chore: update Claude Code permission allowlist Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.claude/settings.json b/.claude/settings.json index 22df689..33e8325 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,9 @@ { "permissions": { "allow": [ + "Bash(biome check:*)", "Bash(cat:*)", + "Bash(curl -s http://localhost:3000)", "Bash(find:*)", "Bash(gh api:*)", "Bash(gh pr checks:*)", @@ -10,6 +12,7 @@ "Bash(gh repo:*)", "Bash(gh run view:*)", "Bash(gh search:*)", + "Bash(ls .changeset/*.md)", "Bash(npm view:*)", "Bash(npx biome:*)", "Bash(pnpm add:*)", @@ -24,9 +27,11 @@ "Bash(pnpm format)", "Bash(pnpm install:*)", "Bash(pnpm lint)", + "Bash(pnpm lint:*)", "Bash(pnpm list:*)", "Bash(pnpm overtone:build)", "Bash(pnpm overtone:test)", + "Bash(pnpm overtone:test:*)", "Bash(pnpm overtone:typecheck)", "Bash(pnpm test)", "Bash(pnpm test-infra:bootstrap)", @@ -35,10 +40,12 @@ "Bash(pnpm test-infra:destroy)", "Bash(pnpm test-infra:typecheck)", "Bash(pnpm typecheck:*)", + "Bash(pnpm --filter website build)", "Fetch", "mcp__aws-knowledge-mcp-server__*", "mcp__awslabs_aws-documentation-mcp-server__*", "mcp__playwright__*", + "Read(//Users/ckoch/.claude/**)", "Skill", "WebFetch", "WebSearch" From b8b0d6b2e12bd3be3295e8f48d469daebdb2c8d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 21:44:31 +0000 Subject: [PATCH 16/24] fix(amplify-overtone): Align email templates with brand guidelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update all 4 email templates to use the Overtone brand palette: - CTA buttons: #e8734a (orange) → #A78BFA (lavender accent) - Body text: #333 → #1C1C1C (graphite) - Muted text: #888 → #6B6B6B (secondary text) - Code block background: #f4f4f7 → #FFFFFF with #E5E5E0 border - Page background: #f4f4f7 → #FFFFFF - Footer/header border: #eee → #E5E5E0 - Font stack: add Merriweather Sans as primary Also fix plain text / HTML content parity: - confirmation-code: plain text now includes the intro sentence - password-reset: plain text now includes the full request paragraph - invite: plain text now includes the CTA instruction sentence - getting-started: plain text now includes the full second paragraph Co-Authored-By: Claude https://claude.ai/code/session_015H8yNLg63q5Pb8YQxP75hF --- .../templates/defaults/confirmation-code.ts | 12 ++++++++---- .../email/templates/defaults/getting-started.ts | 12 +++++++----- .../src/email/templates/defaults/invite.ts | 12 +++++++----- .../email/templates/defaults/password-reset.ts | 17 +++++++++++------ .../src/email/templates/renderer.ts | 10 +++++----- 5 files changed, 38 insertions(+), 25 deletions(-) diff --git a/packages/amplify-overtone/src/email/templates/defaults/confirmation-code.ts b/packages/amplify-overtone/src/email/templates/defaults/confirmation-code.ts index 896ea03..8ab5efe 100644 --- a/packages/amplify-overtone/src/email/templates/defaults/confirmation-code.ts +++ b/packages/amplify-overtone/src/email/templates/defaults/confirmation-code.ts @@ -9,12 +9,12 @@ export const confirmationCodeTemplate: TemplateDefinition = { } const expiryHtml = data.expiresInMinutes - ? `

This code expires in ${data.expiresInMinutes} minutes.

` + ? `

This code expires in ${data.expiresInMinutes} minutes.

` : ""; - return `

Use the confirmation code below to verify your identity.

+ return `

Use the confirmation code below to verify your identity.

- ${data.code} + ${data.code} ${expiryHtml}
`; }, @@ -24,7 +24,11 @@ export const confirmationCodeTemplate: TemplateDefinition = { throw new Error('Template "confirmation-code" requires data.code'); } - const parts = [`Your confirmation code is: ${data.code}`]; + const parts = [ + "Use the confirmation code below to verify your identity.", + "", + `Your confirmation code is: ${data.code}`, + ]; if (data.expiresInMinutes) { parts.push(`This code expires in ${data.expiresInMinutes} minutes.`); diff --git a/packages/amplify-overtone/src/email/templates/defaults/getting-started.ts b/packages/amplify-overtone/src/email/templates/defaults/getting-started.ts index a58ff5d..3472c47 100644 --- a/packages/amplify-overtone/src/email/templates/defaults/getting-started.ts +++ b/packages/amplify-overtone/src/email/templates/defaults/getting-started.ts @@ -5,20 +5,20 @@ export const gettingStartedTemplate: TemplateDefinition = { renderHtml(data: Record, brandName: string): string { const greeting = data.userName - ? `

Hi ${data.userName},

` + ? `

Hi ${data.userName},

` : ""; const brand = brandName || "the app"; const ctaHtml = data.dashboardLink ? `` : ""; return `${greeting} -

Welcome to ${brand}! We're excited to have you on board.

-

You're all set and ready to get started. Explore the features and let us know if you have any questions.

+

Welcome to ${brand}! We're excited to have you on board.

+

You're all set and ready to get started. Explore the features and let us know if you have any questions.

${ctaHtml}`; }, @@ -32,7 +32,9 @@ ${ctaHtml}`; } parts.push(`Welcome to ${brand}! We're excited to have you on board.`); - parts.push("You're all set and ready to get started."); + parts.push( + "You're all set and ready to get started. Explore the features and let us know if you have any questions.", + ); if (data.dashboardLink) { parts.push(""); diff --git a/packages/amplify-overtone/src/email/templates/defaults/invite.ts b/packages/amplify-overtone/src/email/templates/defaults/invite.ts index 93a06e9..65ae83c 100644 --- a/packages/amplify-overtone/src/email/templates/defaults/invite.ts +++ b/packages/amplify-overtone/src/email/templates/defaults/invite.ts @@ -13,10 +13,10 @@ export const inviteTemplate: TemplateDefinition = { const resourceText = data.resourceName ? ` on ${data.resourceName}` : ""; - return `

${data.inviterName} invited you to collaborate${resourceText}.

-

Click the button below to accept the invitation and get started.

+ return `

${data.inviterName} invited you to collaborate${resourceText}.

+

Click the button below to accept the invitation and get started.

`; }, @@ -32,8 +32,10 @@ export const inviteTemplate: TemplateDefinition = { const parts = [ `${data.inviterName} invited you to collaborate${resourceText}.`, - "Accept Invitation:", - data.inviteLink, + "", + "Click the link below to accept the invitation and get started.", + "", + `Accept Invitation: ${data.inviteLink}`, ]; return parts.join("\n"); diff --git a/packages/amplify-overtone/src/email/templates/defaults/password-reset.ts b/packages/amplify-overtone/src/email/templates/defaults/password-reset.ts index 5ac6e65..c641a86 100644 --- a/packages/amplify-overtone/src/email/templates/defaults/password-reset.ts +++ b/packages/amplify-overtone/src/email/templates/defaults/password-reset.ts @@ -9,14 +9,14 @@ export const passwordResetTemplate: TemplateDefinition = { } const expiryHtml = data.expiresInMinutes - ? `

This link expires in ${data.expiresInMinutes} minutes.

` + ? `

This link expires in ${data.expiresInMinutes} minutes.

` : ""; - return `

We received a request to reset your password. Click the button below to choose a new one.

+ return `

We received a request to reset your password. Click the button below to choose a new one.

-

If you did not request a password reset, you can ignore this email.

+

If you did not request a password reset, you can ignore this email.

${expiryHtml}`; }, @@ -25,8 +25,13 @@ ${expiryHtml}`; throw new Error('Template "password-reset" requires data.resetLink'); } - const parts = [`Reset your password: ${data.resetLink}`]; - parts.push("If you did not request a password reset, you can ignore this email."); + const parts = [ + "We received a request to reset your password.", + "", + `Reset Password: ${data.resetLink}`, + "", + "If you did not request a password reset, you can ignore this email.", + ]; if (data.expiresInMinutes) { parts.push(`This link expires in ${data.expiresInMinutes} minutes.`); diff --git a/packages/amplify-overtone/src/email/templates/renderer.ts b/packages/amplify-overtone/src/email/templates/renderer.ts index 6870223..5be057b 100644 --- a/packages/amplify-overtone/src/email/templates/renderer.ts +++ b/packages/amplify-overtone/src/email/templates/renderer.ts @@ -26,21 +26,21 @@ const templateRegistry: Record = { function buildBaseHtml(brandName: string, content: string): string { const brandHtml = brandName - ? `${escapeHtml(brandName)}` + ? `${escapeHtml(brandName)}` : ""; return ` - - + +
- +
${brandHtml} - +
${content}
You received this email because you have an account with ${escapeHtml(brandName || "us")}.
You received this email because you have an account with ${escapeHtml(brandName || "us")}.
From 9a029734b6c8e8f643ce11c8c4f3ef616be4e9ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 21:48:10 +0000 Subject: [PATCH 17/24] chore: Add changeset for email template brand alignment https://claude.ai/code/session_015H8yNLg63q5Pb8YQxP75hF --- .changeset/brand-email-templates.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/brand-email-templates.md diff --git a/.changeset/brand-email-templates.md b/.changeset/brand-email-templates.md new file mode 100644 index 0000000..6f5627d --- /dev/null +++ b/.changeset/brand-email-templates.md @@ -0,0 +1,5 @@ +--- +"@nxsflow/amplify-overtone": patch +--- + +Align email templates with Overtone brand guidelines and fix plain text content parity From 66b45827ed21d389fce6b0eb8cb6645dc71b5122 Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Wed, 8 Apr 2026 12:21:12 +0200 Subject: [PATCH 18/24] fix(amplify-overtone): Revert custom output key to customOutputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AWS::Amplify::Custom output schema requires the payload key to be 'customOutputs' — it is enforced by @aws-amplify/backend-output-schemas. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/lambda-backed-custom-resource.md | 2 +- packages/amplify-overtone/src/email/factory.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/lambda-backed-custom-resource.md b/.changeset/lambda-backed-custom-resource.md index 2ed7f12..80d29a0 100644 --- a/.changeset/lambda-backed-custom-resource.md +++ b/.changeset/lambda-backed-custom-resource.md @@ -2,4 +2,4 @@ "@nxsflow/amplify-overtone": minor --- -Replace AwsCustomResource with Lambda-backed CustomResource for idempotent email identity, rename custom output key to customEmailOutputs, and add unit tests for the identity handler +Replace AwsCustomResource with Lambda-backed CustomResource for idempotent email identity and add unit tests for the identity handler diff --git a/packages/amplify-overtone/src/email/factory.ts b/packages/amplify-overtone/src/email/factory.ts index ac6a0c0..35fd184 100644 --- a/packages/amplify-overtone/src/email/factory.ts +++ b/packages/amplify-overtone/src/email/factory.ts @@ -39,7 +39,7 @@ export class EmailFactory implements ConstructFactory Date: Wed, 8 Apr 2026 12:25:34 +0200 Subject: [PATCH 19/24] chore: add changeset for custom output key fix Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/fix-custom-output-key.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-custom-output-key.md diff --git a/.changeset/fix-custom-output-key.md b/.changeset/fix-custom-output-key.md new file mode 100644 index 0000000..8b639a7 --- /dev/null +++ b/.changeset/fix-custom-output-key.md @@ -0,0 +1,5 @@ +--- +"@nxsflow/amplify-overtone": patch +--- + +Fix custom output key to match AWS::Amplify::Custom schema (must be customOutputs, not customEmailOutputs) From baf7c48f71e88f33b16bcb848c722be2402d73a1 Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Wed, 8 Apr 2026 13:06:56 +0200 Subject: [PATCH 20/24] fix(amplify-overtone): Handle SES identity drift in Update handler The Update handler now always calls CreateEmailIdentity even when the email hasn't changed, re-creating identities that were deleted externally. A DeployToken property forces CloudFormation to trigger Update on every deploy. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../functions/idempotent-identity/handler.ts | 13 +++++++-- .../src/email/idempotent-email-identity.ts | 3 ++ .../email/idempotent-identity-handler.test.ts | 28 +++++++++++++++++-- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/amplify-overtone/src/email/functions/idempotent-identity/handler.ts b/packages/amplify-overtone/src/email/functions/idempotent-identity/handler.ts index c6de028..2d17a7c 100644 --- a/packages/amplify-overtone/src/email/functions/idempotent-identity/handler.ts +++ b/packages/amplify-overtone/src/email/functions/idempotent-identity/handler.ts @@ -65,8 +65,17 @@ export const handler = async (event: CfnEvent): Promise => { console.log(`Update (replacement): ${email}, preExisted=${preExisted}`); return { PhysicalResourceId: makePhysicalId(email, preExisted) }; } - // Email unchanged → preserve physical ID (and its preExisted flag) - console.log(`Update (no-op): ${email}`); + // Email unchanged → ensure identity still exists (handles drift) + try { + await ses.send(new CreateEmailIdentityCommand({ EmailIdentity: email })); + console.log(`Update (re-created after drift): ${email}`); + } catch (error) { + if (error instanceof AlreadyExistsException) { + console.log(`Update (no-op, identity exists): ${email}`); + } else { + throw error; + } + } return { PhysicalResourceId: event.PhysicalResourceId! }; } diff --git a/packages/amplify-overtone/src/email/idempotent-email-identity.ts b/packages/amplify-overtone/src/email/idempotent-email-identity.ts index 74b3c01..e9e8fd0 100644 --- a/packages/amplify-overtone/src/email/idempotent-email-identity.ts +++ b/packages/amplify-overtone/src/email/idempotent-email-identity.ts @@ -74,6 +74,9 @@ export class IdempotentEmailIdentity extends Construct { resourceType: "Custom::SesEmailIdentity", properties: { Email: props.email, + // Force an Update call on every deploy so the handler can + // re-create the identity if it was deleted externally (drift). + DeployToken: new Date().toISOString(), }, }); } diff --git a/packages/amplify-overtone/test/unit/email/idempotent-identity-handler.test.ts b/packages/amplify-overtone/test/unit/email/idempotent-identity-handler.test.ts index cf10976..95f9389 100644 --- a/packages/amplify-overtone/test/unit/email/idempotent-identity-handler.test.ts +++ b/packages/amplify-overtone/test/unit/email/idempotent-identity-handler.test.ts @@ -65,7 +65,25 @@ describe("Create", () => { // --------------------------------------------------------------------------- describe("Update", () => { - it("returns the same physical ID when the email is unchanged", async () => { + it("ensures identity exists and preserves physical ID when email is unchanged", async () => { + sesMock + .on(CreateEmailIdentityCommand) + .rejects(new AlreadyExistsException({ message: "Already exists", $metadata: {} })); + + const result = await handler({ + RequestType: "Update", + ResourceProperties: { Email: "same@example.com", ServiceToken: "" }, + OldResourceProperties: { Email: "same@example.com", ServiceToken: "" }, + PhysicalResourceId: "ses-identity:same@example.com:created", + }); + + expect(result.PhysicalResourceId).toBe("ses-identity:same@example.com:created"); + expect(sesMock.commandCalls(CreateEmailIdentityCommand)).toHaveLength(1); + }); + + it("re-creates identity after drift and preserves physical ID", async () => { + sesMock.on(CreateEmailIdentityCommand).resolves({}); + const result = await handler({ RequestType: "Update", ResourceProperties: { Email: "same@example.com", ServiceToken: "" }, @@ -74,10 +92,14 @@ describe("Update", () => { }); expect(result.PhysicalResourceId).toBe("ses-identity:same@example.com:created"); - expect(sesMock.commandCalls(CreateEmailIdentityCommand)).toHaveLength(0); + expect(sesMock.commandCalls(CreateEmailIdentityCommand)).toHaveLength(1); }); - it("preserves 'preexisted' flag on no-op update", async () => { + it("preserves 'preexisted' flag on update", async () => { + sesMock + .on(CreateEmailIdentityCommand) + .rejects(new AlreadyExistsException({ message: "Already exists", $metadata: {} })); + const result = await handler({ RequestType: "Update", ResourceProperties: { Email: "same@example.com", ServiceToken: "" }, From 22e3eea336120a0850cc0ba18d55d6a348b0277c Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Wed, 8 Apr 2026 13:09:13 +0200 Subject: [PATCH 21/24] chore: add changeset for identity drift fix Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/fix-identity-drift.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-identity-drift.md diff --git a/.changeset/fix-identity-drift.md b/.changeset/fix-identity-drift.md new file mode 100644 index 0000000..9bf3e8e --- /dev/null +++ b/.changeset/fix-identity-drift.md @@ -0,0 +1,5 @@ +--- +"@nxsflow/amplify-overtone": patch +--- + +Handle SES identity drift by re-creating identities on every deploy if they were deleted externally From 80a507e776bbfca5c0cc47456714a905ac1cd3d9 Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Wed, 8 Apr 2026 14:52:43 +0200 Subject: [PATCH 22/24] chore: merge version/release skills, update branch strategy Combine version-management and release-management into a single versioning-and-releases skill. Update branch model from per-feature alpha/beta branches (alpha/**, beta/**) to single integration branches (alpha, beta). Update publish.yml branch triggers and conditionals to match. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/release-management/SKILL.md | 266 ------------------ .claude/skills/version-management/SKILL.md | 228 --------------- .../skills/versioning-and-releases/SKILL.md | 167 +++++++++++ .github/workflows/publish.yml | 8 +- 4 files changed, 171 insertions(+), 498 deletions(-) delete mode 100644 .claude/skills/release-management/SKILL.md delete mode 100644 .claude/skills/version-management/SKILL.md create mode 100644 .claude/skills/versioning-and-releases/SKILL.md diff --git a/.claude/skills/release-management/SKILL.md b/.claude/skills/release-management/SKILL.md deleted file mode 100644 index 2f5b018..0000000 --- a/.claude/skills/release-management/SKILL.md +++ /dev/null @@ -1,266 +0,0 @@ ---- -name: release-management -description: CI/CD pipeline and publishing workflow for @nxsflow/amplify-overtone and @nxsflow/amplify-overtone-client — GitHub Actions workflows, tiered quality gates (alpha/beta/stable), npm dist-tags, manual fallback, and rollback. Use when publishing to npm, setting up CI/CD, or managing releases. ---- - -# Release Management: Amplify Overtone Monorepo - -CI/CD pipeline and publishing workflow. How code gets from main to npm. - -## Published Packages - -This monorepo publishes two packages independently: - -| Package | Path | npm | -| ---------------------------------- | ----------------------------------- | ------------------ | -| `@nxsflow/amplify-overtone` | `packages/amplify-overtone/` | Backend construct | -| `@nxsflow/amplify-overtone-client` | `packages/amplify-overtone-client/` | Server-side client | - -Changesets handles multi-package versioning automatically. Each package has its own `CHANGELOG.md` and version in its `package.json`. A single changeset can bump one or both packages. - -## Branch Strategy - -| Branch | Purpose | Dist-tag | Trigger | -| --------- | ------------------ | -------- | --------------------------- | -| `main` | Stable releases | `latest` | Merge "Version Packages" PR | -| `alpha/*` | Alpha pre-releases | `alpha` | Push to branch | -| `beta/*` | Beta pre-releases | `beta` | Push to branch | - -Pre-release mode (`changeset pre enter`) is only used on feature branches, never on main. - ---- - -## Tiered Quality Gates - -| Channel | Gates | Rationale | -| -------- | ------------------------------------------------- | ----------------------------------- | -| `alpha` | `pnpm build` + `pnpm typecheck` | Fast iteration, types must be sound | -| `beta` | + `pnpm test` + `pnpm lint` | Feature-complete, full validation | -| `stable` | + manual approval (GitHub environment protection) | Rock-solid, human sign-off | - ---- - -## GitHub Actions Workflows - -### `ci.yml` — PR validation - -Runs on all pull requests to main. All four gates must pass. - -```yaml -name: CI - -on: - pull_request: - branches: [main] - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -jobs: - ci: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm build - - run: pnpm typecheck - - run: pnpm test - - run: pnpm lint -``` - -### `publish.yml` — stable + pre-release publishing - -Single workflow for all publishing. Uses npm Trusted Publishing (OIDC) — no npm token required. - -- **main**: full validation + changesets/action (creates Version PR or publishes with `latest` tag) -- **beta/\*\***: full validation + `changeset publish` (pre-release with `beta` tag) -- **alpha/\*\***: build + typecheck only + `changeset publish` (pre-release with `alpha` tag) - -Stable releases require manual approval via the `production` GitHub environment. - -```yaml -name: Publish - -on: - push: - branches: - - main - - "alpha/**" - - "beta/**" - -concurrency: - group: publish-${{ github.ref }} - cancel-in-progress: false - -jobs: - publish: - runs-on: ubuntu-latest - environment: ${{ github.ref == 'refs/heads/main' && 'production' || '' }} - permissions: - contents: write - pull-requests: write - id-token: write # Required for npm Trusted Publishing (OIDC) - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: pnpm - registry-url: https://registry.npmjs.org - - run: pnpm install --frozen-lockfile - - run: pnpm build - - run: pnpm typecheck - - name: Run tests - if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/beta/') - run: pnpm test - - name: Run lint - if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/beta/') - run: pnpm lint - - name: Create Release PR or Publish (stable) - if: github.ref == 'refs/heads/main' - uses: changesets/action@v1 - with: - publish: pnpm changeset publish - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Publish pre-release - if: github.ref != 'refs/heads/main' - run: pnpm changeset publish -``` - ---- - -## npm Setup - -### Trusted Publishing (OIDC) - -Publishing uses npm Trusted Publishing via GitHub Actions OIDC — no npm token required. Each package must be configured on npmjs.com: - -1. Go to npmjs.com → package settings → "Trusted Publisher" -2. Select "GitHub Actions" -3. Set: Organization = `nxsflow`, Repository = `amplify-overtone`, Workflow = `publish.yml` -4. Leave Environment empty (the workflow handles environment selection internally) - -### Repository Secrets - -| Secret | Purpose | Where to get it | -| -------------- | --------------------- | ---------------------------------------- | -| `GITHUB_TOKEN` | Create PRs, push tags | Provided automatically by GitHub Actions | - -**Do NOT use `NPM_TOKEN`.** Publishing uses npm Trusted Publishing (OIDC) — GitHub Actions authenticates directly with npm via short-lived tokens. Long-lived npm tokens are a security risk and should not be stored as secrets. - -### GitHub Environment - -Create a `production` environment in GitHub repo settings: - -1. Settings → Environments → New environment → "production" -2. Add required reviewers (at least one) -3. The `release.yml` workflow references this environment for manual approval - -### First Publish - -Scoped packages (`@nxsflow/*`) default to private on npm. Each package's first publish must use: - -```bash -pnpm --filter @nxsflow/amplify-overtone publish --access public -pnpm --filter @nxsflow/amplify-overtone-client publish --access public -``` - -After the first publish, subsequent publishes via `pnpm changeset publish` inherit the access level. Changesets will publish both packages in one command when both have pending version bumps. - ---- - -## What Gets Published - -Each package publishes only its `dist/` directory (controlled by `"files": ["dist"]` in each `package.json`): - -``` -packages/amplify-overtone/dist/ packages/amplify-overtone-client/dist/ -├── index.js ← ESM entry ├── index.js ← ESM entry -├── index.cjs ← CJS entry ├── index.cjs ← CJS entry -├── index.d.ts ← TypeScript ├── index.d.ts ← TypeScript -└── *.js.map ← source maps └── *.js.map ← source maps -``` - -Consumers import as: - -```typescript -// Backend (amplify/backend.ts) -import { n } from "@nxsflow/amplify-overtone"; - -// Server-side client (app code) -import { generateClient } from "@nxsflow/amplify-overtone-client"; -``` - ---- - -## Manual Fallback - -For publishing without CI (e.g., CI is broken, urgent hotfix): - -### Stable Release - -```bash -# 1. Ensure clean main branch -git checkout main && git pull - -# 2. Build and verify (all gates) -pnpm build && pnpm typecheck && pnpm test && pnpm lint - -# 3. Version bump -pnpm changeset version -git add package.json CHANGELOG.md .changeset -git commit -m "chore(release): bump version" - -# 4. Publish with latest tag -pnpm changeset publish -git push --follow-tags -``` - -### Pre-release - -```bash -# 1. Ensure correct branch -git checkout alpha/my-feature - -# 2. Build and verify (alpha: build + typecheck only) -pnpm build && pnpm typecheck - -# 3. Version bump -pnpm changeset version -git add package.json CHANGELOG.md .changeset -git commit -m "chore(release): bump alpha version" - -# 4. Publish with explicit dist-tag -pnpm changeset publish --tag alpha -git push --follow-tags -``` - -### Safety Checklist - -Before any manual publish: - -- [ ] Correct branch? (`main` for stable, `alpha/*` or `beta/*` for pre-release) -- [ ] All tier-appropriate gates pass? -- [ ] Correct dist-tag? (never publish pre-release without `--tag`) -- [ ] `npm view @nxsflow/amplify-overtone dist-tags` shows expected state after publish? -- [ ] `npm view @nxsflow/amplify-overtone-client dist-tags` shows expected state after publish? - ---- - -## Rollback - -| Scenario | Action | -| --------------- | ----------------------------------------------------------------------------- | -| Within 72 hours | `npm unpublish @nxsflow/amplify-overtone@X.Y.Z` (repeat for client if needed) | -| After 72 hours | `npm deprecate @nxsflow/amplify-overtone@X.Y.Z "use X.Y.Z+1"` + publish patch | -| Pre-release | Publish the next pre-release number (`-alpha.1` replaces `-alpha.0`) | - -**Note:** `npm unpublish` removes the version entirely. Use it only for broken releases. When rolling back, check if both packages need action or just one. diff --git a/.claude/skills/version-management/SKILL.md b/.claude/skills/version-management/SKILL.md deleted file mode 100644 index 08eeff3..0000000 --- a/.claude/skills/version-management/SKILL.md +++ /dev/null @@ -1,228 +0,0 @@ ---- -name: version-management -description: Changesets-based version management for @nxsflow/amplify-overtone and @nxsflow/amplify-overtone-client — semver guide, stable workflow, alpha/beta/rc pre-release channels, npm dist-tags, changelog format, and peer dependency ranges. Use when bumping versions, creating changesets, entering pre-release mode, or deciding on bump type. ---- - -# Version Management: @nxsflow/amplify-overtone Monorepo - -How and when versions are bumped. Uses `@changesets/cli` for all version management. - -## Tooling - -- **Version manager**: `@changesets/cli` (semver, changelog generation, multi-package) -- **Package manager**: pnpm (workspace mode) -- **Repo structure**: pnpm monorepo with independently versioned packages -- **Config**: `.changeset/config.json` - -## Published Packages - -| Package | Path | Versioned independently | -| ---------------------------------- | ----------------------------------- | ----------------------- | -| `@nxsflow/amplify-overtone` | `packages/amplify-overtone/` | Yes | -| `@nxsflow/amplify-overtone-client` | `packages/amplify-overtone-client/` | Yes | - -Each package has its own `package.json` version and `CHANGELOG.md`. A single changeset can select one or both packages to bump. - ---- - -## Semantic Versioning Guide - -| Bump | When | Example | -| ------- | ---------------------------------------------------------------- | ----------------------------------- | -| `patch` | Bug fix, docs, internal refactor, new optional prop with default | Fix handler timeout config | -| `minor` | New optional `OvertoneProps` field, new export, new utility | Add `wireOvertoneToAuth()` | -| `major` | Rename prop, change `OvertoneResources` shape, remove export | Rename `config` to `overtoneConfig` | - -### Decision Guide - -Ask yourself: - -- **Can existing consumers upgrade without changing their code?** → patch or minor -- **Does it add new capabilities without breaking existing usage?** → minor -- **Will existing consumers need to change their code?** → major - ---- - -## Stable Workflow - -### 1. Create a changeset (on feature branch) - -```bash -pnpm changeset -``` - -Choose bump type (patch/minor/major) and describe the change. A `.changeset/.md` file is created. Commit it with the feature: - -```bash -git add .changeset/ -git commit -m "feat: add new feature - -" -``` - -### 2. Merge to main - -Open a PR and merge. CI handles the rest: - -- `changesets/action` detects pending changesets -- Creates a "Version Packages" PR that bumps `package.json` and updates `CHANGELOG.md` - -### 3. Merge the Version Packages PR - -This triggers `pnpm changeset publish` via CI, which: - -- Publishes to npm with the `latest` tag -- Creates a git tag (`v0.2.0`) - ---- - -## Pre-release Channels - -Three tiers with escalating quality gates. - -**Key difference from stable:** On pre-release branches, versioning happens **locally** before pushing. CI only runs `changeset publish`. On main, `changesets/action` handles versioning via a "Version Packages" PR. - -### Alpha — early iteration, breaking changes expected - -```bash -# 1. Create a branch (must match alpha/**) -git checkout -b alpha/my-feature - -# 2. Enter alpha pre-release mode (creates .changeset/pre.json) -pnpm changeset pre enter alpha - -# 3. Create changesets as normal -pnpm changeset - -# 4. Version locally — bumps package.json (e.g., 0.2.0-alpha.0) -pnpm changeset version - -# 5. Commit everything (pre.json, changeset, version bump, changelog) -git add .changeset/ packages/ -git commit -m "chore(release): enter alpha, version 0.2.0-alpha.0" - -# 6. Push — CI runs `changeset publish` (dist-tag handled by pre mode) -git push -u origin alpha/my-feature -``` - -Subsequent changes on this branch: add a changeset, run `changeset version` locally (increments to `alpha.1`, `alpha.2`, etc.), commit, and push. CI publishes automatically. - -### Beta — feature-complete, needs validation - -```bash -git checkout -b beta/my-feature - -pnpm changeset pre enter beta -pnpm changeset -pnpm changeset version # 0.2.0-beta.0 - -git add .changeset/ packages/ -git commit -m "chore(release): enter beta, version 0.2.0-beta.0" -git push -u origin beta/my-feature -``` - -### RC (optional) — release candidate, final validation - -```bash -pnpm changeset pre enter rc -pnpm changeset -pnpm changeset version # 0.2.0-rc.0 - -git add .changeset/ packages/ -git commit -m "chore(release): enter rc, version 0.2.0-rc.0" -git push -u origin beta/my-feature # still uses beta/** branch -``` - -### Exiting Pre-release Mode - -When the pre-release is validated and ready for stable: - -```bash -# Exit pre-release mode -pnpm changeset pre exit - -# Version bumps to stable (0.2.0) -pnpm changeset version - -# Commit and merge to main -git add package.json CHANGELOG.md .changeset -git commit -m "chore(release): exit pre-release, bump to 0.2.0" -``` - -Merging to main triggers the stable release via CI. - -**Rules:** -- Pre-release mode (`changeset pre enter`) is only used on feature branches, never on main. -- `pre.json` **must** exist on `alpha/**` and `beta/**` branches — the publish workflow validates this. -- `pre.json` **must not** exist on `main` — the publish workflow rejects it. -- Never pass `--tag` to `changeset publish` when in pre mode — pre mode handles dist-tags automatically. - ---- - -## npm Dist-tags - -| Tag | Channel | Who installs it | -| -------- | ------------------ | -------------------------------------------- | -| `latest` | Stable releases | Default `pnpm add @nxsflow/amplify-overtone` | -| `alpha` | Alpha pre-releases | `pnpm add @nxsflow/amplify-overtone@alpha` | -| `beta` | Beta pre-releases | `pnpm add @nxsflow/amplify-overtone@beta` | - -Both `@nxsflow/amplify-overtone` and `@nxsflow/amplify-overtone-client` use the same dist-tag scheme. - -**Critical:** Pre-releases must be published from branches with changeset pre mode active (`.changeset/pre.json`). Pre mode automatically sets the correct dist-tag. Never pass `--tag` manually when in pre mode — it causes an error. The publish workflow validates that `pre.json` exists on `alpha/**` and `beta/**` branches. - -### Verify Dist-tags - -```bash -npm view @nxsflow/amplify-overtone dist-tags -npm view @nxsflow/amplify-overtone-client dist-tags -``` - -Expected output after an alpha release: - -``` -{ latest: '0.1.0', alpha: '0.2.0-alpha.0' } -``` - ---- - -## Changelog Format - -Auto-generated by changesets. Each entry includes the changeset hash and description: - -```markdown -## 0.2.0 - -### Minor Changes - -- abc1234: Add wireOvertoneToAuth() utility for wiring overtone to Cognito - -### Patch Changes - -- def5678: Fix handler timeout configuration - -## 0.2.0-beta.0 - -### Minor Changes - -- abc1234: Add wireOvertoneToAuth() utility for wiring overtone to Cognito -``` - -Do not edit `CHANGELOG.md` manually. If a changeset description needs fixing, edit the `.changeset/*.md` file before running `pnpm changeset version`. - ---- - -## Peer Dependency Ranges - -| Package | Range | Rule | -| --------------------------- | --------- | ------------------------------------------------ | -| `aws-cdk-lib` | `^2.0.0` | Permissive — consumer controls their CDK version | -| `constructs` | `^10.0.0` | CDK base class | -| `@aws-amplify/plugin-types` | `^1.0.0` | ConstructFactory interface | - -**Rules:** - -- Never bundle peer deps — they're in tsup's `external` list -- Test against the minimum supported version, not latest -- Keep ranges permissive (`^2.0.0` not `^2.170.0`) — consumers have their own CDK version constraints -- Bumping a peer dep minimum range is a **major** version bump for this library diff --git a/.claude/skills/versioning-and-releases/SKILL.md b/.claude/skills/versioning-and-releases/SKILL.md new file mode 100644 index 0000000..f5b1278 --- /dev/null +++ b/.claude/skills/versioning-and-releases/SKILL.md @@ -0,0 +1,167 @@ +--- +name: versioning-and-releases +description: Use when bumping versions, creating changesets, entering/exiting pre-release mode, deciding bump type, publishing to npm, managing alpha/beta branches, promoting features to alpha/beta/stable, or when the user says something is "ready for alpha", "ready for beta", or "ready to release". +--- + +# Versioning and Releases + +Uses `@changesets/cli` for versioning. CI (`.github/workflows/publish.yml`) handles publishing automatically — never publish manually unless CI is broken. + +## Packages + +| Package | npm | Versioned independently | +| ---------------------------------- | ----------------------------------- | ----------------------- | +| `@nxsflow/amplify-overtone` | `packages/amplify-overtone/` | Yes | +| `@nxsflow/amplify-overtone-client` | `packages/amplify-overtone-client/` | Yes | + +## Semver Guide + +| Bump | When | Example | +| ------- | ---------------------------------------------------------------- | ---------------------------------- | +| `patch` | Bug fix, docs, internal refactor, new optional prop with default | Fix handler timeout | +| `minor` | New optional prop, new export, new utility | Add `wireOvertoneToAuth()` | +| `major` | Rename prop, change `OvertoneResources` shape, remove export | Rename `config` → `overtoneConfig` | + +Quick test: Can consumers upgrade without code changes? → patch/minor. New capabilities? → minor. Breaking? → major. Bumping a peer dep minimum range is always **major**. + +## Branch Strategy + +| Branch | Purpose | Dist-tag | CI gates | +| ------- | ------------------------------------------- | -------- | ------------------------------------------------- | +| `main` | Stable releases | `latest` | build + typecheck + test + lint + manual approval | +| `alpha` | Integration branch for in-progress features | `alpha` | build + typecheck | +| `beta` | Validated features ready for release | `beta` | build + typecheck + test + lint | + +**`alpha` and `beta` are single integration branches, not per-feature.** Feature branches (`feat/*`) merge into `alpha`. When all features in alpha are validated, alpha is promoted to `beta` as a cohort. Beta is promoted to main for stable release. + +``` +feat/foo ──┐ +feat/bar ──┤──► alpha ──► beta ──► main +feat/baz ──┘ +``` + +**PRs target `alpha` (or `beta` if skipping alpha), never `main` directly** for feature work. Promotion between integration branches is done by merging branches and switching pre-release mode. + +### Selective Release + +If some features aren't ready when you want to release: + +1. Revert unfinished features from `alpha` +2. Promote remaining `alpha` → `beta` → `main` (releases as e.g. `0.3.0`) +3. Re-apply unfinished features to `alpha` (starts next version, e.g. `0.4.0-alpha.0`) + +This preserves the invariant that `beta` is always a superset of `alpha` at the moment of promotion. + +## Workflows + +### Feature Development (on `feat/*`) + +```bash +# Create changeset with your feature +pnpm changeset +git add .changeset/ && git commit -m "feat: description" +# Open PR targeting `alpha` (or `beta` if skipping alpha) +``` + +No `pre.json`, no version bumps on feature branches — just code and changesets. + +### "Ready for Alpha" — Merging a Feature into Alpha + +When the user says a feature is "ready for alpha": + +1. **Merge the feature PR into the `alpha` branch** +2. **If `alpha` doesn't have `pre.json` yet** (first feature), enter alpha pre-release mode: + + ```bash + git checkout alpha + pnpm changeset pre enter alpha + ``` + +3. **Version and publish:** + + ```bash + pnpm changeset version # bumps to X.Y.Z-alpha.N + git add .changeset/ packages/ + git commit -m "chore(release): version X.Y.Z-alpha.N" + git push # CI publishes automatically + ``` + +Subsequent features merged into alpha: just run `changeset version`, commit, push. The alpha counter increments automatically. + +### "Ready for Beta" — Promoting Alpha to Beta + +When the user says features are "ready for beta": + +1. **Merge alpha into beta and switch pre-release mode:** + + ```bash + git checkout beta && git merge alpha + pnpm changeset pre exit + pnpm changeset pre enter beta + pnpm changeset version # bumps to X.Y.Z-beta.0 + git add .changeset/ packages/ + git commit -m "chore(release): version X.Y.Z-beta.0" + git push # CI publishes automatically + ``` + +### "Ready to Release" — Promoting Beta to Main + +When the user says it's "ready to release" or "ready for stable": + +1. **Exit pre-release mode on the beta branch:** + + ```bash + git checkout beta + pnpm changeset pre exit + pnpm changeset version # bumps to stable X.Y.Z + git add .changeset/ packages/ + git commit -m "chore(release): exit pre-release, bump to X.Y.Z" + git push + ``` + +2. **Create a PR from `beta` → `main`** — CI runs all four gates +3. **After merge**, `changesets/action` creates a "Version Packages" PR or publishes directly. Stable publish requires manual approval via the `production` GitHub environment. + +**Key:** `pre.json` must be removed on the `beta` branch _before_ creating the PR to main. The CI guard validates that `pre.json` does not exist on `main`. + +## Rules + +- **`pre.json` must exist** on `alpha` and `beta` branches — CI validates this +- **`pre.json` must NOT exist** on `main` — CI rejects it +- **Never pass `--tag`** to `changeset publish` when in pre mode — pre mode handles dist-tags +- **Versioning on pre-release branches is local** (`changeset version` before push); on main, `changesets/action` handles it via a "Version Packages" PR +- **Never edit `CHANGELOG.md` manually** — edit `.changeset/*.md` files before running `changeset version` + +## Verify Dist-tags + +```bash +npm view @nxsflow/amplify-overtone dist-tags +npm view @nxsflow/amplify-overtone-client dist-tags +``` + +## CI Pipeline + +See `.github/workflows/publish.yml` for implementation. Key behaviors: + +- **main push**: `changesets/action` creates a "Version Packages" PR (bumps versions, updates changelogs). Merging that PR publishes to npm with `latest` tag after manual approval (`production` environment). +- **alpha/beta push**: Runs `changeset publish` directly. Pre mode sets the dist-tag automatically. +- **PRs to main**: `.github/workflows/ci.yml` runs all four gates (build, typecheck, test, lint). +- **Publishing uses npm Trusted Publishing (OIDC)** — no npm token needed. + +## Rollback + +| Scenario | Action | +| --------------- | ----------------------------------------------------------------------------- | +| Within 72 hours | `npm unpublish @nxsflow/amplify-overtone@X.Y.Z` | +| After 72 hours | `npm deprecate @nxsflow/amplify-overtone@X.Y.Z "use X.Y.Z+1"` + publish patch | +| Pre-release | Publish the next pre-release number (`-alpha.1` replaces `-alpha.0`) | + +## Peer Dependency Ranges + +| Package | Range | Rule | +| --------------------------- | --------- | ------------------------------------------ | +| `aws-cdk-lib` | `^2.0.0` | Permissive — consumer controls CDK version | +| `constructs` | `^10.0.0` | CDK base class | +| `@aws-amplify/plugin-types` | `^1.0.0` | ConstructFactory interface | + +Never bundle peer deps. Keep ranges permissive. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a504650..32ff7ff 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,8 +4,8 @@ on: push: branches: - main - - "alpha/**" - - "beta/**" + - alpha + - beta concurrency: group: publish-${{ github.ref }} @@ -36,11 +36,11 @@ jobs: # Full validation for main and beta branches - name: Run tests - if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/beta/') + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta' run: pnpm test - name: Run lint - if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/beta/') + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta' run: pnpm lint # Guard: pre.json must NOT exist on main, MUST exist on pre-release branches From 115df30ab3f59672a6ce3dc35704994b97b738cb Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Wed, 8 Apr 2026 15:32:51 +0200 Subject: [PATCH 23/24] fix(amplify-overtone): Widen IAM policy for identity handler to prevent AccessDenied on replacement During CloudFormation replacements (email property change), the IAM policy is updated to scope to the new email before the Delete handler runs for the old one. A per-identity ARN causes AccessDenied when deleting the old identity. Use identity/* wildcard instead. Co-Authored-By: Claude --- .../src/email/idempotent-email-identity.ts | 7 +++++-- .../test/construct/email/ses.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/amplify-overtone/src/email/idempotent-email-identity.ts b/packages/amplify-overtone/src/email/idempotent-email-identity.ts index e9e8fd0..3803bc8 100644 --- a/packages/amplify-overtone/src/email/idempotent-email-identity.ts +++ b/packages/amplify-overtone/src/email/idempotent-email-identity.ts @@ -48,7 +48,6 @@ export class IdempotentEmailIdentity extends Construct { const region = Stack.of(this).region; const account = Stack.of(this).account; - const identityArn = `arn:aws:ses:${region}:${account}:identity/${props.email}`; const handler = new NodejsFunction(this, "Handler", { entry: path.join(HANDLER_DIR, "handler.ts"), @@ -61,7 +60,11 @@ export class IdempotentEmailIdentity extends Construct { new PolicyStatement({ effect: Effect.ALLOW, actions: ["ses:CreateEmailIdentity", "ses:DeleteEmailIdentity"], - resources: [identityArn], + // Use a wildcard for identity resources: during CloudFormation + // replacements (email property change), the IAM policy is updated + // to the new email before the Delete handler runs for the old one. + // A scoped ARN causes AccessDenied on the old identity's deletion. + resources: [`arn:aws:ses:${region}:${account}:identity/*`], }), ); diff --git a/packages/amplify-overtone/test/construct/email/ses.test.ts b/packages/amplify-overtone/test/construct/email/ses.test.ts index 4bafe63..ad89e16 100644 --- a/packages/amplify-overtone/test/construct/email/ses.test.ts +++ b/packages/amplify-overtone/test/construct/email/ses.test.ts @@ -1,3 +1,4 @@ +import { Match } from "aws-cdk-lib/assertions"; import { describe, it } from "vitest"; import { createEmailTemplate, createNoDomainTemplate } from "./helpers.js"; @@ -34,6 +35,21 @@ describe("SES — Mode 1 (no domain)", () => { template.resourceCountIs("AWS::SES::EmailIdentity", 0); }); + it("grants identity handler a wildcard SES policy for safe replacements", () => { + template.hasResourceProperties("AWS::IAM::Policy", { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: ["ses:CreateEmailIdentity", "ses:DeleteEmailIdentity"], + Resource: { + "Fn::Join": ["", Match.arrayWith([":identity/*"])], + }, + }), + ]), + }, + }); + }); + it("still creates a ConfigurationSet", () => { template.resourceCountIs("AWS::SES::ConfigurationSet", 1); }); From a2db2d30b510950cf9c78c017035eb31d104e237 Mon Sep 17 00:00:00 2001 From: Carsten Koch Date: Wed, 8 Apr 2026 15:33:02 +0200 Subject: [PATCH 24/24] build(deps): Bump @biomejs/biome to 2.4.10 --- package.json | 2 +- pnpm-lock.yaml | 76 +++++++++++++++++++++++++------------------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index 1c6540f..48153b5 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "packageManager": "pnpm@10.33.0", "devDependencies": { - "@biomejs/biome": "^2.4.9", + "@biomejs/biome": "2.4.10", "@changesets/cli": "^2.30.0", "esbuild": "^0.27.4", "typescript": "^5.8.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73594d6..99f67c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: devDependencies: '@biomejs/biome': - specifier: ^2.4.9 - version: 2.4.9 + specifier: 2.4.10 + version: 2.4.10 '@changesets/cli': specifier: ^2.30.0 version: 2.30.0(@types/node@22.19.15) @@ -1373,59 +1373,59 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.4.9': - resolution: {integrity: sha512-wvZW92FrwitTcacvCBT8xdAbfbxWfDLwjYMmU3djjqQTh7Ni4ZdiWIT/x5VcZ+RQuxiKzIOzi5D+dcyJDFZMsA==} + '@biomejs/biome@2.4.10': + resolution: {integrity: sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.4.9': - resolution: {integrity: sha512-d5G8Gf2RpH5pYwiHLPA+UpG3G9TLQu4WM+VK6sfL7K68AmhcEQ9r+nkj/DvR/GYhYox6twsHUtmWWWIKfcfQQA==} + '@biomejs/cli-darwin-arm64@2.4.10': + resolution: {integrity: sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.4.9': - resolution: {integrity: sha512-LNCLNgqDMG7BLdc3a8aY/dwKPK7+R8/JXJoXjCvZh2gx8KseqBdFDKbhrr7HCWF8SzNhbTaALhTBoh/I6rf9lA==} + '@biomejs/cli-darwin-x64@2.4.10': + resolution: {integrity: sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.4.9': - resolution: {integrity: sha512-8RCww5xnPn2wpK4L/QDGDOW0dq80uVWfppPxHIUg6mOs9B6gRmqPp32h1Ls3T8GnW8Wo5A8u7vpTwz4fExN+sw==} + '@biomejs/cli-linux-arm64-musl@2.4.10': + resolution: {integrity: sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.4.9': - resolution: {integrity: sha512-4adnkAUi6K4C/emPRgYznMOcLlUqZdXWM6aIui4VP4LraE764g6Q4YguygnAUoxKjKIXIWPteKMgRbN0wsgwcg==} + '@biomejs/cli-linux-arm64@2.4.10': + resolution: {integrity: sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.4.9': - resolution: {integrity: sha512-5TD+WS9v5vzXKzjetF0hgoaNFHMcpQeBUwKKVi3JbG1e9UCrFuUK3Gt185fyTzvRdwYkJJEMqglRPjmesmVv4A==} + '@biomejs/cli-linux-x64-musl@2.4.10': + resolution: {integrity: sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.4.9': - resolution: {integrity: sha512-L10na7POF0Ks/cgLFNF1ZvIe+X4onLkTi5oP9hY+Rh60Q+7fWzKDDCeGyiHUFf1nGIa9dQOOUPGe2MyYg8nMSQ==} + '@biomejs/cli-linux-x64@2.4.10': + resolution: {integrity: sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.4.9': - resolution: {integrity: sha512-aDZr0RBC3sMGJOU10BvG7eZIlWLK/i51HRIfScE2lVhfts2dQTreowLiJJd+UYg/tHKxS470IbzpuKmd0MiD6g==} + '@biomejs/cli-win32-arm64@2.4.10': + resolution: {integrity: sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.4.9': - resolution: {integrity: sha512-NS4g/2G9SoQ4ktKtz31pvyc/rmgzlcIDCGU/zWbmHJAqx6gcRj2gj5Q/guXhoWTzCUaQZDIqiCQXHS7BcGYc0w==} + '@biomejs/cli-win32-x64@2.4.10': + resolution: {integrity: sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -10147,39 +10147,39 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@biomejs/biome@2.4.9': + '@biomejs/biome@2.4.10': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.4.9 - '@biomejs/cli-darwin-x64': 2.4.9 - '@biomejs/cli-linux-arm64': 2.4.9 - '@biomejs/cli-linux-arm64-musl': 2.4.9 - '@biomejs/cli-linux-x64': 2.4.9 - '@biomejs/cli-linux-x64-musl': 2.4.9 - '@biomejs/cli-win32-arm64': 2.4.9 - '@biomejs/cli-win32-x64': 2.4.9 - - '@biomejs/cli-darwin-arm64@2.4.9': + '@biomejs/cli-darwin-arm64': 2.4.10 + '@biomejs/cli-darwin-x64': 2.4.10 + '@biomejs/cli-linux-arm64': 2.4.10 + '@biomejs/cli-linux-arm64-musl': 2.4.10 + '@biomejs/cli-linux-x64': 2.4.10 + '@biomejs/cli-linux-x64-musl': 2.4.10 + '@biomejs/cli-win32-arm64': 2.4.10 + '@biomejs/cli-win32-x64': 2.4.10 + + '@biomejs/cli-darwin-arm64@2.4.10': optional: true - '@biomejs/cli-darwin-x64@2.4.9': + '@biomejs/cli-darwin-x64@2.4.10': optional: true - '@biomejs/cli-linux-arm64-musl@2.4.9': + '@biomejs/cli-linux-arm64-musl@2.4.10': optional: true - '@biomejs/cli-linux-arm64@2.4.9': + '@biomejs/cli-linux-arm64@2.4.10': optional: true - '@biomejs/cli-linux-x64-musl@2.4.9': + '@biomejs/cli-linux-x64-musl@2.4.10': optional: true - '@biomejs/cli-linux-x64@2.4.9': + '@biomejs/cli-linux-x64@2.4.10': optional: true - '@biomejs/cli-win32-arm64@2.4.9': + '@biomejs/cli-win32-arm64@2.4.10': optional: true - '@biomejs/cli-win32-x64@2.4.9': + '@biomejs/cli-win32-x64@2.4.10': optional: true '@cdklabs/tskb@0.0.4': {}