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.