add builder and updater

This commit is contained in:
Joey Kimsey
2026-09-12 21:21:28 -04:00
parent 6e42b1012d
commit 1ee2774241
24 changed files with 4115 additions and 10 deletions

View File

@ -0,0 +1,4 @@
import { rm } from 'node:fs/promises'
// A failed test/build must not leave a previous release marked as ready to publish.
await rm('dist/release/release-ready.json', { force: true })

26
scripts/package.mjs Normal file
View File

@ -0,0 +1,26 @@
import { build, Platform, Arch } from 'electron-builder'
import { rm } from 'node:fs/promises'
import { createBuildConfig } from '../build/config.mjs'
import { writeReleaseManifest } from './releaseArtifacts.mjs'
const args = process.argv.slice(2)
if (args.some((arg) => !['--release', '--dir'].includes(arg))) {
throw new Error('Usage: node scripts/package.mjs [--release] [--dir]')
}
const release = args.includes('--release')
if (release && args.includes('--dir')) throw new Error('Release builds must produce an installer.')
// Never let a failed rebuild leave an older directory looking publishable.
if (release) {
await rm('dist/release/release-ready.json', { force: true })
} else {
process.env.CSC_IDENTITY_AUTO_DISCOVERY = 'false'
}
const config = createBuildConfig({ release })
await build({
config,
targets: Platform.WINDOWS.createTarget(args.includes('--dir') ? ['dir'] : ['nsis'], Arch.x64),
publish: 'never'
})
if (release) {
await writeReleaseManifest(config.directories.output, config.publish[0].url)
}

15
scripts/publish.mjs Normal file
View File

@ -0,0 +1,15 @@
import { verifyRelease } from './releaseArtifacts.mjs'
import { uploadRelease } from './uploadRelease.mjs'
const args = process.argv.slice(2)
if (args.some((arg) => arg !== '--upload')) throw new Error('Usage: npm run publish:release -- [--upload]')
const release = await verifyRelease('dist/release')
console.log(`Capsule ${release.version}: ${release.feedUrl}`)
if (!args.includes('--upload')) {
console.log('Verified. Upload order:')
for (const file of release.files) console.log(` ${file.name} (${file.data.length} bytes)`)
console.log('No files uploaded. Add --upload with CAPSULE_UPLOAD_URL and CAPSULE_UPLOAD_TOKEN to publish.')
} else {
// This adapter targets an authenticated HTTPS PUT/WebDAV endpoint, not any TTP site API.
await uploadRelease(release, { uploadUrl: process.env.CAPSULE_UPLOAD_URL, token: process.env.CAPSULE_UPLOAD_TOKEN })
}

20
scripts/release.ps1 Normal file
View File

@ -0,0 +1,20 @@
param([switch]$Publish)
$ErrorActionPreference = 'Stop'
Push-Location (Split-Path $PSScriptRoot -Parent)
try {
# Invalidate any older result even if npm ci or tests fail.
Remove-Item -LiteralPath 'dist/release/release-ready.json' -Force -ErrorAction SilentlyContinue
npm ci
if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed.' }
npm run release:win
if ($LASTEXITCODE -ne 0) { throw 'Signed release build failed.' }
if ($Publish) {
npm run publish:release -- --upload
} else {
npm run publish:release
}
if ($LASTEXITCODE -ne 0) { throw 'Release verification or publication failed.' }
} finally {
Pop-Location
}

View File

