Files
Ncurator/scripts/release/artifact-bundle.mjs
T
Yoan Huang 4ddc364528 feat(release): implement artifact bundle creation and verification
- Added `artifact-bundle.mjs` for creating and verifying release bundles.
- Updated macOS and Windows release scripts to create and upload release bundles.
- Introduced `--package-e2e` flag to run end-to-end tests on packaged releases.
- Modified `package.json` scripts to remove deprecated commands and streamline release process.
- Enhanced E2E testing to utilize release bundles instead of building from source.
- Added unit tests for artifact bundle creation and verification.
- Removed obsolete `route-windows.mjs` script in favor of a unified `windows.mjs` script.
2026-07-22 11:31:12 +08:00

152 lines
5.6 KiB
JavaScript

import { createHash } from 'node:crypto'
import { readFile, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { assertReleaseFragment } from './r2/fragment-contract.mjs'
const SHA256_HEX = /^[a-f0-9]{64}$/u
const SHA512_BASE64 = /^[A-Za-z0-9+/]{86}==$/u
const hashFile = async (filePath, algorithm, encoding) =>
createHash(algorithm)
.update(await readFile(filePath))
.digest(encoding)
const assertPlainFileName = (value, label) => {
if (
typeof value !== 'string' ||
!value ||
value !== path.basename(value) ||
value === '.' ||
value === '..'
) {
throw new Error(`${label} must be a plain file name`)
}
}
export const assertReleaseBundle = (
bundle,
{ tag: expectedTag, platform: expectedPlatform } = {}
) => {
if (!bundle || typeof bundle !== 'object') throw new Error('release bundle is missing')
if (bundle.schemaVersion !== 1) throw new Error('release bundle schemaVersion must be 1')
if (!/^v\d+\.\d+\.\d+$/u.test(bundle.tag)) throw new Error('release bundle tag is invalid')
if (expectedTag && bundle.tag !== expectedTag) {
throw new Error(`release bundle tag must be ${expectedTag}`)
}
if (expectedPlatform && bundle.platform !== expectedPlatform) {
throw new Error(`release bundle platform must be ${expectedPlatform}`)
}
if (!/^[a-f0-9]{40}$/u.test(bundle.commit)) throw new Error('release bundle commit is invalid')
if (Number.isNaN(Date.parse(bundle.createdAt))) {
throw new Error('release bundle createdAt is invalid')
}
assertPlainFileName(bundle.fragment, 'release bundle fragment')
if (!Array.isArray(bundle.files) || bundle.files.length === 0) {
throw new Error('release bundle files are missing')
}
const names = new Set()
for (const file of bundle.files) {
assertPlainFileName(file?.name, 'release bundle asset')
if (names.has(file.name)) throw new Error(`duplicate release bundle asset: ${file.name}`)
names.add(file.name)
if (!['installer', 'update'].includes(file.role)) {
throw new Error(`invalid release bundle role: ${file.name}`)
}
if (!Number.isSafeInteger(file.size) || file.size <= 0) {
throw new Error(`invalid release bundle size: ${file.name}`)
}
if (!SHA256_HEX.test(file.sha256)) throw new Error(`invalid sha256: ${file.name}`)
if (!SHA512_BASE64.test(file.sha512)) throw new Error(`invalid sha512: ${file.name}`)
}
if (bundle.files.filter((file) => file.role === 'installer').length !== 1) {
throw new Error('release bundle must contain exactly one installer')
}
return bundle
}
export const createReleaseBundle = async ({ fragmentPath, outputPath, commit }) => {
const fragment = JSON.parse(await readFile(fragmentPath, 'utf8'))
assertReleaseFragment(fragment)
if (!/^[a-f0-9]{40}$/u.test(commit)) throw new Error(`invalid release commit: ${commit}`)
const directory = path.dirname(fragmentPath)
const files = []
for (const file of fragment.files) {
const filePath = path.join(directory, file.name)
const fileStat = await stat(filePath)
const sha512 = await hashFile(filePath, 'sha512', 'base64')
if (fileStat.size !== file.size || sha512 !== file.sha512) {
throw new Error(`release fragment does not match local asset: ${file.name}`)
}
files.push({
name: file.name,
role: file.role,
size: file.size,
sha256: await hashFile(filePath, 'sha256', 'hex'),
sha512
})
}
const bundle = {
schemaVersion: 1,
tag: fragment.tag,
commit,
platform: fragment.platform,
createdAt: new Date().toISOString(),
fragment: path.basename(fragmentPath),
files
}
assertReleaseBundle(bundle)
await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, 'utf8')
return bundle
}
export const readReleaseBundle = async (bundlePath, expected = {}) => {
const bundle = JSON.parse(await readFile(bundlePath, 'utf8'))
return assertReleaseBundle(bundle, expected)
}
export const verifyReleaseBundle = async (bundlePath, expected = {}) => {
const bundle = await readReleaseBundle(bundlePath, expected)
const directory = path.dirname(bundlePath)
const fragmentPath = path.join(directory, bundle.fragment)
const fragment = JSON.parse(await readFile(fragmentPath, 'utf8'))
assertReleaseFragment(fragment, { tag: bundle.tag, platform: bundle.platform })
for (const file of bundle.files) {
const fragmentFile = fragment.files.find((candidate) => candidate.name === file.name)
if (
!fragmentFile ||
fragmentFile.role !== file.role ||
fragmentFile.size !== file.size ||
fragmentFile.sha512 !== file.sha512
) {
throw new Error(`release bundle does not match fragment: ${file.name}`)
}
const filePath = path.join(directory, file.name)
const fileStat = await stat(filePath)
if (fileStat.size !== file.size) throw new Error(`release asset size changed: ${file.name}`)
const [sha256, sha512] = await Promise.all([
hashFile(filePath, 'sha256', 'hex'),
hashFile(filePath, 'sha512', 'base64')
])
if (sha256 !== file.sha256 || sha512 !== file.sha512) {
throw new Error(`release asset hash changed: ${file.name}`)
}
}
if (fragment.files.length !== bundle.files.length) {
throw new Error('release bundle file set does not match fragment')
}
return { bundle, directory, fragment, fragmentPath }
}
export const resolveBundleInstaller = async (bundlePath, expected = {}) => {
const verified = await verifyReleaseBundle(bundlePath, expected)
const installer = verified.bundle.files.find((file) => file.role === 'installer')
return {
...verified,
installer,
installerPath: path.join(verified.directory, installer.name)
}
}