feat: pump doc references (#29331)

This commit is contained in:
Jason Rasmussen 2026-06-26 12:45:06 -04:00 committed by GitHub
parent 29949bebe4
commit 6507b1f94c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1050 additions and 591 deletions

15
packages/scripts/.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
*-debug.log
*-error.log
/.nyc_output
/dist
/lib
/tmp
/yarn.lock
node_modules
oclif.manifest.json
.vscode
.idea
/coverage/
.reverse-geocoding-dump/
upload/

View file

@ -0,0 +1,36 @@
{
"name": "@immich/scripts",
"version": "3.0.0-rc.2",
"private": true,
"type": "module",
"main": "pump-wrapper.ts",
"license": "GNU Affero General Public License version 3",
"repository": {
"type": "git",
"url": "git+https://github.com/immich-app/immich.git",
"directory": "packages/scripts"
},
"scripts": {
"cli": "node dist/main.js",
"build": "vite build",
"watch": "vite build --watch",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "pnpm run lint --fix",
"test": "vitest",
"test:cov": "vitest --coverage",
"format": "prettier --cache --check .",
"format:fix": "prettier --cache --write --list-different .",
"check": "tsc --noEmit"
},
"dependencies": {
"commander": "^15.0.0",
"semver": "^7.8.1"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/semver": "^7.7.1",
"vite": "^8.0.16",
"vitest": "^4.1.8"
},
"packageManager": "pnpm@11.6.0"
}

View file

@ -0,0 +1,31 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cli } from './cli';
const run = (args: string[]) => cli(['node', 'pnpm cli', ...args]);
describe('cli', () => {
let exitMock: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
exitMock = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`process.exit(${code})`);
});
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
});
describe('release', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
[[], '--type is required'],
[['--type'], '--type requires a value'],
[['--type', 'invalid'], 'invalid is not an allowed choice'],
[['--type', 'major'], 'major is invalid'],
])('should not accept %j because %s', (args, _description) => {
expect(() => run(['release', ...args])).toThrow();
expect(exitMock).toHaveBeenCalledWith(1);
});
});
});

View file

@ -0,0 +1,54 @@
import { Command, Option } from 'commander';
import { handleRelease } from './commands/release';
import {
RELEASE_TYPES,
ReleaseError,
ReleaseInputError,
type ReleaseType,
} from './types';
export const cli = (argv: string[]) => {
const program = new Command();
program
.name('pnpm cli')
.description('Scripts for managing the repo, releases, etc.')
.version('0.1.0');
program
.command('release')
.description('pump release versions across relevant files')
.addOption(
new Option('-t, --type <type>', 'the type of version pump')
.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;
}
},
);
return program.parse(argv);
};

View file

