Files
open-science/scripts/generate-version-manifest.test.ts
Ewen 052d83d092 feat(linux): add native ARM64 distribution support (#3106)
* feat(linux): add native ARM64 distribution support

* fix(linux): complete ARM64 feed promotion and web packaging

* test(linux): cover ARM64 channel promotion fixtures

* fix(release): bootstrap the first ARM64 update channel

* test(release): include ARM64 bootstrap in workflow contract

* ci(runtime): gate PRs on ARM64 bundle readiness
2026-09-28 05:41:05 -07:00

310 lines
11 KiB
TypeScript

import { createHash } from 'node:crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildManifest,
extractHighlights,
parseSha256Sums,
readReleaseNotes
} from './generate-version-manifest.mjs'
const VERSION = '0.1.2'
const CDN = 'https://cdn.example.com'
const PREFIX = 'open-science'
// The full set of installer filenames a complete release produces (matches electron-builder.yml
// artifactName templates with the aipoch- prefix).
const INSTALLERS = {
'mac-arm64': `aipoch-open-science-${VERSION}-mac-arm64.dmg`,
'mac-x64': `aipoch-open-science-${VERSION}-mac-x64.dmg`,
'win-x64': `aipoch-open-science-${VERSION}-win-x64-setup.exe`,
'linux-x64-appimage': `aipoch-open-science-${VERSION}-linux-x64.AppImage`,
'linux-x64-deb': `aipoch-open-science_${VERSION}_amd64.deb`,
'linux-arm64-appimage': `aipoch-open-science-${VERSION}-linux-arm64.AppImage`,
'linux-arm64-deb': `aipoch-open-science_${VERSION}_arm64.deb`
}
// One line of SHA256SUMS.txt worth of file: content is hashed by `sha` (or omitted when sha is null).
type FileSpec = { name: string; content: string; sha: string | null }
// Builds a hermetic release directory: writes each given file with deterministic contents and a
// matching SHA256SUMS.txt. Contents/hashes are arbitrary but fixed, so assertions stay stable.
function makeReleaseDir(files: FileSpec[]): string {
const dir = mkdtempSync(join(tmpdir(), 'version-manifest-'))
const sumsLines: string[] = []
for (const { name, content, sha } of files) {
writeFileSync(join(dir, name), content)
if (sha !== null) sumsLines.push(`${sha} ${name}`)
}
writeFileSync(join(dir, 'SHA256SUMS.txt'), `${sumsLines.join('\n')}\n`)
return dir
}
// A canonical entry: 64-hex sha keyed off the platform so each file gets a distinct, checkable hash.
const HEX = (n: string): string => n.repeat(64)
function entry(key: keyof typeof INSTALLERS, extra = 0): FileSpec {
const content = 'x'.repeat(10 + extra)
return { name: INSTALLERS[key], content, sha: createHash('sha256').update(content).digest('hex') }
}
describe('parseSha256Sums', () => {
it('parses `<hex> <filename>` lines and lowercases the hash', () => {
const map = parseSha256Sums(
`${'A'.repeat(64)} file-one.dmg\n${'b'.repeat(64)} file two.exe\n`
)
expect(map['file-one.dmg']).toBe('a'.repeat(64))
expect(map['file two.exe']).toBe('b'.repeat(64))
})
it('tolerates the optional binary-mode `*` marker and ignores junk lines', () => {
const map = parseSha256Sums(`${'c'.repeat(64)} *app.AppImage\nnot a checksum line\n`)
expect(map['app.AppImage']).toBe('c'.repeat(64))
expect(Object.keys(map)).toHaveLength(1)
})
})
describe('extractHighlights', () => {
const body = [
'# Open-Science v0.2.0',
'',
'> one-line tagline',
'',
'Intro paragraph.',
'',
'## ✨ Highlights',
'',
'- Point one (#1)',
'- Point two',
'',
'## 🚀 New Features',
'',
'- Feature A',
'',
'## 🔧 Improvements',
'',
'- Improvement A',
'',
'## 🐛 Bug Fixes',
'',
'- Fix A',
'',
'## 📦 Install',
'',
'Download the build for your platform.',
'',
"## What's Changed",
'',
'- chore: bump deps'
].join('\n')
it('keeps only the allowlisted sections, in document order', () => {
const notes = extractHighlights(body)
expect(notes).toContain('## ✨ Highlights')
expect(notes).toContain('## 🚀 New Features')
expect(notes).toContain('## 🔧 Improvements')
expect(notes).toContain('## 🐛 Bug Fixes')
// Dropped sections and their content.
expect(notes).not.toContain('## 📦 Install')
expect(notes).not.toContain('Download the build')
expect(notes).not.toContain("What's Changed")
expect(notes).not.toContain('bump deps')
// Order preserved.
expect(notes.indexOf('Highlights')).toBeLessThan(notes.indexOf('Bug Fixes'))
})
it('returns an empty string for an empty or blank body', () => {
expect(extractHighlights('')).toBe('')
expect(extractHighlights(' \n \n')).toBe('')
expect(extractHighlights(undefined)).toBe('')
})
it('falls back to the preamble (minus the H1 title) when no allowlisted section exists', () => {
const plain = '# Title\n\n> tagline\n\nSome intro without standard sections.'
const notes = extractHighlights(plain)
expect(notes).not.toContain('# Title')
expect(notes).toContain('tagline')
expect(notes).toContain('Some intro without standard sections.')
})
})
describe('readReleaseNotes', () => {
let dir: string | undefined
afterEach(() => dir && rmSync(dir, { recursive: true, force: true }))
it('requires English and returns optional localized Markdown by canonical locale', () => {
dir = mkdtempSync(join(tmpdir(), 'release-notes-'))
writeFileSync(join(dir, 'en.md'), ' English notes\n')
writeFileSync(join(dir, 'zh-Hans.md'), ' 简体中文说明\n')
writeFileSync(join(dir, 'fr.md'), ' Notes françaises\n')
writeFileSync(join(dir, 'de.md'), ' Deutsche Versionshinweise\n')
writeFileSync(join(dir, 'es.md'), ' Notas en español\n')
expect(readReleaseNotes(dir)).toEqual({
notes: 'English notes',
localizedNotes: {
'zh-Hans': '简体中文说明',
fr: 'Notes françaises',
de: 'Deutsche Versionshinweise',
es: 'Notas en español'
}
})
})
it('rejects a missing English fallback', () => {
dir = mkdtempSync(join(tmpdir(), 'release-notes-'))
writeFileSync(join(dir, 'ja.md'), '日本語')
expect(() => readReleaseNotes(dir)).toThrow(/require en\.md/)
})
it('rejects unsupported files instead of silently omitting release content', () => {
dir = mkdtempSync(join(tmpdir(), 'release-notes-'))
writeFileSync(join(dir, 'en.md'), 'English')
writeFileSync(join(dir, 'pt.md'), 'Português')
expect(() => readReleaseNotes(dir)).toThrow(/unsupported release-note file: pt\.md/)
})
})
describe('buildManifest', () => {
let dir: string | undefined
afterEach(() => dir && rmSync(dir, { recursive: true, force: true }))
it.each(['x64', 'arm64'] as const)(
'validates %s feed bytes and rejects a swapped architecture',
(arch) => {
const files = [entry(`linux-${arch}-appimage`), entry(`linux-${arch}-deb`)]
dir = makeReleaseDir(files)
const feed = JSON.stringify({
version: VERSION,
files: files.map((file) => ({
url: file.name,
size: file.content.length,
sha512: createHash('sha512').update(file.content).digest('base64')
}))
})
const options = { dir, version: VERSION, cdnBase: CDN, prefix: PREFIX }
const name = arch === 'arm64' ? 'latest-linux-arm64.yml' : 'latest-linux.yml'
writeFileSync(join(dir, name), feed)
expect(Object.keys(buildManifest(options).downloads)).toHaveLength(2)
writeFileSync(
join(dir, arch === 'arm64' ? 'latest-linux.yml' : 'latest-linux-arm64.yml'),
feed
)
expect(() => buildManifest(options)).toThrow(/Wrong architecture/)
}
)
it('keeps historical backfill partial while requiring ARM64 for a new promotion', () => {
dir = makeReleaseDir(
Object.keys(INSTALLERS)
.filter((key) => !key.includes('linux-arm64'))
.map((key) => entry(key))
)
const options = { dir, version: VERSION, cdnBase: CDN, prefix: PREFIX }
expect(
Object.keys(buildManifest({ ...options, allowLegacyNames: true }).downloads)
).toHaveLength(5)
expect(() => buildManifest({ ...options, requireComplete: true })).toThrow(
/Missing stable release installer: linux-arm64/
)
})
it('maps every installer to its key with url, size and sha256', () => {
const files = Object.keys(INSTALLERS).map((key, i) => entry(key, i + 1))
dir = makeReleaseDir(files)
const manifest = buildManifest({
dir,
version: VERSION,
notes: 'Release notes here',
localizedNotes: { 'zh-Hans': '版本说明' },
releaseDate: '2026-07-12T00:00:00Z',
cdnBase: CDN,
prefix: PREFIX
})
// version / notes / releaseDate pass through untouched.
expect(manifest.version).toBe(VERSION)
expect(manifest.notes).toBe('Release notes here')
expect(manifest.localizedNotes).toEqual({ 'zh-Hans': '版本说明' })
expect(manifest.releaseDate).toBe('2026-07-12T00:00:00Z')
// All platform keys present.
expect(Object.keys(manifest.downloads).sort()).toEqual(Object.keys(INSTALLERS).sort())
// url construction: <cdn>/<prefix>/releases/<version>/<filename>.
expect(manifest.downloads['win-x64'].url).toBe(
`${CDN}/${PREFIX}/releases/${VERSION}/${INSTALLERS['win-x64']}`
)
// deb keeps the underscore/amd64 convention in its url.
expect(manifest.downloads['linux-x64-deb'].url).toBe(
`${CDN}/${PREFIX}/releases/${VERSION}/aipoch-open-science_${VERSION}_amd64.deb`
)
// sha256 comes from SHA256SUMS.txt (entry #2 -> content length 12, sha of '2').
const deb = files.find((f) => f.name === INSTALLERS['linux-x64-deb'])
expect(manifest.downloads['linux-x64-deb'].sha256).toBe(deb.sha)
expect(manifest.downloads['linux-x64-deb'].size).toBe(deb.content.length)
})
it('omits a platform whose installer is missing', () => {
dir = makeReleaseDir([entry('mac-arm64', 1), entry('win-x64', 2)])
const manifest = buildManifest({
dir,
version: VERSION,
notes: '',
releaseDate: '',
cdnBase: CDN,
prefix: PREFIX
})
expect(Object.keys(manifest.downloads).sort()).toEqual(['mac-arm64', 'win-x64'])
expect(manifest.downloads['mac-x64']).toBeUndefined()
expect(manifest.downloads['linux-x64-deb']).toBeUndefined()
})
it('rejects an installer missing from SHA256SUMS', () => {
dir = makeReleaseDir([entry('mac-arm64', 1), { ...entry('mac-x64', 2), sha: null }])
expect(() => buildManifest({ dir, version: VERSION, cdnBase: CDN, prefix: PREFIX })).toThrow(
/no sha256/
)
})
it('warns on an unrecognized file but stays silent for zips and checksums', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
dir = makeReleaseDir([
entry('mac-arm64', 1),
{
name: 'aipoch-open-science-0.1.2-mac-arm64.zip',
content: 'zip',
sha: createHash('sha256').update('zip').digest('hex')
},
{ name: 'mystery-artifact.bin', content: 'bin', sha: HEX('8') }
])
const manifest = buildManifest({
dir,
version: VERSION,
notes: '',
releaseDate: '',
cdnBase: CDN,
prefix: PREFIX
})
// zip is mirrored to S3 but is not a manifest key.
expect(manifest.downloads['mac-arm64']).toBeDefined()
expect(Object.keys(manifest.downloads)).toEqual(['mac-arm64'])
expect(warn).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('mystery-artifact.bin'))
warn.mockRestore()
})
})