mirror of
https://github.com/aipoch/open-science.git
synced 2026-10-02 02:14:40 +08:00
* 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
310 lines
11 KiB
TypeScript
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()
|
|
})
|
|
})
|