Add native runtime update notifications

This commit is contained in:
reaper
2026-08-22 03:12:58 -05:00
parent 2bd9babab2
commit 057e1ed0e7
14 changed files with 310 additions and 4 deletions
@@ -0,0 +1,36 @@
name: Runtime release watch
on:
schedule:
- cron: '17 */6 * * *'
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
php-releases:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Check official PHP releases
id: check
run: |
RESULT=$(node scripts/check-php-releases.mjs --report "$RUNNER_TEMP/php-runtime-report.md")
echo "count=$(node -e 'const value=JSON.parse(process.argv[1]); console.log(value.updates.length)' "$RESULT")" >> "$GITHUB_OUTPUT"
- name: Create or refresh packaging issue
if: steps.check.outputs.count != '0'
env:
GH_TOKEN: ${{ github.token }}
run: |
TITLE="Runtime update: new PHP release needs packaging"
EXISTING=$(gh issue list --state open --search "$TITLE in:title" --json number --jq '.[0].number // empty')
if [ -n "$EXISTING" ]; then
gh issue edit "$EXISTING" --body-file "$RUNNER_TEMP/php-runtime-report.md"
else
gh issue create --title "$TITLE" --body-file "$RUNNER_TEMP/php-runtime-report.md"
fi
+6
View File
@@ -26,3 +26,9 @@ Every project receives reserved loopback ports, generated service configuration,
6. Additional PHP/database versions, Apache, Drupal, Node.js, and developer services.
Native project creation must stay disabled until the platform bundle passes executable, service-health, database, routing, and cleanup checks. Existing projects default to the container engine for backward compatibility.
## Runtime update notifications
Dockside reads the bundled runtime catalog at startup and checks again every six hours. A remote catalog is accepted only when `AURORA_RUNTIME_CATALOG_PUBLIC_KEY` contains the Ed25519 public key and the adjacent `catalog.json.sig` validates. The last verified remote catalog is cached; otherwise Dockside falls back to its bundled trusted catalog.
The scheduled `runtime-release-watch.yml` workflow checks PHP's official JSON release feed every six hours. When a tracked PHP branch changes, it creates or updates a GitHub issue. It does not publish a runtime automatically: each platform package must be built, checksummed, tested, added to the catalog, and signed before users see it.
+1
View File
@@ -22,6 +22,7 @@
"build:modules": "node scripts/package-modules.cjs",
"build:connector": "node scripts/package-connector.cjs",
"build:native-runtime": "node scripts/package-native-runtime.cjs",
"check:php-releases": "node scripts/check-php-releases.mjs",
"module:new": "node scripts/create-module.cjs",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
+5
View File
@@ -0,0 +1,5 @@
{
"schema": 1,
"generatedAt": "2026-08-22T00:00:00.000Z",
"releases": []
}
+10
View File
@@ -0,0 +1,10 @@
{
"schema": 1,
"checkedAt": "2026-08-22T00:00:00.000Z",
"php": {
"8.2": "8.2.33",
"8.3": "8.3.33",
"8.4": "8.4.24",
"8.5": "8.5.9"
}
}
+33
View File
@@ -0,0 +1,33 @@
import { readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const sourceUrl = 'https://www.php.net/releases/index.php?json&version=8&max=30'
const baselinePath = resolve('runtime/upstream-baseline.json')
const reportArgument = process.argv.indexOf('--report')
const reportPath = reportArgument >= 0 ? process.argv[reportArgument + 1] : undefined
function latestByBranch(releases) {
const result = {}
for (const version of Object.keys(releases)) {
if (!/^8\.[2-5]\.\d+$/.test(version)) continue
const branch = version.split('.').slice(0, 2).join('.')
if (!result[branch] || Number(version.split('.')[2]) > Number(result[branch].split('.')[2])) result[branch] = version
}
return result
}
const baseline = JSON.parse(await readFile(baselinePath, 'utf8'))
const response = await fetch(sourceUrl, { headers: { 'user-agent': 'Aurora-Dockside-Runtime-Watcher/1.0' } })
if (!response.ok) throw new Error(`PHP release feed returned HTTP ${response.status}.`)
const releases = await response.json()
const latest = latestByBranch(releases)
const updates = Object.entries(latest)
.filter(([branch, version]) => baseline.php[branch] !== version)
.map(([branch, version]) => ({ branch, packaged: baseline.php[branch] || 'not tracked', upstream: version, security: releases[version]?.tags?.includes('security') === true }))
const lines = updates.length
? ['# PHP runtime releases need packaging', '', 'The official PHP release feed contains versions newer than Auroras tracked packaging baseline.', '', '| Branch | Aurora baseline | Upstream | Security release |', '| --- | --- | --- | --- |', ...updates.map((update) => `| ${update.branch} | ${update.packaged} | ${update.upstream} | ${update.security ? 'Yes' : 'No'} |`), '', `Source: ${sourceUrl}`, '', 'After packages pass platform tests, update `runtime/upstream-baseline.json` and the signed runtime catalog.']
: ['# PHP runtime release check', '', 'No untracked PHP releases were found.']
if (reportPath) await writeFile(reportPath, `${lines.join('\n')}\n`, 'utf8')
process.stdout.write(`${JSON.stringify({ updates, latest })}\n`)
+5
View File
@@ -1,6 +1,11 @@
import { ipcMain } from 'electron'
import { getRuntimeStatus } from '../nativeRuntime'
import { getRuntimeUpdates } from '../runtimeCatalog'
export function registerRuntimeIpc(): void {
ipcMain.handle('runtime:status', () => getRuntimeStatus())
ipcMain.handle('runtime:updates', async () => {
const status = await getRuntimeStatus()
return getRuntimeUpdates(status.native.components)
})
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { findRuntimeUpdates, validateRuntimeCatalog } from './runtimeCatalog'
const checksum = 'a'.repeat(64)
describe('runtime catalog', () => {
it('selects the newest compatible component release', () => {
const catalog = validateRuntimeCatalog({ schema: 1, generatedAt: '2026-08-22T00:00:00.000Z', releases: [
{ component: 'php', version: '8.5.8', platform: 'linux', arch: 'x64', channel: 'stable', url: 'https://auroradockside.com/php-8.5.8.tar.zst', sha256: checksum },
{ component: 'php', version: '8.5.9', platform: 'linux', arch: 'x64', channel: 'security', url: 'https://auroradockside.com/php-8.5.9.tar.zst', sha256: checksum },
{ component: 'php', version: '8.6.0', platform: 'darwin', arch: 'arm64', channel: 'stable', url: 'https://auroradockside.com/php-8.6.0.tar.zst', sha256: checksum }
] })
expect(findRuntimeUpdates(catalog, [{ id: 'php', version: '8.5.7', executable: 'bin/php', sha256: checksum }], 'linux', 'x64')).toEqual([
{ component: 'php', installedVersion: '8.5.7', availableVersion: '8.5.9', channel: 'security' }
])
})
it('rejects insecure release URLs', () => {
expect(() => validateRuntimeCatalog({ schema: 1, generatedAt: 'now', releases: [{ component: 'php', version: '8.5.9', platform: 'linux', arch: 'x64', channel: 'stable', url: 'http://example.test/php', sha256: checksum }] })).toThrow('Invalid runtime catalog release')
})
})
+116
View File
@@ -0,0 +1,116 @@
import { app } from 'electron'
import { createPublicKey, verify } from 'crypto'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { join } from 'path'
import type {
AuroraNativeRuntimeComponent,
AuroraRuntimeCatalog,
AuroraRuntimeCatalogRelease,
AuroraRuntimeUpdate,
AuroraRuntimeUpdateStatus
} from '../shared/types'
const catalogUrl = process.env.AURORA_RUNTIME_CATALOG_URL || 'https://auroradockside.com/runtime/catalog.json'
const publicKeyPem = process.env.AURORA_RUNTIME_CATALOG_PUBLIC_KEY
function compareVersions(left: string, right: string): number {
const a = left.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0)
const b = right.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0)
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
if ((a[index] || 0) !== (b[index] || 0)) return (a[index] || 0) - (b[index] || 0)
}
return 0
}
export function validateRuntimeCatalog(value: unknown): AuroraRuntimeCatalog {
if (!value || typeof value !== 'object') throw new Error('Runtime catalog must be an object.')
const catalog = value as Record<string, unknown>
if (catalog.schema !== 1 || typeof catalog.generatedAt !== 'string' || !Array.isArray(catalog.releases))
throw new Error('Unsupported runtime catalog.')
const releases = catalog.releases.map((entry) => {
if (!entry || typeof entry !== 'object') throw new Error('Invalid runtime catalog release.')
const release = entry as Record<string, unknown>
if (
!['php', 'nginx', 'apache', 'mariadb', 'mysql', 'postgres', 'node', 'composer', 'wp-cli', 'drush'].includes(String(release.component)) ||
typeof release.version !== 'string' ||
!['linux', 'darwin', 'win32'].includes(String(release.platform)) ||
!['x64', 'arm64'].includes(String(release.arch)) ||
!['stable', 'security'].includes(String(release.channel)) ||
typeof release.url !== 'string' ||
!release.url.startsWith('https://') ||
typeof release.sha256 !== 'string' ||
!/^[a-f0-9]{64}$/i.test(release.sha256)
) throw new Error('Invalid runtime catalog release.')
return release as unknown as AuroraRuntimeCatalogRelease
})
return { schema: 1, generatedAt: catalog.generatedAt, releases }
}
export function findRuntimeUpdates(
catalog: AuroraRuntimeCatalog,
installed: AuroraNativeRuntimeComponent[],
platform: NodeJS.Platform,
arch: string
): AuroraRuntimeUpdate[] {
return installed.flatMap((component) => {
const candidates = catalog.releases.filter((release) =>
release.component === component.id && release.platform === platform && release.arch === arch
)
const latest = candidates.sort((a, b) => compareVersions(b.version, a.version))[0]
return latest && compareVersions(latest.version, component.version) > 0
? [{ component: component.id, installedVersion: component.version, availableVersion: latest.version, channel: latest.channel }]
: []
})
}
function bundledCatalogPath(): string {
return join(app.getAppPath(), 'resources', 'runtime-catalog', 'catalog.json')
}
async function remoteCatalog(): Promise<AuroraRuntimeCatalog> {
if (!publicKeyPem) throw new Error('Remote runtime catalog verification is not configured yet.')
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 8000)
try {
const [catalogResponse, signatureResponse] = await Promise.all([
fetch(catalogUrl, { signal: controller.signal }),
fetch(`${catalogUrl}.sig`, { signal: controller.signal })
])
if (!catalogResponse.ok || !signatureResponse.ok) throw new Error('Runtime catalog server is unavailable.')
const body = Buffer.from(await catalogResponse.arrayBuffer())
const signature = Buffer.from((await signatureResponse.text()).trim(), 'base64')
if (!verify(null, body, createPublicKey(publicKeyPem), signature))
throw new Error('Runtime catalog signature is invalid.')
return validateRuntimeCatalog(JSON.parse(body.toString('utf8')))
} finally {
clearTimeout(timer)
}
}
export async function getRuntimeUpdates(installed: AuroraNativeRuntimeComponent[]): Promise<AuroraRuntimeUpdateStatus> {
const cacheDirectory = join(app.getPath('userData'), 'runtime-catalog')
const cachePath = join(cacheDirectory, 'catalog.json')
let source: AuroraRuntimeUpdateStatus['source'] = 'bundled'
let message: string | undefined
let catalog: AuroraRuntimeCatalog
try {
catalog = await remoteCatalog()
source = 'remote'
await mkdir(cacheDirectory, { recursive: true })
await writeFile(cachePath, JSON.stringify(catalog, null, 2), 'utf8')
} catch (error) {
message = error instanceof Error ? error.message : 'Runtime update check failed.'
try {
catalog = validateRuntimeCatalog(JSON.parse(await readFile(cachePath, 'utf8')))
source = 'cache'
} catch {
catalog = validateRuntimeCatalog(JSON.parse(await readFile(bundledCatalogPath(), 'utf8')))
}
}
return {
checkedAt: new Date().toISOString(),
source,
updates: findRuntimeUpdates(catalog, installed, process.platform, process.arch),
message
}
}
+3 -1
View File
@@ -12,6 +12,7 @@ import type {
AuroraRemoteSiteProfile,
AuroraRemoteSiteStatus,
AuroraRuntimeStatus,
AuroraRuntimeUpdateStatus,
AuroraStackOptions,
EnvironmentUpdate,
LogDataEvent,
@@ -157,7 +158,8 @@ const api = {
ipcRenderer.invoke('remote:pull', operationId, name)
},
runtime: {
status: (): Promise<AuroraRuntimeStatus> => ipcRenderer.invoke('runtime:status')
status: (): Promise<AuroraRuntimeStatus> => ipcRenderer.invoke('runtime:status'),
updates: (): Promise<AuroraRuntimeUpdateStatus> => ipcRenderer.invoke('runtime:updates')
},
zoom: {
in: (): Promise<number> => ipcRenderer.invoke('window:zoomIn'),
+14 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useState } from 'react'
import { Anchor, Boxes, FolderOpen, Plus, Settings, Sparkles, TerminalSquare } from 'lucide-react'
import { Anchor, Bell, Boxes, FolderOpen, Plus, Settings, Sparkles, TerminalSquare } from 'lucide-react'
import { ProjectDetail } from './components/projects/ProjectDetail'
import { ProjectList } from './components/projects/ProjectList'
import { TerminalPanel } from './components/terminal/TerminalPanel'
@@ -13,12 +13,14 @@ import { useTerminalEvents } from './hooks/useTerminalEvents'
import { useAppliedTheme } from './hooks/useAppliedTheme'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import docksideIcon from './assets/dockside-icon.png'
import { useRuntimeUpdates } from './hooks/useRuntime'
function App(): React.JSX.Element {
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
const [isModulesOpen, setIsModulesOpen] = useState(false)
const { data: runtimeUpdates } = useRuntimeUpdates()
useTerminalEvents()
useAppliedTheme()
useKeyboardShortcuts({
@@ -53,6 +55,17 @@ function App(): React.JSX.Element {
>
<Plus size={16} />
</button>
{runtimeUpdates && runtimeUpdates.updates.length > 0 && (
<button
type="button"
onClick={() => setIsSettingsOpen(true)}
title={`${runtimeUpdates.updates.length} runtime update${runtimeUpdates.updates.length === 1 ? '' : 's'} available`}
className="relative rounded-md p-1.5 text-amber-600 transition hover:bg-amber-50 dark:text-amber-300 dark:hover:bg-amber-400/10"
>
<Bell size={16} />
<span className="absolute right-0 top-0 size-2 rounded-full bg-amber-500" />
</button>
)}
<button
type="button"
onClick={() => setIsSettingsOpen(true)}
@@ -1,6 +1,7 @@
import { Minus, Plus, RotateCcw, X } from 'lucide-react'
import { CheckCircle2, Minus, Plus, RefreshCw, RotateCcw, X } from 'lucide-react'
import { clsx } from 'clsx'
import { useThemeStore, type Theme } from '../../stores/themeStore'
import { useRuntimeUpdates } from '../../hooks/useRuntime'
const THEMES: { value: Theme; label: string }[] = [
{ value: 'light', label: 'Light' },
@@ -18,6 +19,7 @@ const SHORTCUTS = [
export function SettingsModal({ onClose }: { onClose: () => void }): React.JSX.Element {
const theme = useThemeStore((s) => s.theme)
const setTheme = useThemeStore((s) => s.setTheme)
const runtimeUpdates = useRuntimeUpdates()
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-8">
@@ -34,6 +36,20 @@ export function SettingsModal({ onClose }: { onClose: () => void }): React.JSX.E
</div>
<div className="flex flex-col gap-6 p-4">
<section>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-xs font-medium text-neutral-500 dark:text-neutral-400">Runtime updates</h3>
<button type="button" onClick={() => runtimeUpdates.refetch()} disabled={runtimeUpdates.isFetching} className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-cyan-700 hover:bg-cyan-50 disabled:opacity-50 dark:text-cyan-300 dark:hover:bg-cyan-400/10"><RefreshCw size={12} className={runtimeUpdates.isFetching ? 'animate-spin' : ''}/>Check now</button>
</div>
<div className="rounded-lg border border-neutral-200 p-3 text-sm dark:border-neutral-700">
{runtimeUpdates.isLoading ? <p className="text-neutral-500">Checking trusted runtime catalog</p> : runtimeUpdates.data?.updates.length ? (
<div className="space-y-2">{runtimeUpdates.data.updates.map((update) => <div key={update.component} className="flex items-center justify-between"><span className="font-medium uppercase">{update.component}</span><span className="text-xs text-amber-700 dark:text-amber-300">{update.installedVersion} {update.availableVersion}</span></div>)}</div>
) : <p className="flex items-center gap-2 text-neutral-600 dark:text-neutral-300"><CheckCircle2 size={15} className="text-emerald-600"/>Installed Aurora runtimes are current.</p>}
{runtimeUpdates.data?.message && <p className="mt-2 text-xs text-neutral-500">Using the {runtimeUpdates.data.source} catalog. {runtimeUpdates.data.message}</p>}
<p className="mt-2 text-xs text-neutral-400">Updates are announced automatically. Installation remains a user-approved action.</p>
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
Theme
+11 -1
View File
@@ -1,5 +1,5 @@
import { useQuery, type UseQueryResult } from '@tanstack/react-query'
import type { AuroraRuntimeStatus } from '@shared/types'
import type { AuroraRuntimeStatus, AuroraRuntimeUpdateStatus } from '@shared/types'
export function useRuntimeStatus(): UseQueryResult<AuroraRuntimeStatus, Error> {
return useQuery({
@@ -8,3 +8,13 @@ export function useRuntimeStatus(): UseQueryResult<AuroraRuntimeStatus, Error> {
staleTime: 30000
})
}
export function useRuntimeUpdates(): UseQueryResult<AuroraRuntimeUpdateStatus, Error> {
return useQuery({
queryKey: ['runtime', 'updates'],
queryFn: () => window.api.runtime.updates(),
staleTime: 60 * 60 * 1000,
refetchInterval: 6 * 60 * 60 * 1000,
retry: 1
})
}
+32
View File
@@ -22,6 +22,38 @@ export interface AuroraRuntimeStatus {
native: { available: boolean; platform: string; arch: string; runtimeVersion?: string; components: AuroraNativeRuntimeComponent[]; reason?: string }
}
export type AuroraRuntimeComponentId = AuroraNativeRuntimeComponent['id']
export interface AuroraRuntimeCatalogRelease {
component: AuroraRuntimeComponentId
version: string
platform: AuroraNativeRuntimeManifest['platform']
arch: AuroraNativeRuntimeManifest['arch']
channel: 'stable' | 'security'
url: string
sha256: string
}
export interface AuroraRuntimeCatalog {
schema: 1
generatedAt: string
releases: AuroraRuntimeCatalogRelease[]
}
export interface AuroraRuntimeUpdate {
component: AuroraRuntimeComponentId
installedVersion: string
availableVersion: string
channel: AuroraRuntimeCatalogRelease['channel']
}
export interface AuroraRuntimeUpdateStatus {
checkedAt: string
source: 'bundled' | 'remote' | 'cache'
updates: AuroraRuntimeUpdate[]
message?: string
}
export interface AuroraProjectSummary {
name: string
status: ProjectStatus