@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
resolveArchivedVersions,
ArchivedVersion,
getNewVersion,
ReleaseError,
} from './release';
const archived = (label: string): ArchivedVersion => ({
label,
url: `https://docs.${label}.archive.immich.app`,
});
const labels = (versions: ArchivedVersion[]) => versions.map((v) => v.label);
describe(getNewVersion.name, () => {
describe('transitions', () => {
const valid = [
{
name: 'patch',
items: [['patch', '2.7.5', '2.7.6']],
},
{
name: 'prepatch',
items: [
['prepatch', '2.7.5', '2.7.6-rc.0'],
['prerelease', '2.7.6-rc.0', '2.7.6-rc.1'],
['release', '2.7.6-rc.1', '2.7.6'],
],
},
{
name: 'minor',
items: [['minor', '2.7.5', '2.8.0']],
},
{
name: 'preminor',
items: [
['preminor', '2.7.5', '2.8.0-rc.0'],
['prerelease', '2.8.0-rc.0', '2.8.0-rc.1'],
['release', '2.8.0-rc.1', '2.8.0'],
],
},
{
name: 'premajor',
items: [
['premajor', '2.7.5', '3.0.0-rc.0'],
['prerelease', '3.0.0-rc.0', '3.0.0-rc.1'],
['release', '3.0.0-rc.1', '3.0.0'],
],
},
];
for (const group of valid) {
describe(group.name, () => {
it.each(group.items)(
'should allow a $0 from $1 to $2',
(type, version, next) => {
expect(getNewVersion(version, type)).toEqual(next);
},
);
});
}
describe('invalid', () => {
it.each([
['patch', 'v3.0.0-rc.0'],
['prepatch', 'v3.0.0-rc.0'],
['minor', 'v3.0.0-rc.0'],
['preminor', 'v3.0.0-rc.0'],
['premajor', 'v3.0.0-rc.0'],
['prerelease', 'v3.0.0'],
['release', 'v3.0.0'],
])('should not allow a $0 on $1', (type, version) => {
expect(() => getNewVersion(version, type)).toThrow(ReleaseError);
});
});
});
});
describe(resolveArchivedVersions.name, () => {
beforeEach(() => {
// silence the "Removed ..." progress logging
vi.spyOn(console, 'log').mockImplementation(() => {});
});
it('should prepend the new version to the front', () => {
const result = resolveArchivedVersions([archived('v2.9.0')], '3.0.0');
expect(result[0]).toEqual({
label: 'v3.0.0',
url: 'https://docs.v3.0.0.archive.immich.app',
});
expect(labels(result)).toEqual(['v3.0.0', 'v2.9.0']);
});
it('should handle an empty list', () => {
expect(labels(resolveArchivedVersions([], '3.0.0'))).toEqual(['v3.0.0']);
});
it('should drop older patch releases of the new version minor', () => {
const versions = ['v3.0.4', 'v3.0.3', 'v3.0.2'].map(archived);
expect(labels(resolveArchivedVersions(versions, '3.0.5'))).toEqual([
'v3.0.5',
]);
});
it('should keep the latest patch of each older minor', () => {
const versions = ['v3.1.2', 'v3.1.1', 'v3.0.9', 'v2.0.0'].map(archived);
expect(labels(resolveArchivedVersions(versions, '3.2.0'))).toEqual([
'v3.2.0',
'v3.1.2',
'v3.0.9',
'v2.0.0',
]);
});
it('should keep an older minor when bumping a patch', () => {
const versions = ['v3.0.4', 'v3.0.3', 'v2.9.1', 'v2.9.0', 'v1.5.0'].map(
archived,
);
expect(labels(resolveArchivedVersions(versions, '3.0.5'))).toEqual([
'v3.0.5',
'v2.9.1',
'v1.5.0',
]);
});
it('should replace a prerelease with its release', () => {
const versions = ['v3.0.0-rc.2', 'v3.0.0-rc.1', 'v2.9.0'].map(archived);
expect(labels(resolveArchivedVersions(versions, '3.0.0'))).toEqual([
'v3.0.0',
'v2.9.0',
]);
});
});

View file

