a5fd25f10a
- 客户端接入 tauri-plugin-updater:原生侧注册插件,删除自研更新下载命令、下载进度事件与安装器启动逻辑 - 客户端更新服务与更新提示改走官方插件接口,删除自研清单解析、版本比较与下载实现 - 更新能力只授予客户端主窗口,移除只为自研清单放行的 OSS 白名单与 CSP 连接项 - 新增构建期更新检查开关:开发态默认关闭,agc 启动不请求更新清单、不显示更新入口 - 发布脚本按渠道生成官方更新插件清单与签名,universal macOS 产物同时挂两个平台键,缺签名失败关闭 - 发布脚本按渠道上传安装包、签名与渠道清单,并为 dev-win 生成旧协议 sha256 迁移清单 - 构建期按渠道注入更新端点配置,渠道与目标平台不匹配时发布失败关闭 - Jenkins 流水线新增渠道参数与签名凭据注入,归档补充签名与迁移清单 - 新增发布上传 dry-run 开关,只打印 ossutil 命令且不回显凭据 - 更新技术方案与开发运维文档,登记 macOS 渠道落地待办
458 lines
14 KiB
JavaScript
458 lines
14 KiB
JavaScript
import { spawnSync } from 'node:child_process';
|
||
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 {
|
||
defaultEditorFeatures,
|
||
withDefaultCargoFeatures,
|
||
} from './cargo-features.mjs';
|
||
|
||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
|
||
const releaseTarget =
|
||
process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget;
|
||
const bundleRoot = path.join(
|
||
appRoot,
|
||
'src-tauri',
|
||
'target',
|
||
releaseTarget,
|
||
'release',
|
||
'bundle',
|
||
);
|
||
const packageJsonPath = path.join(appRoot, 'package.json');
|
||
const rootPackageLockPath = path.resolve(appRoot, '../..', 'package-lock.json');
|
||
const tauriConfigPath = path.join(appRoot, 'src-tauri', 'tauri.conf.json');
|
||
const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml');
|
||
const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
|
||
const defaultOssBaseUrl =
|
||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
|
||
|
||
/**
|
||
* 发布渠道 → 目标平台。渠道名会进入 OSS 路径并烘焙进客户端端点,
|
||
* 一旦发布就不能改名(改名等于已发布客户端再也找不到更新)。
|
||
*/
|
||
const releaseChannels = {
|
||
'dev-win': 'windows',
|
||
'dev-mac': 'darwin',
|
||
};
|
||
|
||
function ossBaseUrl() {
|
||
return (
|
||
process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl
|
||
).replace(/\/+$/u, '');
|
||
}
|
||
|
||
function readPackageJson() {
|
||
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||
}
|
||
|
||
function readReleaseNotes() {
|
||
return process.env.AGC_UPDATE_RELEASE_NOTES?.trim() || '';
|
||
}
|
||
|
||
export function compareVersions(left, right) {
|
||
const leftParts = left.split('.').map(Number);
|
||
const rightParts = right.split('.').map(Number);
|
||
for (let index = 0; index < 3; index += 1) {
|
||
if (leftParts[index] !== rightParts[index]) {
|
||
return leftParts[index] > rightParts[index] ? 1 : -1;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function parseVersion(value, label) {
|
||
if (typeof value !== 'string' || !/^\d+\.\d+\.\d+$/u.test(value)) {
|
||
throw new Error(`${label} 不是有效的三段版本号:${String(value)}`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
export function nextPatchVersion(localVersion, remoteVersion) {
|
||
const local = parseVersion(localVersion, '本地版本');
|
||
const remote =
|
||
remoteVersion == null ? null : parseVersion(remoteVersion, 'OSS版本');
|
||
const base = remote && compareVersions(remote, local) > 0 ? remote : local;
|
||
const [major, minor, patch] = base.split('.').map(Number);
|
||
if (patch === Number.MAX_SAFE_INTEGER) {
|
||
throw new Error(`版本号 patch 已达到上限:${base}`);
|
||
}
|
||
return `${major}.${minor}.${patch + 1}`;
|
||
}
|
||
|
||
export function resolveReleasePlatform(target = releaseTarget) {
|
||
if (target.includes('windows')) return 'windows';
|
||
if (target.includes('apple-darwin')) return 'darwin';
|
||
if (target.includes('linux')) return 'linux';
|
||
throw new Error(`不支持的发布目标:${target}`);
|
||
}
|
||
|
||
export function resolveReleaseChannel(
|
||
env = process.env,
|
||
target = releaseTarget,
|
||
) {
|
||
const platform = resolveReleasePlatform(target);
|
||
const requested = env.AGC_UPDATE_CHANNEL?.trim();
|
||
if (requested) {
|
||
const channelPlatform = releaseChannels[requested];
|
||
if (!channelPlatform) {
|
||
throw new Error(
|
||
`未知发布渠道 ${requested};当前支持:${Object.keys(releaseChannels).join('、')}`,
|
||
);
|
||
}
|
||
if (channelPlatform !== platform) {
|
||
throw new Error(
|
||
`渠道 ${requested} 只能用于 ${channelPlatform} 目标,当前构建目标为 ${target}`,
|
||
);
|
||
}
|
||
return requested;
|
||
}
|
||
const defaultChannel = Object.entries(releaseChannels).find(
|
||
([, channelPlatform]) => channelPlatform === platform,
|
||
)?.[0];
|
||
if (!defaultChannel) {
|
||
throw new Error(
|
||
`目标 ${target} 没有默认发布渠道,请显式设置 AGC_UPDATE_CHANNEL`,
|
||
);
|
||
}
|
||
return defaultChannel;
|
||
}
|
||
|
||
export function updateManifestUrl(channel = resolveReleaseChannel()) {
|
||
return `${ossBaseUrl()}/${channel}/latest.json`;
|
||
}
|
||
|
||
/**
|
||
* 更新插件按运行时平台键查找清单条目:universal macOS 包同时挂
|
||
* `darwin-aarch64` 与 `darwin-x86_64`,单架构目标只挂对应键。
|
||
*/
|
||
export function resolveManifestPlatformKeys(target = releaseTarget) {
|
||
if (target === 'universal-apple-darwin') {
|
||
return ['darwin-aarch64', 'darwin-x86_64'];
|
||
}
|
||
if (target === 'aarch64-apple-darwin') return ['darwin-aarch64'];
|
||
if (target === 'x86_64-apple-darwin') return ['darwin-x86_64'];
|
||
if (target.includes('windows')) {
|
||
return [
|
||
target.startsWith('aarch64') ? 'windows-aarch64' : 'windows-x86_64',
|
||
];
|
||
}
|
||
throw new Error(`不支持的发布目标:${target}`);
|
||
}
|
||
|
||
async function readRemoteVersion(channel = resolveReleaseChannel()) {
|
||
const manifestUrl = updateManifestUrl(channel);
|
||
let response;
|
||
try {
|
||
response = await fetch(manifestUrl, {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
} catch (error) {
|
||
throw new Error(`读取 OSS 渠道清单失败:${error.message}`);
|
||
}
|
||
if (response.status === 404) return null;
|
||
if (!response.ok) {
|
||
throw new Error(`读取 OSS 渠道清单失败:HTTP ${response.status}`);
|
||
}
|
||
let manifest;
|
||
try {
|
||
manifest = await response.json();
|
||
} catch (error) {
|
||
throw new Error(`OSS 渠道清单不是有效 JSON:${error.message}`);
|
||
}
|
||
return parseVersion(manifest?.version, 'OSS渠道清单 version');
|
||
}
|
||
|
||
function replaceVersionLine(source, version, pattern, label) {
|
||
if (!pattern.test(source)) throw new Error(`未找到${label}版本字段`);
|
||
return source.replace(pattern, `$1${version}$3`);
|
||
}
|
||
|
||
export async function prepareReleaseVersion() {
|
||
const channel = resolveReleaseChannel();
|
||
const localVersion = parseVersion(readPackageJson().version, '本地版本');
|
||
const remoteVersion = await readRemoteVersion(channel);
|
||
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
|
||
const nextVersion = requestedVersion
|
||
? parseVersion(requestedVersion, '指定版本')
|
||
: nextPatchVersion(localVersion, remoteVersion);
|
||
|
||
const packageSource = fs.readFileSync(packageJsonPath, 'utf8');
|
||
fs.writeFileSync(
|
||
packageJsonPath,
|
||
replaceVersionLine(
|
||
packageSource,
|
||
nextVersion,
|
||
/("version"\s*:\s*")([^"]+)(")/u,
|
||
'package.json',
|
||
),
|
||
);
|
||
|
||
const rootPackageLockSource = fs.readFileSync(rootPackageLockPath, 'utf8');
|
||
fs.writeFileSync(
|
||
rootPackageLockPath,
|
||
replaceVersionLine(
|
||
rootPackageLockSource,
|
||
nextVersion,
|
||
/("apps\/ai-game-creator-shell"\s*:\s*\{\s*\n\s*"name"\s*:\s*"@genarrative\/ai-game-creator-shell"\s*,\s*\n\s*"version"\s*:\s*")([^"]+)(")/u,
|
||
'root package-lock.json',
|
||
),
|
||
);
|
||
|
||
const tauriSource = fs.readFileSync(tauriConfigPath, 'utf8');
|
||
fs.writeFileSync(
|
||
tauriConfigPath,
|
||
replaceVersionLine(
|
||
tauriSource,
|
||
nextVersion,
|
||
/("version"\s*:\s*")([^"]+)(")/u,
|
||
'tauri.conf.json',
|
||
),
|
||
);
|
||
|
||
const cargoSource = fs.readFileSync(cargoManifestPath, 'utf8');
|
||
fs.writeFileSync(
|
||
cargoManifestPath,
|
||
replaceVersionLine(
|
||
cargoSource,
|
||
nextVersion,
|
||
/(^\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/mu,
|
||
'Cargo.toml',
|
||
),
|
||
);
|
||
|
||
const cargoLockSource = fs.readFileSync(cargoLockPath, 'utf8');
|
||
fs.writeFileSync(
|
||
cargoLockPath,
|
||
replaceVersionLine(
|
||
cargoLockSource,
|
||
nextVersion,
|
||
/(^name\s*=\s*"genarrative-ai-game-creator-shell"\s*\nversion\s*=\s*")([^"]+)(")/mu,
|
||
'Cargo.lock',
|
||
),
|
||
);
|
||
|
||
console.log(
|
||
requestedVersion
|
||
? `[ai-game-creator-shell] 渠道 ${channel} 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})`
|
||
: `[ai-game-creator-shell] 渠道 ${channel} 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
|
||
);
|
||
return nextVersion;
|
||
}
|
||
|
||
export function buildTauriBuildArguments(
|
||
args = [],
|
||
target = releaseTarget,
|
||
platform = process.platform,
|
||
) {
|
||
const noBundle = args.includes('--no-bundle');
|
||
const targetIndex = args.indexOf('--target');
|
||
const explicitTarget =
|
||
targetIndex >= 0
|
||
? args[targetIndex + 1]
|
||
: args
|
||
.find((value) => value.startsWith('--target='))
|
||
?.slice('--target='.length);
|
||
const targetArgs = noBundle || explicitTarget ? [] : ['--target', target];
|
||
const features = defaultEditorFeatures(
|
||
explicitTarget || (noBundle ? platform : target),
|
||
);
|
||
return [
|
||
'build',
|
||
...withDefaultCargoFeatures([...targetArgs, ...args], features),
|
||
];
|
||
}
|
||
|
||
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
|
||
export function createChannelConfig(channel = resolveReleaseChannel()) {
|
||
return {
|
||
plugins: {
|
||
updater: {
|
||
endpoints: [updateManifestUrl(channel)],
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function writeChannelConfigFile(channel) {
|
||
const configPath = path.join(
|
||
os.tmpdir(),
|
||
`agc-tauri-channel-${channel}.json`,
|
||
);
|
||
fs.writeFileSync(
|
||
configPath,
|
||
`${JSON.stringify(createChannelConfig(channel), null, 2)}\n`,
|
||
);
|
||
return configPath;
|
||
}
|
||
|
||
export function runTauriBuild(args = []) {
|
||
const tauriArguments = buildTauriBuildArguments(args);
|
||
if (!tauriArguments.includes('--config') && !tauriArguments.includes('-c')) {
|
||
const channel = resolveReleaseChannel();
|
||
const configPath = writeChannelConfigFile(channel);
|
||
console.log(
|
||
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
|
||
);
|
||
tauriArguments.push('--config', configPath);
|
||
}
|
||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||
const result = spawnSync(
|
||
npmCommand,
|
||
['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments],
|
||
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
|
||
);
|
||
if (result.error) throw result.error;
|
||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||
}
|
||
|
||
function listFiles(root) {
|
||
if (!fs.existsSync(root)) return [];
|
||
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
||
const fullPath = path.join(root, entry.name);
|
||
return entry.isDirectory() ? listFiles(fullPath) : [fullPath];
|
||
});
|
||
}
|
||
|
||
function artifactPriority(filePath) {
|
||
const name = path.basename(filePath).toLowerCase();
|
||
if (releaseTarget.includes('windows')) return name.endsWith('.exe') ? 0 : 99;
|
||
// 更新链路要的是 updater 产物(macOS 为 .app.tar.gz),dmg 只作人工分发。
|
||
if (releaseTarget.includes('apple-darwin')) {
|
||
return name.endsWith('.app.tar.gz') ? 0 : 99;
|
||
}
|
||
if (name.endsWith('.appimage.tar.gz')) return 0;
|
||
if (name.endsWith('.appimage')) return 1;
|
||
if (name.endsWith('.deb')) return 2;
|
||
if (name.endsWith('.rpm')) return 3;
|
||
return 99;
|
||
}
|
||
|
||
export function selectReleaseArtifact(files) {
|
||
const explicit = process.env.AGC_UPDATE_ARTIFACT?.trim();
|
||
if (explicit) {
|
||
const resolved = path.resolve(explicit);
|
||
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
||
throw new Error(`AGC_UPDATE_ARTIFACT 不是有效文件:${resolved}`);
|
||
}
|
||
return resolved;
|
||
}
|
||
return (
|
||
[...files]
|
||
.filter((filePath) => artifactPriority(filePath) < 99)
|
||
.sort((left, right) => {
|
||
const priority = artifactPriority(left) - artifactPriority(right);
|
||
return priority || left.localeCompare(right);
|
||
})[0] ?? null
|
||
);
|
||
}
|
||
|
||
function readUpdaterSignature(artifactPath) {
|
||
const signaturePath = `${artifactPath}.sig`;
|
||
if (!fs.existsSync(signaturePath)) {
|
||
throw new Error(
|
||
`缺少更新包签名:${signaturePath};需要 bundle.createUpdaterArtifacts 与签名私钥(TAURI_SIGNING_PRIVATE_KEY / TAURI_SIGNING_PRIVATE_KEY_PATH)`,
|
||
);
|
||
}
|
||
const signature = fs.readFileSync(signaturePath, 'utf8').trim();
|
||
if (!signature) throw new Error(`更新包签名为空:${signaturePath}`);
|
||
return signature;
|
||
}
|
||
|
||
export function createUpdateManifest(
|
||
artifactPath,
|
||
{
|
||
channel = resolveReleaseChannel(),
|
||
target = releaseTarget,
|
||
publishedAt = new Date().toISOString(),
|
||
} = {},
|
||
) {
|
||
const signature = readUpdaterSignature(artifactPath);
|
||
const version = readPackageJson().version;
|
||
const fileName = path.basename(artifactPath);
|
||
const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
|
||
const platforms = {};
|
||
for (const key of resolveManifestPlatformKeys(target)) {
|
||
platforms[key] = { signature, url };
|
||
}
|
||
const notes = readReleaseNotes();
|
||
return {
|
||
version,
|
||
...(notes ? { notes } : {}),
|
||
pub_date: publishedAt,
|
||
platforms,
|
||
};
|
||
}
|
||
|
||
/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */
|
||
export function createLegacyUpdateManifest(
|
||
artifactPath,
|
||
{ channel = resolveReleaseChannel() } = {},
|
||
) {
|
||
const bytes = fs.readFileSync(artifactPath);
|
||
const version = readPackageJson().version;
|
||
const fileName = path.basename(artifactPath);
|
||
const notes = readReleaseNotes();
|
||
return {
|
||
version,
|
||
downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`,
|
||
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||
size: bytes.length,
|
||
...(notes ? { releaseNotes: notes } : {}),
|
||
};
|
||
}
|
||
|
||
export function generateUpdateManifest() {
|
||
const channel = resolveReleaseChannel();
|
||
const artifact = selectReleaseArtifact(listFiles(bundleRoot));
|
||
if (!artifact) {
|
||
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
|
||
}
|
||
const manifest = createUpdateManifest(artifact, { channel });
|
||
const manifestPath = path.join(bundleRoot, 'latest.json');
|
||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||
const legacyManifest =
|
||
channel === 'dev-win'
|
||
? createLegacyUpdateManifest(artifact, { channel })
|
||
: null;
|
||
const legacyManifestPath = legacyManifest
|
||
? path.join(bundleRoot, 'legacy-latest.json')
|
||
: null;
|
||
if (legacyManifest && legacyManifestPath) {
|
||
fs.writeFileSync(
|
||
legacyManifestPath,
|
||
`${JSON.stringify(legacyManifest, null, 2)}\n`,
|
||
);
|
||
}
|
||
console.log(
|
||
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
|
||
);
|
||
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
|
||
if (legacyManifestPath) {
|
||
console.log(
|
||
`[ai-game-creator-shell] 旧协议迁移清单:${legacyManifestPath}`,
|
||
);
|
||
}
|
||
return {
|
||
channel,
|
||
artifact,
|
||
manifest,
|
||
manifestPath,
|
||
legacyManifest,
|
||
legacyManifestPath,
|
||
};
|
||
}
|
||
|
||
if (
|
||
process.argv[1] &&
|
||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||
) {
|
||
const args = process.argv.slice(2);
|
||
if (!args.includes('--no-bundle')) await prepareReleaseVersion();
|
||
runTauriBuild(args);
|
||
if (!args.includes('--no-bundle')) generateUpdateManifest();
|
||
}
|