Merge branch 'master' into fix/401
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from './cargo-features.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
|
||||
const repoRoot = path.resolve(appRoot, '..', '..');
|
||||
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
|
||||
const releaseTarget =
|
||||
process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget;
|
||||
@@ -39,6 +41,20 @@ const releaseChannels = {
|
||||
'dev-mac': 'darwin',
|
||||
};
|
||||
|
||||
/**
|
||||
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
|
||||
* 保持一致 —— `build-release.test.mjs` 有守卫用例逐条比对两边。
|
||||
*/
|
||||
export const agcReleasePathPatterns = [
|
||||
'apps/ai-game-creator-shell/',
|
||||
'packages/',
|
||||
'server-rs/crates/',
|
||||
'plugins/agc-cocos-editor/',
|
||||
'apps/desktop-shell/src-tauri/icons/',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
];
|
||||
|
||||
function ossBaseUrl() {
|
||||
return (
|
||||
process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl
|
||||
@@ -148,7 +164,7 @@ function legacyBridgeManifestUrl() {
|
||||
return `${ossBaseUrl()}/latest.json`;
|
||||
}
|
||||
|
||||
async function readManifestVersion(manifestUrl, label) {
|
||||
async function fetchManifest(manifestUrl, label) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(manifestUrl, {
|
||||
@@ -161,13 +177,52 @@ async function readManifestVersion(manifestUrl, label) {
|
||||
if (!response.ok) {
|
||||
throw new Error(`读取 ${label} 失败:HTTP ${response.status}`);
|
||||
}
|
||||
let manifest;
|
||||
try {
|
||||
manifest = await response.json();
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`${label} 不是有效 JSON:${error.message}`);
|
||||
}
|
||||
return parseVersion(manifest?.version, `${label} version`);
|
||||
}
|
||||
|
||||
async function readManifestVersion(manifestUrl, label) {
|
||||
const manifest = await fetchManifest(manifestUrl, label);
|
||||
return manifest == null
|
||||
? null
|
||||
: parseVersion(manifest?.version, `${label} version`);
|
||||
}
|
||||
|
||||
/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */
|
||||
async function readRemoteChannelManifest(channel = resolveReleaseChannel()) {
|
||||
return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单');
|
||||
}
|
||||
|
||||
/**
|
||||
* 摘要锚点:上次发布对应的提交。
|
||||
*
|
||||
* 首选渠道清单里的 `commit`(发布产物自己的事实来源);清单缺该字段时(首次启用
|
||||
* 摘要、或更换渠道后清单还没带过 commit)回退到 CI 传入的 `AGC_UPDATE_PREVIOUS_COMMIT`
|
||||
* —— 它是上一次成功构建的 COMMIT_HASH,同样指向用户拿到的那个版本。
|
||||
*/
|
||||
export async function resolvePreviousReleaseCommit(
|
||||
channel = resolveReleaseChannel(),
|
||||
{ override = process.env.AGC_UPDATE_PREVIOUS_COMMIT } = {},
|
||||
) {
|
||||
const explicit = override?.trim();
|
||||
if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
try {
|
||||
const manifest = await readRemoteChannelManifest(channel);
|
||||
const commit =
|
||||
typeof manifest?.commit === 'string' ? manifest.commit.trim() : '';
|
||||
return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null;
|
||||
} catch (error) {
|
||||
// 摘要只是附注:清单读不到(网络抖动等)不能把发布带崩,降级为「没有锚点」。
|
||||
console.warn(
|
||||
`[ai-game-creator-shell] 读取摘要锚点失败,本次不写自动摘要:${error.message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -398,6 +453,8 @@ export function createUpdateManifest(
|
||||
channel = resolveReleaseChannel(),
|
||||
target = releaseTarget,
|
||||
publishedAt = new Date().toISOString(),
|
||||
notes = readReleaseNotes(),
|
||||
commit = readHeadCommit(),
|
||||
} = {},
|
||||
) {
|
||||
const signature = readUpdaterSignature(artifactPath);
|
||||
@@ -408,24 +465,136 @@ export function createUpdateManifest(
|
||||
for (const key of resolveManifestPlatformKeys(target)) {
|
||||
platforms[key] = { signature, url };
|
||||
}
|
||||
const notes = readReleaseNotes();
|
||||
return {
|
||||
version,
|
||||
...(notes ? { notes } : {}),
|
||||
pub_date: publishedAt,
|
||||
platforms,
|
||||
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
|
||||
...(commit ? { commit } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readHeadCommit() {
|
||||
try {
|
||||
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上一次发布到本次之间的客户端相关提交。
|
||||
*
|
||||
* 返回 null 表示无法判定(没有上一次 commit,或本地没有该提交),此时不生成摘要。
|
||||
*/
|
||||
export function collectReleaseCommits(
|
||||
previousCommit,
|
||||
headCommit = 'HEAD',
|
||||
{ cwd = repoRoot, paths = agcReleasePathPatterns } = {},
|
||||
) {
|
||||
if (!previousCommit) return null;
|
||||
try {
|
||||
for (const revision of [previousCommit, headCommit]) {
|
||||
execFileSync('git', ['rev-parse', '--verify', `${revision}^{commit}`], {
|
||||
cwd,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let output;
|
||||
try {
|
||||
output = execFileSync(
|
||||
'git',
|
||||
[
|
||||
'log',
|
||||
'--no-merges',
|
||||
'--format=%h%x09%s',
|
||||
`${previousCommit}..${headCommit}`,
|
||||
'--',
|
||||
...paths,
|
||||
],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return output
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [sha = '', ...subject] = line.split('\t');
|
||||
return { sha, subject: subject.join('\t') };
|
||||
});
|
||||
}
|
||||
|
||||
/** 自动更新摘要:逐条列客户端相关改动,超过上限时折叠并整体截断。 */
|
||||
export function formatReleaseNotes(
|
||||
commits,
|
||||
{ limit = 12, subjectLength = 80, maxLength = 900 } = {},
|
||||
) {
|
||||
if (!commits || commits.length === 0) return '';
|
||||
const lines = commits.slice(0, limit).map(({ sha, subject }) => {
|
||||
const trimmed =
|
||||
subject.length > subjectLength
|
||||
? `${subject.slice(0, subjectLength - 1)}…`
|
||||
: subject;
|
||||
return `- ${trimmed}(${sha})`;
|
||||
});
|
||||
if (commits.length > limit) {
|
||||
lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`);
|
||||
}
|
||||
const text = lines.join('\n');
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
|
||||
}
|
||||
|
||||
/** 无锚点时的兜底:列出最近的客户端相关提交,并注明可能与上一版重复。 */
|
||||
export function collectRecentReleaseCommits({
|
||||
cwd = repoRoot,
|
||||
paths = agcReleasePathPatterns,
|
||||
limit = 8,
|
||||
} = {}) {
|
||||
let output;
|
||||
try {
|
||||
output = execFileSync(
|
||||
'git',
|
||||
['log', '--no-merges', `-n${limit}`, '--format=%h%x09%s', '--', ...paths],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const commits = output
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [sha = '', ...subject] = line.split('\t');
|
||||
return { sha, subject: subject.join('\t') };
|
||||
});
|
||||
return commits.length > 0 ? commits : null;
|
||||
}
|
||||
|
||||
export function formatRecentReleaseNotes(commits) {
|
||||
const notes = formatReleaseNotes(commits, { limit: 8 });
|
||||
if (!notes) return '';
|
||||
return `最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n${notes}`;
|
||||
}
|
||||
|
||||
/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */
|
||||
export function createLegacyUpdateManifest(
|
||||
artifactPath,
|
||||
{ channel = resolveReleaseChannel() } = {},
|
||||
{ channel = resolveReleaseChannel(), notes = readReleaseNotes() } = {},
|
||||
) {
|
||||
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)}`,
|
||||
@@ -435,18 +604,36 @@ export function createLegacyUpdateManifest(
|
||||
};
|
||||
}
|
||||
|
||||
export function generateUpdateManifest() {
|
||||
export async function generateUpdateManifest() {
|
||||
const channel = resolveReleaseChannel();
|
||||
const artifact = selectReleaseArtifact(listFiles(bundleRoot));
|
||||
if (!artifact) {
|
||||
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
|
||||
}
|
||||
const manifest = createUpdateManifest(artifact, { channel });
|
||||
const manualNotes = readReleaseNotes();
|
||||
const previousCommit = await resolvePreviousReleaseCommit(channel);
|
||||
const commits = collectReleaseCommits(previousCommit);
|
||||
const recentCommits = previousCommit ? null : collectRecentReleaseCommits();
|
||||
const notes =
|
||||
manualNotes ||
|
||||
formatReleaseNotes(commits) ||
|
||||
formatRecentReleaseNotes(recentCommits);
|
||||
if (!manualNotes && !notes) {
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`,
|
||||
);
|
||||
}
|
||||
const manifest = createUpdateManifest(artifact, { channel, notes });
|
||||
const manifestPath = path.join(bundleRoot, 'latest.json');
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
const notesPath = path.join(bundleRoot, 'release-notes.txt');
|
||||
fs.writeFileSync(
|
||||
notesPath,
|
||||
notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n',
|
||||
);
|
||||
const legacyManifest =
|
||||
channel === 'dev-win'
|
||||
? createLegacyUpdateManifest(artifact, { channel })
|
||||
? createLegacyUpdateManifest(artifact, { channel, notes })
|
||||
: null;
|
||||
const legacyManifestPath = legacyManifest
|
||||
? path.join(bundleRoot, 'legacy-latest.json')
|
||||
@@ -461,6 +648,14 @@ export function generateUpdateManifest() {
|
||||
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
|
||||
);
|
||||
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
|
||||
console.log(
|
||||
manualNotes
|
||||
? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案'
|
||||
: notes && !previousCommit
|
||||
? `[ai-game-creator-shell] 更新摘要:无锚点,列出最近 ${recentCommits ? recentCommits.length : 0} 条客户端相关提交`
|
||||
: `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'})`,
|
||||
);
|
||||
console.log(`[ai-game-creator-shell] 更新摘要文件:${notesPath}`);
|
||||
if (legacyManifestPath) {
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 旧协议迁移清单:${legacyManifestPath}`,
|
||||
@@ -471,6 +666,10 @@ export function generateUpdateManifest() {
|
||||
artifact,
|
||||
manifest,
|
||||
manifestPath,
|
||||
notes,
|
||||
notesPath,
|
||||
previousCommit,
|
||||
commits,
|
||||
legacyManifest,
|
||||
legacyManifestPath,
|
||||
};
|
||||
@@ -483,5 +682,5 @@ if (
|
||||
const args = process.argv.slice(2);
|
||||
if (!args.includes('--no-bundle')) await prepareReleaseVersion();
|
||||
runTauriBuild(args);
|
||||
if (!args.includes('--no-bundle')) generateUpdateManifest();
|
||||
if (!args.includes('--no-bundle')) await generateUpdateManifest();
|
||||
}
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
agcReleasePathPatterns,
|
||||
collectRecentReleaseCommits,
|
||||
collectReleaseCommits,
|
||||
compareVersions,
|
||||
createChannelConfig,
|
||||
createLegacyUpdateManifest,
|
||||
createUpdateManifest,
|
||||
formatRecentReleaseNotes,
|
||||
formatReleaseNotes,
|
||||
nextPatchVersion,
|
||||
resolveManifestPlatformKeys,
|
||||
resolvePreviousReleaseCommit,
|
||||
resolveReleaseChannel,
|
||||
resolveRemoteHighWaterVersion,
|
||||
selectReleaseArtifact,
|
||||
@@ -231,6 +244,96 @@ test('version high water ignores the windows migration pointer for other channel
|
||||
);
|
||||
});
|
||||
|
||||
test('release notes anchor prefers the explicit commit and falls back to the manifest', async () => {
|
||||
await withStubbedFetch(
|
||||
() => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }),
|
||||
async () => {
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev-win', {
|
||||
override: '6017d46088c04199e99cf89f347b12d67591475e',
|
||||
}),
|
||||
'6017d46088c04199e99cf89f347b12d67591475e',
|
||||
);
|
||||
// 覆盖值非法时忽略,继续用清单里的 commit。
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev-win', {
|
||||
override: 'not-a-sha',
|
||||
}),
|
||||
'abcdef1234567890',
|
||||
);
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev-win', { override: ' ' }),
|
||||
'abcdef1234567890',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await withStubbedFetch(
|
||||
() => jsonResponse({ version: '0.1.61' }),
|
||||
async () => {
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
|
||||
null,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('release notes anchor degrades to null when the manifest cannot be read', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error('fetch failed');
|
||||
};
|
||||
try {
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('recent commit fallback marks that entries may repeat the previous release', () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-recent-git-'));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: directory, encoding: 'utf8' });
|
||||
try {
|
||||
git('init', '--quiet');
|
||||
git('config', 'user.email', 'release@example.test');
|
||||
git('config', 'user.name', 'release test');
|
||||
mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), {
|
||||
recursive: true,
|
||||
});
|
||||
for (const name of ['one', 'two']) {
|
||||
writeFileSync(
|
||||
path.join(directory, `apps/ai-game-creator-shell/${name}.rs`),
|
||||
`fn ${name}() {}\n`,
|
||||
);
|
||||
git('add', '.');
|
||||
git('commit', '--quiet', '-m', `客户端:${name}`);
|
||||
}
|
||||
writeFileSync(path.join(directory, 'README.md'), '# 文档\n');
|
||||
git('add', '.');
|
||||
git('commit', '--quiet', '-m', '文档:说明');
|
||||
|
||||
const recent = collectRecentReleaseCommits({ cwd: directory, limit: 5 });
|
||||
assert.deepEqual(
|
||||
recent.map((entry) => entry.subject),
|
||||
['客户端:two', '客户端:one'],
|
||||
);
|
||||
const notes = formatRecentReleaseNotes(recent);
|
||||
assert.match(
|
||||
notes,
|
||||
/^最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n- 客户端:two/u,
|
||||
);
|
||||
assert.equal(formatRecentReleaseNotes([]), '');
|
||||
assert.equal(formatRecentReleaseNotes(null), '');
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('release upload forces overwrite for artifact, signature and channel pointers', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./release-upload.mjs', import.meta.url),
|
||||
@@ -243,3 +346,141 @@ test('release upload forces overwrite for artifact, signature and channel pointe
|
||||
assert.match(source, /agc\/\$\{channel\}\/latest\.json/u);
|
||||
assert.match(source, /agc\/latest\.json/u);
|
||||
});
|
||||
|
||||
test('release notes list client commits with short sha and bound their size', () => {
|
||||
const notes = formatReleaseNotes([
|
||||
{ sha: 'a5fd25f1', subject: '客户端更新切换到官方更新插件' },
|
||||
{ sha: '55af6014', subject: '修'.repeat(120) },
|
||||
]);
|
||||
const lines = notes.split('\n');
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[0], /^- 客户端更新切换到官方更新插件(a5fd25f1)$/u);
|
||||
const truncatedSubject = lines[1]
|
||||
.replace(/^- /u, '')
|
||||
.replace(/(55af6014)$/u, '');
|
||||
assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`);
|
||||
assert.match(truncatedSubject, /…$/u);
|
||||
assert.match(lines[1], /(55af6014)$/u);
|
||||
|
||||
const many = formatReleaseNotes(
|
||||
Array.from({ length: 20 }, (_, index) => ({
|
||||
sha: `sha${index}`,
|
||||
subject: `改动 ${index}`,
|
||||
})),
|
||||
);
|
||||
assert.match(many, /- 其余 8 项客户端改动省略$/u);
|
||||
assert.equal(formatReleaseNotes([]), '');
|
||||
assert.equal(formatReleaseNotes(null), '');
|
||||
});
|
||||
|
||||
test('release commits cover only client paths and skip merge commits', () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-changelog-git-'));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: directory, encoding: 'utf8' });
|
||||
try {
|
||||
git('init', '--quiet');
|
||||
git('config', 'user.email', 'release@example.test');
|
||||
git('config', 'user.name', 'release test');
|
||||
git('commit', '--allow-empty', '--quiet', '-m', '基点');
|
||||
const base = git('rev-parse', 'HEAD').trim();
|
||||
|
||||
mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), {
|
||||
recursive: true,
|
||||
});
|
||||
mkdirSync(path.join(directory, 'docs'), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(directory, 'apps/ai-game-creator-shell/main.rs'),
|
||||
'fn main() {}\n',
|
||||
);
|
||||
git('add', '.');
|
||||
git('commit', '--quiet', '-m', '客户端:新增更新插件接入');
|
||||
|
||||
writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n');
|
||||
git('add', '.');
|
||||
git('commit', '--quiet', '-m', '文档:补充说明');
|
||||
|
||||
writeFileSync(
|
||||
path.join(directory, 'apps/ai-game-creator-shell/other.rs'),
|
||||
'fn other() {}\n',
|
||||
);
|
||||
git('add', '.');
|
||||
git('commit', '--quiet', '-m', '客户端:修复版本回退');
|
||||
|
||||
git('checkout', '--quiet', '-b', 'side');
|
||||
writeFileSync(
|
||||
path.join(directory, 'apps/ai-game-creator-shell/side.rs'),
|
||||
'fn side() {}\n',
|
||||
);
|
||||
git('add', '.');
|
||||
git('commit', '--quiet', '-m', '客户端:侧分支改动');
|
||||
git('checkout', '--quiet', 'master');
|
||||
git('merge', '--quiet', '--no-ff', '--no-edit', 'side');
|
||||
|
||||
const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory });
|
||||
assert.ok(commits, '应能在临时仓库里收集提交');
|
||||
const subjects = commits.map((entry) => entry.subject);
|
||||
// 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。
|
||||
assert.deepEqual(subjects, [
|
||||
'客户端:侧分支改动',
|
||||
'客户端:修复版本回退',
|
||||
'客户端:新增更新插件接入',
|
||||
]);
|
||||
assert.ok(commits.every((entry) => /^[0-9a-f]{7,}$/u.test(entry.sha)));
|
||||
|
||||
assert.equal(
|
||||
collectReleaseCommits('1234567890abcdef', 'HEAD', { cwd: directory }),
|
||||
null,
|
||||
);
|
||||
assert.equal(collectReleaseCommits(null, 'HEAD', { cwd: directory }), null);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('scheduler path filter stays in sync with client release paths', () => {
|
||||
const jenkinsfile = readFileSync(
|
||||
new URL(
|
||||
'../../../jenkins/Jenkinsfile.scheduled-revision-trigger',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const filterLine = jenkinsfile
|
||||
.split('\n')
|
||||
.find((line) => line.includes('apps/ai-game-creator-shell/*|'));
|
||||
assert.ok(filterLine, '调度管线里应存在发布范围过滤模式');
|
||||
for (const pattern of agcReleasePathPatterns) {
|
||||
const bashPattern = pattern.includes('/') ? `${pattern}*` : pattern;
|
||||
assert.ok(
|
||||
filterLine.includes(bashPattern),
|
||||
`调度管线过滤缺少 ${bashPattern}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('scheduler skips the full build only for non-deploy paths', () => {
|
||||
const jenkinsfile = readFileSync(
|
||||
new URL(
|
||||
'../../../jenkins/Jenkinsfile.scheduled-revision-trigger',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const skipLine = jenkinsfile
|
||||
.split('\n')
|
||||
.find((line) => line.includes('docs/*|.codex/*|jenkins/*'));
|
||||
assert.ok(skipLine, '调度管线里应存在 Full Build 跳过模式');
|
||||
for (const pattern of [
|
||||
'docs/*',
|
||||
'.codex/*',
|
||||
'jenkins/*',
|
||||
'apps/ai-game-creator-shell/*',
|
||||
'apps/mobile-shell/*',
|
||||
'apps/desktop-shell/*',
|
||||
'apps/preview-deployer-web/*',
|
||||
'tools/*',
|
||||
'*.md',
|
||||
]) {
|
||||
assert.ok(skipLine.includes(pattern), `Full Build 跳过模式缺少 ${pattern}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ function runOssutil(args) {
|
||||
await prepareReleaseVersion();
|
||||
runTauriBuild([]);
|
||||
const { artifact, channel, legacyManifestPath, manifest, manifestPath } =
|
||||
generateUpdateManifest();
|
||||
await generateUpdateManifest();
|
||||
const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`;
|
||||
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
|
||||
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
|
||||
|
||||
@@ -92,6 +92,8 @@
|
||||
- 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。
|
||||
- 渠道由构建参数显式指定,并按目标平台校验:Windows 目标只允许 `dev-win`,macOS 目标只允许 `dev-mac`;未显式指定时按目标平台取默认渠道。
|
||||
- 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。
|
||||
- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。
|
||||
- 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。
|
||||
- 上传:安装包与 `.sig` 上传到 `agc/<channel>/<version>/`,清单以 `--force` 覆盖上传到 `agc/<channel>/latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。
|
||||
- Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。
|
||||
- 归档证据:安装包、`.sig`、渠道清单与源码 commit。
|
||||
@@ -100,24 +102,25 @@
|
||||
|
||||
已获得的证据:
|
||||
|
||||
| 条款 | 验收方式 | 证据 |
|
||||
| -------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 渠道与端点映射、渠道校验 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过(默认渠道、错配失败关闭、未知渠道失败关闭) |
|
||||
| universal 包挂两个平台键 | 同上 + 本地发布烟测(伪造 bundle) | 通过(两键同 URL 同签名,不生成迁移清单) |
|
||||
| 缺签名时失败关闭 | 同上 | 通过 |
|
||||
| 开发态不检查更新 | `vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts` | 通过(开关关闭时不请求清单) |
|
||||
| 旧自研链路整条删除 | 代码检索无残留命令、事件与白名单条目 | 通过(`download_agc_update` / 下载事件 / 清单常量均无残留) |
|
||||
| 清单与对象布局符合渠道约定 | `Genarrative-Agc-Windows-Build` #68(2026-09-17,SUCCESS) | 通过:`agc/dev-win/latest.json` = 0.1.48 + `windows-x86_64`;`agc/dev-win/0.1.48/陶泥儿_0.1.48_x64-setup.exe` 与同名 `.sig` 公网可读 |
|
||||
| 清单签名与签名对象一致 | 取回 `.sig` 对象与渠道清单 `signature` 比对 | 通过(逐字相同,420 字节) |
|
||||
| 安装包与清单登记一致 | 下载安装包实算 SHA-256 与尺寸后与迁移桥清单比对 | 通过(size `104678031`、sha256 `1f67…4fd0` 一致) |
|
||||
| 旧协议迁移桥 | 公网读取 `agc/latest.json` | 通过(0.1.48,`downloadUrl` 指向同一对象,含 `sha256` / `size`) |
|
||||
| 条款 | 验收方式 | 证据 |
|
||||
| ---------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 渠道与端点映射、渠道校验 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过(默认渠道、错配失败关闭、未知渠道失败关闭) |
|
||||
| universal 包挂两个平台键 | 同上 + 本地发布烟测(伪造 bundle) | 通过(两键同 URL 同签名,不生成迁移清单) |
|
||||
| 缺签名时失败关闭 | 同上 | 通过 |
|
||||
| 开发态不检查更新 | `vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts` | 通过(开关关闭时不请求清单) |
|
||||
| 旧自研链路整条删除 | 代码检索无残留命令、事件与白名单条目 | 通过(`download_agc_update` / 下载事件 / 清单常量均无残留) |
|
||||
| 清单与对象布局符合渠道约定 | `Genarrative-Agc-Windows-Build` #68(2026-09-17,SUCCESS) | 通过:`agc/dev-win/latest.json` = 0.1.48 + `windows-x86_64`;`agc/dev-win/0.1.48/陶泥儿_0.1.48_x64-setup.exe` 与同名 `.sig` 公网可读 |
|
||||
| 清单签名与签名对象一致 | 取回 `.sig` 对象与渠道清单 `signature` 比对 | 通过(逐字相同,420 字节) |
|
||||
| 安装包与清单登记一致 | 下载安装包实算 SHA-256 与尺寸后与迁移桥清单比对 | 通过(size `104678031`、sha256 `1f67…4fd0` 一致) |
|
||||
| 旧协议迁移桥 | 公网读取 `agc/latest.json` | 通过(0.1.48,`downloadUrl` 指向同一对象,含 `sha256` / `size`) |
|
||||
| 真实更新闭环(含升级后重启) | 0.1.47 客户端按提示下载安装并重启 | 通过(2026-09-17 用户实测:提示 → 下载 → 安装 → 关于页显示新版本,再次检查为已是最新) |
|
||||
|
||||
待执行证据(首次渠道发布后回填):
|
||||
|
||||
| 条款 | 验收方式 | 证据 |
|
||||
| ---------------------------- | --------------------------------------------------------------------- | ------ |
|
||||
| 真实更新闭环(含升级后重启) | 0.1.47 客户端升级到新版本,再启动不再提示;`npm run agc` 仍无更新入口 | 待执行 |
|
||||
| 签名校验失败拒绝安装 | 篡改渠道清单 `signature` 后观察客户端拒绝安装的表现 | 待执行 |
|
||||
| 条款 | 验收方式 | 证据 |
|
||||
| -------------------- | --------------------------------------------------- | ------ |
|
||||
| 签名校验失败拒绝安装 | 篡改渠道清单 `signature` 后观察客户端拒绝安装的表现 | 待执行 |
|
||||
| 签名校验失败拒绝安装 | 篡改渠道清单 `signature` 后观察客户端拒绝安装的表现 | 待执行 |
|
||||
|
||||
## 未决问题与决策
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去
|
||||
|
||||
`Genarrative-Scheduled-Revision-Trigger` 是唯一的定时入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。它只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与上一次触发过的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游。
|
||||
|
||||
revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build`,纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。
|
||||
revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy` 与 `Genarrative-Agc-Windows-Build`,两条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。两条下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build`,纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。两条下游各自判定:AGC Windows Build 采用「客户端相关路径白名单」,Full Build 采用「与线上站点 / 后端无关的路径黑名单」(`docs/`、`.codex/`、`jenkins/`、`apps/ai-game-creator-shell/`、`apps/mobile-shell/`、`apps/desktop-shell/`、`apps/preview-deployer-web/`、`tools/`、根级 `*.md`),改动只要落在黑名单之外就会照常部署,避免漏发线上站点或后端;两条同时被判为跳过时调度管线只推进 revision 状态、不触发任何发布。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。
|
||||
|
||||
调度状态是调度 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 仍会继续触发。
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ pipeline {
|
||||
string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按该渠道 OSS 与本地版本自动递增 patch')
|
||||
choice(name: 'AGC_UPDATE_CHANNEL', choices: ['dev-win', 'dev-mac'], description: 'AGC 发布渠道;dev-win 在 Windows 节点执行,dev-mac 需在 macOS 构建机本地执行')
|
||||
booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '勾选后只构建并打印将要执行的上传命令,不写入 OSS')
|
||||
text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本,写入渠道清单的发布说明')
|
||||
text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本;留空则由本次发布的客户端相关提交自动生成更新摘要')
|
||||
string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 或 ossutil.exe 的绝对路径/命令名')
|
||||
}
|
||||
|
||||
@@ -122,6 +122,22 @@ pipeline {
|
||||
|
||||
stage('Build and upload') {
|
||||
steps {
|
||||
script {
|
||||
// 摘要锚点兜底:清单里还没有 commit 字段时(首次启用摘要 / 换渠道),
|
||||
// 用上一次成功构建的 COMMIT_HASH 作为「上次发布提交」。读取失败保持为空,
|
||||
// 发布脚本会退回清单锚点或干脆不写摘要。
|
||||
def anchor = ''
|
||||
try {
|
||||
def previousBuild = currentBuild.previousSuccessfulBuild
|
||||
anchor = (previousBuild?.buildVariables?.COMMIT_HASH ?: '').toString().trim()
|
||||
} catch (error) {
|
||||
echo "读取上一次成功构建的 commit 失败,跳过摘要锚点兜底:${error}"
|
||||
}
|
||||
env.AGC_UPDATE_PREVIOUS_COMMIT = anchor
|
||||
if (anchor) {
|
||||
echo "更新摘要锚点兜底:${anchor.take(12)}"
|
||||
}
|
||||
}
|
||||
withCredentials([
|
||||
string(credentialsId: 'AliyunAccessKeyId', variable: 'AGC_OSS_ACCESS_KEY_ID'),
|
||||
string(credentialsId: 'AliyunaccessKeySecret', variable: 'AGC_OSS_ACCESS_KEY_SECRET'),
|
||||
@@ -134,6 +150,7 @@ pipeline {
|
||||
"AGC_RELEASE_VERSION=${params.AGC_RELEASE_VERSION}",
|
||||
"AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}",
|
||||
"AGC_RELEASE_DRY_RUN=${params.AGC_RELEASE_DRY_RUN ? '1' : '0'}",
|
||||
"AGC_UPDATE_PREVIOUS_COMMIT=${env.AGC_UPDATE_PREVIOUS_COMMIT ?: ''}",
|
||||
"AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}",
|
||||
]) {
|
||||
powershell '''
|
||||
@@ -159,7 +176,7 @@ pipeline {
|
||||
|
||||
stage('Archive release') {
|
||||
steps {
|
||||
archiveArtifacts artifacts: 'apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.sig,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/legacy-latest.json,.jenkins-source-commit', fingerprint: true
|
||||
archiveArtifacts artifacts: 'apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.sig,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/legacy-latest.json,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/release-notes.txt,.jenkins-source-commit', fingerprint: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,24 +58,25 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
// 只有在「本轮到达的提交」里出现 AGC 相关路径时,Windows 客户端才发布新版本;
|
||||
// 纯文档或流水线自身的提交仍然触发 Full Build,但不再推高客户端版本号。
|
||||
stage('Resolve AGC Release Scope') {
|
||||
// 按「本轮到达的提交」判定两条下游各自是否需要跑:
|
||||
// - AGC:只有出现客户端相关路径才发布 Windows 客户端(避免纯文档提交推高版本号)。
|
||||
// - Full Build:只在改动全部落在「与线上站点/后端无关」的路径时跳过(fail-open 到部署)。
|
||||
stage('Resolve Release Scope') {
|
||||
when {
|
||||
expression { return env.REVISION_CHANGED == 'true' }
|
||||
}
|
||||
steps {
|
||||
withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) {
|
||||
script {
|
||||
// 判定失败一律按「需要发布」处理,避免这段逻辑影响其它下游管线。
|
||||
def scope = 'changed'
|
||||
// 判定失败一律按「两条都要跑」处理,避免这段逻辑影响下游发布。
|
||||
def output = 'agc=changed\nfull=changed'
|
||||
try {
|
||||
scope = sh(script: '''#!/usr/bin/env bash
|
||||
output = sh(script: '''#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
export GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?缺少 Git SSH 凭据} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
|
||||
previous="$(cat "${REVISION_STATE_FILE}" 2>/dev/null || true)"
|
||||
if [[ -z "${previous}" ]]; then
|
||||
echo changed
|
||||
printf 'agc=changed\nfull=changed\n'
|
||||
exit 0
|
||||
fi
|
||||
mkdir -p "${AGC_SCOPE_CACHE_DIR}"
|
||||
@@ -85,31 +86,46 @@ pipeline {
|
||||
fi
|
||||
refspec="+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}"
|
||||
if ! git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags --filter=blob:none origin "${refspec}"; then
|
||||
git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags origin "${refspec}" || { echo changed; exit 0; }
|
||||
git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags origin "${refspec}" || { printf 'agc=changed\nfull=changed\n'; exit 0; }
|
||||
fi
|
||||
if ! git -C "${AGC_SCOPE_CACHE_DIR}" cat-file -e "${previous}^{commit}" 2>/dev/null; then
|
||||
echo "浅取窗口内没有 ${previous},按需要发布处理" >&2
|
||||
echo changed
|
||||
printf 'agc=changed\nfull=changed\n'
|
||||
exit 0
|
||||
fi
|
||||
changed_paths="$(git -C "${AGC_SCOPE_CACHE_DIR}" diff --name-only "${previous}" "${REMOTE_REVISION}" 2>/dev/null || true)"
|
||||
agc_scope=unchanged
|
||||
full_scope=unchanged
|
||||
while IFS= read -r changed_path; do
|
||||
[[ -z "${changed_path}" ]] && continue
|
||||
case "${changed_path}" in
|
||||
apps/ai-game-creator-shell/*|packages/*|server-rs/crates/*|plugins/agc-cocos-editor/*|apps/desktop-shell/src-tauri/icons/*|package.json|package-lock.json)
|
||||
echo changed
|
||||
exit 0
|
||||
agc_scope=changed
|
||||
;;
|
||||
esac
|
||||
case "${changed_path}" in
|
||||
docs/*|.codex/*|jenkins/*|apps/ai-game-creator-shell/*|apps/mobile-shell/*|apps/desktop-shell/*|apps/preview-deployer-web/*|tools/*|*.md)
|
||||
;;
|
||||
*)
|
||||
full_scope=changed
|
||||
;;
|
||||
esac
|
||||
done <<< "${changed_paths}"
|
||||
echo unchanged
|
||||
printf 'agc=%s\nfull=%s\n' "${agc_scope}" "${full_scope}"
|
||||
''', returnStdout: true).trim()
|
||||
} catch (error) {
|
||||
echo "AGC 发布范围判定失败,按需要发布处理:${error}"
|
||||
scope = 'changed'
|
||||
echo "发布范围判定失败,按需要发布处理:${error}"
|
||||
output = 'agc=changed\nfull=changed'
|
||||
}
|
||||
env.AGC_RELEASE_SCOPE = (scope == 'unchanged') ? 'unchanged' : 'changed'
|
||||
echo "AGC 发布范围:${env.AGC_RELEASE_SCOPE}(上一轮已触发 revision=${env.LAST_TRIGGERED_REVISION ?: '无'})"
|
||||
def values = output.split('\n').collect { it.trim() }.findAll { it }
|
||||
def readScope = { String key ->
|
||||
def entry = values.find { it.startsWith(key + '=') }
|
||||
def value = entry == null ? '' : entry.split('=')[1]
|
||||
return (value == 'unchanged') ? 'unchanged' : 'changed'
|
||||
}
|
||||
env.AGC_RELEASE_SCOPE = readScope('agc')
|
||||
env.FULL_BUILD_SCOPE = readScope('full')
|
||||
echo "发布范围:AGC=${env.AGC_RELEASE_SCOPE} FullBuild=${env.FULL_BUILD_SCOPE}(上一轮已触发 revision=${env.LAST_TRIGGERED_REVISION ?: '无'})"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,7 +144,13 @@ pipeline {
|
||||
string(name: 'COMMIT_HASH', value: pinnedRevision),
|
||||
string(name: 'DATABASE_BACKUP_MODE', value: 'skip'),
|
||||
]
|
||||
build job: env.FULL_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters
|
||||
def fullTriggered = false
|
||||
if (params.FORCE_TRIGGER || env.FULL_BUILD_SCOPE != 'unchanged') {
|
||||
build job: env.FULL_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters
|
||||
fullTriggered = true
|
||||
} else {
|
||||
echo "本轮提交全部与线上站点/后端无关,跳过 ${env.FULL_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER"
|
||||
}
|
||||
def agcTriggered = false
|
||||
if (params.FORCE_TRIGGER || env.AGC_RELEASE_SCOPE != 'unchanged') {
|
||||
build job: env.AGC_BUILD_JOB_NAME, wait: false, propagate: false, parameters: pinnedParameters
|
||||
@@ -137,9 +159,13 @@ pipeline {
|
||||
echo "本轮提交不含 AGC 相关路径,跳过 ${env.AGC_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER"
|
||||
}
|
||||
writeFile file: env.REVISION_STATE_FILE, text: pinnedRevision
|
||||
currentBuild.description = agcTriggered
|
||||
? "已触发 ${env.FULL_BUILD_JOB_NAME} 与 ${env.AGC_BUILD_JOB_NAME}:${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}"
|
||||
: "已触发 ${env.FULL_BUILD_JOB_NAME}(AGC 渠道未发布:本次提交不含 AGC 相关路径):${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}"
|
||||
def triggered = []
|
||||
if (fullTriggered) { triggered.add(env.FULL_BUILD_JOB_NAME) }
|
||||
if (agcTriggered) { triggered.add(env.AGC_BUILD_JOB_NAME) }
|
||||
def target = "${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}"
|
||||
currentBuild.description = triggered.isEmpty()
|
||||
? "本轮提交与两条下游都无关,未触发任何发布:${target}"
|
||||
: "已触发 ${triggered.join(' 与 ')}:${target}"
|
||||
echo currentBuild.description
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.1' encoding='UTF-8'?>
|
||||
<flow-definition plugin="workflow-job">
|
||||
<actions/>
|
||||
<description>按小时检查源码分支版本,只有版本变化时用同一个 commit 触发 Full Build;AGC Windows Build 额外按变更路径过滤,只有本轮提交触及客户端相关路径时才触发。</description>
|
||||
<description>按小时检查源码分支版本,只有版本变化时用同一个 commit 触发下游;两条下游各自按变更路径过滤:AGC Windows Build 仅在本轮提交触及客户端相关路径时触发,Full Build 仅在本轮提交不只是文档 / 流水线 / 客户端改动时触发。</description>
|
||||
<keepDependencies>false</keepDependencies>
|
||||
<properties>
|
||||
<hudson.model.ParametersDefinitionProperty>
|
||||
|
||||
Reference in New Issue
Block a user