feat(release): add incremental gate and auto CI status for 103 publish

When the runtime artifact is unchanged, carry forward prior gate results and
re-run only REL scenarios; resolve REL-02 from Gitea commit status and allow
stable promotion when canary matches the same artifact bundle.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-27 08:32:12 +08:00
parent 9e2aa4f71d
commit a6a9bb4eab
11 changed files with 533 additions and 10 deletions
+64 -3
View File
@@ -2,6 +2,12 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { hashArtifact } from '../release-gate/artifact.mjs';
import {
diffTouchesRuntimePaths,
isReleaseOnlyPath,
} from '../release-gate/incremental.mjs';
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
const REMOTE_ROOT = process.env.STUDIO_REMOTE_ROOT ?? '/Users/john/Project';
const CANDIDATE_BASE = `${REMOTE_ROOT}/Memind-candidates`;
@@ -35,11 +41,13 @@ function parseArgs(argv) {
const options = {
host: process.env.STUDIO_HOST ?? '58.38.22.103',
commit: null,
artifact: path.join(ROOT, '.runtime', 'portal'),
};
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--host') options.host = argv[++index] ?? '';
else if (arg === '--commit') options.commit = argv[++index] ?? '';
else if (arg === '--artifact') options.artifact = path.resolve(argv[++index] ?? '');
else throw new Error(`Unknown argument: ${arg}`);
}
return options;
@@ -74,10 +82,54 @@ function parseManifest(content) {
return fields;
}
function listReleaseOnlyDiff(baseCommit, headCommit) {
const result = spawnSync(
'git',
['diff', '--name-only', `${baseCommit}..${headCommit}`],
{ cwd: ROOT, encoding: 'utf8' },
);
if (result.status !== 0) {
throw new Error(`git diff failed: ${result.stderr || result.stdout}`);
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
function assertArtifactIdenticalPromotion({
candidateCommit,
expectedCommit,
candidateArtifactSha,
localArtifactSha,
}) {
if (candidateArtifactSha !== localArtifactSha) {
throw new Error(
`Canary candidate artifact mismatch: candidate=${candidateArtifactSha ?? 'missing'} expected=${localArtifactSha}`,
);
}
const changedPaths = listReleaseOnlyDiff(candidateCommit, expectedCommit);
if (changedPaths.length === 0) {
return { changedPaths, promotion: 'artifact_identical_no_diff' };
}
if (diffTouchesRuntimePaths(changedPaths)) {
throw new Error(
`Canary candidate commit mismatch and diff is not release-only: candidate=${candidateCommit} expected=${expectedCommit} changed=${changedPaths.join(',')}`,
);
}
if (!changedPaths.every(isReleaseOnlyPath)) {
throw new Error(
`Canary promotion diff contains non-release paths: ${changedPaths.join(',')}`,
);
}
return { changedPaths, promotion: 'artifact_identical_release_only_diff' };
}
try {
const options = parseArgs(process.argv);
const commit = options.commit ?? git('rev-parse', 'HEAD');
const remoteUserHost = options.host.includes('@') ? options.host : `john@${options.host}`;
const localArtifact = await hashArtifact(options.artifact);
const remoteOutput = ssh(
remoteUserHost,
@@ -107,11 +159,18 @@ try {
if (!candidateDir) {
throw new Error('103 has no canary candidate directory');
}
let promotionMode = 'commit_match';
if (manifest.git_head !== commit) {
throw new Error(
`Canary candidate commit mismatch: candidate=${manifest.git_head ?? 'missing'} expected=${commit}`,
);
const promotion = assertArtifactIdenticalPromotion({
candidateCommit: manifest.git_head,
expectedCommit: commit,
candidateArtifactSha: manifest.artifact_bundle_sha256,
localArtifactSha: localArtifact.sha256,
});
promotionMode = promotion.promotion;
}
if (!/^HTTP\/1\.1 200/m.test(healthText)) {
throw new Error('Canary candidate /api/status is not healthy on 18081');
}
@@ -123,6 +182,8 @@ try {
console.log(`candidate_dir=${candidateDir}`);
console.log(`release_id=${manifest.release_id ?? 'unknown'}`);
console.log(`git_head=${manifest.git_head}`);
console.log(`promotion_mode=${promotionMode}`);
console.log(`artifact_sha256=${localArtifact.sha256}`);
} catch (error) {
console.error(error.message);
process.exit(1);