refactor(scripts): split and harden release tooling
This commit is contained in:
1 parent
65df80091c
commit
dd86eabefa
14 files changed
+218
-458
No files matched your search
@@ -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];
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)' : ''}`);
|
||||
@@ -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.
Reference in new issue
Block a user