305 lines
11 KiB
JavaScript
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
|
|
}
|