feat: derive mobile build number from semver (#30569)

This commit is contained in:
bo0tzz 2026-08-11 14:45:32 +02:00 committed by GitHub
parent 9273a4ca2a
commit cf17dcbfee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 104 additions and 41 deletions

View file

@ -23,32 +23,25 @@ export const cli = (argv: string[]) => {
.choices(RELEASE_TYPES)
.makeOptionMandatory(),
)
.addOption(
new Option('-m, --mobile <value>', 'pump mobile build number')
.choices(['true', 'false'])
.default('false'),
)
.action(
({ type, mobile }: { type: ReleaseType; mobile: 'true' | 'false' }) => {
try {
console.log(handleRelease({ type, mobile: mobile === 'true' }));
} catch (error) {
if (error instanceof ReleaseInputError) {
console.log(program.usage());
process.exit(1);
}
if (error instanceof ReleaseError) {
console.log(
`Invalid pump: ${type}. Pumping from ${error.version} to ${error.newVersion} is not allowed.`,
);
process.exit(1);
}
throw error;
.action(({ type }: { type: ReleaseType }) => {
try {
console.log(handleRelease({ type }));
} catch (error) {
if (error instanceof ReleaseInputError) {
console.log(program.usage());
process.exit(1);
}
},
);
if (error instanceof ReleaseError) {
console.log(
`Invalid pump: ${type}. Pumping from ${error.version} to ${error.newVersion} is not allowed.`,
);
process.exit(1);
}
throw error;
}
});
return program.parse(argv);
};

View file

@ -1,11 +1,16 @@
import semver from 'semver';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
resolveArchivedVersions,
ArchivedVersion,
getMobileBuild,
getNewVersion,
ReleaseError,
} from './release';
const mobileBuild = (version: string) =>
getMobileBuild(semver.parse(version) as semver.SemVer);
const archived = (label: string): ArchivedVersion => ({
label,
url: `https://docs.${label}.archive.immich.app`,
@ -137,3 +142,56 @@ describe(resolveArchivedVersions.name, () => {
]);
});
});
describe(getMobileBuild.name, () => {
it('should encode the version', () => {
expect(mobileBuild('3.1.0')).toBe(3_010_099);
expect(mobileBuild('3.1.4')).toBe(3_010_499);
expect(mobileBuild('3.2.0-rc.0')).toBe(3_020_000);
expect(mobileBuild('3.2.0-rc.3')).toBe(3_020_003);
});
it('should be above the last hand-maintained build number', () => {
expect(mobileBuild('3.1.0')).toBeGreaterThan(3057);
});
it('should stay under the play store limit', () => {
expect(mobileBuild('99.99.99')).toBeLessThan(2_100_000_000);
});
it('should increase across a release cycle', () => {
const versions = [
'3.1.0',
'3.1.1',
'3.2.0-rc.0',
'3.2.0-rc.1',
'3.2.0',
'3.2.1',
'3.3.0-rc.0',
'4.0.0-rc.0',
'4.0.0',
];
const builds = versions.map((version) => mobileBuild(version));
expect(builds).toEqual([...builds].sort((a, b) => a - b));
expect(new Set(builds).size).toBe(builds.length);
});
it('should rank a release above its own candidates', () => {
expect(mobileBuild('3.2.0')).toBeGreaterThan(mobileBuild('3.2.0-rc.98'));
});
it('should rank a patch on an older line below the newer line', () => {
expect(mobileBuild('3.1.5')).toBeLessThan(mobileBuild('3.2.0'));
});
it('should refuse versions it cannot encode', () => {
expect(() => mobileBuild('100.0.0')).toThrow('Cannot derive');
expect(() => mobileBuild('3.100.0')).toThrow('Cannot derive');
expect(() => mobileBuild('3.1.100')).toThrow('Cannot derive');
expect(() => mobileBuild('3.1.0-rc.99')).toThrow('Cannot derive');
expect(() => mobileBuild('3.1.0-beta')).toThrow('Cannot derive');
expect(() => mobileBuild('3.1.0-rc.-1')).toThrow('Cannot derive');
expect(() => mobileBuild('3.1.0-rc.x')).toThrow('Cannot derive');
});
});

View file

@ -35,7 +35,7 @@ const Files = {
},
};
export const handleRelease = ({ type, mobile }: ReleaseOptions) => {
export const handleRelease = ({ type }: ReleaseOptions) => {
const versionRaw = getVersion();
const newVersionRaw = getNewVersion(versionRaw, type);
const newVersion = semver.parse(normalize(newVersionRaw));
@ -43,8 +43,7 @@ export const handleRelease = ({ type, mobile }: ReleaseOptions) => {
throw new ReleaseInputError();
}
const newVersionNoRc = `${newVersion.major}.${newVersion.minor}.${newVersion.patch}`;
const mobileBuild = getMobileBuild();
const newMobileBuild = mobile ? mobileBuild + 1 : mobileBuild;
const newMobileBuild = getMobileBuild(newVersion);
// pump versions everywhere
@ -189,14 +188,32 @@ export const getNewVersion = (versionRaw: string, type: string) => {
return newVersionRaw;
};
const getMobileBuild = () => {
const pubspec = new TextFile(Files.Mobile.Pubspec).read();
const match = pubspec.match(/^version: .*\+(\d+)$/m);
if (!match) {
throw new Error('Could not find mobile build number in pubspec.yaml');
const RADIX = 100;
const STABLE = RADIX - 1;
export const getMobileBuild = (version: SemVer) => {
const { major, minor, patch, prerelease } = version;
const candidate = prerelease[1];
const digit = typeof candidate === 'number' ? candidate : STABLE;
const valid =
major < RADIX &&
minor < RADIX &&
patch < RADIX &&
(prerelease.length === 0 || digit < STABLE);
if (!valid) {
throw new Error(
`Cannot derive a mobile build number from ${version.format()}`,
);
}
return Number(match[1]);
return (
major * Math.pow(RADIX, 3) +
minor * Math.pow(RADIX, 2) +
patch * RADIX +
digit
);
};
const pump = (path: string, pattern: RegExp, replacement: string) => {

View file

@ -11,7 +11,7 @@ export const RELEASE_TYPES = [
] as const;
export type ReleaseType = (typeof RELEASE_TYPES)[number];
export type ReleaseOptions = { type: string; mobile: boolean };
export type ReleaseOptions = { type: string };
export class TextFile {
constructor(private file: string) {}