From a6a9bb4eab851ef05e18ccaac4596bddb7eedceb Mon Sep 17 00:00:00 2001 From: john Date: Mon, 27 Jul 2026 08:32:12 +0800 Subject: [PATCH] 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 --- docs/release-gate-automation.md | 13 +- release-gate/ci-status.mjs | 57 +++++++ release-gate/incremental.mjs | 156 +++++++++++++++++++ release-gate/incremental.test.mjs | 46 ++++++ release-gate/release-script.test.mjs | 2 + release-gate/report.mjs | 22 ++- release-gate/runner.mjs | 101 +++++++++++- scripts/release-portal-runtime-prod.sh | 13 ++ scripts/resolve-release-ci-status.mjs | 32 ++++ scripts/run-release-gate-incremental.mjs | 34 ++++ scripts/verify-canary-promotion-evidence.mjs | 67 +++++++- 11 files changed, 533 insertions(+), 10 deletions(-) create mode 100644 release-gate/ci-status.mjs create mode 100644 release-gate/incremental.mjs create mode 100644 release-gate/incremental.test.mjs create mode 100644 scripts/resolve-release-ci-status.mjs create mode 100644 scripts/run-release-gate-incremental.mjs diff --git a/docs/release-gate-automation.md b/docs/release-gate-automation.md index 04eb180..cc47501 100644 --- a/docs/release-gate-automation.md +++ b/docs/release-gate-automation.md @@ -48,8 +48,17 @@ 全部 187 项都已有自动判定路径,未执行的 mode 仍保持 `unknown`。UI-01 至 UI-08 在隔离本地 Portal、390×844 移动视口和公开页 fixture 上运行;COMP-09 由独立执行器 要求 active 脱敏回归 manifest 并逐条回放。没有 manifest 时 COMP-09 明确 `failed`, -不会因“没有用例”而通过。`REL-02` 只在候选等于 `origin/main` 且 CI 注入 -`MEMIND_RELEASE_CI_STATUS=success` 时通过。 +不会因“没有用例”而通过。`REL-02` 在候选等于 `origin/main` 且 Gitea commit status 为 +`success` 时通过;也可显式注入 `MEMIND_RELEASE_CI_STATUS=success`。 + +当 runtime artifact SHA256 与已发布版本一致时,可使用增量 Gate: + +```bash +node scripts/run-release-gate-incremental.mjs --artifact .runtime/portal --deployed-commit <103-stable-sha> +``` + +增量 Gate 会复用基线 report 中的非 `REL-*` 场景,仅重跑发布相关场景。 +稳定发布脚本在完整 report 缺失或过期时会自动尝试增量 Gate。 2026-07-26 本地补齐验证中,历史完整报告为 180/187 通过;`REL-01` 因当前仍在功能 分支且工作区不干净而失败。PAGE-01/02 与 DATA-01/02/03/04 的隔离栈现在使用后台 diff --git a/release-gate/ci-status.mjs b/release-gate/ci-status.mjs new file mode 100644 index 0000000..a314cad --- /dev/null +++ b/release-gate/ci-status.mjs @@ -0,0 +1,57 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +const ROOT = path.resolve(new URL('..', import.meta.url).pathname); + +function git(args) { + const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' }); + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed`); + } + return result.stdout.trim(); +} + +async function resolveRemote() { + let remote = ''; + try { + remote = git(['remote', 'get-url', 'origin']); + } catch { + return null; + } + const slugMatch = remote.match(/(?:https?:\/\/[^/]+\/|git@[^:]+:)(.+?)(?:\.git)?$/); + if (!slugMatch) return null; + const slug = slugMatch[1].replace(/\.git$/, ''); + const [owner, repo] = slug.split('/'); + if (!owner || !repo) return null; + const hostMatch = remote.match(/^git@([^:]+):/) || remote.match(/^https?:\/\/([^/]+)\//); + const host = hostMatch?.[1] ?? 'git.tkmind.cn'; + const protocol = remote.startsWith('git@') ? 'https' : remote.split('://')[0]; + return `${protocol}://${host}/api/v1/repos/${owner}/${repo}`; +} + +async function queryGiteaCommitStatus(apiBase, commitSha) { + const response = await fetch(`${apiBase}/commits/${commitSha}/status`, { + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new Error(`Gitea commit status lookup failed: HTTP ${response.status}`); + } + const payload = await response.json(); + const state = String(payload?.state ?? '').trim().toLowerCase(); + if (state === 'success') return 'success'; + if (state === 'pending') return 'pending'; + return state || 'missing'; +} + +export async function resolveReleaseCiStatus(commitSha) { + const explicit = String(process.env.MEMIND_RELEASE_CI_STATUS ?? '').trim().toLowerCase(); + if (explicit) return explicit; + + const apiBase = await resolveRemote(); + if (!apiBase) return 'missing'; + try { + return await queryGiteaCommitStatus(apiBase, commitSha); + } catch { + return 'missing'; + } +} diff --git a/release-gate/incremental.mjs b/release-gate/incremental.mjs new file mode 100644 index 0000000..a46674a --- /dev/null +++ b/release-gate/incremental.mjs @@ -0,0 +1,156 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { hashArtifact } from './artifact.mjs'; +import { validateGateReport } from './report.mjs'; + +const RELEASE_ONLY_PATH_PREFIXES = [ + 'release-gate/', + 'scripts/release-', + 'scripts/verify-canary-', + 'scripts/verify-release-gate', + 'scripts/check-release', + 'scripts/run-release-gate', + 'scripts/resolve-release-ci-status.mjs', + 'docs/production-release-guardian.md', + 'docs/release-gate-automation.md', +]; + +export function isReleaseOnlyPath(relativePath) { + return RELEASE_ONLY_PATH_PREFIXES.some((prefix) => relativePath.startsWith(prefix)); +} + +export function isReleaseScenario(scenarioId) { + return scenarioId.startsWith('REL-'); +} + +export function diffTouchesRuntimePaths(changedPaths) { + return changedPaths.some((relativePath) => !isReleaseOnlyPath(relativePath)); +} + +export async function loadGateReport(reportPath) { + const raw = await fs.readFile(reportPath, 'utf8'); + return JSON.parse(raw); +} + +export async function findCarryForwardBaseline({ + reportRoot, + artifactSha256, + preferredCommitSha = null, + deployedCommitSha = null, + now = new Date(), +}) { + let entries = []; + try { + entries = await fs.readdir(reportRoot, { withFileTypes: true }); + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } + + const candidates = []; + for (const entry of entries) { + if (!entry.isDirectory() || entry.name === 'local') continue; + const reportPath = path.join(reportRoot, entry.name, 'report.json'); + try { + const report = await loadGateReport(reportPath); + if (report.artifact_sha256 !== artifactSha256) continue; + const validation = validateGateReport(report, { + expectedArtifactSha256: artifactSha256, + now, + requireFullCatalog: true, + allowExpired: true, + }); + if (!validation.valid) continue; + candidates.push({ report, reportPath, commitSha: report.commit_sha }); + } catch { + // ignore unreadable or invalid reports + } + } + + if (candidates.length === 0) return null; + + const rank = (candidate) => { + let score = 0; + if (deployedCommitSha && candidate.commitSha === deployedCommitSha) score += 100; + if (preferredCommitSha && candidate.commitSha === preferredCommitSha) score += 50; + score += Date.parse(candidate.report.completed_at ?? '') / 1_000_000_000_000; + return score; + }; + + candidates.sort((left, right) => rank(right) - rank(left)); + return candidates[0]; +} + +export function mergeIncrementalScenarios({ + baselineScenarios, + reexecutedScenarios, +}) { + const reexecutedById = new Map(reexecutedScenarios.map((scenario) => [scenario.id, scenario])); + return baselineScenarios.map((baselineScenario) => { + const reexecuted = reexecutedById.get(baselineScenario.id); + if (!reexecuted) { + return { + ...baselineScenario, + evidence: [ + ...(baselineScenario.evidence ?? []), + 'carried_forward=true', + ], + }; + } + return { + ...reexecuted, + evidence: [ + ...(reexecuted.evidence ?? []), + 'carried_forward=false', + ], + }; + }); +} + +export function buildIncrementalReport({ + baselineReport, + baselineReportPath, + commitSha, + branch, + artifactSha256, + artifact, + scenarios, + startedAt, + completedAt, +}) { + const carriedForward = scenarios.filter( + (scenario) => (scenario.evidence ?? []).includes('carried_forward=true'), + ).length; + const reexecuted = scenarios.length - carriedForward; + return { + schema_version: 1, + mode: 'incremental', + commit_sha: commitSha, + branch, + artifact_sha256: artifactSha256, + artifact, + started_at: startedAt.toISOString(), + completed_at: completedAt.toISOString(), + expires_at: new Date(completedAt.getTime() + 4 * 60 * 60 * 1000).toISOString(), + environment: baselineReport.environment, + environment_fingerprint: baselineReport.environment_fingerprint, + baseline: { + commit_sha: baselineReport.commit_sha, + report_path: baselineReportPath, + carried_forward: carriedForward, + reexecuted, + }, + summary: null, + scenarios, + exemptions: scenarios + .filter((scenario) => scenario.status === 'not_applicable') + .map((scenario) => scenario.exemption) + .filter(Boolean), + approved_for_release: false, + }; +} + +export async function hashArtifactFromOptions(artifactPath) { + return hashArtifact(artifactPath); +} diff --git a/release-gate/incremental.test.mjs b/release-gate/incremental.test.mjs new file mode 100644 index 0000000..ae4a975 --- /dev/null +++ b/release-gate/incremental.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + diffTouchesRuntimePaths, + isReleaseOnlyPath, + isReleaseScenario, + mergeIncrementalScenarios, +} from './incremental.mjs'; + +test('isReleaseScenario identifies REL family only', () => { + assert.equal(isReleaseScenario('REL-02'), true); + assert.equal(isReleaseScenario('CHAT-01'), false); +}); + +test('isReleaseOnlyPath accepts release workflow files only', () => { + assert.equal(isReleaseOnlyPath('scripts/release-portal-runtime-prod.sh'), true); + assert.equal(isReleaseOnlyPath('release-gate/runner.mjs'), true); + assert.equal(isReleaseOnlyPath('server.mjs'), false); +}); + +test('diffTouchesRuntimePaths flags runtime-impacting changes', () => { + assert.equal( + diffTouchesRuntimePaths(['scripts/release-portal-runtime-prod.sh']), + false, + ); + assert.equal( + diffTouchesRuntimePaths(['scripts/release-portal-runtime-prod.sh', 'server.mjs']), + true, + ); +}); + +test('mergeIncrementalScenarios carries forward unchanged scenarios', () => { + const merged = mergeIncrementalScenarios({ + baselineScenarios: [ + { id: 'CHAT-01', status: 'passed', evidence: ['suite=chat'] }, + { id: 'REL-02', status: 'passed', evidence: ['old'] }, + ], + reexecutedScenarios: [ + { id: 'REL-02', status: 'passed', evidence: ['new'] }, + ], + }); + assert.equal(merged[0].evidence.includes('carried_forward=true'), true); + assert.equal(merged[1].evidence.includes('carried_forward=false'), true); + assert.equal(merged[1].evidence.includes('new'), true); +}); diff --git a/release-gate/release-script.test.mjs b/release-gate/release-script.test.mjs index f8899a0..8b7c287 100644 --- a/release-gate/release-script.test.mjs +++ b/release-gate/release-script.test.mjs @@ -49,8 +49,10 @@ test('production stable release verifies canary promotion evidence before gate c ); const promotionIndex = source.indexOf('verify-canary-promotion-evidence.mjs'); const gateIndex = source.indexOf('verify-release-gate-report.mjs'); + const incrementalIndex = source.indexOf('run-release-gate-incremental.mjs'); assert.ok(promotionIndex > 0, 'missing canary promotion evidence verifier'); assert.ok(gateIndex > promotionIndex, 'gate verification must follow promotion evidence'); + assert.ok(incrementalIndex > 0, 'missing incremental gate fallback'); assert.doesNotMatch(source, /在同一候选完成 103 灰度验收且晋升证据校验落地前,禁止非 dry-run/); assert.match(source, /read_agent_run_status_json/); assert.match(source, /sed -n '\/\^\{/); diff --git a/release-gate/report.mjs b/release-gate/report.mjs index 9bfcc8f..7955980 100644 --- a/release-gate/report.mjs +++ b/release-gate/report.mjs @@ -124,10 +124,28 @@ export function validateGateReport(report, { expectedBranch = 'main', now = new Date(), requireFullCatalog = true, + allowExpired = false, } = {}) { const errors = []; if (report?.schema_version !== 1) errors.push('schema_version must be 1'); - if (report?.mode !== 'all' && requireFullCatalog) errors.push('release report mode must be all'); + if (report?.mode !== 'all' && report?.mode !== 'incremental' && requireFullCatalog) { + errors.push('release report mode must be all or incremental'); + } + if (report?.mode === 'incremental') { + const baseline = report?.baseline; + if (!baseline || typeof baseline !== 'object') { + errors.push('incremental report is missing baseline metadata'); + } else { + for (const field of ['commit_sha', 'report_path', 'carried_forward', 'reexecuted']) { + if (baseline[field] === undefined || baseline[field] === null || baseline[field] === '') { + errors.push(`incremental baseline is missing ${field}`); + } + } + if (Number(baseline.carried_forward) + Number(baseline.reexecuted) !== report?.scenarios?.length) { + errors.push('incremental baseline counts do not match scenario total'); + } + } + } if (!/^[0-9a-f]{40}$/i.test(report?.commit_sha ?? '')) errors.push('commit_sha must be a full SHA'); if (expectedCommit && report?.commit_sha !== expectedCommit) errors.push('commit_sha does not match candidate'); if (expectedBranch && report?.branch !== expectedBranch) errors.push(`branch must be ${expectedBranch}`); @@ -148,7 +166,7 @@ export function validateGateReport(report, { } else { if (expiresAt <= completedAt) errors.push('report expiry must be after completion'); if (expiresAt - completedAt > 4 * 60 * 60 * 1000) errors.push('report validity exceeds four hours'); - if (new Date(now).getTime() > expiresAt) errors.push('report has expired'); + if (!allowExpired && new Date(now).getTime() > expiresAt) errors.push('report has expired'); } if (!Array.isArray(report?.scenarios)) { diff --git a/release-gate/runner.mjs b/release-gate/runner.mjs index 355c69b..8b27656 100644 --- a/release-gate/runner.mjs +++ b/release-gate/runner.mjs @@ -6,11 +6,18 @@ import { assertPortalRuntimePath, hashArtifact, inspectPortalRuntime } from './a import { loadScenarioCatalog } from './catalog.mjs'; import { AUTOMATION_SUITES, validateAutomationSuites } from './coverage.mjs'; import { loadActiveRegressionCorpus } from './regression-corpus.mjs'; -import { createGateReport, writeGateReport } from './report.mjs'; +import { + buildIncrementalReport, + findCarryForwardBaseline, + isReleaseScenario, + mergeIncrementalScenarios, +} from './incremental.mjs'; +import { createGateReport, summarizeScenarios, writeGateReport } from './report.mjs'; import { assertSafeGateEnvironment, assertSafePortalBase } from './safety.mjs'; +import { resolveReleaseCiStatus } from './ci-status.mjs'; const ROOT = path.resolve(new URL('..', import.meta.url).pathname); -const MODES = new Set(['deterministic', 'scenarios', 'browser', 'providers', 'upgrade', 'all']); +const MODES = new Set(['deterministic', 'scenarios', 'browser', 'providers', 'upgrade', 'all', 'incremental']); async function git(...args) { const result = await runCommand('git', args, { cwd: ROOT, timeoutMs: 30_000 }); @@ -26,6 +33,8 @@ export function parseRunnerArgs(argv) { reportRoot: path.join(ROOT, '.release-gate'), timeoutMs: 15 * 60 * 1000, suiteConcurrency: Number(process.env.RELEASE_GATE_SUITE_CONCURRENCY ?? 4), + baselineCommit: null, + deployedCommit: null, }; for (let index = 2; index < argv.length; index += 1) { const arg = argv[index]; @@ -35,6 +44,8 @@ export function parseRunnerArgs(argv) { else if (arg === '--report-root') options.reportRoot = path.resolve(argv[++index] ?? ''); else if (arg === '--timeout-ms') options.timeoutMs = Number(argv[++index]); else if (arg === '--suite-concurrency') options.suiteConcurrency = Number(argv[++index]); + else if (arg === '--baseline-commit') options.baselineCommit = argv[++index] ?? ''; + else if (arg === '--deployed-commit') options.deployedCommit = argv[++index] ?? ''; else if (arg === '-h' || arg === '--help') options.help = true; else throw new Error(`Unknown argument: ${arg}`); } @@ -191,7 +202,9 @@ async function applyRepositoryChecks(results, artifactPath) { `ignored_generated_paths=${status !== relevantStatus}`, ); - const ciStatus = String(process.env.MEMIND_RELEASE_CI_STATUS ?? '').trim().toLowerCase(); + const ciStatus = remoteSha === headSha + ? await resolveReleaseCiStatus(headSha) + : 'missing'; const rel02 = byId.get('REL-02'); rel02.status = remoteSha === headSha && ciStatus === 'success' ? 'passed' : 'failed'; rel02.reason = rel02.status === 'passed' @@ -296,3 +309,85 @@ export async function executeReleaseGate(options) { await writeGateReport(report, outputDir); return { report, outputDir }; } + +export async function executeIncrementalReleaseGate(options) { + const startedAt = new Date(); + assertSafeGateEnvironment({ targets: [options.portalBase] }); + assertSafePortalBase(options.portalBase); + if (!options.artifact) { + throw new Error('incremental release gate requires --artifact'); + } + options.artifact = assertPortalRuntimePath(options.artifact, { repoRoot: ROOT }); + + const catalog = await loadScenarioCatalog({ root: ROOT }); + validateAutomationSuites(catalog); + const commitSha = await git('rev-parse', 'HEAD'); + const branch = await git('branch', '--show-current'); + const outputDir = path.join(options.reportRoot, commitSha); + const artifact = await hashArtifact(options.artifact); + + const baseline = await findCarryForwardBaseline({ + reportRoot: options.reportRoot, + artifactSha256: artifact.sha256, + preferredCommitSha: options.baselineCommit ?? null, + deployedCommitSha: options.deployedCommit ?? null, + }); + if (!baseline) { + throw new Error( + 'No valid baseline Gate report found for the current artifact; run the full release gate first.', + ); + } + + const reexecuted = catalog + .filter((scenario) => isReleaseScenario(scenario.id)) + .map(scenarioResult); + const reexecutedById = new Map(reexecuted.map((result) => [result.id, result])); + await applyRepositoryChecks(reexecuted, options.artifact); + + const upgradeSuites = AUTOMATION_SUITES.filter((suite) => suite.mode === 'upgrade'); + const executions = await runSuitesWithConcurrency( + upgradeSuites, + options.suiteConcurrency, + (suite) => runSuite(suite, outputDir, options.timeoutMs), + ); + for (let index = 0; index < upgradeSuites.length; index += 1) { + const suite = upgradeSuites[index]; + const execution = executions[index]; + for (const scenarioId of suite.scenarios) { + if (!isReleaseScenario(scenarioId)) continue; + const scenario = reexecutedById.get(scenarioId); + scenario.status = execution.code === 0 && !execution.timedOut ? 'passed' : 'failed'; + scenario.reason = scenario.status === 'passed' ? null : 'automation_suite_failed'; + scenario.evidence.push( + `suite=${suite.id}`, + `log=${execution.logPath}`, + ...suite.cases[scenarioId].map((assertedCase) => `asserted_case=${assertedCase}`), + ); + } + } + + const mergedScenarios = mergeIncrementalScenarios({ + baselineScenarios: baseline.report.scenarios, + reexecutedScenarios: reexecuted, + }); + const completedAt = new Date(); + const report = buildIncrementalReport({ + baselineReport: baseline.report, + baselineReportPath: path.relative(ROOT, baseline.reportPath), + commitSha, + branch, + artifactSha256: artifact.sha256, + artifact: { + path: path.relative(ROOT, options.artifact), + kind: artifact.kind, + files: artifact.files, + bytes: artifact.bytes, + }, + scenarios: mergedScenarios, + startedAt, + completedAt, + }); + report.summary = summarizeScenarios(mergedScenarios); + await writeGateReport(report, outputDir); + return { report, outputDir, baseline }; +} diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index 20e578c..4638ff3 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -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 diff --git a/scripts/resolve-release-ci-status.mjs b/scripts/resolve-release-ci-status.mjs new file mode 100644 index 0000000..b16fb5b --- /dev/null +++ b/scripts/resolve-release-ci-status.mjs @@ -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); +} diff --git a/scripts/run-release-gate-incremental.mjs b/scripts/run-release-gate-incremental.mjs new file mode 100644 index 0000000..614f1b7 --- /dev/null +++ b/scripts/run-release-gate-incremental.mjs @@ -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 ] [--deployed-commit ] + +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); +} diff --git a/scripts/verify-canary-promotion-evidence.mjs b/scripts/verify-canary-promotion-evidence.mjs index a292917..e867186 100644 --- a/scripts/verify-canary-promotion-evidence.mjs +++ b/scripts/verify-canary-promotion-evidence.mjs @@ -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);