#!/usr/bin/env node import { createHash } from 'node:crypto' import { readFileSync, writeFileSync } from 'node:fs' const shaPattern = /^[0-9a-f]{40}$/ const digestImagePattern = /^[A-Za-z0-9.-]+(?::[0-9]+)?(?:\/[A-Za-z0-9._-]+)+@sha256:[0-9a-f]{64}$/ const allowedComponents = new Set(['api', 'web']) const smokeChecks = [ 'health', 'readiness', 'openapi', 'register', 'login', 'apiKey', 'models', 'chatCompletions', 'responses', 'images', 'web', ] function requireExactKeys(value, keys, path) { const actual = Object.keys(value).sort() const expected = [...keys].sort() if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { fail(`${path} has unexpected or missing fields`) } } function releaseIntegrity(manifest) { const payload = structuredClone(manifest) delete payload.integrity delete payload.deployment return createHash('sha256').update(JSON.stringify(payload)).digest('hex') } function fail(message) { console.error(`release_manifest=FAIL ${message}`) process.exit(1) } function load(path) { try { return JSON.parse(readFileSync(path, 'utf8')) } catch (error) { fail(`cannot read JSON manifest: ${error.message}`) } } function validate(manifest) { if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { fail('manifest root must be an object') } const rootKeys = [ 'schemaVersion', 'releaseId', 'sourceSha', 'baseReleaseSha', 'sourceRef', 'createdAt', 'buildCompletedAt', 'targetPlatform', 'components', 'migrationsChanged', 'images', 'smoke', 'integrity', ] if (manifest.deployment !== undefined) rootKeys.push('deployment') requireExactKeys(manifest, rootKeys, 'manifest') if (manifest.schemaVersion !== 1) fail('schemaVersion must be 1') if (!shaPattern.test(manifest.sourceSha || '')) fail('sourceSha must be a full lowercase Git SHA') if (manifest.releaseId !== manifest.sourceSha) fail('releaseId must equal sourceSha') if (manifest.sourceRef !== 'refs/heads/main') fail('sourceRef must be refs/heads/main') if (manifest.baseReleaseSha !== null && !shaPattern.test(manifest.baseReleaseSha || '')) { fail('baseReleaseSha must be null or a full lowercase Git SHA') } if (manifest.baseReleaseSha === manifest.sourceSha) fail('baseReleaseSha must differ from sourceSha') if (manifest.targetPlatform !== 'linux/amd64') fail('targetPlatform must be linux/amd64') if (!Number.isFinite(Date.parse(manifest.createdAt))) fail('createdAt must be an ISO timestamp') if (!Number.isFinite(Date.parse(manifest.buildCompletedAt))) { fail('buildCompletedAt must be an ISO timestamp') } if (!Array.isArray(manifest.components) || manifest.components.length < 1 || manifest.components.length > 2) { fail('components must contain api, web, or both') } const uniqueComponents = [...new Set(manifest.components)] if (uniqueComponents.length !== manifest.components.length || uniqueComponents.some((component) => !allowedComponents.has(component))) { fail('components contains an invalid or duplicate value') } if (typeof manifest.migrationsChanged !== 'boolean') fail('migrationsChanged must be boolean') if (manifest.migrationsChanged && !manifest.components.includes('api')) { fail('migration changes require the api component') } if (!manifest.images || typeof manifest.images !== 'object') fail('images must be an object') requireExactKeys(manifest.images, ['api', 'web'], 'images') for (const component of allowedComponents) { if (!digestImagePattern.test(manifest.images[component] || '')) { fail(`images.${component} must be a registry digest reference`) } } if (!manifest.smoke || manifest.smoke.status !== 'passed' || manifest.smoke.mode !== 'simulation') { fail('smoke must record a passed simulation check') } requireExactKeys(manifest.smoke, ['status', 'mode', 'completedAt', 'checks'], 'smoke') if (!Number.isFinite(Date.parse(manifest.smoke.completedAt))) { fail('smoke.completedAt must be an ISO timestamp') } if (!Array.isArray(manifest.smoke.checks) || manifest.smoke.checks.length !== smokeChecks.length || manifest.smoke.checks.some((check, index) => check !== smokeChecks[index])) { fail('smoke.checks is incomplete or out of order') } if (!manifest.integrity || typeof manifest.integrity !== 'object') { fail('integrity must be an object') } requireExactKeys(manifest.integrity, ['algorithm', 'value'], 'integrity') if (manifest.integrity.algorithm !== 'sha256' || !/^[0-9a-f]{64}$/.test(manifest.integrity.value || '')) { fail('integrity must contain a SHA-256 value') } if (releaseIntegrity(manifest) !== manifest.integrity.value) { fail('manifest content integrity check failed') } if (manifest.deployment !== undefined) { if (!manifest.deployment || typeof manifest.deployment !== 'object') { fail('deployment must be an object') } requireExactKeys(manifest.deployment, ['status', 'action', 'deployedAt', 'durationSeconds'], 'deployment') if (manifest.deployment.status !== 'passed' || !['deploy', 'rollback'].includes(manifest.deployment.action) || !Number.isFinite(Date.parse(manifest.deployment.deployedAt)) || !Number.isInteger(manifest.deployment.durationSeconds) || manifest.deployment.durationSeconds < 0) { fail('deployment record is invalid') } } const forbiddenKey = /(password|secret|token|credential|private.?key)/i const visit = (value, path = '') => { if (!value || typeof value !== 'object') return for (const [key, child] of Object.entries(value)) { const childPath = path ? `${path}.${key}` : key if (forbiddenKey.test(key)) fail(`forbidden sensitive field: ${childPath}`) visit(child, childPath) } } visit(manifest) return manifest } function requireEnv(name, pattern) { const value = process.env[name] || '' if (!value || (pattern && !pattern.test(value))) fail(`invalid or missing ${name}`) return value } const [command = 'validate', path, field] = process.argv.slice(2) if (command === 'create') { if (!path) fail('create requires an output path') const sourceSha = requireEnv('RELEASE_SOURCE_SHA', shaPattern) const baseReleaseSha = process.env.RELEASE_BASE_SHA || null if (baseReleaseSha !== null && !shaPattern.test(baseReleaseSha)) fail('invalid RELEASE_BASE_SHA') const components = requireEnv('RELEASE_COMPONENTS').split(',').filter(Boolean) const migrationsChanged = process.env.RELEASE_MIGRATIONS_CHANGED === 'true' const manifest = { schemaVersion: 1, releaseId: sourceSha, sourceSha, baseReleaseSha, sourceRef: 'refs/heads/main', createdAt: new Date().toISOString(), buildCompletedAt: requireEnv('RELEASE_BUILD_COMPLETED_AT'), targetPlatform: 'linux/amd64', components, migrationsChanged, images: { api: requireEnv('RELEASE_API_IMAGE', digestImagePattern), web: requireEnv('RELEASE_WEB_IMAGE', digestImagePattern), }, smoke: { status: 'passed', mode: 'simulation', completedAt: requireEnv('RELEASE_SMOKE_COMPLETED_AT'), checks: smokeChecks, }, integrity: { algorithm: 'sha256', value: '' }, } manifest.integrity.value = releaseIntegrity(manifest) validate(manifest) writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600 }) const digest = createHash('sha256').update(readFileSync(path)).digest('hex') console.log(`release_manifest=CREATED path=${path} sha256=${digest}`) process.exit(0) } if (!path) fail(`${command} requires a manifest path`) const manifest = validate(load(path)) if (command === 'record-deployment') { if (!field) fail('record-deployment requires an output path') const deployedAt = requireEnv('RELEASE_DEPLOYED_AT') const durationSeconds = Number(process.env.RELEASE_DEPLOY_DURATION_SECONDS || '') const action = process.env.RELEASE_DEPLOY_ACTION || 'deploy' if (!Number.isFinite(Date.parse(deployedAt))) fail('invalid RELEASE_DEPLOYED_AT') if (!Number.isInteger(durationSeconds) || durationSeconds < 0) { fail('invalid RELEASE_DEPLOY_DURATION_SECONDS') } if (!['deploy', 'rollback'].includes(action)) fail('invalid RELEASE_DEPLOY_ACTION') manifest.deployment = { status: 'passed', action, deployedAt, durationSeconds } validate(manifest) writeFileSync(field, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600 }) console.log(`release_manifest=RECORDED release=${manifest.releaseId} action=${action}`) process.exit(0) } if (command === 'validate') { const digest = createHash('sha256').update(readFileSync(path)).digest('hex') console.log(`release_manifest=PASS release=${manifest.releaseId} sha256=${digest}`) process.exit(0) } if (command === 'get') { if (!field || !/^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/.test(field)) { fail('get requires a safe dotted field path') } let value = manifest for (const segment of field.split('.')) value = value?.[segment] if (typeof value === 'object') console.log(JSON.stringify(value)) else if (value === null || value === undefined) console.log('') else console.log(String(value)) process.exit(0) } fail(`unknown command: ${command}`)