Merge pull request #94 from router-for-me/dev
ci(release): automate app versioning from Git tags
This commit is contained in:
9 files changed
+250
-54
No files matched your search
@@ -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,7 +1,6 @@
|
||||
{
|
||||
"name": "cpa-gui",
|
||||
"private": true,
|
||||
"version": "0.2.14",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 +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');
|
||||
|
||||
@@ -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,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,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
@@ -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'));
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user