@ -0,0 +1,262 @@
import { execSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import semver, { SemVer } from 'semver';
import {
JsonFile,
RELEASE_TYPES,
ReleaseError,
ReleaseInputError,
ReleaseOptions,
ReleaseType,
TextFile,
} from 'src/types';
const root = join(dirname(fileURLToPath(import.meta.url)), '../../../');
const Files = {
PackageJson: {
Root: join(root, 'package.json'),
Rest: ['web', 'packages/cli', 'packages/sdk', 'e2e', 'server'].map(
(folder) => join(root, folder, `package.json`),
),
},
ExampleEnv: join(root, 'docker/example.env'),
Docs: {
Env: join(root, 'docs/docs/install/environment-variables.md'),
Upgrading: join(root, 'docs/docs/install/upgrading.md'),
ArchivedVersions: join(root, 'docs/static/archived-versions.json'),
},
Mobile: {
Pubspec: join(root, 'mobile/pubspec.yaml'),
Fastfile: join(root, 'mobile/android/fastlane/Fastfile'),
InfoPlist: join(root, 'mobile/ios/Runner/Info.plist'),
},
};
export const handleRelease = ({ type, mobile }: ReleaseOptions) => {
const versionRaw = getVersion();
const newVersionRaw = getNewVersion(versionRaw, type);
const newVersion = semver.parse(normalize(newVersionRaw));
if (!newVersion) {
throw new ReleaseInputError();
}
const newVersionNoRc = `${newVersion.major}.${newVersion.minor}.${newVersion.patch}`;
const mobileBuild = getMobileBuild();
const newMobileBuild = mobile ? mobileBuild + 1 : mobileBuild;
// pump versions everywhere
// package.json
for (const file of [Files.PackageJson.Root, ...Files.PackageJson.Rest]) {
pump(file, /^ "version": ".*"/m, ` "version": "${newVersionRaw}"`);
}
// machine-learning
execSync(`uv version --directory machine-learning ${newVersionRaw}`, {
cwd: root,
stdio: 'inherit',
});
// mobile
pump(
Files.Mobile.Fastfile,
/"android\.injected\.version\.name" => ".*",/g,
`"android.injected.version.name" => "${newVersionRaw}",`,
);
pump(
Files.Mobile.Fastfile,
/"android\.injected\.version\.code" => \d+,/g,
`"android.injected.version.code" => ${newMobileBuild},`,
);
pump(
Files.Mobile.Pubspec,
/^version: .*\+\d+$/m,
`version: ${newVersionRaw}+${newMobileBuild}`,
);
// strip prerelease from CFBundleShortVersionString
// (deploying to testflight _is_ the prerelease)
pump(
Files.Mobile.InfoPlist,
/(<key>CFBundleShortVersionString<\/key>\s*<string>).*?(<\/string>)/s,
`$1${newVersionNoRc}$2`,
);
if (type === 'release') {
// docker tag references (v2, :v2, etc) in docs
const major = `v${newVersion.major}`;
// sync major tag references in docs and example env file
pump(Files.ExampleEnv, /^IMMICH_VERSION=v\d+$/m, `IMMICH_VERSION=${major}`);
pump(Files.Docs.Env, /(`IMMICH_VERSION`.*?)`v\d+`/, `$1\`${major}\``);
pump(Files.Docs.Upgrading, /:v\d+/, `:${major}`);
}
// update archived versions list
const archivedFile = new JsonFile<ArchivedVersion[]>(
Files.Docs.ArchivedVersions,
);
const versions = archivedFile.read();
archivedFile.write(resolveArchivedVersions(versions, newVersionRaw));
if (process.env.GITHUB_ENV) {
// make available for following steps
appendFileSync(
process.env.GITHUB_ENV,
`IMMICH_VERSION=v${newVersionRaw}\n`,
);
}
return newVersionRaw;
};
const getVersion = () =>
new JsonFile<{ version: string }>(Files.PackageJson.Root).read().version;
export const getNewVersion = (versionRaw: string, type: string) => {
if (!versionRaw || !type || !RELEASE_TYPES.includes(type as ReleaseType)) {
throw new ReleaseInputError();
}
versionRaw = normalize(versionRaw);
const version = semver.parse(versionRaw);
if (!version) {
throw new ReleaseInputError();
}
let newVersionRaw;
let valid = true;
switch (type) {
case 'patch':
case 'prepatch':
case 'minor':
case 'preminor':
case 'premajor': {
newVersionRaw = inc(version, type);
// can only use while not in a prerelease
valid = !isPrerelease(version);
break;
}
case 'prerelease': {
newVersionRaw = inc(version, type);
// can only use while in a prerelease
valid = isPrerelease(version);
break;
}
case 'release': {
// drop prerelease part
newVersionRaw = `${version.major}.${version.minor}.${version.patch}`;
// can only use to promote a prerelease to a release (no version change)
valid = isPrerelease(version);
break;
}
default: {
throw new ReleaseInputError();
}
}
if (!newVersionRaw) {
throw new ReleaseInputError();
}
newVersionRaw = normalize(newVersionRaw);
const newVersion = semver.parse(newVersionRaw);
if (!newVersion) {
throw new ReleaseInputError();
}
const invalidUpgrade =
isPrerelease(version) &&
!isPrerelease(newVersion) &&
(version.major !== newVersion.major ||
version.minor !== newVersion.minor ||
version.patch !== newVersion.patch);
if (!valid || invalidUpgrade) {
throw new ReleaseError({
version: versionRaw,
newVersion: newVersionRaw,
});
}
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');
}
return Number(match[1]);
};
const pump = (path: string, pattern: RegExp, replacement: string) => {
const file = new TextFile(path);
const update = file.read().replace(pattern, replacement);
file.write(update);
};
export interface ArchivedVersion {
label: string;
url: string;
}
export const resolveArchivedVersions = (
versions: ArchivedVersion[],
nextVersion: string,
): ArchivedVersion[] => {
const newVersion: ArchivedVersion = {
label: `v${nextVersion}`,
url: `https://docs.v${nextVersion}.archive.immich.app`,
};
let result = versions;
let lastVersion = asVersion(newVersion);
for (const item of versions) {
const version = asVersion(item);
// only keep the latest patch version for each minor release
if (
lastVersion.major === version.major &&
lastVersion.minor === version.minor &&
lastVersion.patch >= version.patch
) {
result = result.filter((item) => item.label !== version.label);
console.log(
`Removed ${version.label} (replaced with ${lastVersion.label})`,
);
continue;
}
lastVersion = version;
}
return [newVersion, ...result];
};
const asVersion = (item: ArchivedVersion) => {
const { label, url } = item;
const [version] = label.substring(1).split('-');
const [major, minor, patch] = version.split('.').map(Number);
return { major, minor, patch, label, url };
};
const isPrerelease = (version: SemVer) => version.prerelease.length > 0;
/**
* @param {SemVer} version
* @returns {boolean}
*/
const inc = (version: SemVer, type: ReleaseType) =>
`v${semver.inc(version, type, {}, 'rc')}`;
const normalize = (version: string) =>
version.startsWith('v') ? version.slice(1) : version;

