From 865df448ef310b2ba4a3a9ad19cf8dc2cdf3f0bd Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Wed, 13 May 2026 15:10:57 -0500 Subject: [PATCH 1/4] feat: add release candidate flow to bump script and prepare-release workflow --- .github/workflows/prepare-release.yml | 15 ++++- misc/release/pump-version.sh | 84 ++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index d4fe794913..084ca38aca 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -17,6 +17,15 @@ on: description: 'Bump mobile build number' required: false type: boolean + rc: + description: 'Release candidate mode' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' + - 'finalize' skipTranslations: description: 'Skip translations' required: false @@ -74,7 +83,8 @@ jobs: env: SERVER_BUMP: ${{ inputs.serverBump }} MOBILE_BUMP: ${{ inputs.mobileBump }} - run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}" + RC: ${{ inputs.rc }} + run: misc/release/pump-version.sh -s "${SERVER_BUMP}" -m "${MOBILE_BUMP}" -r "${RC}" - id: output run: echo "version=$IMMICH_VERSION" >> $GITHUB_OUTPUT @@ -108,7 +118,7 @@ jobs: with: ref: ${{ needs.bump_version.outputs.ref }} - environment: production + environment: ${{ inputs.rc != 'false' && 'rc' || 'production' }} prepare_release: runs-on: ubuntu-latest @@ -140,6 +150,7 @@ jobs: uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: draft: true + prerelease: ${{ contains(needs.bump_version.outputs.version, '-rc.') }} tag_name: ${{ needs.bump_version.outputs.version }} token: ${{ steps.generate-token.outputs.token }} generate_release_notes: true diff --git a/misc/release/pump-version.sh b/misc/release/pump-version.sh index 39a3364723..3a8721520b 100755 --- a/misc/release/pump-version.sh +++ b/misc/release/pump-version.sh @@ -3,21 +3,26 @@ # # Pump one or both of the server/mobile versions in appropriate files # -# usage: './scripts/pump-version.sh -s <-m> +# usage: './scripts/pump-version.sh -s <-m> <-r> # # 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 -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) # SERVER_PUMP="false" MOBILE_PUMP="false" +RC="false" -while getopts 's:m:' flag; do +while getopts 's:m:r:' flag; do case "${flag}" in s) SERVER_PUMP=${OPTARG} ;; m) MOBILE_PUMP=${OPTARG} ;; + r) RC=${OPTARG} ;; *) echo "Invalid args" exit 1 @@ -25,10 +30,51 @@ while getopts 's:m:' flag; do esac done +if [[ "$RC" != "true" && "$RC" != "false" && "$RC" != "finalize" ]]; then + echo "Expected for the -r argument" + exit 1 +fi + CURRENT_SERVER=$(jq -r '.version' server/package.json) -MAJOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f1) -MINOR=$(echo "$CURRENT_SERVER" | cut -d '.' -f2) -PATCH=$(echo "$CURRENT_SERVER" | cut -d '.' -f3) + +if [[ "$CURRENT_SERVER" == *-rc.* ]]; then + CURRENT_BASE="${CURRENT_SERVER%-rc.*}" + CURRENT_RC_NUM="${CURRENT_SERVER##*-rc.}" +else + CURRENT_BASE="$CURRENT_SERVER" + CURRENT_RC_NUM="" +fi + +# Validate RC/server-bump combinations against current version state +if [[ -n "$CURRENT_RC_NUM" ]]; then + # Currently on an RC + if [[ "$RC" == "false" ]]; then + echo "Current version $CURRENT_SERVER is a release candidate. Pass -r true to iterate the RC or -r finalize to finalize the release." + exit 1 + fi + if [[ "$RC" == "true" && "$SERVER_PUMP" != "false" ]]; then + echo "Cannot start a new RC while still on an RC; finalize first." + exit 1 + fi + if [[ "$RC" == "finalize" && "$SERVER_PUMP" != "false" ]]; then + echo "Finalize takes no server bump." + exit 1 + fi +else + # Not currently on an RC + if [[ "$RC" == "true" && "$SERVER_PUMP" == "false" ]]; then + echo "Starting an RC requires a server bump." + exit 1 + fi + if [[ "$RC" == "finalize" ]]; then + echo "Nothing to finalize." + exit 1 + 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)) @@ -46,7 +92,22 @@ else exit 1 fi -NEXT_SERVER=$MAJOR.$MINOR.$PATCH +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" +else + NEXT_SERVER="$NEXT_BASE" +fi CURRENT_MOBILE=$(grep "^version: .*+[0-9]\+$" mobile/pubspec.yaml | cut -d "+" -f2) NEXT_MOBILE=$CURRENT_MOBILE @@ -72,9 +133,12 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then # copy version to open-api spec mise run //:open-api - uv version --directory machine-learning "$NEXT_SERVER" + NEXT_PY="${NEXT_SERVER//-rc./rc}" + uv version --directory machine-learning "$NEXT_PY" - ./misc/release/archive-version.js "$NEXT_SERVER" + if [[ "$NEXT_SERVER" != *-rc.* ]]; then + ./misc/release/archive-version.js "$NEXT_SERVER" + fi fi if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then @@ -84,7 +148,9 @@ fi sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml -perl -i -p0e "s/(CFBundleShortVersionString<\/key>\s*)$CURRENT_SERVER(<\/string>)/\${1}$NEXT_SERVER\${2}/s" mobile/ios/Runner/Info.plist +# iOS marketing version cannot contain a pre-release suffix; strip -rc.N before writing the plist. +IOS_NEXT="${NEXT_SERVER%-rc.*}" +perl -i -p0e "s/(CFBundleShortVersionString<\/key>\s*)$CURRENT_SERVER(<\/string>)/\${1}$IOS_NEXT\${2}/s" mobile/ios/Runner/Info.plist echo "IMMICH_VERSION=v$NEXT_SERVER" >>"$GITHUB_ENV" From f8244e953b8635a261b59f6c620f7c63f1df4ae5 Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Thu, 14 May 2026 15:50:49 -0500 Subject: [PATCH 2/4] pr feedback --- .github/workflows/prepare-release.yml | 2 +- misc/release/pump-version.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 084ca38aca..d5ff1b4716 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -150,7 +150,7 @@ jobs: uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: draft: true - prerelease: ${{ contains(needs.bump_version.outputs.version, '-rc.') }} + prerelease: ${{ inputs.rc == 'true' }} tag_name: ${{ needs.bump_version.outputs.version }} token: ${{ steps.generate-token.outputs.token }} generate_release_notes: true diff --git a/misc/release/pump-version.sh b/misc/release/pump-version.sh index 3a8721520b..4ca8cdbbb9 100755 --- a/misc/release/pump-version.sh +++ b/misc/release/pump-version.sh @@ -148,9 +148,9 @@ fi sed -i "s/\"android\.injected\.version\.name\" => \"$CURRENT_SERVER\",/\"android\.injected\.version\.name\" => \"$NEXT_SERVER\",/" mobile/android/fastlane/Fastfile sed -i "s/\"android\.injected\.version\.code\" => $CURRENT_MOBILE,/\"android\.injected\.version\.code\" => $NEXT_MOBILE,/" mobile/android/fastlane/Fastfile sed -i "s/^version: $CURRENT_SERVER+$CURRENT_MOBILE$/version: $NEXT_SERVER+$NEXT_MOBILE/" mobile/pubspec.yaml -# iOS marketing version cannot contain a pre-release suffix; strip -rc.N before writing the plist. +# iOS marketing version cannot contain a pre-release suffix; the plist always holds the base version. IOS_NEXT="${NEXT_SERVER%-rc.*}" -perl -i -p0e "s/(CFBundleShortVersionString<\/key>\s*)$CURRENT_SERVER(<\/string>)/\${1}$IOS_NEXT\${2}/s" mobile/ios/Runner/Info.plist +perl -i -p0e "s/(CFBundleShortVersionString<\/key>\s*)$CURRENT_BASE(<\/string>)/\${1}$IOS_NEXT\${2}/s" mobile/ios/Runner/Info.plist echo "IMMICH_VERSION=v$NEXT_SERVER" >>"$GITHUB_ENV" From f7f8d38ad5a987fa72bfe9247fb79ef2214e544c Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Fri, 15 May 2026 10:51:24 -0500 Subject: [PATCH 3/4] feedback --- misc/release/pump-version.sh | 54 ++---- misc/release/pump-version.test.mjs | 300 +++++++++++++++++++++++++++++ 2 files changed, 320 insertions(+), 34 deletions(-) create mode 100644 misc/release/pump-version.test.mjs 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'); +}); From e8a94851db20049589fd514c85f51351c25421fc Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Tue, 19 May 2026 22:04:34 +0200 Subject: [PATCH 4/4] chore: simplify rc flag --- .github/workflows/prepare-release.yml | 14 +++---- misc/release/pump-version.sh | 59 ++++++++++++++------------- misc/release/pump-version.test.mjs | 18 ++++++-- 3 files changed, 50 insertions(+), 41 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index d5ff1b4716..353416bbc7 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -18,14 +18,10 @@ on: required: false type: boolean rc: - description: 'Release candidate mode' + description: 'Release candidate' required: false - default: 'false' - type: choice - options: - - 'false' - - 'true' - - 'finalize' + default: false + type: boolean skipTranslations: description: 'Skip translations' required: false @@ -118,7 +114,7 @@ jobs: with: ref: ${{ needs.bump_version.outputs.ref }} - environment: ${{ inputs.rc != 'false' && 'rc' || 'production' }} + environment: ${{ inputs.rc && 'rc' || 'production' }} prepare_release: runs-on: ubuntu-latest @@ -150,7 +146,7 @@ jobs: uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: draft: true - prerelease: ${{ inputs.rc == 'true' }} + prerelease: ${{ inputs.rc }} tag_name: ${{ needs.bump_version.outputs.version }} token: ${{ steps.generate-token.outputs.token }} generate_release_notes: true diff --git a/misc/release/pump-version.sh b/misc/release/pump-version.sh index 499248d387..c88f119d76 100755 --- a/misc/release/pump-version.sh +++ b/misc/release/pump-version.sh @@ -1,17 +1,31 @@ #!/usr/bin/env bash # -# Pump one or both of the server/mobile versions in appropriate files +# Pump one or both of the server/mobile versions in appropriate files. # -# usage: './scripts/pump-version.sh -s <-m> <-r> +# Usage: +# ./misc/release/pump-version.sh [-s ] [-m ] [-r ] # -# 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 -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.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) +# Flags: +# -s Server version bump scope. Omit to leave the server +# version unchanged, except when finalizing an RC (see -r). +# (default: false) +# -m Whether to increment the mobile build number. +# (default: false) +# -r Release candidate mode. When true, starts a new RC +# (combined with -s) or iterates an existing one. When +# false while the current version is already an RC, +# finalizes it (e.g. 3.1.0-rc.2 => 3.1.0). A server bump +# is rejected while on an RC; finalize first. +# (default: false) +# +# Examples: +# ./misc/release/pump-version.sh -s major # 1.0.0+50 => 2.0.0+50 +# ./misc/release/pump-version.sh -s minor -m true # 1.0.0+50 => 1.1.0+51 +# ./misc/release/pump-version.sh -m true # 1.0.0+50 => 1.0.0+51 +# ./misc/release/pump-version.sh -s minor -m true -r true # 3.0.0 => 3.1.0-rc.0 (start RC) +# ./misc/release/pump-version.sh -m true -r true # 3.1.0-rc.0 => 3.1.0-rc.1 (iterate RC) +# ./misc/release/pump-version.sh -m true # 3.1.0-rc.1 => 3.1.0 (finalize RC) # SERVER_PUMP="false" @@ -30,8 +44,8 @@ while getopts 's:m:r:' flag; do esac done -if [[ "$RC" != "true" && "$RC" != "false" && "$RC" != "finalize" ]]; then - echo "Expected for the -r argument" +if [[ "$RC" != "true" && "$RC" != "false" ]]; then + echo "Expected for the -r argument" exit 1 fi @@ -47,17 +61,9 @@ fi # Validate RC/server-bump combinations against current version state if [[ -n "$CURRENT_RC_NUM" ]]; then - # Currently on an RC - if [[ "$RC" == "false" ]]; then - echo "Current version $CURRENT_SERVER is a release candidate. Pass -r true to iterate the RC or -r finalize to finalize the release." - exit 1 - fi - if [[ "$RC" == "true" && "$SERVER_PUMP" != "false" ]]; then - echo "Cannot start a new RC while still on an RC; finalize first." - exit 1 - fi - if [[ "$RC" == "finalize" && "$SERVER_PUMP" != "false" ]]; then - echo "Finalize takes no server bump." + # Currently on an RC: -r true iterates, -r false finalizes. Either way, a server bump is invalid. + if [[ "$SERVER_PUMP" != "false" ]]; then + echo "Cannot bump server while on an RC ($CURRENT_SERVER); finalize first by re-running with -r false and no -s." exit 1 fi else @@ -66,10 +72,6 @@ else echo "Starting an RC requires a server bump." exit 1 fi - if [[ "$RC" == "finalize" ]]; then - echo "Nothing to finalize." - exit 1 - fi fi if [[ "$SERVER_PUMP" != "major" && "$SERVER_PUMP" != "minor" && "$SERVER_PUMP" != "patch" && "$SERVER_PUMP" != "false" ]]; then @@ -78,7 +80,7 @@ if [[ "$SERVER_PUMP" != "major" && "$SERVER_PUMP" != "minor" && "$SERVER_PUMP" ! fi NEXT_SERVER="$CURRENT_SERVER" -if [[ "$SERVER_PUMP" == "false" && "$RC" == "false" ]]; then +if [[ "$SERVER_PUMP" == "false" && "$RC" == "false" && -z "$CURRENT_RC_NUM" ]]; then echo 'Skipping Server Pump' else npm version "$CURRENT_SERVER" --allow-same-version --no-git-tag-version || exit 1 @@ -87,7 +89,8 @@ else 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 + elif [[ -n "$CURRENT_RC_NUM" ]]; then + # rc=false while on an RC → finalize npm version "$CURRENT_BASE" --no-git-tag-version || exit 1 else npm version "$SERVER_PUMP" --no-git-tag-version || exit 1 diff --git a/misc/release/pump-version.test.mjs b/misc/release/pump-version.test.mjs index c35de63786..7e21ddc21b 100644 --- a/misc/release/pump-version.test.mjs +++ b/misc/release/pump-version.test.mjs @@ -250,10 +250,10 @@ test('iterates an existing RC', (t) => { assert.equal(fixture.hasFile('archive-version.calls'), false); }); -test('finalizes an existing RC', (t) => { +test('finalizes an existing RC when rc is false', (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'); + const result = fixture.run('-m', 'false', '-r', 'false'); assertCommandPassed(result); assertPackageVersions(fixture, '3.1.0'); @@ -288,13 +288,23 @@ test('bumps mobile only', (t) => { assert.equal(fixture.hasFile('archive-version.calls'), false); }); -test('rejects starting a new RC while already on an RC', (t) => { +test('rejects a server bump while 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.match(result.stdout, /Cannot bump server while on an RC/); assert.equal(packageVersion(fixture.path), '2.7.5'); assert.equal(packageVersion(fixture.file('server')), '3.1.0-rc.0'); }); + +test('rejects a server bump while finalizing an RC', (t) => { + const fixture = makeFixture(t, { rootVersion: '2.7.5', serverVersion: '3.1.0-rc.1', mobileBuild: 3048 }); + + const result = fixture.run('-s', 'patch', '-r', 'false'); + + assert.notEqual(result.status, 0); + assert.match(result.stdout, /Cannot bump server while on an RC/); + assert.equal(packageVersion(fixture.file('server')), '3.1.0-rc.1'); +});