Files
Genarrative/apps/ai-game-creator-shell/scripts/nsis-toolset.mjs
T
kdletters 33036b65fc
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
修复 AGC Windows 构建的 Tauri NSIS 工具链重复下载与截断失败
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 排障记录
2026-09-21 15:59:30 +08:00

343 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}