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
+13
View File
@@ -336,6 +336,19 @@ REMOTE
}
say "验证与当前 main 和 runtime artifact 绑定的 Gate report"
if ! node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" >/dev/null 2>&1; then
say "尝试基于相同 artifact 的增量 Gate"
DEPLOYED_SHA="$(
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
"grep -E '^git_head=' '${APP_DIR}/.release-manifest.txt' 2>/dev/null | tail -1 | cut -d= -f2-" \
2>/dev/null || true
)"
INCREMENTAL_ARGS=(--artifact "${RUNTIME_ROOT}")
if [[ -n "${DEPLOYED_SHA}" ]]; then
INCREMENTAL_ARGS+=(--deployed-commit "${DEPLOYED_SHA}")
fi
node "${ROOT}/scripts/run-release-gate-incremental.mjs" "${INCREMENTAL_ARGS[@]}"
fi
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
if [[ "${DRY_RUN}" -ne 1 ]]; then
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
import { runCommand } from '../release-gate/runner.mjs';
import { resolveReleaseCiStatus } from '../release-gate/ci-status.mjs';
const ROOT = new URL('..', import.meta.url).pathname;
function parseArgs(argv) {
const options = { commit: null };
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--commit') options.commit = argv[++index] ?? '';
else throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
async function git(...args) {
const result = await runCommand('git', args, { cwd: ROOT, timeoutMs: 30_000 });
if (result.code !== 0) throw new Error(`git ${args.join(' ')} failed`);
return result.stdout.trim();
}
try {
const options = parseArgs(process.argv);
const commitSha = options.commit ?? await git('rev-parse', 'HEAD');
const status = await resolveReleaseCiStatus(commitSha);
process.stdout.write(`${status}\n`);
process.exit(status === 'success' ? 0 : 1);
} catch (error) {
console.error(error.message);
process.exit(1);
}
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
import { executeIncrementalReleaseGate, parseRunnerArgs } from '../release-gate/runner.mjs';
function usage() {
console.log(`Usage:
node scripts/run-release-gate-incremental.mjs --artifact .runtime/portal [--baseline-commit <sha>] [--deployed-commit <sha>]
Reuses a prior full Gate report when the runtime artifact SHA256 is unchanged,
then re-executes only REL-* release scenarios.`);
}
try {
const options = parseRunnerArgs(process.argv);
if (options.help) {
usage();
process.exit(0);
}
options.mode = 'incremental';
const { report, outputDir, baseline } = await executeIncrementalReleaseGate(options);
const summary = report.summary;
console.log(`Incremental release gate report: ${outputDir}`);
console.log(`baseline_commit=${baseline.report.commit_sha}`);
console.log(JSON.stringify(summary));
const passed = summary.failed === 0
&& summary.skipped === 0
&& summary.blocked === 0
&& summary.unknown === 0
&& summary.cleanup_failed === 0
&& summary.passed + summary.not_applicable === summary.required;
process.exit(passed ? 0 : 1);
} catch (error) {
console.error(`Incremental release gate failed: ${error.message}`);
process.exit(1);
}
+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);