From 25254e37d635e91a482c8f6833b51a934e72aee3 Mon Sep 17 00:00:00 2001 From: Masterluke77 <163446896+Masterluke77@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:22:24 +0200 Subject: [PATCH] feat: add updater 3.4 lifecycle model --- tools/updater-lifecycle-3.4.mjs | 196 ++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 tools/updater-lifecycle-3.4.mjs diff --git a/tools/updater-lifecycle-3.4.mjs b/tools/updater-lifecycle-3.4.mjs new file mode 100644 index 0000000..8cca9f3 --- /dev/null +++ b/tools/updater-lifecycle-3.4.mjs @@ -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); + } +}