Merge pull request #94 from router-for-me/dev

ci(release): automate app versioning from Git tags
This commit is contained in:
lzt404 authored and GitHub committed 2026-08-05 16:26:52 +08:00
commit 83702e94f1
9 files changed
+250 -54

No files matched your search

+50 -43
View File
@@ -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
-1
View File
@@ -1,7 +1,6 @@
{
"name": "cpa-gui",
"private": true,
"version": "0.2.14",
"type": "module",
"packageManager": "bun@1.3.14",
"scripts": {
+77
View File
@@ -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());
}
+2 -6
View File
@@ -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');
+45
View File
@@ -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 <semver>');
}
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)' : ''}`);
+1
View File
@@ -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"]
-1
View File
@@ -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",
+12 -3
View File
@@ -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<CoreLatest> | 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<CorePlatform | null>(null);
const [platformError, setPlatformError] = useState('');
const [latest, setLatest] = useState<CoreLatest | null>(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'));
+63
View File
@@ -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"');
});
});