592 lines
19 KiB
JavaScript
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}`)
|
|
}
|
|
}
|
|
}
|