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,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
|
||||
})
|
||||
Reference in new issue
Block a user