客户端更新下载改为自动安装
Project CI / Repository checks (pull_request) Successful in 3m12s
Project CI / Frontend tests (pull_request) Successful in 3m58s
Project CI / Backend tests (pull_request) Successful in 6m48s
Project CI / Native shell tests (pull_request) Failing after 18m57s

下载时显示全屏进度遮罩

更新完成后从临时目录静默安装

关于页支持手动检查更新

构建流水线支持指定版本号
This commit is contained in:
2026-09-01 16:25:40 +08:00
parent 1ce880c89b
commit de3dbd330e
10 changed files with 423 additions and 60 deletions
@@ -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;
}
+1
View File
@@ -1722,6 +1722,7 @@ dependencies = [
"oxc_parser",
"oxc_semantic",
"oxc_span",
"percent-encoding",
"platform-agent",
"platform-llm",
"portable-pty",
@@ -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 = [] }
@@ -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<u64>,
}
#[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())
}
@@ -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<AppUpdateInfo | null>(null);
const [downloading, setDownloading] = useState(false);
const [downloadState, setDownloadState] = useState<DownloadState>('idle');
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress>({
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<DownloadProgress>(
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 (
<aside className="app-update-notice" role="status" aria-label="发现新版本">
<div>
<strong> {currentUpdate.version}</strong>
<span> {APP_VERSION}</span>
{currentUpdate.releaseNotes ? (
<p>{currentUpdate.releaseNotes}</p>
) : null}
</div>
<button
type="button"
onClick={() => void handleDownload()}
disabled={downloading}
<>
<aside
className="app-update-notice"
role="status"
aria-label="发现新版本"
>
{downloading ? '正在打开下载…' : '下载更新'}
</button>
</aside>
<div>
<strong> {currentUpdate.version}</strong>
<span> {APP_VERSION}</span>
{currentUpdate.releaseNotes ? (
<p>{currentUpdate.releaseNotes}</p>
) : null}
</div>
<button
type="button"
onClick={() => void handleDownload()}
disabled={isDownloading}
>
{isDownloading ? '正在下载…' : '下载更新'}
</button>
</aside>
{downloadState !== 'idle' ? (
<div
className="app-update-overlay"
role="dialog"
aria-modal="true"
aria-label="下载更新"
>
<div className="app-update-progress-dialog">
<div className="app-update-progress-icon" aria-hidden="true">
{isDownloading ? (
<LoaderCircle className="is-spinning" size={24} />
) : (
<Download size={24} />
)}
</div>
<h2>
{isDownloading
? `正在下载 ${currentUpdate.version}`
: downloadState === 'completed'
? '下载完成'
: '下载失败'}
</h2>
{isDownloading ? (
<>
<div
className="app-update-progress-track"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress ?? undefined}
>
<span
style={{
width: progress === null ? '35%' : `${progress}%`,
}}
/>
</div>
<p>
{progress === null
? '正在获取下载进度…'
: `${progress}% · ${formatBytes(downloadProgress.downloadedBytes)} / ${formatBytes(totalBytes ?? downloadProgress.downloadedBytes)}`}
</p>
<small></small>
</>
) : downloadState === 'completed' ? (
<>
<p></p>
<button type="button" onClick={dismiss}>
</button>
</>
) : (
<>
<p>{downloadError || '下载更新失败,请稍后重试。'}</p>
<div className="app-update-progress-actions">
<button type="button" onClick={() => void handleDownload()}>
</button>
<button type="button" onClick={dismiss}>
</button>
</div>
</>
)}
</div>
</div>
) : null}
</>
);
}
@@ -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<RuntimeSettingsSection>('general');
const [expandedAgentIds, setExpandedAgentIds] = useState<string[]>([]);
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({
<dd></dd>
</div>
</dl>
<div className="runtime-settings-about-update">
<button
type="button"
onClick={() => void checkAppUpdateManually()}
disabled={appUpdateChecking}
>
{appUpdateChecking ? '正在检查…' : '检查更新'}
</button>
<span role="status" aria-live="polite">
{appUpdateStatus}
</span>
</div>
</section>
) : null}
</div>
@@ -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<AppUpdateInfo | null> | 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<AppUpdateInfo | null> {
export function checkForAppUpdate(
options: { force?: boolean } = {},
): Promise<AppUpdateInfo | null> {
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<AppUpdateManifest, 'sha256' | 'size'> = {},
@@ -122,7 +138,7 @@ export async function downloadAppUpdate(
if (typeof window !== 'undefined' && window.__TAURI__) {
const invoke = resolveTauriInvoke();
if (invoke) {
await invoke<string>('download_agc_update', {
return await invoke<string>('download_agc_update', {
downloadUrl: url.toString(),
expectedSha256: integrity.sha256,
expectedSize: integrity.size,
+110
View File
@@ -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;
@@ -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`,因此回滚或测试旧版本前应确认不会覆盖线上更新入口。
@@ -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 '''