5ca6ed9094
Co-authored-by: Cursor <cursoragent@cursor.com>
241 lines
7.8 KiB
JavaScript
241 lines
7.8 KiB
JavaScript
#!/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);
|
|
}
|