Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/` 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).
Expand Down
5 changes: 5 additions & 0 deletions packages/nx-plugin/migrations.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/schema",
"name": "@aws/nx-plugin",
"generators": {}
}
3 changes: 3 additions & 0 deletions packages/nx-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
},
"generators": "./generators.json",
"executors": "./executors.json",
"nx-migrations": {
"migrations": "./migrations.json"
},
"engines": {
"node": ">=20.19.0"
},
Expand Down
20 changes: 19 additions & 1 deletion packages/nx-plugin/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@
"input": "./packages/nx-plugin",
"glob": "executors.json",
"output": "."
},
{
"input": "./packages/nx-plugin",
"glob": "migrations.json",
"output": "."
}
]
}
Expand Down Expand Up @@ -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": [
Expand Down
109 changes: 109 additions & 0 deletions packages/nx-plugin/src/utils/migration-versions.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
});
118 changes: 118 additions & 0 deletions packages/nx-plugin/src/utils/migration-versions.ts
Original file line number Diff line number Diff line change
@@ -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<string, { version?: string } & Record<string, unknown>>;
}

/**
* 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<string, string>,
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 },
]),
),
};
};
Loading
Loading