feat(release): add dev prereleases and update notices

Publish bleeding-edge builds from dev to GitHub prereleases and npm dist-tag 'dev'. Dev builds poll GitHub prereleases and surface update availability via /api/meta for UI notifications.
This commit is contained in:
Shantur Rathore
2026-02-12 23:53:05 +00:00
parent d3484ec3af
commit 45fab91e7f
16 changed files with 292 additions and 8 deletions

View File

@@ -1 +1,4 @@
public/
# Local developer config (may contain secrets)
config-*.json

View File

@@ -286,6 +286,8 @@ export interface ServerMeta {
serverVersion?: string
ui?: UiMeta
support?: SupportMeta
/** Optional update info (dev channel only). */
update?: LatestReleaseInfo | null
}
export type BackgroundProcessStatus = "running" | "stopped" | "error"

View File

@@ -22,6 +22,7 @@ import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
const require = createRequire(import.meta.url)
@@ -348,6 +349,21 @@ async function main() {
minServerVersion: uiResolution.minServerVersion,
}
const updateChannel = (process.env.CODENOMAD_UPDATE_CHANNEL ?? "").trim().toLowerCase()
const githubRepo = (process.env.CODENOMAD_GITHUB_REPO ?? "NeuralNomadsAI/CodeNomad").trim()
const isDevVersion = packageJson.version.includes("-dev.")
const enableDevUpdateChecks = updateChannel === "dev" || (updateChannel === "" && isDevVersion)
const devReleaseMonitor = enableDevUpdateChecks
? startDevReleaseMonitor({
currentVersion: packageJson.version,
repo: githubRepo,
logger: logger.child({ component: "updates" }),
onUpdate: (release) => {
serverMeta.update = release
},
})
: null
if (uiResolution.uiDevServerUrl && options.https) {
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
}
@@ -507,6 +523,8 @@ async function main() {
// no-op: remote UI manifest replaces GitHub release monitor
devReleaseMonitor?.stop()
logger.info("Exiting process")
process.exit(0)
}

View File

@@ -0,0 +1,118 @@
import { fetch } from "undici"
import type { LatestReleaseInfo } from "../api-types"
import type { Logger } from "../logger"
import { compareVersionStrings, stripTagPrefix } from "./release-monitor"
interface DevReleaseMonitorOptions {
/** Current running server version (from package.json). */
currentVersion: string
/** GitHub repo in the form "owner/name". */
repo: string
logger: Logger
onUpdate: (release: LatestReleaseInfo | null) => void
pollIntervalMs?: number
}
interface GithubReleaseListItem {
tag_name?: string
name?: string
html_url?: string
body?: string
published_at?: string
created_at?: string
prerelease?: boolean
draft?: boolean
}
export interface DevReleaseMonitor {
stop(): void
}
const DEFAULT_POLL_INTERVAL_MS = 15 * 60 * 1000
export function startDevReleaseMonitor(options: DevReleaseMonitorOptions): DevReleaseMonitor {
let stopped = false
let timer: ReturnType<typeof setInterval> | null = null
const pollIntervalMs =
Number.isFinite(options.pollIntervalMs) && (options.pollIntervalMs ?? 0) > 0
? (options.pollIntervalMs as number)
: DEFAULT_POLL_INTERVAL_MS
const refresh = async () => {
if (stopped) return
try {
const release = await fetchLatestPrerelease({
repo: options.repo,
currentVersion: options.currentVersion,
})
options.onUpdate(release)
} catch (error) {
options.logger.debug({ err: error }, "Failed to refresh dev prerelease information")
}
}
void refresh()
timer = setInterval(() => void refresh(), pollIntervalMs)
return {
stop() {
stopped = true
if (timer) {
clearInterval(timer)
timer = null
}
},
}
}
async function fetchLatestPrerelease(args: {
repo: string
currentVersion: string
}): Promise<LatestReleaseInfo | null> {
const normalizedRepo = args.repo.trim()
if (!/^[^/\s]+\/[^/\s]+$/.test(normalizedRepo)) {
throw new Error(`Invalid GitHub repo: ${args.repo}`)
}
const apiUrl = `https://api.github.com/repos/${normalizedRepo}/releases?per_page=20`
const response = await fetch(apiUrl, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok) {
throw new Error(`GitHub releases API responded with ${response.status}`)
}
const list = (await response.json()) as GithubReleaseListItem[]
const latest = list.find((r) => r && r.prerelease === true && r.draft !== true)
if (!latest) {
return null
}
const tag = latest.tag_name || latest.name
if (!tag) {
return null
}
const normalizedVersion = stripTagPrefix(tag)
if (!normalizedVersion) {
return null
}
if (compareVersionStrings(normalizedVersion, args.currentVersion) <= 0) {
return null
}
return {
version: normalizedVersion,
tag,
url: latest.html_url ?? `https://github.com/${normalizedRepo}/releases/tag/${encodeURIComponent(tag)}`,
channel: "dev",
publishedAt: latest.published_at ?? latest.created_at,
notes: latest.body,
}
}

View File

@@ -52,6 +52,12 @@ export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMoni
}
}
export function compareVersionStrings(a: string, b: string): number {
const left = parseVersion(a)
const right = parseVersion(b)
return compareVersions(left, right)
}
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
const response = await fetch(RELEASES_API_URL, {
headers: {
@@ -92,7 +98,7 @@ async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<Lates
}
}
function stripTagPrefix(tag: string | undefined): string | null {
export function stripTagPrefix(tag: string | undefined): string | null {
if (!tag) return null
const trimmed = tag.trim()
if (!trimmed) return null