refactor(scripts): split and harden release tooling

This commit is contained in:
lzt404 committed 2026-08-07 14:54:55 +08:00
1 parent 65df80091c
commit dd86eabefa
14 files changed
+218 -458

No files matched your search

-85
View File
@@ -1,85 +0,0 @@
name: Mirror GitHub release to GitCode
run-name: >-
GitCode mirror ${{ inputs.release_tag || github.event.workflow_run.display_title }}
on:
workflow_run:
workflows:
- Build portable releases
types:
- completed
workflow_dispatch:
inputs:
release_tag:
description: Existing GitHub release tag to mirror
required: true
type: string
permissions:
contents: read
concurrency:
group: gitcode-mirror-${{ inputs.release_tag || github.event.workflow_run.id }}
cancel-in-progress: false
jobs:
mirror:
name: Mirror release assets
if: >-
vars.GITCODE_REPOSITORY != ''
&& (
github.event_name == 'workflow_dispatch'
|| github.event.workflow_run.conclusion == 'success'
)
runs-on: ubuntu-24.04
timeout-minutes: 35
steps:
- uses: actions/checkout@v7
- name: Resolve release tag
id: release
shell: bash
env:
INPUT_RELEASE_TAG: ${{ inputs.release_tag }}
RUN_TITLE: ${{ github.event.workflow_run.display_title }}
run: |
set -euo pipefail
release_tag="$INPUT_RELEASE_TAG"
if [ -z "$release_tag" ]; then
case "$RUN_TITLE" in
"Portable release "*) release_tag="${RUN_TITLE#Portable release }" ;;
*) echo "Cannot resolve a release tag from workflow title: $RUN_TITLE" >&2; exit 1 ;;
esac
fi
case "$release_tag" in
v*) ;;
*) release_tag="v$release_tag" ;;
esac
if ! node scripts/app-version.mjs "${release_tag#v}" >/dev/null 2>&1; then
if [ -n "$INPUT_RELEASE_TAG" ]; then
echo "Invalid release tag: $release_tag" >&2
exit 1
fi
echo "Skipping GitCode mirror because the completed run did not publish a release"
echo "should_mirror=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "tag=$release_tag" >> "$GITHUB_OUTPUT"
echo "should_mirror=true" >> "$GITHUB_OUTPUT"
- name: Download GitHub release assets
if: steps.release.outputs.should_mirror == 'true'
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: gh release download "${{ steps.release.outputs.tag }}" --dir artifacts
- name: Publish GitCode mirror
if: steps.release.outputs.should_mirror == 'true'
timeout-minutes: 30
env:
ARTIFACT_DIRECTORY: artifacts
GITCODE_ACCESS_TOKEN: ${{ secrets.GITCODE_ACCESS_TOKEN }}
GITCODE_REPOSITORY: ${{ vars.GITCODE_REPOSITORY }}
GITCODE_TARGET_COMMITISH: ${{ vars.GITCODE_TARGET_COMMITISH || 'main' }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: node scripts/publish-gitcode-release.mjs
+6 -6
View File
@@ -44,7 +44,7 @@ jobs:
run: |
set -euo pipefail
source_version="$(node scripts/app-version.mjs)"
source_version="$(node scripts/version.mjs)"
release_tag="$REQUESTED_VERSION"
if [ -z "$release_tag" ]; then
release_tag="v$source_version"
@@ -55,7 +55,7 @@ jobs:
esac
fi
version="$(node scripts/app-version.mjs "${release_tag#v}")"
version="$(node scripts/version.mjs "${release_tag#v}")"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "tag=$release_tag" >> "$GITHUB_OUTPUT"
@@ -77,7 +77,7 @@ jobs:
with:
components: clippy, rustfmt
- name: Apply release version
run: node scripts/set-app-version.mjs "${{ needs.version.outputs.version }}"
run: node scripts/set-version.mjs "${{ needs.version.outputs.version }}"
- name: Install Linux dependencies
run: |
sudo apt-get update
@@ -150,7 +150,7 @@ jobs:
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 }}"
run: node scripts/set-version.mjs "${{ needs.version.outputs.version }}"
- name: Resolve release package name
id: package
shell: bash
@@ -174,7 +174,7 @@ jobs:
shell: bash
- name: Prepare portable directory
run: >-
node scripts/prepare-portable.mjs
node scripts/portable.mjs
--binary "src-tauri/target/release/cpa-gui${{ matrix.os == 'windows' && '.exe' || '' }}"
--output "release/${{ steps.package.outputs.stem }}"
--os "${{ matrix.os }}"
@@ -414,7 +414,7 @@ jobs:
env:
RELEASE_TAG: ${{ needs.version.outputs.tag }}
run: >-
node scripts/generate-portable-update-manifest.mjs
node scripts/manifest.mjs
--directory artifacts
--output artifacts/portable-update-windows.json
--repository "${{ github.repository }}"
+2 -2
View File
@@ -12,7 +12,7 @@ $RootDir = $PSScriptRoot
$AppBin = Join-Path $RootDir 'src-tauri\target\release\cpa-gui.exe'
$BinDir = Join-Path $RootDir 'bin-work'
$BinOut = Join-Path $BinDir 'EasyCLIProxyAPI.exe'
$PreparePortable = Join-Path $RootDir 'scripts\prepare-portable.mjs'
$PortableScript = Join-Path $RootDir 'scripts\portable.mjs'
Set-Location -LiteralPath $RootDir
@@ -72,7 +72,7 @@ if (-not (Test-Path -LiteralPath $AppBin -PathType Leaf)) {
throw "Build finished, but executable not found: $AppBin"
}
& bun $PreparePortable --binary $AppBin --output $BinDir --download true --preserve-runtime-config true
& bun $PortableScript --binary $AppBin --output $BinDir --download true --preserve-runtime-config true
if ($LASTEXITCODE -ne 0) {
throw "Portable preparation failed with exit code $LASTEXITCODE."
}
+1 -1
View File
@@ -26,7 +26,7 @@ if [ ! -x "$APP_BIN" ]; then
fi
mkdir -p "$BIN_DIR"
bun "$ROOT_DIR/scripts/prepare-portable.mjs" \
bun "$ROOT_DIR/scripts/portable.mjs" \
--binary "$APP_BIN" \
--output "$BIN_DIR" \
--download true
@@ -1,22 +0,0 @@
export type PortableUpdateManifestAsset = {
url: string;
sha256: string;
sizeBytes: number;
};
export type PortableUpdateManifest = {
schemaVersion: number;
version: string;
publishedAt: string;
releaseUrl: string;
assets: Record<string, PortableUpdateManifestAsset>;
fullAssets: Record<string, PortableUpdateManifestAsset>;
};
export function generatePortableUpdateManifest(options: {
directory?: string;
output?: string;
repository?: string;
tag?: string;
publishedAt?: string;
}): Promise<PortableUpdateManifest>;
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import { readFile, stat, writeFile } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { validateAppVersion } from './version.mjs';
export async function generatePortableUpdateManifest({
directory,
@@ -18,11 +19,8 @@ export async function generatePortableUpdateManifest({
const normalizedRawTag = String(rawTag ?? '').trim();
const tag = normalizedRawTag.startsWith('v') ? normalizedRawTag : `v${normalizedRawTag}`;
const version = tag.slice(1);
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(version)) {
throw new Error(`Invalid release tag: ${normalizedRawTag}`);
}
validateAppVersion(version, 'release tag');
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(resolvedRepository)) {
throw new Error(`Invalid GitHub repository: ${resolvedRepository}`);
}
@@ -75,6 +73,12 @@ export async function generatePortableUpdateManifest({
return manifest;
}
export function gitcodeReleaseAttachmentUrl(repository, tag, filename) {
const [owner, repo] = repository.split('/');
return `https://api.gitcode.com/api/v5/repos/${owner}/${repo}`
+ `/releases/${tag}/attach_files/${filename}/download`;
}
async function main() {
const args = new Map();
for (let index = 2; index < process.argv.length; index += 2) {
@@ -92,12 +96,6 @@ async function main() {
console.log(`Generated ${basename(output)} for v${manifest.version}`);
}
export function gitcodeReleaseAttachmentUrl(repository, tag, filename) {
const [owner, repo] = repository.split('/');
return `https://api.gitcode.com/api/v5/repos/${owner}/${repo}`
+ `/releases/${tag}/attach_files/${filename}/download`;
}
const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : '';
if (import.meta.url === entryPoint) {
await main();
@@ -1,15 +1,16 @@
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';
import { basename, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readAppVersion } from './version.mjs';
const args = new Map();
for (let index = 2; index < process.argv.length; index += 2) {
args.set(process.argv[index], process.argv[index + 1]);
}
const root = resolve(import.meta.dirname, '..');
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const output = resolve(args.get('--output') ?? join(root, 'bin-work'));
const binary = resolve(args.get('--binary') ?? join(root, 'src-tauri', 'target', 'release', process.platform === 'win32' ? 'cpa-gui.exe' : 'cpa-gui'));
const targetOS = args.get('--os') ?? ({ linux: 'linux', darwin: 'darwin', win32: 'windows' })[process.platform];
-188
View File
@@ -1,188 +0,0 @@
import { spawn } from 'node:child_process';
import { readdir } from 'node:fs/promises';
import { basename, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const API_BASE = 'https://api.gitcode.com/api/v5';
const API_TIMEOUT_MS = 30_000;
const UPLOAD_TIMEOUT_SECONDS = 180;
function parseRepository(value) {
const repository = String(value ?? '').trim();
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
throw new Error(`Invalid GitCode repository: ${repository}`);
}
return repository.split('/');
}
function authenticatedUrl(path, token, query = {}) {
const url = new URL(`${API_BASE}${path}`);
url.searchParams.set('access_token', token);
for (const [name, value] of Object.entries(query)) {
url.searchParams.set(name, value);
}
return url;
}
async function apiRequest(path, {
token,
method = 'GET',
body,
query,
expected = [200],
fetchImpl = fetch,
}) {
let response;
try {
response = await fetchImpl(authenticatedUrl(path, token, query), {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(API_TIMEOUT_MS),
});
} catch (error) {
throw new Error(`GitCode API request failed for ${path}: ${error instanceof Error ? error.message : error}`);
}
if (!expected.includes(response.status)) {
const details = (await response.text()).slice(0, 500);
throw new Error(`GitCode API ${method} ${path} failed: HTTP ${response.status}: ${details}`);
}
return response.status === 204 || response.status === 404 ? null : response.json();
}
export function uploadFileWithCurl({ url, headers, path }) {
const args = [
'--fail-with-body',
'--silent',
'--show-error',
'--location',
'--connect-timeout', '30',
'--speed-limit', '1',
'--speed-time', '30',
'--max-time', String(UPLOAD_TIMEOUT_SECONDS),
'--request', 'PUT',
'--upload-file', path,
'--output', process.platform === 'win32' ? 'NUL' : '/dev/null',
];
for (const [name, value] of Object.entries(headers)) {
args.push('--header', `${name}: ${value}`);
}
args.push(url);
return new Promise((resolveUpload, rejectUpload) => {
const child = spawn('curl', args, { stdio: ['ignore', 'inherit', 'inherit'] });
child.once('error', rejectUpload);
child.once('exit', (code, signal) => {
if (code === 0) {
resolveUpload();
} else {
rejectUpload(new Error(`curl upload failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`));
}
});
});
}
export async function publishGitcodeRelease({
directory,
repository,
token,
tag,
targetCommitish = 'main',
prerelease = false,
fetchImpl = fetch,
uploadImpl = uploadFileWithCurl,
}) {
const [owner, repo] = parseRepository(repository);
if (!token) throw new Error('GITCODE_ACCESS_TOKEN is required');
if (!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(tag)) {
throw new Error(`Invalid release tag: ${tag}`);
}
const encodedOwner = encodeURIComponent(owner);
const encodedRepo = encodeURIComponent(repo);
const encodedTag = encodeURIComponent(tag);
const releaseByTagPath = `/repos/${encodedOwner}/${encodedRepo}/releases/tags/${encodedTag}`;
let release;
const existing = await apiRequest(releaseByTagPath, {
token,
expected: [200, 404],
fetchImpl,
});
if (existing?.tag_name === tag) {
release = existing;
} else {
release = await apiRequest(`/repos/${encodedOwner}/${encodedRepo}/releases`, {
token,
method: 'POST',
body: {
tag_name: tag,
name: `EasyCLIProxyAPI ${tag}`,
body: 'Mirror of the GitHub release for users who cannot access GitHub.',
target_commitish: targetCommitish,
release_status: prerelease ? 'pre' : 'latest',
},
fetchImpl,
});
}
const existingAssets = new Set((release.assets ?? []).map((asset) => asset.name));
const entries = (await readdir(directory, { withFileTypes: true }))
.filter((entry) => entry.isFile())
.sort((left, right) => left.name.localeCompare(right.name));
if (entries.length === 0) throw new Error(`No release artifacts found in ${directory}`);
for (const entry of entries) {
if (existingAssets.has(entry.name)) {
console.log(`GitCode asset already exists, skipping: ${entry.name}`);
continue;
}
const upload = await apiRequest(
`/repos/${encodedOwner}/${encodedRepo}/releases/${encodedTag}/upload_url`,
{ token, query: { file_name: entry.name }, fetchImpl },
);
const descriptor = upload?.data ?? upload;
const uploadUrl = descriptor?.upload_url ?? descriptor?.url;
if (typeof uploadUrl !== 'string' || !uploadUrl.startsWith('https://')) {
throw new Error(`GitCode returned an invalid upload URL for ${entry.name}`);
}
const rawHeaders = descriptor?.headers ?? descriptor?.upload_headers ?? descriptor?.header;
const headers = Array.isArray(rawHeaders)
? Object.fromEntries(rawHeaders.map(({ key, name, value }) => [key ?? name, value]))
: rawHeaders && typeof rawHeaders === 'object' ? rawHeaders : {};
console.log(`Uploading GitCode asset: ${entry.name}`);
try {
await uploadImpl({
url: uploadUrl,
headers,
path: join(directory, entry.name),
});
} catch (error) {
console.warn(`GitCode upload command did not finish cleanly; verifying asset: ${entry.name}`);
const refreshedRelease = await apiRequest(releaseByTagPath, { token, fetchImpl });
const uploadedDespiteError = (refreshedRelease.assets ?? [])
.some((asset) => asset.name === entry.name);
if (!uploadedDespiteError) throw error;
console.log(`GitCode asset exists despite upload error: ${entry.name}`);
}
existingAssets.add(entry.name);
console.log(`Uploaded GitCode asset: ${entry.name}`);
}
}
async function main() {
const directory = resolve(process.env.ARTIFACT_DIRECTORY ?? 'artifacts');
await publishGitcodeRelease({
directory,
repository: process.env.GITCODE_REPOSITORY,
token: process.env.GITCODE_ACCESS_TOKEN,
tag: process.env.RELEASE_TAG,
targetCommitish: process.env.GITCODE_TARGET_COMMITISH || 'main',
prerelease: process.env.PRERELEASE === 'true' || process.env.RELEASE_TAG.includes('-'),
});
console.log(`Published ${basename(directory)} to GitCode ${process.env.GITCODE_REPOSITORY}`);
}
const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : '';
if (import.meta.url === entryPoint) {
await main();
}
-45
View File
@@ -1,45 +0,0 @@
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)' : ''}`);
+110
View File
@@ -0,0 +1,110 @@
import { spawnSync } from 'node:child_process';
import { readFile, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
parseCargoPackageVersion,
setCargoLockPackageVersion,
setCargoPackageVersion,
validateAppVersion,
} from './version.mjs';
const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
export function runCargoMetadata(manifest, spawnImpl = spawnSync) {
const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
const metadata = spawnImpl(cargo, [
'metadata',
'--manifest-path', manifest,
'--no-deps',
'--format-version', '1',
], {
encoding: 'utf8',
windowsHide: true,
});
if (metadata.error) throw metadata.error;
if (metadata.status !== 0) {
const detail = String(metadata.stderr || metadata.stdout || '').trim();
throw new Error(
`cargo metadata failed with exit code ${metadata.status}${detail ? `: ${detail}` : ''}`,
);
}
}
export async function applyAppVersion(requestedVersion, {
rootDir = defaultRoot,
readText = readFile,
writeText = writeFile,
validateProject = runCargoMetadata,
} = {}) {
const version = validateAppVersion(requestedVersion, 'requested app version');
const manifest = join(rootDir, 'src-tauri', 'Cargo.toml');
const lockfile = join(rootDir, 'src-tauri', 'Cargo.lock');
const [contents, lockContents] = await Promise.all([
readText(manifest, 'utf8'),
readText(lockfile, 'utf8'),
]);
const previousVersion = parseCargoPackageVersion(contents, manifest);
const nextContents = setCargoPackageVersion(contents, version, manifest);
const nextLockContents = setCargoLockPackageVersion(
lockContents,
'cpa-gui',
version,
lockfile,
);
const manifestChanged = nextContents !== contents;
const lockfileChanged = nextLockContents !== lockContents;
let manifestWriteAttempted = false;
let lockfileWriteAttempted = false;
try {
if (manifestChanged) {
manifestWriteAttempted = true;
await writeText(manifest, nextContents);
}
if (lockfileChanged) {
lockfileWriteAttempted = true;
await writeText(lockfile, nextLockContents);
}
await validateProject(manifest);
} catch (error) {
const rollbackTasks = [];
if (manifestWriteAttempted) rollbackTasks.push(writeText(manifest, contents));
if (lockfileWriteAttempted) rollbackTasks.push(writeText(lockfile, lockContents));
const rollbackResults = await Promise.allSettled(rollbackTasks);
const rollbackFailures = rollbackResults
.filter((result) => result.status === 'rejected')
.map((result) => errorMessage(result.reason));
if (rollbackFailures.length > 0) {
throw new Error(
`${errorMessage(error)}; rollback failed: ${rollbackFailures.join('; ')}`,
{ cause: error },
);
}
throw error;
}
return {
version,
previousVersion,
changed: manifestChanged || lockfileChanged,
};
}
async function main() {
const requestedVersion = process.argv[2];
if (!requestedVersion || process.argv.length > 3) {
throw new Error('Usage: node scripts/set-version.mjs <semver>');
}
const result = await applyAppVersion(requestedVersion);
console.log(`Applied app version ${result.version}${result.changed ? '' : ' (unchanged)'}`);
}
const entryPoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : '';
if (import.meta.url === entryPoint) {
await main();
}
File renamed without changes.
+86 -1
View File
@@ -4,7 +4,8 @@ import {
setCargoLockPackageVersion,
setCargoPackageVersion,
validateAppVersion,
} from '../scripts/app-version.mjs';
} from '../scripts/version.mjs';
import { applyAppVersion, runCargoMetadata } from '../scripts/set-version.mjs';
describe('app version', () => {
it('reads the version from the package section', () => {
@@ -60,4 +61,88 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
expect(updated).toContain('name = "cpa-gui"\nversion = "2.0.0"');
expect(updated).toContain('name = "dependency"\nversion = "1.2.3"');
});
it('repairs a stale lockfile even when Cargo.toml already has the requested version', async () => {
const files = new Map<string, string>([
['Cargo.toml', '[package]\nname = "cpa-gui"\nversion = "2.0.0"\n'],
['Cargo.lock', '[[package]]\nname = "cpa-gui"\nversion = "1.0.0"\n'],
]);
const writes: string[] = [];
const result = await applyAppVersion('2.0.0', {
rootDir: 'test-root',
readText: async (path: string) => files.get(
path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock',
)!,
writeText: async (path: string, contents: string) => {
const name = path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock';
writes.push(name);
files.set(name, contents);
},
validateProject: async () => {},
});
expect(result).toMatchObject({ version: '2.0.0', previousVersion: '2.0.0', changed: true });
expect(writes).toEqual(['Cargo.lock']);
expect(files.get('Cargo.lock')).toContain('version = "2.0.0"');
});
it('restores both files when cargo metadata validation fails', async () => {
const originalManifest = '[package]\nname = "cpa-gui"\nversion = "1.0.0"\n';
const originalLockfile = '[[package]]\nname = "cpa-gui"\nversion = "1.0.0"\n';
const files = new Map<string, string>([
['Cargo.toml', originalManifest],
['Cargo.lock', originalLockfile],
]);
await expect(applyAppVersion('2.0.0', {
rootDir: 'test-root',
readText: async (path: string) => files.get(
path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock',
)!,
writeText: async (path: string, contents: string) => {
files.set(path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock', contents);
},
validateProject: async () => { throw new Error('metadata validation failed'); },
})).rejects.toThrow('metadata validation failed');
expect(files.get('Cargo.toml')).toBe(originalManifest);
expect(files.get('Cargo.lock')).toBe(originalLockfile);
});
it('restores the manifest when writing the lockfile fails', async () => {
const originalManifest = '[package]\nname = "cpa-gui"\nversion = "1.0.0"\n';
const originalLockfile = '[[package]]\nname = "cpa-gui"\nversion = "1.0.0"\n';
const files = new Map<string, string>([
['Cargo.toml', originalManifest],
['Cargo.lock', originalLockfile],
]);
let lockfileWrites = 0;
await expect(applyAppVersion('2.0.0', {
rootDir: 'test-root',
readText: async (path: string) => files.get(
path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock',
)!,
writeText: async (path: string, contents: string) => {
const name = path.endsWith('Cargo.toml') ? 'Cargo.toml' : 'Cargo.lock';
if (name === 'Cargo.lock' && lockfileWrites++ === 0) {
throw new Error('lockfile write failed');
}
files.set(name, contents);
},
validateProject: async () => {},
})).rejects.toThrow('lockfile write failed');
expect(files.get('Cargo.toml')).toBe(originalManifest);
expect(files.get('Cargo.lock')).toBe(originalLockfile);
});
it('includes cargo stderr when metadata validation fails', () => {
expect(() => runCargoMetadata('Cargo.toml', () => ({
status: 101,
stderr: 'manifest parse error',
stdout: '',
}))).toThrow('cargo metadata failed with exit code 101: manifest parse error');
});
});
-94
View File
@@ -1,94 +0,0 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, test } from 'bun:test';
import { publishGitcodeRelease } from '../scripts/publish-gitcode-release.mjs';
describe('GitCode release mirror', () => {
test('creates a release and uploads compiled artifacts', async () => {
const directory = await mkdtemp(join(tmpdir(), 'easycli-gitcode-release-'));
const calls: Array<{ url: string; method: string; body: unknown }> = [];
const uploads: Array<{ url: string; path: string }> = [];
try {
await writeFile(join(directory, 'EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip'), 'artifact');
const fetchImpl = async (input: URL | RequestInfo, init: RequestInit = {}) => {
const url = String(input);
const method = init.method ?? 'GET';
calls.push({ url, method, body: init.body });
if (method === 'GET' && url.includes('/releases/tags/')) {
return new Response('', { status: 404 });
}
if (method === 'POST' && url.includes('/releases?')) {
return Response.json({ tag_name: 'v1.2.3', assets: [] });
}
if (method === 'GET' && url.includes('/upload_url?')) {
return Response.json({
upload_url: 'https://file-cdn.gitcode.com/presigned-upload',
headers: { 'x-upload-token': 'signed' },
});
}
return new Response('', { status: 500 });
};
await publishGitcodeRelease({
directory,
repository: 'mirror-owner/EasyCLIProxyAPI',
token: 'secret-token',
tag: 'v1.2.3',
fetchImpl,
uploadImpl: async ({ url, path }) => {
uploads.push({ url, path });
},
});
expect(calls.map(({ method }) => method)).toEqual(['GET', 'POST', 'GET']);
expect(calls[0].url).toContain('/repos/mirror-owner/EasyCLIProxyAPI/releases/tags/v1.2.3');
expect(calls[0].url).toContain('access_token=secret-token');
expect(calls[2].url).toContain('file_name=EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip');
expect(uploads).toEqual([{
url: 'https://file-cdn.gitcode.com/presigned-upload',
path: join(directory, 'EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip'),
}]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test('continues when GitCode stores an asset but the upload response times out', async () => {
const directory = await mkdtemp(join(tmpdir(), 'easycli-gitcode-release-'));
const artifactName = 'EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip';
let releaseRequests = 0;
try {
await writeFile(join(directory, artifactName), 'artifact');
const fetchImpl = async (input: URL | RequestInfo) => {
const url = String(input);
if (url.includes('/releases/tags/')) {
releaseRequests += 1;
return Response.json({
tag_name: 'v1.2.3',
assets: releaseRequests === 1 ? [] : [{ name: artifactName }],
});
}
if (url.includes('/upload_url?')) {
return Response.json({ upload_url: 'https://file-cdn.gitcode.com/presigned-upload' });
}
return new Response('', { status: 500 });
};
await expect(publishGitcodeRelease({
directory,
repository: 'mirror-owner/EasyCLIProxyAPI',
token: 'secret-token',
tag: 'v1.2.3',
fetchImpl,
uploadImpl: async () => {
throw new Error('upload response timed out');
},
})).resolves.toBeUndefined();
expect(releaseRequests).toBe(2);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
+1 -1
View File
@@ -5,7 +5,7 @@ import { join } from 'node:path';
import { describe, expect, test } from 'bun:test';
import {
generatePortableUpdateManifest,
} from '../scripts/generate-portable-update-manifest.mjs';
} from '../scripts/manifest.mjs';
describe('Windows 便携更新清单', () => {
test('URL、大小和哈希与实际上传资产一致', async () => {