/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import { appendFileSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { basename, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; import { CLI_RELEASE_ARTIFACT_LIMITS } from './release-cli-artifact-policy.mjs'; import { assertProductNightlyVersion, parseProductNightlyVersion, parseProductReleaseVersion, } from './release-version.mjs'; const PACKAGE_NAME = 'maka-agent'; const REGISTRY_ORIGIN = 'https://registry.npmjs.org'; const REPOSITORY = 'apache/maka'; const PUBLICATION_WORKFLOW_PATH = '.github/workflows/npm-publication.yml'; const REGISTRY_REQUEST_TIMEOUT_MS = 30_000; const RELEASE_RECORD_KEYS = [ 'schemaVersion', 'packageName', 'version', 'productTag', 'distTag', 'tarball', 'sha256', 'checksum', 'inventory', 'source', 'publisher', ]; export function parseCliReleaseVersion(version) { const { prerelease } = parseProductReleaseVersion(version); if (prerelease.length > 0) { throw new Error('Formal CLI releases must use a stable product version'); } return { version, distTag: 'latest', tarball: `${PACKAGE_NAME}-${version}.tgz`, }; } export function parseCliNightlyVersion(version, productVersion) { assertProductNightlyVersion(version, productVersion); return { version, distTag: 'nightly', tarball: `${PACKAGE_NAME}-${version}.tgz`, }; } export function validateRegistryChannels({ releaseVersion, releaseDistTag, distTags }) { if (!distTags || typeof distTags !== 'object' || Array.isArray(distTags)) { throw new Error('Registry package metadata has no valid dist-tags'); } if (distTags[releaseDistTag] !== releaseVersion) { throw new Error(`Registry dist-tag ${releaseDistTag} does not point to ${releaseVersion}`); } } export function prepareNightlyRelease({ repoRoot, releaseDirectory, expectedVersion }) { const cliManifest = readJson(join(repoRoot, 'packages/cli/package.json'), 'CLI manifest'); if (cliManifest.name !== PACKAGE_NAME) { throw new Error(`CLI package name must be ${PACKAGE_NAME}`); } const identity = parseCliNightlyVersion(expectedVersion, cliManifest.version); const candidate = validateCandidateFiles(releaseDirectory, identity); return { ...identity, tarballPath: candidate.tarballPath, sha256: candidate.sha256 }; } export function prepareStageRelease({ repoRoot, releaseDirectory, expectedVersion, productTag, sourceSha, publisherSha, runId, runAttempt, repository, workflowPath, }) { const cliManifest = readJson(join(repoRoot, 'packages/cli/package.json'), 'CLI manifest'); if (cliManifest.name !== PACKAGE_NAME) { throw new Error(`CLI package name must be ${PACKAGE_NAME}`); } const identity = parseCliReleaseVersion(expectedVersion); if (productTag !== `v${identity.version}`) { throw new Error(`Product tag ${productTag} does not match ${identity.version}`); } validateProductSource({ sourceSha, repository }); validatePublisherIdentity({ publisherSha, runId, runAttempt, workflowPath, }); const candidate = validateCandidateFiles(releaseDirectory, identity); const record = { schemaVersion: 4, packageName: PACKAGE_NAME, ...identity, productTag, sha256: candidate.sha256, checksum: `${identity.tarball}.sha256`, inventory: `${identity.tarball}.files.json`, source: { repository, commit: sourceSha, }, publisher: { workflow: workflowPath, commit: publisherSha, runId, runAttempt, }, }; writeFileSync(join(releaseDirectory, 'release.json'), `${JSON.stringify(record, null, 2)}\n`, { flag: 'wx', mode: 0o644, }); return { record, tarballPath: candidate.tarballPath }; } export function validateStageRun({ releaseDirectory, expectedVersion, run }) { const record = loadReleaseRecord(releaseDirectory); if (expectedVersion !== record.version) { throw new Error( `Finalization version confirmation ${expectedVersion} does not match ${record.version}`, ); } if ( !run || String(run.id) !== record.publisher.runId || String(run.run_attempt) !== record.publisher.runAttempt || run.path !== record.publisher.workflow || run.event !== 'workflow_dispatch' || run.head_branch !== 'main' || run.head_sha !== record.publisher.commit || run.conclusion !== 'success' || run.head_repository?.full_name !== record.source.repository ) { throw new Error( 'Release record does not belong to the exact successful main-branch stage workflow run', ); } return record; } export async function fetchRegistryRelease({ releaseDirectory, registryDirectory, fetchImpl = fetch, }) { const record = loadReleaseRecord(releaseDirectory); const versionUrl = `${REGISTRY_ORIGIN}/${PACKAGE_NAME}/${encodeURIComponent(record.version)}`; const metadata = await fetchJson( fetchImpl, versionUrl, 'package version metadata', 'application/json', ); if (metadata.name !== PACKAGE_NAME || metadata.version !== record.version) { throw new Error('Registry package identity does not match the staged release'); } const tags = await fetchJson(fetchImpl, `${REGISTRY_ORIGIN}/${PACKAGE_NAME}`, 'package metadata'); validateRegistryChannels({ releaseVersion: record.version, releaseDistTag: record.distTag, distTags: tags['dist-tags'], }); const tarballUrl = parseRegistryTarballUrl(metadata.dist?.tarball, record.tarball); const response = await fetchImpl(tarballUrl, { redirect: 'error' }); if (!response.ok) { throw new Error(`Registry tarball request failed with status ${response.status}`); } const bytes = await readBoundedBytes( response, CLI_RELEASE_ARTIFACT_LIMITS.compressedBytes, 'Registry tarball exceeds the reviewed compressed size limit', ); const sha256 = digest('sha256', bytes, 'hex'); if (sha256 !== record.sha256) { throw new Error('Registry tarball does not match the staged release checksum'); } if (metadata.dist?.integrity !== `sha512-${digest('sha512', bytes, 'base64')}`) { throw new Error('Registry tarball does not match its published integrity'); } if (metadata.dist?.shasum !== digest('sha1', bytes, 'hex')) { throw new Error('Registry tarball does not match its published shasum'); } mkdirSync(registryDirectory, { recursive: true, mode: 0o755 }); const tarballPath = join(registryDirectory, record.tarball); writeFileSync(tarballPath, bytes, { flag: 'wx', mode: 0o644 }); for (const name of [record.checksum, record.inventory, 'release.json']) { copyFileSync(join(releaseDirectory, name), join(registryDirectory, name)); } return { ...record, tarballPath, sha256 }; } export async function resolveRegistryNightlyPredecessor({ fetchImpl = fetch } = {}) { const packageMetadata = await fetchJson( fetchImpl, `${REGISTRY_ORIGIN}/${PACKAGE_NAME}`, 'package metadata', ); const version = packageMetadata?.['dist-tags']?.nightly; parseProductNightlyVersion(version); const versionMetadata = await fetchJson( fetchImpl, `${REGISTRY_ORIGIN}/${PACKAGE_NAME}/${encodeURIComponent(version)}`, 'package version metadata', 'application/json', ); if (versionMetadata.name !== PACKAGE_NAME || versionMetadata.version !== version) { throw new Error('Registry Nightly identity does not match its dist-tag'); } const tarball = `${PACKAGE_NAME}-${version}.tgz`; const tarballUrl = parseRegistryTarballUrl(versionMetadata.dist?.tarball, tarball); const integrity = parseSha512Integrity(versionMetadata.dist?.integrity); return { version, tarballUrl, integrity, }; } export async function assertRegistryNightlyPredecessor({ expectedVersion, expectedTarballUrl, expectedIntegrity, fetchImpl = fetch, }) { const current = await resolveRegistryNightlyPredecessor({ fetchImpl }); if ( current.version !== expectedVersion || current.tarballUrl !== expectedTarballUrl || current.integrity !== expectedIntegrity ) { throw new Error( `Qualified npm Nightly predecessor ${expectedVersion} is no longer current; found ${current.version}`, ); } return current; } export function validateSignatureAudit({ releaseDirectory, audit }) { const record = loadReleaseRecord(releaseDirectory); if (!Array.isArray(audit?.invalid) || !Array.isArray(audit?.missing)) { throw new Error('npm signature audit did not return its bounded result arrays'); } if (audit.invalid.length > 0 || audit.missing.length > 0) { throw new Error('npm signature audit found invalid or missing signatures'); } const verified = Array.isArray(audit.verified) ? audit.verified : []; const own = verified.find( (entry) => entry?.name === PACKAGE_NAME && entry.version === record.version, ); if (!own?.attestations?.provenance) { throw new Error( `npm signature audit did not include verified provenance for ${record.version}`, ); } const statements = (own.attestationBundles ?? []).map(parseProvenanceStatement); if (!statements.some((statement) => matchesReleaseProvenance(statement, record))) { throw new Error(`npm signature audit provenance does not match ${record.productTag}`); } return record; } export function prepareSignatureAuditTree({ releaseDirectory, auditDirectory }) { const record = loadReleaseRecord(releaseDirectory); const packageDirectory = join(auditDirectory, 'node_modules', PACKAGE_NAME); mkdirSync(packageDirectory, { recursive: true, mode: 0o755 }); writeJson( join(auditDirectory, 'package.json'), { name: 'maka-cli-signature-audit', private: true, dependencies: { [PACKAGE_NAME]: record.version }, }, 0o644, ); writeJson( join(packageDirectory, 'package.json'), { name: PACKAGE_NAME, version: record.version }, 0o644, ); return record; } function loadReleaseRecord(releaseDirectory) { const record = readJson(join(releaseDirectory, 'release.json'), 'release record'); exactKeys(record, RELEASE_RECORD_KEYS, 'release record'); if (record.schemaVersion !== 4 || record.packageName !== PACKAGE_NAME) { throw new Error('Unsupported CLI release record'); } const identity = parseCliReleaseVersion(record.version); const derivedFields = { productTag: `v${identity.version}`, distTag: identity.distTag, tarball: identity.tarball, checksum: `${identity.tarball}.sha256`, inventory: `${identity.tarball}.files.json`, }; for (const [key, expected] of Object.entries(derivedFields)) { if (record[key] !== expected) throw new Error(`Release record ${key} is inconsistent`); } if (!/^[0-9a-f]{64}$/u.test(record.sha256)) { throw new Error('Release record sha256 is invalid'); } exactKeys(record.source, ['repository', 'commit'], 'release source'); validateProductSource({ sourceSha: record.source.commit, repository: record.source.repository, }); exactKeys(record.publisher, ['workflow', 'commit', 'runId', 'runAttempt'], 'release publisher'); validatePublisherIdentity({ publisherSha: record.publisher.commit, runId: record.publisher.runId, runAttempt: record.publisher.runAttempt, workflowPath: record.publisher.workflow, }); const candidate = validateCandidateFiles(releaseDirectory, identity); if (candidate.sha256 !== record.sha256) { throw new Error('Release record checksum does not match the candidate'); } return record; } function validateCandidateFiles(releaseDirectory, identity) { const tarballPath = join(releaseDirectory, identity.tarball); const bytes = readFileSync(tarballPath); if (bytes.length > CLI_RELEASE_ARTIFACT_LIMITS.compressedBytes) { throw new Error('CLI release candidate exceeds the reviewed compressed size limit'); } const checksum = readFileSync(`${tarballPath}.sha256`, 'utf8'); const match = /^([0-9a-f]{64}) {2}([^\r\n]+)\r?\n?$/u.exec(checksum); if (!match || match[2] !== identity.tarball) { throw new Error('CLI release candidate checksum sidecar is malformed'); } const sha256 = digest('sha256', bytes, 'hex'); if (match[1] !== sha256) { throw new Error('CLI release candidate checksum does not match'); } const inventory = readJson(`${tarballPath}.files.json`, 'CLI release file inventory'); if (!Array.isArray(inventory)) throw new Error('CLI release file inventory must be an array'); return { tarballPath, sha256 }; } function validateProductSource({ sourceSha, repository }) { if (!/^[0-9a-f]{40}$/u.test(sourceSha)) throw new Error('Release source SHA is invalid'); if (repository !== REPOSITORY) throw new Error(`Release repository must be ${REPOSITORY}`); } function validatePublisherIdentity({ publisherSha, runId, runAttempt, workflowPath }) { if (!/^[0-9a-f]{40}$/u.test(publisherSha)) throw new Error('Release publisher SHA is invalid'); if (!/^[1-9]\d*$/u.test(runId)) throw new Error('Release workflow run ID is invalid'); if (!/^[1-9]\d*$/u.test(runAttempt)) throw new Error('Release workflow run attempt is invalid'); if (workflowPath !== PUBLICATION_WORKFLOW_PATH) { throw new Error(`Release workflow must be ${PUBLICATION_WORKFLOW_PATH}`); } } function parseProvenanceStatement(attestation) { if (attestation?.predicateType !== 'https://slsa.dev/provenance/v1') return null; const envelope = attestation.bundle?.dsseEnvelope; if ( envelope?.payloadType !== 'application/vnd.in-toto+json' || typeof envelope.payload !== 'string' ) { return null; } try { return JSON.parse(Buffer.from(envelope.payload, 'base64').toString('utf8')); } catch { return null; } } function matchesReleaseProvenance(statement, record) { const repository = `https://github.com/${record.source.repository}`; const ref = 'refs/heads/main'; const definition = statement?.predicate?.buildDefinition; const workflow = definition?.externalParameters?.workflow; const dependencies = definition?.resolvedDependencies; const invocationId = `${repository}/actions/runs/${record.publisher.runId}/attempts/${record.publisher.runAttempt}`; return ( statement?._type === 'https://in-toto.io/Statement/v1' && statement?.predicateType === 'https://slsa.dev/provenance/v1' && definition?.buildType === 'https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1' && workflow?.repository === repository && workflow?.ref === ref && workflow?.path === record.publisher.workflow && Array.isArray(dependencies) && dependencies.some( (dependency) => dependency?.uri === `git+${repository}@${ref}` && dependency?.digest?.gitCommit === record.publisher.commit, ) && definition?.internalParameters?.github?.event_name === 'workflow_dispatch' && statement?.predicate?.runDetails?.builder?.id?.startsWith( 'https://github.com/actions/runner/', ) && statement?.predicate?.runDetails?.metadata?.invocationId === invocationId ); } async function fetchJson(fetchImpl, url, label, accept = 'application/vnd.npm.install-v1+json') { const response = await fetchImpl(url, { headers: { accept }, redirect: 'error', signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), }); if (!response.ok) throw new Error(`Registry ${label} request failed with status ${response.status}`); const bytes = await readBoundedBytes( response, 4 * 1024 * 1024, `Registry ${label} exceeds the bounded response size`, ); try { return JSON.parse(bytes.toString('utf8')); } catch (error) { throw new Error(`Registry ${label} is not valid JSON`, { cause: error }); } } async function readBoundedBytes(response, limit, errorMessage) { const contentLength = response.headers.get('content-length'); if (contentLength !== null && Number(contentLength) > limit) { throw new Error(errorMessage); } if (!response.body) return Buffer.alloc(0); const reader = response.body.getReader(); const chunks = []; let total = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > limit) { await reader.cancel(); throw new Error(errorMessage); } chunks.push(Buffer.from(value)); } } finally { reader.releaseLock(); } return Buffer.concat(chunks, total); } function parseRegistryTarballUrl(value, expectedName) { if (typeof value !== 'string' || !URL.canParse(value)) { throw new Error('Registry package metadata has no valid tarball URL'); } const url = new URL(value); if ( url.origin !== REGISTRY_ORIGIN || url.username || url.password || basename(url.pathname) !== expectedName ) { throw new Error('Registry package metadata points outside the npm registry release path'); } return url.href; } function parseSha512Integrity(value) { if (typeof value !== 'string' || !value.startsWith('sha512-')) { throw new Error('Registry package metadata has no valid SHA-512 integrity'); } const encoded = value.slice('sha512-'.length); const bytes = Buffer.from(encoded, 'base64'); if (bytes.byteLength !== 64 || bytes.toString('base64') !== encoded) { throw new Error('Registry package metadata has no valid SHA-512 integrity'); } return value; } function exactKeys(value, keys, label) { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`${label} must be an object`); } const actual = Object.keys(value).sort(); const expected = [...keys].sort(); if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { throw new Error(`${label} fields are invalid`); } } function readJson(path, label) { try { return JSON.parse(readFileSync(path, 'utf8')); } catch (error) { throw new Error(`${label} is unavailable or invalid`, { cause: error }); } } function writeJson(path, value, mode) { writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode }); } function digest(algorithm, bytes, encoding) { return createHash(algorithm).update(bytes).digest(encoding); } function appendOutputs(path, values) { for (const [name, value] of Object.entries(values)) { const text = String(value); if (!/^[a-z_]+$/u.test(name) || /[\r\n]/u.test(text)) { throw new Error('Unsafe GitHub Actions output'); } appendFileSync(path, `${name}=${text}\n`, 'utf8'); } } async function main() { const [command, ...args] = process.argv.slice(2); if (command === 'prepare-stage' && args.length === 10) { const [ releaseDirectory, expectedVersion, productTag, sourceSha, publisherSha, runId, runAttempt, repository, workflowPath, output, ] = args; const result = prepareStageRelease({ repoRoot: resolve(import.meta.dirname, '..'), releaseDirectory: resolve(releaseDirectory), expectedVersion, productTag, sourceSha, publisherSha, runId, runAttempt, repository, workflowPath, }); appendOutputs(output, { version: result.record.version, dist_tag: result.record.distTag, tarball: result.tarballPath, }); return; } if (command === 'prepare-nightly' && args.length === 3) { const [releaseDirectory, expectedVersion, output] = args; const result = prepareNightlyRelease({ repoRoot: resolve(import.meta.dirname, '..'), releaseDirectory: resolve(releaseDirectory), expectedVersion, }); appendOutputs(output, { version: result.version, dist_tag: result.distTag, tarball: result.tarballPath, }); return; } if (command === 'validate-stage-run' && (args.length === 3 || args.length === 4)) { const [releaseDirectory, runPath, expectedVersion, output] = args; const record = validateStageRun({ releaseDirectory: resolve(releaseDirectory), expectedVersion, run: readJson(resolve(runPath), 'stage workflow run'), }); if (output) { appendOutputs(output, { product_tag: record.productTag, source_commit: record.source.commit, version: record.version, }); } return; } if (command === 'prepare-audit' && args.length === 2) { const [releaseDirectory, auditDirectory] = args; prepareSignatureAuditTree({ releaseDirectory: resolve(releaseDirectory), auditDirectory: resolve(auditDirectory), }); return; } if (command === 'fetch-registry' && args.length === 2) { const [releaseDirectory, registryDirectory] = args; await fetchRegistryRelease({ releaseDirectory: resolve(releaseDirectory), registryDirectory: resolve(registryDirectory), }); return; } if (command === 'resolve-nightly-predecessor' && args.length === 1) { const [output] = args; const predecessor = await resolveRegistryNightlyPredecessor(); appendOutputs(output, { version: predecessor.version, tarball_url: predecessor.tarballUrl, integrity: predecessor.integrity, }); return; } if (command === 'assert-nightly-predecessor' && args.length === 3) { const [expectedVersion, expectedTarballUrl, expectedIntegrity] = args; await assertRegistryNightlyPredecessor({ expectedVersion, expectedTarballUrl, expectedIntegrity, }); return; } if (command === 'validate-audit' && args.length === 2) { const [releaseDirectory, auditPath] = args; validateSignatureAudit({ releaseDirectory: resolve(releaseDirectory), audit: readJson(resolve(auditPath), 'npm signature audit'), }); return; } throw new Error( `Usage: release-cli-publication.mjs ...`, ); } if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { await main(); }