Files
vendoo/tools/updater-lifecycle-3.4.mjs
Masterluke77andGitHub 637275effc Updater 3.4 – Lifecycle Hardening (#28)
Production-ready updater lifecycle hardening with final-head checks, Windows PowerShell 5.1 compatibility, reproducible package generation, provenance validation, payload rollback protection and documented acceptance.
2026-07-10 19:54:36 +02:00

201 lines
9.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
'Updater-3.4-Paket nach Abnahme',
]);
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) {
return [...runs].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];
}
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);
}
}