mirror of
https://github.com/immich-app/immich.git
synced 2026-07-15 21:32:32 +03:00
feat: pump doc references (#29331)
This commit is contained in:
8
.github/workflows/prepare-release.yml
vendored
8
.github/workflows/prepare-release.yml
vendored
@@ -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: |
|
||||
|
||||
12
.github/workflows/test.yml
vendored
12
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
10
package.json
10
package.json
@@ -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
15
packages/scripts/.gitignore
vendored
Normal 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/
|
||||
36
packages/scripts/package.json
Normal file
36
packages/scripts/package.json
Normal 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"
|
||||
}
|
||||
31
packages/scripts/src/cli.spec.ts
Normal file
31
packages/scripts/src/cli.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
54
packages/scripts/src/cli.ts
Normal file
54
packages/scripts/src/cli.ts
Normal 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);
|
||||
};
|
||||
139
packages/scripts/src/commands/release.spec.ts
Normal file
139
packages/scripts/src/commands/release.spec.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
262
packages/scripts/src/commands/release.ts
Normal file
262
packages/scripts/src/commands/release.ts
Normal 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;
|
||||
3
packages/scripts/src/main.ts
Normal file
3
packages/scripts/src/main.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { cli } from './cli';
|
||||
|
||||
cli(process.argv);
|
||||
55
packages/scripts/src/types.ts
Normal file
55
packages/scripts/src/types.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
26
packages/scripts/tsconfig.json
Normal file
26
packages/scripts/tsconfig.json
Normal 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"]
|
||||
}
|
||||
25
packages/scripts/vite.config.ts
Normal file
25
packages/scripts/vite.config.ts
Normal 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
623
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['misc/**/*.spec.js'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user