feat(release): update lockfile after version command (#21107)

This commit is contained in:
Austin Fahsl 2024-01-23 11:22:16 -07:00 committed by GitHub
parent 6164481d57
commit 096cefb109
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 475 additions and 57 deletions

View File

@ -2,6 +2,7 @@ import { joinPathFragments } from '@nx/devkit';
import {
cleanupProject,
exists,
getSelectedPackageManager,
newProject,
readFile,
runCLI,
@ -34,6 +35,16 @@ expect.addSnapshotSerializer({
.replaceAll(/\d*\.\d*\s?kB/g, 'XXX.XXX kb')
// Normalize the version title date
.replaceAll(/\(\d{4}-\d{2}-\d{2}\)/g, '(YYYY-MM-DD)')
.replaceAll('package-lock.json', '{lock-file}')
.replaceAll('yarn.lock', '{lock-file}')
.replaceAll('pnpm-lock.yaml', '{lock-file}')
.replaceAll('npm install --package-lock-only', '{lock-file-command}')
.replaceAll(
'yarn install --mode update-lockfile',
'{lock-file-command}'
)
.replaceAll('pnpm install --lockfile-only', '{lock-file-command}')
.replaceAll(getSelectedPackageManager(), '{package-manager}')
// We trim each line to reduce the chances of snapshot flakiness
.split('\n')
.map((r) => r.trim())
@ -127,6 +138,9 @@ describe('nx release - independent projects', () => {
"scripts": {
> NX Updating {package-manager} lock file
> NX Staging changed files with git
@ -159,6 +173,9 @@ describe('nx release - independent projects', () => {
+
> NX Updating {package-manager} lock file
> NX Staging changed files with git
@ -198,6 +215,9 @@ describe('nx release - independent projects', () => {
}
> NX Updating {package-manager} lock file
> NX Staging changed files with git
@ -237,10 +257,15 @@ describe('nx release - independent projects', () => {
"scripts": {
> NX Updating {package-manager} lock file
Updating {lock-file} with the following command:
{lock-file-command}
> NX Committing changes with git
Staging files in git with the following command:
git add {project-name}/package.json
git add {project-name}/package.json {lock-file}
Committing files in git with the following command:
git commit --message chore(release): publish --message - project: {project-name} 999.9.9-version-git-operations-test.2
@ -340,10 +365,20 @@ describe('nx release - independent projects', () => {
"scripts": {
> NX Updating {package-manager} lock file
Updating {lock-file} with the following command:
{lock-file-command}
> NX Updating {package-manager} lock file
Updating {lock-file} with the following command:
{lock-file-command}
> NX Committing changes with git
Staging files in git with the following command:
git add {project-name}/package.json {project-name}/package.json {project-name}/package.json
git add {project-name}/package.json {project-name}/package.json {project-name}/package.json {lock-file}
Committing files in git with the following command:
git commit --message chore(release): publish --message - project: {project-name} 999.9.9-version-git-operations-test.3 --message - project: {project-name} 999.9.9-version-git-operations-test.3 --message - release-group: fixed 999.9.9-version-git-operations-test.3

View File

@ -0,0 +1,187 @@
import {
cleanupProject,
newProject,
runCLI,
runCommand,
uniq,
updateFile,
updateJson,
} from '@nx/e2e/utils';
expect.addSnapshotSerializer({
serialize(str: string) {
return (
str
// Remove all output unique to specific projects to ensure deterministic snapshots
.replaceAll(/my-pkg-\d+/g, '{project-name}')
.replaceAll(
/integrity:\s*.*/g,
'integrity: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
)
.replaceAll(/\b[0-9a-f]{40}\b/g, '{SHASUM}')
.replaceAll(/\d*B index\.js/g, 'XXB index.js')
.replaceAll(/\d*B project\.json/g, 'XXB project.json')
.replaceAll(/\d*B package\.json/g, 'XXXB package.json')
.replaceAll(/size:\s*\d*\s?B/g, 'size: XXXB')
.replaceAll(/\d*\.\d*\s?kB/g, 'XXX.XXX kb')
.replaceAll(/[a-fA-F0-9]{7}/g, '{COMMIT_SHA}')
.replaceAll(/Test @[\w\d]+/g, 'Test @{COMMIT_AUTHOR}')
// Normalize the version title date.
.replaceAll(/\(\d{4}-\d{2}-\d{2}\)/g, '(YYYY-MM-DD)')
// We trim each line to reduce the chances of snapshot flakiness
.split('\n')
.map((r) => r.trim())
.join('\n')
);
},
test(val: string) {
return val != null && typeof val === 'string';
},
});
describe('nx release lock file updates', () => {
let pkg1: string;
let pkg2: string;
let pkg3: string;
let previousPackageManager: string;
let previousYarnEnableImmutableInstalls: string;
let previousNodeOptions: string;
beforeAll(() => {
previousPackageManager = process.env.SELECTED_PM;
previousYarnEnableImmutableInstalls =
process.env.YARN_ENABLE_IMMUTABLE_INSTALLS;
previousNodeOptions = process.env.NODE_OPTIONS;
});
// project will be created by each test individually
// in order to test different package managers
const initializeProject = (packageManager: 'npm' | 'yarn' | 'pnpm') => {
process.env.SELECTED_PM = packageManager;
newProject({
unsetProjectNameAndRootFormat: false,
packages: ['@nx/js'],
packageManager,
});
pkg1 = uniq('my-pkg-1');
runCLI(`generate @nx/workspace:npm-package ${pkg1}`);
pkg2 = uniq('my-pkg-2');
runCLI(`generate @nx/workspace:npm-package ${pkg2}`);
pkg3 = uniq('my-pkg-3');
runCLI(`generate @nx/workspace:npm-package ${pkg3}`);
// Update pkg2 to depend on pkg1
updateJson(`${pkg2}/package.json`, (json) => {
json.dependencies ??= {};
json.dependencies[`@proj/${pkg1}`] = '0.0.0';
return json;
});
};
afterEach(() => {
cleanupProject();
});
afterAll(() => {
process.env.SELECTED_PM = previousPackageManager;
process.env.YARN_ENABLE_IMMUTABLE_INSTALLS =
previousYarnEnableImmutableInstalls;
process.env.NODE_OPTIONS = previousNodeOptions;
});
it('should update package-lock.json when package manager is npm', async () => {
initializeProject('npm');
runCommand(`npm install`);
// workaround for NXC-143
runCLI('reset');
runCommand(`git add .`);
runCommand(`git commit -m "chore: initial commit"`);
const versionOutput = runCLI(`release version 999.9.9`);
expect(versionOutput.match(/NX Updating npm lock file/g).length).toBe(1);
const filesChanges = runCommand('git diff --name-only HEAD');
expect(filesChanges).toMatchInlineSnapshot(`
{project-name}/package.json
{project-name}/package.json
{project-name}/package.json
package-lock.json
`);
});
it.skip('should update yarn.lock when package manager is yarn', async () => {
process.env.YARN_ENABLE_IMMUTABLE_INSTALLS = 'false';
process.env.NODE_OPTIONS = '--no-enable-network-family-autoselection';
initializeProject('yarn');
updateJson('package.json', (json) => {
json.workspaces = [pkg1, pkg2, pkg3];
return json;
});
runCommand(`yarn install`);
// workaround for NXC-143
runCLI('reset');
runCommand(`git add .`);
runCommand(`git commit -m "chore: initial commit"`);
const versionOutput = runCLI(`release version 999.9.9`);
expect(versionOutput.match(/NX Updating yarn lock file/g).length).toBe(1);
const filesChanges = runCommand('git diff --name-only HEAD');
expect(filesChanges).toMatchInlineSnapshot(`
.yarn/install-state.gz
{project-name}/package.json
{project-name}/package.json
{project-name}/package.json
yarn.lock
`);
});
it('should update pnpm-lock.yaml when package manager is pnpm', async () => {
initializeProject('pnpm');
updateFile(
'pnpm-workspace.yaml',
`packages:\n - ${pkg1}\n - ${pkg2}\n - ${pkg3}\n`
);
// workaround for NXC-143
runCLI('reset');
runCommand(`pnpm install`);
runCommand(`git add .`);
runCommand(`git commit -m "chore: initial commit"`);
const versionOutput = runCLI(`release version 999.9.9`);
expect(versionOutput.match(/NX Updating pnpm lock file/g).length).toBe(1);
const filesChanges = runCommand('git diff --name-only HEAD');
expect(filesChanges).toMatchInlineSnapshot(`
{project-name}/package.json
{project-name}/package.json
{project-name}/package.json
pnpm-lock.yaml
`);
});
});

View File

@ -1146,17 +1146,14 @@ ${JSON.stringify(
silenceError: true,
});
expect(releaseOutput6a).toMatchInlineSnapshot(`
> NX Running release version for project: {project-name}
{project-name} 🔍 Reading data for package "@proj/{project-name}" from {project-name}/package.json
> NX Unable to resolve the current version from the registry ${e2eRegistryUrl}. Please ensure that the package exists in the registry in order to use the "registry" currentVersionResolver. Alternatively, you can set the "version.generatorOptions.fallbackCurrentVersionResolver" to "disk" in order to fallback to the version on disk when the registry lookup fails.
- Resolving the current version for tag "other" on ${e2eRegistryUrl}
`);
expect(
releaseOutput6a.match(
new RegExp(
`> NX Unable to resolve the current version from the registry ${e2eRegistryUrl}. Please ensure that the package exists in the registry in order to use the "registry" currentVersionResolver. Alternatively, you can set the "version.generatorOptions.fallbackCurrentVersionResolver" to "disk" in order to fallback to the version on disk when the registry lookup fails.`,
'g'
)
).length
).toEqual(1);
const releaseOutput6b = runCLI(
`release patch --skip-publish --first-release`,

View File

@ -88,33 +88,36 @@ describe('release-version', () => {
})
).toMatchInlineSnapshot(`
{
"my-lib": {
"currentVersion": "0.0.1",
"dependentProjects": [
{
"dependencyCollection": "dependencies",
"source": "project-with-dependency-on-my-pkg",
"target": "my-lib",
"type": "static",
},
{
"dependencyCollection": "devDependencies",
"source": "project-with-devDependency-on-my-pkg",
"target": "my-lib",
"type": "static",
},
],
"newVersion": "1.0.0",
},
"project-with-dependency-on-my-pkg": {
"currentVersion": "0.0.1",
"dependentProjects": [],
"newVersion": "1.0.0",
},
"project-with-devDependency-on-my-pkg": {
"currentVersion": "0.0.1",
"dependentProjects": [],
"newVersion": "1.0.0",
"callback": [Function],
"data": {
"my-lib": {
"currentVersion": "0.0.1",
"dependentProjects": [
{
"dependencyCollection": "dependencies",
"source": "project-with-dependency-on-my-pkg",
"target": "my-lib",
"type": "static",
},
{
"dependencyCollection": "devDependencies",
"source": "project-with-devDependency-on-my-pkg",
"target": "my-lib",
"type": "static",
},
],
"newVersion": "1.0.0",
},
"project-with-dependency-on-my-pkg": {
"currentVersion": "0.0.1",
"dependentProjects": [],
"newVersion": "1.0.0",
},
"project-with-devDependency-on-my-pkg": {
"currentVersion": "0.0.1",
"dependentProjects": [],
"newVersion": "1.0.0",
},
},
}
`);

View File

@ -19,21 +19,24 @@ import {
} from 'nx/src/command-line/release/utils/resolve-semver-specifier';
import { isValidSemverSpecifier } from 'nx/src/command-line/release/utils/semver';
import {
ReleaseVersionGeneratorResult,
VersionData,
deriveNewSemverVersion,
validReleaseVersionPrefixes,
} from 'nx/src/command-line/release/version';
import { daemonClient } from 'nx/src/daemon/client/client';
import { interpolate } from 'nx/src/tasks-runner/utils';
import * as ora from 'ora';
import { relative } from 'path';
import { prerelease } from 'semver';
import { ReleaseVersionGeneratorSchema } from './schema';
import { resolveLocalPackageDependencies } from './utils/resolve-local-package-dependencies';
import { updateLockFile } from './utils/update-lock-file';
export async function releaseVersionGenerator(
tree: Tree,
options: ReleaseVersionGeneratorSchema
) {
): Promise<ReleaseVersionGeneratorResult> {
try {
const versionData: VersionData = {};
@ -473,7 +476,36 @@ To fix this you will either need to add a package.json file at that location, or
await formatFiles(tree);
// Return the version data so that it can be leveraged by the overall version command
return versionData;
return {
data: versionData,
callback: async (tree, opts) => {
const cwd = tree.root;
const isDaemonEnabled = daemonClient.enabled();
if (isDaemonEnabled) {
// temporarily stop the daemon, as it will error if the lock file is updated
await daemonClient.stop();
}
const updatedFiles = updateLockFile(cwd, opts);
if (isDaemonEnabled) {
try {
await daemonClient.startInBackground();
} catch (e) {
// If the daemon fails to start, we don't want to prevent the user from continuing, so we just log the error and move on
if (opts.verbose) {
output.warn({
title:
'Unable to restart the Nx Daemon. It will be disabled until you run "nx reset"',
bodyLines: [e.message],
});
}
}
}
return updatedFiles;
},
};
} catch (e) {
if (process.env.NX_VERBOSE_LOGGING === 'true') {
output.error({

View File

@ -0,0 +1,103 @@
import {
detectPackageManager,
getPackageManagerCommand,
getPackageManagerVersion,
output,
} from '@nx/devkit';
import { execSync } from 'child_process';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { getLockFileName } from 'nx/src/plugins/js/lock-file/lock-file';
import { gte } from 'semver';
export function updateLockFile(
cwd: string,
{
dryRun,
verbose,
generatorOptions,
}: {
dryRun?: boolean;
verbose?: boolean;
generatorOptions?: Record<string, unknown>;
}
) {
if (generatorOptions?.skipLockFileUpdate) {
if (verbose) {
console.log(
'\nSkipped lock file update because skipLockFileUpdate was set.'
);
}
return [];
}
const packageManager = detectPackageManager(cwd);
const packageManagerCommands = getPackageManagerCommand(packageManager);
let installArgs = generatorOptions?.installArgs || '';
output.logSingleLine(`Updating ${packageManager} lock file`);
let env: object = {};
if (generatorOptions?.installIgnoreScripts) {
if (
packageManager === 'yarn' &&
gte(getPackageManagerVersion(packageManager), '2.0.0')
) {
env = { YARN_ENABLE_SCRIPTS: 'false' };
} else {
// npm, pnpm, and yarn classic all use the same --ignore-scripts option
installArgs = `${installArgs} --ignore-scripts`.trim();
}
}
const lockFile = getLockFileName(packageManager);
const command =
`${packageManagerCommands.updateLockFile} ${installArgs}`.trim();
if (verbose) {
if (dryRun) {
console.log(
`Would update ${lockFile} with the following command, but --dry-run was set:`
);
} else {
console.log(`Updating ${lockFile} with the following command:`);
}
console.log(command);
}
if (dryRun) {
return [];
}
execLockFileUpdate(command, cwd, env);
return [lockFile];
}
function execLockFileUpdate(
command: string,
cwd: string,
env: object = {}
): void {
try {
execSync(command, {
cwd,
env: {
...process.env,
...env,
},
});
} catch (e) {
output.error({
title: `Error updating lock file with command '${command}'`,
bodyLines: [
`Verify that '${command}' succeeds when run from the workspace root.`,
`To configure a string of arguments to be passed to this command, set the 'release.version.generatorOptions.installArgs' property in nx.json.`,
`To ignore install lifecycle scripts, set 'release.version.generatorOptions.installIgnoreScripts' to true in nx.json.`,
`To disable this step entirely, set 'release.version.skipLockFileUpdate' to true in nx.json.`,
],
});
throw e;
}
}

View File

@ -1,11 +1,24 @@
import { prerelease } from 'semver';
import { ProjectGraph } from '../../../config/project-graph';
import { Tree } from '../../../generators/tree';
import { createFileMapUsingProjectGraph } from '../../../project-graph/file-map-utils';
import { interpolate } from '../../../tasks-runner/utils';
import { output } from '../../../utils/output';
import type { ReleaseGroupWithName } from '../config/filter-release-groups';
import { GitCommit, gitAdd, gitCommit } from './git';
export type ReleaseVersionGeneratorResult = {
data: VersionData;
callback: (
tree: Tree,
opts: {
dryRun?: boolean;
verbose?: boolean;
generatorOptions?: Record<string, unknown>;
}
) => Promise<string[]>;
};
export type VersionData = Record<
string,
{

View File

@ -34,6 +34,7 @@ import {
import { gitAdd, gitTag } from './utils/git';
import { printDiff } from './utils/print-changes';
import {
ReleaseVersionGeneratorResult,
VersionData,
commitChanges,
createCommitMessageValues,
@ -43,7 +44,10 @@ import {
// Reexport some utils for use in plugin release-version generator implementations
export { deriveNewSemverVersion } from './utils/semver';
export type { VersionData } from './utils/shared';
export type {
ReleaseVersionGeneratorResult,
VersionData,
} from './utils/shared';
export const validReleaseVersionPrefixes = ['auto', '', '~', '^'];
@ -128,6 +132,8 @@ export async function releaseVersion(
const versionData: VersionData = {};
const commitMessage: string | undefined =
args.gitCommitMessage || nxReleaseConfig.version.git.commitMessage;
const changedLockFiles = new Set<string>();
const generatorCallbacks: (() => Promise<void>)[] = [];
if (args.projects?.length) {
/**
@ -150,7 +156,7 @@ export async function releaseVersion(
releaseGroupToFilteredProjects.get(releaseGroup)
);
await runVersionOnProjects(
const generatorCallback = await runVersionOnProjects(
projectGraph,
nxJson,
args,
@ -160,6 +166,16 @@ export async function releaseVersion(
releaseGroup,
versionData
);
generatorCallbacks.push(async () =>
(
await generatorCallback(tree, {
dryRun: !!args.dryRun,
verbose: !!args.verbose,
generatorOptions: releaseGroup.version.generatorOptions,
})
).forEach((f) => changedLockFiles.add(f))
);
}
// Resolve any git tags as early as possible so that we can hard error in case of any duplicates before reaching the actual git command
@ -175,7 +191,14 @@ export async function releaseVersion(
printAndFlushChanges(tree, !!args.dryRun);
const changedFiles = tree.listChanges().map((f) => f.path);
for (const generatorCallback of generatorCallbacks) {
await generatorCallback();
}
const changedFiles = [
...tree.listChanges().map((f) => f.path),
...changedLockFiles,
];
// No further actions are necessary in this scenario (e.g. if conventional commits detected no changes)
if (!changedFiles.length) {
@ -188,7 +211,7 @@ export async function releaseVersion(
if (args.gitCommit ?? nxReleaseConfig.version.git.commit) {
await commitChanges(
tree.listChanges().map((f) => f.path),
changedFiles,
!!args.dryRun,
!!args.verbose,
createCommitMessageValues(
@ -249,7 +272,7 @@ export async function releaseVersion(
projects,
});
await runVersionOnProjects(
const callback = await runVersionOnProjects(
projectGraph,
nxJson,
args,
@ -259,6 +282,16 @@ export async function releaseVersion(
releaseGroup,
versionData
);
generatorCallbacks.push(async () =>
(
await callback(tree, {
dryRun: !!args.dryRun,
verbose: !!args.verbose,
generatorOptions: releaseGroup.version.generatorOptions,
})
).forEach((f) => changedLockFiles.add(f))
);
}
// Resolve any git tags as early as possible so that we can hard error in case of any duplicates before reaching the actual git command
@ -274,6 +307,10 @@ export async function releaseVersion(
printAndFlushChanges(tree, !!args.dryRun);
for (const generatorCallback of generatorCallbacks) {
await generatorCallback();
}
// Only applicable when there is a single release group with a fixed relationship
let workspaceVersion: string | null | undefined = undefined;
if (releaseGroups.length === 1) {
@ -286,7 +323,10 @@ export async function releaseVersion(
}
}
const changedFiles = tree.listChanges().map((f) => f.path);
const changedFiles = [
...tree.listChanges().map((f) => f.path),
...changedLockFiles,
];
// No further actions are necessary in this scenario (e.g. if conventional commits detected no changes)
if (!changedFiles.length) {
@ -366,7 +406,7 @@ async function runVersionOnProjects(
projectNames: string[],
releaseGroup: ReleaseGroupWithName,
versionData: VersionData
) {
): Promise<ReleaseVersionGeneratorResult['callback']> {
const generatorOptions: ReleaseVersionGeneratorSchema = {
// Always ensure a string to avoid generator schema validation errors
specifier: args.specifier ?? '',
@ -395,20 +435,22 @@ async function runVersionOnProjects(
const releaseVersionGenerator = generatorData.implementationFactory();
// We expect all version generator implementations to return a VersionData object, rather than a GeneratorCallback
const versionDataForProjects = (await releaseVersionGenerator(
// We expect all version generator implementations to return a ReleaseVersionGeneratorResult object, rather than a GeneratorCallback
const versionResult = (await releaseVersionGenerator(
tree,
combinedOpts
)) as unknown as VersionData;
)) as unknown as ReleaseVersionGeneratorResult;
if (typeof versionDataForProjects === 'function') {
if (typeof versionResult === 'function') {
throw new Error(
`The version generator ${generatorData.collectionName}:${generatorData.normalizedGeneratorName} returned a function instead of an expected VersionData object`
`The version generator ${generatorData.collectionName}:${generatorData.normalizedGeneratorName} returned a function instead of an expected ReleaseVersionGeneratorResult`
);
}
// Merge the extra version data into the existing
appendVersionData(versionData, versionDataForProjects);
appendVersionData(versionData, versionResult.data);
return versionResult.callback;
}
function printAndFlushChanges(tree: Tree, isDryRun: boolean) {

View File

@ -2,13 +2,13 @@ import { exec, execSync } from 'child_process';
import { copyFileSync, existsSync, writeFileSync } from 'fs';
import { remove } from 'fs-extra';
import { dirname, join, relative } from 'path';
import { gte, lt } from 'semver';
import { dirSync } from 'tmp';
import { promisify } from 'util';
import { readNxJson } from '../config/configuration';
import { readFileIfExisting, writeJsonFile } from './fileutils';
import { readModulePackageJson } from './package-json';
import { gte, lt } from 'semver';
import { workspaceRoot } from './workspace-root';
import { readNxJson } from '../config/configuration';
const execAsync = promisify(exec);
@ -18,6 +18,7 @@ export interface PackageManagerCommands {
preInstall?: string;
install: string;
ciInstall: string;
updateLockFile: string;
add: string;
addDev: string;
rm: string;
@ -71,6 +72,9 @@ export function getPackageManagerCommand(
ciInstall: useBerry
? 'yarn install --immutable'
: 'yarn install --frozen-lockfile',
updateLockFile: useBerry
? 'yarn install --mode update-lockfile'
: 'yarn install',
add: useBerry ? 'yarn add' : 'yarn add -W',
addDev: useBerry ? 'yarn add -D' : 'yarn add -D -W',
rm: 'yarn remove',
@ -89,6 +93,7 @@ export function getPackageManagerCommand(
return {
install: 'pnpm install --no-frozen-lockfile', // explicitly disable in case of CI
ciInstall: 'pnpm install --frozen-lockfile',
updateLockFile: 'pnpm install --lockfile-only',
add: isPnpmWorkspace ? 'pnpm add -w' : 'pnpm add',
addDev: isPnpmWorkspace ? 'pnpm add -Dw' : 'pnpm add -D',
rm: 'pnpm rm',
@ -108,6 +113,7 @@ export function getPackageManagerCommand(
return {
install: 'npm install',
ciInstall: 'npm ci',
updateLockFile: 'npm install --package-lock-only',
add: 'npm install',
addDev: 'npm install -D',
rm: 'npm rm',