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

+14 -2
View File
@@ -166,6 +166,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Build GUI
env:
GITCODE_REPOSITORY: ${{ vars.GITCODE_REPOSITORY }}
RUSTFLAGS: -D warnings
run: |
bun install --frozen-lockfile
@@ -390,7 +391,7 @@ jobs:
if-no-files-found: error
retention-days: 14
publish:
name: Publish GitHub release
name: Publish releases
if: >-
(github.event_name == 'release' && github.event.release.prerelease == true)
|| startsWith(github.ref, 'refs/tags/')
@@ -399,7 +400,7 @@ jobs:
- version
- build
runs-on: ubuntu-24.04
timeout-minutes: 15
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
@@ -417,6 +418,7 @@ jobs:
--directory artifacts
--output artifacts/portable-update-windows.json
--repository "${{ github.repository }}"
--gitcode-repository "${{ vars.GITCODE_REPOSITORY }}"
--tag "$RELEASE_TAG"
- name: Publish release
shell: bash
@@ -459,3 +461,13 @@ jobs:
else
gh release create "$RELEASE_TAG" "${release_args[@]}" artifacts/*
fi
- name: Publish GitCode mirror
if: ${{ vars.GITCODE_REPOSITORY != '' }}
env:
ARTIFACT_DIRECTORY: artifacts
GITCODE_ACCESS_TOKEN: ${{ secrets.GITCODE_ACCESS_TOKEN }}
GITCODE_REPOSITORY: ${{ vars.GITCODE_REPOSITORY }}
GITCODE_TARGET_COMMITISH: ${{ vars.GITCODE_TARGET_COMMITISH || 'main' }}
PRERELEASE: ${{ github.event_name == 'release' && github.event.release.prerelease || inputs.prerelease }}
RELEASE_TAG: ${{ needs.version.outputs.tag }}
run: node scripts/publish-gitcode-release.mjs
+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();
}
+236 -17
View File
@@ -52,6 +52,7 @@ const RELEASE_DOWNLOAD_PREFIX: &str =
const APP_UPDATE_MANIFEST_URL: &str = "https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest/download/portable-update-windows.json";
const APP_RELEASE_DOWNLOAD_PREFIX: &str =
"https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/";
const APP_UPDATE_MANIFEST_NAME: &str = "portable-update-windows.json";
const CODEX_MODEL_CATALOG_URL: &str = "https://raw.githubusercontent.com/router-for-me/EasyCLIProxyAPI/main/src-tauri/resources/codex_models/model-catalog.json";
const CODEX_MODEL_CATALOG_OVERRIDE_DIR: &str = "codex_models";
const CODEX_MODEL_CATALOG_SOURCE_FILE: &str = "model-catalog.json";
@@ -365,10 +366,17 @@ struct PortableUpdateManifest {
#[serde(rename_all = "camelCase")]
struct PortableUpdateAsset {
url: String,
#[serde(default)]
fallback_urls: Vec<String>,
sha256: String,
size_bytes: u64,
}
#[derive(Debug, Deserialize)]
struct GitcodeRelease {
tag_name: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PortableAppManifest {
@@ -10708,26 +10716,13 @@ async fn check_app_update(
state: tauri::State<'_, AppUpdateState>,
) -> Result<AppUpdateInfo, String> {
let client = reqwest::Client::builder()
.redirect(github_https_redirect_policy())
.redirect(release_https_redirect_policy())
.connect_timeout(Duration::from_secs(8))
.read_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(20))
.build()
.map_err(|error| format!("创建版本检查客户端失败: {error}"))?;
let manifest = client
.get(APP_UPDATE_MANIFEST_URL)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
.send()
.await
.map_err(|error| format!("检查软件更新失败: {error}"))?
.error_for_status()
.map_err(|error| format!("读取软件更新清单失败: {error}"))?
.json::<PortableUpdateManifest>()
.await
.map_err(|error| format!("解析软件更新清单失败: {error}"))?;
validate_portable_update_manifest(&manifest)?;
let manifest = fetch_portable_update_manifest(&client).await?;
let latest_version = normalize_version(&manifest.version);
let current_version = normalize_version(env!("CARGO_PKG_VERSION"));
let update_available = is_app_update_available(&current_version, &latest_version)?;
@@ -10783,6 +10778,124 @@ async fn check_app_update(
})
}
async fn fetch_portable_update_manifest(
client: &reqwest::Client,
) -> Result<PortableUpdateManifest, String> {
match fetch_portable_update_manifest_url(client, APP_UPDATE_MANIFEST_URL).await {
Ok(manifest) => Ok(manifest),
Err(github_error) => {
let Some(repository) = configured_gitcode_repository() else {
return Err(github_error);
};
let fallback = async {
let release_url =
format!("https://api.gitcode.com/api/v5/repos/{repository}/releases/latest");
let release = client
.get(release_url)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
.send()
.await
.map_err(|error| format!("query GitCode latest release: {error}"))?
.error_for_status()
.map_err(|error| format!("read GitCode latest release: {error}"))?
.json::<GitcodeRelease>()
.await
.map_err(|error| format!("parse GitCode latest release: {error}"))?;
validate_release_tag(&release.tag_name)?;
let manifest_url = gitcode_release_attachment_url(
repository,
&release.tag_name,
APP_UPDATE_MANIFEST_NAME,
);
fetch_portable_update_manifest_url(client, &manifest_url).await
}
.await;
fallback.map_err(|gitcode_error| {
format!(
"GitHub update source failed: {github_error}; GitCode fallback failed: {gitcode_error}"
)
})
}
}
}
async fn fetch_portable_update_manifest_url(
client: &reqwest::Client,
url: &str,
) -> Result<PortableUpdateManifest, String> {
let manifest = client
.get(url)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
.send()
.await
.map_err(|error| format!("request update manifest: {error}"))?
.error_for_status()
.map_err(|error| format!("read update manifest: {error}"))?
.json::<PortableUpdateManifest>()
.await
.map_err(|error| format!("parse update manifest: {error}"))?;
validate_portable_update_manifest(&manifest)?;
Ok(manifest)
}
fn configured_gitcode_repository() -> Option<&'static str> {
let repository = option_env!("GITCODE_REPOSITORY")?.trim();
let mut parts = repository.split('/');
let valid_part = |part: &str| {
!part.is_empty()
&& part
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
};
match (parts.next(), parts.next(), parts.next()) {
(Some(owner), Some(repo), None) if valid_part(owner) && valid_part(repo) => {
Some(repository)
}
_ => None,
}
}
fn validate_release_tag(tag: &str) -> Result<(), String> {
semver::Version::parse(tag.strip_prefix('v').unwrap_or(tag))
.map(|_| ())
.map_err(|error| format!("invalid release tag {tag}: {error}"))
}
fn gitcode_release_attachment_url(repository: &str, tag: &str, filename: &str) -> String {
format!(
"https://api.gitcode.com/api/v5/repos/{repository}/releases/{tag}/attach_files/{filename}/download"
)
}
fn release_https_redirect_policy() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
let url = attempt.url();
let trusted_host = matches!(
url.host_str(),
Some(
"github.com"
| "objects.githubusercontent.com"
| "release-assets.githubusercontent.com"
| "api.gitcode.com"
| "gitcode.com"
| "file-cdn.gitcode.com"
)
);
if url.scheme() == "https"
&& url.port().is_none()
&& url.username().is_empty()
&& url.password().is_none()
&& trusted_host
{
attempt.follow()
} else {
attempt.stop()
}
})
}
fn github_https_redirect_policy() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
let url = attempt.url();
@@ -10864,6 +10977,11 @@ fn validate_portable_update_manifest(manifest: &PortableUpdateManifest) -> Resul
if asset.url != full_package_url && asset.url != legacy_package_url {
return Err(format!("软件更新资产名称与 {key} 不匹配"));
}
validate_portable_update_asset_fallbacks(
asset,
&format!("v{}", manifest.version.trim().trim_start_matches('v')),
&[&full_package_name, &legacy_package_name],
)?;
}
if let Some(full_assets) = &manifest.full_assets {
if full_assets.len() != 2 {
@@ -10888,6 +11006,11 @@ fn validate_portable_update_manifest(manifest: &PortableUpdateManifest) -> Resul
if asset.url != expected_url {
return Err(format!("Full update asset name does not match {key}"));
}
validate_portable_update_asset_fallbacks(
asset,
&format!("v{}", manifest.version.trim().trim_start_matches('v')),
&[&package_name],
)?;
}
}
Ok(())
@@ -10916,6 +11039,43 @@ fn validate_portable_update_asset(asset: &PortableUpdateAsset) -> Result<(), Str
Ok(())
}
fn validate_portable_update_asset_fallbacks(
asset: &PortableUpdateAsset,
tag: &str,
expected_filenames: &[&str],
) -> Result<(), String> {
validate_portable_update_asset_fallbacks_for_repository(
asset,
tag,
expected_filenames,
configured_gitcode_repository(),
)
}
fn validate_portable_update_asset_fallbacks_for_repository(
asset: &PortableUpdateAsset,
tag: &str,
expected_filenames: &[&str],
repository: Option<&str>,
) -> Result<(), String> {
if asset.fallback_urls.is_empty() {
return Ok(());
}
let repository = repository
.ok_or_else(|| "GitCode fallback is not configured for this build".to_string())?;
if asset.fallback_urls.len() != 1 {
return Err("Software update assets may define only one fallback URL".to_string());
}
let fallback = &asset.fallback_urls[0];
let trusted = expected_filenames
.iter()
.any(|filename| fallback == &gitcode_release_attachment_url(repository, tag, filename));
if !trusted {
return Err("Software update fallback URL is not trusted".to_string());
}
Ok(())
}
fn portable_update_target() -> Option<(&'static str, &'static str)> {
if !cfg!(windows) {
return None;
@@ -11124,14 +11284,46 @@ async fn download_portable_update_archive(
destination: &Path,
) -> Result<(), String> {
let client = reqwest::Client::builder()
.redirect(github_https_redirect_policy())
.redirect(release_https_redirect_policy())
.connect_timeout(Duration::from_secs(15))
.read_timeout(Duration::from_secs(30))
.timeout(Duration::from_secs(15 * 60))
.build()
.map_err(|error| format!("创建应用更新下载客户端失败: {error}"))?;
let urls = std::iter::once(&pending.asset.url)
.chain(pending.asset.fallback_urls.iter())
.collect::<Vec<_>>();
let mut failures = Vec::new();
for (index, url) in urls.iter().enumerate() {
update_app_task(app, |task| {
task.downloaded_bytes = 0;
task.percent = Some(0.0);
if index > 0 {
task.message = Some("GitHub 下载失败,正在切换到 GitCode".to_string());
}
});
match download_portable_update_archive_url(app, pending, token, destination, &client, url)
.await
{
Ok(()) => return Ok(()),
Err(error) if token.is_cancelled() => return Err(error),
Err(error) => failures.push(error),
}
}
Err(format!("所有应用更新下载源均失败: {}", failures.join("; ")))
}
#[cfg(windows)]
async fn download_portable_update_archive_url(
app: &tauri::AppHandle,
pending: &PendingAppUpdate,
token: &CancellationToken,
destination: &Path,
client: &reqwest::Client,
url: &str,
) -> Result<(), String> {
let response = client
.get(&pending.asset.url)
.get(url)
.header(reqwest::header::ACCEPT, "application/octet-stream")
.header(reqwest::header::USER_AGENT, APP_USER_AGENT)
.send()
@@ -23435,6 +23627,7 @@ custom_option = "keep-original"
let name = format!("EasyCLIProxyAPI-v{version}-Windows-{arch}.zip");
PortableUpdateAsset {
url: format!("{APP_RELEASE_DOWNLOAD_PREFIX}v{version}/{name}"),
fallback_urls: Vec::new(),
sha256: "ab".repeat(32),
size_bytes: 1024,
}
@@ -23444,6 +23637,7 @@ custom_option = "keep-original"
let name = format!("EasyCLIProxyAPI-update-v{version}-Windows-{arch}.zip");
PortableUpdateAsset {
url: format!("{APP_RELEASE_DOWNLOAD_PREFIX}v{version}/{name}"),
fallback_urls: Vec::new(),
sha256: "ab".repeat(32),
size_bytes: 1024,
}
@@ -23540,6 +23734,31 @@ custom_option = "keep-original"
assert!(validate_portable_update_manifest(&mismatched_tag).is_err());
}
#[test]
fn portable_update_asset_accepts_only_the_configured_gitcode_fallback() {
let mut asset = portable_update_test_asset("1.2.3", "amd64");
let filename = "EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip";
asset.fallback_urls = vec![gitcode_release_attachment_url(
"mirror-owner/EasyCLIProxyAPI",
"v1.2.3",
filename,
)];
assert!(validate_portable_update_asset_fallbacks_for_repository(
&asset,
"v1.2.3",
&[filename],
Some("mirror-owner/EasyCLIProxyAPI"),
)
.is_ok());
assert!(validate_portable_update_asset_fallbacks_for_repository(
&asset,
"v1.2.3",
&[filename],
Some("another-owner/EasyCLIProxyAPI"),
)
.is_err());
}
#[test]
fn portable_update_state_supports_cancellation_and_snapshot_recovery() {
let state = AppUpdateState::default();
+52
View File
@@ -0,0 +1,52 @@
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 }> = [];
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' },
});
}
if (method === 'PUT' && url === 'https://file-cdn.gitcode.com/presigned-upload') {
return new Response('', { status: 200 });
}
return new Response('', { status: 500 });
};
await publishGitcodeRelease({
directory,
repository: 'mirror-owner/EasyCLIProxyAPI',
token: 'secret-token',
tag: 'v1.2.3',
fetchImpl,
});
expect(calls.map(({ method }) => method)).toEqual(['GET', 'POST', 'GET', 'PUT']);
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(calls[3].body).toBeInstanceOf(Uint8Array);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
+7
View File
@@ -31,6 +31,7 @@ describe('Windows 便携更新清单', () => {
directory: root,
output,
repository: 'router-for-me/EasyCLIProxyAPI',
gitcodeRepository: 'mirror-owner/EasyCLIProxyAPI',
tag: 'v1.2.3',
publishedAt: '2026-07-24T00:00:00.000Z',
});
@@ -42,10 +43,16 @@ describe('Windows 便携更新清单', () => {
expect(asset.url).toBe(
`https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/v1.2.3/EasyCLIProxyAPI-update-v1.2.3-Windows-${arch}.zip`,
);
expect(asset.fallbackUrls).toEqual([
`https://api.gitcode.com/api/v5/repos/mirror-owner/EasyCLIProxyAPI/releases/v1.2.3/attach_files/EasyCLIProxyAPI-update-v1.2.3-Windows-${arch}.zip/download`,
]);
const fullAsset = manifest.fullAssets[`windows-${arch}`];
expect(fullAsset.url).toBe(
`https://github.com/router-for-me/EasyCLIProxyAPI/releases/download/v1.2.3/EasyCLIProxyAPI-v1.2.3-Windows-${arch}.zip`,
);
expect(fullAsset.fallbackUrls).toEqual([
`https://api.gitcode.com/api/v5/repos/mirror-owner/EasyCLIProxyAPI/releases/v1.2.3/attach_files/EasyCLIProxyAPI-v1.2.3-Windows-${arch}.zip/download`,
]);
expect(fullAsset.sizeBytes).toBe(payloads[arch].byteLength);
expect(fullAsset.sha256).toBe(createHash('sha256').update(payloads[arch]).digest('hex'));
}