Enable isolated Capsule Local automatic update testing

This commit is contained in:
2026-09-12 22:18:16 -04:00
parent edefc74c1a
commit a767ee4514
12 changed files with 85 additions and 17 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Opt-in Capsule Local automatic-update testing, with a 0.1.0 bootstrap, isolated unsigned test feed, and 0.1.1 update target. Public signing requirements remain enforced.
- Capsule Local 0.1.1 displays “you have updated to 0.1.1” on the dashboard for manual installer upgrade testing. - Capsule Local 0.1.1 displays “you have updated to 0.1.1” on the dashboard for manual installer upgrade testing.
- TTP Capsule plugin publishing integration with bounded chunk uploads, JSON-compatible YAML metadata, public artifact verification, and single-range update downloads. - TTP Capsule plugin publishing integration with bounded chunk uploads, JSON-compatible YAML metadata, public artifact verification, and single-range update downloads.

View File

@ -17,6 +17,8 @@ npm run dev
## Package and update ## Package and update
For automatic-update testing without a signing certificate, use the separate `dist:test-bootstrap` and `dist:test` commands described in [Free automatic-update testing](docs/releases.md#free-automatic-update-testing). Original Local installations require one bootstrap installation; subsequent test updates use the in-app updater.
`npm run dist:win` builds a per-user Windows x64 installer at `dist/local/Capsule-Local-0.1.1-x64-Setup.exe` (the filename follows the package version). This unsigned **Capsule Local** build has its own installation and session directory; automatic updates are disabled. To update a 0.1.0 test installation, close Capsule Local and run the 0.1.1 installer under the same Windows account. Reopen it and confirm the dashboard says “you have updated to 0.1.1”; the installer preserves app data. This manual test does not publish to the signed release feed. `npm run pack:win` produces an unpacked app for inspection. `npm test` checks updater behavior and release validation. `npm run dist:win` builds a per-user Windows x64 installer at `dist/local/Capsule-Local-0.1.1-x64-Setup.exe` (the filename follows the package version). This unsigned **Capsule Local** build has its own installation and session directory; automatic updates are disabled. To update a 0.1.0 test installation, close Capsule Local and run the 0.1.1 installer under the same Windows account. Reopen it and confirm the dashboard says “you have updated to 0.1.1”; the installer preserves app data. This manual test does not publish to the signed release feed. `npm run pack:win` produces an unpacked app for inspection. `npm test` checks updater behavior and release validation.
Public builds use `npm run release:win`, require a permanent HTTPS update URL and Windows signing identity, and go to `dist/release/`. They download updates in the background and offer **Restart to update**, including before sign-in. Closing the app normally does not install an update. A closed app checks after its next launch. Public builds use `npm run release:win`, require a permanent HTTPS update URL and Windows signing identity, and go to `dist/release/`. They download updates in the background and offer **Restart to update**, including before sign-in. Closing the app normally does not install an update. A closed app checks after its next launch.

View File

@ -18,8 +18,13 @@ export function validateFeedUrl(value) {
} }
/** Return a Windows build config; public releases fail closed without signing. */ /** Return a Windows build config; public releases fail closed without signing. */
export function createBuildConfig({ release = false, env = process.env } = {}) { export function createBuildConfig({ release = false, testUpdates = false, bootstrap = false, env = process.env } = {}) {
const url = release ? validateFeedUrl(env.CAPSULE_UPDATE_URL) : null if (release && testUpdates) throw new Error('Test updates cannot be combined with a public release.')
if (bootstrap && !testUpdates) throw new Error('Bootstrap requires test updates.')
const url = testUpdates ? validateFeedUrl(env.CAPSULE_TEST_UPDATE_URL) : release ? validateFeedUrl(env.CAPSULE_UPDATE_URL) : null
if (testUpdates && !new URL(url).pathname.endsWith('/capsule/testfeed/')) {
throw new Error('Test updates require a separate /capsule/testfeed/ URL.')
}
const publisher = env.CAPSULE_PUBLISHER_NAME?.trim() const publisher = env.CAPSULE_PUBLISHER_NAME?.trim()
if (release && !publisher) { if (release && !publisher) {
throw new Error('CAPSULE_PUBLISHER_NAME must match the signing certificate subject CN.') throw new Error('CAPSULE_PUBLISHER_NAME must match the signing certificate subject CN.')
@ -31,7 +36,7 @@ export function createBuildConfig({ release = false, env = process.env } = {}) {
appId: release ? APP_ID : `${APP_ID}.local`, appId: release ? APP_ID : `${APP_ID}.local`,
productName: release ? 'Capsule' : 'Capsule Local', productName: release ? 'Capsule' : 'Capsule Local',
executableName: release ? 'Capsule' : 'Capsule Local', executableName: release ? 'Capsule' : 'Capsule Local',
directories: { output: release ? 'dist/release' : 'dist/local' }, directories: { output: release ? 'dist/release' : testUpdates ? bootstrap ? 'dist/test-bootstrap' : 'dist/test' : 'dist/local' },
files: ['out/**/*', 'package.json'], files: ['out/**/*', 'package.json'],
asar: true, asar: true,
npmRebuild: false, npmRebuild: false,
@ -39,12 +44,13 @@ export function createBuildConfig({ release = false, env = process.env } = {}) {
extraMetadata: { extraMetadata: {
// Keep the release userData path compatible with the original dev app. // Keep the release userData path compatible with the original dev app.
name: release ? 'capsule' : 'capsule-local', name: release ? 'capsule' : 'capsule-local',
capsuleUpdates: { enabled: release } ...(bootstrap ? { version: '0.1.0' } : {}),
capsuleUpdates: { enabled: release || testUpdates, testing: testUpdates }
}, },
artifactName: release ? 'Capsule-${version}-${arch}-Setup.${ext}' : 'Capsule-Local-${version}-${arch}-Setup.${ext}', artifactName: release || testUpdates ? 'Capsule-${version}-${arch}-Setup.${ext}' : 'Capsule-Local-${version}-${arch}-Setup.${ext}',
win: { win: {
target: [{ target: 'nsis', arch: ['x64'] }], target: [{ target: 'nsis', arch: ['x64'] }],
verifyUpdateCodeSignature: true, verifyUpdateCodeSignature: !testUpdates,
signExecutable: release, signExecutable: release,
...(release ? { signtoolOptions: { ...(release ? { signtoolOptions: {
publisherName: publisher, publisherName: publisher,
@ -60,6 +66,6 @@ export function createBuildConfig({ release = false, env = process.env } = {}) {
runAfterFinish: false, runAfterFinish: false,
shortcutName: release ? 'Capsule' : 'Capsule Local' shortcutName: release ? 'Capsule' : 'Capsule Local'
}, },
publish: release ? [{ provider: 'generic', url, channel: 'latest', useMultipleRangeRequest: false }] : null publish: release || testUpdates ? [{ provider: 'generic', url, channel: 'latest', useMultipleRangeRequest: false }] : null
} }
} }

View File

@ -10,6 +10,28 @@ Local builds are unsigned, named **Capsule Local**, and have application ID `com
Public builds use the permanent ID `com.thetempusproject.capsule`, executable `Capsule.exe`, and `%APPDATA%/capsule/session.json`, preserving the original app's session location. Both installers run per user and preserve app data on uninstall. Do not change the public ID, package name, install scope, or data path after shipping without an explicit migration. The current installer uses Electron's default icon; add an approved `build/icon.ico` before public branding is finalized. Public builds use the permanent ID `com.thetempusproject.capsule`, executable `Capsule.exe`, and `%APPDATA%/capsule/session.json`, preserving the original app's session location. Both installers run per user and preserve app data on uninstall. Do not change the public ID, package name, install scope, or data path after shipping without an explicit migration. The current installer uses Electron's default icon; add an approved `build/icon.ico` before public branding is finalized.
## Free automatic-update testing
The original Capsule Local 0.1.0 installer cannot check for updates. Install the update-enabled bootstrap once to start a real 0.1.0 to 0.1.1 test:
```powershell
$env:CAPSULE_TEST_UPDATE_URL = 'https://ttp.joeykimsey.com/capsule/testfeed/'
npm run dist:test-bootstrap
npm run dist:test
```
`dist/test-bootstrap/Capsule-0.1.0-x64-Setup.exe` installs as Capsule Local and preserves its existing session location. Close the existing app before installing it. `dist/test/Capsule-0.1.1-x64-Setup.exe` is the update target; do not manually install that target when testing the updater. Both builds use the same Local identity. The dashboard message is hidden in the bootstrap and appears after updating to 0.1.1.
Test builds explicitly omit Authenticode verification; they rely on HTTPS, the publisher-controlled test feed, and metadata checksums. This is for trusted testers, not public signed releases. Windows can warn or block unsigned executables. The app labels this mode “Unsigned test channel.” Public release builds still require signing and verify publisher signatures. Normal `dist:win` builds still have updates disabled.
The TTP plugin must include `/capsule/testfeed/` and `/capsule/testupload/` support before publication. Those endpoints use separate storage from the public release channel, with the existing dedicated publisher token. After the operator deploys that code, set `CAPSULE_UPLOAD_URL` to `https://ttp.joeykimsey.com/capsule/testupload/` and load `CAPSULE_UPLOAD_TOKEN` securely, then run:
```powershell
npm run publish:release -- --test-updates --upload
```
The command publishes only `dist/test`, with metadata last. It never publishes the bootstrap or changes the public feed. Open the installed bootstrap, choose **Check for updates**, wait for **Restart to update**, then verify version 0.1.1, the dashboard message, and the preserved connection. Signed production upgrade acceptance remains a separate test.
## 2. Configure the release feed ## 2. Configure the release feed
Set `CAPSULE_UPDATE_URL` in the build environment to a permanent public HTTPS directory ending in `/`. The TTP Capsule plugin is installed on the LAN demo: use `https://ttp.joeykimsey.com/capsule/feed/` for updates and `https://ttp.joeykimsey.com/capsule/upload/` for uploads. These are environment settings, not hardcoded defaults. See the TTP checkout's `docs/capsule-hosting.md` for provisioning and token handoff. The URL must not contain login credentials, a query, or a fragment. The generated `app-update.yml` is baked into the signed application; site selection, login, and IPC cannot change it. Set `CAPSULE_UPDATE_URL` in the build environment to a permanent public HTTPS directory ending in `/`. The TTP Capsule plugin is installed on the LAN demo: use `https://ttp.joeykimsey.com/capsule/feed/` for updates and `https://ttp.joeykimsey.com/capsule/upload/` for uploads. These are environment settings, not hardcoded defaults. See the TTP checkout's `docs/capsule-hosting.md` for provisioning and token handoff. The URL must not contain login credentials, a query, or a fragment. The generated `app-update.yml` is baked into the signed application; site selection, login, and IPC cannot change it.

View File

@ -14,6 +14,8 @@
"smoke": "electron scripts/smoke.cjs", "smoke": "electron scripts/smoke.cjs",
"pack:win": "npm run build && node scripts/package.mjs --dir", "pack:win": "npm run build && node scripts/package.mjs --dir",
"dist:win": "npm run build && node scripts/package.mjs", "dist:win": "npm run build && node scripts/package.mjs",
"dist:test": "npm run build && node scripts/package.mjs --test-updates",
"dist:test-bootstrap": "npm run build && node scripts/package.mjs --test-updates --bootstrap",
"prerelease:win": "node scripts/invalidate-release.mjs", "prerelease:win": "node scripts/invalidate-release.mjs",
"release:win": "npm test && npm run build && node scripts/package.mjs --release", "release:win": "npm test && npm run build && node scripts/package.mjs --release",
"publish:release": "node scripts/publish.mjs" "publish:release": "node scripts/publish.mjs"

View File

@ -4,10 +4,13 @@ import { createBuildConfig } from '../build/config.mjs'
import { writeReleaseManifest } from './releaseArtifacts.mjs' import { writeReleaseManifest } from './releaseArtifacts.mjs'
const args = process.argv.slice(2) const args = process.argv.slice(2)
if (args.some((arg) => !['--release', '--dir'].includes(arg))) { if (args.some((arg) => !['--release', '--dir', '--test-updates', '--bootstrap'].includes(arg))) {
throw new Error('Usage: node scripts/package.mjs [--release] [--dir]') throw new Error('Usage: node scripts/package.mjs [--release | --test-updates [--bootstrap]] [--dir]')
} }
const release = args.includes('--release') const release = args.includes('--release')
const testUpdates = args.includes('--test-updates')
const bootstrap = args.includes('--bootstrap')
if (testUpdates && args.includes('--dir')) throw new Error('Test update builds must produce an installer.')
if (release && args.includes('--dir')) throw new Error('Release builds must produce an installer.') 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. // Never let a failed rebuild leave an older directory looking publishable.
if (release) { if (release) {
@ -15,12 +18,13 @@ if (release) {
} else { } else {
process.env.CSC_IDENTITY_AUTO_DISCOVERY = 'false' process.env.CSC_IDENTITY_AUTO_DISCOVERY = 'false'
} }
const config = createBuildConfig({ release }) const config = createBuildConfig({ release, testUpdates, bootstrap })
if (testUpdates) await rm(`${config.directories.output}/release-ready.json`, { force: true })
await build({ await build({
config, config,
targets: Platform.WINDOWS.createTarget(args.includes('--dir') ? ['dir'] : ['nsis'], Arch.x64), targets: Platform.WINDOWS.createTarget(args.includes('--dir') ? ['dir'] : ['nsis'], Arch.x64),
publish: 'never' publish: 'never'
}) })
if (release) { if (release || testUpdates) {
await writeReleaseManifest(config.directories.output, config.publish[0].url) await writeReleaseManifest(config.directories.output, config.publish[0].url)
} }

View File

@ -2,8 +2,13 @@ import { verifyRelease } from './releaseArtifacts.mjs'
import { uploadRelease } from './uploadRelease.mjs' import { uploadRelease } from './uploadRelease.mjs'
const args = process.argv.slice(2) const args = process.argv.slice(2)
if (args.some((arg) => arg !== '--upload')) throw new Error('Usage: npm run publish:release -- [--upload]') if (args.some((arg) => !['--upload', '--test-updates'].includes(arg))) throw new Error('Usage: npm run publish:release -- [--test-updates] [--upload]')
const release = await verifyRelease('dist/release') const testing = args.includes('--test-updates')
const release = await verifyRelease(testing ? 'dist/test' : 'dist/release')
if (testing && (!new URL(release.feedUrl).pathname.endsWith('/capsule/testfeed/') ||
(args.includes('--upload') && !new URL(process.env.CAPSULE_UPLOAD_URL).pathname.endsWith('/capsule/testupload/')))) {
throw new Error('Test publication requires the separate testfeed and testupload endpoints.')
}
console.log(`Capsule ${release.version}: ${release.feedUrl}`) console.log(`Capsule ${release.version}: ${release.feedUrl}`)
if (!args.includes('--upload')) { if (!args.includes('--upload')) {
console.log('Verified. Upload order:') console.log('Verified. Upload order:')

View File

@ -1,7 +1,7 @@
/** Own update state and retries without exposing updater options to the renderer. */ /** Own update state and retries without exposing updater options to the renderer. */
export function createUpdateController({ updater, version, enabled, onState = () => {}, export function createUpdateController({ updater, version, enabled, testing = false, onState = () => {},
schedule = setTimeout, cancel = clearTimeout, firstDelay = 15000, interval = 4 * 60 * 60 * 1000 }) { schedule = setTimeout, cancel = clearTimeout, firstDelay = 15000, interval = 4 * 60 * 60 * 1000 }) {
let state = { status: enabled ? 'idle' : 'disabled', currentVersion: version, version: null, percent: 0 } let state = { status: enabled ? 'idle' : 'disabled', currentVersion: version, version: null, percent: 0, testing }
let timer let timer
let busy = false let busy = false
let disposed = false let disposed = false

View File

@ -8,15 +8,18 @@ import { registerUpdateIpc, isTrustedUpdateSender } from './updateIpc.mjs'
/** Start updates independently of site sign-in, using only packaged release config. */ /** Start updates independently of site sign-in, using only packaged release config. */
export function registerUpdates(rendererUrl) { export function registerUpdates(rendererUrl) {
let enabled = false let enabled = false
let testing = false
if (app.isPackaged && process.platform === 'win32') { if (app.isPackaged && process.platform === 'win32') {
const metadata = JSON.parse(readFileSync(join(app.getAppPath(), 'package.json'), 'utf8')) const metadata = JSON.parse(readFileSync(join(app.getAppPath(), 'package.json'), 'utf8'))
enabled = metadata.capsuleUpdates?.enabled === true enabled = metadata.capsuleUpdates?.enabled === true
testing = metadata.capsuleUpdates?.testing === true && metadata.name === 'capsule-local'
} }
const { autoUpdater } = electronUpdater const { autoUpdater } = electronUpdater
const controller = createUpdateController({ const controller = createUpdateController({
updater: autoUpdater, updater: autoUpdater,
version: app.getVersion(), version: app.getVersion(),
enabled, enabled,
testing,
onState(state) { onState(state) {
const windows = BrowserWindow.getAllWindows() const windows = BrowserWindow.getAllWindows()
for (const window of windows) { for (const window of windows) {

View File

@ -352,7 +352,7 @@
<div class="m-2 m-lg-4"> <div class="m-2 m-lg-4">
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg"> <div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h3">Dashboard</h1> <h1 class="h3">Dashboard</h1>
<p class="alert alert-success" id="home-update-notice">you have updated to 0.1.1</p> <p class="alert alert-success" id="home-update-notice" hidden>you have updated to 0.1.1</p>
<p class="text-muted" id="home-note"></p> <p class="text-muted" id="home-note"></p>
<p class="mb-0"> <p class="mb-0">
Connected to <a id="home-site" href="#" target="_blank" rel="noreferrer"></a> Connected to <a id="home-site" href="#" target="_blank" rel="noreferrer"></a>

View File

@ -16,7 +16,8 @@ function renderUpdate(state) {
installing: 'Restarting to install the update…', installing: 'Restarting to install the update…',
error: 'Could not update Capsule. You can keep working and try again.' error: 'Could not update Capsule. You can keep working and try again.'
} }
label.textContent = `Capsule ${state.currentVersion} · ${messages[state.status] || messages.error}` label.textContent = `Capsule ${state.currentVersion}${state.testing ? ' · Unsigned test channel' : ''} · ${messages[state.status] || messages.error}`
document.getElementById('home-update-notice').hidden = state.currentVersion !== '0.1.1'
check.hidden = state.status === 'disabled' check.hidden = state.status === 'disabled'
check.disabled = ['checking', 'downloading', 'ready', 'installing'].includes(state.status) check.disabled = ['checking', 'downloading', 'ready', 'installing'].includes(state.status)
install.hidden = state.status !== 'ready' install.hidden = state.status !== 'ready'

View File

@ -37,6 +37,28 @@ test('local installer has separate identity, no feed, and cannot wipe app data',
assert.equal(config.nsis.deleteAppDataOnUninstall, false) assert.equal(config.nsis.deleteAppDataOnUninstall, false)
}) })
test('unsigned updates require explicit isolated test mode and retain the Local install identity', () => {
const testEnv = { CAPSULE_TEST_UPDATE_URL: 'https://ttp.joeykimsey.com/capsule/testfeed/' }
assert.throws(() => createBuildConfig({ testUpdates: true, env: {} }), /HTTPS/)
assert.throws(() => createBuildConfig({ release: true, testUpdates: true, env: testEnv }), /combined/)
assert.throws(() => createBuildConfig({ bootstrap: true }), /Bootstrap/)
assert.throws(() => createBuildConfig({ testUpdates: true, env: { CAPSULE_TEST_UPDATE_URL: feed } }), /separate/)
const local = createBuildConfig({ env: {} })
const testBuild = createBuildConfig({ testUpdates: true, env: testEnv })
assert.equal(testBuild.appId, local.appId)
assert.equal(testBuild.extraMetadata.name, local.extraMetadata.name)
assert.equal(testBuild.extraMetadata.capsuleUpdates.enabled, true)
assert.equal(testBuild.extraMetadata.capsuleUpdates.testing, true)
assert.equal(testBuild.win.verifyUpdateCodeSignature, false)
assert.equal(testBuild.forceCodeSigning, false)
assert.equal(testBuild.publish[0].url, testEnv.CAPSULE_TEST_UPDATE_URL)
assert.equal(testBuild.nsis.deleteAppDataOnUninstall, false)
const bootstrap = createBuildConfig({ testUpdates: true, bootstrap: true, env: testEnv })
assert.equal(bootstrap.extraMetadata.version, '0.1.0')
assert.notEqual(bootstrap.directories.output, testBuild.directories.output)
assert.equal(createBuildConfig({ release: true, env }).win.verifyUpdateCodeSignature, true)
})
test('release verification detects tampering and publishes metadata last', async (t) => { test('release verification detects tampering and publishes metadata last', async (t) => {
const directory = await mkdtemp(join(tmpdir(), 'capsule-release-test-')) const directory = await mkdtemp(join(tmpdir(), 'capsule-release-test-'))
t.after(() => rm(directory, { recursive: true, force: true })) t.after(() => rm(directory, { recursive: true, force: true }))