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