Files
ycode/scripts/updates/release.mjs
T

170 lines
5.1 KiB
JavaScript

import { spawn } from 'node:child_process'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import { createInterface } from 'node:readline/promises'
import {
DEFAULT_BASE_URL,
REPO_ROOT,
readPrivateKey,
readPublicKey,
runSsh,
writeFileAtomic
} from './lib.mjs'
import {
ReleaseError,
acquireReleaseLock,
finalizeReleaseRequest,
parseReleaseArgs,
resolveVersionChoice,
runReleaseWorkflow,
toReleaseError,
verifyPublishedManifest
} from './release-workflow.mjs'
import { readLatestViaSsh, validateReleaseBundle } from './transport.mjs'
const usage = `Usage:
npm run release
npm run release -- --version <x.y.z> [--notes-zh <text>] [--notes-en <text>]
npm run release -- --resume --version <x.y.z>
npm run release -- --help
Options:
--version <x.y.z> Stable Windows x64 version. Prompted when omitted.
--notes-zh <text> Chinese release notes. Defaults to "YCode <version> 更新".
--notes-en <text> English release notes. Defaults to "YCode <version> update".
--resume Reuse an existing installer and signed manifest.
--ssh-host <host> SSH host, default local001.
--base-url <url> Release base URL, default ${DEFAULT_BASE_URL}.
--private-key <path> Signing key, default in the current user profile.
--help Show this help.
The workflow runs tests, builds, signs, deploys, and verifies latest.json.
It does not create keys, roll back the server, or clean stale release locks.
`
async function pathExists(filename) {
try {
await fsp.access(filename)
return true
} catch (error) {
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false
throw error
}
}
function runExecutable(command, args, { cwd = REPO_ROOT } = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: process.env,
shell: false,
stdio: 'inherit',
windowsHide: true
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`${path.basename(command)} exited with ${signal ? `signal ${signal}` : `code ${code}`}`))
}
})
})
}
function resolveNpmCli() {
const candidates = [
path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
process.env.npm_execpath
].filter((candidate) => candidate && path.basename(candidate).toLowerCase() === 'npm-cli.js')
const npmCli = candidates.find((candidate) => fs.existsSync(candidate))
if (!npmCli) throw new Error('Unable to locate npm-cli.js; run this command through npm run release')
return npmCli
}
async function promptForVersion() {
const readline = createInterface({ input: process.stdin, output: process.stdout })
try {
return await readline.question('Release version (stable x.y.z): ')
} finally {
readline.close()
}
}
function productionDependencies() {
const npmCli = resolveNpmCli()
return {
root: REPO_ROOT,
pathExists,
realpath: fsp.realpath,
readFile: fsp.readFile,
writeFileAtomic,
readPrivateKey,
readPublicKey,
validateBundle: validateReleaseBundle,
acquireReleaseLock,
checkToolchain: async (paths, { resume }) => {
const required = [['deploy.mjs', paths.deployScript]]
if (!resume) {
required.unshift(
['npm-cli.js', npmCli],
['electron-builder CLI', paths.builderCli],
['prepare-release.mjs', paths.prepareScript]
)
}
for (const [label, filename] of required) {
if (!fs.existsSync(filename)) throw new Error(`${label} not found: ${filename}`)
}
},
checkSsh: async (host) => {
runSsh(host, 'true\n')
},
readLatest: ({ host, publicKeyPem, baseUrl }) => readLatestViaSsh(host, publicKeyPem, baseUrl),
runNpm: (args) => runExecutable(process.execPath, [npmCli, ...args]),
runNode: (args) => runExecutable(process.execPath, args),
verifyHttp: verifyPublishedManifest,
logWarning: (message) => console.warn(message)
}
}
async function main() {
let parsed
try {
parsed = parseReleaseArgs(process.argv.slice(2))
} catch (error) {
throw toReleaseError('input', error)
}
if (parsed.help) {
process.stdout.write(usage)
return
}
let version
try {
version = await resolveVersionChoice({ version: parsed.version, prompt: promptForVersion })
} catch (error) {
throw toReleaseError('prompt', error)
}
if (!version) {
console.log('Release cancelled.')
return
}
let request
try {
request = finalizeReleaseRequest(parsed, version)
} catch (error) {
throw toReleaseError('input', error)
}
const result = await runReleaseWorkflow(request, productionDependencies())
console.log(`Release ${result.version} published and verified.`)
console.log(`Installer: ${result.installerPath}`)
console.log(`Manifest: ${result.manifestPath}`)
}
main().catch((error) => {
const releaseError = error instanceof ReleaseError ? error : toReleaseError('input', error)
console.error(`Release failed during ${releaseError.stage}: ${releaseError.message}`)
process.exitCode = 1
})