170 lines
5.1 KiB
JavaScript
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
|
|
})
|