View file

@ -0,0 +1,3 @@
import { cli } from './cli';
cli(process.argv);

View file

@ -0,0 +1,55 @@
import { readFileSync, writeFileSync } from 'node:fs';
export const RELEASE_TYPES = [
'minor',
'patch',
'premajor',
'preminor',
'prepatch',
'prerelease',
'release',
] as const;
export type ReleaseType = (typeof RELEASE_TYPES)[number];
export type ReleaseOptions = { type: string; mobile: boolean };
export class TextFile {
constructor(private file: string) {}
read() {
return readFileSync(this.file, 'utf8');
}
write(contents: string) {
writeFileSync(this.file, contents);
}
}
export class JsonFile<T extends object> {
private text: TextFile;
constructor(private file: string) {
this.text = new TextFile(file);
}
read() {
return JSON.parse(this.text.read()) as T;
}
write<T>(contents: T) {
return this.text.write(JSON.stringify(contents, null, 2) + '\n');
}
}
export class ReleaseInputError extends Error {}
export class ReleaseError extends Error {
version: string;
newVersion: string;
constructor(options: { version: string; newVersion: string }) {
super(`Invalid pump`);
this.version = options.version;
this.newVersion = options.newVersion;
}
}

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"incremental": true,
"module": "esnext",
"moduleResolution": "bundler",
"outDir": "./dist",
"paths": {
"src/*": ["./src/*"]
},
"removeComments": true,
"resolveJsonModule": true,
"rootDir": "./src",
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "es2023",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
"types": ["vitest/globals", "node"]
},
"exclude": ["dist", "node_modules", "vite.config.ts"]
}

View file

@ -0,0 +1,25 @@
import { defineConfig, UserConfig } from 'vite';
export default defineConfig({
resolve: {
alias: { src: '/src' },
tsconfigPaths: true,
},
build: {
rolldownOptions: {
input: 'src/main.ts',
output: {
dir: 'dist',
},
},
ssr: true,
},
ssr: {
// bundle everything except for Node built-ins
noExternal: /^(?!node:).*$/,
},
test: {
name: 'scripts:unit',
globals: true,
},
} as UserConfig);