feat: ship skill and MCP workflows with signed updater for v0.0.7
This commit is contained in:
1 parent
1b52df0603
commit
a01085c656
136 files changed
+18646
-217
No files matched your search
@@ -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
|
||||
})
|
||||
@@ -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))
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
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.')
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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
|
||||
})
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
@@ -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, /<script>/)
|
||||
assert.match(html, /<img src=x onerror=alert\(1\)>/)
|
||||
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
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user