diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a4cd598a..5a2ff924a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,6 +102,54 @@ it('should be idempotent when re-run with same options', async () => { - **Component generators**: run twice with the same inputs and assert the tree is unchanged after the second run (no duplicate wiring), and that adding a second differently-named component leaves the first intact. - **Guarded refusals**: assert the generator throws the expected error on re-run. +### Migrations + +When a change breaks projects generated by a previous version — renamed targets, changed vended config, a dependency bump requiring code changes — ship a migration so `nx migrate @aws/nx-plugin` upgrades users automatically. Migrations live under `packages/nx-plugin/src/migrations//` and are registered in `packages/nx-plugin/migrations.json`. + +#### The three kinds of migration + +Nx 23 migrations come in three forms, discriminated by which fields the `migrations.json` entry carries: + +- **Deterministic** (`implementation`): a generator function with an exact before/after. Runs unattended, including in CI and non-interactive terminals. This is the required upgrade path — deterministic migrations alone must take an uncustomised generated workspace from one version to the next with build, lint and test green. +- **Agentic** (`prompt`): a markdown instruction file applied by the user's local coding agent (Claude Code, Codex or OpenCode) via Nx's agentic migrate flow. Use for changes to user-owned code where the correct edit depends on what the user has built. When no agent runs (CI, no agent installed, consent declined), Nx writes the prompt to `tools/ai-migrations/` in the user's workspace as manual instructions — so prompts must read as standalone, self-contained instructions. +- **Hybrid** (`implementation` + `prompt`): one breaking change with a mechanical half and a judgment half. The generator fixes everything we own and returns `agentContext` describing what it changed or skipped; the prompt directs the agent at the user-owned call sites. + +#### What should be a migration + +Decide with two questions: **is the change deterministic?** and **do we own the target file?** + +Deterministic migrations should cover: + +- Vended dependency version bumps that require accompanying changes (JS bumps alone use Nx's declarative `packageJsonUpdates`; `pyproject.toml` bumps need a generator migration) +- Config the plugin fully owns: `project.json` targets and options, `nx.json` plugin/sync-generator entries, `aws-nx-plugin.config.mts` +- Generated files users aren't expected to edit: shared constructs following the vended pattern, generated clients, runtime-config wiring +- Mechanical AST edits with an exact before/after: import paths, renamed exports, renamed generator ID references + +Agentic (prompt) migrations should cover: + +- User-authored code built on scaffolding that changed: agent implementations, custom CDK stacks, custom routers and components +- Framework major upgrades whose required edits land in user code + +Not a migration at all: + +- Formatting or stylistic changes +- New optional features — users adopt them by re-running the (idempotent) generator +- Generator changes that only affect newly generated output and leave existing workspaces working (e.g. a file the generator no longer vends, where the leftover copy is harmless) + +#### Guardrails for deterministic migrations + +- **Pattern-match before writing.** If the target file has diverged from the vended shape, skip it and report via `nextSteps` (see `MigrationReturnObject` in `@nx/devkit`) rather than clobbering user changes. +- **Idempotent.** Re-running the migration must be a no-op, mirroring the generator idempotency principle above. +- **Never destroy user intent.** The same rule as generators: user-owned files are reported on, not rewritten. + +#### Versioning + +Do not add a `version` field to `migrations.json` entries. Versions are stamped at package time (`scripts/stamp-migrations.ts`): a migration that already shipped keeps the version of the first release tag that included it, and an unshipped migration gets a version just above the latest release tag so it runs for every user upgrading from any released version. + +#### Testing + +Every migration needs a `migration.spec.ts` alongside it using `createTreeUsingTsSolutionSetup()`, covering: the migration applies to the vended shape, skips (and reports) customised files, and is idempotent. + ### End to End Tests The end to end tests run our generators and check that generated projects function correctly (usually by performing a build). diff --git a/packages/nx-plugin/migrations.json b/packages/nx-plugin/migrations.json new file mode 100644 index 000000000..aa89df8a7 --- /dev/null +++ b/packages/nx-plugin/migrations.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/schema", + "name": "@aws/nx-plugin", + "generators": {} +} diff --git a/packages/nx-plugin/package.json b/packages/nx-plugin/package.json index 4f181d58e..0af55db32 100644 --- a/packages/nx-plugin/package.json +++ b/packages/nx-plugin/package.json @@ -32,6 +32,9 @@ }, "generators": "./generators.json", "executors": "./executors.json", + "nx-migrations": { + "migrations": "./migrations.json" + }, "engines": { "node": ">=20.19.0" }, diff --git a/packages/nx-plugin/project.json b/packages/nx-plugin/project.json index f66e96e08..c5b669d51 100644 --- a/packages/nx-plugin/project.json +++ b/packages/nx-plugin/project.json @@ -37,6 +37,11 @@ "input": "./packages/nx-plugin", "glob": "executors.json", "output": "." + }, + { + "input": "./packages/nx-plugin", + "glob": "migrations.json", + "output": "." } ] } @@ -71,8 +76,21 @@ }, "dependsOn": ["compile"] }, + "stamp-migrations": { + "executor": "nx:run-commands", + "options": { + "command": "tsx scripts/stamp-migrations.ts", + "cwd": "{workspaceRoot}" + }, + "dependsOn": ["compile"] + }, "package": { - "dependsOn": ["compile", "post-compile", "generate-3p-license"] + "dependsOn": [ + "compile", + "post-compile", + "stamp-migrations", + "generate-3p-license" + ] }, "build": { "dependsOn": [ diff --git a/packages/nx-plugin/src/utils/migration-versions.spec.ts b/packages/nx-plugin/src/utils/migration-versions.spec.ts new file mode 100644 index 000000000..8e4b76079 --- /dev/null +++ b/packages/nx-plugin/src/utils/migration-versions.spec.ts @@ -0,0 +1,109 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, expect, it } from 'vitest'; +import { + compareVersions, + stampMigrationVersions, + unshippedMigrationVersion, +} from './migration-versions'; + +describe('migration versions', () => { + describe('compareVersions', () => { + it('should order release versions numerically', () => { + expect(compareVersions('1.2.3', '1.2.3')).toBe(0); + expect(compareVersions('1.2.10', '1.2.9')).toBeGreaterThan(0); + expect(compareVersions('1.10.0', '1.9.0')).toBeGreaterThan(0); + expect(compareVersions('2.0.0', '1.99.99')).toBeGreaterThan(0); + }); + + it('should order prereleases below their release', () => { + expect(compareVersions('1.0.0-rc.1', '1.0.0')).toBeLessThan(0); + expect(compareVersions('1.0.0', '1.0.0-rc.32')).toBeGreaterThan(0); + }); + + it('should order prerelease identifiers numerically', () => { + expect(compareVersions('1.0.0-rc.10', '1.0.0-rc.9')).toBeGreaterThan(0); + expect(compareVersions('1.0.0-rc.2', '1.0.0-rc.10')).toBeLessThan(0); + }); + + it('should order a longer prerelease above its prefix', () => { + expect(compareVersions('1.0.0-rc.32.1', '1.0.0-rc.32')).toBeGreaterThan( + 0, + ); + expect(compareVersions('1.0.0-rc.32.1', '1.0.0-rc.33')).toBeLessThan(0); + }); + + it('should order numeric prerelease identifiers below alphanumeric', () => { + expect(compareVersions('1.0.0-1', '1.0.0-alpha')).toBeLessThan(0); + }); + }); + + describe('unshippedMigrationVersion', () => { + it('should sit strictly between a release and any possible next release', () => { + const version = unshippedMigrationVersion('1.2.3'); + expect(compareVersions(version, '1.2.3')).toBeGreaterThan(0); + expect(compareVersions(version, '1.2.4')).toBeLessThan(0); + expect(compareVersions(version, '1.3.0')).toBeLessThan(0); + expect(compareVersions(version, '2.0.0')).toBeLessThan(0); + }); + + it('should sit strictly between a prerelease and the next prerelease or release', () => { + const version = unshippedMigrationVersion('1.0.0-rc.32'); + expect(compareVersions(version, '1.0.0-rc.32')).toBeGreaterThan(0); + expect(compareVersions(version, '1.0.0-rc.33')).toBeLessThan(0); + expect(compareVersions(version, '1.0.0')).toBeLessThan(0); + }); + }); + + describe('stampMigrationVersions', () => { + it('should stamp shipped migrations with their first shipped version', () => { + const stamped = stampMigrationVersions( + { + generators: { + 'my-migration': { description: 'shipped' }, + }, + }, + { 'my-migration': '1.1.0' }, + '1.2.0', + ); + expect(stamped.generators?.['my-migration'].version).toBe('1.1.0'); + }); + + it('should stamp unshipped migrations with a version above the latest release', () => { + const stamped = stampMigrationVersions( + { + generators: { + 'new-migration': { description: 'unshipped' }, + }, + }, + {}, + '1.2.0', + ); + expect(stamped.generators?.['new-migration'].version).toBe( + '1.2.1-migration.0', + ); + }); + + it('should preserve all other entry fields', () => { + const stamped = stampMigrationVersions( + { + generators: { + 'my-migration': { + description: 'a migration', + implementation: './src/migrations/my-migration/migration', + }, + }, + }, + {}, + '1.0.0-rc.32', + ); + expect(stamped.generators?.['my-migration']).toEqual({ + version: '1.0.0-rc.32.1', + description: 'a migration', + implementation: './src/migrations/my-migration/migration', + }); + }); + }); +}); diff --git a/packages/nx-plugin/src/utils/migration-versions.ts b/packages/nx-plugin/src/utils/migration-versions.ts new file mode 100644 index 000000000..6935141fa --- /dev/null +++ b/packages/nx-plugin/src/utils/migration-versions.ts @@ -0,0 +1,118 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Version stamping for migrations. + * + * Source `migrations.json` entries carry no `version` field — the release + * model never commits a version to source (releases are calculated from + * conventional commits and written only to `dist/` and a git tag). Versions + * are stamped into the compiled `migrations.json` at package time: + * + * - A migration that already shipped is stamped with the version of the + * earliest release tag whose `migrations.json` registers it, so it never + * re-runs for users already past that release. + * - A migration that hasn't shipped yet is stamped with a version strictly + * greater than the latest tag and strictly less than any possible next + * release, so it runs for every user upgrading from any released version. + * `nx migrate` runs a migration when `installed < migration.version`, so + * any version in that open interval is correct regardless of what the next + * release number turns out to be. + */ + +export interface MigrationsJson { + generators?: Record>; +} + +/** + * Version stamped onto migrations that are not present in any release tag. + * + * Appending a prerelease (or extending an existing one) yields a version + * that semver orders strictly between the latest release and every possible + * next release: + * - `1.2.3` -> `1.2.4-migration.0` (> 1.2.3, < 1.2.4 / 1.3.0 / 2.0.0) + * - `1.0.0-rc.32` -> `1.0.0-rc.32.1` (> rc.32, < rc.33 and < 1.0.0) + */ +export const unshippedMigrationVersion = (latestVersion: string): string => { + const [release, prerelease] = splitPrerelease(latestVersion); + if (prerelease) { + return `${release}-${prerelease}.1`; + } + const parts = release.split('.').map(Number); + parts[parts.length - 1] += 1; + return `${parts.join('.')}-migration.0`; +}; + +const splitPrerelease = (version: string): [string, string | undefined] => { + const dash = version.indexOf('-'); + return dash === -1 + ? [version, undefined] + : [version.slice(0, dash), version.slice(dash + 1)]; +}; + +/** + * Semver precedence comparison (semver.org §11). Git's `v:refname` sort is + * not semver-aware for prereleases (it orders `1.0.0-rc.1` after `1.0.0`), + * so release tags are ordered with this instead. + */ +export const compareVersions = (a: string, b: string): number => { + const [releaseA, prereleaseA] = splitPrerelease(a); + const [releaseB, prereleaseB] = splitPrerelease(b); + const partsA = releaseA.split('.').map(Number); + const partsB = releaseB.split('.').map(Number); + for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { + const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0); + if (diff !== 0) return diff; + } + // A prerelease has lower precedence than the release it precedes + if (!prereleaseA && !prereleaseB) return 0; + if (!prereleaseA) return 1; + if (!prereleaseB) return -1; + const idsA = prereleaseA.split('.'); + const idsB = prereleaseB.split('.'); + for (let i = 0; i < Math.min(idsA.length, idsB.length); i++) { + const idA = idsA[i]; + const idB = idsB[i]; + const numA = /^\d+$/.test(idA) ? Number(idA) : undefined; + const numB = /^\d+$/.test(idB) ? Number(idB) : undefined; + if (numA !== undefined && numB !== undefined) { + if (numA !== numB) return numA - numB; + } else if (numA !== undefined) { + return -1; // numeric identifiers sort below alphanumeric + } else if (numB !== undefined) { + return 1; + } else if (idA !== idB) { + return idA < idB ? -1 : 1; + } + } + return idsA.length - idsB.length; +}; + +/** + * Return a copy of the migrations collection with a `version` stamped onto + * every generator entry. + * + * @param migrations parsed migrations.json to stamp + * @param shippedVersions migration name -> version of the earliest release + * tag that registers it (absent for migrations that haven't shipped) + * @param latestVersion version of the latest release tag (without the `v` + * prefix), used to derive versions for unshipped migrations + */ +export const stampMigrationVersions = ( + migrations: MigrationsJson, + shippedVersions: Record, + latestVersion: string, +): MigrationsJson => { + const unshippedVersion = unshippedMigrationVersion(latestVersion); + return { + ...migrations, + generators: Object.fromEntries( + Object.entries(migrations.generators ?? {}).map(([name, entry]) => [ + name, + { version: shippedVersions[name] ?? unshippedVersion, ...entry }, + ]), + ), + }; +}; diff --git a/scripts/stamp-migrations.ts b/scripts/stamp-migrations.ts new file mode 100644 index 000000000..d164f0da4 --- /dev/null +++ b/scripts/stamp-migrations.ts @@ -0,0 +1,80 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { + compareVersions, + type MigrationsJson, + stampMigrationVersions, +} from '../packages/nx-plugin/src/utils/migration-versions'; + +/** + * Stamps versions onto the compiled `migrations.json` (see + * `utils/migration-versions.ts` for the versioning model). Runs as part of + * the nx-plugin `package` target, after `compile` populates dist. + */ + +const SOURCE_MIGRATIONS_PATH = 'packages/nx-plugin/migrations.json'; +const DIST_MIGRATIONS_PATH = 'dist/packages/nx-plugin/migrations.json'; + +const releaseTagsAscending = (): string[] => + execSync("git tag -l 'v*'", { encoding: 'utf-8' }) + .split('\n') + .filter(Boolean) + .sort((a, b) => compareVersions(a.slice(1), b.slice(1))); + +const readMigrationsAtTag = (tag: string): MigrationsJson | undefined => { + try { + return JSON.parse( + execSync(`git show ${tag}:${SOURCE_MIGRATIONS_PATH}`, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'ignore'], + }), + ); + } catch { + // Tag predates migrations.json + return undefined; + } +}; + +const main = () => { + const migrations: MigrationsJson = JSON.parse( + readFileSync(SOURCE_MIGRATIONS_PATH, 'utf-8'), + ); + + const tags = releaseTagsAscending(); + if (tags.length === 0) { + throw new Error( + 'No release tags found — migrations cannot be stamped. Fetch tags (git fetch --tags) and retry.', + ); + } + + // Earliest release tag registering each migration + const shippedVersions: Record = {}; + for (const tag of tags) { + const tagged = readMigrationsAtTag(tag); + for (const name of Object.keys(tagged?.generators ?? {})) { + shippedVersions[name] ??= tag.slice(1); + } + } + + const latestVersion = tags[tags.length - 1].slice(1); + const stamped = stampMigrationVersions( + migrations, + shippedVersions, + latestVersion, + ); + + writeFileSync( + DIST_MIGRATIONS_PATH, + `${JSON.stringify(stamped, null, 2)}\n`, + 'utf-8', + ); + console.log( + `Stamped ${Object.keys(stamped.generators ?? {}).length} migration(s) into ${DIST_MIGRATIONS_PATH} (latest release: ${latestVersion})`, + ); +}; + +main();