chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:36 +08:00
commit 8e2a6eb840
10194 changed files with 1593658 additions and 0 deletions
@@ -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;
+7
View File
@@ -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": []
}
@@ -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.
@@ -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';`
);
});
});
});
@@ -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
+386
View File
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
import { join } from 'path';
export const nxVersion = require(join('@nx/docker', 'package.json')).version;