Files
ycode/scripts/updates/release-selftest.mjs

642 lines
23 KiB
JavaScript

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