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
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 排障记录
343 lines
12 KiB
JavaScript
343 lines
12 KiB
JavaScript
// 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);
|
||
}
|