Updater 3.4 – Lifecycle Hardening #28
@@ -0,0 +1,196 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const ACTIVE = new Set(['queued', 'pending', 'in_progress', 'waiting', 'requested']);
|
||||
const SUCCESS = new Set(['success', 'neutral', 'skipped']);
|
||||
const FAILURE = new Set(['failure', 'timed_out', 'action_required', 'startup_failure', 'stale']);
|
||||
const CANCELLED = new Set(['cancelled']);
|
||||
|
||||
export const UPDATER_VERSION = '3.4.0';
|
||||
export const SESSION_SCHEMA = 'vendoo-updater-session-v2';
|
||||
export const PACKAGE_MANIFEST_SCHEMA = 'vendoo-release-package-v2';
|
||||
export const REQUIRED_CHECKS = Object.freeze([
|
||||
'Syntax, Sicherheit und Release-Gates',
|
||||
'Windows – vollständiger Releasevertrag',
|
||||
]);
|
||||
export const REQUIRED_WORKFLOWS = Object.freeze([
|
||||
'Vendoo CI',
|
||||
'Vendoo Release Manifest Sync',
|
||||
]);
|
||||
|
||||
const lower = value => String(value || '').trim().toLowerCase();
|
||||
const time = value => {
|
||||
const parsed = Date.parse(value || '');
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
const stablePayload = manifest => ({
|
||||
schema: PACKAGE_MANIFEST_SCHEMA,
|
||||
targetVersion: String(manifest.targetVersion || manifest.version || ''),
|
||||
updaterVersion: String(manifest.updaterVersion || ''),
|
||||
sourceCommit: String(manifest.sourceCommit || ''),
|
||||
baseCommit: String(manifest.baseCommit || ''),
|
||||
createdAt: String(manifest.createdAt || ''),
|
||||
mode: String(manifest.mode || ''),
|
||||
requiredFiles: [...(manifest.requiredFiles || [])],
|
||||
removedFiles: [...(manifest.removedFiles || [])],
|
||||
files: [...(manifest.files || [])],
|
||||
});
|
||||
|
||||
export function computePackagePayloadHash(manifest) {
|
||||
return createHash('sha256').update(JSON.stringify(stablePayload(manifest))).digest('hex');
|
||||
}
|
||||
|
||||
export function createPackageManifest(sourceManifest, { sourceCommit, baseCommit, createdAt }) {
|
||||
const manifest = {
|
||||
...sourceManifest,
|
||||
schema: PACKAGE_MANIFEST_SCHEMA,
|
||||
targetVersion: String(sourceManifest.version || sourceManifest.targetVersion || ''),
|
||||
sourceCommit: String(sourceCommit || ''),
|
||||
baseCommit: String(baseCommit || ''),
|
||||
createdAt: String(createdAt || ''),
|
||||
};
|
||||
manifest.payloadHash = computePackagePayloadHash(manifest);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
export function payloadIdentity(manifest) {
|
||||
const targetVersion = String(manifest?.targetVersion || manifest?.version || '');
|
||||
const payloadHash = String(manifest?.payloadHash || '');
|
||||
return `${targetVersion}:${payloadHash}`;
|
||||
}
|
||||
|
||||
export function createSession({ repository, targetVersion, updaterVersion = UPDATER_VERSION, payloadHash, branch }) {
|
||||
return {
|
||||
schema: SESSION_SCHEMA,
|
||||
repository,
|
||||
targetVersion,
|
||||
updaterVersion,
|
||||
payloadHash,
|
||||
branch,
|
||||
releaseState: 'new',
|
||||
prNumber: null,
|
||||
prUrl: '',
|
||||
finalPrHeadSha: '',
|
||||
mergeCommit: '',
|
||||
productionVersion: '',
|
||||
completedAt: '',
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionMatches(session, { repository, targetVersion, payloadHash }) {
|
||||
return Boolean(session && session.schema === SESSION_SCHEMA && session.repository === repository && session.targetVersion === targetVersion && session.payloadHash === payloadHash);
|
||||
}
|
||||
|
||||
export function decideReleasePath({ targetVersion, mainVersion, productionReady, productionVersion, pullRequests = [], session = null }) {
|
||||
const productionAtTarget = Boolean(productionReady && productionVersion === targetVersion);
|
||||
if (session?.releaseState === 'completed' && productionAtTarget) return { action: 'complete', reason: 'completed-session' };
|
||||
if (mainVersion === targetVersion) {
|
||||
return productionAtTarget ? { action: 'complete', reason: 'main-and-production-at-target' } : { action: 'deploy', reason: 'main-at-target-production-behind' };
|
||||
}
|
||||
const relevant = pullRequests.filter(pr => pr && pr.targetVersion === targetVersion);
|
||||
const open = relevant.filter(pr => lower(pr.state) === 'open').sort((a, b) => time(b.updatedAt) - time(a.updatedAt))[0];
|
||||
if (open) return { action: 'resume-pr', reason: 'matching-open-pr', pullRequest: open };
|
||||
const merged = relevant.filter(pr => Boolean(pr.mergedAt || pr.merged)).sort((a, b) => time(b.mergedAt || b.updatedAt) - time(a.mergedAt || a.updatedAt))[0];
|
||||
if (merged) return { action: 'deploy', reason: 'matching-pr-already-merged', pullRequest: merged };
|
||||
return { action: 'source', reason: 'source-update-required' };
|
||||
}
|
||||
|
||||
function normalizeCheck(check) {
|
||||
const status = lower(check?.status);
|
||||
const conclusion = lower(check?.conclusion);
|
||||
const updatedAt = check?.completedAt || check?.startedAt || check?.updatedAt || '';
|
||||
let state = 'missing';
|
||||
if (ACTIVE.has(status) || (!conclusion && status && status !== 'completed')) state = 'pending';
|
||||
else if (SUCCESS.has(conclusion)) state = 'success';
|
||||
else if (FAILURE.has(conclusion)) state = 'failure';
|
||||
else if (CANCELLED.has(conclusion)) state = 'cancelled';
|
||||
else if (status === 'completed' && !conclusion) state = 'pending';
|
||||
return { name: String(check?.name || ''), workflowName: String(check?.workflowName || ''), state, status, conclusion, updatedAt, detailsUrl: String(check?.detailsUrl || '') };
|
||||
}
|
||||
|
||||
function bestCheck(checks) {
|
||||
return [...checks].sort((a, b) => {
|
||||
const dateDelta = time(b.updatedAt) - time(a.updatedAt);
|
||||
if (dateDelta) return dateDelta;
|
||||
const rank = { success: 4, pending: 3, failure: 2, cancelled: 1, missing: 0 };
|
||||
return (rank[b.state] || 0) - (rank[a.state] || 0);
|
||||
})[0];
|
||||
}
|
||||
|
||||
function normalizeRun(run) {
|
||||
const status = lower(run?.status);
|
||||
const conclusion = lower(run?.conclusion);
|
||||
let state = 'missing';
|
||||
if (ACTIVE.has(status)) state = 'pending';
|
||||
else if (SUCCESS.has(conclusion)) state = 'success';
|
||||
else if (FAILURE.has(conclusion)) state = 'failure';
|
||||
else if (CANCELLED.has(conclusion)) state = 'cancelled';
|
||||
return { name: String(run?.name || run?.workflowName || ''), state, status, conclusion, updatedAt: run?.updated_at || run?.updatedAt || run?.run_started_at || '', url: String(run?.html_url || run?.url || '') };
|
||||
}
|
||||
|
||||
function bestRun(runs) {
|
||||
const successful = runs.filter(run => run.state === 'success').sort((a, b) => time(b.updatedAt) - time(a.updatedAt))[0];
|
||||
if (successful) return successful;
|
||||
return [...runs].sort((a, b) => time(b.updatedAt) - time(a.updatedAt))[0];
|
||||
}
|
||||
|
||||
export function classifyChecks({ checks = [], workflowRuns = [], requiredChecks = REQUIRED_CHECKS, requiredWorkflows = REQUIRED_WORKFLOWS, elapsedSeconds = 0, gracePeriodSeconds = 90, timedOut = false }) {
|
||||
const normalizedChecks = checks.map(normalizeCheck);
|
||||
const normalizedRuns = workflowRuns.map(normalizeRun);
|
||||
const checkRows = requiredChecks.map(name => {
|
||||
const candidates = normalizedChecks.filter(check => check.name === name);
|
||||
return candidates.length ? bestCheck(candidates) : { name, state: 'missing', status: '', conclusion: '', updatedAt: '', detailsUrl: '' };
|
||||
});
|
||||
const workflowRows = requiredWorkflows.map(name => {
|
||||
const candidates = normalizedRuns.filter(run => run.name === name);
|
||||
return candidates.length ? bestRun(candidates) : { name, state: 'missing', status: '', conclusion: '', updatedAt: '', url: '' };
|
||||
});
|
||||
const rows = [...checkRows, ...workflowRows];
|
||||
const terminalFailure = rows.find(row => row.state === 'failure');
|
||||
if (terminalFailure) return { state: 'failed', reason: `${terminalFailure.name}: ${terminalFailure.conclusion || terminalFailure.status}`, rows };
|
||||
const terminalCancelled = rows.find(row => row.state === 'cancelled');
|
||||
if (terminalCancelled) return { state: 'failed', reason: `${terminalCancelled.name}: cancelled`, rows };
|
||||
if (rows.every(row => row.state === 'success')) return { state: 'success', reason: 'all-required-checks-successful', rows };
|
||||
const missing = rows.filter(row => row.state === 'missing');
|
||||
if (timedOut) return { state: 'timeout', reason: 'required-checks-not-terminal-before-timeout', rows };
|
||||
if (missing.length && elapsedSeconds < gracePeriodSeconds) return { state: 'waiting-registration', reason: 'checks-within-registration-grace-period', rows };
|
||||
if (missing.length) return { state: 'waiting-missing', reason: 'required-checks-not-yet-registered', rows };
|
||||
return { state: 'pending', reason: 'required-checks-still-running', rows };
|
||||
}
|
||||
|
||||
export function formatStatusMatrix(rows = []) {
|
||||
return rows.map(row => `${row.name}: ${row.state} (${row.conclusion || row.status || 'not-reported'})`).join('\n');
|
||||
}
|
||||
|
||||
export function evaluatePayloadGuard({ baseCommit, currentMainCommit, baseIsAncestor, conflictingPaths = [] }) {
|
||||
if (!baseCommit || !currentMainCommit) return { allowed: false, reason: 'missing-commit-provenance', conflicts: [] };
|
||||
if (!baseIsAncestor) return { allowed: false, reason: 'base-is-not-ancestor-of-main', conflicts: [] };
|
||||
if (conflictingPaths.length) return { allowed: false, reason: 'payload-would-overwrite-newer-main', conflicts: [...conflictingPaths].sort() };
|
||||
return { allowed: true, reason: baseCommit === currentMainCommit ? 'exact-base' : 'non-conflicting-main-advance', conflicts: [] };
|
||||
}
|
||||
|
||||
function parseStdin() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let input = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => { input += chunk; });
|
||||
process.stdin.on('end', () => { try { resolve(input.trim() ? JSON.parse(input) : {}); } catch (error) { reject(error); } });
|
||||
process.stdin.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1].replaceAll('\\', '/')}`).href) {
|
||||
const command = process.argv[2];
|
||||
try {
|
||||
const input = await parseStdin();
|
||||
let output;
|
||||
if (command === 'decide') output = decideReleasePath(input);
|
||||
else if (command === 'classify-checks') output = classifyChecks(input);
|
||||
else if (command === 'payload-guard') output = evaluatePayloadGuard(input);
|
||||
else throw new Error(`Unbekannter Lifecycle-Befehl: ${command}`);
|
||||
process.stdout.write(`${JSON.stringify(output)}\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`[FEHLER] ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user