From 6deb73af589f39390784edfdbee4d3b3c364eb37 Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Wed, 30 Sep 2026 21:16:06 -0700 Subject: [PATCH] fix(build): name canaries above every published release of their major `ng add @angular/fire@canary` installs `^`, and npm picks the highest version in that range. Canaries were named after package.json's version, 21.0.0, so they ranked below 21.0.0-rc.1 and would rank below 21.0.0 once it ships, and the command installed those instead. A canary is now named one patch above the highest published version of its major that is not a canary, or after package.json's version if that is higher. Published canaries don't count, so the name doesn't climb with every build, and a release of another major doesn't rename them. Release candidate and release ranges like `^21.0.0-rc.1` and `^21.0.0` still never match a canary. The rule lives in tools/canary-version.js, with a spec run by test:node. Fixes #3779 --- tools/build.sh | 12 +++--- tools/canary-version.jasmine.ts | 74 +++++++++++++++++++++++++++++++++ tools/canary-version.js | 29 +++++++++++++ tsconfig.jasmine.json | 1 + 4 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 tools/canary-version.jasmine.ts create mode 100644 tools/canary-version.js diff --git a/tools/build.sh b/tools/build.sh index 13946716b..285f7633c 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -9,14 +9,12 @@ if [[ $GITHUB_REF =~ $TAG_TEST ]]; then NPM_TAG=next fi; else - FULL_VERSION=$(node -e "console.log(require('./package.json').version)") - # Name the canary after the release itself, never after a prerelease of it. A canary built on - # `21.0.0-rc.1` sorts above it, so the caret range `ng add` writes into a user's package.json - # resolves to the canary rather than to the release candidate they asked for. - BASE_VERSION=${FULL_VERSION%%-*} - if [[ $BASE_VERSION != "$FULL_VERSION" ]]; then - echo "package.json version is $FULL_VERSION. Naming this canary after $BASE_VERSION instead, so it does not outrank $FULL_VERSION on npm. Prereleases are published from their own git tag, so this field is meant to hold a plain release number." >&2 + PACKAGE_VERSION=$(node -e "console.log(require('./package.json').version)") + if ! PUBLISHED_VERSIONS=$(npm view @angular/fire versions --json); then + echo "Could not read the published @angular/fire versions from npm, so the canary has no version to build on." >&2 + exit 1 fi + BASE_VERSION=$(node ./tools/canary-version.js "$PACKAGE_VERSION" "$PUBLISHED_VERSIONS") || exit 1 # `sha-` stops npm dropping an all-digit sha's leading zero. CANARY_ID=$(TZ=UTC git show -s --date=format-local:%Y%m%d%H%M%S --format=%cd.sha-%h $GITHUB_SHA) OVERRIDE_VERSION=$BASE_VERSION-canary.$CANARY_ID diff --git a/tools/canary-version.jasmine.ts b/tools/canary-version.jasmine.ts new file mode 100644 index 000000000..14e94fcd0 --- /dev/null +++ b/tools/canary-version.jasmine.ts @@ -0,0 +1,74 @@ +import { maxSatisfying as semverMaxSatisfying } from 'semver'; +import { canaryBaseVersion } from './canary-version.js'; +import 'jasmine'; + +/* What's on npm today: a placeholder, v20 releases, rc.1, canaries named after the rc series + * (deprecated), and canaries named after 21.0.0. */ +const publishedToday = [ + '0.0.0', + '20.0.0', + '20.1.0', + '21.0.0-rc.0', + '21.0.0-rc.0-canary.fb6796b', + '21.0.0-rc.1', + '21.0.0-rc.1-canary.95b3de1', + '21.0.0-canary.a2662fe', + '21.0.0-canary.20260930011755.sha-59d44e7', +]; + +const canaryNamedAfter = (base: string) => `${base}-canary.20261001000000.sha-abcdef0`; + +describe('canaryBaseVersion', () => { + + it('names the canary one patch above the release candidate', () => { + expect(canaryBaseVersion('21.0.0', publishedToday)).toBe('21.0.1'); + }); + + it('names the canary one patch above the release once it ships', () => { + expect(canaryBaseVersion('21.0.0', [...publishedToday, '21.0.0'])).toBe('21.0.1'); + expect(canaryBaseVersion('21.0.0', [...publishedToday, '21.0.0', '21.0.1'])).toBe('21.0.2'); + }); + + it('names the canary above a minor or patch release candidate', () => { + expect(canaryBaseVersion('21.0.0', [...publishedToday, '21.0.0', '21.1.0-rc.0'])).toBe('21.1.1'); + expect(canaryBaseVersion('21.0.0', [...publishedToday, '21.0.0', '21.0.1-rc.0'])).toBe('21.0.2'); + }); + + it('does not count earlier canaries', () => { + const published = [...publishedToday, canaryNamedAfter('21.0.1'), canaryNamedAfter('21.0.1')]; + expect(canaryBaseVersion('21.0.0', published)).toBe('21.0.1'); + }); + + it('ignores releases from other majors', () => { + expect(canaryBaseVersion('21.0.0', [...publishedToday, '21.0.0', '20.0.4'])).toBe('21.0.1'); + expect(canaryBaseVersion('21.0.0', [...publishedToday, '21.0.0', '22.0.0-rc.0'])).toBe('21.0.1'); + }); + + it('uses the package.json version when nothing in its major is published yet', () => { + expect(canaryBaseVersion('22.0.0-rc.0', publishedToday)).toBe('22.0.0'); + expect(canaryBaseVersion('22.0.0-rc.0', [...publishedToday, '22.0.0-rc.0'])).toBe('22.0.1'); + }); + + it('throws when the package.json version has no version number in it', () => { + expect(() => canaryBaseVersion('abc', publishedToday)).toThrowError(TypeError); + }); + + it('gives a canary that its own caret range selects, and that release ranges never select', () => { + const states = [ + publishedToday, + [...publishedToday, '21.0.0'], + [...publishedToday, '21.0.0', '21.0.1'], + [...publishedToday, '21.0.0', '21.1.0-rc.0'], + [...publishedToday, '21.0.0', '21.0.1-rc.0'], + ]; + for (const published of states) { + const canary = canaryNamedAfter(canaryBaseVersion('21.0.0', published)); + const versions = [...published, canary]; + expect(semverMaxSatisfying(versions, `^${canary}`)).toBe(canary); + for (const range of ['^21.0.0', '~21.0.0', '^21.0.0-rc.1', '^20.0.0']) { + expect(semverMaxSatisfying(versions, range)).not.toBe(canary); + } + } + }); + +}); diff --git a/tools/canary-version.js b/tools/canary-version.js new file mode 100644 index 000000000..c589cec87 --- /dev/null +++ b/tools/canary-version.js @@ -0,0 +1,29 @@ +const semver = require('semver'); + +/** + * Picks the version a canary build is named after, as in `-canary.