-
Notifications
You must be signed in to change notification settings - Fork 2.2k
fix(schematics): pin prerelease installs to the exact version #3709
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { logging } from '@angular-devkit/core'; | ||
| import { HostTree, SchematicContext } from '@angular-devkit/schematics'; | ||
| import { pinInstalledPrereleaseVersion } from './common.js'; | ||
| import 'jasmine'; | ||
|
|
||
| const context = { logger: new logging.Logger('test') } as unknown as SchematicContext; | ||
|
|
||
| const treeWithAngularFire = (declaredVersion: string) => { | ||
| const tree = new HostTree(); | ||
| tree.create('package.json', JSON.stringify({ | ||
| name: 'test-app', | ||
| dependencies: { | ||
| '@angular/fire': declaredVersion, | ||
| firebase: '^12.4.0', | ||
| }, | ||
| }, null, 2)); | ||
| return tree; | ||
| }; | ||
|
|
||
| const dependenciesIn = (tree: HostTree) => | ||
| JSON.parse(tree.readText('package.json')).dependencies; | ||
|
|
||
| describe('pinInstalledPrereleaseVersion', () => { | ||
|
|
||
| it('pins a caret prerelease range to the exact installed version', () => { | ||
| const tree = treeWithAngularFire('^21.0.0-rc.0'); | ||
| pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0'); | ||
| expect(dependenciesIn(tree)['@angular/fire']).toBe('21.0.0-rc.0'); | ||
| }); | ||
|
|
||
| it('pins a tilde prerelease range to the exact installed version', () => { | ||
| const tree = treeWithAngularFire('~21.0.0-rc.0'); | ||
| pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0'); | ||
| expect(dependenciesIn(tree)['@angular/fire']).toBe('21.0.0-rc.0'); | ||
| }); | ||
|
|
||
| it('leaves other dependencies untouched when pinning', () => { | ||
| const tree = treeWithAngularFire('^21.0.0-rc.0'); | ||
| pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0'); | ||
| expect(dependenciesIn(tree).firebase).toBe('^12.4.0'); | ||
| }); | ||
|
|
||
| it('leaves a stable caret range untouched', () => { | ||
| const tree = treeWithAngularFire('^21.0.0'); | ||
| pinInstalledPrereleaseVersion(tree, context, '21.0.0'); | ||
| expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0'); | ||
| }); | ||
|
|
||
| it('leaves an exact prerelease version untouched', () => { | ||
| const tree = treeWithAngularFire('21.0.0-rc.0'); | ||
| pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0'); | ||
| expect(dependenciesIn(tree)['@angular/fire']).toBe('21.0.0-rc.0'); | ||
| }); | ||
|
|
||
| it('leaves a hand-authored range expression untouched', () => { | ||
| const tree = treeWithAngularFire('>=21.0.0-rc.0'); | ||
| pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0'); | ||
| expect(dependenciesIn(tree)['@angular/fire']).toBe('>=21.0.0-rc.0'); | ||
| }); | ||
|
|
||
| it('does not pin when the installed version does not satisfy the declared range', () => { | ||
| const tree = treeWithAngularFire('^21.0.0-rc.0'); | ||
| pinInstalledPrereleaseVersion(tree, context, '22.0.0-rc.0'); | ||
| expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0-rc.0'); | ||
| }); | ||
|
|
||
| it('does not pin when the installed version is not valid semver', () => { | ||
| const tree = treeWithAngularFire('^21.0.0-rc.0'); | ||
| pinInstalledPrereleaseVersion(tree, context, 'ANGULARFIRE2_VERSION'); | ||
| expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0-rc.0'); | ||
| }); | ||
|
|
||
| it('does nothing when @angular/fire is not a dependency', () => { | ||
| const tree = new HostTree(); | ||
| tree.create('package.json', JSON.stringify({ name: 'test-app', dependencies: {} })); | ||
| expect(() => pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0')).not.toThrow(); | ||
| expect(dependenciesIn(tree)['@angular/fire']).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('does nothing when package.json is absent', () => { | ||
| const tree = new HostTree(); | ||
| expect(() => pinInstalledPrereleaseVersion(tree, context, '21.0.0-rc.0')).not.toThrow(); | ||
| }); | ||
|
|
||
| it('skips gracefully when the installed version cannot be determined', () => { | ||
| const tree = treeWithAngularFire('^21.0.0-rc.0'); | ||
| pinInstalledPrereleaseVersion(tree, context, undefined); | ||
| expect(dependenciesIn(tree)['@angular/fire']).toBe('^21.0.0-rc.0'); | ||
| }); | ||
|
|
||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,12 @@ | ||
| import { readFileSync } from 'fs'; | ||
| import { join } from 'path'; | ||
| import { SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics'; | ||
| import { intersects as semverIntersects } from 'semver'; | ||
| import { | ||
| intersects as semverIntersects, | ||
| prerelease as semverPrerelease, | ||
| satisfies as semverSatisfies, | ||
| valid as semverValid, | ||
| } from 'semver'; | ||
| import { FirebaseHostingSite } from './interfaces'; | ||
|
|
||
| export const shortSiteName = (site?: FirebaseHostingSite) => site?.name?.split('/').pop(); | ||
|
|
@@ -65,3 +72,52 @@ export const addDependencies = ( | |
|
|
||
| overwriteIfExists(host, 'package.json', stringifyFormatted(packageJson)); | ||
| }; | ||
|
|
||
| /** | ||
| * Reads the exact version of the installed `@angular/fire` package from its own manifest — the | ||
| * compiled schematics run from `<package root>/schematics/<entry>/`, so the manifest sits two | ||
| * directories up. Returns undefined when that layout doesn't hold (e.g. running from source), | ||
| * so callers skip pinning instead of failing the schematic. | ||
| */ | ||
| const readInstalledVersion = () => { | ||
| try { | ||
| return JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json')).toString()).version; | ||
| } catch { | ||
| return undefined; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: if the file read fails for any reason, |
||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Pins the workspace's `@angular/fire` entry to the exact installed version when `ng add` wrote a | ||
| * prerelease range. A prerelease range like `^21.0.0-rc.0` also matches the canary build published | ||
| * for every merge to main, so a later fresh install can silently replace the version the user | ||
| * chose. Stable ranges are left untouched. | ||
| */ | ||
| export const pinInstalledPrereleaseVersion = ( | ||
| host: Tree, | ||
| context: SchematicContext, | ||
| installedVersion = readInstalledVersion(), | ||
| ) => { | ||
| if (!host.exists('package.json')) { return; } | ||
| const packageJson = safeReadJSON('package.json', host); | ||
|
|
||
| const declaredAngularFireVersion = packageJson.dependencies?.['@angular/fire']; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The function only reads from |
||
| if ( | ||
| typeof declaredAngularFireVersion !== 'string' || | ||
| !(declaredAngularFireVersion.startsWith('^') || declaredAngularFireVersion.startsWith('~')) | ||
| ) { return; } | ||
|
|
||
| if ( | ||
| semverValid(installedVersion) && | ||
| semverPrerelease(installedVersion) && | ||
| semverSatisfies(installedVersion, declaredAngularFireVersion, { includePrerelease: true }) | ||
| ) { | ||
| packageJson.dependencies['@angular/fire'] = installedVersion; | ||
| overwriteIfExists(host, 'package.json', stringifyFormatted(packageJson)); | ||
| context.logger.info( | ||
| `Pinned @angular/fire to the exact version ${installedVersion} — a prerelease range like ` + | ||
| `${declaredAngularFireVersion} also matches unreviewed canary builds, so a later install ` + | ||
| 'could silently change versions.' | ||
| ); | ||
| } | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Optional/nit, design suggestion:
tools/build.tsalready stamps values intoversions.jsonat build time, andadd/index.tsalready imports from it. AddingangularFireVersionthere would let you replace thereadFileSync/__dirnametraversal with a plain JSON import, same pattern already in use and no filesystem-path assumptions.