Skip to content

Commit 1d4f9eb

Browse files
committed
fix(@angular/cli): discover migrations from installed packages when omitted by registry metadata (#33718)
Private package registries such as GitHub Packages frequently strip out custom non-npm metadata properties (e.g., ng-update or schematics) from their remote API responses. Previously, this caused ng update to skip migration execution during updates from those registries, even though ng update --migrate-only worked correctly. This commit adds a post-installation disk-fallback check in ng update: for any updated packages that were not scheduled for migrations from registry metadata, the CLI inspects node_modules/<package>/package.json on disk after installation. If an ng-update.migrations collection is found, it is automatically added to the migration queue and executed. Closes #33717 PR Close #33718
1 parent 4fb1229 commit 1d4f9eb

2 files changed

Lines changed: 180 additions & 1 deletion

File tree

packages/angular/cli/src/commands/update/cli.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -634,7 +634,7 @@ export default class UpdateCommandModule extends CommandModule<UpdateCommandArgs
634634
}
635635
}
636636

637-
const migrations = plan.migrationsToRun;
637+
const migrations = await resolveFallbackMigrations(this.context.root, plan);
638638

639639
if (migrations) {
640640
for (const migration of migrations) {
@@ -706,3 +706,49 @@ async function readPackageManifest(manifestPath: string): Promise<PackageManifes
706706
return undefined;
707707
}
708708
}
709+
710+
/**
711+
* Resolves migrations from installed package manifests on disk when they were omitted
712+
* from the initial update plan.
713+
*
714+
* This fallback is necessary because private package registries (such as GitHub Packages)
715+
* frequently strip custom non-npm metadata properties (like `ng-update`) from their remote
716+
* registry API responses. By inspecting `node_modules/<package>/package.json` after installation,
717+
* we ensure that any migration collections defined by the package are discovered and queued.
718+
*/
719+
export async function resolveFallbackMigrations(
720+
workspaceRoot: string,
721+
plan: UpdatePlan,
722+
): Promise<{ package: string; collection: string; from: string; to: string }[]> {
723+
const migrations = [...plan.migrationsToRun];
724+
const existingMigrationPackages = new Set(migrations.map((m) => m.package));
725+
726+
for (const [packageName, targetVersion] of plan.packagesToUpdate) {
727+
if (existingMigrationPackages.has(packageName)) {
728+
continue;
729+
}
730+
731+
const packageJsonPath = findPackageJson(workspaceRoot, packageName);
732+
if (packageJsonPath) {
733+
try {
734+
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
735+
const ngUpdate = packageJson?.['ng-update'];
736+
if (ngUpdate && typeof ngUpdate === 'object' && typeof ngUpdate.migrations === 'string') {
737+
const installedVersion = plan.packageInfoMap.get(packageName)?.installed.version;
738+
if (installedVersion) {
739+
migrations.push({
740+
package: packageName,
741+
collection: ngUpdate.migrations,
742+
from: installedVersion,
743+
to: targetVersion,
744+
});
745+
}
746+
}
747+
} catch {
748+
// Ignore read/parse errors for optional fallback
749+
}
750+
}
751+
}
752+
753+
return migrations;
754+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import assert from 'node:assert';
10+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
11+
import path from 'path';
12+
import { resolveFallbackMigrations } from './cli';
13+
import type { PackageVersionInfo, UpdatePlan } from './update-resolver';
14+
15+
describe('resolveFallbackMigrations', () => {
16+
let tempRoot: string;
17+
let pkgDir: string;
18+
beforeEach(async () => {
19+
const baseTmpDir = process.env['TEST_TMPDIR'];
20+
assert(baseTmpDir, 'TEST_TMPDIR is not set');
21+
tempRoot = await mkdtemp(path.join(baseTmpDir, 'angular-cli-update-cli-test-'));
22+
pkgDir = path.join(tempRoot, 'node_modules/@company/library-name');
23+
await mkdir(pkgDir, { recursive: true });
24+
});
25+
26+
afterEach(async () => {
27+
await rm(tempRoot, { recursive: true, force: true });
28+
});
29+
30+
it('discovers migrations from installed package.json when omitted from plan.migrationsToRun', async () => {
31+
await writeFile(
32+
path.join(pkgDir, 'package.json'),
33+
JSON.stringify({
34+
name: '@company/library-name',
35+
version: '21.2.0-next.1',
36+
'ng-update': {
37+
migrations: './schematics/migration.json',
38+
},
39+
}),
40+
'utf8',
41+
);
42+
43+
const plan: UpdatePlan = {
44+
packagesToUpdate: new Map([['@company/library-name', '21.2.0-next.1']]),
45+
migrationsToRun: [],
46+
packageInfoMap: new Map([
47+
[
48+
'@company/library-name',
49+
{
50+
name: '@company/library-name',
51+
npmPackageJson: {
52+
name: '@company/library-name',
53+
versions: ['21.1.0', '21.2.0-next.1'],
54+
'dist-tags': {},
55+
},
56+
installed: {
57+
version: '21.1.0' as unknown as PackageVersionInfo['version'],
58+
packageJson: { name: '@company/library-name', version: '21.1.0' },
59+
updateMetadata: { packageGroup: {}, requirements: {} },
60+
},
61+
packageJsonRange: '^21.1.0',
62+
},
63+
],
64+
]),
65+
registryClient: undefined as unknown as UpdatePlan['registryClient'],
66+
};
67+
68+
const migrations = await resolveFallbackMigrations(tempRoot, plan);
69+
70+
expect(migrations).toEqual([
71+
{
72+
package: '@company/library-name',
73+
collection: './schematics/migration.json',
74+
from: '21.1.0',
75+
to: '21.2.0-next.1',
76+
},
77+
]);
78+
});
79+
80+
it('does not duplicate migration if package is already in plan.migrationsToRun', async () => {
81+
await writeFile(
82+
path.join(pkgDir, 'package.json'),
83+
JSON.stringify({
84+
name: '@company/library-name',
85+
version: '21.2.0-next.1',
86+
'ng-update': {
87+
migrations: './schematics/migration.json',
88+
},
89+
}),
90+
'utf8',
91+
);
92+
93+
const plan: UpdatePlan = {
94+
packagesToUpdate: new Map([['@company/library-name', '21.2.0-next.1']]),
95+
migrationsToRun: [
96+
{
97+
package: '@company/library-name',
98+
collection: './schematics/migration.json',
99+
from: '21.1.0',
100+
to: '21.2.0-next.1',
101+
},
102+
],
103+
packageInfoMap: new Map(),
104+
registryClient: undefined as unknown as UpdatePlan['registryClient'],
105+
};
106+
107+
const migrations = await resolveFallbackMigrations(tempRoot, plan);
108+
109+
expect(migrations).toHaveSize(1);
110+
});
111+
112+
it('returns unchanged migrations when package has no ng-update field on disk', async () => {
113+
await writeFile(
114+
path.join(pkgDir, 'package.json'),
115+
JSON.stringify({
116+
name: '@company/library-name',
117+
version: '21.2.0-next.1',
118+
}),
119+
'utf8',
120+
);
121+
122+
const plan: UpdatePlan = {
123+
packagesToUpdate: new Map([['@company/library-name', '21.2.0-next.1']]),
124+
migrationsToRun: [],
125+
packageInfoMap: new Map(),
126+
registryClient: undefined as unknown as UpdatePlan['registryClient'],
127+
};
128+
129+
const migrations = await resolveFallbackMigrations(tempRoot, plan);
130+
131+
expect(migrations).toHaveSize(0);
132+
});
133+
});

0 commit comments

Comments
 (0)