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
22 changed files with 1050 additions and 591 deletions

View File

@@ -3,8 +3,8 @@ name: Prepare new release
on:
workflow_dispatch:
inputs:
serverBump:
description: 'Bump server version'
releaseType:
description: 'Release type'
required: true
default: 'false'
type: choice
@@ -77,9 +77,9 @@ jobs:
- name: Bump version
env:
SERVER_BUMP: ${{ inputs.serverBump }}
RELEASE_TYPE: ${{ inputs.releaseType }}
MOBILE_BUMP: ${{ inputs.mobileBump }}
run: pnpm --silent release -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}"
run: mise //:release --type "${RELEASE_TYPE}" --mobile "${MOBILE_BUMP}"
- id: output
run: |

View File

@@ -28,8 +28,8 @@ jobs:
with:
github-token: ${{ steps.token.outputs.token }}
filters: |
root:
- 'misc/**'
scripts:
- 'packages/scripts/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
i18n:
@@ -68,10 +68,10 @@ jobs:
- '.github/workflows/test.yml'
force-events: 'workflow_dispatch'
root-unit-tests:
name: Test the root workspace
script-unit-tests:
name: Scripts unit tests
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).root == true }}
if: ${{ fromJSON(needs.pre-job.outputs.should_run).scripts == true }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -94,7 +94,7 @@ jobs:
github_token: ${{ steps.token.outputs.token }}
- name: Run unit tests
run: pnpm test
run: pnpm --filter @immich/scripts test
server-unit-tests:
name: Test & Lint Server

View File

@@ -12,11 +12,11 @@ Our [GitHub Repository](https://github.com/immich-app/immich) is a [monorepo](ht
| `.vscode/` | VSCode debug launch profiles |
| `packages/cli` | Source code for the CLI |
| `packages/sdk` | Source code for the generated OpenAPI SDK |
| `packages/scripts` | Scripts for version bumps and draft releases |
| `docker/` | Docker compose resources for dev, test, production |
| `design/` | Screenshots and logos for the README |
| `docs/` | Source code for the [https://immich.app](https://immich.app) website |
| `machine-learning/` | Source code for the `immich-machine-learning` docker image |
| `misc/release/` | Scripts for version bumps and draft releases |
| `mobile/` | Source code for the mobile app, both Android and iOS |
| `server/` | Source code for the `immich-server` docker image |
| `web/` | Source code for the `web` |

View File

@@ -1,46 +0,0 @@
#! /usr/bin/env node
import { readFileSync, writeFileSync } from 'node:fs';
const asVersion = (item) => {
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 nextVersion = process.argv[2];
if (!nextVersion) {
console.log('Usage: archive-version.js <version>');
process.exit(1);
}
const filename = './docs/static/archived-versions.json';
let versions = JSON.parse(readFileSync(filename));
const newVersion = {
label: `v${nextVersion}`,
url: `https://docs.v${nextVersion}.archive.immich.app`,
};
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
) {
versions = versions.filter((item) => item.label !== version.label);
console.log(
`Removed ${version.label} (replaced with ${lastVersion.label})`,
);
continue;
}
lastVersion = version;
}
writeFileSync(
filename,
JSON.stringify([newVersion, ...versions], null, 2) + '\n',
);

View File

@@ -1,80 +0,0 @@
#!/usr/bin/env bash
#
# Pump one or both of the server/mobile versions in appropriate files
#
# usage: './scripts/pump-version.sh -s <minor|patch|premajor|preminor|prepatch|prerelease> <-m> <true|false>
#
# examples:
# ./scripts/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50
# ./scripts/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51
# ./scripts/pump-version.sh -s premajor # 1.0.0+50 => 2.0.0-rc.0+50
# ./scripts/pump-version.sh -s prerelease # 2.0.0-rc.0+50 => 2.0.0-rc.1+50
# ./scripts/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51
#
SERVER_PUMP="false"
MOBILE_PUMP="false"
while getopts 's:m:' flag; do
case "${flag}" in
s) SERVER_PUMP=${OPTARG} ;;
m) MOBILE_PUMP=${OPTARG} ;;
*)
echo "Invalid args"
exit 1
;;
esac
done
CURRENT_SERVER=$(jq -r '.version' package.json)
if ! NEXT_SERVER=$(pnpm --silent pump "$CURRENT_SERVER" "$SERVER_PUMP"); then
echo "Fatal: failed to pump server version: $NEXT_SERVER" >&2
exit 1
fi
CURRENT_MOBILE=$(grep "^version: .*+[0-9]\+$" mobile/pubspec.yaml | cut -d "+" -f2)
NEXT_MOBILE=$CURRENT_MOBILE
if [[ $MOBILE_PUMP == "true" ]]; then
set $((NEXT_MOBILE++))
elif [[ $MOBILE_PUMP == "false" ]]; then
echo 'Skipping Mobile Pump'
else
echo "Fatal: MOBILE_PUMP value $MOBILE_PUMP is invalid"
exit 1
fi
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix server
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/cli
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix web
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix e2e
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/sdk
# copy version to open-api spec
mise run //:open-api
uv version --directory machine-learning "$NEXT_SERVER"
./misc/release/archive-version.js "$NEXT_SERVER"
fi
if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
echo "Pumping Mobile: $CURRENT_MOBILE => $NEXT_MOBILE"
fi
sed -i "s/\"android\.injected\.version\.name\" => \".*\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile
sed -i "s/\"android\.injected\.version\.code\" => [0-9]\+,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile
sed -i "s/^version: .*+[0-9]\+$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml
# strip prerelease from CFBundleShortVersionString (deploying to testflight _is_ the prerelease)
NEXT_SERVER_SHORT="${NEXT_SERVER%%-*}"
perl -i -p0e "s/(<key>CFBundleShortVersionString<\/key>\s*<string>).*?(<\/string>)/\${1}$NEXT_SERVER_SHORT\${2}/s" mobile/ios/Runner/Info.plist
echo "IMMICH_VERSION=v$NEXT_SERVER" >>"$GITHUB_ENV"

