修复 AGC Windows 构建的 Tauri NSIS 工具链重复下载与截断失败
Project CI / AI game creator shell Rust crates (push) Successful in 3m3s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m50s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 6m16s
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled

tauri-bundler 打包时现场从 GitHub 下载 nsis-3.11.zip 与 nsis_tauri_utils.dll 且不重试,
Checkout 的 git clean -fdx 又会删掉工作区内的 target/.tauri,于是每个构建都要重下,
响应一旦被截断只会抛 `io: unexpected end of file`,让发布在 Rust 编译数分钟后失败。

- Jenkins Checkout 的 git clean 增加 -e apps/ai-game-creator-shell/src-tauri/target/.tauri,只保留工具缓存
- 新增 nsis-toolset.mjs:按固定 SHA1 预置 target/.tauri/NSIS,带 4 次重试并把原始归档缓存到工作区外
- 新增 ensure-nsis-toolset.mjs 与 nsis:prepare 脚本,作为 Jenkins 预检入口
- buildRelease 在 Windows 目标打包前接入工具链预置,预置失败即失败关闭
- Jenkins 新增 Tauri NSIS toolchain 阶段,在编译前预置、跑回归测试并校验 makensis 可执行
- 新增 nsis-toolset.test.mjs 并补 3 项 build-release 预置接入测试
- 同步开发运维文档与 shared-memory 排障记录
This commit is contained in:
2026-09-21 15:59:04 +08:00
parent f4ebc45612
commit 33036b65fc
11 changed files with 813 additions and 3 deletions
+2
View File
@@ -9,6 +9,7 @@
"dev-stack": "node scripts/start-dev-stack.mjs",
"build": "node scripts/build-release.mjs",
"release:upload": "node scripts/release-upload.mjs",
"nsis:prepare": "node scripts/ensure-nsis-toolset.mjs",
"skill-pack:check": "node scripts/check-skill-pack.mjs",
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
@@ -75,6 +76,7 @@
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@types/three": "^0.184.1",
"jszip": "^3.10.1",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
"vitest": "^0.34.6"
@@ -13,6 +13,7 @@ import {
defaultEditorFeatures,
withDefaultCargoFeatures,
} from './cargo-features.mjs';
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
import { stageNodeRuntime } from './stage-node-runtime.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -877,14 +878,19 @@ export async function buildRelease(
args = [],
{
prepareVersion = prepareReleaseVersion,
prepareToolset = prepareNsisToolsetForRelease,
build = runTauriBuild,
generateManifest = generateUpdateManifest,
} = {},
) {
const context = resolveReleaseContext(args);
if (!args.includes('--no-bundle')) await prepareVersion(context);
const bundling = !args.includes('--no-bundle');
if (bundling) await prepareVersion(context);
// Tauri bundler 下载 NSIS 工具链时不重试,网络截断会直接毁掉整次打包;
// 因此打包前先在 Windows 目标上预置(详见 nsis-toolset.mjs)。
if (bundling) await prepareToolset(context, { bundling });
build(args, context);
if (!args.includes('--no-bundle')) return generateManifest(context);
if (bundling) return generateManifest(context);
}
if (
@@ -590,6 +590,71 @@ test('no-bundle smoke skips version writes and manifest generation', async () =>
assert.deepEqual(steps, ['dev']);
});
test('Windows 打包在 Tauri 构建前预置 NSIS 工具链', async () => {
const events = [];
await buildRelease(['--target', windowsTarget], {
prepareVersion: () => {
events.push('version');
},
prepareToolset: (context, options) => {
events.push(`toolset:${context.target}:${options.bundling}`);
},
build: () => {
events.push('build');
},
generateManifest: () => {
events.push('manifest');
},
});
assert.deepEqual(events, [
'version',
`toolset:${windowsTarget}:true`,
'build',
'manifest',
]);
});
test('NSIS 工具链预置失败即失败关闭,不进入 Tauri 构建', async () => {
const events = [];
await assert.rejects(
buildRelease(['--target', windowsTarget], {
prepareVersion: () => {
events.push('version');
},
prepareToolset: () => {
throw new Error('NSIS 工具链预置失败:下载 nsis-3.11.zip 失败');
},
build: () => {
events.push('build');
},
generateManifest: () => {
events.push('manifest');
},
}),
/NSIS 工具链预置失败/u,
);
assert.deepEqual(events, ['version']);
});
test('--no-bundle 不预置 NSIS 工具链', async () => {
const steps = [];
await buildRelease(['--no-bundle', '--target', windowsTarget], {
prepareVersion: () => {
steps.push('version');
},
prepareToolset: () => {
steps.push('toolset');
},
build: () => {
steps.push('build');
},
generateManifest: () => {
steps.push('manifest');
},
});
assert.deepEqual(steps, ['build']);
});
test('release stages Node before Tauri and injects its resource mapping only for bundles', () => {
const context = resolveReleaseContext(['--target', windowsTarget]);
const events = [];
@@ -0,0 +1,28 @@
// Jenkins Windows 预检入口:在数分钟的 Rust 编译之前完成 NSIS 工具链预置。
//
// 预置失败必须在此之前失败关闭,避免 bundler 用 `io: unexpected end of file`
// 把网络问题伪装成打包问题。
import { ensureNsisToolset, LOG_PREFIX } from './nsis-toolset.mjs';
// Jenkins 阶段用 `$ErrorActionPreference = 'Stop'` 执行 Powershell:重试告警走
// stderr 时可能被 PowerShell 当成终止错误,因此重试与进度一律写 stdout,只有
// 最终失败才写 stderr 并以退出码 1 失败关闭。
const logger = {
log: (message) => console.log(message),
warn: (message) => console.log(`${message}(将重试)`),
};
try {
const result = await ensureNsisToolset({ logger });
console.log(`${LOG_PREFIX} NSIS 工具链目录:${result.nsisDir}`);
console.log(`${LOG_PREFIX} NSIS 原始归档缓存:${result.cacheDir}`);
console.log(
result.reused
? `${LOG_PREFIX} NSIS 工具链复用已有目录,未访问网络`
: `${LOG_PREFIX} NSIS 工具链本次预置:${result.downloaded.join('、')}`,
);
} catch (error) {
console.error(`${LOG_PREFIX} NSIS 工具链预置失败:${error.message}`);
process.exit(1);
}
@@ -0,0 +1,342 @@
// Tauri Windows bundler 的 NSIS 工具链预置。
//
// 背景:`tauri build` 打 Windows NSIS 包时会现场从 GitHub 下载 `nsis-3.11.zip`
// 与 `nsis_tauri_utils.dll`(见 tauri-bundler `bundle/windows/nsis/mod.rs`)。
// 构建机每个检出(`git clean -fdx`)都会丢掉 `target/.tauri` 缓存,于是每次
// 发布都要重新下载;响应一旦被截断,bundler 只会报 `io: unexpected end of file`
// 整条流水线在 Rust 编译数分钟之后才失败。
//
// 这里在打包前用固定哈希 + 重试预置同一份工具链目录:bundler 检查到必需文件齐全
// 且 `nsis_tauri_utils.dll` 哈希一致后就不会再自行下载。原始归档(两个文件)
// 额外缓存在工作区之外,构建机重复构建时不再依赖 GitHub 连通性。
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import JSZip from 'jszip';
export const LOG_PREFIX = '[ai-game-creator-shell]';
/** bundler 把工具链解到 `<tools>/.tauri/NSIS``bundle.useLocalToolsDir: true`)。 */
export const NSIS_TOOLSET_DIR_NAME = 'NSIS';
export const NSIS_ARCHIVE_ASSET_NAME = 'nsis-3.11.zip';
export const NSIS_ARCHIVE_URL =
'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip';
export const NSIS_ARCHIVE_SHA1 = 'ef7ff767e5cbd9edd22add3a32c9b8f4500bb10d';
export const NSIS_ARCHIVE_TOP_LEVEL_DIR = 'nsis-3.11';
export const NSIS_TAURI_UTILS_ASSET_NAME = 'nsis_tauri_utils.dll';
export const NSIS_TAURI_UTILS_URL =
'https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll';
export const NSIS_TAURI_UTILS_SHA1 = '75197fee3c6a814fe035788d1c34ead39349b860';
export const NSIS_TAURI_UTILS_REQUIRED_FILE =
'Plugins/x86-unicode/additional/nsis_tauri_utils.dll';
/**
* 与 tauri-bundler 2.9.x 的 `NSIS_REQUIRED_FILES` 逐条对齐:少一条 bundler 就会
* 删掉整个目录重新下载,等于预置失效。升级 `@tauri-apps/cli` 时要同步核对。
*/
export const NSIS_REQUIRED_FILES = [
'makensis.exe',
'Bin/makensis.exe',
'Stubs/lzma-x86-unicode',
'Stubs/lzma_solid-x86-unicode',
NSIS_TAURI_UTILS_REQUIRED_FILE,
'Include/MUI2.nsh',
'Include/FileFunc.nsh',
'Include/x64.nsh',
'Include/nsDialogs.nsh',
'Include/WinMessages.nsh',
'Include/Win/COM.nsh',
'Include/Win/Propkey.nsh',
'Include/Win/RestartManager.nsh',
];
/** 需要预置的原始归档;测试可注入同结构描述替换其中的地址与哈希。 */
export const NSIS_ASSETS = [
{
assetName: NSIS_ARCHIVE_ASSET_NAME,
url: NSIS_ARCHIVE_URL,
sha1: NSIS_ARCHIVE_SHA1,
},
{
assetName: NSIS_TAURI_UTILS_ASSET_NAME,
url: NSIS_TAURI_UTILS_URL,
sha1: NSIS_TAURI_UTILS_SHA1,
},
];
const DEFAULT_DOWNLOAD_ATTEMPTS = 4;
const DEFAULT_RETRY_DELAY_MS = 3000;
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 180_000;
export function defaultAppRoot() {
return fileURLToPath(new URL('..', import.meta.url));
}
export function resolveTauriToolsDir(appRoot = defaultAppRoot()) {
// 必须与 `src-tauri/tauri.windows.conf.json` 的 `bundle.useLocalToolsDir: true`
// 保持一致,否则预置的文件不在 bundler 的查找路径上。
return path.join(appRoot, 'src-tauri', 'target', '.tauri');
}
export function resolveNsisCacheDir(
env = process.env,
platform = process.platform,
) {
const explicit = env.AGC_TAURI_NSIS_CACHE_DIR?.trim();
if (explicit) return path.resolve(explicit);
// Jenkins Windows 节点以 SYSTEM 运行,ProgramData 稳定可写且不受工作区清理影响;
// 缓存里只有待解压的原始归档,不会从该目录执行任何程序。
if (platform === 'win32') {
const programData = env.ProgramData?.trim() || 'C:\\ProgramData';
return path.join(programData, 'genarrative', 'tauri-nsis-cache');
}
return path.join(os.homedir(), '.cache', 'genarrative', 'tauri-nsis-cache');
}
/** 与 tauri-bundler 相同的镜像开关语义,便于构建机绕过不可达的 GitHub。 */
export function resolveDownloadUrl(url, env = process.env) {
if (!url.startsWith('https://github.com/')) return url;
const template = env.TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE?.trim();
const match =
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/releases\/download\/([^/]+)\/(.+)$/u.exec(
url,
);
if (template && match) {
return template
.replaceAll('<owner>', match[1])
.replaceAll('<repo>', match[2])
.replaceAll('<version>', match[3])
.replaceAll('<asset>', match[4]);
}
const base = env.TAURI_BUNDLER_TOOLS_GITHUB_MIRROR?.trim();
if (base) return `${base.replace(/\/+$/u, '')}/${url}`;
return url;
}
export function sha1Of(data) {
return createHash('sha1').update(data).digest('hex');
}
function sha1OfFile(filePath) {
try {
return sha1Of(fs.readFileSync(filePath));
} catch {
return null;
}
}
export function verifyNsisToolset(
nsisDir,
{ utilsSha1 = NSIS_TAURI_UTILS_SHA1 } = {},
) {
const missing = NSIS_REQUIRED_FILES.filter(
(relativePath) => !fs.existsSync(path.join(nsisDir, relativePath)),
);
const hashMismatch =
missing.length === 0 &&
sha1OfFile(path.join(nsisDir, NSIS_TAURI_UTILS_REQUIRED_FILE)) !==
utilsSha1;
return { ok: missing.length === 0 && !hashMismatch, missing, hashMismatch };
}
/** 解析 zip 条目落盘位置,并拒绝 `../` 这类越界路径。 */
export function resolveArchiveEntryTarget(rootDir, entryName) {
const root = path.resolve(rootDir);
const target = path.resolve(root, entryName);
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
throw new Error(`NSIS 归档包含越界路径:${entryName}`);
}
return target;
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function downloadBuffer(url, { fetchImpl, timeoutMs }) {
const response = await fetchImpl(url, {
redirect: 'follow',
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`.trim());
}
const data = Buffer.from(await response.arrayBuffer());
if (data.length === 0) throw new Error('响应为空');
return data;
}
async function downloadVerifiedAsset({
assetName,
url,
sha1,
env,
fetchImpl,
attempts,
retryDelayMs,
timeoutMs,
logger,
}) {
const downloadUrl = resolveDownloadUrl(url, env);
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const data = await downloadBuffer(downloadUrl, { fetchImpl, timeoutMs });
const actual = sha1Of(data);
if (actual !== sha1) {
throw new Error(`SHA1 不匹配(期望 ${sha1},实际 ${actual}`);
}
logger.log(
`${LOG_PREFIX} NSIS 工具链:已下载 ${assetName}${data.length} 字节,第 ${attempt} 次尝试)`,
);
return data;
} catch (error) {
lastError = error;
logger.warn(
`${LOG_PREFIX} NSIS 工具链:下载 ${assetName} 失败(第 ${attempt}/${attempts} 次):${error.message}`,
);
if (attempt < attempts) await sleep(retryDelayMs * attempt);
}
}
throw new Error(
`下载 ${assetName} 失败(已重试 ${attempts} 次):${lastError?.message ?? '未知错误'}\n` +
`下载地址:${downloadUrl}\n` +
`可先把该文件放入缓存目录(AGC_TAURI_NSIS_CACHE_DIR)或配置 ` +
`TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE 后重试。`,
);
}
async function ensureCachedAsset(options) {
const { assetName, sha1, cacheDir, logger } = options;
const cachePath = path.join(cacheDir, assetName);
if (sha1OfFile(cachePath) === sha1) {
logger.log(`${LOG_PREFIX} NSIS 工具链:命中缓存 ${cachePath}`);
return cachePath;
}
if (fs.existsSync(cachePath)) {
logger.warn(
`${LOG_PREFIX} NSIS 工具链:缓存文件校验失败,重新下载 ${cachePath}`,
);
}
const data = await downloadVerifiedAsset(options);
fs.mkdirSync(cacheDir, { recursive: true });
const tempPath = `${cachePath}.tmp-${process.pid}`;
fs.writeFileSync(tempPath, data);
fs.rmSync(cachePath, { force: true });
fs.renameSync(tempPath, cachePath);
return cachePath;
}
export async function extractNsisArchive(archivePath, destinationDir) {
const archive = await JSZip.loadAsync(fs.readFileSync(archivePath));
for (const [entryName, entry] of Object.entries(archive.files)) {
if (entry.dir) continue;
const target = resolveArchiveEntryTarget(destinationDir, entryName);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, await entry.async('nodebuffer'));
}
}
/**
* 预置 `target/.tauri/NSIS`:已就绪时零网络直接返回,否则用缓存或重试下载补齐。
* 返回结构用于测试与日志,不参与发布产物。
*/
export async function ensureNsisToolset({
appRoot = defaultAppRoot(),
toolsDir = resolveTauriToolsDir(appRoot),
cacheDir = resolveNsisCacheDir(process.env),
env = process.env,
fetchImpl = globalThis.fetch,
assets = NSIS_ASSETS,
attempts = DEFAULT_DOWNLOAD_ATTEMPTS,
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS,
logger = console,
} = {}) {
const nsisDir = path.join(toolsDir, NSIS_TOOLSET_DIR_NAME);
// 生产路径下这里恒等于 bundler 固定的 `nsis_tauri_utils.dll` SHA1;测试注入
// 自己的归档描述时,校验口径必须与被注入的资产一致。
const missingAsset = [
NSIS_ARCHIVE_ASSET_NAME,
NSIS_TAURI_UTILS_ASSET_NAME,
].find((assetName) => !assets.some((asset) => asset.assetName === assetName));
if (missingAsset) throw new Error(`NSIS 资产描述缺少 ${missingAsset}`);
const utilsSha1 =
assets.find((asset) => asset.assetName === NSIS_TAURI_UTILS_ASSET_NAME)
?.sha1 ?? NSIS_TAURI_UTILS_SHA1;
const existing = verifyNsisToolset(nsisDir, { utilsSha1 });
if (existing.ok) {
logger.log(`${LOG_PREFIX} NSIS 工具链已就绪:${nsisDir}`);
return { nsisDir, toolsDir, cacheDir, reused: true, downloaded: [] };
}
logger.log(
`${LOG_PREFIX} NSIS 工具链需要预置:${nsisDir}` +
(existing.missing.length > 0
? `(缺少 ${existing.missing.length} 个文件)`
: '(哈希不符)'),
);
const downloadOptions = {
env,
fetchImpl,
attempts,
retryDelayMs,
timeoutMs,
cacheDir,
logger,
};
const assetPaths = {};
for (const asset of assets) {
assetPaths[asset.assetName] = await ensureCachedAsset({
...asset,
...downloadOptions,
});
}
fs.rmSync(nsisDir, { recursive: true, force: true });
await extractNsisArchive(assetPaths[NSIS_ARCHIVE_ASSET_NAME], toolsDir);
const extractedDir = path.join(toolsDir, NSIS_ARCHIVE_TOP_LEVEL_DIR);
if (!fs.existsSync(extractedDir)) {
throw new Error(
`NSIS 归档结构不符合预期:${assetPaths[NSIS_ARCHIVE_ASSET_NAME]} 未解出 ${NSIS_ARCHIVE_TOP_LEVEL_DIR}`,
);
}
fs.renameSync(extractedDir, nsisDir);
const utilsTarget = path.join(nsisDir, NSIS_TAURI_UTILS_REQUIRED_FILE);
fs.mkdirSync(path.dirname(utilsTarget), { recursive: true });
fs.copyFileSync(assetPaths[NSIS_TAURI_UTILS_ASSET_NAME], utilsTarget);
const installed = verifyNsisToolset(nsisDir, { utilsSha1 });
if (!installed.ok) {
throw new Error(
`NSIS 工具链预置不完整:缺少 ${installed.missing.join(', ') || '无'}` +
`哈希不符=${installed.hashMismatch}`,
);
}
logger.log(`${LOG_PREFIX} NSIS 工具链预置完成:${nsisDir}`);
return {
nsisDir,
toolsDir,
cacheDir,
reused: false,
downloaded: assets.map((asset) => asset.assetName),
};
}
/** `buildRelease` 用:只在 Windows 目标且需要打包时预置 NSIS 工具链。 */
export async function prepareNsisToolsetForRelease(
context,
{ bundling = true, ...deps } = {},
) {
if (!bundling || !context.target.includes('windows')) return null;
return ensureNsisToolset(deps);
}
@@ -0,0 +1,331 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import JSZip from 'jszip';
import {
ensureNsisToolset,
extractNsisArchive,
NSIS_ARCHIVE_ASSET_NAME,
NSIS_ARCHIVE_TOP_LEVEL_DIR,
NSIS_REQUIRED_FILES,
NSIS_TAURI_UTILS_ASSET_NAME,
NSIS_TOOLSET_DIR_NAME,
prepareNsisToolsetForRelease,
resolveArchiveEntryTarget,
resolveDownloadUrl,
resolveNsisCacheDir,
resolveTauriToolsDir,
sha1Of,
verifyNsisToolset,
} from './nsis-toolset.mjs';
const appRoot = path.resolve(
path.dirname(new URL(import.meta.url).pathname),
'..',
);
const silentLogger = { log() {}, warn() {} };
function createSandbox() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'agc-nsis-toolset-'));
}
/** 与真实归档同构的最小 zip:只保留 bundler 必需文件。 */
async function createArchiveFixture(extraEntries = {}) {
const zip = new JSZip();
for (const relativePath of NSIS_REQUIRED_FILES) {
zip.file(
`${NSIS_ARCHIVE_TOP_LEVEL_DIR}/${relativePath}`,
`fixture:${relativePath}`,
);
}
for (const [name, contents] of Object.entries(extraEntries)) {
zip.file(name, contents);
}
return zip.generateAsync({ type: 'nodebuffer' });
}
function fixtureAssets({ archive, utils }) {
return [
{
assetName: NSIS_ARCHIVE_ASSET_NAME,
url: `https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/${NSIS_ARCHIVE_ASSET_NAME}`,
sha1: sha1Of(archive),
},
{
assetName: NSIS_TAURI_UTILS_ASSET_NAME,
url: `https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/${NSIS_TAURI_UTILS_ASSET_NAME}`,
sha1: sha1Of(utils),
},
];
}
function fixtureFetch({ archive, utils, failures = 0 }) {
let remainingFailures = failures;
const calls = [];
const fetchImpl = async (url) => {
calls.push(url);
if (remainingFailures > 0) {
remainingFailures -= 1;
throw new Error('network truncated');
}
const body = url.includes(NSIS_TAURI_UTILS_ASSET_NAME) ? utils : archive;
return {
ok: true,
status: 200,
statusText: 'OK',
arrayBuffer: async () => body,
};
};
return { fetchImpl, calls };
}
test('NSIS 工具链目录与 Tauri useLocalToolsDir 配置保持一致', () => {
const config = JSON.parse(
fs.readFileSync(
path.join(appRoot, 'src-tauri', 'tauri.windows.conf.json'),
'utf8',
),
);
assert.equal(config.bundle.useLocalToolsDir, true);
assert.equal(
resolveTauriToolsDir(appRoot),
path.join(appRoot, 'src-tauri', 'target', '.tauri'),
);
});
test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => {
assert.equal(
resolveNsisCacheDir(
{ AGC_TAURI_NSIS_CACHE_DIR: '/tmp/agc-cache' },
'linux',
),
'/tmp/agc-cache',
);
assert.equal(
resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'),
path.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'),
);
assert.ok(
resolveNsisCacheDir({}, 'linux').endsWith(
path.join('.cache', 'genarrative', 'tauri-nsis-cache'),
),
);
});
test('下载地址支持 tauri bundler 的两套 GitHub 镜像开关', () => {
const url =
'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip';
assert.equal(resolveDownloadUrl(url, {}), url);
assert.equal(
resolveDownloadUrl(url, {
TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE:
'https://mirror.example.com/<owner>/<repo>/<version>/<asset>',
}),
'https://mirror.example.com/tauri-apps/binary-releases/nsis-3.11/nsis-3.11.zip',
);
assert.equal(
resolveDownloadUrl(url, {
TAURI_BUNDLER_TOOLS_GITHUB_MIRROR: 'https://mirror.example.com/',
}),
`https://mirror.example.com/${url}`,
);
});
test('工具链已就绪时零下载复用', async () => {
const sandbox = createSandbox();
const toolsDir = path.join(sandbox, '.tauri');
const cacheDir = path.join(sandbox, 'cache');
const archive = await createArchiveFixture();
const utils = Buffer.from('nsis-tauri-utils-dll');
const assets = fixtureAssets({ archive, utils });
await ensureNsisToolset({
toolsDir,
cacheDir,
assets,
fetchImpl: fixtureFetch({ archive, utils }).fetchImpl,
logger: silentLogger,
});
let fetchCalls = 0;
const reused = await ensureNsisToolset({
toolsDir,
cacheDir,
assets,
fetchImpl: async () => {
fetchCalls += 1;
throw new Error('工具链已就绪时不应访问网络');
},
logger: silentLogger,
});
assert.equal(reused.reused, true);
assert.deepEqual(reused.downloaded, []);
assert.equal(fetchCalls, 0);
assert.equal(reused.nsisDir, path.join(toolsDir, NSIS_TOOLSET_DIR_NAME));
});
test('冷启动时下载、校验、解压并落缓存,重跑走缓存', async () => {
const sandbox = createSandbox();
const toolsDir = path.join(sandbox, '.tauri');
const cacheDir = path.join(sandbox, 'cache');
const archive = await createArchiveFixture();
const utils = Buffer.from('nsis-tauri-utils-dll');
const assets = fixtureAssets({ archive, utils });
const { fetchImpl, calls } = fixtureFetch({ archive, utils });
const result = await ensureNsisToolset({
toolsDir,
cacheDir,
assets,
fetchImpl,
logger: silentLogger,
});
assert.deepEqual(calls.length, 2);
assert.deepEqual(result.downloaded, [
NSIS_ARCHIVE_ASSET_NAME,
NSIS_TAURI_UTILS_ASSET_NAME,
]);
assert.equal(
verifyNsisToolset(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), {
utilsSha1: sha1Of(utils),
}).ok,
true,
);
assert.equal(
fs.readFileSync(
path.join(
toolsDir,
NSIS_TOOLSET_DIR_NAME,
'Plugins/x86-unicode/additional/nsis_tauri_utils.dll',
),
'utf8',
),
'nsis-tauri-utils-dll',
);
// 第二次构建:清空工作区工具目录后仍应零下载恢复(模拟 Jenkins git clean -fdx)。
fs.rmSync(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), {
recursive: true,
force: true,
});
const offline = await ensureNsisToolset({
toolsDir,
cacheDir,
assets,
fetchImpl: async () => {
throw new Error('命中缓存时不应访问网络');
},
logger: silentLogger,
});
assert.equal(
verifyNsisToolset(offline.nsisDir, { utilsSha1: sha1Of(utils) }).ok,
true,
);
});
test('下载失败按次数重试,最终成功', async () => {
const sandbox = createSandbox();
const archive = await createArchiveFixture();
const utils = Buffer.from('dll');
const { fetchImpl, calls } = fixtureFetch({ archive, utils, failures: 2 });
const result = await ensureNsisToolset({
toolsDir: path.join(sandbox, '.tauri'),
cacheDir: path.join(sandbox, 'cache'),
assets: fixtureAssets({ archive, utils }),
fetchImpl,
attempts: 3,
retryDelayMs: 1,
logger: silentLogger,
});
assert.equal(result.downloaded.length, 2);
assert.equal(calls.length, 4);
});
test('哈希不匹配时报错并给出可操作提示', async () => {
const sandbox = createSandbox();
const { fetchImpl } = fixtureFetch({
archive: Buffer.from('corrupted'),
utils: Buffer.from('corrupted'),
});
await assert.rejects(
ensureNsisToolset({
toolsDir: path.join(sandbox, '.tauri'),
cacheDir: path.join(sandbox, 'cache'),
assets: [
{
assetName: NSIS_ARCHIVE_ASSET_NAME,
url: 'https://github.com/a/b/releases/download/1/n.zip',
sha1: 'deadbeef',
},
{
assetName: NSIS_TAURI_UTILS_ASSET_NAME,
url: 'https://github.com/a/b/releases/download/1/n.dll',
sha1: 'deadbeef',
},
],
fetchImpl,
attempts: 2,
retryDelayMs: 1,
logger: silentLogger,
}),
/SHA1 不匹配/u,
);
assert.equal(
fs.existsSync(path.join(sandbox, 'cache', NSIS_ARCHIVE_ASSET_NAME)),
false,
);
});
test('归档越界路径与缺失必需文件都会失败关闭', async () => {
const sandbox = createSandbox();
assert.throws(
() =>
resolveArchiveEntryTarget(path.join(sandbox, 'extract'), '../escape.txt'),
/越界路径/u,
);
assert.equal(
resolveArchiveEntryTarget(path.join(sandbox, 'extract'), 'nsis-3.11/a/b'),
path.resolve(sandbox, 'extract', 'nsis-3.11/a/b'),
);
const emptyArchive = new JSZip()
.file(`${NSIS_ARCHIVE_TOP_LEVEL_DIR}/makensis.exe`, 'only-one')
.generateAsync({ type: 'nodebuffer' });
const emptyPath = path.join(sandbox, 'incomplete.zip');
fs.writeFileSync(emptyPath, await emptyArchive);
const toolsDir = path.join(sandbox, 'incomplete-tools');
await extractNsisArchive(emptyPath, toolsDir);
const status = verifyNsisToolset(
path.join(toolsDir, NSIS_ARCHIVE_TOP_LEVEL_DIR),
);
assert.equal(status.ok, false);
assert.ok(status.missing.includes('Bin/makensis.exe'));
});
test('非 Windows 目标或 --no-bundle 不预置工具链', async () => {
let called = 0;
const deps = {
ensure: async () => {
called += 1;
},
};
assert.equal(
await prepareNsisToolsetForRelease(
{ target: 'x86_64-pc-windows-msvc' },
{ bundling: false, ...deps },
),
null,
);
assert.equal(
await prepareNsisToolsetForRelease(
{ target: 'aarch64-apple-darwin' },
deps,
),
null,
);
assert.equal(called, 0);
});
@@ -5425,6 +5425,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- 处理:Windows 专用 Tauri 配置设置 `bundle.useLocalToolsDir: true`,把工具缓存到 `src-tauri/target/.tauri/NSIS`;Jenkins 预检验证实际用户、项目工具目录可写,并在构建失败时打印实际缓存路径和绝对路径执行结果。
- 验证:不要把 PATH 中 `makensis` 可发现当作 Tauri bundler 工具可执行的充分证据;需要在 Windows Agent 上检查 `target/.tauri/NSIS/makensis.exe`、ACL、EDR/Defender 和直接 `-VERSION` 结果。
## Tauri NSIS 工具链必须在打包前预置并重试(2026-09-21)
- 现象:AGC Windows 发布构建已完成 Rust releaseTauri 依次打印 `Downloading .../nsis-3.11.zip``Info extracting NSIS``Downloading .../nsis_tauri_utils.dll` 之后,直接以 ``failed to bundle project `io: unexpected end of file` `` 失败(退出码 1),安装包不会产出。
- 原因:tauri-bundler 的 `download_and_verify` 现场从 GitHub 取 NSIS 工具链,只有一次机会、没有重试;响应体被截断即报 `io: unexpected end of file`,看起来像打包错误其实是网络问题。Checkout 阶段的 `git clean -fdx` 每次都会清掉 `target/.tauri`,所以每个构建都要重新下载,在受限网络下必然反复失败。
- 处理:新增 `apps/ai-game-creator-shell/scripts/nsis-toolset.mjs``ensure-nsis-toolset.mjs`,在 `buildRelease`(Windows 目标且需要打包时)与 Jenkins `Tauri NSIS toolchain` 阶段按固定 SHA1 预置 `target/.tauri/NSIS`:原始归档带 4 次重试,缓存在工作区外的 `%ProgramData%\genarrative\tauri-nsis-cache`(可用 `AGC_TAURI_NSIS_CACHE_DIR` 覆盖),镜像开关沿用 bundler 的 `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE` / `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR`。该阶段同时执行 `makensis.exe -VERSION`,把 2026-09-02 记录的缓存目录不可执行问题也提前到编译之前暴露。Checkout 阶段改为 `git clean -fdx -e apps/ai-game-creator-shell/src-tauri/target/.tauri`:Tauri 的工具缓存位于工作区内,裸 `git clean -fdx` 会连它一起删,排除后同一节点的稳态构建不再需要联网,只有冷缓存(新节点、工作区重建)才下载。
- 验证:`node --test apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs`(已就绪零下载复用、缓存离线还原、失败重试、哈希不符与归档越界失败关闭)与 `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs`;真实节点上该阶段必须早于 Rust 编译失败关闭。
- 注意:不要改回 `bundle.useLocalToolsDir: false` 去用 `%LOCALAPPDATA%`,也不要依赖 PATH 里预装的 `makensis`;升级 `@tauri-apps/cli` 时同步核对归档 URL、SHA1 与必需文件清单。
## AGC 登录态续期必须同步本地运行时
- 模型目录 HTTP 请求与 DirectProject 的 Rust/app-server 使用同一账号,但凭据分别保存在 WebView 与 Rust / Runner;续期应复用 `requestPlatformSessionRefresh` 完成用户核验及本地会话安装,不能只写 localStorage。
@@ -147,6 +147,8 @@ revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH`
手工发布入口 `Genarrative-Manual-Build-And-Deploy``DEPLOY_TARGET` 与 AGC 更新渠道是两个独立维度。手工入口必须把 `release` 映射为 `AGC_UPDATE_CHANNEL=release`、把 `development` 映射为 `dev`,并把该参数同时透传给 `Genarrative-Agc-Windows-Build``Genarrative-Agc-MacOS-Build`;只传 `AGC_RELEASE_VERSION` 时下游会落回各自默认 `dev`。统一号 `0.1.95` 的 release 包应分别位于 `agc/release-win/0.1.95/``agc/release-mac/0.1.95/`,渠道清单为对应目录下的 `latest.json`,不存在 `agc/release/` 这一层。补发本轮已烧号的版本时,直接以相同 `AGC_RELEASE_VERSION` 重跑两条 AGC Job,不重新发号。
`Genarrative-Agc-Windows-Build``Tauri NSIS toolchain` 阶段必须在 Rust 编译前预置 NSIS 工具链并失败关闭:tauri-bundler 打包时现场从 GitHub 下载 `nsis-3.11.zip``nsis_tauri_utils.dll` 且不重试,构建机每次检出都会重下,响应一旦被截断就只能抛 `io: unexpected end of file`,让发布在编译数分钟后才失败。该阶段先跑 `node apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs`(固定 SHA1 校验、4 次重试、解压到 `target/.tauri/NSIS`),再执行 `makensis.exe -VERSION` 验证可执行性;Checkout 的 `git clean -fdx` 必须带 `-e apps/ai-game-creator-shell/src-tauri/target/.tauri`,只保留这份工具缓存、其余 `target/` 内容照常清空,否则工作区内缓存会被每个构建删掉,退回到「每次从 GitHub 重下」(实测裸 `git clean -fdx` 会输出 `Would remove apps/ai-game-creator-shell/src-tauri/target/`);原始归档缓存在工作区外的 `%ProgramData%\genarrative\tauri-nsis-cache`(可用 `AGC_TAURI_NSIS_CACHE_DIR` 覆盖),因此同一节点只有冷缓存才需要联网,离线补缓存时把这两个文件放进缓存目录即可;构建机确实无法访问 GitHub 时使用 bundler 自带的 `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE` / `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR` 指向可达镜像。升级 `@tauri-apps/cli` 时必须同步核对 `nsis-toolset.mjs` 里的归档地址、SHA1 与必需文件清单(与 tauri-bundler 的 `NSIS_REQUIRED_FILES` 逐条对齐),否则预置会被 bundler 判为不完整。
调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh``<triggers/>` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发。
Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。该阶段只能通过 `agent none` 和显式 `node(...)` 分配目标机,直接执行 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`;目标机不得 checkout Git、挂载 Git SSH 凭据或依赖 Jenkins workspace 源码。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。
@@ -41,7 +41,11 @@ pipeline {
if (Test-Path '.git') {
& $git fetch --tags --force --prune origin "+refs/heads/$($env:SOURCE_BRANCH):refs/remotes/origin/$($env:SOURCE_BRANCH)"
if ($LASTEXITCODE -ne 0) { throw 'Git fetch 失败' }
& $git clean -fdx
# Tauri 的 NSIS 工具缓存位于工作区内的 target/.tauri:裸 git clean -fdx
# 会把它一起删掉,导致每个构建都重新从 GitHub 下载工具链(下载无重试,
# 响应截断即 `io: unexpected end of file`)。这里只排除该缓存目录,
# 其余 target 内容照常清空。
& $git clean -fdx -e 'apps/ai-game-creator-shell/src-tauri/target/.tauri'
& $git reset --hard "origin/$($env:SOURCE_BRANCH)"
} else {
& $git clone --no-single-branch $env:GIT_REMOTE_URL .
@@ -144,6 +148,26 @@ pipeline {
}
}
stage('Tauri NSIS toolchain') {
steps {
withEnv(["PATH=${env.AGC_WINDOWS_PATH}"]) {
// 必须早于 Rust 编译与打包:Tauri bundler 自己下载 NSIS 工具链时不重试,
// 响应截断只会报 `io: unexpected end of file`,让发布在数分钟后才失败。
powershell '''
$ErrorActionPreference = 'Stop'
node --test apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs
if ($LASTEXITCODE -ne 0) { throw "NSIS 工具链回归测试失败,退出码=$LASTEXITCODE" }
node apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs
if ($LASTEXITCODE -ne 0) { throw "NSIS 工具链预置失败,退出码=$LASTEXITCODE" }
$nsis = Join-Path $PWD 'apps/ai-game-creator-shell/src-tauri/target/.tauri/NSIS/makensis.exe'
if (-not (Test-Path $nsis)) { throw "NSIS makensis.exe 缺失:$nsis" }
& $nsis -VERSION
if ($LASTEXITCODE -ne 0) { throw "NSIS makensis.exe 无法执行,退出码=$LASTEXITCODE" }
'''
}
}
}
stage('Build and upload') {
steps {
script {
+1
View File
@@ -138,6 +138,7 @@
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@types/three": "^0.184.1",
"jszip": "^3.10.1",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
"vitest": "^0.34.6"
+1
View File
@@ -178,6 +178,7 @@
"ai-game-creator-shell:dev-server": "npm --prefix apps/ai-game-creator-shell run dev-server",
"ai-game-creator-shell:build": "npm --prefix apps/ai-game-creator-shell run build --",
"ai-game-creator-shell:release:upload": "npm --prefix apps/ai-game-creator-shell run release:upload",
"ai-game-creator-shell:nsis:prepare": "npm --prefix apps/ai-game-creator-shell run nsis:prepare",
"ai-game-creator-shell:llm-status": "npm --prefix apps/ai-game-creator-shell run llm-status --",
"ai-game-creator-shell:agent-task": "npm --prefix apps/ai-game-creator-shell run agent-task --",
"agc:chat": "npm --prefix apps/ai-game-creator-shell run chat --",