@ -0,0 +1,60 @@
import { readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { createHash } from 'node:crypto'
import { parse } from 'yaml'
import { validateFeedUrl } from '../build/config.mjs'
/** Hash an artifact for local tamper/corruption checks and upload verification. */
export function digest(data, algorithm = 'sha256', encoding = 'hex') {
return createHash(algorithm).update(data).digest(encoding)
}
/** Read only the x64 stable installer referenced by electron-builder metadata. */
export async function readReleaseArtifacts(directory) {
const metadata = await readFile(join(directory, 'latest.yml'))
const info = parse(metadata.toString('utf8'))
if (!/^\d+\.\d+\.\d+$/.test(info?.version) || info.files?.length !== 1) {
throw new Error('Expected one Windows x64 installer for a stable semantic version.')
}
const installer = `Capsule-${info.version}-x64-Setup.exe`
if (info.files[0].url !== installer || (info.path && info.path !== installer)) {
throw new Error('Release metadata must reference the expected local installer filename.')
}
const binary = await readFile(join(directory, installer))
if (info.files[0].sha512 !== digest(binary, 'sha512', 'base64') || info.files[0].size !== binary.length) {
throw new Error('Installer does not match latest.yml checksum or size.')
}
const blockmap = await readFile(join(directory, `${installer}.blockmap`))
return {
version: info.version,
files: [
{ name: installer, data: binary },
{ name: `${installer}.blockmap`, data: blockmap },
// Publish the pointer only after both immutable artifacts are available.
{ name: 'latest.yml', data: metadata }
]
}
}
/** Mark a successful signed build with hashes of its exact publishable files. */
export async function writeReleaseManifest(directory, feedUrl) {
const release = await readReleaseArtifacts(directory)
const manifest = {
version: release.version,
feedUrl: validateFeedUrl(feedUrl),
files: release.files.map(({ name, data }) => ({ name, sha256: digest(data) }))
}
await writeFile(join(directory, 'release-ready.json'), JSON.stringify(manifest, null, 2))
}
/** Refuse local builds, missing files, stale metadata, and changed release assets. */
export async function verifyRelease(directory) {
const manifest = JSON.parse(await readFile(join(directory, 'release-ready.json'), 'utf8'))
validateFeedUrl(manifest.feedUrl)
const release = await readReleaseArtifacts(directory)
if (release.version !== manifest.version || manifest.files?.length !== release.files.length ||
release.files.some(({ name, data }, index) => manifest.files[index].name !== name || manifest.files[index].sha256 !== digest(data))) {
throw new Error('Release artifacts have changed since the signed build. Rebuild before publishing.')
}
return { ...release, feedUrl: manifest.feedUrl }
}

61
scripts/smoke.cjs Normal file
View File

@ -0,0 +1,61 @@
// Launch the compiled app with isolated data and capture its update panel without signing in.
const { app } = require('electron')
const { mkdirSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const assert = require('node:assert/strict')
const profile = join(__dirname, '../dist/smoke-profile')
mkdirSync(profile, { recursive: true })
mkdirSync(join(__dirname, '../dist/local'), { recursive: true })
app.setName('capsule-local')
app.setVersion(require('../package.json').version)
app.setPath('appData', profile)
const timeout = setTimeout(() => {
console.error('Smoke test timed out.')
app.exit(1)
}, 30000)
app.on('browser-window-created', (_event, window) => {
window.show = () => {} // Exercise the real app without interrupting the user's desktop.
window.webContents.once('did-finish-load', async () => {
try {
const state = await window.webContents.executeJavaScript(`(async () => {
const state = await window.capsule.updateStatus()
await window.capsule.checkForUpdates()
await new Promise(resolve => setTimeout(resolve, 250))
return {
state,
panelVisible: !document.getElementById('capsule-updates').hidden,
checkHidden: document.getElementById('update-check').hidden,
restartHidden: document.getElementById('update-install').hidden,
label: document.getElementById('update-status').textContent
}
})()`)
assert.equal(state.state.status, 'disabled')
assert.equal(state.state.currentVersion, require('../package.json').version)
assert.equal(state.panelVisible, true)
assert.equal(state.checkHidden, true)
assert.equal(state.restartHidden, true)
assert.match(state.label, /automatic updates are disabled/)
assert.equal(app.getPath('userData'), join(profile, 'capsule-local'))
const image = await window.webContents.capturePage()
writeFileSync(join(__dirname, '../dist/local/smoke.png'), image.toPNG())
// Exercise the ready prompt with synthetic state; never invoke installation.
window.webContents.send('capsule:updates:changed', {
status: 'ready', currentVersion: app.getVersion(), version: '0.1.1', percent: 100
})
const readyVisible = await window.webContents.executeJavaScript(`new Promise(resolve => setTimeout(() => {
const button = document.getElementById('update-install')
resolve(!button.hidden && document.getElementById('update-status').textContent.includes('Save your work'))
}, 100))`)
assert.equal(readyVisible, true)
writeFileSync(join(__dirname, '../dist/local/smoke-ready.png'), (await window.webContents.capturePage()).toPNG())
console.log(JSON.stringify({ smoke: 'passed', ...state }))
clearTimeout(timeout)
app.exit(0)
} catch (error) {
console.error(error)
app.exit(1)
}
})
})
require('../out/main/index.js')

34
scripts/uploadRelease.mjs Normal file
View File

@ -0,0 +1,34 @@
import { digest } from './releaseArtifacts.mjs'
import { validateFeedUrl } from '../build/config.mjs'
/** Publish immutable assets first and the feed pointer last, verifying public bytes. */
export async function uploadRelease(release, { uploadUrl, token, fetchImpl = fetch, log = console.log }) {
uploadUrl = validateFeedUrl(uploadUrl)
if (!token || /[\r\n]/.test(token)) throw new Error('Set CAPSULE_UPLOAD_TOKEN in the release environment.')
for (const file of release.files) {
const metadata = file.name === 'latest.yml'
const response = await fetchImpl(new URL(file.name, uploadUrl), {
method: 'PUT',
redirect: 'error',
signal: AbortSignal.timeout(10 * 60 * 1000),
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': metadata ? 'application/yaml' : 'application/octet-stream',
'Cache-Control': metadata ? 'no-cache' : 'public, max-age=31536000, immutable',
...(!metadata ? { 'If-None-Match': '*' } : {})
},
body: file.data
})
// Existing immutable files are safe to reuse only after verifying their public bytes.
if (!response.ok && !(response.status === 412 && !metadata)) {
throw new Error(`Upload failed for ${file.name}: HTTP ${response.status}. Publication stopped.`)
}
const publicResponse = await fetchImpl(new URL(file.name, release.feedUrl), {
redirect: 'error', signal: AbortSignal.timeout(10 * 60 * 1000), cache: 'no-store'
})
if (!publicResponse.ok || digest(Buffer.from(await publicResponse.arrayBuffer())) !== digest(file.data)) {
throw new Error(`Public verification failed for ${file.name}. Publication stopped.`)
}
log(`Published and verified ${file.name}`)
}
}