diff --git a/misc/release/pump-version.sh b/misc/release/pump-version.sh index 4ca8cdbbb9..499248d387 100755 --- a/misc/release/pump-version.sh +++ b/misc/release/pump-version.sh @@ -9,9 +9,9 @@ # ./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 -m true # 1.0.0+50 => 1.0.0+51 -# ./scripts/pump-version.sh -s minor -m true -r true # 3.0.0 => 3.1.0-rc.1 (start RC) -# ./scripts/pump-version.sh -m true -r true # 3.1.0-rc.1 => 3.1.0-rc.2 (iterate RC) -# ./scripts/pump-version.sh -m true -r finalize # 3.1.0-rc.2 => 3.1.0 (finalize RC) +# ./scripts/pump-version.sh -s minor -m true -r true # 3.0.0 => 3.1.0-rc.0 (start RC) +# ./scripts/pump-version.sh -m true -r true # 3.1.0-rc.0 => 3.1.0-rc.1 (iterate RC) +# ./scripts/pump-version.sh -m true -r finalize # 3.1.0-rc.1 => 3.1.0 (finalize RC) # SERVER_PUMP="false" @@ -72,41 +72,28 @@ else fi fi -MAJOR=$(echo "$CURRENT_BASE" | cut -d '.' -f1) -MINOR=$(echo "$CURRENT_BASE" | cut -d '.' -f2) -PATCH=$(echo "$CURRENT_BASE" | cut -d '.' -f3) - -if [[ $SERVER_PUMP == "major" ]]; then - MAJOR=$((MAJOR + 1)) - MINOR=0 - PATCH=0 -elif [[ $SERVER_PUMP == "minor" ]]; then - MINOR=$((MINOR + 1)) - PATCH=0 -elif [[ $SERVER_PUMP == "patch" ]]; then - PATCH=$((PATCH + 1)) -elif [[ $SERVER_PUMP == "false" ]]; then - echo 'Skipping Server Pump' -else +if [[ "$SERVER_PUMP" != "major" && "$SERVER_PUMP" != "minor" && "$SERVER_PUMP" != "patch" && "$SERVER_PUMP" != "false" ]]; then echo 'Expected for the server argument' exit 1 fi -NEXT_BASE=$MAJOR.$MINOR.$PATCH - -if [[ "$RC" == "true" ]]; then - if [[ -n "$CURRENT_RC_NUM" ]]; then - # Iterate existing RC - NEXT_RC_NUM=$((CURRENT_RC_NUM + 1)) - NEXT_SERVER="${NEXT_BASE}-rc.${NEXT_RC_NUM}" - else - # Start new RC after server bump - NEXT_SERVER="${NEXT_BASE}-rc.1" - fi -elif [[ "$RC" == "finalize" ]]; then - NEXT_SERVER="$NEXT_BASE" +NEXT_SERVER="$CURRENT_SERVER" +if [[ "$SERVER_PUMP" == "false" && "$RC" == "false" ]]; then + echo 'Skipping Server Pump' else - NEXT_SERVER="$NEXT_BASE" + npm version "$CURRENT_SERVER" --allow-same-version --no-git-tag-version || exit 1 + + if [[ "$RC" == "true" && -n "$CURRENT_RC_NUM" ]]; then + npm version prerelease --no-git-tag-version || exit 1 + elif [[ "$RC" == "true" ]]; then + npm version "pre$SERVER_PUMP" --preid=rc --no-git-tag-version || exit 1 + elif [[ "$RC" == "finalize" ]]; then + npm version "$CURRENT_BASE" --no-git-tag-version || exit 1 + else + npm version "$SERVER_PUMP" --no-git-tag-version || exit 1 + fi + + NEXT_SERVER=$(jq -r '.version' package.json) fi CURRENT_MOBILE=$(grep "^version: .*+[0-9]\+$" mobile/pubspec.yaml | cut -d "+" -f2) @@ -123,7 +110,6 @@ fi if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER" - pnpm version "$NEXT_SERVER" --no-git-tag-version pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix server pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix packages/cli pnpm version "$NEXT_SERVER" --no-git-tag-version --prefix web diff --git a/misc/release/pump-version.test.mjs b/misc/release/pump-version.test.mjs new file mode 100644 index 0000000000..c35de63786 --- /dev/null +++ b/misc/release/pump-version.test.mjs @@ -0,0 +1,300 @@ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import assert from 'node:assert/strict'; +import test from 'node:test'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, '../..'); +const scriptUnderTest = join(repoRoot, 'misc/release/pump-version.sh'); + +const read = (path) => readFileSync(path, 'utf8'); +const packageVersion = (dir) => JSON.parse(read(join(dir, 'package.json'))).version; +const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`; + +const writeExecutable = (path, contents) => { + writeFileSync(path, contents, { mode: 0o755 }); +}; + +const writePackageJson = (dir, name, version) => { + writeFileSync( + join(dir, 'package.json'), + `${JSON.stringify({ name, version, private: true }, null, 2)}\n`, + ); +}; + +const makeFixture = (t, { rootVersion = '2.7.5', serverVersion = '3.0.0', mobileBuild = 3047 } = {}) => { + const workdir = mkdtempSync(join(tmpdir(), 'pump-version-')); + t.after(() => rmSync(workdir, { recursive: true, force: true })); + + const currentBase = serverVersion.replace(/-rc\..+$/, ''); + + for (const path of [ + 'bin', + 'server', + 'packages/cli', + 'web', + 'e2e', + 'packages/sdk', + 'misc/release', + 'mobile/android/fastlane', + 'mobile/ios/Runner', + 'machine-learning', + ]) { + mkdirSync(join(workdir, path), { recursive: true }); + } + + writeCommandStubs(workdir); + + writePackageJson(workdir, 'immich-monorepo', rootVersion); + writePackageJson(join(workdir, 'server'), 'immich', serverVersion); + writePackageJson(join(workdir, 'packages/cli'), '@immich/cli', serverVersion); + writePackageJson(join(workdir, 'web'), 'immich-web', serverVersion); + writePackageJson(join(workdir, 'e2e'), 'immich-e2e', serverVersion); + writePackageJson(join(workdir, 'packages/sdk'), '@immich/sdk', serverVersion); + + writeExecutable( + join(workdir, 'misc/release/archive-version.js'), + `#!/usr/bin/env bash +set -euo pipefail +echo "$*" >>"$PWD/archive-version.calls" +`, + ); + + writeFileSync( + join(workdir, 'mobile/pubspec.yaml'), + `name: immich_mobile +version: ${serverVersion}+${mobileBuild} +`, + ); + + writeFileSync( + join(workdir, 'mobile/android/fastlane/Fastfile'), + `lane :gha_release_prod do + gradle( + properties: { + "android.injected.version.code" => ${mobileBuild}, + "android.injected.version.name" => "${serverVersion}", + } + ) +end +`, + ); + + writeFileSync( + join(workdir, 'mobile/ios/Runner/Info.plist'), + ` + + + CFBundleShortVersionString + ${currentBase} + + +`, + ); + + return { + path: workdir, + file: (path) => join(workdir, path), + readFile: (path) => read(join(workdir, path)), + hasFile: (path) => { + try { + return read(join(workdir, path)).length > 0; + } catch { + return false; + } + }, + run: (...args) => + spawnSync('bash', [scriptUnderTest, ...args], { + cwd: workdir, + env: { + ...process.env, + GITHUB_ENV: join(workdir, 'github_env'), + PATH: `${join(workdir, 'bin')}:${process.env.PATH}`, + }, + encoding: 'utf8', + }), + }; +}; + +const writeCommandStubs = (workdir) => { + const realNpm = spawnSync('which', ['npm'], { encoding: 'utf8' }).stdout.trim(); + + writeExecutable( + join(workdir, 'bin/npm'), + `#!/usr/bin/env bash +set -euo pipefail +real_npm=${shellQuote(realNpm)} +echo "$*" >>"$PWD/npm.calls" +"$real_npm" "$@" +`, + ); + + writeExecutable( + join(workdir, 'bin/pnpm'), + `#!/usr/bin/env bash +set -euo pipefail + +if [[ "\${1:-}" != "version" ]]; then + echo "Unexpected pnpm command: $*" >&2 + exit 1 +fi + +shift +version="\${1:-}" +shift +prefix="." + +while [[ $# -gt 0 ]]; do + case "$1" in + --prefix) + prefix="$2" + shift 2 + ;; + --no-git-tag-version) + shift + ;; + *) + echo "Unexpected pnpm argument: $1" >&2 + exit 1 + ;; + esac +done + +npm --prefix "$prefix" version "$version" --no-git-tag-version --allow-same-version >/dev/null +`, + ); + + writeExecutable( + join(workdir, 'bin/mise'), + `#!/usr/bin/env bash +set -euo pipefail +echo "$*" >>"$PWD/mise.calls" +`, + ); + + writeExecutable( + join(workdir, 'bin/uv'), + `#!/usr/bin/env bash +set -euo pipefail +echo "$*" >>"$PWD/uv.calls" + +if [[ "\${1:-}" != "version" ]]; then + echo "Unexpected uv command: $*" >&2 + exit 1 +fi + +shift +directory="." + +if [[ "\${1:-}" == "--directory" ]]; then + directory="$2" + shift 2 +fi + +version="\${1:-}" +mkdir -p "$directory" +cat >"$directory/pyproject.toml" < { + assert.equal(result.status, 0, result.stderr || result.stdout); +}; + +const assertPackageVersions = (fixture, expected) => { + assert.equal(packageVersion(fixture.path), expected); + assert.equal(packageVersion(fixture.file('server')), expected); + assert.equal(packageVersion(fixture.file('packages/cli')), expected); + assert.equal(packageVersion(fixture.file('web')), expected); + assert.equal(packageVersion(fixture.file('e2e')), expected); + assert.equal(packageVersion(fixture.file('packages/sdk')), expected); +}; + +const npmCalls = (fixture) => fixture.readFile('npm.calls').trim().split('\n'); + +test('starts an RC from the server version when the root package is stale', (t) => { + const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.0.0', mobileBuild: 3047 }); + + const result = fixture.run('-s', 'minor', '-m', 'true', '-r', 'true'); + + assertCommandPassed(result); + assertPackageVersions(fixture, '3.1.0-rc.0'); + assert.ok(npmCalls(fixture).includes('version preminor --preid=rc --no-git-tag-version')); + assert.match(fixture.readFile('mobile/pubspec.yaml'), /version: 3\.1\.0-rc\.0\+3048/); + assert.match(fixture.readFile('mobile/android/fastlane/Fastfile'), /"android\.injected\.version\.name" => "3\.1\.0-rc\.0"/); + assert.match(fixture.readFile('mobile/android/fastlane/Fastfile'), /"android\.injected\.version\.code" => 3048/); + assert.match(fixture.readFile('mobile/ios/Runner/Info.plist'), /3\.1\.0<\/string>/); + assert.match(fixture.readFile('uv.calls'), /version --directory machine-learning 3\.1\.0rc0/); + assert.equal(fixture.hasFile('archive-version.calls'), false); + assert.match(fixture.readFile('github_env'), /IMMICH_VERSION=v3\.1\.0-rc\.0/); +}); + +test('iterates an existing RC', (t) => { + const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0-rc.0', mobileBuild: 3048 }); + + const result = fixture.run('-m', 'false', '-r', 'true'); + + assertCommandPassed(result); + assertPackageVersions(fixture, '3.1.0-rc.1'); + assert.ok(npmCalls(fixture).includes('version prerelease --no-git-tag-version')); + assert.equal(npmCalls(fixture).some((call) => call.startsWith('version prerelease --preid')), false); + assert.match(fixture.readFile('mobile/pubspec.yaml'), /version: 3\.1\.0-rc\.1\+3048/); + assert.match(fixture.readFile('uv.calls'), /version --directory machine-learning 3\.1\.0rc1/); + assert.equal(fixture.hasFile('archive-version.calls'), false); +}); + +test('finalizes an existing RC', (t) => { + const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0-rc.1', mobileBuild: 3048 }); + + const result = fixture.run('-m', 'false', '-r', 'finalize'); + + assertCommandPassed(result); + assertPackageVersions(fixture, '3.1.0'); + assert.match(fixture.readFile('mobile/pubspec.yaml'), /version: 3\.1\.0\+3048/); + assert.match(fixture.readFile('mobile/ios/Runner/Info.plist'), /3\.1\.0<\/string>/); + assert.match(fixture.readFile('uv.calls'), /version --directory machine-learning 3\.1\.0/); + assert.match(fixture.readFile('archive-version.calls'), /3\.1\.0/); +}); + +test('bumps a normal patch release', (t) => { + const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0', mobileBuild: 3048 }); + + const result = fixture.run('-s', 'patch', '-m', 'true'); + + assertCommandPassed(result); + assertPackageVersions(fixture, '3.1.1'); + assert.match(fixture.readFile('mobile/pubspec.yaml'), /version: 3\.1\.1\+3049/); + assert.match(fixture.readFile('uv.calls'), /version --directory machine-learning 3\.1\.1/); + assert.match(fixture.readFile('archive-version.calls'), /3\.1\.1/); +}); + +test('bumps mobile only', (t) => { + const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0', mobileBuild: 3048 }); + + const result = fixture.run('-m', 'true'); + + assertCommandPassed(result); + assert.equal(packageVersion(fixture.path), '2.7.5'); + assert.equal(packageVersion(fixture.file('server')), '3.1.0'); + assert.match(fixture.readFile('mobile/pubspec.yaml'), /version: 3\.1\.0\+3049/); + assert.equal(fixture.hasFile('uv.calls'), false); + assert.equal(fixture.hasFile('archive-version.calls'), false); +}); + +test('rejects starting a new RC while already on an RC', (t) => { + const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0-rc.0', mobileBuild: 3048 }); + + const result = fixture.run('-s', 'patch', '-r', 'true'); + + assert.notEqual(result.status, 0); + assert.match(result.stdout, /Cannot start a new RC while still on an RC; finalize first\./); + assert.equal(packageVersion(fixture.path), '2.7.5'); + assert.equal(packageVersion(fixture.file('server')), '3.1.0-rc.0'); +});