From de3dbd330e96b8ce29c3355bfbe7d348c87179c5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 1 Sep 2026 16:25:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E6=94=B9=E4=B8=BA=E8=87=AA=E5=8A=A8=E5=AE=89?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下载时显示全屏进度遮罩 更新完成后从临时目录静默安装 关于页支持手动检查更新 构建流水线支持指定版本号 --- .../scripts/build-release.mjs | 9 +- .../src-tauri/Cargo.lock | 1 + .../src-tauri/Cargo.toml | 1 + .../src-tauri/src/main.rs | 115 +++++++++--- .../src/components/AppUpdateNotice.tsx | 174 ++++++++++++++++-- .../runtime-config/RuntimeConfigDialog.tsx | 33 ++++ .../src/services/appUpdate.ts | 30 ++- apps/ai-game-creator-shell/src/styles.css | 110 +++++++++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 8 +- .../Jenkinsfile.ai-game-creator-shell-build | 2 + 10 files changed, 423 insertions(+), 60 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index 82a4ee2ce..03a61ba9d 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -91,7 +91,10 @@ function replaceVersionLine(source, version, pattern, label) { export async function prepareReleaseVersion() { const localVersion = parseVersion(readPackageJson().version, '本地版本'); const remoteVersion = await readRemoteVersion(); - const nextVersion = nextPatchVersion(localVersion, remoteVersion); + const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim(); + const nextVersion = requestedVersion + ? parseVersion(requestedVersion, '指定版本') + : nextPatchVersion(localVersion, remoteVersion); const packageSource = fs.readFileSync(packageJsonPath, 'utf8'); fs.writeFileSync( @@ -149,7 +152,9 @@ export async function prepareReleaseVersion() { ); console.log( - `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`, + requestedVersion + ? `[ai-game-creator-shell] 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})` + : `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`, ); return nextVersion; } diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index a6936c44b..3215ccc82 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1722,6 +1722,7 @@ dependencies = [ "oxc_parser", "oxc_semantic", "oxc_span", + "percent-encoding", "platform-agent", "platform-llm", "portable-pty", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 318951ed9..055a317d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -42,6 +42,7 @@ similar = "2.7" platform-llm = { path = "../../../server-rs/crates/platform-llm" } platform-agent = { path = "../../../server-rs/crates/platform-agent" } portable-pty = "0.9" +percent-encoding = "2" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] } shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false } tauri = { version = "2.11.2", features = [] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 92bf0dfe4..8610ef687 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -6,11 +6,13 @@ use std::fs::{File, OpenOptions}; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; +use std::process::Command; use std::sync::atomic::AtomicBool; use std::sync::{mpsc, Arc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use futures::StreamExt; use platform_agent::{ build_game_creation_seed_task_graph, plan_game_creation_agent_pass, route_game_creation_repair_issues, @@ -21,6 +23,7 @@ use platform_llm::{ }; use reqwest::header; use serde::{Deserialize, Serialize}; +use sha2::Digest; use shared_contracts::game_creation_app::{ new_game_creation_app_manifest, new_game_creation_app_seed_tasks, validate_game_iteration_versions, GameCreationAgentArtifactTrace, @@ -45,6 +48,14 @@ use tauri_plugin_opener::OpenerExt; const AGC_UPDATE_OSS_HOST: &str = "agc-dev.oss-rg-china-mainland.aliyuncs.com"; const AGC_UPDATE_MAX_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024; +const AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT: &str = "agc-update-download-progress"; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgcUpdateDownloadProgress { + downloaded_bytes: u64, + total_bytes: Option, +} #[tauri::command] async fn download_agc_update( @@ -57,15 +68,22 @@ async fn download_agc_update( if parsed.scheme() != "https" || parsed.host_str() != Some(AGC_UPDATE_OSS_HOST) { return Err("更新下载地址必须来自受信任的 OSS".to_string()); } - let filename = parsed + let encoded_filename = parsed .path_segments() .and_then(|segments| segments.last()) - .filter(|value| !value.is_empty() && value.len() <= 128) + .filter(|value| !value.is_empty()) .ok_or_else(|| "更新下载地址缺少文件名".to_string())? .to_string(); + let filename = percent_encoding::percent_decode_str(&encoded_filename) + .decode_utf8() + .map_err(|_| "更新文件名无效".to_string())? + .into_owned(); if filename.contains('/') || filename.contains('\\') || filename.contains("..") { return Err("更新文件名无效".to_string()); } + if filename.is_empty() || filename.len() > 128 { + return Err("更新文件名无效".to_string()); + } let response = reqwest::Client::new() .get(parsed) .send() @@ -80,15 +98,65 @@ async fn download_agc_update( { return Err("更新文件超过大小限制".to_string()); } - let bytes = response - .bytes() - .await - .map_err(|_| "读取更新文件失败".to_string())?; - if bytes.len() as u64 > AGC_UPDATE_MAX_DOWNLOAD_BYTES { - return Err("更新文件超过大小限制".to_string()); + let download_dir = app + .path() + .temp_dir() + .map_err(|_| "无法定位临时目录".to_string())? + .join("genarrative-agc-update"); + fs::create_dir_all(&download_dir).map_err(|_| "无法创建临时目录".to_string())?; + let target = download_dir.join(&filename); + let temporary = download_dir.join(format!( + "{}.{}.download", + filename, + uuid::Uuid::new_v4().simple() + )); + let mut file = File::create(&temporary).map_err(|_| "保存更新文件失败".to_string())?; + let mut hasher = sha2::Sha256::new(); + let total_bytes = response.content_length(); + let mut downloaded_bytes = 0_u64; + let _ = app.emit( + AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT, + AgcUpdateDownloadProgress { + downloaded_bytes, + total_bytes, + }, + ); + let mut stream = response.bytes_stream(); + while let Some(chunk_result) = stream.next().await { + let chunk = match chunk_result { + Ok(chunk) => chunk, + Err(_) => { + let _ = fs::remove_file(&temporary); + return Err("读取更新文件失败".to_string()); + } + }; + downloaded_bytes = match downloaded_bytes.checked_add(chunk.len() as u64) { + Some(value) if value <= AGC_UPDATE_MAX_DOWNLOAD_BYTES => value, + _ => { + let _ = fs::remove_file(&temporary); + return Err("更新文件超过大小限制".to_string()); + } + }; + hasher.update(&chunk); + if file.write_all(&chunk).is_err() { + let _ = fs::remove_file(&temporary); + return Err("保存更新文件失败".to_string()); + } + let _ = app.emit( + AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT, + AgcUpdateDownloadProgress { + downloaded_bytes, + total_bytes, + }, + ); + } + if file.flush().is_err() { + let _ = fs::remove_file(&temporary); + return Err("保存更新文件失败".to_string()); } if let Some(expected_size) = expected_size { - if bytes.len() as u64 != expected_size { + if downloaded_bytes != expected_size { + let _ = fs::remove_file(&temporary); return Err("更新文件大小校验失败".to_string()); } } @@ -97,38 +165,27 @@ async fn download_agc_update( if !expected_sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) || expected_sha256.len() != 64 { + let _ = fs::remove_file(&temporary); return Err("更新文件摘要无效".to_string()); } - use sha2::{Digest, Sha256}; - let actual = format!("{:x}", Sha256::digest(&bytes)); + let actual = format!("{:x}", hasher.finalize()); if actual != expected_sha256 { + let _ = fs::remove_file(&temporary); return Err("更新文件完整性校验失败".to_string()); } } - let download_dir = app - .path() - .download_dir() - .map_err(|_| "无法定位下载目录".to_string())?; - fs::create_dir_all(&download_dir).map_err(|_| "无法创建下载目录".to_string())?; - let mut target = download_dir.join(&filename); if target.exists() { - let unique_name = format!( - "{}-{}.{}", - target - .file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("agc-update"), - &uuid::Uuid::new_v4().simple(), - target.extension().and_then(|value| value.to_str()).unwrap_or("bin") - ); - target = download_dir.join(unique_name); + let _ = fs::remove_file(&target); } - let temporary = target.with_extension(format!("{}.download", uuid::Uuid::new_v4())); - fs::write(&temporary, &bytes).map_err(|_| "保存更新文件失败".to_string())?; if let Err(error) = fs::rename(&temporary, &target) { let _ = fs::remove_file(&temporary); return Err(format!("提交更新文件失败:{error}")); } + Command::new(&target) + .arg("/S") + .spawn() + .map_err(|_| "无法启动更新安装程序".to_string())?; + app.exit(0); Ok(target.to_string_lossy().into_owned()) } diff --git a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx index 63132ab48..7cc1c8444 100644 --- a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx +++ b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx @@ -1,54 +1,190 @@ +import { listen } from '@tauri-apps/api/event'; +import { Download, LoaderCircle } from 'lucide-react'; import { useEffect, useState } from 'react'; import { APP_VERSION } from '../app/appMetadata'; import { + AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT, type AppUpdateInfo, checkForAppUpdate, downloadAppUpdate, + subscribeToAppUpdate, } from '../services/appUpdate'; +type DownloadProgress = { + downloadedBytes: number; + totalBytes?: number; +}; + +type DownloadState = 'idle' | 'downloading' | 'completed' | 'error'; + +function formatBytes(bytes: number) { + if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + export function AppUpdateNotice() { const [update, setUpdate] = useState(null); - const [downloading, setDownloading] = useState(false); + const [downloadState, setDownloadState] = useState('idle'); + const [downloadProgress, setDownloadProgress] = useState({ + downloadedBytes: 0, + }); + const [downloadError, setDownloadError] = useState(''); useEffect(() => { let mounted = true; + const unsubscribe = subscribeToAppUpdate((result) => { + if (mounted) setUpdate(result); + }); void checkForAppUpdate().then((result) => { if (mounted) setUpdate(result); }); return () => { mounted = false; + unsubscribe(); }; }, []); + useEffect(() => { + if (typeof window === 'undefined' || !window.__TAURI__ || !update) return; + let disposed = false; + let unlisten: (() => void) | undefined; + void listen( + AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT, + (event) => { + if (!disposed) setDownloadProgress(event.payload); + }, + ).then((cleanup) => { + if (disposed) cleanup(); + else unlisten = cleanup; + }); + return () => { + disposed = true; + unlisten?.(); + }; + }, [update]); + if (!update) return null; const currentUpdate = update; + const isDownloading = downloadState === 'downloading'; + const totalBytes = downloadProgress.totalBytes ?? currentUpdate.size; + const progress = totalBytes + ? Math.min( + 100, + Math.round((downloadProgress.downloadedBytes / totalBytes) * 100), + ) + : null; + async function handleDownload() { - setDownloading(true); + if (isDownloading) return; + setDownloadError(''); + setDownloadProgress({ downloadedBytes: 0, totalBytes }); + setDownloadState('downloading'); try { await downloadAppUpdate(currentUpdate.downloadUrl, currentUpdate); - } finally { - setDownloading(false); + setDownloadState('completed'); + } catch (error) { + setDownloadError(error instanceof Error ? error.message : String(error)); + setDownloadState('error'); } } + function dismiss() { + if (isDownloading) return; + setUpdate(null); + setDownloadState('idle'); + } + return ( - +
+ 发现新版本 {currentUpdate.version} + 当前版本 {APP_VERSION} + {currentUpdate.releaseNotes ? ( +

{currentUpdate.releaseNotes}

+ ) : null} +
+ + + {downloadState !== 'idle' ? ( +
+
+ +

+ {isDownloading + ? `正在下载 ${currentUpdate.version}` + : downloadState === 'completed' + ? '下载完成' + : '下载失败'} +

+ {isDownloading ? ( + <> +
+ +
+

+ {progress === null + ? '正在获取下载进度…' + : `${progress}% · ${formatBytes(downloadProgress.downloadedBytes)} / ${formatBytes(totalBytes ?? downloadProgress.downloadedBytes)}`} +

+ 下载期间请勿关闭客户端或进行其他操作 + + ) : downloadState === 'completed' ? ( + <> +

安装程序已启动,客户端将自动完成更新。

+ + + ) : ( + <> +

{downloadError || '下载更新失败,请稍后重试。'}

+
+ + +
+ + )} +
+
+ ) : null} + ); } diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 386c6d2cf..4d1a1b421 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -33,6 +33,7 @@ import { type RuntimeAgentLlmProviderPresetId, type RuntimeLlmProviderPresetId, } from '../../app/types'; +import { checkForAppUpdate } from '../../services/appUpdate'; const runtimeAgentReasoningEffortDefaults = { 'project-supervisor': 'high', @@ -383,6 +384,8 @@ export function RuntimeConfigDialog({ const [activeSection, setActiveSection] = useState('general'); const [expandedAgentIds, setExpandedAgentIds] = useState([]); + const [appUpdateStatus, setAppUpdateStatus] = useState(''); + const [appUpdateChecking, setAppUpdateChecking] = useState(false); const runtimeConfigBusyRef = useRef(false); useEscapeToClose(onClose); @@ -588,6 +591,24 @@ export function RuntimeConfigDialog({ setRuntimeConfigStatus('已恢复默认配置,保存后生效'); } + async function checkAppUpdateManually() { + if (appUpdateChecking) return; + setAppUpdateChecking(true); + setAppUpdateStatus('正在检查更新…'); + try { + const update = await checkForAppUpdate({ force: true }); + setAppUpdateStatus( + update + ? `发现新版本 v${update.version},可在右上角下载` + : '当前已是最新版本', + ); + } catch { + setAppUpdateStatus('检查更新失败,请稍后重试'); + } finally { + setAppUpdateChecking(false); + } + } + const selectedSection = runtimeSettingsSections.find((section) => section.id === activeSection) ?? runtimeSettingsSections[0]; @@ -1308,6 +1329,18 @@ export function RuntimeConfigDialog({
桌面客户端
+
+ + + {appUpdateStatus} + +
) : null} diff --git a/apps/ai-game-creator-shell/src/services/appUpdate.ts b/apps/ai-game-creator-shell/src/services/appUpdate.ts index fe3ac0bbd..f9021f3d0 100644 --- a/apps/ai-game-creator-shell/src/services/appUpdate.ts +++ b/apps/ai-game-creator-shell/src/services/appUpdate.ts @@ -8,6 +8,8 @@ import { resolveTauriInvoke } from '../app/tauri'; export const AGC_UPDATE_MANIFEST_URL = import.meta.env.VITE_AGC_UPDATE_MANIFEST_URL?.trim() || 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/latest.json'; +export const AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT = + 'agc-update-download-progress'; export type AppUpdateManifest = { version: string; @@ -22,6 +24,7 @@ export type AppUpdateInfo = AppUpdateManifest & { }; let updateCheckPromise: Promise | null = null; +const updateListeners = new Set<(update: AppUpdateInfo | null) => void>(); function parseVersion(value: string) { const match = value @@ -100,19 +103,32 @@ async function fetchUpdateManifest() { } /** 同一客户端生命周期内只请求一次,避免 StrictMode 或多窗口重复检测。 */ -export function checkForAppUpdate(): Promise { +export function checkForAppUpdate( + options: { force?: boolean } = {}, +): Promise { + if (options.force) updateCheckPromise = null; if (!updateCheckPromise) { updateCheckPromise = fetchUpdateManifest() - .then((manifest) => - manifest && isNewerVersion(manifest.version, APP_VERSION) - ? { ...manifest, currentVersion: APP_VERSION } - : null, - ) + .then((manifest) => { + const update = + manifest && isNewerVersion(manifest.version, APP_VERSION) + ? { ...manifest, currentVersion: APP_VERSION } + : null; + updateListeners.forEach((listener) => listener(update)); + return update; + }) .catch(() => null); } return updateCheckPromise; } +export function subscribeToAppUpdate( + listener: (update: AppUpdateInfo | null) => void, +) { + updateListeners.add(listener); + return () => updateListeners.delete(listener); +} + export async function downloadAppUpdate( downloadUrl: string, integrity: Pick = {}, @@ -122,7 +138,7 @@ export async function downloadAppUpdate( if (typeof window !== 'undefined' && window.__TAURI__) { const invoke = resolveTauriInvoke(); if (invoke) { - await invoke('download_agc_update', { + return await invoke('download_agc_update', { downloadUrl: url.toString(), expectedSha256: integrity.sha256, expectedSize: integrity.size, diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index a4f9c7b2c..d36ca4236 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -75,6 +75,90 @@ body { opacity: 0.65; } +.app-update-overlay { + position: fixed; + z-index: 260; + inset: 0; + display: grid; + padding: 24px; + background: rgb(35 20 12 / 48%); + place-items: center; + pointer-events: auto; +} + +.app-update-progress-dialog { + display: grid; + width: min(420px, calc(100vw - 48px)); + gap: 12px; + padding: 28px; + border: 1px solid #efc9ae; + border-radius: 18px; + background: #fffaf5; + box-shadow: 0 20px 60px rgb(38 18 8 / 28%); + color: #4a220f; + text-align: center; +} + +.app-update-progress-icon { + display: grid; + width: 52px; + height: 52px; + margin: 0 auto; + border-radius: 16px; + background: #f6dfd0; + color: #c7653d; + place-items: center; +} + +.app-update-progress-dialog h2, +.app-update-progress-dialog p, +.app-update-progress-dialog small { + margin: 0; +} + +.app-update-progress-dialog h2 { + font-size: 18px; +} + +.app-update-progress-dialog p, +.app-update-progress-dialog small { + color: #8d6a58; + font-size: 12px; +} + +.app-update-progress-track { + height: 8px; + overflow: hidden; + border-radius: 99px; + background: #f1ded2; +} + +.app-update-progress-track span { + display: block; + height: 100%; + border-radius: inherit; + background: #c7653d; + transition: width 180ms ease; +} + +.app-update-progress-dialog button { + justify-self: center; + min-width: 96px; + padding: 8px 16px; + border: 0; + border-radius: 8px; + background: #c7653d; + color: #fff; + cursor: pointer; + font-weight: 700; +} + +.app-update-progress-actions { + display: flex; + justify-content: center; + gap: 10px; +} + :root { /* 网页内自绘标题栏占用的顶部高度;portal 到 body 的固定弹层也要从它下方开始。 */ --window-chrome-height: 50px; @@ -3730,6 +3814,32 @@ h2 { font-weight: 800; } +.runtime-settings-about-update { + display: flex; + align-items: center; + gap: 12px; +} + +.runtime-settings-about-update button { + padding: 8px 14px; + border: 0; + border-radius: 8px; + background: var(--platform-accent); + color: #fff; + cursor: pointer; + font-weight: 700; +} + +.runtime-settings-about-update button:disabled { + cursor: wait; + opacity: 0.65; +} + +.runtime-settings-about-update span { + color: var(--platform-text-soft); + font-size: 12px; +} + .runtime-agent-list { grid-column: 1 / -1; display: grid; diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 01ca8122e..7c3e3cebd 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -19,7 +19,7 @@ AGC 每次启动时由根窗口检查一次公开 OSS 更新清单。清单默 } ``` -`downloadUrl` 必须是 HTTPS;如提供 `sha256` / `size`,Tauri 下载时会校验摘要和字节数。点击“下载更新”后,客户端把安装包保存到系统下载目录,安装包安装/替换由操作系统安装器完成。 +`downloadUrl` 必须是 HTTPS;如提供 `sha256` / `size`,Tauri 下载时会校验摘要和字节数。点击“下载更新”后,客户端将安装包流式写入系统临时目录并显示进度,校验成功后自动启动 NSIS 静默安装并退出旧客户端。 ## 启动与失败策略 @@ -37,7 +37,8 @@ OSS 请求失败、清单格式错误或版本无效会终止发布,避免覆 安装包,并在 `apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json` 生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_BUILD_TARGET` 显式覆盖目标(发布仍应使用 Windows x64),通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定 -OSS 前缀,通过 `AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会读取 OSS、修改版本或生成清单。 +OSS 前缀,通过 `AGC_RELEASE_VERSION` 指定三段版本号(仅在明确需要复现指定版本时使用),通过 +`AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会读取 OSS、修改版本或生成清单。 每次发布安装包上传完成后,再上传同一目录生成的 `latest.json`,确保 `downloadUrl` 指向已存在的 OSS 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本本身不负责上传 OSS,发布流水线通过 `release:upload` 完成上传。 @@ -58,4 +59,5 @@ Jenkins Job 在“Build and upload”阶段通过受保护凭据 ID `AliyunAcces `AliyunaccessKeySecret` 注入 AccessKey,仅在当前进程运行时传给 ossutil,不写入仓库、workspace 或构建日志; 本机运行仍使用 ossutil 配置。凭据必须具备 `PutObject` 权限;OSS 对客户端保持公共读即可,公共读本身不授予 Jenkins 上传权限。由于版本号取决于 OSS 当前清单,Job 已关闭并发构建;若 Jenkins -上存在多个 AGC 发布 Job,还应使用同一个 Lockable Resource 串行化发布。 +上存在多个 AGC 发布 Job,还应使用同一个 Lockable Resource 串行化发布。Job 参数 +`AGC_RELEASE_VERSION` 留空时自动递增,填写后会使用指定版本并更新对应的 `latest.json`,因此回滚或测试旧版本前应确认不会覆盖线上更新入口。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index f8fd93c8a..e541f2abe 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -21,6 +21,7 @@ pipeline { parameters { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') + string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按 OSS 与本地版本自动递增 patch') string(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,写入 latest.json 的发布说明') string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 或 ossutil.exe 的绝对路径/命令名') } @@ -119,6 +120,7 @@ pipeline { withEnv([ "PATH=${env.AGC_WINDOWS_PATH}", "OSSUTIL_BIN=${params.OSSUTIL_BIN}", + "AGC_RELEASE_VERSION=${params.AGC_RELEASE_VERSION}", "AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}", ]) { powershell '''