test(install-e2e): admit pinned signed bundle transitions
This commit is contained in:
@@ -45,7 +45,8 @@ import { fileURLToPath } from 'node:url';
|
||||
* -IncludeDesktop), which also builds the desktop app -- on windows it
|
||||
* registers Start Menu / Desktop shortcuts too, on linux/macos it
|
||||
* builds into the checkout without registering an OS entry point;
|
||||
* packaged-app is declared but not used by any OS spec yet.
|
||||
* packaged-app installs a signed bundle. Its native in-app update pair
|
||||
* is declared separately, not crossed with source-checkout update methods.
|
||||
* @typedef {InstallMethod | 'hermes-update' | 'open-app-update' | 'hermes-desktop-app-update'} UpdateMethod
|
||||
* Every install method doubles as an update method (re-run it over the
|
||||
* existing install), plus the updater CLI and the two app-update
|
||||
@@ -91,6 +92,17 @@ export function legId(name) {
|
||||
return name.replace(/[^A-Za-z0-9._-]+/g, '-');
|
||||
}
|
||||
|
||||
/** One pinned package transition; source-release sampling does not apply.
|
||||
* @param {'windows' | 'macos'} os
|
||||
* @param {string} oldTag
|
||||
* @returns {{include: MatrixEntry[]}}
|
||||
*/
|
||||
export function bundledMatrix(os, oldTag) {
|
||||
const name = `${os}: packaged-app -> open-app-update (${oldTag} -> HEAD)`;
|
||||
return { include: [{ name, leg_id: legId(name), install_method: 'packaged-app',
|
||||
update_method: 'open-app-update', install_ref: oldTag, tag_has_desktop: true }] };
|
||||
}
|
||||
|
||||
/** @type {Record<Os, OsSpec>} */
|
||||
export const SPEC = {
|
||||
windows: {
|
||||
|
||||
86
tests-js/bundle-inputs.test.mjs
Normal file
86
tests-js/bundle-inputs.test.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, expect, test } from 'vitest'
|
||||
import { stageBundleInputs, validateBundleInputs } from '../tests/install/e2e-assets/bundle-inputs.mjs'
|
||||
import { bundledMatrix, renderMarkdownResults } from '../scripts/sandbox/generate-e2e-matrix.mjs'
|
||||
|
||||
const cleanup = []
|
||||
afterEach(async () => { while (cleanup.length) await cleanup.pop()() })
|
||||
function fixture(platform = 'windows') {
|
||||
const extension = platform === 'windows' ? 'msixbundle' : 'zip'
|
||||
return { schema: 1, platform, arch: 'x64', old: {
|
||||
tag: 'v1.2.0', version: platform === 'windows' ? '1.2.0.0' : '1.2.0',
|
||||
commit: 'a'.repeat(40), identity: 'test.bundle', teamId: 'ABCDEFGHIJ', publisher: 'CN=Test', applicationId: 'Test',
|
||||
artifact: { url: `https://example.com/old.${extension}`, sha256: 'a'.repeat(64) }
|
||||
}, new: {
|
||||
tag: 'v1.3.0', version: platform === 'windows' ? '1.3.0.0' : '1.3.0',
|
||||
commit: 'b'.repeat(40), identity: 'test.bundle', teamId: 'ABCDEFGHIJ', publisher: 'CN=Test', applicationId: 'Test',
|
||||
artifact: { url: `https://example.com/new.${extension}`, sha256: 'b'.repeat(64) }
|
||||
} }
|
||||
}
|
||||
|
||||
test('package transitions are ordered, identity-preserving, and report through the existing family', () => {
|
||||
for (const platform of ['windows', 'macos']) {
|
||||
const manifest = fixture(platform)
|
||||
expect(validateBundleInputs(manifest, platform, 'x64')).toBe(manifest)
|
||||
for (const change of [
|
||||
value => { value.new.version = value.old.version },
|
||||
value => { value.new.identity = 'other' },
|
||||
value => { value.new.commit = 'not-a-commit' },
|
||||
value => { value.new.artifact.sha256 = 'not-a-digest' },
|
||||
value => { value.new.artifact.url = 'http://example.com/package.zip' },
|
||||
value => { value.arch = 'arm64' }
|
||||
]) {
|
||||
const bad = structuredClone(manifest)
|
||||
change(bad)
|
||||
expect(() => validateBundleInputs(bad, platform, 'x64')).toThrow()
|
||||
}
|
||||
const { include } = bundledMatrix(platform, manifest.old.tag)
|
||||
expect(include).toHaveLength(1)
|
||||
expect(include[0].update_method).toBe('open-app-update')
|
||||
expect(renderMarkdownResults([{ name: `${include[0].name} / e2e`, conclusion: 'failure' }])).toContain('1 failed')
|
||||
}
|
||||
})
|
||||
|
||||
test('staging streams actual bytes, verifies hashes and removes rejected partial downloads', async () => {
|
||||
// These bytes test download integrity only, not native package acceptance.
|
||||
const bytes = { old: Buffer.from('old transport payload'), new: Buffer.from('new transport payload') }
|
||||
const manifest = fixture()
|
||||
const server = createServer((req, res) => {
|
||||
res.end(req.url === '/manifest.json' ? JSON.stringify(manifest) : bytes[req.url.slice(1).split('.')[0]])
|
||||
})
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
cleanup.push(() => new Promise(resolve => { server.closeAllConnections(); server.close(resolve) }))
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'bundle-input-test-'))
|
||||
cleanup.push(() => rm(directory, { recursive: true, force: true }))
|
||||
const base = `http://127.0.0.1:${server.address().port}`
|
||||
for (const slot of ['old', 'new']) {
|
||||
manifest[slot].artifact.url = `${base}/${slot}.msixbundle`
|
||||
manifest[slot].artifact.sha256 = createHash('sha256').update(bytes[slot]).digest('hex')
|
||||
}
|
||||
const out = path.join(directory, 'good')
|
||||
const filename = await stageBundleInputs({ manifestUrl: `${base}/manifest.json`, platform: 'windows', arch: 'x64', out })
|
||||
const result = JSON.parse(await readFile(filename, 'utf8'))
|
||||
for (const slot of ['old', 'new']) expect(await readFile(result[slot].artifact.path)).toEqual(bytes[slot])
|
||||
manifest.old.artifact.sha256 = 'c'.repeat(64)
|
||||
const bad = path.join(directory, 'bad')
|
||||
await expect(stageBundleInputs({ manifestUrl: `${base}/manifest.json`, platform: 'windows', arch: 'x64', out: bad })).rejects.toThrow('SHA-256')
|
||||
expect(await readdir(bad)).toEqual([])
|
||||
})
|
||||
|
||||
test('explicit bundled routes refuse absent package inputs instead of reporting a skipped pass', async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'bundle-plan-test-'))
|
||||
cleanup.push(() => rm(directory, { recursive: true, force: true }))
|
||||
const script = fileURLToPath(new URL('../tests/install/e2e-assets/bundle-plan.mjs', import.meta.url))
|
||||
const env = { ...process.env, BUNDLE_WINDOWS_MANIFEST: '', BUNDLE_MACOS_MANIFEST: '',
|
||||
GITHUB_OUTPUT: path.join(directory, 'output'), GITHUB_STEP_SUMMARY: path.join(directory, 'summary') }
|
||||
expect(() => execFileSync(process.execPath, [script], { env: { ...env, BUNDLE_ROUTE: 'windows-bundled' }, stdio: 'pipe' })).toThrow()
|
||||
execFileSync(process.execPath, [script], { env: { ...env, BUNDLE_ROUTE: 'all' }, stdio: 'pipe' })
|
||||
expect(await readFile(env.GITHUB_STEP_SUMMARY, 'utf8')).toContain('no signed package pair supplied')
|
||||
expect(await readFile(env.GITHUB_OUTPUT, 'utf8')).toContain('windows={"include":[]}')
|
||||
})
|
||||
27
tests/install/BUNDLED_UPDATES.md
Normal file
27
tests/install/BUNDLED_UPDATES.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Bundled application update acceptance
|
||||
|
||||
The `packaged-app -> open-app-update` arms belong to the existing Install & Update E2E workflow. They are not crossed with source-checkout update methods. Source-tag sampling remains unchanged.
|
||||
|
||||
## Input contract
|
||||
|
||||
Manual routes are `windows-bundled`, `macos-bundled`, and `bundled` (both). Supply `windows-bundle-manifest` and/or `macos-bundle-manifest` with an HTTPS URL. An explicitly selected bundle route without its manifest fails. An ordinary scheduled/source run without manifests reports no bundled coverage, not a passed update.
|
||||
|
||||
Each JSON manifest has `schema: 1`, `platform` (`windows` or `macos`), `arch` (`x64` or `arm64`), and `old`/`new` objects. Each object contains:
|
||||
|
||||
- `tag`: exact release tag.
|
||||
- `version`: actual package version (Windows numeric quad; macOS app semver).
|
||||
- `commit`: full commit SHA from the artifact's install stamp.
|
||||
- `identity`: exact MSIX Identity Name or macOS CFBundleIdentifier.
|
||||
- `artifact`: HTTPS `url` and lowercase `sha256` of the actual release package.
|
||||
- Windows additionally requires `publisher` and `applicationId`.
|
||||
- macOS additionally requires `teamId`, the signing TeamIdentifier.
|
||||
|
||||
Windows artifacts are signed universal `.msixbundle` files. macOS artifacts are signed application `.zip` files. Do not use bootstrap Setup.exe/DMG artifacts, development builds, repackaged placeholders, or unsigned stand-ins. Both artifacts must already exist. The candidate commit must equal the workflow checkout SHA. Identities and signing ownership must match; versions and commits must change. The macOS transition stays on one update channel.
|
||||
|
||||
`bundle-inputs.mjs` validates the manifest and streams both downloads with SHA-256 verification. Local paths are added only after verification; mismatched partial downloads are removed. The test's temporary feed serves these real bytes through the normal production update protocol. No public channel is modified.
|
||||
|
||||
## Proof boundaries
|
||||
|
||||
The native drivers run only on disposable GitHub Actions hosts. They verify old installation identity/provenance, click the actual in-app Update control, and observe native package replacement plus automatic relaunch. The driver must not launch the new app or manually start the relaunch waiter as the pass signal. New process identity, payload provenance, version/commit, backend health, and preserved user/plugin state must all agree. Recordings and logs use the existing per-leg artifact/report conventions.
|
||||
|
||||
Unit tests of feed/manifest helpers use transport fixtures only. Passing them does not prove native signing, deployment, update, or relaunch. A green merge/typecheck does not establish those properties either. Each release acceptance claim needs a real native run and its parsed receipts.
|
||||
@@ -45,6 +45,10 @@ A leg can install a release from months back. The driver must not assume that th
|
||||
|
||||
## The install methods
|
||||
|
||||
- `packaged-app`: a signed Windows MSIX bundle or macOS application ZIP.
|
||||
This pairs only with `open-app-update`, using separately pinned package
|
||||
inputs rather than the source-tag cross product. See [bundled update
|
||||
acceptance](BUNDLED_UPDATES.md) for the manifest and proof contracts.
|
||||
- `installer-script`: the platform's one-liner (`curl | bash` on linux and macos, `irm | iex` on windows).
|
||||
- `installer-script+desktop`: the same one-liner with its desktop stage opted in (`--include-desktop` / `-IncludeDesktop`). The stage builds the desktop app during the install. On windows it also registers Start Menu and Desktop shortcuts. On linux and macos it builds the app inside the checkout and registers no OS entry point.
|
||||
- `desktop-installer@latest`: the published GUI installer (`Hermes-Setup.exe` on windows, `Hermes-Setup.dmg` on macos), driven through the real user flow.
|
||||
|
||||
128
tests/install/e2e-assets/bundle-inputs.mjs
Normal file
128
tests/install/e2e-assets/bundle-inputs.mjs
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
// Pin real release packages before a destructive update leg starts.
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { Transform, Readable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import semver from 'semver'
|
||||
|
||||
const TAG = /^v\d+\.\d+\.\d+(?:-canary\.\d{14})?$/
|
||||
const COMMIT = /^[0-9a-f]{40}$/
|
||||
const SHA256 = /^[0-9a-f]{64}$/
|
||||
const FORMATS = { windows: '.msixbundle', macos: '.zip' }
|
||||
|
||||
function artifactUrl(value) {
|
||||
const url = new URL(value)
|
||||
const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
|
||||
if (url.username || url.password || !(url.protocol === 'https:' || (url.protocol === 'http:' && local))) {
|
||||
throw new Error('Bundle URLs must use HTTPS or loopback HTTP, without credentials')
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function windowsVersion(version) {
|
||||
if (typeof version !== 'string' || !/^\d+\.\d+\.\d+\.\d+$/.test(version)) {
|
||||
throw new Error('Windows package version must have four numeric components')
|
||||
}
|
||||
const parts = version.split('.').map(Number)
|
||||
if (parts.some(n => n > 65535)) throw new Error('Windows package version exceeds 16 bits')
|
||||
return parts
|
||||
}
|
||||
|
||||
export function validateBundleInputs(value, platform, arch) {
|
||||
if (!Object.hasOwn(FORMATS, platform) || !['x64', 'arm64'].includes(arch)) {
|
||||
throw new Error('Unsupported bundled-update platform or architecture')
|
||||
}
|
||||
if (value?.schema !== 1 || value.platform !== platform || value.arch !== arch) {
|
||||
throw new Error('Bundle manifest schema, platform or architecture mismatch')
|
||||
}
|
||||
for (const slot of ['old', 'new']) {
|
||||
const item = value[slot]
|
||||
if (!item || !TAG.test(item.tag) || !COMMIT.test(item.commit) || !item.identity) {
|
||||
throw new Error(`${slot}: exact release tag, full commit and package identity are required`)
|
||||
}
|
||||
if (!item.artifact || !SHA256.test(item.artifact.sha256)) throw new Error(`${slot}: SHA-256 is required`)
|
||||
const url = artifactUrl(item.artifact.url)
|
||||
if (!url.pathname.endsWith(FORMATS[platform])) throw new Error(`${slot}: expected ${FORMATS[platform]} artifact`)
|
||||
if (platform === 'windows') {
|
||||
windowsVersion(item.version)
|
||||
if (!item.publisher || !item.applicationId) throw new Error(`${slot}: publisher and applicationId are required`)
|
||||
} else {
|
||||
if (!semver.valid(item.version) || item.version !== item.tag.slice(1)) throw new Error(`${slot}: macOS version must match its release tag`)
|
||||
if (!/^[A-Z0-9]{10}$/.test(item.teamId)) throw new Error(`${slot}: macOS signing teamId is required`)
|
||||
}
|
||||
}
|
||||
if (value.old.identity !== value.new.identity || value.old.commit === value.new.commit) {
|
||||
throw new Error('Update must preserve package identity and change the build commit')
|
||||
}
|
||||
if (value.old.artifact.sha256 === value.new.artifact.sha256) throw new Error('Update artifacts must differ')
|
||||
if (platform === 'windows') {
|
||||
if (value.old.publisher !== value.new.publisher || value.old.applicationId !== value.new.applicationId) {
|
||||
throw new Error('Update must preserve publisher and applicationId')
|
||||
}
|
||||
const old = windowsVersion(value.old.version)
|
||||
const newer = windowsVersion(value.new.version)
|
||||
const first = newer.findIndex((n, i) => n !== old[i])
|
||||
if (first < 0 || newer[first] <= old[first]) throw new Error('New package version must increase')
|
||||
} else {
|
||||
if (value.new.teamId !== value.old.teamId) throw new Error('Update must preserve signing team')
|
||||
if (!semver.gt(value.new.version, value.old.version)) throw new Error('New package version must increase')
|
||||
if (value.old.tag.includes('-canary.') !== value.new.tag.includes('-canary.')) throw new Error('Bundle transition must stay on one update channel')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export async function downloadArtifact(artifact, destination) {
|
||||
const url = artifactUrl(artifact.url)
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(30 * 60 * 1000) })
|
||||
if (!response.ok || !response.body) throw new Error(`Package download returned HTTP ${response.status}`)
|
||||
const digest = createHash('sha256')
|
||||
const temporary = `${destination}.partial`
|
||||
try {
|
||||
await pipeline(Readable.fromWeb(response.body), new Transform({
|
||||
transform(chunk, encoding, done) { digest.update(chunk); done(null, chunk) }
|
||||
}), createWriteStream(temporary, { flags: 'wx' }))
|
||||
if (digest.digest('hex') !== artifact.sha256) throw new Error('Package SHA-256 mismatch')
|
||||
await rename(temporary, destination)
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function stageBundleInputs({ manifestUrl, platform, arch, out, expectedCommit }) {
|
||||
const response = await fetch(artifactUrl(manifestUrl), { signal: AbortSignal.timeout(60_000) })
|
||||
if (!response.ok) throw new Error(`Manifest download returned HTTP ${response.status}`)
|
||||
const text = await response.text()
|
||||
if (text.length > 1024 * 1024) throw new Error('Bundle input manifest is too large')
|
||||
const manifest = validateBundleInputs(JSON.parse(text), platform, arch)
|
||||
if (expectedCommit && manifest.new.commit !== expectedCommit) {
|
||||
throw new Error('Candidate commit must equal the tested workflow SHA')
|
||||
}
|
||||
await mkdir(out, { recursive: true })
|
||||
const staged = { ...manifest }
|
||||
for (const slot of ['old', 'new']) {
|
||||
const destination = path.resolve(out, `${slot}${FORMATS[platform]}`)
|
||||
await downloadArtifact(manifest[slot].artifact, destination)
|
||||
staged[slot] = { ...manifest[slot], artifact: { ...manifest[slot].artifact, path: destination } }
|
||||
}
|
||||
const filename = path.resolve(out, 'bundle-inputs.json')
|
||||
await writeFile(filename, JSON.stringify(staged, null, 2) + '\n', { encoding: 'utf8', flag: 'wx' })
|
||||
return filename
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
const { values } = parseArgs({ options: {
|
||||
'manifest-url': { type: 'string' }, platform: { type: 'string' },
|
||||
arch: { type: 'string' }, out: { type: 'string' }
|
||||
} })
|
||||
if (!values['manifest-url'] || !values.platform || !values.arch || !values.out) {
|
||||
throw new Error('--manifest-url, --platform, --arch and --out are required')
|
||||
}
|
||||
console.log(await stageBundleInputs({ manifestUrl: values['manifest-url'], platform: values.platform, arch: values.arch, out: values.out,
|
||||
expectedCommit: process.env.GITHUB_ACTIONS === 'true' ? process.env.GITHUB_SHA : undefined }))
|
||||
}
|
||||
29
tests/install/e2e-assets/bundle-plan.mjs
Normal file
29
tests/install/e2e-assets/bundle-plan.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
import { appendFile } from 'node:fs/promises'
|
||||
import { validateBundleInputs } from './bundle-inputs.mjs'
|
||||
import { bundledMatrix } from '../../../scripts/sandbox/generate-e2e-matrix.mjs'
|
||||
|
||||
const route = process.env.BUNDLE_ROUTE || 'all'
|
||||
for (const platform of ['windows', 'macos']) {
|
||||
const url = process.env[`BUNDLE_${platform.toUpperCase()}_MANIFEST`] || ''
|
||||
const selected = ['all', 'bundled', `${platform}-bundled`].includes(route)
|
||||
if (!url || !selected) {
|
||||
if (selected && ['bundled', `${platform}-bundled`].includes(route)) {
|
||||
throw new Error(`${platform}: a pinned bundle manifest is required for this route`)
|
||||
}
|
||||
await appendFile(process.env.GITHUB_OUTPUT, `${platform}={"include":[]}\n${platform}-arch=\n`)
|
||||
await appendFile(process.env.GITHUB_STEP_SUMMARY, `\n${platform} bundled update: not run (${!url ? 'no signed package pair supplied' : 'route not selected'}).\n`)
|
||||
continue
|
||||
}
|
||||
const location = new URL(url)
|
||||
if (location.protocol !== 'https:' || location.username || location.password) throw new Error('CI manifests require HTTPS without credentials')
|
||||
const response = await fetch(location, { signal: AbortSignal.timeout(60_000) })
|
||||
if (!response.ok) throw new Error(`Bundle manifest HTTP ${response.status}`)
|
||||
const text = await response.text()
|
||||
if (text.length > 1024 * 1024) throw new Error('Bundle manifest exceeds size limit')
|
||||
const data = JSON.parse(text)
|
||||
const manifest = validateBundleInputs(data, platform, data.arch)
|
||||
if (manifest.new.commit !== process.env.GITHUB_SHA) throw new Error(`${platform}: candidate commit must equal the tested workflow SHA`)
|
||||
await appendFile(process.env.GITHUB_OUTPUT, `${platform}=${JSON.stringify(bundledMatrix(platform, manifest.old.tag))}\n${platform}-arch=${manifest.arch}\n`)
|
||||
await appendFile(process.env.GITHUB_STEP_SUMMARY, `\n${platform}/${manifest.arch} packaged-app → open-app-update: ${manifest.old.tag} → ${manifest.new.tag} (${manifest.new.commit}).\n`)
|
||||
}
|
||||
Reference in New Issue
Block a user