Files
ycode/scripts/updates/release-workflow.mjs

592 lines
19 KiB
JavaScript

import crypto from 'node:crypto'
import fsp from 'node:fs/promises'
import path from 'node:path'
import {
DEFAULT_BASE_URL,
DEFAULT_PRIVATE_KEY,
DEFAULT_PUBLIC_KEY,
DEFAULT_SSH_HOST,
assert,
assertOutsideRepo,
compareVersions,
createPrivateKey,
createPublicKey,
normalizeBaseUrl,
parseVersion,
verifySignedManifest
} from './lib.mjs'
export const STABLE_VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
export const RELEASE_LOCK_RELATIVE = path.join('.tmp', 'updates', 'release.lock')
export const MAX_HTTP_MANIFEST_BYTES = 1024 * 1024
const CLI_VALUE_OPTIONS = new Set([
'version',
'notes-zh',
'notes-en',
'ssh-host',
'base-url',
'private-key'
])
export class ReleaseError extends Error {
constructor(stage, message, options = {}) {
super(message, options)
this.name = 'ReleaseError'
this.stage = stage
}
}
export function toReleaseError(stage, error) {
if (error instanceof ReleaseError) return error
return new ReleaseError(stage, String(error?.message || error), { cause: error })
}
async function runStage(stage, action) {
try {
return await action()
} catch (error) {
throw toReleaseError(stage, error)
}
}
export function validateStableVersion(value) {
const version = String(value ?? '')
assert(
STABLE_VERSION_RE.test(version),
'Release version must be a stable x.y.z version without a leading zero, prerelease or build metadata'
)
parseVersion(version)
for (const segment of version.split('.').map(BigInt)) {
assert(segment <= 65_535n, 'Release version segments must be between 0 and 65535 for Windows EXE metadata')
}
return version
}
export function defaultReleaseNotes(version) {
validateStableVersion(version)
return {
zh: `YCode ${version} 更新`,
en: `YCode ${version} update`
}
}
export async function resolveVersionChoice({ version, prompt }) {
if (version !== undefined) return validateStableVersion(version)
const answer = await prompt()
if (!answer || !answer.trim()) return null
return validateStableVersion(answer.trim())
}
export function parseReleaseArgs(argv, { cwd = process.cwd() } = {}) {
const raw = {}
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (!argument.startsWith('--')) throw new Error(`Unexpected argument: ${argument}`)
const equals = argument.indexOf('=')
const name = argument.slice(2, equals === -1 ? undefined : equals)
if (name === 'help' || name === 'resume') {
assert(equals === -1, `--${name} does not accept a value`)
assert(!(name in raw), `Option --${name} was provided more than once`)
raw[name] = true
continue
}
assert(CLI_VALUE_OPTIONS.has(name), `Unknown option: --${name}`)
assert(!(name in raw), `Option --${name} was provided more than once`)
const value = equals === -1 ? argv[++index] : argument.slice(equals + 1)
assert(value !== undefined && value !== '', `Missing value for --${name}`)
raw[name] = value
}
if (raw.help) {
return { help: true }
}
if (raw.resume) {
assert(raw.version, '--resume requires an explicit --version')
assert(!raw['notes-zh'] && !raw['notes-en'], '--notes-zh and --notes-en cannot be used with --resume')
}
const version = raw.version === undefined ? undefined : validateStableVersion(raw.version)
const notes = {}
if (raw['notes-zh'] !== undefined) notes.zh = raw['notes-zh'].trim()
if (raw['notes-en'] !== undefined) notes.en = raw['notes-en'].trim()
if ('zh' in notes) assert(notes.zh, '--notes-zh must not be empty')
if ('en' in notes) assert(notes.en, '--notes-en must not be empty')
return {
help: false,
resume: raw.resume === true,
version,
notes,
sshHost: raw['ssh-host'] || DEFAULT_SSH_HOST,
baseUrl: normalizeBaseUrl(raw['base-url'] || DEFAULT_BASE_URL),
privateKeyPath: path.resolve(cwd, raw['private-key'] || DEFAULT_PRIVATE_KEY),
publicKeyPath: path.resolve(cwd, DEFAULT_PUBLIC_KEY)
}
}
export function finalizeReleaseRequest(parsed, version) {
validateStableVersion(version)
const defaults = defaultReleaseNotes(version)
return {
...parsed,
version,
notes: {
zh: parsed.notes?.zh ?? defaults.zh,
en: parsed.notes?.en ?? defaults.en
}
}
}
export function releasePaths(root, version) {
validateStableVersion(version)
const outputDir = path.join(root, 'release', 'publish', version)
return {
root,
packagePath: path.join(root, 'package.json'),
lockPath: path.join(root, RELEASE_LOCK_RELATIVE),
outputDir,
installerPath: path.join(outputDir, `YCode-${version}-Setup-x64.exe`),
manifestPath: path.join(outputDir, 'latest.json'),
builderCli: path.join(root, 'node_modules', 'electron-builder', 'cli.js'),
prepareScript: path.join(root, 'scripts', 'updates', 'prepare-release.mjs'),
deployScript: path.join(root, 'scripts', 'updates', 'deploy.mjs')
}
}
function isOutside(root, filename) {
const relative = path.relative(path.resolve(root), path.resolve(filename))
return relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
}
async function canonicalPrivateKeyPath({ root, filename, deps }) {
const lexical = path.resolve(filename)
assert(isOutside(root, lexical), `Private key must be outside the repository: ${lexical}`)
await assertOutsideRepo(lexical, 'Private key')
if (!(await deps.pathExists(lexical))) throw new Error(`Signing key not found: ${lexical}`)
const canonical = await deps.realpath(lexical)
assert(isOutside(root, canonical), `Private key resolves inside the repository: ${canonical}`)
await assertOutsideRepo(canonical, 'Private key')
return canonical
}
export function assertKeyPairMatches(privatePem, publicPem) {
const privateKey = createPrivateKey(privatePem)
const derived = createPublicKey(privateKey).export({ type: 'spki', format: 'der' })
const supplied = createPublicKey(publicPem).export({ type: 'spki', format: 'der' })
assert(derived.equals(supplied), 'Signing private key does not match the update public key')
}
function parseJson(text, label) {
try {
return JSON.parse(text)
} catch (error) {
throw new Error(`${label} is not valid JSON: ${error.message}`)
}
}
function jsonFormat(text) {
const newline = text.includes('\r\n') ? '\r\n' : '\n'
const indentMatch = text.match(/\n([ \t]+)"/)
return {
indent: indentMatch?.[1] || ' ',
newline,
trailingNewline: text.endsWith('\n')
}
}
function rewriteJson(text, mutate) {
const value = parseJson(text, 'JSON document')
mutate(value)
const format = jsonFormat(text)
let serialized = JSON.stringify(value, null, format.indent).replace(/\n/g, format.newline)
if (format.trailingNewline) serialized += format.newline
return serialized
}
export function updatePackageText(text, version) {
validateStableVersion(version)
return rewriteJson(text, (document) => {
assert(document.name === 'ycode', 'package.json is not the YCode package')
assert(document.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app')
document.version = version
})
}
export function updateLockText(text, version) {
validateStableVersion(version)
return rewriteJson(text, (document) => {
assert(document && typeof document === 'object', 'package-lock.json must be an object')
assert(
document.packages && typeof document.packages === 'object',
'package-lock.json has no root package entry'
)
assert(
document.packages[''] && typeof document.packages[''] === 'object',
'package-lock.json has no packages[""] entry'
)
document.version = version
document.packages[''].version = version
})
}
function packageVersions(packageText, lockText) {
const pkg = parseJson(packageText, 'package.json')
const lock = parseJson(lockText, 'package-lock.json')
assert(pkg.name === 'ycode', 'package.json is not the YCode package')
assert(pkg.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app')
const version = validateStableVersion(pkg.version)
assert(
lock.version !== undefined && lock.packages?.['']?.version !== undefined,
'package-lock.json must contain top-level and packages[""] versions'
)
assert(lock.version === version, 'package-lock.json top-level version does not match package.json')
assert(lock.packages[''].version === version, 'package-lock.json root version does not match package.json')
return { pkg, lock, version }
}
function sameManifest(left, right) {
return JSON.stringify(left) === JSON.stringify(right)
}
function parseSignedManifest(text, publicKeyPem, baseUrl, label) {
const document = parseJson(text, label)
verifySignedManifest(document, publicKeyPem, { baseUrl })
return document
}
export async function preflightRelease({ request, paths, deps }) {
const publicKeyPath = path.resolve(request.publicKeyPath)
const privateKeyPath = await canonicalPrivateKeyPath({
root: paths.root,
filename: request.privateKeyPath,
deps
})
const privatePem = await deps.readPrivateKey(privateKeyPath)
const publicPem = await deps.readPublicKey(publicKeyPath)
assertKeyPairMatches(privatePem, publicPem)
const packageLockPath = path.join(paths.root, 'package-lock.json')
for (const [label, filename] of [
['package.json', paths.packagePath],
['package-lock.json', packageLockPath]
]) {
assert(await deps.pathExists(filename), `${label} not found: ${filename}`)
}
const packageText = await deps.readFile(paths.packagePath, 'utf8')
const lockText = await deps.readFile(packageLockPath, 'utf8')
const versions = packageVersions(packageText, lockText)
if (request.resume) {
assert(
versions.version === request.version,
`--resume requires repository version ${request.version}; found ${versions.version}`
)
assert(await deps.pathExists(paths.outputDir), `Resume output directory not found: ${paths.outputDir}`)
assert(await deps.pathExists(paths.installerPath), `Resume installer not found: ${paths.installerPath}`)
assert(await deps.pathExists(paths.manifestPath), `Resume manifest not found: ${paths.manifestPath}`)
} else {
const compared = compareVersions(request.version, versions.version)
assert(
compared >= 0,
`Refusing to release ${request.version}; repository version is newer (${versions.version})`
)
assert(
!(await deps.pathExists(paths.outputDir)),
`Refusing to build an existing release directory: ${paths.outputDir}`
)
}
if (deps.checkToolchain) await deps.checkToolchain(paths, { resume: request.resume })
await deps.checkSsh(request.sshHost)
const current = await deps.readLatest({
host: request.sshHost,
publicKeyPem: publicPem,
baseUrl: request.baseUrl
})
let currentVersion = null
if (current?.document) {
verifySignedManifest(current.document, publicPem, { baseUrl: request.baseUrl })
currentVersion = current.document.payload.version
const compared = compareVersions(request.version, currentVersion)
if (request.resume) {
assert(
compared >= 0,
`Refusing resume for ${request.version}; server latest is newer (${currentVersion})`
)
} else {
assert(
compared > 0,
`Release version ${request.version} must be strictly newer than server latest ${currentVersion}`
)
}
}
let localManifestText = null
let localManifest = null
if (request.resume) {
localManifestText = await deps.readFile(paths.manifestPath, 'utf8')
localManifest = parseSignedManifest(localManifestText, publicPem, request.baseUrl, 'Local latest.json')
assert(
localManifest.payload.version === request.version,
`Resume manifest version ${localManifest.payload.version} does not match ${request.version}`
)
if (currentVersion === request.version) {
assert(
sameManifest(current.document, localManifest),
`Server already has a different ${request.version} manifest`
)
}
const bundle = await deps.validateBundle({
manifestPath: paths.manifestPath,
installerPath: paths.installerPath,
publicKeyPath,
baseUrl: request.baseUrl
})
assert(
bundle?.document?.payload?.version === request.version,
`Revalidated bundle version does not match ${request.version}`
)
}
return {
repoVersion: versions.version,
privateKeyPath,
packageText,
lockText,
publicPem,
currentVersion,
currentRaw: current?.raw ?? null,
localManifestText,
localManifest
}
}
async function updateTargetVersions({ request, paths, preflight, deps }) {
if (request.resume) return { changed: false }
const nextPackageText = updatePackageText(preflight.packageText, request.version)
const nextLockText = updateLockText(preflight.lockText, request.version)
let changed = false
if (nextPackageText !== preflight.packageText) {
await deps.writeFileAtomic(paths.packagePath, nextPackageText)
changed = true
}
if (nextLockText !== preflight.lockText) {
await deps.writeFileAtomic(path.join(paths.root, 'package-lock.json'), nextLockText)
changed = true
}
return { changed }
}
export async function acquireReleaseLock(lockPath, { version } = {}) {
await fsp.mkdir(path.dirname(lockPath), { recursive: true })
const token = crypto.randomBytes(16).toString('hex')
let handle
let created = false
try {
handle = await fsp.open(lockPath, 'wx', 0o600)
created = true
await handle.writeFile(
`${JSON.stringify({
token,
pid: process.pid,
version: version || null,
startedAt: new Date().toISOString()
})}\n`,
'utf8'
)
await handle.sync()
} catch (error) {
if (handle) await handle.close().catch(() => {})
if (created) await fsp.rm(lockPath, { force: true }).catch(() => {})
if (error?.code === 'EEXIST') {
throw new Error(`Release lock already exists: ${lockPath}`)
}
throw error
}
await handle.close()
let released = false
return async function releaseLock() {
if (released) return
let raw
try {
raw = await fsp.readFile(lockPath, 'utf8')
} catch (error) {
if (error?.code === 'ENOENT') {
released = true
return
}
throw error
}
let record
try {
record = JSON.parse(raw)
} catch {
throw new Error(`Release lock was replaced or corrupted: ${lockPath}`)
}
assert(record.token === token, `Release lock ownership changed: ${lockPath}`)
await fsp.rm(lockPath, { force: true })
released = true
}
}
async function readResponseLimited(response, maxBytes) {
const declared = Number(response.headers.get('content-length'))
if (Number.isFinite(declared) && declared > maxBytes) {
throw new Error(`latest.json exceeds ${maxBytes} bytes`)
}
if (!response.body) return Buffer.alloc(0)
const reader = response.body.getReader()
const chunks = []
let total = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > maxBytes) {
await reader.cancel()
throw new Error(`latest.json exceeds ${maxBytes} bytes`)
}
chunks.push(Buffer.from(value))
}
return Buffer.concat(chunks, total)
}
export async function verifyPublishedManifest({
baseUrl,
expectedManifestText,
publicKeyPem,
version,
fetchImpl = globalThis.fetch,
timeoutMs = 30_000,
maxBytes = MAX_HTTP_MANIFEST_BYTES
}) {
validateStableVersion(version)
const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
assert(typeof fetchImpl === 'function', 'fetch is not available')
const manifestUrl = new URL('latest.json', normalizedBaseUrl)
const response = await fetchImpl(manifestUrl, {
method: 'GET',
cache: 'no-store',
credentials: 'omit',
redirect: 'error',
referrerPolicy: 'no-referrer',
headers: {
accept: 'application/json',
'cache-control': 'no-cache',
pragma: 'no-cache'
},
signal: AbortSignal.timeout(timeoutMs)
})
assert(response.status === 200, `latest.json HTTP status is ${response.status}, expected 200`)
const expected = Buffer.from(expectedManifestText, 'utf8')
assert(expected.length <= maxBytes, 'Local signed manifest exceeds the HTTP verification limit')
const actual = await readResponseLimited(response, maxBytes)
assert(actual.equals(expected), 'HTTP latest.json does not exactly match the local signed manifest')
const document = parseSignedManifest(actual.toString('utf8'), publicKeyPem, normalizedBaseUrl, 'HTTP latest.json')
assert(document.payload.version === version, `HTTP latest.json version is ${document.payload.version}, expected ${version}`)
return document
}
function deployArgs({ request, paths }) {
return [
'--manifest',
paths.manifestPath,
'--installer',
paths.installerPath,
'--transport',
'ssh',
'--base-url',
request.baseUrl,
'--public-key',
request.publicKeyPath,
'--ssh-host',
request.sshHost
]
}
export async function runReleaseWorkflow(request, deps) {
validateStableVersion(request.version)
const paths = releasePaths(deps.root, request.version)
let releaseLock
let primaryError = null
releaseLock = await runStage('lock', () => deps.acquireReleaseLock(paths.lockPath, { version: request.version }))
assert(typeof releaseLock === 'function', 'Release lock must return a release function')
try {
const preflight = await runStage('preflight', () => preflightRelease({ request, paths, deps }))
if (!request.resume) {
await runStage('version-update', () => updateTargetVersions({ request, paths, preflight, deps }))
await runStage('test-updates', () => deps.runNpm(['run', 'test:updates']))
await runStage('build', () => deps.runNpm(['run', 'build']))
await runStage('package', () =>
deps.runNode([
paths.builderCli,
'--win',
'nsis',
'--x64',
'--publish',
'never',
`--config.directories.output=${path.relative(paths.root, paths.outputDir).replaceAll(path.sep, '/')}`
])
)
await runStage('sign', () =>
deps.runNode([
paths.prepareScript,
'--installer',
paths.installerPath,
'--version',
request.version,
'--notes-zh',
request.notes.zh,
'--notes-en',
request.notes.en,
'--base-url',
request.baseUrl,
'--private-key',
preflight.privateKeyPath,
'--public-key',
request.publicKeyPath,
'--output',
paths.manifestPath
])
)
}
await runStage('deploy-dry-run', () => deps.runNode([paths.deployScript, ...deployArgs({ request, paths }), '--dry-run']))
await runStage('deploy', () => deps.runNode([paths.deployScript, ...deployArgs({ request, paths })]))
await runStage('verify-http', async () => {
const expectedManifestText =
preflight.localManifestText || (await deps.readFile(paths.manifestPath, 'utf8'))
await deps.verifyHttp({
baseUrl: request.baseUrl,
expectedManifestText,
publicKeyPem: preflight.publicPem,
version: request.version
})
})
return {
version: request.version,
outputDir: paths.outputDir,
installerPath: paths.installerPath,
manifestPath: paths.manifestPath
}
} catch (error) {
primaryError = toReleaseError('workflow', error)
throw primaryError
} finally {
try {
await releaseLock()
} catch (error) {
if (!primaryError) throw toReleaseError('lock-release', error)
if (deps.logWarning) deps.logWarning(`Release lock cleanup failed: ${error.message}`)
}
}
}