642 lines
23 KiB
JavaScript
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
|
|
})
|