Updater 3.4 – Lifecycle Hardening #28

Merged
Masterluke77 merged 96 commits from feature/updater-3.4-lifecycle into main 2026-07-10 17:54:37 +00:00
Showing only changes of commit 25254e37d6 - Show all commits
+196
View File
@@ -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);
}
}