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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user