构建时自动生成 AGC 更新清单
Project CI / Repository checks (pull_request) Failing after 16s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 3m6s
Project CI / Native shell tests (pull_request) Failing after 16m48s

新增发布构建包装脚本并扫描平台安装包
自动计算下载地址、文件大小和 SHA-256
补充构建脚本测试与发布文档
This commit is contained in:
2026-08-31 14:55:57 +08:00
parent da76cb9056
commit 0899118fe1
4 changed files with 147 additions and 2 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "node scripts/start-tauri-dev.mjs",
"dev-server": "node scripts/start-dev-server.mjs",
"dev-stack": "node scripts/start-dev-stack.mjs",
"build": "npm --prefix ../.. exec tauri -- build",
"build": "node scripts/build-release.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",
@@ -0,0 +1,107 @@
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const bundleRoot = path.join(
appRoot,
'src-tauri',
'target',
'release',
'bundle',
);
const packageJson = JSON.parse(
fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'),
);
const defaultOssBaseUrl =
'https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/agc';
function runTauriBuild(args) {
const result = spawnSync(
'npm',
['--prefix', '../..', 'exec', 'tauri', '--', 'build', ...args],
{ cwd: appRoot, stdio: 'inherit' },
);
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 (process.platform === 'win32') return name.endsWith('.exe') ? 0 : 99;
if (process.platform === 'darwin') return name.endsWith('.dmg') ? 0 : 99;
if (name.endsWith('.appimage')) return 0;
if (name.endsWith('.deb')) return 1;
if (name.endsWith('.rpm')) return 2;
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
);
}
export function createUpdateManifest(artifactPath) {
const bytes = fs.readFileSync(artifactPath);
const version = packageJson.version;
const fileName = path.basename(artifactPath);
const baseUrl = (
process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl
).replace(/\/+$/u, '');
const encodedFileName = encodeURIComponent(fileName).replace(/%2F/giu, '/');
return {
version,
downloadUrl: `${baseUrl}/${encodeURIComponent(version)}/${encodedFileName}`,
sha256: createHash('sha256').update(bytes).digest('hex'),
size: bytes.length,
...(process.env.AGC_UPDATE_RELEASE_NOTES?.trim()
? { releaseNotes: process.env.AGC_UPDATE_RELEASE_NOTES.trim() }
: {}),
};
}
export function generateUpdateManifest() {
const artifact = selectReleaseArtifact(listFiles(bundleRoot));
if (!artifact) {
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
}
const manifest = createUpdateManifest(artifact);
const manifestPath = path.join(bundleRoot, 'latest.json');
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
console.log(`[ai-game-creator-shell] 已生成 ${manifestPath}`);
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
return { artifact, manifestPath, manifest };
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
const args = process.argv.slice(2);
runTauriBuild(args);
if (!args.includes('--no-bundle')) generateUpdateManifest();
}
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
createUpdateManifest,
selectReleaseArtifact,
} from './build-release.mjs';
test('selects an explicit release artifact when configured', () => {
const artifactPath = new URL('../package.json', import.meta.url).pathname;
const previous = process.env.AGC_UPDATE_ARTIFACT;
process.env.AGC_UPDATE_ARTIFACT = artifactPath;
try {
assert.equal(selectReleaseArtifact([]), artifactPath);
} finally {
if (previous === undefined) delete process.env.AGC_UPDATE_ARTIFACT;
else process.env.AGC_UPDATE_ARTIFACT = previous;
}
});
test('does not select unsupported files', () => {
assert.equal(
selectReleaseArtifact(['/tmp/latest.json', '/tmp/readme.txt']),
null,
);
});
test('manifest contains version, download URL and integrity fields', () => {
const manifest = createUpdateManifest(
new URL('../package.json', import.meta.url).pathname,
);
assert.equal(manifest.version, '0.1.12');
assert.match(manifest.downloadUrl, /\/agc\/0\.1\.12\/package\.json$/u);
assert.equal(manifest.sha256.length, 64);
assert.equal(typeof manifest.size, 'number');
});
@@ -29,4 +29,6 @@ AGC 每次启动时由根窗口检查一次公开 OSS 更新清单。清单默
## 发布约定
每次发布安装包上传完成后,再上传同一版本的 `agc/latest.json`,确保 `downloadUrl` 指向已存在的 OSS 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径
执行 `npm run ai-game-creator-shell:build` 会在 Tauri 构建完成后自动扫描平台安装包,并在 `apps/ai-game-creator-shell/src-tauri/target/release/bundle/latest.json` 生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定 OSS 前缀,通过 `AGC_UPDATE_RELEASE_NOTES` 写入发布说明;`--no-bundle` smoke 构建不会生成清单
每次发布安装包上传完成后,再上传同一目录生成的 `latest.json`,确保 `downloadUrl` 指向已存在的 OSS 对象;清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本不自动上传 OSS,避免把 AccessKey 写入本地配置或 CI 日志。