diff --git a/.github/workflows/runtime-release-watch.yml b/.github/workflows/runtime-release-watch.yml new file mode 100644 index 0000000..250c6f9 --- /dev/null +++ b/.github/workflows/runtime-release-watch.yml @@ -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 diff --git a/docs/AURORA_NATIVE_RUNTIME.md b/docs/AURORA_NATIVE_RUNTIME.md index 8150ff8..8c39d58 100644 --- a/docs/AURORA_NATIVE_RUNTIME.md +++ b/docs/AURORA_NATIVE_RUNTIME.md @@ -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. diff --git a/package.json b/package.json index 40aa352..944ffbd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/runtime-catalog/catalog.json b/resources/runtime-catalog/catalog.json new file mode 100644 index 0000000..c53dde3 --- /dev/null +++ b/resources/runtime-catalog/catalog.json @@ -0,0 +1,5 @@ +{ + "schema": 1, + "generatedAt": "2026-08-22T00:00:00.000Z", + "releases": [] +} diff --git a/runtime/upstream-baseline.json b/runtime/upstream-baseline.json new file mode 100644 index 0000000..3a08cf7 --- /dev/null +++ b/runtime/upstream-baseline.json @@ -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" + } +} diff --git a/scripts/check-php-releases.mjs b/scripts/check-php-releases.mjs new file mode 100644 index 0000000..97c9338 --- /dev/null +++ b/scripts/check-php-releases.mjs @@ -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 Aurora’s 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`) diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 41ff521..e8999c4 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -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) + }) } diff --git a/src/main/runtimeCatalog.test.ts b/src/main/runtimeCatalog.test.ts new file mode 100644 index 0000000..a652bf6 --- /dev/null +++ b/src/main/runtimeCatalog.test.ts @@ -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') + }) +}) diff --git a/src/main/runtimeCatalog.ts b/src/main/runtimeCatalog.ts new file mode 100644 index 0000000..4af3b53 --- /dev/null +++ b/src/main/runtimeCatalog.ts @@ -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 + 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 + 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 { + 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 { + 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 + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index ec7d1a5..4449475 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('runtime:status') + status: (): Promise => ipcRenderer.invoke('runtime:status'), + updates: (): Promise => ipcRenderer.invoke('runtime:updates') }, zoom: { in: (): Promise => ipcRenderer.invoke('window:zoomIn'), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c2f9f6a..e7f6b1d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { > + {runtimeUpdates && runtimeUpdates.updates.length > 0 && ( + + )} + +
+ {runtimeUpdates.isLoading ?

Checking trusted runtime catalog…

: runtimeUpdates.data?.updates.length ? ( +
{runtimeUpdates.data.updates.map((update) =>
{update.component}{update.installedVersion} → {update.availableVersion}
)}
+ ) :

Installed Aurora runtimes are current.

} + {runtimeUpdates.data?.message &&

Using the {runtimeUpdates.data.source} catalog. {runtimeUpdates.data.message}

} +

Updates are announced automatically. Installation remains a user-approved action.

+
+ +

Theme diff --git a/src/renderer/src/hooks/useRuntime.ts b/src/renderer/src/hooks/useRuntime.ts index c1deb22..b18f1ba 100644 --- a/src/renderer/src/hooks/useRuntime.ts +++ b/src/renderer/src/hooks/useRuntime.ts @@ -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 { return useQuery({ @@ -8,3 +8,13 @@ export function useRuntimeStatus(): UseQueryResult { staleTime: 30000 }) } + +export function useRuntimeUpdates(): UseQueryResult { + return useQuery({ + queryKey: ['runtime', 'updates'], + queryFn: () => window.api.runtime.updates(), + staleTime: 60 * 60 * 1000, + refetchInterval: 6 * 60 * 60 * 1000, + retry: 1 + }) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 95e1279..741565e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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