feat(release): add GitCode fallback mirror

This commit is contained in:
lzt404 committed 2026-08-05 16:23:17 +08:00
1 parent 391fc877a7
commit 06f024abde
6 files changed
+475 -20

No files matched your search

+22 -1
View File
@@ -7,12 +7,14 @@ export async function generatePortableUpdateManifest({
directory,
output,
repository,
gitcodeRepository,
tag: rawTag,
publishedAt = new Date().toISOString(),
}) {
const resolvedDirectory = resolve(directory ?? 'artifacts');
const resolvedOutput = resolve(output ?? join(resolvedDirectory, 'portable-update-windows.json'));
const resolvedRepository = repository ?? 'router-for-me/EasyCLIProxyAPI';
const resolvedGitcodeRepository = String(gitcodeRepository ?? '').trim();
const normalizedRawTag = String(rawTag ?? '').trim();
const tag = normalizedRawTag.startsWith('v') ? normalizedRawTag : `v${normalizedRawTag}`;
const version = tag.slice(1);
@@ -24,6 +26,10 @@ export async function generatePortableUpdateManifest({
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(resolvedRepository)) {
throw new Error(`Invalid GitHub repository: ${resolvedRepository}`);
}
if (resolvedGitcodeRepository
&& !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(resolvedGitcodeRepository)) {
throw new Error(`Invalid GitCode repository: ${resolvedGitcodeRepository}`);
}
if (Number.isNaN(Date.parse(publishedAt))) {
throw new Error(`Invalid publishedAt: ${publishedAt}`);
}
@@ -40,11 +46,19 @@ export async function generatePortableUpdateManifest({
if (!metadata.isFile() || metadata.size === 0) {
throw new Error(`Portable release asset is empty or not a file: ${filename}`);
}
collection[`windows-${arch}`] = {
const asset = {
url: `https://github.com/${resolvedRepository}/releases/download/${tag}/${filename}`,
sha256: createHash('sha256').update(contents).digest('hex'),
sizeBytes: metadata.size,
};
if (resolvedGitcodeRepository) {
asset.fallbackUrls = [gitcodeReleaseAttachmentUrl(
resolvedGitcodeRepository,
tag,
filename,
)];
}
collection[`windows-${arch}`] = asset;
}
}
@@ -72,11 +86,18 @@ async function main() {
directory,
output,
repository: args.get('--repository'),
gitcodeRepository: args.get('--gitcode-repository'),
tag: args.get('--tag'),
});
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();
+144
View File
@@ -0,0 +1,144 @@
import { readdir, readFile } 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';
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,
});
} 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 async function publishGitcodeRelease({
directory,
repository,
token,
tag,
targetCommitish = 'main',
prerelease = false,
fetchImpl = fetch,
}) {
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 : {};
const response = await fetchImpl(uploadUrl, {
method: 'PUT',
headers,
body: await readFile(join(directory, entry.name)),
redirect: 'follow',
});
if (!response.ok) {
throw new Error(`Uploading ${entry.name} to GitCode failed: HTTP ${response.status}`);
}
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();
}