#!/usr/bin/env node 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`; const REMOTE_SCRIPT = [ 'set -euo pipefail', 'expected_commit="$1"', 'candidate_base="$2"', 'stable_manifest="/Users/john/Project/Memind/.release-manifest.txt"', 'matched=""', 'latest=""', 'for dir in "${candidate_base}"/*; do', ' [[ -d "${dir}" ]] || continue', ' [[ -f "${dir}/.release-manifest.txt" ]] || continue', ' if [[ -z "${latest}" || "$(basename "${dir}")" > "$(basename "${latest}")" ]]; then', ' latest="${dir}"', ' fi', ' if grep -q "^git_head=${expected_commit}$" "${dir}/.release-manifest.txt"; then', ' matched="${dir}"', ' fi', 'done', 'if [[ -n "${matched}" ]]; then candidate="${matched}"; else candidate="${latest}"; fi', '[[ -n "${candidate}" ]] || { echo "NO_CANDIDATE"; exit 1; }', 'printf \'candidate_dir=%s\\n\' "${candidate}"', 'cat "${candidate}/.release-manifest.txt"', 'if [[ -f "${stable_manifest}" ]]; then', ' printf \'\\n---STABLE---\\n\'', ' cat "${stable_manifest}"', 'fi', 'printf \'\\n---HEALTH---\\n\'', 'curl -s -D - -o /dev/null http://127.0.0.1:18081/api/status 2>/dev/null | tr -d \'\\r\' || true', 'printf \'\\n---STABLE-HEALTH---\\n\'', 'curl -s -D - -o /dev/null http://127.0.0.1:8081/api/status 2>/dev/null | tr -d \'\\r\' || true', '', ].join('\n'); 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; } function git(...args) { const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' }); if (result.status !== 0) { throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`); } return result.stdout.trim(); } function ssh(host, args, input = '') { const result = spawnSync('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', host, ...args], { encoding: 'utf8', input, }); if (result.status !== 0) { throw new Error(`SSH to ${host} failed:\n${result.stderr || result.stdout}`); } return result.stdout.trim(); } function parseManifest(content) { const fields = {}; for (const line of content.split('\n')) { const separator = line.indexOf('='); if (separator <= 0) continue; fields[line.slice(0, separator)] = line.slice(separator + 1); } 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, }) { 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' }; } function parseRemoteSections(remoteOutput) { const sections = { manifestText: remoteOutput, stableText: '', healthText: '', stableHealthText: '', }; const stableMarker = '\n---STABLE---\n'; const healthMarker = '\n---HEALTH---\n'; const stableHealthMarker = '\n---STABLE-HEALTH---\n'; const stableIndex = remoteOutput.indexOf('---STABLE---'); const healthIndex = remoteOutput.indexOf('---HEALTH---'); const stableHealthIndex = remoteOutput.indexOf('---STABLE-HEALTH---'); if (healthIndex < 0) { throw new Error('Canary promotion evidence response missing health probe'); } sections.manifestText = remoteOutput.slice( 0, stableIndex >= 0 ? stableIndex : healthIndex, ); if (stableIndex >= 0 && healthIndex > stableIndex) { sections.stableText = remoteOutput.slice( stableIndex + stableMarker.trim().length, healthIndex, ); } sections.healthText = remoteOutput.slice( healthIndex + healthMarker.trim().length, stableHealthIndex >= 0 ? stableHealthIndex : remoteOutput.length, ); if (stableHealthIndex >= 0) { sections.stableHealthText = remoteOutput.slice( stableHealthIndex + stableHealthMarker.trim().length, ); } return sections; } 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, ['/bin/bash', '-s', '--', commit, CANDIDATE_BASE], REMOTE_SCRIPT, ); const sections = parseRemoteSections(remoteOutput); const manifestText = sections.manifestText; const healthText = sections.healthText; const stableHealthText = sections.stableHealthText; const stableManifest = parseManifest(sections.stableText); const candidateDir = manifestText .split('\n') .find((line) => line.startsWith('candidate_dir=')) ?.slice('candidate_dir='.length); const manifest = parseManifest( manifestText .split('\n') .filter((line) => !line.startsWith('candidate_dir=')) .join('\n'), ); if (!candidateDir) { throw new Error('103 has no canary candidate directory'); } let promotionMode = 'commit_match'; if (manifest.git_head !== commit) { const promotion = assertArtifactIdenticalPromotion({ candidateCommit: manifest.git_head, expectedCommit: commit, }); promotionMode = promotion.promotion; } const candidateHealthy = /^HTTP\/1\.1 200/m.test(healthText) && /^x-memind-runtime-role: candidate/im.test(healthText); const stableHealthy = /^HTTP\/1\.1 200/m.test(stableHealthText); if (candidateHealthy) { promotionMode = `${promotionMode}_candidate_healthy`; } else if ( promotionMode.startsWith('artifact_identical') && stableHealthy && stableManifest.git_head === manifest.git_head ) { promotionMode = `${promotionMode}_stable_fallback`; } else if (!candidateHealthy) { throw new Error('Canary candidate /api/status is not healthy on 18081'); } if (candidateHealthy && !/^x-memind-runtime-role: candidate/im.test(healthText)) { throw new Error('Canary candidate health probe missing X-Memind-Runtime-Role: candidate'); } console.log('Canary promotion evidence verified'); 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); }