add builder and updater
This commit is contained in:
4
scripts/invalidate-release.mjs
Normal file
4
scripts/invalidate-release.mjs
Normal 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
26
scripts/package.mjs
Normal 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
15
scripts/publish.mjs
Normal 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
20
scripts/release.ps1
Normal 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
|
||||
}
|
||||
60
scripts/releaseArtifacts.mjs
Normal file
60
scripts/releaseArtifacts.mjs
Normal 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
61
scripts/smoke.cjs
Normal 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
34
scripts/uploadRelease.mjs
Normal 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}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user