View File

@@ -1,7 +0,0 @@
import { pump } from './pump.js';
const [versionRaw, type] = process.argv.slice(2);
const { message, exitCode } = pump(versionRaw, type);
console.log(message);
process.exit(exitCode);

View File

@@ -1,105 +0,0 @@
import semver, { SemVer } from 'semver';
const printUsage = () => {
return {
message:
'Usage: ./pump_cli.js <semver> <minor|patch|premajor|preminor|prepatch|prerelease|release>',
exitCode: 1,
};
};
const isPrerelease = (version) => version.prerelease.length > 0;
/**
* @param {SemVer} version
* @returns {boolean}
*/
const inc = (version, type) => `v${semver.inc(version, type, {}, 'rc')}`;
/** @param {string} version */
const normalize = (version) => {
if (version.startsWith('v')) {
version = version.slice(1);
}
return version;
};
/**
* @param {string} versionRaw
* @param {string} type
*/
export const pump = (versionRaw, type) => {
if (!versionRaw) {
return printUsage();
}
versionRaw = normalize(versionRaw);
const version = semver.parse(versionRaw);
if (!version) {
return printUsage();
}
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: {
return printUsage();
}
}
if (!newVersionRaw) {
return printUsage();
}
newVersionRaw = normalize(newVersionRaw);
const newVersion = semver.parse(newVersionRaw);
if (!newVersion) {
return printUsage();
}
const invalidUpgrade =
isPrerelease(version) &&
!isPrerelease(newVersion) &&
(version.major !== newVersion.major ||
version.minor !== newVersion.minor ||
version.patch !== newVersion.patch);
if (!valid || invalidUpgrade) {
return {
message: `Invalid pump: ${type}. Pumping from ${versionRaw} to ${newVersionRaw} is not allowed.`,
exitCode: 1,
};
}
return { message: newVersionRaw, exitCode: 0 };
};

View File

@@ -1,87 +0,0 @@
import { describe, expect, it } from 'vitest';
import { pump } from './pump';
describe(pump.name, () => {
describe('usage', () => {
it.each([
[],
['2.7.5'],
['2.7.5', 'invalid'],
['invalid', 'patch'],
['2.7.5', 'major'],
])('should not accept $0, $1 as inputs', (version, type) => {
expect(pump(version, type)).toEqual({
message: expect.stringContaining('Usage: '),
exitCode: 1,
});
});
});
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(pump(version, type)).toEqual({
message: next,
exitCode: 0,
});
},
);
});
}
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(pump(version, type)).toEqual({
message: expect.stringContaining('Invalid pump'),
exitCode: 1,
});
});
});
});
});

View File

@@ -39,6 +39,14 @@ experimental = true
pin = true
lockfile = true
[tasks.release]
run = [
"pnpm --filter @immich/scripts install --frozen-lockfile",
"pnpm --filter @immich/scripts build",
"pnpm --filter @immich/scripts cli release {{arg(name='args', var=true)}}",
{ task = "//:open-api" },
]
[tasks.plugins]
run = [
"pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",

View File

@@ -6,20 +6,14 @@
"private": true,
"scripts": {
"format": "prettier --cache --check i18n/",
"format:fix": "prettier --cache --write --list-different i18n",
"test": "vitest",
"release": "./misc/release/pump-version.sh",
"pump": "node ./misc/release/pump-wrapper.js"
"format:fix": "prettier --cache --write --list-different i18n"
},
"packageManager": "pnpm@11.6.0",
"engines": {
"pnpm": ">=10.0.0"
},
"devDependencies": {
"@types/node": "^24.13.2",
"prettier": "^3.8.3",
"prettier-plugin-sort-json": "^4.2.0",
"semver": "^7.8.1",
"vitest": "^4.1.8"
"prettier-plugin-sort-json": "^4.2.0"
}
}

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);

623
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['misc/**/*.spec.js'],
},
});