From 391fc877a7be73169cb733998222f6e1c768b301 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Wed, 5 Aug 2026 15:37:43 +0800 Subject: [PATCH] ci(release): automate app versioning from Git tags --- .github/workflows/release.yml | 93 +++++++++++++++++++---------------- package.json | 1 - scripts/app-version.mjs | 77 +++++++++++++++++++++++++++++ scripts/prepare-portable.mjs | 8 +-- scripts/set-app-version.mjs | 45 +++++++++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/tauri.conf.json | 1 - src/pages/Kernel.tsx | 15 ++++-- tests/app-version.test.ts | 63 ++++++++++++++++++++++++ 9 files changed, 250 insertions(+), 54 deletions(-) create mode 100644 scripts/app-version.mjs create mode 100644 scripts/set-app-version.mjs create mode 100644 tests/app-version.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68cb42b..c4e9362 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,8 +26,43 @@ permissions: contents: write jobs: + version: + name: Resolve app version + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.resolve.outputs.version }} + tag: ${{ steps.resolve.outputs.tag }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }} + - name: Read and validate app version + id: resolve + shell: bash + env: + REQUESTED_VERSION: ${{ github.event.release.tag_name || (github.ref_type == 'tag' && github.ref_name) || inputs.release_tag }} + run: | + set -euo pipefail + + source_version="$(node scripts/app-version.mjs)" + release_tag="$REQUESTED_VERSION" + if [ -z "$release_tag" ]; then + release_tag="v$source_version" + else + case "$release_tag" in + v*) ;; + *) release_tag="v$release_tag" ;; + esac + fi + + version="$(node scripts/app-version.mjs "${release_tag#v}")" + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=$release_tag" >> "$GITHUB_OUTPUT" + validate: name: Validate source + needs: version if: github.event_name != 'release' || github.event.release.prerelease == true runs-on: ubuntu-24.04 timeout-minutes: 30 @@ -41,6 +76,8 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt + - name: Apply release version + run: node scripts/set-app-version.mjs "${{ needs.version.outputs.version }}" - name: Install Linux dependencies run: | sudo apt-get update @@ -65,7 +102,9 @@ jobs: build: name: Build ${{ matrix.os }}-${{ matrix.arch }} - needs: validate + needs: + - version + - validate strategy: fail-fast: false matrix: @@ -110,50 +149,16 @@ jobs: with: bun-version: 1.3.14 - uses: dtolnay/rust-toolchain@stable + - name: Apply release version + run: node scripts/set-app-version.mjs "${{ needs.version.outputs.version }}" - name: Resolve release package name id: package shell: bash env: - REQUESTED_VERSION: ${{ github.event.release.tag_name || (github.ref_type == 'tag' && github.ref_name) || inputs.release_tag }} + APP_VERSION: ${{ needs.version.outputs.version }} run: | - source_version="$(node -p 'require("./package.json").version')" - version="$REQUESTED_VERSION" - if [ -z "$version" ]; then - version="v$source_version" - else - case "$version" in - v*) ;; - *) version="v$version" ;; - esac - fi - - if ! node -e 'process.exit(/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(process.argv[1]) ? 0 : 1)' "$version"; then - echo "Invalid release version: $version" >&2 - exit 1 - fi - - RELEASE_VERSION="${version#v}" node <<'NODE' - const { readFileSync, writeFileSync } = require('node:fs'); - - const version = process.env.RELEASE_VERSION; - for (const file of ['package.json', 'src-tauri/tauri.conf.json']) { - const manifest = JSON.parse(readFileSync(file, 'utf8')); - manifest.version = version; - writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`); - } - - const cargoManifest = 'src-tauri/Cargo.toml'; - const contents = readFileSync(cargoManifest, 'utf8'); - const versionPattern = /^version = "[^"]+"$/m; - if (!versionPattern.test(contents)) { - throw new Error(`Could not find version in ${cargoManifest}`); - } - const updated = contents.replace(versionPattern, `version = "${version}"`); - writeFileSync(cargoManifest, updated); - NODE - - echo "stem=EasyCLIProxyAPI-${version}-${{ matrix.display_os }}-${{ matrix.arch }}" >> "$GITHUB_OUTPUT" - echo "version=${version#v}" >> "$GITHUB_OUTPUT" + echo "stem=EasyCLIProxyAPI-v${APP_VERSION}-${{ matrix.display_os }}-${{ matrix.arch }}" >> "$GITHUB_OUTPUT" + echo "version=$APP_VERSION" >> "$GITHUB_OUTPUT" - name: Install Linux dependencies if: matrix.os == 'linux' run: | @@ -390,7 +395,9 @@ jobs: (github.event_name == 'release' && github.event.release.prerelease == true) || startsWith(github.ref, 'refs/tags/') || inputs.release_tag != '' - needs: build + needs: + - version + - build runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -404,7 +411,7 @@ jobs: - name: Generate Windows portable update manifest shell: bash env: - RELEASE_TAG: ${{ github.event.release.tag_name || (github.ref_type == 'tag' && github.ref_name) || inputs.release_tag }} + RELEASE_TAG: ${{ needs.version.outputs.tag }} run: >- node scripts/generate-portable-update-manifest.mjs --directory artifacts @@ -416,7 +423,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - RELEASE_TAG: ${{ github.event.release.tag_name || (github.ref_type == 'tag' && github.ref_name) || inputs.release_tag }} + RELEASE_TAG: ${{ needs.version.outputs.tag }} MANUAL_PRERELEASE: ${{ github.event_name == 'release' && github.event.release.prerelease || inputs.prerelease }} run: | set -euo pipefail diff --git a/package.json b/package.json index 100116b..6ca7758 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { "name": "cpa-gui", "private": true, - "version": "0.2.14", "type": "module", "packageManager": "bun@1.3.14", "scripts": { diff --git a/scripts/app-version.mjs b/scripts/app-version.mjs new file mode 100644 index 0000000..46f9459 --- /dev/null +++ b/scripts/app-version.mjs @@ -0,0 +1,77 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const semverPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +export function validateAppVersion(value, source = 'app version') { + const version = String(value).trim(); + if (!semverPattern.test(version)) { + throw new Error(`Invalid ${source}: ${version}`); + } + return version; +} + +export function parseCargoPackageVersion(contents, source = 'Cargo.toml') { + const packageSection = contents.match(/(?:^|\r?\n)\[package\][ \t]*\r?\n([\s\S]*?)(?=\r?\n\[|$)/); + if (!packageSection) { + throw new Error(`Missing [package] section in ${source}`); + } + + const versionMatch = packageSection[1].match(/^[ \t]*version[ \t]*=[ \t]*"([^"]+)"[ \t]*(?:#.*)?$/m); + if (!versionMatch) { + throw new Error(`Missing package version in ${source}`); + } + + return validateAppVersion(versionMatch[1], `package version in ${source}`); +} + +export function setCargoPackageVersion(contents, version, source = 'Cargo.toml') { + const nextVersion = validateAppVersion(version); + const packageSection = contents.match(/(?:^|\r?\n)\[package\][ \t]*\r?\n([\s\S]*?)(?=\r?\n\[|$)/); + if (!packageSection) { + throw new Error(`Missing [package] section in ${source}`); + } + + const versionPattern = /^[ \t]*version[ \t]*=[ \t]*"[^"]+"[ \t]*(?:#.*)?$/m; + if (!versionPattern.test(packageSection[0])) { + throw new Error(`Missing package version in ${source}`); + } + + const updatedSection = packageSection[0].replace(versionPattern, `version = "${nextVersion}"`); + return contents.replace(packageSection[0], updatedSection); +} + +export function setCargoLockPackageVersion(contents, packageName, version, source = 'Cargo.lock') { + const nextVersion = validateAppVersion(version); + const packageBlocks = contents.match(/\[\[package\]\]\r?\n[\s\S]*?(?=\r?\n\[\[package\]\]|$)/g) ?? []; + const matchingBlocks = packageBlocks.filter((block) => ( + new RegExp(`^name = "${packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"$`, 'm').test(block) + && !/^source = /m.test(block) + )); + + if (matchingBlocks.length !== 1) { + throw new Error(`Expected one local ${packageName} package in ${source}, found ${matchingBlocks.length}`); + } + + const versionPattern = /^version = "[^"]+"$/m; + if (!versionPattern.test(matchingBlocks[0])) { + throw new Error(`Missing ${packageName} version in ${source}`); + } + + const updatedBlock = matchingBlocks[0].replace(versionPattern, `version = "${nextVersion}"`); + return contents.replace(matchingBlocks[0], updatedBlock); +} + +export async function readAppVersion() { + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + const manifest = join(root, 'src-tauri', 'Cargo.toml'); + return parseCargoPackageVersion(await readFile(manifest, 'utf8'), manifest); +} + +const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''; +if (import.meta.url === entryPoint) { + console.log(process.argv[2] + ? validateAppVersion(process.argv[2], 'app version argument') + : await readAppVersion()); +} diff --git a/scripts/prepare-portable.mjs b/scripts/prepare-portable.mjs index 37b7fd8..e4bc7b7 100644 --- a/scripts/prepare-portable.mjs +++ b/scripts/prepare-portable.mjs @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; import { chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { basename, join, resolve } from 'node:path'; +import { readAppVersion } from './app-version.mjs'; const args = new Map(); for (let index = 2; index < process.argv.length; index += 2) { @@ -22,12 +23,7 @@ if (!existsSync(binary)) throw new Error(`GUI binary not found: ${binary}`); const rawVersion = (await readFile(join(root, 'core-version.txt'), 'utf8')).trim(); if (!/^v?\d+(?:\.\d+)+$/.test(rawVersion)) throw new Error(`Invalid core-version.txt: ${rawVersion}`); const version = rawVersion.replace(/^v/i, ''); -const packageMetadata = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); -const appVersion = String(packageMetadata.version ?? '').trim(); -const semverPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; -if (!semverPattern.test(appVersion)) { - throw new Error(`Invalid package.json version: ${appVersion}`); -} +const appVersion = await readAppVersion(); const extension = targetOS === 'windows' ? 'zip' : 'tar.gz'; const assetName = `CLIProxyAPI_${version}_${targetOS}_${targetArch}.${extension}`; const sourceDir = join(root, 'cpa-core'); diff --git a/scripts/set-app-version.mjs b/scripts/set-app-version.mjs new file mode 100644 index 0000000..6c839a3 --- /dev/null +++ b/scripts/set-app-version.mjs @@ -0,0 +1,45 @@ +import { spawnSync } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + parseCargoPackageVersion, + setCargoLockPackageVersion, + setCargoPackageVersion, + validateAppVersion, +} from './app-version.mjs'; + +const requestedVersion = process.argv[2]; +if (!requestedVersion) { + throw new Error('Usage: node scripts/set-app-version.mjs '); +} + +const version = validateAppVersion(requestedVersion, 'requested app version'); +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const manifest = join(root, 'src-tauri', 'Cargo.toml'); +const lockfile = join(root, 'src-tauri', 'Cargo.lock'); +const contents = await readFile(manifest, 'utf8'); +const lockContents = await readFile(lockfile, 'utf8'); +const previousVersion = parseCargoPackageVersion(contents, manifest); + +if (previousVersion !== version) { + await Promise.all([ + writeFile(manifest, setCargoPackageVersion(contents, version, manifest)), + writeFile(lockfile, setCargoLockPackageVersion(lockContents, 'cpa-gui', version, lockfile)), + ]); +} + +const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; +const metadata = spawnSync(cargo, [ + 'metadata', + '--manifest-path', manifest, + '--no-deps', + '--format-version', '1', +], { stdio: 'ignore' }); + +if (metadata.error) throw metadata.error; +if (metadata.status !== 0) { + throw new Error(`cargo metadata failed with exit code ${metadata.status}`); +} + +console.log(`Applied app version ${version}${previousVersion === version ? ' (unchanged)' : ''}`); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 12256c1..83b3518 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "cpa-gui" +# Single source of truth for the desktop app version. version = "0.2.14" description = "CPA desktop management shell" authors = ["lzt404"] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3de0e36..2ad1e51 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "EasyCLIProxyAPI", - "version": "0.2.14", "identifier": "com.cpa.gui", "build": { "beforeDevCommand": "bun run dev", diff --git a/src/pages/Kernel.tsx b/src/pages/Kernel.tsx index c9ab2bf..c082a6b 100644 --- a/src/pages/Kernel.tsx +++ b/src/pages/Kernel.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { getVersion } from '@tauri-apps/api/app'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { AlertCircle, Check, Copy, ExternalLink, Eye, EyeOff, Info } from 'lucide-react'; @@ -7,7 +8,6 @@ import openaiIcon from '../assets/icons/openai-light.svg'; import claudeIcon from '../assets/icons/claude.svg'; import geminiIcon from '../assets/icons/gemini.svg'; import { clientApiProfiles } from '../services/clientAccess'; -import packageMetadata from '../../package.json'; import { useI18n } from '../i18n'; import { useAppUpdate } from '../appUpdate'; @@ -73,7 +73,7 @@ let cachedLatestError = ''; let latestCheckPromise: Promise | null = null; function displayAppVersion(version: string) { - const resolvedVersion = version.trim() || packageMetadata.version; + const resolvedVersion = version.trim(); return resolvedVersion.startsWith('v') ? resolvedVersion : `v${resolvedVersion}`; } @@ -118,6 +118,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { refreshStatus, publishStatus, } = useCoreRuntime(); + const [installedAppVersion, setInstalledAppVersion] = useState(''); const [platform, setPlatform] = useState(null); const [platformError, setPlatformError] = useState(''); const [latest, setLatest] = useState(cachedLatest); @@ -233,6 +234,11 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { loadBundledCore(); loadInstallTask(); loadGuiSettings(); + void getVersion() + .then((version) => { + if (!disposed) setInstalledAppVersion(version); + }) + .catch(() => undefined); if (view === 'home') { void loadHomeApiKey(); } @@ -607,7 +613,10 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) { : statusError ? t('common.detectionFailed') : t('common.detecting'); - const currentAppVersion = displayAppVersion(appUpdate?.currentVersion || packageMetadata.version); + const resolvedAppVersion = appUpdate?.currentVersion || installedAppVersion; + const currentAppVersion = resolvedAppVersion + ? displayAppVersion(resolvedAppVersion) + : t('common.detecting'); const latestLabel = checkingLatest ? t('kernel.update.checking') : latestVersion || (latestError ? t('kernel.update.failed') : t('kernel.update.notChecked')); diff --git a/tests/app-version.test.ts b/tests/app-version.test.ts new file mode 100644 index 0000000..3f9824f --- /dev/null +++ b/tests/app-version.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import { + parseCargoPackageVersion, + setCargoLockPackageVersion, + setCargoPackageVersion, + validateAppVersion, +} from '../scripts/app-version.mjs'; + +describe('app version', () => { + it('reads the version from the package section', () => { + expect(parseCargoPackageVersion(` +[package] +name = "cpa-gui" +version = "1.2.3-beta.1+build.7" + +[dependencies] +example = "9.9.9" +`)).toBe('1.2.3-beta.1+build.7'); + }); + + it('rejects a missing package version', () => { + expect(() => parseCargoPackageVersion('[package]\nname = "cpa-gui"\n')) + .toThrow('Missing package version'); + }); + + it('rejects an invalid semantic version', () => { + expect(() => parseCargoPackageVersion('[package]\nversion = "v1.2.3"\n')) + .toThrow('Invalid package version'); + }); + + it('updates only the package version', () => { + const manifest = `[package] +name = "cpa-gui" +version = "1.2.3" + +[dependencies] +example = "9.9.9" +`; + const updated = setCargoPackageVersion(manifest, '2.0.0-rc.1'); + expect(parseCargoPackageVersion(updated)).toBe('2.0.0-rc.1'); + expect(updated).toContain('example = "9.9.9"'); + }); + + it('validates a version supplied by a release tag', () => { + expect(validateAppVersion('0.2.15')).toBe('0.2.15'); + expect(() => validateAppVersion('v0.2.15')).toThrow('Invalid app version'); + }); + + it('updates only the local app entry in Cargo.lock', () => { + const lockfile = `[[package]] +name = "cpa-gui" +version = "1.2.3" + +[[package]] +name = "dependency" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +`; + const updated = setCargoLockPackageVersion(lockfile, 'cpa-gui', '2.0.0'); + expect(updated).toContain('name = "cpa-gui"\nversion = "2.0.0"'); + expect(updated).toContain('name = "dependency"\nversion = "1.2.3"'); + }); +});