chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
type ExecutorContext,
|
||||
type ProjectGraphProjectNode,
|
||||
logger,
|
||||
workspaceRoot,
|
||||
} from '@nx/devkit';
|
||||
import { signalToCode } from '@nx/devkit/internal';
|
||||
import { exec } from 'child_process';
|
||||
import type { DockerReleasePublishSchema } from './schema';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { getDockerVersionPath } from '../../release/version-utils';
|
||||
|
||||
export interface NormalizedDockerReleasePublishSchema {
|
||||
quiet: boolean;
|
||||
imageReference: string;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export const LARGE_BUFFER = 1024 * 1000000;
|
||||
|
||||
export default async function dockerReleasePublish(
|
||||
schema: DockerReleasePublishSchema,
|
||||
context: ExecutorContext
|
||||
) {
|
||||
const projectConfig = context.projectGraph.nodes[context.projectName];
|
||||
const options = await normalizeOptions(projectConfig, schema);
|
||||
if (!options.dryRun) {
|
||||
const digest = await dockerPush(options.imageReference, options.quiet);
|
||||
logger.log(
|
||||
`Successfully pushed ${options.imageReference}${
|
||||
options.quiet ? `. Digest: ${digest}` : ''
|
||||
}`
|
||||
);
|
||||
} else {
|
||||
logger.log(
|
||||
`Docker Image ${options.imageReference} was not pushed as --dry-run is enabled.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function normalizeOptions(
|
||||
projectConfig: ProjectGraphProjectNode,
|
||||
schema: DockerReleasePublishSchema
|
||||
): Promise<NormalizedDockerReleasePublishSchema> {
|
||||
return {
|
||||
quiet: schema.quiet ?? false,
|
||||
imageReference: await findImageReference(projectConfig, schema),
|
||||
dryRun: process.env.NX_DRY_RUN === 'true' || schema.dryRun || false,
|
||||
};
|
||||
}
|
||||
|
||||
async function findImageReference(
|
||||
projectConfig: ProjectGraphProjectNode,
|
||||
schema: DockerReleasePublishSchema
|
||||
) {
|
||||
let imageRef = readVersionFromFile(projectConfig.data.root);
|
||||
if (imageRef) {
|
||||
if (await checkDockerImageExistsLocally(imageRef)) {
|
||||
return imageRef;
|
||||
}
|
||||
throw new Error(
|
||||
`Could not find Docker Image ${imageRef}. Did you run 'nx release version'?`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readVersionFromFile(projectRoot: string) {
|
||||
const versionFilePath = getDockerVersionPath(workspaceRoot, projectRoot);
|
||||
if (!existsSync(versionFilePath)) {
|
||||
throw new Error(
|
||||
`Could not find ${versionFilePath} file. Did you run 'nx release version'?`
|
||||
);
|
||||
}
|
||||
|
||||
const version = readFileSync(versionFilePath, { encoding: 'utf8' });
|
||||
return version.trim();
|
||||
}
|
||||
|
||||
async function checkDockerImageExistsLocally(imageRef: string) {
|
||||
try {
|
||||
return await new Promise((res) => {
|
||||
// If the ref starts with 'docker.io/', then we need to strip it since it is the default value and Docker CLI will not find it.
|
||||
const normalizedImageRef = imageRef.startsWith('docker.io/')
|
||||
? imageRef.split('docker.io/')[1]
|
||||
: imageRef;
|
||||
const childProcess = exec(
|
||||
`docker images --filter "reference=${normalizedImageRef}" --quiet`,
|
||||
{ encoding: 'utf8', windowsHide: true }
|
||||
);
|
||||
let result = '';
|
||||
childProcess.stdout?.on('data', (data) => {
|
||||
result += data;
|
||||
});
|
||||
childProcess.stderr?.on('data', (data) => {
|
||||
console.error(data);
|
||||
});
|
||||
childProcess.on('error', (error) => {
|
||||
console.error('Docker command failed:', error);
|
||||
res(false);
|
||||
});
|
||||
childProcess.on('exit', () => {
|
||||
res(result.trim().length > 0);
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function dockerPush(imageReference: string, quiet: boolean) {
|
||||
try {
|
||||
return await new Promise((res, rej) => {
|
||||
const childProcess = exec(
|
||||
`docker push ${imageReference}${quiet ? ' --quiet' : ''}`,
|
||||
{
|
||||
encoding: 'utf8',
|
||||
maxBuffer: LARGE_BUFFER,
|
||||
windowsHide: true,
|
||||
}
|
||||
);
|
||||
let result = '';
|
||||
childProcess.stdout?.on('data', (data) => {
|
||||
result += data;
|
||||
if (!quiet) {
|
||||
console.log(data);
|
||||
}
|
||||
});
|
||||
childProcess.stderr?.on('data', (data) => {
|
||||
console.error(data);
|
||||
});
|
||||
childProcess.on('error', (error) => {
|
||||
rej(error);
|
||||
});
|
||||
childProcess.on('exit', (code, signal) => {
|
||||
if (code === null) code = signalToCode(signal);
|
||||
if (code === 0) {
|
||||
res(result.trim());
|
||||
} else {
|
||||
rej(new Error(`Docker push failed with exit code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(`Failed to push ${imageReference}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface DockerReleasePublishSchema {
|
||||
dryRun?: boolean;
|
||||
quiet?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/schema",
|
||||
"version": 2,
|
||||
"title": "Implementation details of `nx release publish`",
|
||||
"description": "DO NOT INVOKE DIRECTLY WITH `nx run`. Use `nx release publish` instead.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dryRun": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run the command without actually publishing the image to the registry."
|
||||
},
|
||||
"quiet": {
|
||||
"type": "boolean",
|
||||
"description": "Suppress verbose output",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { addPlugin } from '@nx/devkit/internal';
|
||||
import {
|
||||
type Tree,
|
||||
type GeneratorCallback,
|
||||
readNxJson,
|
||||
createProjectGraphAsync,
|
||||
addDependenciesToPackageJson,
|
||||
formatFiles,
|
||||
runTasksInSerial,
|
||||
logger,
|
||||
updateNxJson,
|
||||
} from '@nx/devkit';
|
||||
import { InitGeneratorSchema } from './schema';
|
||||
import { createNodes } from '../../plugins/plugin';
|
||||
import { nxVersion } from '../../utils/versions';
|
||||
|
||||
export function updateDependencies(tree: Tree, schema: InitGeneratorSchema) {
|
||||
return addDependenciesToPackageJson(
|
||||
tree,
|
||||
{},
|
||||
{
|
||||
'@nx/docker': nxVersion,
|
||||
},
|
||||
undefined,
|
||||
schema.keepExistingVersions
|
||||
);
|
||||
}
|
||||
|
||||
function addPluginToNxJson(tree: Tree, updatePackageScripts: boolean) {
|
||||
if (!tree.exists('nx.json')) {
|
||||
logger.warn(
|
||||
'"nx.json" not found. Skipping "@nx/docker" plugin registration.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const nxJson = readNxJson(tree);
|
||||
if (!nxJson) {
|
||||
logger.warn(
|
||||
'Unable to read "nx.json" content. Skipping "@nx/docker" plugin registration.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
nxJson.plugins ??= [];
|
||||
const pluginExists = nxJson.plugins.some((plugin) =>
|
||||
typeof plugin === 'string'
|
||||
? plugin === '@nx/docker'
|
||||
: plugin?.plugin === '@nx/docker'
|
||||
);
|
||||
if (pluginExists) {
|
||||
logger.info('"@nx/docker" plugin is already registered in "nx.json".');
|
||||
return;
|
||||
}
|
||||
|
||||
nxJson.plugins.push({
|
||||
plugin: '@nx/docker',
|
||||
options: {
|
||||
buildTarget: { name: 'docker:build' },
|
||||
runTarget: { name: 'docker:run' },
|
||||
},
|
||||
});
|
||||
updateNxJson(tree, nxJson);
|
||||
logger.info('Added "@nx/docker" to plugins array in "nx.json".');
|
||||
}
|
||||
|
||||
export async function initGenerator(tree: Tree, schema: InitGeneratorSchema) {
|
||||
logger.warn(
|
||||
`Docker support is experimental. Breaking changes may occur and not adhere to semver versioning.`
|
||||
);
|
||||
|
||||
const tasks: GeneratorCallback[] = [];
|
||||
if (!schema.skipPackageJson && tree.exists('package.json')) {
|
||||
tasks.push(updateDependencies(tree, schema));
|
||||
}
|
||||
|
||||
addPluginToNxJson(tree, schema.updatePackageScripts);
|
||||
|
||||
if (!schema.skipFormat) {
|
||||
await formatFiles(tree);
|
||||
}
|
||||
|
||||
return runTasksInSerial(...tasks);
|
||||
}
|
||||
|
||||
export default initGenerator;
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface InitGeneratorSchema {
|
||||
rootProject?: boolean;
|
||||
keepExistingVersions?: boolean;
|
||||
updatePackageScripts?: boolean;
|
||||
skipFormat?: boolean;
|
||||
skipPackageJson?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/schema",
|
||||
"$id": "Init",
|
||||
"title": "Nx Docker Init Generator",
|
||||
"type": "object",
|
||||
"description": "Docker init generator.",
|
||||
"properties": {
|
||||
"rootProject": {
|
||||
"type": "boolean",
|
||||
"x-priority": "internal"
|
||||
},
|
||||
"keepExistingVersions": {
|
||||
"type": "boolean",
|
||||
"x-priority": "internal",
|
||||
"description": "Keep existing dependencies versions",
|
||||
"default": false
|
||||
},
|
||||
"updatePackageScripts": {
|
||||
"type": "boolean",
|
||||
"x-priority": "internal",
|
||||
"description": "Update package scripts",
|
||||
"default": false
|
||||
},
|
||||
"skipFormat": {
|
||||
"description": "Skip formatting files.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"skipPackageJson": {
|
||||
"description": "Do not add dependencies to `package.json`.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
#### Rename `createNodesV2` imports to `createNodes`
|
||||
|
||||
`@nx/docker` renamed its primary inferred-plugin export from `createNodesV2` to `createNodes`. The `createNodesV2` name is preserved as a deprecated alias for now, but new code should use `createNodes`.
|
||||
|
||||
This migration scans every `.ts`, `.tsx`, `.cts`, and `.mts` file in your workspace and rewrites named imports and re-exports of `createNodesV2` from `@nx/docker` to `createNodes`.
|
||||
|
||||
#### Sample Code Changes
|
||||
|
||||
##### Before
|
||||
|
||||
```ts
|
||||
import { createNodesV2 } from '@nx/docker';
|
||||
```
|
||||
|
||||
##### After
|
||||
|
||||
```ts
|
||||
import { createNodes } from '@nx/docker';
|
||||
```
|
||||
|
||||
Aliases are preserved (`createNodesV2 as cn` becomes `createNodes as cn`), and if a file already imports both names (`{ createNodes, createNodesV2 }`) the redundant binding is dropped.
|
||||
|
||||
#### What is not rewritten
|
||||
|
||||
Only static `import`/`export` named bindings from `@nx/docker` are rewritten. Namespace imports, dynamic `import(...)`, `require(...)` destructuring, and property access such as `plugin.createNodesV2` are left untouched — they keep working through the `createNodesV2` runtime alias. Update those by hand if you want to drop the deprecated name everywhere.
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
import { type Tree } from '@nx/devkit';
|
||||
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
||||
import migration, {
|
||||
rewriteCreateNodesV2Imports,
|
||||
} from './migrate-create-nodes-v2-to-create-nodes';
|
||||
|
||||
const SPECIFIERS = new Set(['@nx/docker']);
|
||||
const rewrite = (source: string) =>
|
||||
rewriteCreateNodesV2Imports(source, SPECIFIERS);
|
||||
|
||||
describe('migrate-create-nodes-v2-to-create-nodes migration', () => {
|
||||
let tree: Tree;
|
||||
|
||||
beforeEach(() => {
|
||||
tree = createTreeWithEmptyWorkspace();
|
||||
});
|
||||
|
||||
describe('rewriteCreateNodesV2Imports', () => {
|
||||
it('renames a lone createNodesV2 named import', () => {
|
||||
const input = `import { createNodesV2 } from '@nx/docker';\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import { createNodes } from '@nx/docker';\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves other named imports while renaming createNodesV2', () => {
|
||||
const input = `import { createNodesV2, SomeOtherExport } from '@nx/docker';\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import { createNodes, SomeOtherExport } from '@nx/docker';\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites the original name in `as` aliases', () => {
|
||||
const input = `import { createNodesV2 as cn } from '@nx/docker';\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import { createNodes as cn } from '@nx/docker';\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses a redundant `createNodesV2 as createNodes` alias', () => {
|
||||
const input = `import { createNodesV2 as createNodes } from '@nx/docker';\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import { createNodes } from '@nx/docker';\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('dedupes when both createNodes and createNodesV2 are imported', () => {
|
||||
const input = `import { createNodes, createNodesV2 } from '@nx/docker';\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import { createNodes } from '@nx/docker';\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('dedupes when createNodesV2 precedes createNodes', () => {
|
||||
const input = `import { createNodesV2, createNodes } from '@nx/docker';\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import { createNodes } from '@nx/docker';\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('handles multi-line named imports', () => {
|
||||
const input = `import {\n createNodes,\n createNodesV2,\n SomeOtherExport,\n} from '@nx/docker';\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import { createNodes, SomeOtherExport } from '@nx/docker';\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a default import alongside the renamed binding', () => {
|
||||
const input = `import def, { createNodesV2 } from '@nx/docker';\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import def, { createNodes } from '@nx/docker';\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('handles `import type` declarations', () => {
|
||||
const input = `import type { createNodesV2 } from '@nx/docker';\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import type { createNodes } from '@nx/docker';\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves inline `type` modifiers', () => {
|
||||
const input = `import { type createNodesV2 } from '@nx/docker';\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import { type createNodes } from '@nx/docker';\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites named re-exports', () => {
|
||||
const input = `export { createNodesV2 } from '@nx/docker';\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`export { createNodes } from '@nx/docker';\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites aliased named re-exports', () => {
|
||||
const input = `export { createNodesV2 as cn } from '@nx/docker';\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`export { createNodes as cn } from '@nx/docker';\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('does not touch createNodesV2 from a different specifier', () => {
|
||||
const input = `import { createNodesV2 } from '@nx/other-plugin/plugin';\n`;
|
||||
expect(rewrite(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('does not touch createNodesV2 from a relative specifier', () => {
|
||||
const input = `import { createNodesV2 } from './plugin';\n`;
|
||||
expect(rewrite(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('does not touch unrelated imports from the target specifier', () => {
|
||||
const input = `import { createNodes } from '@nx/docker';\n`;
|
||||
expect(rewrite(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('does not rewrite `export * from` declarations', () => {
|
||||
const input = `export * from '@nx/docker';\n`;
|
||||
expect(rewrite(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('leaves createNodesV2 in strings and comments alone', () => {
|
||||
const input =
|
||||
`// import { createNodesV2 } from '@nx/docker';\n` +
|
||||
`const s = "createNodesV2";\n`;
|
||||
expect(rewrite(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('does not rewrite require() destructuring (still valid via alias)', () => {
|
||||
const input = `const { createNodesV2 } = require('@nx/docker');\n`;
|
||||
expect(rewrite(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('renames value usages of a lone createNodesV2 import', () => {
|
||||
const input =
|
||||
`import { createNodesV2 } from '@nx/docker';\n` +
|
||||
`export const plugin = createNodesV2;\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import { createNodes } from '@nx/docker';\n` +
|
||||
`export const plugin = createNodes;\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('renames a value usage passed as a call argument', () => {
|
||||
const input =
|
||||
`import { createNodesV2 } from '@nx/docker';\n` +
|
||||
`addPlugin(graph, '@nx/docker', createNodesV2, {});\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import { createNodes } from '@nx/docker';\n` +
|
||||
`addPlugin(graph, '@nx/docker', createNodes, {});\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('renames value usages when deduped against an existing createNodes', () => {
|
||||
const input =
|
||||
`import { createNodes, createNodesV2 } from '@nx/docker';\n` +
|
||||
`use(createNodesV2);\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import { createNodes } from '@nx/docker';\n` + `use(createNodes);\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('expands a shorthand property usage to preserve the key', () => {
|
||||
const input =
|
||||
`import { createNodesV2 } from '@nx/docker';\n` +
|
||||
`export const plugins = { createNodesV2 };\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import { createNodes } from '@nx/docker';\n` +
|
||||
`export const plugins = { createNodesV2: createNodes };\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves property accesses and strings while renaming the binding', () => {
|
||||
const input =
|
||||
`import { createNodesV2 } from '@nx/docker';\n` +
|
||||
`const v = createNodesV2;\n` +
|
||||
`const w = config.createNodesV2;\n` +
|
||||
`const s = 'createNodesV2';\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import { createNodes } from '@nx/docker';\n` +
|
||||
`const v = createNodes;\n` +
|
||||
`const w = config.createNodesV2;\n` +
|
||||
`const s = 'createNodesV2';\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('does not rename usages for an aliased import (local name preserved)', () => {
|
||||
const input =
|
||||
`import { createNodesV2 as cn } from '@nx/docker';\n` +
|
||||
`export const plugin = cn;\n`;
|
||||
expect(rewrite(input)).toBe(
|
||||
`import { createNodes as cn } from '@nx/docker';\n` +
|
||||
`export const plugin = cn;\n`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration runner', () => {
|
||||
it('rewrites imports across .ts/.tsx/.cts/.mts files', async () => {
|
||||
tree.write(
|
||||
'libs/foo/src/a.ts',
|
||||
`import { createNodesV2 } from '@nx/docker';\n`
|
||||
);
|
||||
tree.write(
|
||||
'libs/foo/src/b.tsx',
|
||||
`import { createNodesV2 as cn } from '@nx/docker';\n`
|
||||
);
|
||||
tree.write(
|
||||
'libs/foo/src/c.cts',
|
||||
`export { createNodesV2 } from '@nx/docker';\n`
|
||||
);
|
||||
tree.write(
|
||||
'libs/foo/src/d.mts',
|
||||
`import { createNodesV2, SomeOtherExport } from '@nx/docker';\n`
|
||||
);
|
||||
|
||||
await migration(tree);
|
||||
|
||||
expect(tree.read('libs/foo/src/a.ts', 'utf-8')).toContain(
|
||||
`import { createNodes } from '@nx/docker';`
|
||||
);
|
||||
expect(tree.read('libs/foo/src/b.tsx', 'utf-8')).toContain(
|
||||
`import { createNodes as cn } from '@nx/docker';`
|
||||
);
|
||||
expect(tree.read('libs/foo/src/c.cts', 'utf-8')).toContain(
|
||||
`export { createNodes } from '@nx/docker';`
|
||||
);
|
||||
expect(tree.read('libs/foo/src/d.mts', 'utf-8')).toContain(
|
||||
`import { createNodes, SomeOtherExport } from '@nx/docker';`
|
||||
);
|
||||
});
|
||||
|
||||
it('does not touch files without the deprecated name', async () => {
|
||||
const content = `import { createNodes } from '@nx/docker';\n`;
|
||||
tree.write('libs/foo/src/index.ts', content);
|
||||
|
||||
await migration(tree);
|
||||
|
||||
expect(tree.read('libs/foo/src/index.ts', 'utf-8')).toBe(content);
|
||||
});
|
||||
|
||||
it('does not rewrite non-TS files', async () => {
|
||||
const md = `Example: \`import { createNodesV2 } from '@nx/docker';\`\n`;
|
||||
tree.write('docs/example.md', md);
|
||||
|
||||
await migration(tree);
|
||||
|
||||
expect(tree.read('docs/example.md', 'utf-8')).toContain(
|
||||
`import { createNodesV2 } from '@nx/docker';`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
import {
|
||||
applyChangesToString,
|
||||
ChangeType,
|
||||
ensurePackage,
|
||||
formatFiles,
|
||||
logger,
|
||||
type StringChange,
|
||||
type Tree,
|
||||
visitNotIgnoredFiles,
|
||||
} from '@nx/devkit';
|
||||
import type {
|
||||
ExportDeclaration,
|
||||
ExportSpecifier,
|
||||
Identifier,
|
||||
ImportDeclaration,
|
||||
ImportSpecifier,
|
||||
NamedExports,
|
||||
NamedImports,
|
||||
Node,
|
||||
SourceFile,
|
||||
} from 'typescript';
|
||||
|
||||
const TS_EXTENSIONS = ['.ts', '.tsx', '.cts', '.mts'] as const;
|
||||
const DEPRECATED_NAME = 'createNodesV2';
|
||||
const CANONICAL_NAME = 'createNodes';
|
||||
|
||||
// Module specifiers from which `@nx/docker` publicly exposes `createNodesV2`.
|
||||
// A named import or re-export of `createNodesV2` from one of these is rewritten
|
||||
// to the canonical `createNodes` export.
|
||||
const TARGET_SPECIFIERS: ReadonlySet<string> = new Set(['@nx/docker']);
|
||||
|
||||
let ts: typeof import('typescript') | undefined;
|
||||
|
||||
export default async function migrateCreateNodesV2ToCreateNodes(
|
||||
tree: Tree
|
||||
): Promise<void> {
|
||||
let touchedCount = 0;
|
||||
|
||||
visitNotIgnoredFiles(tree, '.', (filePath) => {
|
||||
if (!TS_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
|
||||
return;
|
||||
}
|
||||
const original = tree.read(filePath, 'utf-8');
|
||||
if (!original || !original.includes(DEPRECATED_NAME)) {
|
||||
return;
|
||||
}
|
||||
const updated = rewriteCreateNodesV2Imports(original, TARGET_SPECIFIERS);
|
||||
if (updated !== original) {
|
||||
tree.write(filePath, updated);
|
||||
touchedCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (touchedCount > 0) {
|
||||
logger.info(
|
||||
`Renamed \`${DEPRECATED_NAME}\` imports to \`${CANONICAL_NAME}\` in ${touchedCount} file(s).`
|
||||
);
|
||||
}
|
||||
|
||||
await formatFiles(tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites named imports and re-exports of `createNodesV2` to `createNodes`
|
||||
* when they come from one of the given module specifiers. Only the named
|
||||
* bindings are touched — the module specifier, the `import`/`export` keyword,
|
||||
* any `type` modifier, and any default import are left untouched.
|
||||
*/
|
||||
export function rewriteCreateNodesV2Imports(
|
||||
source: string,
|
||||
specifiers: ReadonlySet<string>
|
||||
): string {
|
||||
ts ??= ensurePackage<typeof import('typescript')>('typescript', '*');
|
||||
const sourceFile = ts.createSourceFile(
|
||||
'tmp.ts',
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
/* setParentNodes */ true,
|
||||
ts.ScriptKind.TSX
|
||||
);
|
||||
|
||||
const changes: StringChange[] = [];
|
||||
let renameLocalUsages = false;
|
||||
for (const stmt of sourceFile.statements) {
|
||||
if (ts.isImportDeclaration(stmt)) {
|
||||
renameLocalUsages =
|
||||
collectImportRewrite(sourceFile, stmt, specifiers, changes) ||
|
||||
renameLocalUsages;
|
||||
} else if (ts.isExportDeclaration(stmt)) {
|
||||
collectExportRewrite(sourceFile, stmt, specifiers, changes);
|
||||
}
|
||||
}
|
||||
|
||||
// Renaming a local `createNodesV2` import binding to `createNodes` (a lone
|
||||
// `{ createNodesV2 }`, or one deduped against an existing `createNodes`)
|
||||
// changes the name in scope, so value references to `createNodesV2` in the
|
||||
// file body must be renamed too — otherwise they dangle. Aliased imports and
|
||||
// re-exports keep their local name, so they never trigger this.
|
||||
if (renameLocalUsages) {
|
||||
collectValueUsageRewrites(sourceFile, changes);
|
||||
}
|
||||
|
||||
return changes.length > 0 ? applyChangesToString(source, changes) : source;
|
||||
}
|
||||
|
||||
function isTargetSpecifier(
|
||||
node: ImportDeclaration['moduleSpecifier'],
|
||||
specifiers: ReadonlySet<string>
|
||||
): boolean {
|
||||
return ts!.isStringLiteral(node) && specifiers.has(node.text);
|
||||
}
|
||||
|
||||
function collectImportRewrite(
|
||||
sourceFile: SourceFile,
|
||||
stmt: ImportDeclaration,
|
||||
specifiers: ReadonlySet<string>,
|
||||
changes: StringChange[]
|
||||
): boolean {
|
||||
if (!isTargetSpecifier(stmt.moduleSpecifier, specifiers)) {
|
||||
return false;
|
||||
}
|
||||
const namedBindings = stmt.importClause?.namedBindings;
|
||||
// Only `import { ... }` carries renameable named bindings. `import x`,
|
||||
// `import * as ns`, and side-effect imports reference the module wholesale
|
||||
// and keep working through the `createNodesV2` runtime alias, so we leave
|
||||
// them be. A mixed `import def, { createNodesV2 }` still has its named
|
||||
// bindings rewritten below — the default binding is untouched.
|
||||
if (!namedBindings || !ts!.isNamedImports(namedBindings)) {
|
||||
return false;
|
||||
}
|
||||
// The local `createNodesV2` binding only disappears when it is imported
|
||||
// without an alias — a lone `{ createNodesV2 }` or one deduped against an
|
||||
// existing `createNodes`. `{ createNodesV2 as x }` keeps the local `x`, so
|
||||
// its in-file usages are unaffected and must not be rewritten.
|
||||
const localBindingRenamed = (
|
||||
namedBindings.elements as readonly ImportSpecifier[]
|
||||
).some(
|
||||
(el) =>
|
||||
el.name.text === DEPRECATED_NAME &&
|
||||
(el.propertyName ?? el.name).text === DEPRECATED_NAME
|
||||
);
|
||||
rewriteNamedBindings(sourceFile, namedBindings, changes);
|
||||
return localBindingRenamed;
|
||||
}
|
||||
|
||||
function collectExportRewrite(
|
||||
sourceFile: SourceFile,
|
||||
stmt: ExportDeclaration,
|
||||
specifiers: ReadonlySet<string>,
|
||||
changes: StringChange[]
|
||||
): void {
|
||||
if (
|
||||
!stmt.moduleSpecifier ||
|
||||
!isTargetSpecifier(stmt.moduleSpecifier, specifiers)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// `export { ... } from '...'` can be rewritten; `export * from '...'` has no
|
||||
// named bindings to rename.
|
||||
if (!stmt.exportClause || !ts!.isNamedExports(stmt.exportClause)) {
|
||||
return;
|
||||
}
|
||||
rewriteNamedBindings(sourceFile, stmt.exportClause, changes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-renders the `{ ... }` of a named import/export, renaming any
|
||||
* `createNodesV2` specifier to `createNodes`. If renaming would collide with a
|
||||
* `createNodes` that is already present (e.g. `{ createNodes, createNodesV2 }`),
|
||||
* the duplicate is dropped. Returns without recording a change when the binding
|
||||
* list contains no `createNodesV2`.
|
||||
*/
|
||||
function rewriteNamedBindings(
|
||||
sourceFile: SourceFile,
|
||||
namedBindings: NamedImports | NamedExports,
|
||||
changes: StringChange[]
|
||||
): void {
|
||||
const elements = namedBindings.elements as readonly (
|
||||
| ImportSpecifier
|
||||
| ExportSpecifier
|
||||
)[];
|
||||
const hasDeprecated = elements.some(
|
||||
(el) => (el.propertyName ?? el.name).text === DEPRECATED_NAME
|
||||
);
|
||||
if (!hasDeprecated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const rendered: string[] = [];
|
||||
for (const el of elements) {
|
||||
const text = renderSpecifier(el);
|
||||
if (!seen.has(text)) {
|
||||
seen.add(text);
|
||||
rendered.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
const start = namedBindings.getStart(sourceFile);
|
||||
changes.push(
|
||||
{
|
||||
type: ChangeType.Delete,
|
||||
start,
|
||||
length: namedBindings.getEnd() - start,
|
||||
},
|
||||
{
|
||||
type: ChangeType.Insert,
|
||||
index: start,
|
||||
text: `{ ${rendered.join(', ')} }`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function renderSpecifier(el: ImportSpecifier | ExportSpecifier): string {
|
||||
const typePrefix = el.isTypeOnly ? 'type ' : '';
|
||||
const rename = (name: string) =>
|
||||
name === DEPRECATED_NAME ? CANONICAL_NAME : name;
|
||||
|
||||
// `{ name }` — no alias, so the local binding follows the rename.
|
||||
if (!el.propertyName) {
|
||||
return `${typePrefix}${rename(el.name.text)}`;
|
||||
}
|
||||
|
||||
// `{ propertyName as name }` — only the imported (left) side is renamed; the
|
||||
// local alias is preserved. A now-redundant alias such as
|
||||
// `createNodesV2 as createNodes` collapses to `createNodes`.
|
||||
const canonicalImported = rename(el.propertyName.text);
|
||||
const localName = el.name.text;
|
||||
return canonicalImported === localName
|
||||
? `${typePrefix}${localName}`
|
||||
: `${typePrefix}${canonicalImported} as ${localName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames value references of `createNodesV2` to `createNodes` in the file
|
||||
* body. Only called once a local `createNodesV2` import binding has actually
|
||||
* been renamed, so these references would otherwise dangle. Occurrences that
|
||||
* are not references to that binding are skipped: the import/export
|
||||
* declarations themselves, property accesses (`x.createNodesV2`), qualified
|
||||
* type names, object-literal keys, and declaration names that shadow the
|
||||
* import. A shorthand property (`{ createNodesV2 }`) is expanded to
|
||||
* `{ createNodesV2: createNodes }` so the property key is preserved. Strings
|
||||
* and comments are never `Identifier` nodes, so they are left alone.
|
||||
*/
|
||||
function collectValueUsageRewrites(
|
||||
sourceFile: SourceFile,
|
||||
changes: StringChange[]
|
||||
): void {
|
||||
const visit = (node: Node): void => {
|
||||
if (
|
||||
ts!.isIdentifier(node) &&
|
||||
node.text === DEPRECATED_NAME &&
|
||||
isRenamableValueUsage(node)
|
||||
) {
|
||||
const start = node.getStart(sourceFile);
|
||||
changes.push(
|
||||
{
|
||||
type: ChangeType.Delete,
|
||||
start,
|
||||
length: node.getEnd() - start,
|
||||
},
|
||||
{
|
||||
type: ChangeType.Insert,
|
||||
index: start,
|
||||
text: ts!.isShorthandPropertyAssignment(node.parent)
|
||||
? `${DEPRECATED_NAME}: ${CANONICAL_NAME}`
|
||||
: CANONICAL_NAME,
|
||||
}
|
||||
);
|
||||
}
|
||||
node.forEachChild(visit);
|
||||
};
|
||||
sourceFile.forEachChild(visit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `createNodesV2` identifier is a value reference to the renamed
|
||||
* import binding, as opposed to a position that must be left untouched.
|
||||
*/
|
||||
function isRenamableValueUsage(node: Identifier): boolean {
|
||||
const parent = node.parent;
|
||||
if (!parent) {
|
||||
return false;
|
||||
}
|
||||
// Import/export bindings — already handled by the declaration rewrite.
|
||||
if (
|
||||
ts!.isImportSpecifier(parent) ||
|
||||
ts!.isExportSpecifier(parent) ||
|
||||
ts!.isImportClause(parent) ||
|
||||
ts!.isNamespaceImport(parent)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// `x.createNodesV2` / `X.createNodesV2` — a member name, not the binding.
|
||||
if (ts!.isPropertyAccessExpression(parent) && parent.name === node) {
|
||||
return false;
|
||||
}
|
||||
if (ts!.isQualifiedName(parent) && parent.right === node) {
|
||||
return false;
|
||||
}
|
||||
// `{ createNodesV2: ... }` — an object-literal key, not the binding.
|
||||
if (ts!.isPropertyAssignment(parent) && parent.name === node) {
|
||||
return false;
|
||||
}
|
||||
// A declaration whose name shadows the import (variable, param, etc.).
|
||||
if (
|
||||
(ts!.isVariableDeclaration(parent) ||
|
||||
ts!.isParameter(parent) ||
|
||||
ts!.isBindingElement(parent) ||
|
||||
ts!.isFunctionDeclaration(parent) ||
|
||||
ts!.isClassDeclaration(parent)) &&
|
||||
parent.name === node
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,386 @@
|
||||
import {
|
||||
calculateHashesForCreateNodes,
|
||||
getNamedInputs,
|
||||
PluginCache,
|
||||
} from '@nx/devkit/internal';
|
||||
import {
|
||||
type CreateNodes,
|
||||
type ProjectConfiguration,
|
||||
type TargetConfiguration,
|
||||
createNodesFromFiles,
|
||||
readJsonFile,
|
||||
CreateNodesContext,
|
||||
workspaceRoot,
|
||||
} from '@nx/devkit';
|
||||
import { hashObject } from 'nx/src/hasher/file-hasher';
|
||||
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
|
||||
import { existsSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { getLatestCommitSha } from 'nx/src/utils/git-utils';
|
||||
import { interpolateObject } from '../utils/interpolate-pattern';
|
||||
|
||||
export interface DockerTargetOptions {
|
||||
name: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
envFile?: string;
|
||||
cwd?: string;
|
||||
/**
|
||||
* Skip adding the default `--tag` argument to the Docker build command.
|
||||
* When set to `true`, you must provide your own tag via the `args` property.
|
||||
*
|
||||
* **Important:** Setting this to `true` opts out of Nx Release support for this project.
|
||||
* The automatic versioning and publishing features of `nx release` will not work
|
||||
* with Docker projects that have `skipDefaultTag` enabled.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
skipDefaultTag?: boolean;
|
||||
configurations?: Record<
|
||||
string,
|
||||
Omit<DockerTargetOptions, 'configurations' | 'name'>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface DockerPluginOptions {
|
||||
buildTarget?: string | DockerTargetOptions;
|
||||
runTarget?: string | DockerTargetOptions;
|
||||
}
|
||||
|
||||
interface NormalizedDockerPluginOptions {
|
||||
buildTarget: DockerTargetOptions;
|
||||
runTarget: DockerTargetOptions;
|
||||
}
|
||||
|
||||
type DockerTargets = Pick<ProjectConfiguration, 'targets' | 'metadata'>;
|
||||
|
||||
const dockerfileGlob = '**/Dockerfile';
|
||||
|
||||
export const createNodes: CreateNodes<DockerPluginOptions> = [
|
||||
dockerfileGlob,
|
||||
async (configFilePaths, options, context) => {
|
||||
const optionsHash = hashObject(options);
|
||||
const cachePath = join(
|
||||
workspaceDataDirectory,
|
||||
`docker-${optionsHash}.hash`
|
||||
);
|
||||
const targetsCache = new PluginCache<DockerTargets>(cachePath);
|
||||
const projectRoots = configFilePaths.map((c) => dirname(c));
|
||||
const normalizedOptions = normalizePluginOptions(options);
|
||||
// TODO(colum): investigate hashing only the dockerfile
|
||||
const hashes = await calculateHashesForCreateNodes(
|
||||
projectRoots,
|
||||
normalizedOptions,
|
||||
context
|
||||
);
|
||||
try {
|
||||
return await createNodesFromFiles(
|
||||
(configFile, _, context, idx) =>
|
||||
createNodesInternal(
|
||||
configFile,
|
||||
hashes[idx] + configFile,
|
||||
normalizedOptions,
|
||||
context,
|
||||
targetsCache
|
||||
),
|
||||
configFilePaths,
|
||||
options,
|
||||
context
|
||||
);
|
||||
} finally {
|
||||
targetsCache.writeToDisk();
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link createNodes} instead. This will be removed in Nx 24.
|
||||
*/
|
||||
export const createNodesV2 = createNodes;
|
||||
|
||||
async function createNodesInternal(
|
||||
configFilePath: string,
|
||||
hash: string,
|
||||
normalizedOptions: NormalizedDockerPluginOptions,
|
||||
context: CreateNodesContext,
|
||||
targetsCache: PluginCache<DockerTargets>
|
||||
) {
|
||||
const projectRoot = dirname(configFilePath);
|
||||
|
||||
if (!targetsCache.has(hash)) {
|
||||
targetsCache.set(
|
||||
hash,
|
||||
await createDockerTargets(projectRoot, normalizedOptions, context)
|
||||
);
|
||||
}
|
||||
|
||||
const { targets, metadata } = targetsCache.get(hash);
|
||||
|
||||
return {
|
||||
projects: {
|
||||
[projectRoot]: {
|
||||
root: projectRoot,
|
||||
targets,
|
||||
metadata,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function interpolateDockerTargetOptions(
|
||||
options: DockerTargetOptions,
|
||||
projectRoot: string,
|
||||
imageRef: string,
|
||||
context: CreateNodesContext
|
||||
): DockerTargetOptions {
|
||||
const commitSha = getLatestCommitSha();
|
||||
const projectName = getProjectName(projectRoot, context.workspaceRoot);
|
||||
|
||||
const tokens = {
|
||||
projectRoot,
|
||||
projectName,
|
||||
imageRef,
|
||||
currentDate: new Date(),
|
||||
commitSha,
|
||||
shortCommitSha: commitSha ? commitSha.slice(0, 7) : null,
|
||||
};
|
||||
|
||||
return interpolateObject(options, tokens);
|
||||
}
|
||||
|
||||
export function getProjectNameFromPath(
|
||||
projectRoot: string,
|
||||
workspaceRoot: string
|
||||
): string {
|
||||
const root = projectRoot === '.' ? workspaceRoot : projectRoot;
|
||||
const normalized = root
|
||||
.replace(/^[\\/]/, '')
|
||||
.replace(/[\\/\s]+/g, '-')
|
||||
.toLowerCase();
|
||||
return normalized.length > 128 ? normalized.slice(-128) : normalized;
|
||||
}
|
||||
|
||||
function getProjectName(projectRoot: string, workspaceRoot: string): string {
|
||||
const projectJsonPath = join(workspaceRoot, projectRoot, 'project.json');
|
||||
if (existsSync(projectJsonPath)) {
|
||||
const projectJson = readJsonFile(projectJsonPath);
|
||||
if (projectJson.name) {
|
||||
return projectJson.name;
|
||||
}
|
||||
}
|
||||
|
||||
const packageJsonPath = join(workspaceRoot, projectRoot, 'package.json');
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const packageJson = readJsonFile(packageJsonPath);
|
||||
if (packageJson.name) {
|
||||
return packageJson.name;
|
||||
}
|
||||
}
|
||||
|
||||
return getProjectNameFromPath(projectRoot, workspaceRoot);
|
||||
}
|
||||
|
||||
function buildTargetOptions(
|
||||
interpolatedTarget: DockerTargetOptions,
|
||||
projectRoot: string,
|
||||
imageRef: string,
|
||||
isRunTarget = false
|
||||
): Record<string, any> {
|
||||
const options: Record<string, any> = {
|
||||
cwd: interpolatedTarget.cwd ?? projectRoot,
|
||||
};
|
||||
|
||||
if (isRunTarget) {
|
||||
// Run target doesn't have default args
|
||||
if (interpolatedTarget.args) {
|
||||
options.args = interpolatedTarget.args;
|
||||
}
|
||||
} else {
|
||||
// Build target includes --tag default unless skipDefaultTag is true
|
||||
if (interpolatedTarget.skipDefaultTag) {
|
||||
options.args = interpolatedTarget.args ?? [];
|
||||
} else {
|
||||
options.args = [`--tag ${imageRef}`, ...(interpolatedTarget.args ?? [])];
|
||||
}
|
||||
}
|
||||
|
||||
if (interpolatedTarget.env) {
|
||||
options.env = interpolatedTarget.env;
|
||||
}
|
||||
|
||||
if (interpolatedTarget.envFile) {
|
||||
options.envFile = interpolatedTarget.envFile;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function buildTargetConfigurations(
|
||||
interpolatedTarget: DockerTargetOptions,
|
||||
projectRoot: string,
|
||||
imageRef: string,
|
||||
isRunTarget = false
|
||||
): Record<string, any> | undefined {
|
||||
if (!interpolatedTarget.configurations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const configurations: Record<string, any> = {};
|
||||
|
||||
for (const [configName, configOptions] of Object.entries(
|
||||
interpolatedTarget.configurations
|
||||
)) {
|
||||
// Each configuration gets the full treatment with defaults
|
||||
// Inherit skipDefaultTag from parent if not explicitly set in config
|
||||
configurations[configName] = buildTargetOptions(
|
||||
{
|
||||
...configOptions,
|
||||
name: interpolatedTarget.name,
|
||||
skipDefaultTag:
|
||||
configOptions.skipDefaultTag ?? interpolatedTarget.skipDefaultTag,
|
||||
},
|
||||
projectRoot,
|
||||
imageRef,
|
||||
isRunTarget
|
||||
);
|
||||
}
|
||||
|
||||
return configurations;
|
||||
}
|
||||
|
||||
async function createDockerTargets(
|
||||
projectRoot: string,
|
||||
options: NormalizedDockerPluginOptions,
|
||||
context: CreateNodesContext
|
||||
) {
|
||||
const imageRef = getProjectNameFromPath(projectRoot, workspaceRoot);
|
||||
|
||||
const interpolatedBuildTarget = interpolateDockerTargetOptions(
|
||||
options.buildTarget,
|
||||
projectRoot,
|
||||
imageRef,
|
||||
context
|
||||
);
|
||||
const interpolatedRunTarget = interpolateDockerTargetOptions(
|
||||
options.runTarget,
|
||||
projectRoot,
|
||||
imageRef,
|
||||
context
|
||||
);
|
||||
|
||||
const namedInputs = getNamedInputs(projectRoot, context);
|
||||
const targets: Record<string, TargetConfiguration> = {};
|
||||
const metadata = {
|
||||
targetGroups: {
|
||||
['Docker']: [
|
||||
interpolatedBuildTarget.name,
|
||||
interpolatedRunTarget.name,
|
||||
'nx-release-publish',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const buildOptions = buildTargetOptions(
|
||||
interpolatedBuildTarget,
|
||||
projectRoot,
|
||||
imageRef,
|
||||
false
|
||||
);
|
||||
const buildConfigurations = buildTargetConfigurations(
|
||||
interpolatedBuildTarget,
|
||||
projectRoot,
|
||||
imageRef,
|
||||
false
|
||||
);
|
||||
|
||||
targets[interpolatedBuildTarget.name] = {
|
||||
dependsOn: ['build', '^build'],
|
||||
command: `docker build .`,
|
||||
options: buildOptions,
|
||||
...(buildConfigurations && { configurations: buildConfigurations }),
|
||||
inputs: [
|
||||
...('production' in namedInputs
|
||||
? ['production', '^production']
|
||||
: ['default', '^default']),
|
||||
],
|
||||
metadata: {
|
||||
technologies: ['docker'],
|
||||
description: `Run Docker build`,
|
||||
help: {
|
||||
command: `docker build --help`,
|
||||
example: {
|
||||
options: {
|
||||
'cache-from': 'type=s3,region=eu-west-1,bucket=mybucket .',
|
||||
'cache-to': 'type=s3,region=eu-west-1,bucket=mybucket .',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const runOptions = buildTargetOptions(
|
||||
interpolatedRunTarget,
|
||||
projectRoot,
|
||||
imageRef,
|
||||
true
|
||||
);
|
||||
const runConfigurations = buildTargetConfigurations(
|
||||
interpolatedRunTarget,
|
||||
projectRoot,
|
||||
imageRef,
|
||||
true
|
||||
);
|
||||
|
||||
targets[interpolatedRunTarget.name] = {
|
||||
dependsOn: [interpolatedBuildTarget.name],
|
||||
command: `docker run {args} ${imageRef}`,
|
||||
options: runOptions,
|
||||
...(runConfigurations && { configurations: runConfigurations }),
|
||||
inputs: [
|
||||
...('production' in namedInputs
|
||||
? ['production', '^production']
|
||||
: ['default', '^default']),
|
||||
],
|
||||
metadata: {
|
||||
technologies: ['docker'],
|
||||
description: `Run Docker run`,
|
||||
help: {
|
||||
command: `docker run --help`,
|
||||
example: {
|
||||
options: {
|
||||
args: ['-p', '3000:3000'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
targets['nx-release-publish'] = {
|
||||
executor: '@nx/docker:release-publish',
|
||||
continuous: false,
|
||||
};
|
||||
|
||||
return { targets, metadata };
|
||||
}
|
||||
|
||||
function normalizePluginOptions(
|
||||
options: DockerPluginOptions
|
||||
): NormalizedDockerPluginOptions {
|
||||
const normalizeTarget = (
|
||||
target: string | DockerTargetOptions | undefined,
|
||||
defaultName: string
|
||||
): DockerTargetOptions => {
|
||||
if (typeof target === 'string') {
|
||||
return { name: target };
|
||||
}
|
||||
if (target && typeof target === 'object') {
|
||||
return { ...target, name: target.name ?? defaultName };
|
||||
}
|
||||
return { name: defaultName };
|
||||
};
|
||||
|
||||
return {
|
||||
buildTarget: normalizeTarget(options?.buildTarget, 'docker:build'),
|
||||
runTarget: normalizeTarget(options?.runTarget, 'docker:run'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { interpolateVersionPattern } from './version-pattern-utils';
|
||||
import * as gitUtils from 'nx/src/utils/git-utils';
|
||||
|
||||
jest.mock('nx/src/utils/git-utils');
|
||||
|
||||
describe('version-pattern-utils', () => {
|
||||
const mockGetLatestCommitSha =
|
||||
gitUtils.getLatestCommitSha as jest.MockedFunction<
|
||||
typeof gitUtils.getLatestCommitSha
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetLatestCommitSha.mockReturnValue(
|
||||
'abc123456789def0123456789abcdef012345678'
|
||||
);
|
||||
});
|
||||
|
||||
describe('interpolateVersionPattern', () => {
|
||||
it('should interpolate projectName token', () => {
|
||||
const result = interpolateVersionPattern('{projectName}', {
|
||||
projectName: 'my-app',
|
||||
});
|
||||
expect(result).toBe('my-app');
|
||||
});
|
||||
|
||||
it('should interpolate commitSha token', () => {
|
||||
const result = interpolateVersionPattern('{commitSha}', {});
|
||||
expect(result).toBe('abc123456789def0123456789abcdef012345678');
|
||||
});
|
||||
|
||||
it('should interpolate shortCommitSha token', () => {
|
||||
const result = interpolateVersionPattern('{shortCommitSha}', {});
|
||||
expect(result).toBe('abc1234');
|
||||
});
|
||||
|
||||
it('should use provided commitSha when available', () => {
|
||||
const result = interpolateVersionPattern('{commitSha}', {
|
||||
commitSha: 'custom-sha',
|
||||
});
|
||||
expect(result).toBe('custom-sha');
|
||||
});
|
||||
|
||||
it('should use provided shortCommitSha when available', () => {
|
||||
const result = interpolateVersionPattern('{shortCommitSha}', {
|
||||
shortCommitSha: 'short',
|
||||
});
|
||||
expect(result).toBe('short');
|
||||
});
|
||||
|
||||
it('should interpolate currentDate token with ISO format by default', () => {
|
||||
const testDate = new Date('2024-01-15T10:30:45.000Z');
|
||||
const result = interpolateVersionPattern('{currentDate}', {
|
||||
currentDate: testDate,
|
||||
});
|
||||
expect(result).toBe('2024-01-15T10:30:45.000Z');
|
||||
});
|
||||
|
||||
it('should interpolate currentDate with custom format YYYY.MM.DD', () => {
|
||||
const testDate = new Date('2024-01-15T10:30:45.000Z');
|
||||
const result = interpolateVersionPattern('{currentDate|YYYY.MM.DD}', {
|
||||
currentDate: testDate,
|
||||
});
|
||||
expect(result).toBe('2024.01.15');
|
||||
});
|
||||
|
||||
it('should interpolate currentDate with custom format YY.MM.DD', () => {
|
||||
const testDate = new Date('2024-01-15T10:30:45.000Z');
|
||||
const result = interpolateVersionPattern('{currentDate|YY.MM.DD}', {
|
||||
currentDate: testDate,
|
||||
});
|
||||
expect(result).toBe('24.01.15');
|
||||
});
|
||||
|
||||
it('should interpolate currentDate with custom format YYMM.DD', () => {
|
||||
const testDate = new Date('2024-01-15T10:30:45.000Z');
|
||||
const result = interpolateVersionPattern('{currentDate|YYMM.DD}', {
|
||||
currentDate: testDate,
|
||||
});
|
||||
expect(result).toBe('2401.15');
|
||||
});
|
||||
|
||||
it('should interpolate currentDate with time format HH:mm:ss', () => {
|
||||
const testDate = new Date('2024-01-15T10:30:45.000Z');
|
||||
const result = interpolateVersionPattern('{currentDate|HH:mm:ss}', {
|
||||
currentDate: testDate,
|
||||
});
|
||||
expect(result).toBe('10:30:45');
|
||||
});
|
||||
|
||||
it('should handle complex patterns with multiple tokens', () => {
|
||||
const testDate = new Date('2024-01-15T10:30:45.000Z');
|
||||
const result = interpolateVersionPattern(
|
||||
'{projectName}-{currentDate|YYMM.DD}.{shortCommitSha}',
|
||||
{
|
||||
projectName: 'api',
|
||||
currentDate: testDate,
|
||||
}
|
||||
);
|
||||
expect(result).toBe('api-2401.15.abc1234');
|
||||
});
|
||||
|
||||
it('should keep unknown tokens unchanged', () => {
|
||||
const result = interpolateVersionPattern('{unknownToken}', {
|
||||
projectName: 'my-app',
|
||||
});
|
||||
expect(result).toBe('{unknownToken}');
|
||||
});
|
||||
|
||||
it('should handle empty pattern', () => {
|
||||
const result = interpolateVersionPattern('', {
|
||||
projectName: 'my-app',
|
||||
});
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle pattern without tokens', () => {
|
||||
const result = interpolateVersionPattern('1.2.3', {
|
||||
projectName: 'my-app',
|
||||
});
|
||||
expect(result).toBe('1.2.3');
|
||||
});
|
||||
|
||||
it('should handle missing projectName', () => {
|
||||
const result = interpolateVersionPattern('{projectName}', {});
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should use current date when not provided', () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2025-07-25'));
|
||||
const result = interpolateVersionPattern('{currentDate|YYYY}', {});
|
||||
|
||||
// Should be current year
|
||||
const year = new Date().getFullYear().toString();
|
||||
expect(result).toBe(year);
|
||||
});
|
||||
|
||||
it('should handle malformed date format tokens gracefully', () => {
|
||||
const testDate = new Date('2024-01-15T10:30:45.000Z');
|
||||
const result = interpolateVersionPattern('{currentDate|invalid}', {
|
||||
currentDate: testDate,
|
||||
});
|
||||
// Should keep unknown format identifiers as-is
|
||||
expect(result).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should handle nested braces in format', () => {
|
||||
const result = interpolateVersionPattern('{projectName}-{version}', {
|
||||
projectName: 'app-{prod}',
|
||||
});
|
||||
expect(result).toBe('app-{prod}-{version}');
|
||||
});
|
||||
|
||||
it('should substitute versionActionsVersion token when provided', () => {
|
||||
const result = interpolateVersionPattern(
|
||||
'{projectName}-{versionActionsVersion}',
|
||||
{
|
||||
projectName: 'app',
|
||||
versionActionsVersion: '1.2.3',
|
||||
}
|
||||
);
|
||||
expect(result).toBe('app-1.2.3');
|
||||
});
|
||||
|
||||
it('should return versionActionsVersion when pattern is just versionActionsVersion token', () => {
|
||||
const result = interpolateVersionPattern('{versionActionsVersion}', {
|
||||
versionActionsVersion: '4.5.6',
|
||||
});
|
||||
expect(result).toBe('4.5.6');
|
||||
});
|
||||
|
||||
it('should handle special characters in projectName', () => {
|
||||
const result = interpolateVersionPattern('{projectName}', {
|
||||
projectName: '@org/package-name',
|
||||
});
|
||||
expect(result).toBe('@org/package-name');
|
||||
});
|
||||
|
||||
it('should handle month edge cases correctly', () => {
|
||||
// December (month 11 in JS, should be 12 in output)
|
||||
const december = new Date('2024-12-01T00:00:00.000Z');
|
||||
const resultDec = interpolateVersionPattern('{currentDate|MM}', {
|
||||
currentDate: december,
|
||||
});
|
||||
expect(resultDec).toBe('12');
|
||||
|
||||
// January (month 0 in JS, should be 01 in output)
|
||||
const january = new Date('2024-01-01T00:00:00.000Z');
|
||||
const resultJan = interpolateVersionPattern('{currentDate|MM}', {
|
||||
currentDate: january,
|
||||
});
|
||||
expect(resultJan).toBe('01');
|
||||
});
|
||||
|
||||
it('should pad single digit values correctly', () => {
|
||||
const testDate = new Date('2024-01-05T03:07:09.000Z');
|
||||
const result = interpolateVersionPattern(
|
||||
'{currentDate|YYYY-MM-DD HH:mm:ss}',
|
||||
{
|
||||
currentDate: testDate,
|
||||
}
|
||||
);
|
||||
expect(result).toBe('2024-01-05 03:07:09');
|
||||
});
|
||||
|
||||
describe('environment variable interpolation', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should interpolate environment variables', () => {
|
||||
process.env.BUILD_NUMBER = '123';
|
||||
const result = interpolateVersionPattern(
|
||||
'build-{env.BUILD_NUMBER}',
|
||||
{}
|
||||
);
|
||||
expect(result).toBe('build-123');
|
||||
});
|
||||
|
||||
it('should handle multiple environment variables', () => {
|
||||
process.env.STAGE = 'QA';
|
||||
process.env.BUILD_NUMBER = '456';
|
||||
const result = interpolateVersionPattern(
|
||||
'build-{env.STAGE}.{env.BUILD_NUMBER}',
|
||||
{}
|
||||
);
|
||||
expect(result).toBe('build-QA.456');
|
||||
});
|
||||
|
||||
it('should keep unknown environment variables as-is', () => {
|
||||
const result = interpolateVersionPattern(
|
||||
'build-{env.NON_EXISTENT_VAR}',
|
||||
{}
|
||||
);
|
||||
expect(result).toBe('build-{env.NON_EXISTENT_VAR}');
|
||||
});
|
||||
|
||||
it('should combine environment variables with other tokens', () => {
|
||||
process.env.TASK = 'builder';
|
||||
const result = interpolateVersionPattern(
|
||||
'{projectName}-{env.TASK}-{shortCommitSha}',
|
||||
{
|
||||
projectName: 'api',
|
||||
}
|
||||
);
|
||||
expect(result).toBe('api-builder-abc1234');
|
||||
});
|
||||
|
||||
it('should handle environment variables with underscores and numbers', () => {
|
||||
process.env.MY_VAR_123 = 'test-value';
|
||||
const result = interpolateVersionPattern('prefix-{env.MY_VAR_123}', {});
|
||||
expect(result).toBe('prefix-test-value');
|
||||
});
|
||||
|
||||
it('should handle empty environment variable values', () => {
|
||||
process.env.EMPTY_VAR = '';
|
||||
const result = interpolateVersionPattern(
|
||||
'prefix-{env.EMPTY_VAR}-suffix',
|
||||
{}
|
||||
);
|
||||
expect(result).toBe('prefix--suffix');
|
||||
});
|
||||
|
||||
it('should handle environment variables in complex patterns', () => {
|
||||
process.env.VERSION = '2.1.0';
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
const testDate = new Date('2024-01-15T10:30:45.000Z');
|
||||
const result = interpolateVersionPattern(
|
||||
'{env.VERSION}-{projectName}-{env.ENVIRONMENT}-{currentDate|YYYY.MM.DD}',
|
||||
{
|
||||
projectName: 'webapp',
|
||||
currentDate: testDate,
|
||||
}
|
||||
);
|
||||
expect(result).toBe('2.1.0-webapp-production-2024.01.15');
|
||||
});
|
||||
|
||||
it('should handle undefined environment variables', () => {
|
||||
delete process.env.UNDEFINED_VAR;
|
||||
const result = interpolateVersionPattern(
|
||||
'build-{env.UNDEFINED_VAR}',
|
||||
{}
|
||||
);
|
||||
expect(result).toBe('build-{env.UNDEFINED_VAR}');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getLatestCommitSha } from 'nx/src/utils/git-utils';
|
||||
import { interpolatePattern } from '../utils/interpolate-pattern';
|
||||
|
||||
/**
|
||||
* Support Tokens in Version Patterns
|
||||
* {projectName} - the name of the project
|
||||
* {currentDate} - the current date in YY.MM.DD format
|
||||
* {currentDate|DATE FORMAT} - the current date with custom format such as YYMM.DD
|
||||
* {commitSha} - The full commit sha for the current commit
|
||||
* {shortCommitSha} - The seven character commit sha for the current commit
|
||||
* {env.VAR_NAME} - The value of the environment variable VAR_NAME
|
||||
* {versionActionsVersion} - The version generated during the version actions such as "1.2.3"
|
||||
*/
|
||||
export interface PatternTokens {
|
||||
projectName: string;
|
||||
currentDate: Date;
|
||||
commitSha: string;
|
||||
shortCommitSha: string;
|
||||
versionActionsVersion: string;
|
||||
}
|
||||
|
||||
export function interpolateVersionPattern(
|
||||
versionPattern: string,
|
||||
data: Partial<PatternTokens>
|
||||
) {
|
||||
const commitSha = getLatestCommitSha();
|
||||
const substitutions: PatternTokens = {
|
||||
projectName: data.projectName ?? '',
|
||||
currentDate: data.currentDate ?? new Date(),
|
||||
commitSha: data.commitSha ?? commitSha,
|
||||
shortCommitSha: data.shortCommitSha ?? commitSha.slice(0, 7),
|
||||
versionActionsVersion: data.versionActionsVersion ?? '',
|
||||
};
|
||||
|
||||
return interpolatePattern(versionPattern, substitutions);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { handleDockerVersion } from './version-utils';
|
||||
import { prompt } from 'enquirer';
|
||||
|
||||
/** Minimal mock objects to satisfy types without importing full release graph machinery */
|
||||
const mockProjectNode: any = { name: 'my-app', data: { root: 'apps/my-app' } };
|
||||
const versionActionsVersion = '1.2.3';
|
||||
|
||||
jest.mock('enquirer', () => ({
|
||||
prompt: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('handleDockerVersion {versionActionsVersion} integration', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NX_DRY_RUN = 'true';
|
||||
delete process.env.NX_DOCKER_IMAGE_REF;
|
||||
});
|
||||
|
||||
it('interpolates {versionActionsVersion} within selected version scheme', async () => {
|
||||
const finalConfigForProject: any = {
|
||||
dockerOptions: {
|
||||
repositoryName: 'repo',
|
||||
registryUrl: undefined,
|
||||
versionSchemes: { prod: '{projectName}-{versionActionsVersion}' },
|
||||
},
|
||||
};
|
||||
|
||||
const { newVersion } = await handleDockerVersion(
|
||||
process.cwd(),
|
||||
mockProjectNode,
|
||||
finalConfigForProject,
|
||||
'prod',
|
||||
undefined,
|
||||
versionActionsVersion
|
||||
);
|
||||
|
||||
expect(newVersion).toBe('my-app-1.2.3');
|
||||
});
|
||||
|
||||
it('uses explicit dockerVersion when provided (bypassing scheme interpolation)', async () => {
|
||||
const finalConfigForProject: any = {
|
||||
dockerOptions: {
|
||||
repositoryName: 'repo',
|
||||
registryUrl: undefined,
|
||||
versionSchemes: { prod: '{projectName}-{versionActionsVersion}' },
|
||||
},
|
||||
};
|
||||
|
||||
const { newVersion } = await handleDockerVersion(
|
||||
process.cwd(),
|
||||
mockProjectNode,
|
||||
finalConfigForProject,
|
||||
undefined,
|
||||
'explicit-version',
|
||||
versionActionsVersion
|
||||
);
|
||||
|
||||
expect(newVersion).toBe('explicit-version');
|
||||
});
|
||||
|
||||
it('automatically picks the only available version scheme', async () => {
|
||||
const finalConfigForProject: any = {
|
||||
dockerOptions: {
|
||||
repositoryName: 'repo',
|
||||
registryUrl: undefined,
|
||||
versionSchemes: { prod: '{projectName}-{versionActionsVersion}' },
|
||||
},
|
||||
};
|
||||
|
||||
const { newVersion } = await handleDockerVersion(
|
||||
process.cwd(),
|
||||
mockProjectNode,
|
||||
finalConfigForProject,
|
||||
undefined,
|
||||
undefined,
|
||||
versionActionsVersion
|
||||
);
|
||||
|
||||
expect(newVersion).toBe('my-app-1.2.3');
|
||||
});
|
||||
|
||||
it('prompts for version scheme when multiple are available', async () => {
|
||||
const finalConfigForProject: any = {
|
||||
dockerOptions: {
|
||||
repositoryName: 'repo',
|
||||
registryUrl: undefined,
|
||||
versionSchemes: {
|
||||
prod: '{projectName}-{versionActionsVersion}',
|
||||
dev: '{projectName}-0.0.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mock prompt to return 'dev' scheme
|
||||
jest.mocked(prompt).mockResolvedValueOnce({ versionScheme: 'dev' });
|
||||
|
||||
const { newVersion } = await handleDockerVersion(
|
||||
process.cwd(),
|
||||
mockProjectNode,
|
||||
finalConfigForProject,
|
||||
undefined,
|
||||
undefined,
|
||||
versionActionsVersion
|
||||
);
|
||||
|
||||
expect(prompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'versionScheme',
|
||||
type: 'select',
|
||||
choices: expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'prod' }),
|
||||
expect.objectContaining({ name: 'dev' }),
|
||||
]),
|
||||
})
|
||||
);
|
||||
expect(newVersion).toBe('my-app-0.0.0');
|
||||
});
|
||||
|
||||
it("surfaces only the focused scheme's pattern via the footer", async () => {
|
||||
const finalConfigForProject: any = {
|
||||
dockerOptions: {
|
||||
repositoryName: 'repo',
|
||||
registryUrl: undefined,
|
||||
versionSchemes: {
|
||||
prod: '{projectName}-{versionActionsVersion}',
|
||||
dev: '{projectName}-0.0.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
jest.mocked(prompt).mockResolvedValueOnce({ versionScheme: 'dev' });
|
||||
|
||||
await handleDockerVersion(
|
||||
process.cwd(),
|
||||
mockProjectNode,
|
||||
finalConfigForProject,
|
||||
undefined,
|
||||
undefined,
|
||||
versionActionsVersion
|
||||
);
|
||||
|
||||
const call = jest.mocked(prompt).mock.calls[0][0] as any;
|
||||
const styles = { muted: (s: string) => s };
|
||||
const prodChoice = call.choices.find((c: any) => c.name === 'prod');
|
||||
expect(call.footer.call({ focused: prodChoice, styles })).toContain(
|
||||
prodChoice.description
|
||||
);
|
||||
expect(
|
||||
call.footer.call({ focused: { description: undefined }, styles })
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('falls back to env NX_DOCKER_IMAGE_REF tag if provided (extracting version)', async () => {
|
||||
process.env.NX_DOCKER_IMAGE_REF = 'registry.example.com/repo:9.9.9';
|
||||
const finalConfigForProject: any = {
|
||||
dockerOptions: {
|
||||
repositoryName: 'repo',
|
||||
registryUrl: 'registry.example.com',
|
||||
versionSchemes: { prod: '{projectName}-{versionActionsVersion}' },
|
||||
},
|
||||
};
|
||||
|
||||
const { newVersion } = await handleDockerVersion(
|
||||
process.cwd(),
|
||||
mockProjectNode,
|
||||
finalConfigForProject,
|
||||
'prod',
|
||||
undefined,
|
||||
versionActionsVersion
|
||||
);
|
||||
|
||||
expect(newVersion).toBe('9.9.9');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { prompt } from 'enquirer';
|
||||
import { ProjectGraphProjectNode, workspaceRoot } from '@nx/devkit';
|
||||
import type { FinalConfigForProject } from 'nx/src/command-line/release/utils/release-graph';
|
||||
import { interpolateVersionPattern } from './version-pattern-utils';
|
||||
|
||||
const DEFAULT_VERSION_SCHEMES = {
|
||||
production: '{currentDate|YYMM.DD}.{shortCommitSha}',
|
||||
hotfix: '{currentDate|YYMM.DD}.{shortCommitSha}-hotfix',
|
||||
};
|
||||
|
||||
export const getDockerVersionPath = (
|
||||
workspaceRoot: string,
|
||||
projectRoot: string
|
||||
) => {
|
||||
return join(workspaceRoot, 'tmp', projectRoot, '.docker-version');
|
||||
};
|
||||
|
||||
export async function handleDockerVersion(
|
||||
workspaceRoot: string,
|
||||
projectGraphNode: ProjectGraphProjectNode,
|
||||
finalConfigForProject: FinalConfigForProject,
|
||||
dockerVersionScheme?: string,
|
||||
dockerVersion?: string,
|
||||
versionActionsVersion?: string
|
||||
) {
|
||||
// If the full docker image reference is provided, use it directly
|
||||
const nxDockerImageRefEnvOverride =
|
||||
process.env.NX_DOCKER_IMAGE_REF?.trim() || undefined;
|
||||
// If an explicit dockerVersion is provided, use it directly
|
||||
let newVersion: string | undefined;
|
||||
|
||||
if (!nxDockerImageRefEnvOverride) {
|
||||
if (dockerVersion) {
|
||||
newVersion = dockerVersion;
|
||||
} else {
|
||||
const availableVersionSchemes =
|
||||
finalConfigForProject.dockerOptions.versionSchemes ??
|
||||
DEFAULT_VERSION_SCHEMES;
|
||||
const versionScheme =
|
||||
dockerVersionScheme && dockerVersionScheme in availableVersionSchemes
|
||||
? dockerVersionScheme
|
||||
: Object.keys(availableVersionSchemes).length === 1
|
||||
? Object.keys(availableVersionSchemes)[0]
|
||||
: await promptForNewVersion(
|
||||
availableVersionSchemes,
|
||||
projectGraphNode.name
|
||||
);
|
||||
newVersion = calculateNewVersion(
|
||||
projectGraphNode.name,
|
||||
versionScheme,
|
||||
availableVersionSchemes,
|
||||
versionActionsVersion
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const logs = updateProjectVersion(
|
||||
newVersion,
|
||||
nxDockerImageRefEnvOverride,
|
||||
workspaceRoot,
|
||||
projectGraphNode.data.root,
|
||||
finalConfigForProject.dockerOptions.repositoryName,
|
||||
finalConfigForProject.dockerOptions.registryUrl
|
||||
);
|
||||
|
||||
return {
|
||||
newVersion:
|
||||
newVersion || process.env.NX_DOCKER_IMAGE_REF?.split(':')[1] || null,
|
||||
logs,
|
||||
};
|
||||
}
|
||||
|
||||
async function promptForNewVersion(
|
||||
versionSchemes: Record<string, string>,
|
||||
projectName: string
|
||||
) {
|
||||
const { versionScheme } = await prompt<{ versionScheme: string }>({
|
||||
name: 'versionScheme',
|
||||
type: 'select',
|
||||
message: `What type of docker release would you like to make for project "${projectName}"?`,
|
||||
choices: Object.keys(versionSchemes).map((vs) => ({
|
||||
name: vs,
|
||||
message: vs,
|
||||
value: vs,
|
||||
description: interpolateVersionPattern(versionSchemes[vs], {
|
||||
projectName,
|
||||
}),
|
||||
})),
|
||||
// Show only the focused scheme's resolved pattern, not every row's at once.
|
||||
footer: function () {
|
||||
const focused = this.focused as { description?: string };
|
||||
return focused?.description
|
||||
? this.styles.muted(` ${focused.description}`)
|
||||
: '';
|
||||
},
|
||||
} as any);
|
||||
|
||||
return versionScheme;
|
||||
}
|
||||
|
||||
function calculateNewVersion(
|
||||
projectName: string,
|
||||
versionScheme: string,
|
||||
versionSchemes: Record<string, string>,
|
||||
versionActionsVersion?: string
|
||||
): string {
|
||||
if (!(versionScheme in versionSchemes)) {
|
||||
throw new Error(
|
||||
`Could not find version scheme '${versionScheme}'. Available options are: ${Object.keys(
|
||||
versionSchemes
|
||||
).join(', ')}.`
|
||||
);
|
||||
}
|
||||
return interpolateVersionPattern(versionSchemes[versionScheme], {
|
||||
projectName,
|
||||
versionActionsVersion,
|
||||
});
|
||||
}
|
||||
|
||||
function updateProjectVersion(
|
||||
newVersion: string | undefined,
|
||||
nxDockerImageRefEnvOverride: string | undefined,
|
||||
workspaceRoot: string,
|
||||
projectRoot: string,
|
||||
repositoryName?: string,
|
||||
registry?: string
|
||||
): string[] {
|
||||
const isDryRun = process.env.NX_DRY_RUN && process.env.NX_DRY_RUN !== 'false';
|
||||
const imageRef = getDefaultImageReference(projectRoot);
|
||||
const newImageRef = getImageReference(projectRoot, repositoryName, registry);
|
||||
const fullImageRef =
|
||||
nxDockerImageRefEnvOverride ?? `${newImageRef}:${newVersion}`;
|
||||
if (!isDryRun) {
|
||||
execSync(`docker tag ${imageRef} ${fullImageRef}`, {
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
const logs = isDryRun
|
||||
? [`Image would be tagged with ${fullImageRef} but dry run is enabled.`]
|
||||
: [`Image tagged with ${fullImageRef}.`];
|
||||
if (isDryRun) {
|
||||
logs.push(`No changes were applied as --dry-run is enabled.`);
|
||||
} else {
|
||||
const dockerVersionPath = getDockerVersionPath(workspaceRoot, projectRoot);
|
||||
mkdirSync(dirname(dockerVersionPath), { recursive: true });
|
||||
writeFileSync(dockerVersionPath, fullImageRef);
|
||||
}
|
||||
return logs;
|
||||
}
|
||||
|
||||
function getImageReference(
|
||||
projectRoot: string,
|
||||
repositoryName?: string,
|
||||
registry?: string
|
||||
) {
|
||||
let imageRef = repositoryName ?? getDefaultImageReference(projectRoot);
|
||||
|
||||
if (registry) {
|
||||
imageRef = `${registry}/${imageRef}`;
|
||||
}
|
||||
return imageRef;
|
||||
}
|
||||
|
||||
function getDefaultImageReference(projectRoot: string) {
|
||||
const root = projectRoot === '.' ? workspaceRoot : projectRoot;
|
||||
const normalized = root
|
||||
.replace(/^[\\/]/, '')
|
||||
.replace(/[\\/\s]+/g, '-')
|
||||
.toLowerCase();
|
||||
return normalized.length > 128 ? normalized.slice(-128) : normalized;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { interpolatePattern, interpolateObject } from './interpolate-pattern';
|
||||
|
||||
describe('interpolate-pattern', () => {
|
||||
describe('interpolatePattern', () => {
|
||||
it('should interpolate simple tokens', () => {
|
||||
const result = interpolatePattern('{projectName}', {
|
||||
projectName: 'my-app',
|
||||
});
|
||||
expect(result).toBe('my-app');
|
||||
});
|
||||
|
||||
it('should interpolate multiple tokens', () => {
|
||||
const result = interpolatePattern('{projectName}-{version}', {
|
||||
projectName: 'my-app',
|
||||
version: '1.0.0',
|
||||
});
|
||||
expect(result).toBe('my-app-1.0.0');
|
||||
});
|
||||
|
||||
it('should keep unknown tokens unchanged', () => {
|
||||
const result = interpolatePattern('{unknownToken}', {
|
||||
projectName: 'my-app',
|
||||
});
|
||||
expect(result).toBe('{unknownToken}');
|
||||
});
|
||||
|
||||
it('should handle empty pattern', () => {
|
||||
const result = interpolatePattern('', {
|
||||
projectName: 'my-app',
|
||||
});
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle pattern without tokens', () => {
|
||||
const result = interpolatePattern('1.2.3', {
|
||||
projectName: 'my-app',
|
||||
});
|
||||
expect(result).toBe('1.2.3');
|
||||
});
|
||||
|
||||
it('should handle missing token values', () => {
|
||||
const result = interpolatePattern('{projectName}', {});
|
||||
expect(result).toBe('{projectName}');
|
||||
});
|
||||
|
||||
describe('date formatting', () => {
|
||||
it('should interpolate currentDate with ISO format by default', () => {
|
||||
const testDate = new Date('2024-01-15T10:30:45.000Z');
|
||||
const result = interpolatePattern('{currentDate}', {
|
||||
currentDate: testDate,
|
||||
});
|
||||
expect(result).toBe('2024-01-15T10:30:45.000Z');
|
||||
});
|
||||
|
||||
it('should interpolate currentDate with custom format YYYY.MM.DD', () => {
|
||||
const testDate = new Date('2024-01-15T10:30:45.000Z');
|
||||
const result = interpolatePattern('{currentDate|YYYY.MM.DD}', {
|
||||
currentDate: testDate,
|
||||
});
|
||||
expect(result).toBe('2024.01.15');
|
||||
});
|
||||
|
||||
it('should interpolate currentDate with custom format YY.MM.DD', () => {
|
||||
const testDate = new Date('2024-01-15T10:30:45.000Z');
|
||||
const result = interpolatePattern('{currentDate|YY.MM.DD}', {
|
||||
currentDate: testDate,
|
||||
});
|
||||
expect(result).toBe('24.01.15');
|
||||
});
|
||||
|
||||
it('should interpolate currentDate with time format HH:mm:ss', () => {
|
||||
const testDate = new Date('2024-01-15T10:30:45.000Z');
|
||||
const result = interpolatePattern('{currentDate|HH:mm:ss}', {
|
||||
currentDate: testDate,
|
||||
});
|
||||
expect(result).toBe('10:30:45');
|
||||
});
|
||||
|
||||
it('should pad single digit values correctly', () => {
|
||||
const testDate = new Date('2024-01-05T03:07:09.000Z');
|
||||
const result = interpolatePattern('{currentDate|YYYY-MM-DD HH:mm:ss}', {
|
||||
currentDate: testDate,
|
||||
});
|
||||
expect(result).toBe('2024-01-05 03:07:09');
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment variable interpolation', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should interpolate environment variables', () => {
|
||||
process.env.BUILD_NUMBER = '123';
|
||||
const result = interpolatePattern('build-{env.BUILD_NUMBER}', {});
|
||||
expect(result).toBe('build-123');
|
||||
});
|
||||
|
||||
it('should handle multiple environment variables', () => {
|
||||
process.env.STAGE = 'QA';
|
||||
process.env.BUILD_NUMBER = '456';
|
||||
const result = interpolatePattern(
|
||||
'build-{env.STAGE}.{env.BUILD_NUMBER}',
|
||||
{}
|
||||
);
|
||||
expect(result).toBe('build-QA.456');
|
||||
});
|
||||
|
||||
it('should keep unknown environment variables as-is', () => {
|
||||
const result = interpolatePattern('build-{env.NON_EXISTENT_VAR}', {});
|
||||
expect(result).toBe('build-{env.NON_EXISTENT_VAR}');
|
||||
});
|
||||
|
||||
it('should combine environment variables with other tokens', () => {
|
||||
process.env.TASK = 'builder';
|
||||
const result = interpolatePattern('{projectName}-{env.TASK}', {
|
||||
projectName: 'api',
|
||||
});
|
||||
expect(result).toBe('api-builder');
|
||||
});
|
||||
|
||||
it('should handle empty environment variable values', () => {
|
||||
process.env.EMPTY_VAR = '';
|
||||
const result = interpolatePattern('prefix-{env.EMPTY_VAR}-suffix', {});
|
||||
expect(result).toBe('prefix--suffix');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('interpolateObject', () => {
|
||||
it('should interpolate strings in objects', () => {
|
||||
const result = interpolateObject(
|
||||
{
|
||||
name: '{projectName}',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{ projectName: 'my-app' }
|
||||
);
|
||||
expect(result).toEqual({
|
||||
name: 'my-app',
|
||||
version: '1.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('should interpolate strings in arrays', () => {
|
||||
const result = interpolateObject(
|
||||
['--tag', '{imageRef}', '--build-arg', 'VERSION={version}'],
|
||||
{ imageRef: 'my-app:latest', version: '1.0.0' }
|
||||
);
|
||||
expect(result).toEqual([
|
||||
'--tag',
|
||||
'my-app:latest',
|
||||
'--build-arg',
|
||||
'VERSION=1.0.0',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should interpolate nested objects', () => {
|
||||
const result = interpolateObject(
|
||||
{
|
||||
build: {
|
||||
args: ['--tag', '{imageRef}'],
|
||||
env: {
|
||||
APP_NAME: '{projectName}',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ imageRef: 'my-app:latest', projectName: 'my-app' }
|
||||
);
|
||||
expect(result).toEqual({
|
||||
build: {
|
||||
args: ['--tag', 'my-app:latest'],
|
||||
env: {
|
||||
APP_NAME: 'my-app',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve non-string values', () => {
|
||||
const result = interpolateObject(
|
||||
{
|
||||
name: '{projectName}',
|
||||
port: 3000,
|
||||
enabled: true,
|
||||
config: null,
|
||||
},
|
||||
{ projectName: 'my-app' }
|
||||
);
|
||||
expect(result).toEqual({
|
||||
name: 'my-app',
|
||||
port: 3000,
|
||||
enabled: true,
|
||||
config: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle arrays of objects', () => {
|
||||
const result = interpolateObject(
|
||||
[
|
||||
{ name: '{projectName}-web', port: 3000 },
|
||||
{ name: '{projectName}-api', port: 8080 },
|
||||
],
|
||||
{ projectName: 'my-app' }
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{ name: 'my-app-web', port: 3000 },
|
||||
{ name: 'my-app-api', port: 8080 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return primitive values unchanged', () => {
|
||||
expect(interpolateObject('simple string', {})).toBe('simple string');
|
||||
expect(interpolateObject(123, {})).toBe(123);
|
||||
expect(interpolateObject(true, {})).toBe(true);
|
||||
expect(interpolateObject(null, {})).toBe(null);
|
||||
});
|
||||
|
||||
it('should interpolate string primitives', () => {
|
||||
const result = interpolateObject('{projectName}-app', {
|
||||
projectName: 'my',
|
||||
});
|
||||
expect(result).toBe('my-app');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
const tokenRegex = /\{(env\.([^}]+)|([^|{}]+)(?:\|([^{}]+))?)\}/g;
|
||||
|
||||
function formatDate(date: Date, format: string) {
|
||||
const year = String(date.getUTCFullYear());
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
|
||||
|
||||
return format
|
||||
.replace(/YYYY/g, year)
|
||||
.replace(/YY/g, year.slice(-2))
|
||||
.replace(/MM/g, month)
|
||||
.replace(/DD/g, day)
|
||||
.replace(/HH/g, hours)
|
||||
.replace(/mm/g, minutes)
|
||||
.replace(/ss/g, seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolates pattern tokens in a string.
|
||||
*
|
||||
* Supported tokens:
|
||||
* - {tokenName} - Simple token replacement
|
||||
* - {currentDate} - Current date in ISO format
|
||||
* - {currentDate|FORMAT} - Current date with custom format (YYYY, YY, MM, DD, HH, mm, ss)
|
||||
* - {env.VAR_NAME} - Environment variable value
|
||||
*
|
||||
* @param pattern - String containing tokens to interpolate
|
||||
* @param tokens - Record of token values
|
||||
* @returns Interpolated string
|
||||
*/
|
||||
export function interpolatePattern(
|
||||
pattern: string,
|
||||
tokens: Record<string, any>
|
||||
): string {
|
||||
return pattern.replace(
|
||||
tokenRegex,
|
||||
(match, fullMatch, envVarName, identifier, format) => {
|
||||
// Handle environment variables
|
||||
if (envVarName) {
|
||||
const envValue = process.env[envVarName];
|
||||
return envValue !== undefined ? envValue : match;
|
||||
}
|
||||
|
||||
// Handle other tokens
|
||||
const value = tokens[identifier];
|
||||
|
||||
if (value === undefined) {
|
||||
return match; // Keep original token if no data
|
||||
}
|
||||
|
||||
// Handle date formatting
|
||||
if (identifier === 'currentDate') {
|
||||
if (format) {
|
||||
return formatDate(value, format);
|
||||
} else {
|
||||
return (value as Date).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively interpolates pattern tokens in all string values within an object or array.
|
||||
*
|
||||
* @param obj - Object or array to process
|
||||
* @param tokens - Record of token values
|
||||
* @returns New object/array with interpolated values
|
||||
*/
|
||||
export function interpolateObject<T>(obj: T, tokens: Record<string, any>): T {
|
||||
if (typeof obj === 'string') {
|
||||
return interpolatePattern(obj, tokens) as T;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => interpolateObject(item, tokens)) as T;
|
||||
}
|
||||
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = interpolateObject(value, tokens);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { join } from 'path';
|
||||
export const nxVersion = require(join('@nx/docker', 'package.json')).version;
|
||||
Reference in New Issue
Block a user