Files

305 lines
11 KiB
JavaScript

import { createHash } from 'node:crypto'
import { assertDistribution } from './shared/distribution.mjs'
import {
access,
copyFile,
mkdir,
readFile,
readdir,
rename,
rm,
rmdir,
writeFile
} from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { verifyReleaseBundle } from './artifact-bundle.mjs'
import { resolveReleaseAssetPath } from './r2/fragment-contract.mjs'
import { compareReleaseVersions, parseReleaseTag } from './tag.mjs'
import { assertPackageRegressionPassed } from './regression.mjs'
const CANDIDATE_STATES = new Set(['prepared', 'ready', 'rejected', 'published'])
const E2E_STATES = new Set(['pending', 'passed', 'failed', 'skipped'])
const candidateRoot = () =>
path.resolve(
String(process.env.NCURATOR_RELEASE_CANDIDATE_ROOT || '').trim() ||
path.join(os.homedir(), '.ncurator', 'release-candidates')
)
export const resolveCandidateDirectory = (tag, platform, distribution = 'domestic') =>
path.join(candidateRoot(), tag, platform, assertDistribution(distribution))
const statePathFor = (directory) => path.join(directory, 'candidate-state.json')
const assertCandidateState = (state, expected = {}) => {
if (!state || typeof state !== 'object') throw new Error('candidate state is missing')
if (state.schemaVersion !== 2) throw new Error('candidate state schemaVersion must be 2')
try {
parseReleaseTag(state.tag, state.platform)
} catch {
throw new Error('candidate state tag is invalid')
}
if (!/^[a-f0-9]{40}$/u.test(state.commit)) throw new Error('candidate state commit is invalid')
if (expected.tag && state.tag !== expected.tag) {
throw new Error(`candidate state tag must be ${expected.tag}`)
}
if (expected.platform && state.platform !== expected.platform) {
throw new Error(`candidate state platform must be ${expected.platform}`)
}
if (!CANDIDATE_STATES.has(state.status)) throw new Error('candidate status is invalid')
if (!E2E_STATES.has(state.e2e?.status)) throw new Error('candidate E2E status is invalid')
const allowedE2EStatuses = {
prepared: ['pending'],
ready: ['passed', 'skipped'],
rejected: ['failed'],
published: ['passed', 'skipped']
}[state.status]
if (!allowedE2EStatuses.includes(state.e2e.status)) {
throw new Error(
`candidate ${state.status} state must use E2E status ${allowedE2EStatuses.join(' or ')}`
)
}
if (typeof state.bundle !== 'string' || state.bundle !== path.basename(state.bundle)) {
throw new Error('candidate bundle path is invalid')
}
return state
}
const writeState = async (directory, state) => {
assertCandidateState(state)
const statePath = statePathFor(directory)
const temporaryPath = `${statePath}.tmp`
await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
await rename(temporaryPath, statePath)
return state
}
export const materializeReleaseCandidate = async ({ bundlePath }) => {
const verified = await verifyReleaseBundle(bundlePath)
const distribution = verified.bundle.distribution || 'domestic'
const directory = resolveCandidateDirectory(
verified.bundle.tag,
verified.bundle.platform,
distribution
)
await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 })
await mkdir(directory, { recursive: true })
const bundleName = path.basename(bundlePath)
await copyFile(bundlePath, path.join(directory, bundleName))
await copyFile(verified.fragmentPath, path.join(directory, path.basename(verified.fragmentPath)))
for (const file of verified.bundle.files) {
const source = await resolveReleaseAssetPath(verified.directory, file)
await copyFile(source, path.join(directory, file.name))
}
const candidateBundlePath = path.join(directory, bundleName)
await verifyReleaseBundle(candidateBundlePath, {
tag: verified.bundle.tag,
platform: verified.bundle.platform
})
const state = await writeState(directory, {
schemaVersion: 2,
tag: verified.bundle.tag,
commit: verified.bundle.commit,
platform: verified.bundle.platform,
distribution,
bundle: bundleName,
preparedAt: new Date().toISOString(),
status: 'prepared',
e2e: { status: 'pending' }
})
return { directory, bundlePath: candidateBundlePath, state }
}
export const readReleaseCandidate = async ({ tag, platform, distribution = 'domestic' }) => {
const directory = resolveCandidateDirectory(tag, platform, distribution)
const state = assertCandidateState(JSON.parse(await readFile(statePathFor(directory), 'utf8')), {
tag,
platform
})
const bundlePath = path.join(directory, state.bundle)
const verified = await verifyReleaseBundle(bundlePath, { tag, platform })
if (
(verified.bundle.distribution || 'domestic') !== distribution ||
(state.distribution || 'domestic') !== distribution
)
throw new Error('candidate distribution does not match bundle')
if (verified.bundle.commit !== state.commit) {
throw new Error('candidate state commit does not match release bundle')
}
return { directory, state, bundlePath, verified }
}
export const recordCandidateE2E = async ({
tag,
platform,
status,
resultPath = '',
selection,
reason = ''
}) => {
if (!['passed', 'failed'].includes(status)) {
throw new Error(`unsupported candidate E2E status: ${status}`)
}
const candidate = await readReleaseCandidate({ tag, platform, distribution: 'domestic' })
if (candidate.state.status === 'published') {
throw new Error(`published candidate ${tag} ${platform} cannot be retested`)
}
if (status === 'passed') await verifyCandidateRegression(candidate, resultPath)
const state = {
...candidate.state,
status: status === 'failed' ? 'rejected' : 'ready',
e2e: {
status,
recordedAt: new Date().toISOString(),
...(resultPath ? { result: path.relative(candidate.directory, resultPath) } : {}),
...(selection ? { selection } : {}),
...(reason ? { reason: String(reason).trim() } : {})
}
}
await writeState(candidate.directory, state)
return { ...candidate, state }
}
const verifyCandidateRegression = async (candidate, resultPath = '') => {
const file =
resultPath ||
(candidate.state.e2e.result && path.join(candidate.directory, candidate.state.e2e.result))
if (!file) throw new Error('candidate requires a package E2E result file')
const result = JSON.parse(await readFile(file, 'utf8'))
const installer = candidate.verified.bundle.files.find((item) => item.role === 'installer')
assertPackageRegressionPassed(result, candidate.verified.bundle, installer)
}
export const assertCandidatePublishable = async ({
tag,
platform,
commit,
allowPublished = false,
distribution = 'domestic'
}) => {
let candidate = await readReleaseCandidate({ tag, platform, distribution })
if (candidate.state.commit !== commit) {
throw new Error(
`candidate commit ${candidate.state.commit} does not match ${tag} commit ${commit}`
)
}
if (distribution === 'international') {
const domestic = await assertCandidatePublishable({
tag,
platform,
commit,
allowPublished,
distribution: 'domestic'
})
if (domestic.state.e2e.status !== 'passed')
throw new Error('international publication requires passed domestic E2E')
const resourceHash = domestic.verified.bundle.sharedAppHash
if (
!/^[a-f0-9]{64}$/u.test(resourceHash || '') ||
resourceHash !== candidate.verified.bundle.sharedAppHash
)
throw new Error('international resources do not match tested domestic resources')
const domesticBundleSha256 = createHash('sha256')
.update(await readFile(domestic.bundlePath))
.digest('hex')
if (candidate.state.status !== 'published') {
const state = {
...candidate.state,
status: 'ready',
e2e: {
status: 'skipped',
reason: 'No independent international E2E; inherits domestic regression',
domesticBundleSha256,
sharedAppHash: resourceHash,
recordedAt: new Date().toISOString()
}
}
await writeState(candidate.directory, state)
candidate = { ...candidate, state }
} else if (candidate.state.e2e.domesticBundleSha256 !== domesticBundleSha256) {
throw new Error('published international candidate domestic evidence changed')
}
}
if (
candidate.state.status !== 'ready' &&
!(allowPublished && candidate.state.status === 'published')
) {
throw new Error(
`candidate ${tag} ${platform} is ${candidate.state.status}; prepare or test it before publishing`
)
}
if (distribution === 'domestic') {
if (candidate.state.e2e.status !== 'passed')
throw new Error('domestic package E2E cannot be skipped')
await verifyCandidateRegression(candidate)
}
return candidate
}
export const markCandidatePublished = async ({ tag, platform, distribution = 'domestic' }) => {
const candidate = await readReleaseCandidate({ tag, platform, distribution })
const state = {
...candidate.state,
status: 'published',
publishedAt: new Date().toISOString()
}
await writeState(candidate.directory, state)
return { ...candidate, state }
}
const removeDirectoryIfEmpty = async (directory) => {
try {
await rmdir(directory)
} catch (error) {
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error?.code)) throw error
}
}
export const cleanupSupersededReleaseCandidates = async ({ tag, platforms }) => {
const current = parseReleaseTag(tag)
const selectedPlatforms = [...new Set(platforms || [])]
if (selectedPlatforms.length === 0) throw new Error('candidate cleanup requires a platform')
for (const platform of selectedPlatforms) parseReleaseTag(tag, platform)
const root = candidateRoot()
const entries = await readdir(root, { withFileTypes: true }).catch((error) => {
if (error?.code === 'ENOENT') return []
throw error
})
const removed = []
for (const entry of entries) {
if (!entry.isDirectory()) continue
let previous
try {
previous = parseReleaseTag(entry.name)
} catch {
continue
}
if (
previous.family !== current.family ||
compareReleaseVersions(previous.version, current.version) >= 0
) {
continue
}
for (const platform of selectedPlatforms) {
const directory = path.join(root, entry.name, platform)
const exists = await access(directory)
.then(() => true)
.catch((error) => {
if (error?.code === 'ENOENT') return false
throw error
})
if (!exists) continue
await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 })
removed.push(directory)
}
await removeDirectoryIfEmpty(path.join(root, entry.name))
}
await removeDirectoryIfEmpty(root)
return removed
}