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}`) } } }