feat: ship skill and MCP workflows with signed updater for v0.0.7

This commit is contained in:
xiaohan.yuan committed 2026-10-21 00:04:17 +08:00
1 parent 1b52df0603
commit a01085c656
136 files changed
+18646 -217

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
import {
DEFAULT_BASE_URL,
DEFAULT_PUBLIC_KEY,
REPO_ROOT,
generateEd25519KeyPair,
normalizeBaseUrl,
readPublicKey,
signPayload
} from './lib.mjs'
async function main() {
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-contract-selftest-'))
try {
const bundlePath = path.join(temporary, 'manifest.mjs')
await build({
entryPoints: [path.join(REPO_ROOT, 'src', 'main', 'updates', 'manifest.ts')],
bundle: true,
platform: 'node',
format: 'esm',
outfile: bundlePath,
logLevel: 'silent'
})
const backend = await import(`${pathToFileURL(bundlePath).href}?v=${Date.now()}`)
assert.equal(typeof backend.verifySignedManifest, 'function')
const trustAnchor = await readPublicKey(DEFAULT_PUBLIC_KEY)
const trustKey = crypto.createPublicKey(trustAnchor)
assert.equal(trustKey.asymmetricKeyType, 'ed25519')
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl(DEFAULT_BASE_URL)
const payload = {
schemaVersion: 1,
version: '9.9.9',
publishedAt: '2026-10-20T10:00:00.000Z',
notes: { zh: '契约自测', en: 'Contract self-test' },
platforms: {
'windows-x64': {
url: `${baseUrl}versions/9.9.9/YCode-9.9.9-Setup-x64.exe`,
size: 123,
sha256: 'a'.repeat(64)
}
}
}
const document = {
payload,
signature: signPayload(payload, keys.privatePem)
}
const raw = Buffer.from(`${JSON.stringify(document)}\n`, 'utf8')
const verified = backend.verifySignedManifest(raw, {
manifestUrl: `${baseUrl}latest.json`,
publicKeyPem: keys.publicPem
})
assert.equal(verified.payload.version, payload.version)
assert.equal(verified.asset.url, payload.platforms['windows-x64'].url)
const tampered = structuredClone(document)
tampered.payload.notes.en = 'tampered'
assert.throws(
() => backend.verifySignedManifest(Buffer.from(JSON.stringify(tampered), 'utf8'), {
manifestUrl: `${baseUrl}latest.json`,
publicKeyPem: keys.publicPem
}),
/签名验证失败/
)
console.log('Release manifest matches update_backend parser/signature/path policy.')
console.log('Repository public key is a valid Ed25519 SPKI PEM trust anchor.')
} finally {
await fsp.rm(temporary, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+79
View File
@@ -0,0 +1,79 @@
import path from 'node:path'
import {
DEFAULT_BASE_URL,
DEFAULT_PUBLIC_KEY,
DEFAULT_SSH_HOST,
normalizeBaseUrl,
parseOptions,
resolvePath,
usageError
} from './lib.mjs'
import {
publishLocalRelease,
publishViaSsh,
readLatestViaSsh,
readLocalLatest,
validateReleaseBundle
} from './transport.mjs'
const usage = `Usage:
node scripts/updates/deploy.mjs --manifest <latest.json> --installer <YCode-Setup.exe>
[--transport ssh|local] [--base-url <url>] [--public-key <path>]
[--ssh-host local001] [--local-root <dir>] [--dry-run]
The SSH transport uploads to /srv/ycode-staging, verifies the current signed latest.json,
installs an immutable version directory, then atomically replaces latest.json.
The local transport is for temporary fake-transport tests only.
`
async function main() {
const options = parseOptions(process.argv.slice(2), {
values: ['manifest', 'installer', 'transport', 'base-url', 'public-key', 'ssh-host', 'local-root'],
booleans: ['dry-run']
})
if (!options.manifest || !options.installer) throw new Error('--manifest and --installer are required')
const transport = options.transport || 'ssh'
if (transport !== 'ssh' && transport !== 'local') throw new Error('--transport must be ssh or local')
if (transport === 'local' && !options['local-root']) throw new Error('--local-root is required for local transport')
const baseUrl = normalizeBaseUrl(options['base-url'] || DEFAULT_BASE_URL)
const publicKeyPath = resolvePath(options['public-key'] || DEFAULT_PUBLIC_KEY)
const release = await validateReleaseBundle({
manifestPath: resolvePath(options.manifest),
installerPath: resolvePath(options.installer),
publicKeyPath,
baseUrl
})
let current
if (transport === 'ssh') {
current = await readLatestViaSsh(options['ssh-host'] || DEFAULT_SSH_HOST, release.publicKeyPem, baseUrl)
} else {
current = await readLocalLatest(resolvePath(options['local-root']), release.publicKeyPem, baseUrl)
}
if (options['dry-run']) {
console.log(`Verified ${release.document.payload.version} for ${transport} publication.`)
console.log(`Current latest: ${current.document?.payload.version || 'none'}`)
return
}
if (transport === 'ssh') {
await publishViaSsh({
host: options['ssh-host'] || DEFAULT_SSH_HOST,
release,
current
})
} else {
await publishLocalRelease({
root: path.resolve(options['local-root']),
release,
current
})
}
console.log(`Published ${release.document.payload.version} via ${transport}.`)
}
main().catch((error) => usageError(usage, error))
+19
View File
@@ -0,0 +1,19 @@
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$ErrorActionPreference = 'Stop'
$item = Get-Item -LiteralPath $Path
if (-not $item.PSIsContainer -and $item.Extension -ieq '.exe') {
[pscustomobject]@{
productVersion = [string]$item.VersionInfo.ProductVersion
fileVersion = [string]$item.VersionInfo.FileVersion
productName = [string]$item.VersionInfo.ProductName
fileDescription = [string]$item.VersionInfo.FileDescription
} | ConvertTo-Json -Compress
exit 0
}
throw "Not an executable file: $Path"
+81
View File
@@ -0,0 +1,81 @@
import fsp from 'node:fs/promises'
import path from 'node:path'
import {
DEFAULT_PRIVATE_KEY,
DEFAULT_PUBLIC_KEY,
assertOutsideRepo,
generateEd25519KeyPair,
parseOptions,
publicKeyFingerprint,
resolvePath,
runProcess,
usageError,
writeFileAtomic
} from './lib.mjs'
const usage = `Usage:
node scripts/updates/init-key.mjs [--private-key <path>] [--public-key <path>] [--force]
The private key must remain outside the repository. The default private key is:
${DEFAULT_PRIVATE_KEY}
`
function secureWindowsAcl(filename, isDirectory = false) {
if (process.platform !== 'win32') return
const identity = runProcess('whoami', []).trim()
if (!identity) throw new Error('Unable to determine the current Windows identity')
const permission = isDirectory ? `${identity}:(OI)(CI)F` : `${identity}:(F)`
runProcess('icacls', [filename, '/inheritance:r', '/grant:r', permission])
}
async function pathExists(filename) {
try {
await fsp.access(filename)
return true
} catch {
return false
}
}
async function main() {
const options = parseOptions(process.argv.slice(2), {
values: ['private-key', 'public-key'],
booleans: ['force']
})
const privateKeyPath = await assertOutsideRepo(
resolvePath(options['private-key'] || DEFAULT_PRIVATE_KEY),
'Private key'
)
const publicKeyPath = resolvePath(options['public-key'] || DEFAULT_PUBLIC_KEY)
if (!options.force) {
if (await pathExists(privateKeyPath)) throw new Error(`Refusing to overwrite existing private key: ${privateKeyPath}`)
if (await pathExists(publicKeyPath)) throw new Error(`Refusing to overwrite existing public key: ${publicKeyPath}`)
}
const keyPair = generateEd25519KeyPair()
const directory = path.dirname(privateKeyPath)
await fsp.mkdir(directory, { recursive: true, mode: 0o700 })
try {
await fsp.chmod(directory, 0o700)
} catch {
// Windows ACLs are applied below.
}
secureWindowsAcl(directory, true)
await writeFileAtomic(privateKeyPath, keyPair.privatePem, 0o600)
try {
await fsp.chmod(privateKeyPath, 0o600)
} catch {
// Windows ACLs are applied below.
}
secureWindowsAcl(privateKeyPath)
await writeFileAtomic(publicKeyPath, keyPair.publicPem, 0o644)
console.log(`Private key: ${privateKeyPath}`)
console.log(`Public key: ${publicKeyPath}`)
console.log(`Public key SHA-256 fingerprint: ${publicKeyFingerprint(keyPair.publicPem)}`)
console.log('Private key material was not printed. Back up the private key separately before rotating it.')
}
main().catch((error) => usageError(usage, error))
+538
View File
@@ -0,0 +1,538 @@
import crypto from 'node:crypto'
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
export const DEFAULT_BASE_URL = 'http://192.168.10.6/ycode/releases/'
export const DEFAULT_SSH_HOST = 'local001'
export const DEFAULT_PRIVATE_KEY = path.join(os.homedir(), '.ycode-release', 'update-signing.key')
export const DEFAULT_PUBLIC_KEY = path.join(REPO_ROOT, 'resources', 'updates', 'public-key.pem')
export const BASELINE_VERSION = '0.0.1'
export const MAX_INSTALLER_BYTES = 4 * 1024 * 1024 * 1024
export const MIN_PUBLIC_INSTALLER_BYTES = 20 * 1024 * 1024
export const SERVER_PUBLIC_ROOT = '/srv/ycode'
export const SERVER_STAGING_ROOT = '/srv/ycode-staging'
export const SERVER_RELEASES_ROOT = '/srv/ycode/releases'
export const NGINX_SITE_LINK = '/etc/nginx/sites-enabled/default'
export const NGINX_SNIPPET = '/etc/nginx/snippets/ycode-releases.conf'
export const NGINX_MARKER = '# YCode updater releases (managed by scripts/updates)'
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/
const SHA256_RE = /^[a-f0-9]{64}$/
const EXE_NAME_RE = /^[A-Za-z0-9._-]+\.exe$/
function fail(message) {
throw new Error(message)
}
export function assert(condition, message) {
if (!condition) fail(message)
}
export function parseOptions(argv, { values = [], booleans = [] } = {}) {
const valueNames = new Set(values)
const booleanNames = new Set(booleans)
const options = {}
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (!argument.startsWith('--')) fail(`Unexpected argument: ${argument}`)
const equals = argument.indexOf('=')
const name = argument.slice(2, equals === -1 ? undefined : equals)
if (booleanNames.has(name)) {
assert(equals === -1, `--${name} does not accept a value`)
options[name] = true
continue
}
assert(valueNames.has(name), `Unknown option: --${name}`)
const value = equals === -1 ? argv[++index] : argument.slice(equals + 1)
assert(value !== undefined && value !== '', `Missing value for --${name}`)
options[name] = value
}
return options
}
export function normalizeBaseUrl(value = DEFAULT_BASE_URL) {
let url
try {
url = new URL(value)
} catch {
fail(`Invalid base URL: ${value}`)
}
assert(url.protocol === 'http:' || url.protocol === 'https:', 'Base URL must use HTTP or HTTPS')
assert(!url.username && !url.password, 'Base URL must not contain credentials')
assert(!url.search && !url.hash, 'Base URL must not contain a query or fragment')
if (!url.pathname.endsWith('/')) url.pathname += '/'
return url.href
}
export function artifactUrlFor(baseUrl, version, filename) {
parseVersion(version)
assert(EXE_NAME_RE.test(filename), `Unsafe installer filename: ${filename}`)
const base = normalizeBaseUrl(baseUrl)
return new URL(`versions/${version}/${filename}`, base).href
}
export function assertHttpUrl(value, label = 'URL') {
let url
try {
url = new URL(value)
} catch {
fail(`${label} is invalid`)
}
assert(url.protocol === 'http:' || url.protocol === 'https:', `${label} must use HTTP or HTTPS`)
assert(!url.username && !url.password, `${label} must not contain credentials`)
assert(!url.search, `${label} must not contain a query`)
assert(!url.hash, `${label} must not contain a fragment`)
return url
}
export function parseVersion(version) {
assert(typeof version === 'string' && version.length <= 256, 'Version must be a non-empty string')
const match = SEMVER_RE.exec(version)
assert(match, `Version is not strict SemVer: ${version}`)
const prerelease = match[4] ? match[4].split('.') : []
for (const identifier of prerelease) {
assert(!/^\d+$/.test(identifier) || identifier === '0' || !identifier.startsWith('0'), `Invalid SemVer prerelease: ${version}`)
}
return {
major: BigInt(match[1]),
minor: BigInt(match[2]),
patch: BigInt(match[3]),
prerelease,
build: match[5] ? match[5].split('.') : []
}
}
function compareIdentifier(left, right) {
const leftNumeric = /^\d+$/.test(left)
const rightNumeric = /^\d+$/.test(right)
if (leftNumeric && rightNumeric) {
const a = BigInt(left)
const b = BigInt(right)
return a < b ? -1 : a > b ? 1 : 0
}
if (leftNumeric) return -1
if (rightNumeric) return 1
return left < right ? -1 : left > right ? 1 : 0
}
export function compareVersions(left, right) {
const a = parseVersion(left)
const b = parseVersion(right)
for (const key of ['major', 'minor', 'patch']) {
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
}
if (!a.prerelease.length && !b.prerelease.length) return 0
if (!a.prerelease.length) return 1
if (!b.prerelease.length) return -1
const length = Math.max(a.prerelease.length, b.prerelease.length)
for (let index = 0; index < length; index += 1) {
if (a.prerelease[index] === undefined) return -1
if (b.prerelease[index] === undefined) return 1
const compared = compareIdentifier(a.prerelease[index], b.prerelease[index])
if (compared !== 0) return compared
}
return 0
}
export function normalizeProductVersion(value) {
let normalized = String(value ?? '').trim()
if (normalized.startsWith('v')) normalized = normalized.slice(1)
const windowsRevision = /^(\d+\.\d+\.\d+)\.0$/.exec(normalized)
if (windowsRevision) normalized = windowsRevision[1]
return normalized
}
export async function sha256File(filename) {
const hash = crypto.createHash('sha256')
for await (const chunk of fs.createReadStream(filename)) hash.update(chunk)
return hash.digest('hex')
}
export async function readJson(filename) {
try {
return JSON.parse(await fsp.readFile(filename, 'utf8'))
} catch (error) {
fail(`Unable to read JSON ${filename}: ${error.message}`)
}
}
export async function writeFileAtomic(filename, data, mode = 0o644) {
const directory = path.dirname(filename)
await fsp.mkdir(directory, { recursive: true })
const temporary = path.join(directory, `.${path.basename(filename)}.${crypto.randomBytes(8).toString('hex')}.tmp`)
const handle = await fsp.open(temporary, 'wx', mode)
try {
await handle.writeFile(data)
await handle.sync()
} finally {
await handle.close()
}
try {
await fsp.rename(temporary, filename)
} catch (error) {
await fsp.rm(temporary, { force: true })
throw error
}
}
export function resolvePath(value, base = process.cwd()) {
return path.resolve(base, value)
}
export async function assertOutsideRepo(filename, label) {
const candidate = path.resolve(filename)
const repo = path.resolve(REPO_ROOT)
const relative = path.relative(repo, candidate)
assert(relative.startsWith('..') || path.isAbsolute(relative), `${label} must be outside the repository: ${candidate}`)
return candidate
}
export function generateEd25519KeyPair() {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519')
return {
privatePem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
publicPem: publicKey.export({ type: 'spki', format: 'pem' }).toString()
}
}
export function createPrivateKey(privateKeyPem) {
const key = crypto.createPrivateKey(privateKeyPem)
assert(key.asymmetricKeyType === 'ed25519', 'Update signing key must be Ed25519')
return key
}
export function createPublicKey(publicKeyPem) {
const key = crypto.createPublicKey(publicKeyPem)
assert(key.asymmetricKeyType === 'ed25519', 'Update public key must be Ed25519')
return key
}
export function signPayload(payload, privateKeyPem) {
const bytes = Buffer.from(JSON.stringify(payload), 'utf8')
return crypto.sign(null, bytes, createPrivateKey(privateKeyPem)).toString('base64')
}
export function verifyPayloadSignature(payload, signature, publicKeyPem) {
assert(typeof signature === 'string', 'Manifest signature must be a string')
const signatureBytes = Buffer.from(signature, 'base64')
assert(signatureBytes.length === 64, 'Manifest signature must contain 64 bytes')
assert(signatureBytes.toString('base64') === signature, 'Manifest signature must use canonical base64')
const bytes = Buffer.from(JSON.stringify(payload), 'utf8')
return crypto.verify(null, bytes, createPublicKey(publicKeyPem), signatureBytes)
}
export function publicKeyFingerprint(publicKeyPem) {
const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' })
return crypto.createHash('sha256').update(der).digest('hex')
}
function assertExactKeys(value, expected, label) {
assert(value && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`)
const actual = Object.keys(value)
assert(actual.length === expected.length && expected.every((key) => actual.includes(key)), `${label} has unexpected or missing fields`)
}
export function validateManifestDocument(document, { baseUrl = DEFAULT_BASE_URL } = {}) {
assertExactKeys(document, ['payload', 'signature'], 'Manifest')
const payload = document.payload
assertExactKeys(payload, ['schemaVersion', 'version', 'publishedAt', 'notes', 'platforms'], 'Manifest payload')
assert(payload.schemaVersion === 1, 'Unsupported update manifest schemaVersion')
parseVersion(payload.version)
assert(typeof payload.publishedAt === 'string', 'publishedAt must be a string')
const published = new Date(payload.publishedAt)
assert(!Number.isNaN(published.valueOf()), 'publishedAt must be an ISO timestamp')
assert(published.toISOString() === payload.publishedAt, 'publishedAt must be canonical UTC ISO')
assertExactKeys(payload.notes, ['zh', 'en'], 'Manifest notes')
for (const language of ['zh', 'en']) {
assert(typeof payload.notes[language] === 'string' && payload.notes[language].trim(), `Manifest notes.${language} is required`)
assert(payload.notes[language].length <= 16_384, `Manifest notes.${language} is too long`)
}
assertExactKeys(payload.platforms, ['windows-x64'], 'Manifest platforms')
const platform = payload.platforms['windows-x64']
assertExactKeys(platform, ['url', 'size', 'sha256'], 'windows-x64 platform')
assert(Number.isSafeInteger(platform.size) && platform.size > 0 && platform.size <= MAX_INSTALLER_BYTES, 'windows-x64 size is invalid')
assert(typeof platform.sha256 === 'string' && SHA256_RE.test(platform.sha256), 'windows-x64 sha256 must be lowercase SHA-256 hex')
const url = assertHttpUrl(platform.url, 'windows-x64 URL')
const filename = path.posix.basename(url.pathname)
const expected = artifactUrlFor(baseUrl, payload.version, filename)
assert(url.href === expected, 'windows-x64 URL is outside the approved version path')
return payload
}
export function verifySignedManifest(document, publicKeyPem, options = {}) {
const payload = validateManifestDocument(document, options)
assert(verifyPayloadSignature(payload, document.signature, publicKeyPem), 'Update manifest signature is invalid')
return payload
}
export function serializeSignedManifest(document, options = {}) {
validateManifestDocument(document, options)
return `${JSON.stringify(document)}\n`
}
export async function readPublicKey(filename = DEFAULT_PUBLIC_KEY) {
try {
return await fsp.readFile(filename, 'utf8')
} catch (error) {
fail(`Unable to read update public key ${filename}: ${error.message}`)
}
}
export async function readPrivateKey(filename = DEFAULT_PRIVATE_KEY) {
try {
return await fsp.readFile(filename, 'utf8')
} catch (error) {
fail(`Unable to read update signing key ${filename}: ${error.message}`)
}
}
export function runProcess(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
input: options.input,
encoding: 'utf8',
windowsHide: true,
maxBuffer: 8 * 1024 * 1024
})
if (result.error) fail(`Unable to run ${command}: ${result.error.message}`)
if (result.status !== 0) {
const detail = String(result.stderr || result.stdout || '').trim()
fail(`${command} failed with exit code ${result.status}${detail ? `: ${detail}` : ''}`)
}
return result.stdout
}
export function validateSshHost(host = DEFAULT_SSH_HOST) {
assert(/^[A-Za-z0-9_.@:-]+$/.test(host) && !host.startsWith('-'), 'SSH host contains unsafe characters')
return host
}
export function runSsh(host, script) {
return runProcess('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', validateSshHost(host), 'bash', '-s'], {
input: script
})
}
export async function getProductVersion(installerPath) {
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), 'get-exe-version.ps1')
assert(fs.existsSync(script), `Missing version helper: ${script}`)
const output = runProcess('powershell.exe', [
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
script,
'-Path',
installerPath
])
let parsed
try {
parsed = JSON.parse(output)
} catch {
fail('Unable to parse installer version information')
}
assert(parsed && typeof parsed.productVersion === 'string', 'Installer has no ProductVersion')
return parsed
}
export async function readPackageMetadata() {
const packagePath = path.join(REPO_ROOT, 'package.json')
const pkg = await readJson(packagePath)
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')
parseVersion(pkg.version)
return { path: packagePath, package: pkg }
}
export async function verifyReleaseInstaller(installerPath, version, { minimumSize = MIN_PUBLIC_INSTALLER_BYTES } = {}) {
parseVersion(version)
assert(compareVersions(version, BASELINE_VERSION) > 0, `Release version must be newer than the updater-less ${BASELINE_VERSION} baseline`)
assert(EXE_NAME_RE.test(path.basename(installerPath)), `Unsafe installer filename: ${path.basename(installerPath)}`)
let resolved
try {
resolved = await fsp.realpath(installerPath)
} catch (error) {
fail(`Unable to resolve installer: ${error.message}`)
}
const stat = await fsp.stat(resolved)
assert(stat.isFile(), 'Installer must be a regular file')
assert(stat.size >= minimumSize, `Installer is unexpectedly small (${stat.size} bytes)`)
assert(stat.size <= MAX_INSTALLER_BYTES, 'Installer exceeds the 4 GiB limit')
const product = await getProductVersion(resolved)
const productVersion = normalizeProductVersion(product.productVersion)
assert(productVersion === version, `Installer ProductVersion ${product.productVersion} does not match ${version}`)
const { path: packagePath, package: pkg } = await readPackageMetadata()
assert(normalizeProductVersion(pkg.version) === version, `package.json version ${pkg.version} does not match ${version}`)
return {
path: resolved,
filename: path.basename(resolved),
size: stat.size,
sha256: await sha256File(resolved),
productVersion,
packagePath
}
}
export function buildManifestPayload({ version, publishedAt, notes, installer, baseUrl = DEFAULT_BASE_URL }) {
const url = artifactUrlFor(baseUrl, version, installer.filename)
return {
schemaVersion: 1,
version,
publishedAt,
notes: {
zh: notes.zh,
en: notes.en
},
platforms: {
'windows-x64': {
url,
size: installer.size,
sha256: installer.sha256
}
}
}
}
export function createSignedManifest({ payload, privateKeyPem, publicKeyPem, baseUrl = DEFAULT_BASE_URL }) {
validateManifestDocument({ payload, signature: Buffer.alloc(64).toString('base64') }, { baseUrl })
const signature = signPayload(payload, privateKeyPem)
const document = { payload, signature }
assert(verifyPayloadSignature(payload, signature, publicKeyPem), 'Generated manifest does not verify with the configured public key')
validateManifestDocument(document, { baseUrl })
return document
}
export function canonicalNow(value = new Date()) {
return value.toISOString()
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function formatBytes(value) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB (${value.toLocaleString('en-US')} bytes)`
}
export function renderDownloadPage(payload, options = {}) {
validateManifestDocument({ payload, signature: Buffer.alloc(64).toString('base64') }, options)
const platform = payload.platforms['windows-x64']
const version = escapeHtml(payload.version)
const url = escapeHtml(platform.url)
const sha256 = escapeHtml(platform.sha256)
const publishedAt = escapeHtml(payload.publishedAt)
const zhNotes = escapeHtml(payload.notes.zh)
const enNotes = escapeHtml(payload.notes.en)
const size = escapeHtml(formatBytes(platform.size))
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<meta name="referrer" content="no-referrer">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
<title>YCode v${version} 下载</title>
<style>
:root { color-scheme: light; --ink:#17201b; --muted:#667069; --paper:#f4f1e8; --card:#fffdf7; --line:#d9d4c6; --accent:#176b4d; --accent-dark:#0d4d36; }
* { box-sizing: border-box; }
body { margin:0; min-height:100vh; background:radial-gradient(circle at top left, #e2eadf 0, transparent 34rem), var(--paper); color:var(--ink); font-family:"Segoe UI Variable","Microsoft YaHei UI","Noto Sans CJK SC",sans-serif; }
main { width:min(760px, calc(100% - 32px)); margin:0 auto; padding:clamp(48px,9vw,96px) 0; }
.eyebrow { margin:0 0 16px; color:var(--accent-dark); font-size:12px; font-weight:700; letter-spacing:.18em; text-transform:uppercase; }
h1 { margin:0; font-size:clamp(36px,7vw,64px); line-height:1; letter-spacing:-.045em; }
.summary { margin:18px 0 30px; color:var(--muted); font-size:16px; line-height:1.7; }
.card { border:1px solid var(--line); border-radius:18px; background:var(--card); box-shadow:0 24px 70px rgba(24,43,34,.08); overflow:hidden; }
.release { padding:clamp(22px,4vw,34px); border-bottom:1px solid var(--line); }
.meta { display:flex; flex-wrap:wrap; gap:8px 18px; margin:0 0 22px; color:var(--muted); font-size:13px; }
.download { display:inline-flex; align-items:center; justify-content:center; min-height:46px; padding:0 22px; border-radius:999px; background:var(--accent); color:white; font-weight:700; text-decoration:none; }
.download:hover { background:var(--accent-dark); }
.hash { display:block; overflow-wrap:anywhere; margin-top:16px; padding:12px 14px; border:1px solid var(--line); border-radius:10px; background:#f8f6ef; color:#39443d; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace; }
.notes { padding:clamp(22px,4vw,34px); display:grid; gap:24px; }
.notes h2 { margin:0 0 8px; font-size:14px; letter-spacing:.04em; }
.notes p { margin:0; color:#3e4841; line-height:1.75; white-space:pre-wrap; }
.footer { margin-top:20px; color:var(--muted); font-size:12px; line-height:1.7; }
code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; }
</style>
</head>
<body>
<main>
<p class="eyebrow">YCode / Windows x64</p>
<h1>v${version}</h1>
<p class="summary">此页面仅从 Ed25519 验签通过的更新清单生成,不加载脚本或第三方资源。</p>
<section class="card">
<div class="release">
<div class="meta">
<span>发布时间 ${publishedAt}</span>
<span>${size}</span>
</div>
<a class="download" href="${url}" rel="noopener">下载 Windows x64 安装包</a>
<code class="hash">SHA-256 ${sha256}</code>
</div>
<div class="notes">
<div>
<h2>更新说明</h2>
<p lang="zh-CN">${zhNotes}</p>
</div>
<div>
<h2>Release notes</h2>
<p lang="en">${enNotes}</p>
</div>
</div>
</section>
<p class="footer">请核对安装包 SHA-256。更新客户端会独立验证签名、文件大小和哈希。<br>
<a href="latest.json">查看签名清单</a></p>
</main>
</body>
</html>
`
}
export function renderNoReleasePage() {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
<title>YCode 下载</title>
<style>
:root { --paper:#f4f1e8; --card:#fffdf7; --ink:#17201b; --muted:#667069; --line:#d9d4c6; }
* { box-sizing:border-box; }
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:24px; background:var(--paper); color:var(--ink); font-family:"Segoe UI Variable","Microsoft YaHei UI",sans-serif; }
main { width:min(560px,100%); padding:40px; border:1px solid var(--line); border-radius:18px; background:var(--card); box-shadow:0 24px 70px rgba(24,43,34,.08); }
h1 { margin:0 0 12px; font-size:clamp(32px,8vw,52px); letter-spacing:-.04em; }
p { margin:0; color:var(--muted); line-height:1.75; }
</style>
</head>
<body data-ycode-state="no-release"><main><h1>YCode</h1><p>当前还没有发布正式安装包。此静态页不加载脚本、不提供占位版本;发布完成后再刷新。</p></main></body>
</html>
`
}
export function usageError(usage, error) {
console.error(`${error.message}\n\n${usage}`)
process.exitCode = 2
}
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict'
import fsp from 'node:fs/promises'
import path from 'node:path'
import { REPO_ROOT } from './lib.mjs'
const filename = path.join(REPO_ROOT, 'build', 'installer.nsh')
const source = await fsp.readFile(filename, 'utf8')
assert.match(source, /OpenProcess\(i 0x100000, i 0, i R1\) p \.R2 \?e/)
assert.match(source, /Pop \$R3/)
assert.doesNotMatch(source, /GetLastError\(\)/)
assert.match(source, /WaitForSingleObject\(p R2, i 120000\)/)
assert.match(source, /ElseIf\} \$R3 != 87/)
const waitIndex = source.indexOf('WaitForSingleObject')
const imageCheckIndex = source.indexOf('SetEnvironmentVariable(t "YCODE_UPDATE_TARGET_DIR"')
assert.ok(waitIndex >= 0 && imageCheckIndex > waitIndex, 'image check must run after waiting for the parent PID')
assert.ok(imageCheckIndex > source.indexOf('${ElseIf} $R3 != 87'), 'image check must run after the OpenProcess branch join')
assert.ok(source.includes('t "$INSTDIR\\${APP_EXECUTABLE_FILENAME}"'))
assert.match(source, /nsExec::ExecToStack/)
assert.match(source, /Get-CimInstance Win32_Process/)
assert.match(source, /TrimEnd\(\[char\]92\)/)
assert.match(source, /AddSeconds\(5\)/)
assert.match(source, /Start-Sleep -Milliseconds 500/)
assert.match(source, /if \(-not \$\$process\.ExecutablePath\) \{ \$\$found=\$\$true; continue \}/)
assert.match(source, /Name -ieq '\$\{APP_EXECUTABLE_FILENAME\}'/)
assert.match(source, /StrCmp \$R4 "0" \+3/)
assert.doesNotMatch(source, /KillProcess|taskkill|Stop-Process|_CHECK_APP_RUNNING/)
assert.doesNotMatch(source, /GetProcessInfo/)
assert.doesNotMatch(source, /GetLastError\(\)/)
assert.doesNotMatch(source, /Name -ieq 'YCode\.exe'/)
console.log('NSIS updater safety checks passed.')
+71
View File
@@ -0,0 +1,71 @@
import fsp from 'node:fs/promises'
import path from 'node:path'
import {
DEFAULT_BASE_URL,
DEFAULT_PRIVATE_KEY,
DEFAULT_PUBLIC_KEY,
assertOutsideRepo,
buildManifestPayload,
canonicalNow,
createSignedManifest,
normalizeBaseUrl,
parseOptions,
readPrivateKey,
readPublicKey,
resolvePath,
serializeSignedManifest,
usageError,
verifyReleaseInstaller,
verifySignedManifest,
writeFileAtomic
} from './lib.mjs'
const usage = `Usage:
node scripts/updates/prepare-release.mjs --installer <YCode-Setup.exe> --version <x.y.z>
--notes-zh <text> --notes-en <text> [--base-url <url>] [--published-at <ISO>]
[--private-key <path>] [--public-key <path>] [--output <latest.json>]
This validates the actual EXE ProductVersion, current package.json version, size and SHA-256,
then signs exact JSON.stringify(payload) with Ed25519. It never deploys.
`
async function main() {
const options = parseOptions(process.argv.slice(2), {
values: ['installer', 'version', 'notes-zh', 'notes-en', 'base-url', 'published-at', 'private-key', 'public-key', 'output']
})
if (!options.installer || !options.version || !options['notes-zh'] || !options['notes-en']) {
throw new Error('--installer, --version, --notes-zh and --notes-en are required')
}
const baseUrl = normalizeBaseUrl(options['base-url'] || DEFAULT_BASE_URL)
const installerPath = resolvePath(options.installer)
const installer = await verifyReleaseInstaller(installerPath, options.version)
const output = resolvePath(options.output || path.join('.tmp', 'updates', options.version, 'latest.json'))
const privateKeyPath = await assertOutsideRepo(
resolvePath(options['private-key'] || DEFAULT_PRIVATE_KEY),
'Private key'
)
const privateKey = await readPrivateKey(privateKeyPath)
const publicKeyPath = resolvePath(options['public-key'] || DEFAULT_PUBLIC_KEY)
const publicKey = await readPublicKey(publicKeyPath)
const payload = buildManifestPayload({
version: options.version,
publishedAt: options['published-at'] || canonicalNow(),
notes: { zh: options['notes-zh'], en: options['notes-en'] },
installer,
baseUrl
})
const document = createSignedManifest({ payload, privateKeyPem: privateKey, publicKeyPem: publicKey, baseUrl })
verifySignedManifest(document, publicKey, { baseUrl })
await writeFileAtomic(output, serializeSignedManifest(document))
await fsp.access(output)
console.log(`Manifest: ${output}`)
console.log(`Version: ${payload.version}`)
console.log(`Installer: ${installer.filename}`)
console.log(`Size: ${installer.size}`)
console.log(`SHA-256: ${installer.sha256}`)
console.log(`URL: ${payload.platforms['windows-x64'].url}`)
}
main().catch((error) => usageError(usage, error))
+234
View File
@@ -0,0 +1,234 @@
import {
DEFAULT_SSH_HOST,
NGINX_MARKER,
NGINX_SITE_LINK,
NGINX_SNIPPET,
SERVER_PUBLIC_ROOT,
SERVER_RELEASES_ROOT,
SERVER_STAGING_ROOT,
parseOptions,
renderNoReleasePage,
runSsh,
usageError,
validateSshHost
} from './lib.mjs'
const usage = `Usage:
node scripts/updates/provision-server.mjs [--ssh-host local001]
Creates /srv/ycode, /srv/ycode/releases/versions and /srv/ycode-staging, backs up the
nginx site configuration, installs a YCode-only snippet, validates nginx, then reloads.
It never serves an index and never creates a fake latest.json.
`
const snippet = `${NGINX_MARKER}
location = /ycode/releases/latest.json {
alias ${SERVER_RELEASES_ROOT}/latest.json;
default_type application/json;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
limit_except GET { deny all; }
}
location = /ycode/releases/index.html {
alias ${SERVER_RELEASES_ROOT}/index.html;
default_type text/html;
charset utf-8;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
limit_except GET { deny all; }
}
location /ycode/ {
alias ${SERVER_PUBLIC_ROOT}/;
autoindex off;
index index.html;
add_header X-Content-Type-Options "nosniff" always;
limit_except GET { deny all; }
}
`
async function main() {
const options = parseOptions(process.argv.slice(2), { values: ['ssh-host'] })
const host = validateSshHost(options['ssh-host'] || DEFAULT_SSH_HOST)
const snippetBase64 = Buffer.from(snippet, 'utf8').toString('base64')
const noReleasePageBase64 = Buffer.from(renderNoReleasePage(), 'utf8').toString('base64')
const script = `set -euo pipefail
export LC_ALL=C
site_link='${NGINX_SITE_LINK}'
snippet='${NGINX_SNIPPET}'
site="$(readlink -f "$site_link")"
test -f "$site"
test -f /etc/nginx/nginx.conf
nginx -t >/dev/null 2>&1
install -d -o root -g www-data -m 0755 '${SERVER_PUBLIC_ROOT}'
install -d -o root -g www-data -m 0755 '${SERVER_RELEASES_ROOT}'
install -d -o root -g www-data -m 0755 '${SERVER_RELEASES_ROOT}/versions'
install -d -o root -g root -m 0700 '${SERVER_STAGING_ROOT}'
page_tmp="$(mktemp '${SERVER_RELEASES_ROOT}/.index.html.XXXXXX')"
printf '%s' '${noReleasePageBase64}' | base64 -d > "$page_tmp"
chmod 0644 "$page_tmp"
if [ -f '${SERVER_RELEASES_ROOT}/latest.json' ]; then
if [ ! -f '${SERVER_RELEASES_ROOT}/index.html' ]; then
rm -f "$page_tmp"
echo "latest.json exists without a generated download page" >&2
exit 1
fi
if grep -q 'data-ycode-state="no-release"' '${SERVER_RELEASES_ROOT}/index.html'; then
rm -f "$page_tmp"
echo "latest.json exists while the download page still says no release" >&2
exit 1
fi
rm -f "$page_tmp"
elif [ ! -f '${SERVER_RELEASES_ROOT}/index.html' ]; then
mv -f "$page_tmp" '${SERVER_RELEASES_ROOT}/index.html'
else
if ! grep -q 'data-ycode-state="no-release"' '${SERVER_RELEASES_ROOT}/index.html'; then
rm -f "$page_tmp"
echo "download page exists without latest.json" >&2
exit 1
fi
rm -f "$page_tmp"
fi
backup_dir=/var/backups/nginx
install -d -o root -g root -m 0700 "$backup_dir"
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
config_backup="$backup_dir/ycode-before-${'$'}{stamp}-$(basename "$site")"
snippet_backup="$backup_dir/ycode-before-${'$'}{stamp}-ycode-releases.conf"
test ! -e "$config_backup"
cp -a "$site" "$config_backup"
snippet_existed=0
if [ -f "$snippet" ]; then
cp -a "$snippet" "$snippet_backup"
snippet_existed=1
fi
restore() {
changed=0
cp -a "$config_backup" "$site"
if [ "$snippet_existed" -eq 1 ]; then
cp -a "$snippet_backup" "$snippet"
else
rm -f "$snippet"
fi
}
changed=0
snippet_tmp=
cleanup() {
rc=$?
if [ -n "$snippet_tmp" ]; then
rm -f "$snippet_tmp"
fi
if [ "$rc" -ne 0 ] && [ "$changed" -eq 1 ]; then
restore
nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 || true
fi
exit "$rc"
}
trap cleanup EXIT
install -d -o root -g root -m 0755 /etc/nginx/snippets
snippet_tmp="$(mktemp /etc/nginx/snippets/.ycode-releases.XXXXXX)"
printf '%s' '${snippetBase64}' | base64 -d > "$snippet_tmp"
chmod 0644 "$snippet_tmp"
mv -f "$snippet_tmp" "$snippet"
changed=1
python3 - "$site" <<'PY'
import os
import re
import shutil
import sys
import tempfile
site = sys.argv[1]
marker = "${NGINX_MARKER}"
include = "\tinclude ${NGINX_SNIPPET};"
with open(site, "r", encoding="utf-8") as stream:
original = stream.read()
for required in ("location /baocode/ {", "location /baocode/releases/ {"):
if original.count(required) != 1:
raise SystemExit(f"refusing to change nginx: expected one {required}")
if marker in original:
if include not in original:
raise SystemExit("refusing to change nginx: YCode marker exists without include")
updated = original
else:
match = re.search(r"(?m)^([ \\t]*)location /baocode/ \\{", original)
if not match:
raise SystemExit("refusing to change nginx: baocode anchor not found")
indent = match.group(1)
insertion = f"{indent}{marker}\\n{indent}include ${NGINX_SNIPPET};\\n\\n"
updated = original[:match.start()] + insertion + original[match.start():]
if updated != original:
directory = os.path.dirname(site)
descriptor, temporary = tempfile.mkstemp(prefix=".ycode-nginx-", dir=directory)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
stream.write(updated)
stream.flush()
os.fsync(stream.fileno())
shutil.copystat(site, temporary)
os.replace(temporary, site)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
with open(site, "r", encoding="utf-8") as stream:
final = stream.read()
for required in ("location /baocode/ {", "location /baocode/releases/ {"):
if final.count(required) != 1:
raise SystemExit("refusing to change nginx: baocode locations changed")
if marker not in final or include not in final:
raise SystemExit("nginx include was not installed")
PY
if ! nginx -t; then
echo "nginx validation failed; configuration restored" >&2
exit 1
fi
if ! systemctl reload nginx; then
echo "nginx reload failed; configuration restored" >&2
exit 1
fi
status="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1/ycode/releases/latest.json || true)"
if [ -f '${SERVER_RELEASES_ROOT}/latest.json' ]; then
expected_status=200
else
expected_status=404
fi
if [ "$status" != "$expected_status" ]; then
echo "unexpected /ycode/releases/latest.json status: $status (expected $expected_status)" >&2
exit 1
fi
page_status="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1/ycode/releases/ || true)"
if [ "$page_status" != "200" ]; then
echo "unexpected /ycode/releases/ status: $page_status (expected 200)" >&2
exit 1
fi
printf 'site=%s\\n' "$site"
printf 'backup=%s\\n' "$config_backup"
printf 'snippet=%s\\n' "$snippet"
printf 'latest_status=%s\\n' "$status"
printf 'page_status=%s\\n' "$page_status"
`
const output = runSsh(host, script)
process.stdout.write(output)
console.log(`Provisioned ${host}; no release manifest was created.`)
}
main().catch((error) => usageError(usage, error))
+641
View File
@@ -0,0 +1,641 @@
import assert from 'node:assert/strict'
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import {
buildManifestPayload,
createSignedManifest,
generateEd25519KeyPair,
normalizeBaseUrl,
serializeSignedManifest,
writeFileAtomic
} from './lib.mjs'
import {
acquireReleaseLock,
defaultReleaseNotes,
finalizeReleaseRequest,
parseReleaseArgs,
releasePaths,
resolveVersionChoice,
runReleaseWorkflow,
updateLockText,
updatePackageText,
verifyPublishedManifest
} from './release-workflow.mjs'
let passed = 0
async function test(name, callback) {
await callback()
passed += 1
console.log(`PASS ${name}`)
}
function expectedError(pattern) {
return (error) => {
assert.match(String(error?.message || error), pattern)
return true
}
}
function packageText(version) {
return `${JSON.stringify({
name: 'ycode',
version,
private: true,
scripts: { build: 'fixture-build' },
build: { appId: 'com.ycode.app', productName: 'YCode' }
}, null, 2)}\n`
}
function lockText(version) {
return `${JSON.stringify({
name: 'ycode',
version,
lockfileVersion: 3,
requires: true,
packages: {
'': {
name: 'ycode',
version,
private: true,
dependencies: { fixture: '1.0.0' }
},
'node_modules/fixture': {
version: '1.0.0'
}
}
}, null, 2)}\n`
}
function signedManifest({ version, keys, baseUrl }) {
const payload = buildManifestPayload({
version,
publishedAt: '2026-10-20T10:00:00.000Z',
notes: defaultReleaseNotes(version),
installer: {
filename: `YCode-${version}-Setup-x64.exe`,
size: 1,
sha256: 'a'.repeat(64)
},
baseUrl
})
const document = createSignedManifest({
payload,
privateKeyPem: keys.privatePem,
publicKeyPem: keys.publicPem,
baseUrl
})
return {
document,
text: serializeSignedManifest(document, { baseUrl })
}
}
async function makeRepository({ version = '1.0.0' } = {}) {
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-release-selftest-'))
await fsp.writeFile(path.join(root, 'package.json'), packageText(version), 'utf8')
await fsp.writeFile(path.join(root, 'package-lock.json'), lockText(version), 'utf8')
return root
}
function requestFor({ version, notes, privateKeyPath, publicKeyPath, resume = false }) {
return {
help: false,
resume,
version,
notes: notes || defaultReleaseNotes(version),
sshHost: 'fake-local001',
baseUrl: normalizeBaseUrl('http://127.0.0.1/ycode/releases/'),
privateKeyPath,
publicKeyPath
}
}
function makeDependencies({
root,
keys,
current,
events,
overrides = {}
}) {
const paths = releasePaths(root, current?.version || '9.9.9')
const deps = {
root,
pathExists: (filename) => fsp.access(filename).then(() => true, () => false),
realpath: (filename) => fsp.realpath(filename),
readFile: (filename, encoding) => fsp.readFile(filename, encoding),
writeFileAtomic,
readPrivateKey: async () => keys.privatePem,
readPublicKey: async () => keys.publicPem,
validateBundle: async ({ manifestPath }) => {
events.push('bundle-validate')
const document = JSON.parse(await fsp.readFile(manifestPath, 'utf8'))
return { document }
},
acquireReleaseLock: async () => {
events.push('lock-acquire')
return async () => {
events.push('lock-release')
}
},
checkToolchain: async () => {
events.push('toolchain-check')
},
checkSsh: async () => {
events.push('ssh-check')
},
readLatest: async () => {
events.push('server-read')
return current ? { raw: current.text, document: current.document } : { raw: null, document: null }
},
runNpm: async (args) => {
events.push(`npm:${args.join(' ')}`)
},
runNode: async (args) => {
events.push(`node:${args.join(' ')}`)
if (args.some((value) => String(value).endsWith('prepare-release.mjs'))) {
const version = args[args.indexOf('--version') + 1]
const output = args[args.indexOf('--output') + 1]
await writeFileAtomic(output, signedManifest({ version, keys, baseUrl: 'http://127.0.0.1/ycode/releases/' }).text)
}
},
verifyHttp: async () => {
events.push('http-verify')
},
logWarning: (message) => events.push(`warning:${message}`),
...overrides
}
deps.paths = paths
return deps
}
async function main() {
const temporaryDirectories = []
let signingKeyProbePath
try {
const keyProbeDirectory = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-release-key-probe-'))
temporaryDirectories.push(keyProbeDirectory)
signingKeyProbePath = path.join(keyProbeDirectory, 'signing.key')
await fsp.writeFile(signingKeyProbePath, 'fixture key location only', 'utf8')
await test('stable version input rejects ambiguous and non-release versions', async () => {
for (const value of ['01.0.0', '1.01.0', '1.0.01', '1.0.0-rc.1', '1.0.0+build', 'v1.0.0', '1.0']) {
await assert.rejects(() => resolveVersionChoice({ version: value, prompt: async () => '' }), /stable x\.y\.z/)
}
for (const value of ['65536.0.0', '1.65536.0', '1.0.65536']) {
await assert.rejects(() => resolveVersionChoice({ version: value, prompt: async () => '' }), /Windows EXE metadata/)
}
assert.equal(
await resolveVersionChoice({ version: '1.2.3', prompt: async () => assert.fail('prompt should not run') }),
'1.2.3'
)
})
await test('blank interactive version cancels without starting a release', async () => {
assert.equal(await resolveVersionChoice({ prompt: async () => ' ' }), null)
})
await test('CLI defaults and unknown option handling are deterministic', async () => {
const parsed = parseReleaseArgs(['--version', '1.2.3'])
assert.equal(parsed.version, '1.2.3')
assert.deepEqual(parsed.notes, {})
const finalized = finalizeReleaseRequest(parsed, parsed.version)
assert.deepEqual(finalized.notes, {
zh: 'YCode 1.2.3 更新',
en: 'YCode 1.2.3 update'
})
assert.equal(parsed.sshHost, 'local001')
await assert.rejects(async () => parseReleaseArgs(['--dry-run']), /Unknown option/)
await assert.rejects(async () => parseReleaseArgs(['--public-key', 'other.pem']), /Unknown option/)
await assert.rejects(async () => parseReleaseArgs(['--resume']), /requires an explicit --version/)
assert.equal(parseReleaseArgs(['--help']).help, true)
})
await test('interactive version supplies notes from the actual input version', async () => {
const parsed = parseReleaseArgs([])
const version = await resolveVersionChoice({ version: parsed.version, prompt: async () => '0.0.6' })
const finalized = finalizeReleaseRequest(parsed, version)
assert.deepEqual(finalized.notes, {
zh: 'YCode 0.0.6 更新',
en: 'YCode 0.0.6 update'
})
})
await test('package and lock version updates preserve unrelated JSON content', async () => {
const nextPackage = updatePackageText(packageText('1.0.0'), '1.0.1')
const nextLock = updateLockText(lockText('1.0.0'), '1.0.1')
const pkg = JSON.parse(nextPackage)
const lock = JSON.parse(nextLock)
assert.equal(pkg.version, '1.0.1')
assert.equal(pkg.scripts.build, 'fixture-build')
assert.equal(pkg.build.appId, 'com.ycode.app')
assert.equal(lock.version, '1.0.1')
assert.equal(lock.packages[''].version, '1.0.1')
assert.equal(lock.packages['node_modules/fixture'].version, '1.0.0')
})
const normalRoot = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(normalRoot)
await test('normal release follows the required stage and command order', async () => {
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const current = signedManifest({ version: '1.0.0', keys, baseUrl })
const events = []
const deps = makeDependencies({
root: normalRoot,
keys,
current: { ...current, version: '1.0.0' },
events
})
const request = requestFor({
version: '1.0.1',
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
})
const result = await runReleaseWorkflow(request, deps)
assert.equal(result.version, '1.0.1')
const paths = releasePaths(normalRoot, '1.0.1')
assert.deepEqual(events.slice(0, 7), [
'lock-acquire',
'toolchain-check',
'ssh-check',
'server-read',
'npm:run test:updates',
'npm:run build',
`node:${paths.builderCli} --win nsis --x64 --publish never --config.directories.output=release/publish/1.0.1`
])
assert.match(events[7], /prepare-release\.mjs/)
assert.match(events[8], /deploy\.mjs.*--dry-run/)
assert.match(events[9], /deploy\.mjs/)
assert.doesNotMatch(events[9], /--dry-run/)
assert.deepEqual(events.slice(10), ['http-verify', 'lock-release'])
assert.equal(JSON.parse(await fsp.readFile(path.join(normalRoot, 'package.json'), 'utf8')).version, '1.0.1')
assert.equal(JSON.parse(await fsp.readFile(path.join(normalRoot, 'package-lock.json'), 'utf8')).version, '1.0.1')
})
await test('server/latest and repository versions are rejected before build', async () => {
for (const scenario of [
{ repo: '1.0.0', server: '1.0.0', target: '1.0.0', pattern: /strictly newer/ },
{ repo: '1.0.0', server: '0.9.0', target: '0.9.9', pattern: /repository version is newer/ }
]) {
const root = await makeRepository({ version: scenario.repo })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const server = signedManifest({ version: scenario.server, keys, baseUrl })
const events = []
const deps = makeDependencies({
root,
keys,
current: { ...server, version: scenario.server },
events
})
const before = await fsp.readFile(path.join(root, 'package.json'), 'utf8')
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: scenario.target,
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
expectedError(scenario.pattern)
)
assert.equal(await fsp.readFile(path.join(root, 'package.json'), 'utf8'), before)
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:')))
assert.equal(events.at(-1), 'lock-release')
}
})
await test('missing signing key fails in preflight without build or deploy', async () => {
const root = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const events = []
const deps = makeDependencies({
root,
keys,
current: null,
events,
overrides: {
pathExists: async (filename) =>
filename === path.join(os.tmpdir(), 'missing-signing.key')
? false
: fsp.access(filename).then(() => true, () => false),
readPrivateKey: async () => {
throw new Error('Unable to read update signing key')
}
}
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
privateKeyPath: path.join(os.tmpdir(), 'missing-signing.key'),
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'preflight' && /Signing key/.test(error.message)
)
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:')))
assert.equal(events.at(-1), 'lock-release')
})
await test('mismatched private and public keys fail before any external stage', async () => {
const root = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(root)
const privateKeys = generateEd25519KeyPair()
const publicKeys = generateEd25519KeyPair()
const events = []
const deps = makeDependencies({
root,
keys: privateKeys,
current: null,
events,
overrides: {
readPublicKey: async () => publicKeys.publicPem
}
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'preflight' && /does not match/.test(error.message)
)
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:') || event === 'ssh-check'))
assert.equal(events.at(-1), 'lock-release')
})
await test('private key canonical path cannot resolve into the repository', async () => {
const root = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const events = []
const deps = makeDependencies({
root,
keys,
current: null,
events,
overrides: {
realpath: async () => path.join(root, 'linked-signing.key')
}
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'preflight' && /resolves inside the repository/.test(error.message)
)
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:')))
assert.equal(events.at(-1), 'lock-release')
})
await test('a repository path beginning with ..foo is not treated as external', async () => {
const root = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const events = []
const deps = makeDependencies({
root,
keys,
current: null,
events
})
const nestedKey = path.join(root, '..foo', 'signing.key')
await fsp.mkdir(path.dirname(nestedKey), { recursive: true })
await fsp.writeFile(nestedKey, 'fixture inside-repository key', 'utf8')
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
privateKeyPath: nestedKey,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'preflight' && /must be outside the repository/.test(error.message)
)
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:') || event === 'ssh-check'))
assert.equal(events.at(-1), 'lock-release')
})
await test('a failed build stops before signing and deploy while retaining local version', async () => {
const root = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const current = signedManifest({ version: '1.0.0', keys, baseUrl })
const events = []
const deps = makeDependencies({
root,
keys,
current: { ...current, version: '1.0.0' },
events,
overrides: {
runNpm: async (args) => {
events.push(`npm:${args.join(' ')}`)
if (args.join(' ') === 'run build') throw new Error('fixture build failure')
}
}
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'build' && /fixture build failure/.test(error.message)
)
assert(!events.some((event) => event.includes('prepare-release') || event.includes('deploy.mjs')))
assert.equal(JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8')).version, '1.0.1')
assert.equal(events.at(-1), 'lock-release')
})
await test('resume revalidates the existing bundle and never rebuilds or re-signs', async () => {
const root = await makeRepository({ version: '1.0.1' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const local = signedManifest({ version: '1.0.1', keys, baseUrl })
const paths = releasePaths(root, '1.0.1')
await fsp.mkdir(paths.outputDir, { recursive: true })
await fsp.writeFile(paths.installerPath, 'fixture installer', 'utf8')
await fsp.writeFile(paths.manifestPath, local.text, 'utf8')
const events = []
const deps = makeDependencies({
root,
keys,
current: { ...local, version: '1.0.1' },
events
})
await runReleaseWorkflow(
requestFor({
version: '1.0.1',
resume: true,
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
)
assert(events.includes('bundle-validate'))
assert(!events.some((event) => event.startsWith('npm:') || event.includes('prepare-release')))
assert.equal(events.filter((event) => event.includes('deploy.mjs')).length, 2)
assert.equal(events.at(-2), 'http-verify')
assert.equal(events.at(-1), 'lock-release')
})
await test('HTTP verification failure is reported as a failed release', async () => {
const root = await makeRepository({ version: '1.0.1' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const local = signedManifest({ version: '1.0.1', keys, baseUrl })
const paths = releasePaths(root, '1.0.1')
await fsp.mkdir(paths.outputDir, { recursive: true })
await fsp.writeFile(paths.installerPath, 'fixture installer', 'utf8')
await fsp.writeFile(paths.manifestPath, local.text, 'utf8')
const events = []
const deps = makeDependencies({
root,
keys,
current: null,
events,
overrides: {
verifyHttp: async () => {
events.push('http-verify')
throw new Error('fixture HTTP mismatch')
}
}
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
resume: true,
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'verify-http' && /HTTP mismatch/.test(error.message)
)
assert(events.some((event) => event.includes('deploy.mjs')))
assert.equal(events.at(-1), 'lock-release')
})
await test('resume permits the same server version but rejects a newer server latest', async () => {
const root = await makeRepository({ version: '1.0.1' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const local = signedManifest({ version: '1.0.1', keys, baseUrl })
const newer = signedManifest({ version: '1.0.2', keys, baseUrl })
const paths = releasePaths(root, '1.0.1')
await fsp.mkdir(paths.outputDir, { recursive: true })
await fsp.writeFile(paths.installerPath, 'fixture installer', 'utf8')
await fsp.writeFile(paths.manifestPath, local.text, 'utf8')
const events = []
const deps = makeDependencies({
root,
keys,
current: { ...newer, version: '1.0.2' },
events
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
resume: true,
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'preflight' && /server latest is newer/.test(error.message)
)
assert(!events.some((event) => event.includes('deploy.mjs')))
assert.equal(events.at(-1), 'lock-release')
})
await test('release lock is mutually exclusive and released explicitly', async () => {
const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-release-lock-selftest-'))
temporaryDirectories.push(directory)
const lockPath = path.join(directory, '.tmp', 'updates', 'release.lock')
const release = await acquireReleaseLock(lockPath, { version: '1.0.1' })
await assert.rejects(acquireReleaseLock(lockPath, { version: '1.0.2' }), /already exists/)
assert.equal(await fsp.readFile(lockPath, 'utf8').then(() => true, () => false), true)
await release()
assert.equal(await fsp.readFile(lockPath, 'utf8').then(() => true, () => false), false)
const second = await acquireReleaseLock(lockPath, { version: '1.0.2' })
await second()
})
await test('HTTP verification checks no-cache request, exact bytes and signature', async () => {
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const local = signedManifest({ version: '1.0.2', keys, baseUrl })
let requestOptions
const response = new Response(local.text, {
status: 200,
headers: { 'content-type': 'application/json', 'content-length': String(Buffer.byteLength(local.text)) }
})
const document = await verifyPublishedManifest({
baseUrl,
expectedManifestText: local.text,
publicKeyPem: keys.publicPem,
version: '1.0.2',
fetchImpl: async (_url, options) => {
requestOptions = options
return response
}
})
assert.equal(document.payload.version, '1.0.2')
assert.equal(requestOptions.cache, 'no-store')
assert.equal(requestOptions.credentials, 'omit')
assert.equal(requestOptions.redirect, 'error')
assert.equal(requestOptions.headers.pragma, 'no-cache')
await assert.rejects(
verifyPublishedManifest({
baseUrl,
expectedManifestText: local.text,
publicKeyPem: keys.publicPem,
version: '1.0.2',
fetchImpl: async () =>
new Response(local.text.replace('YCode 1.0.2', 'YCode 1.0.3'), {
status: 200,
headers: { 'content-length': String(Buffer.byteLength(local.text)) }
})
}),
/does not exactly match/
)
})
console.log(`\n${passed} release workflow self-tests passed. No SSH, network HTTP, build, installer, signing, deployment, repository version change or production release was performed.`)
} finally {
for (const directory of temporaryDirectories) {
await fsp.rm(directory, { recursive: true, force: true })
}
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+591
View File
@@ -0,0 +1,591 @@
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}`)
}
}
}
+169
View File
@@ -0,0 +1,169 @@
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
})
+372
View File
@@ -0,0 +1,372 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import fsp from 'node:fs/promises'
import http from 'node:http'
import os from 'node:os'
import path from 'node:path'
import {
REPO_ROOT,
buildManifestPayload,
compareVersions,
createSignedManifest,
normalizeBaseUrl,
readPrivateKey,
readPublicKey,
renderDownloadPage,
runProcess,
serializeSignedManifest,
signPayload,
verifyPayloadSignature,
verifySignedManifest
} from './lib.mjs'
import {
publishLocalRelease,
readLocalLatest
} from './transport.mjs'
let passed = 0
async function test(name, callback) {
await callback()
passed += 1
console.log(`PASS ${name}`)
}
function expectedError(pattern) {
return (error) => {
assert.match(String(error?.message || error), pattern)
return true
}
}
function sha256(buffer) {
return createHash('sha256').update(buffer).digest('hex')
}
function contentType(filename) {
return filename.endsWith('.json') ? 'application/json' : 'application/octet-stream'
}
function startStaticServer(root) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1')
const pathname = decodeURIComponent(url.pathname)
const relative = pathname.startsWith('/ycode/releases/')
? pathname.slice('/ycode/releases/'.length)
: null
assert(relative !== null, 'unexpected path')
const requested = relative === '' || relative.endsWith('/') ? `${relative}index.html` : relative
const filename = path.resolve(root, 'ycode', 'releases', requested)
const releaseRoot = path.resolve(root, 'ycode', 'releases')
assert(filename.startsWith(`${releaseRoot}${path.sep}`), 'path traversal')
const data = await fsp.readFile(filename)
response.writeHead(200, {
'content-length': data.length,
'content-type': contentType(filename)
})
response.end(data)
} catch {
response.writeHead(404)
response.end('not found')
}
})
return new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
resolve({ server, origin: `http://127.0.0.1:${address.port}` })
})
})
}
async function releaseFor({ version, bytes, filename, baseUrl, privatePem, publicPem, artifactPath }) {
const installer = {
filename,
size: bytes.length,
sha256: sha256(bytes)
}
const payload = buildManifestPayload({
version,
publishedAt: '2026-10-20T10:00:00.000Z',
notes: { zh: `版本 ${version} 测试说明`, en: `Release ${version} test notes` },
installer,
baseUrl
})
const document = createSignedManifest({
payload,
privateKeyPem: privatePem,
publicKeyPem: publicPem,
baseUrl
})
return {
document,
installer: { ...installer, path: artifactPath },
manifestText: serializeSignedManifest(document, { baseUrl }),
publicKeyPem: publicPem,
baseUrl
}
}
async function main() {
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-update-selftest-'))
const privateKey = path.join(temporary, 'keys', 'update-signing.key')
const publicKey = path.join(temporary, 'keys', 'public-key.pem')
const publicRoot = path.join(temporary, 'ycode')
const artifactDirectory = path.join(temporary, 'fixtures')
await fsp.mkdir(artifactDirectory, { recursive: true })
let server
try {
await test('key generation stays outside the repository and writes a PEM trust anchor', async () => {
const initKey = path.join(REPO_ROOT, 'scripts', 'updates', 'init-key.mjs')
const output = runProcess(process.execPath, [initKey, '--private-key', privateKey, '--public-key', publicKey])
assert.match(output, /Private key:/)
assert.doesNotMatch(output, /BEGIN PRIVATE KEY/)
const privatePem = await fsp.readFile(privateKey, 'utf8')
const publicPem = await fsp.readFile(publicKey, 'utf8')
assert.match(privatePem, /BEGIN PRIVATE KEY/)
assert.match(publicPem, /BEGIN PUBLIC KEY/)
if (process.platform === 'win32') {
const acl = runProcess('icacls', [privateKey])
assert.doesNotMatch(acl, /\(I\)/)
}
})
const privatePem = await readPrivateKey(privateKey)
const publicPem = await readPublicKey(publicKey)
await test('SemVer precedence is strict and build metadata does not create a newer release', async () => {
assert(compareVersions('2.0.0', '1.9.9') > 0)
assert(compareVersions('2.0.0-rc.2', '2.0.0-rc.10') < 0)
assert(compareVersions('2.0.0+1', '2.0.0+2') === 0)
assert.throws(() => compareVersions('01.0.0', '1.0.0'), /strict SemVer/)
assert.throws(() => compareVersions('1.0', '1.0.0'), /strict SemVer/)
})
const staticServer = await startStaticServer(temporary)
server = staticServer.server
const baseUrl = normalizeBaseUrl(`${staticServer.origin}/ycode/releases/`)
const version1 = '2.0.0'
const bytes1 = Buffer.from(`fake installer ${version1} `.repeat(16_384), 'utf8')
const filename1 = `YCode-${version1}-Setup-x64.exe`
const artifact1 = path.join(artifactDirectory, filename1)
await fsp.writeFile(artifact1, bytes1)
const release1 = await releaseFor({
version: version1,
bytes: bytes1,
filename: filename1,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact1
})
await test('manifest signs exact JSON.stringify(payload) and rejects any payload or signature tamper', async () => {
verifySignedManifest(release1.document, publicPem, { baseUrl })
assert(verifyPayloadSignature(release1.document.payload, release1.document.signature, publicPem))
const notesTamper = structuredClone(release1.document)
notesTamper.payload.notes.en = 'tampered'
assert.throws(() => verifySignedManifest(notesTamper, publicPem, { baseUrl }), /signature is invalid/)
const hashTamper = structuredClone(release1.document)
hashTamper.payload.platforms['windows-x64'].sha256 = '0'.repeat(64)
assert.throws(() => verifySignedManifest(hashTamper, publicPem, { baseUrl }), /signature is invalid/)
const signatureTamper = structuredClone(release1.document)
const first = signatureTamper.signature[0] === 'A' ? 'B' : 'A'
signatureTamper.signature = first + signatureTamper.signature.slice(1)
assert.throws(() => verifySignedManifest(signatureTamper, publicPem, { baseUrl }), /signature is invalid/)
})
await test('signed manifests reject query, fragment, credentials, cross-version paths and non-canonical timestamps', async () => {
const cases = [
(payload) => { payload.platforms['windows-x64'].url += '?token=bad' },
(payload) => { payload.platforms['windows-x64'].url += '#fragment' },
(payload) => { payload.platforms['windows-x64'].url = payload.platforms['windows-x64'].url.replace('127.0.0.1', 'user:pass@127.0.0.1') },
(payload) => { payload.platforms['windows-x64'].url = payload.platforms['windows-x64'].url.replace('/versions/2.0.0/', '/versions/2.0.1/') },
(payload) => { payload.publishedAt = '2026-10-20T10:00:00Z' },
(payload) => { payload.platforms['windows-x64'].sha256 = payload.platforms['windows-x64'].sha256.toUpperCase() }
]
for (const mutate of cases) {
const payload = structuredClone(release1.document.payload)
mutate(payload)
const document = { payload, signature: signPayload(payload, privatePem) }
assert.throws(() => verifySignedManifest(document, publicPem, { baseUrl }), /Manifest|URL|publishedAt|sha256/)
}
})
await test('download page escapes signed notes and contains no script', async () => {
const payload = structuredClone(release1.document.payload)
payload.notes.zh = '<script>alert("zh")</script>\n第二行'
payload.notes.en = '<img src=x onerror=alert(1)>'
const html = renderDownloadPage(payload, { baseUrl })
assert.doesNotMatch(html, /<script>alert/)
assert.doesNotMatch(html, /<img src=x/)
assert.match(html, /&lt;script&gt;/)
assert.match(html, /&lt;img src=x onerror=alert\(1\)&gt;/)
assert.match(html, /Content-Security-Policy/)
})
await test('local fake transport publishes immutable artifact then fetches and verifies it over HTTP', async () => {
let current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await publishLocalRelease({ root: publicRoot, release: release1, current })
const response = await fetch(new URL('latest.json', baseUrl))
assert.equal(response.status, 200)
assert.equal(response.headers.get('cache-control'), null)
const text = await response.text()
const document = JSON.parse(text)
verifySignedManifest(document, publicPem, { baseUrl })
const artifactResponse = await fetch(document.payload.platforms['windows-x64'].url)
const downloaded = Buffer.from(await artifactResponse.arrayBuffer())
assert.equal(sha256(downloaded), document.payload.platforms['windows-x64'].sha256)
assert.equal(downloaded.length, document.payload.platforms['windows-x64'].size)
const pageResponse = await fetch(new URL('./', baseUrl))
assert.equal(pageResponse.status, 200)
const page = await pageResponse.text()
assert.match(page, /v2\.0\.0/)
assert.match(page, new RegExp(document.payload.platforms['windows-x64'].sha256))
assert.doesNotMatch(page, /<script\b/i)
})
await test('exact redeploy is idempotent', async () => {
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await publishLocalRelease({ root: publicRoot, release: release1, current })
})
await test('a conflicting artifact in an existing version directory is rejected', async () => {
const bytes = Buffer.from('conflicting same-version bytes', 'utf8')
const artifact = path.join(artifactDirectory, filename1)
await fsp.writeFile(artifact, bytes)
const conflicting = await releaseFor({
version: version1,
bytes,
filename: filename1,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(current.document.payload.version, version1)
await fsp.writeFile(artifact, bytes1)
await assert.rejects(
publishLocalRelease({ root: publicRoot, release: conflicting, current }),
expectedError(/different manifest/)
)
})
await test('a lower version is rejected without changing latest.json', async () => {
const bytes = Buffer.from('lower release fixture', 'utf8')
const filename = 'YCode-1.9.9-Setup-x64.exe'
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const lower = await releaseFor({
version: '1.9.9',
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await assert.rejects(
publishLocalRelease({ root: publicRoot, release: lower, current }),
expectedError(/newer/)
)
const after = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(after.raw, current.raw)
})
await test('failure before latest.json swap leaves the previous signed manifest live', async () => {
const version = '2.1.0'
const bytes = Buffer.from('failed atomic fixture', 'utf8')
const filename = `YCode-${version}-Setup-x64.exe`
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const failed = await releaseFor({
version,
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const before = await readLocalLatest(publicRoot, publicPem, baseUrl)
const pageBefore = await (await fetch(new URL('./', baseUrl))).text()
await assert.rejects(
publishLocalRelease({
root: publicRoot,
release: failed,
current: before,
beforeLatestSwap: async () => { throw new Error('injected publication failure') }
}),
expectedError(/injected publication failure/)
)
const after = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(after.raw, before.raw)
const pageAfter = await (await fetch(new URL('./', baseUrl))).text()
assert.equal(pageAfter, pageBefore)
})
await test('a different artifact cannot reuse a version directory left by a failed publication', async () => {
const version = '2.1.0'
const bytes = Buffer.from('different failed artifact', 'utf8')
const filename = `YCode-${version}-Setup-x64.exe`
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const conflicting = await releaseFor({
version,
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await assert.rejects(
publishLocalRelease({ root: publicRoot, release: conflicting, current }),
expectedError(/conflicting artifact/)
)
})
await test('successful publication swaps the generated download page with latest.json', async () => {
const version = '2.2.0'
const bytes = Buffer.from('successful next release fixture', 'utf8')
const filename = `YCode-${version}-Setup-x64.exe`
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const release = await releaseFor({
version,
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await publishLocalRelease({ root: publicRoot, release, current })
const latest = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(latest.document.payload.version, version)
const page = await (await fetch(new URL('./', baseUrl))).text()
assert.match(page, /v2\.2\.0/)
assert.match(page, new RegExp(latest.document.payload.platforms['windows-x64'].sha256))
})
console.log(`\n${passed} release-tool self-tests passed. No SSH target or production latest.json was touched.`)
} finally {
if (server) await new Promise((resolve) => server.close(resolve))
await fsp.rm(temporary, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+408
View File
@@ -0,0 +1,408 @@
import crypto from 'node:crypto'
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import {
DEFAULT_BASE_URL,
SERVER_RELEASES_ROOT,
SERVER_STAGING_ROOT,
assert,
compareVersions,
normalizeBaseUrl,
parseVersion,
readJson,
readPublicKey,
renderDownloadPage,
serializeSignedManifest,
sha256File,
verifyReleaseInstaller,
verifySignedManifest,
runProcess,
runSsh,
validateSshHost,
writeFileAtomic
} from './lib.mjs'
const MAX_MANIFEST_BYTES = 1024 * 1024
export function parseCurrentManifest(raw, publicKeyPem, baseUrl) {
assert(Buffer.byteLength(raw, 'utf8') <= MAX_MANIFEST_BYTES, 'Current latest.json exceeds 1 MiB')
let document
try {
document = JSON.parse(raw)
} catch {
throw new Error('Current latest.json is not valid JSON')
}
verifySignedManifest(document, publicKeyPem, { baseUrl })
return document
}
export function assertMonotonicTransition(current, target) {
if (!current) return
const compared = compareVersions(target.payload.version, current.payload.version)
assert(compared >= 0, `Refusing to publish ${target.payload.version}; server latest is newer (${current.payload.version})`)
if (compared === 0) {
assert(
JSON.stringify(current) === JSON.stringify(target),
`Refusing to replace ${target.payload.version} with a different manifest`
)
}
}
export async function validateReleaseBundle({
manifestPath,
installerPath,
publicKeyPath,
baseUrl = DEFAULT_BASE_URL
}) {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
const publicKeyPem = await readPublicKey(publicKeyPath)
let document
try {
document = await readJson(manifestPath)
} catch (error) {
throw new Error(`Unable to read release manifest: ${error.message}`)
}
verifySignedManifest(document, publicKeyPem, { baseUrl: normalizedBaseUrl })
const expected = document.payload.platforms['windows-x64']
const installer = await verifyReleaseInstaller(installerPath, document.payload.version)
assert(installer.size === expected.size, 'Installer size does not match the signed manifest')
assert(installer.sha256 === expected.sha256, 'Installer SHA-256 does not match the signed manifest')
assert(path.posix.basename(new URL(expected.url).pathname) === installer.filename, 'Signed URL filename does not match the installer')
return {
document,
manifestText: serializeSignedManifest(document, { baseUrl: normalizedBaseUrl }),
pageHtml: renderDownloadPage(document.payload, { baseUrl: normalizedBaseUrl }),
installer,
publicKeyPem,
baseUrl: normalizedBaseUrl
}
}
export async function readLocalLatest(root, publicKeyPem, baseUrl) {
const filename = path.join(root, 'releases', 'latest.json')
try {
const raw = await fsp.readFile(filename, 'utf8')
return { raw, document: parseCurrentManifest(raw, publicKeyPem, baseUrl) }
} catch (error) {
if (error && error.code === 'ENOENT') return { raw: null, document: null }
throw error
}
}
async function installVersionDirectory({ root, version, installerPath, expectedSha256, runId }) {
const releases = path.join(root, 'releases')
const versions = path.join(releases, 'versions')
const target = path.join(versions, version)
const filename = path.basename(installerPath)
await fsp.mkdir(versions, { recursive: true })
try {
const entries = await fsp.readdir(target)
assert(entries.length === 1 && entries[0] === filename, `Version directory ${version} already conflicts with this artifact`)
const existingHash = await sha256File(path.join(target, filename))
assert(existingHash === expectedSha256, `Version directory ${version} contains a conflicting artifact`)
return
} catch (error) {
if (!error || error.code !== 'ENOENT') throw error
}
const staging = path.join(root, '.staging', runId)
const stagedVersion = path.join(staging, 'version')
const stagedFile = path.join(stagedVersion, filename)
await fsp.mkdir(stagedVersion, { recursive: true })
try {
await fsp.copyFile(installerPath, stagedFile)
const copiedHash = await sha256File(stagedFile)
assert(copiedHash === expectedSha256, 'Staged installer hash changed')
await fsp.rename(stagedVersion, target)
} finally {
await fsp.rm(staging, { recursive: true, force: true })
}
}
export async function publishLocalRelease({
root,
release,
current,
beforeLatestSwap
}) {
const { document, installer, manifestText, publicKeyPem, baseUrl } = release
assertMonotonicTransition(current?.document, document)
await installVersionDirectory({
root,
version: document.payload.version,
installerPath: installer.path,
expectedSha256: document.payload.platforms['windows-x64'].sha256,
runId: crypto.randomBytes(12).toString('hex')
})
const latestPath = path.join(root, 'releases', 'latest.json')
const indexPath = path.join(root, 'releases', 'index.html')
let previousPage = null
try {
previousPage = await fsp.readFile(indexPath)
} catch (error) {
if (!error || error.code !== 'ENOENT') throw error
}
const assertCurrentLatest = async () => {
if (current?.raw != null) {
const liveRaw = await fsp.readFile(latestPath, 'utf8')
assert(liveRaw === current.raw, 'latest.json changed during publication')
return
}
try {
await fsp.access(latestPath)
throw new Error('latest.json appeared during publication')
} catch (error) {
if (error && error.code !== 'ENOENT') throw error
}
}
await assertCurrentLatest()
try {
await writeFileAtomic(
indexPath,
release.pageHtml || renderDownloadPage(document.payload, { baseUrl }),
0o644
)
if (beforeLatestSwap) await beforeLatestSwap()
await assertCurrentLatest()
await writeFileAtomic(latestPath, manifestText, 0o644)
} catch (error) {
try {
if (previousPage == null) {
await fsp.rm(indexPath, { force: true })
} else {
await writeFileAtomic(indexPath, previousPage, 0o644)
}
} catch (rollbackError) {
throw new Error(`${error.message}; failed to restore download page: ${rollbackError.message}`)
}
throw error
}
return { latestPath, document }
}
export async function readLatestViaSsh(host, publicKeyPem, baseUrl) {
const script = `set -euo pipefail
if [ -f '${SERVER_RELEASES_ROOT}/latest.json' ]; then
cat '${SERVER_RELEASES_ROOT}/latest.json'
fi
`
const raw = runSsh(host, script)
if (!raw) return { raw: null, document: null }
assert(Buffer.byteLength(raw, 'utf8') <= MAX_MANIFEST_BYTES, 'Server latest.json exceeds 1 MiB')
return { raw, document: parseCurrentManifest(raw, publicKeyPem, baseUrl) }
}
function sha256Text(value) {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex')
}
export async function publishViaSsh({
host,
release,
current
}) {
validateSshHost(host)
const { document, installer, manifestText, publicKeyPem, baseUrl } = release
assertMonotonicTransition(current?.document, document)
const runId = crypto.randomBytes(12).toString('hex')
const version = document.payload.version
const filename = installer.filename
parseVersion(version)
assert(/^[A-Za-z0-9._-]+\.exe$/.test(filename), 'Unsafe installer filename')
const staging = `${SERVER_STAGING_ROOT}/${runId}`
const manifestSha256 = sha256Text(manifestText)
const pageHtml = renderDownloadPage(document.payload, { baseUrl })
const pageSha256 = sha256Text(pageHtml)
const metadata = {
runId,
version,
filename,
sha256: document.payload.platforms['windows-x64'].sha256,
size: document.payload.platforms['windows-x64'].size,
manifestSha256,
pageSha256,
expectedLatestSha256: current?.raw == null ? null : sha256Text(current.raw)
}
runSsh(host, `set -euo pipefail
install -d -o root -g root -m 0700 '${SERVER_STAGING_ROOT}'
install -d -o root -g root -m 0700 '${staging}'
`)
let publicationError
try {
runProcess('scp', ['-q', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', installer.path, `${host}:${staging}/${filename}`])
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-update-deploy-'))
const localManifest = path.join(temporary, 'latest.json')
const localPage = path.join(temporary, 'index.html')
await fsp.writeFile(localManifest, manifestText, { mode: 0o600 })
await fsp.writeFile(localPage, pageHtml, { mode: 0o600 })
try {
runProcess('scp', ['-q', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', localManifest, `${host}:${staging}/latest.json`])
runProcess('scp', ['-q', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', localPage, `${host}:${staging}/index.html`])
} finally {
await fsp.rm(temporary, { recursive: true, force: true })
}
const metadataBase64 = Buffer.from(JSON.stringify(metadata), 'utf8').toString('base64')
const remoteScript = `set -euo pipefail
umask 022
metadata_file="$(mktemp /tmp/ycode-release.XXXXXX.json)"
cleanup() {
rm -f "$metadata_file"
rm -rf '${staging}'
}
trap cleanup EXIT
printf '%s' '${metadataBase64}' | base64 -d > "$metadata_file"
python3 - "$metadata_file" <<'PY'
import fcntl
import hashlib
import json
import os
import re
import shutil
import sys
meta = json.load(open(sys.argv[1], "r", encoding="utf-8"))
run_id = meta["runId"]
version = meta["version"]
filename = meta["filename"]
expected_hash = meta["sha256"]
expected_size = meta["size"]
manifest_hash = meta["manifestSha256"]
page_hash = meta["pageSha256"]
expected_latest_hash = meta["expectedLatestSha256"]
if not re.fullmatch(r"[0-9A-Fa-f]{24}", run_id):
raise SystemExit("invalid staging id")
if not re.fullmatch(r"[0-9A-Za-z._-]+\\.exe", filename):
raise SystemExit("invalid installer filename")
if not re.fullmatch(r"[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?", version):
raise SystemExit("invalid version")
public_root = "${path.posix.dirname(SERVER_RELEASES_ROOT)}"
releases = "${SERVER_RELEASES_ROOT}"
staging = "${SERVER_STAGING_ROOT}/" + run_id
versions = os.path.join(releases, "versions")
target_dir = os.path.join(versions, version)
staged_artifact = os.path.join(staging, filename)
staged_manifest = os.path.join(staging, "latest.json")
staged_page = os.path.join(staging, "index.html")
latest = os.path.join(releases, "latest.json")
index = os.path.join(releases, "index.html")
def digest(pathname):
value = hashlib.sha256()
with open(pathname, "rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
value.update(chunk)
return value.hexdigest()
if os.stat(staging).st_dev != os.stat(public_root).st_dev:
raise SystemExit("staging and release roots are on different filesystems")
os.makedirs(versions, mode=0o755, exist_ok=True)
os.makedirs("${SERVER_STAGING_ROOT}", mode=0o700, exist_ok=True)
lock_path = "/var/lock/ycode-release.lock"
with open(lock_path, "a+", encoding="utf-8") as lock:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
live_latest_hash = digest(latest) if os.path.isfile(latest) else None
if live_latest_hash != expected_latest_hash:
raise SystemExit("latest.json changed during publication")
if os.path.getsize(staged_artifact) != expected_size or digest(staged_artifact) != expected_hash:
raise SystemExit("staged installer does not match signed metadata")
if os.path.getsize(staged_manifest) > 1024 * 1024 or digest(staged_manifest) != manifest_hash:
raise SystemExit("staged manifest does not match expected bytes")
if os.path.getsize(staged_page) > 512 * 1024 or digest(staged_page) != page_hash:
raise SystemExit("staged download page does not match expected bytes")
if os.path.exists(target_dir):
entries = sorted(os.listdir(target_dir))
if entries != [filename]:
raise SystemExit("version directory conflicts with this artifact")
if digest(os.path.join(target_dir, filename)) != expected_hash:
raise SystemExit("version directory contains a conflicting artifact")
else:
version_stage = os.path.join(staging, "version")
os.makedirs(version_stage, mode=0o755, exist_ok=False)
os.replace(staged_artifact, os.path.join(version_stage, filename))
os.chmod(os.path.join(version_stage, filename), 0o644)
os.replace(version_stage, target_dir)
previous_page = None
if os.path.isfile(index):
with open(index, "rb") as stream:
previous_page = stream.read()
if len(previous_page) > 512 * 1024:
raise SystemExit("existing download page is unexpectedly large")
next_latest = os.path.join(releases, f".latest.json.{run_id}.tmp")
next_page = os.path.join(releases, f".index.html.{run_id}.tmp")
os.replace(staged_manifest, next_latest)
os.chmod(next_latest, 0o644)
with open(next_latest, "rb") as stream:
os.fsync(stream.fileno())
os.replace(staged_page, next_page)
os.chmod(next_page, 0o644)
with open(next_page, "rb") as stream:
os.fsync(stream.fileno())
page_swapped = False
try:
os.replace(next_page, index)
page_swapped = True
os.replace(next_latest, latest)
except Exception:
if page_swapped:
if previous_page is None:
try:
os.unlink(index)
except FileNotFoundError:
pass
else:
rollback_page = os.path.join(releases, f".index.html.{run_id}.rollback")
with open(rollback_page, "wb") as stream:
stream.write(previous_page)
stream.flush()
os.fsync(stream.fileno())
os.chmod(rollback_page, 0o644)
os.replace(rollback_page, index)
raise
directory_fd = os.open(releases, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
print(version)
PY
`
runSsh(host, remoteScript)
} catch (error) {
publicationError = error
throw error
} finally {
try {
runSsh(host, `set -euo pipefail
rm -rf '${staging}'
`)
} catch (cleanupError) {
if (publicationError) {
console.error(`Unable to remove remote staging path after failure: ${cleanupError.message}`)
} else {
console.warn(`Publication succeeded, but remote staging cleanup failed: ${cleanupError.message}`)
}
}
}
return { version }
}