feat(release): add GitCode fallback mirror
This commit is contained in:
1 parent
391fc877a7
commit
06f024abde
6 files changed
+475
-20
No files matched your search
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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(¤t_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();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user