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:
3
packages/server/.gitignore
vendored
3
packages/server/.gitignore
vendored
@@ -1 +1,4 @@
|
||||
public/
|
||||
|
||||
# Local developer config (may contain secrets)
|
||||
config-*.json
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
118
packages/server/src/releases/dev-release-monitor.ts
Normal file
118
packages/server/src/releases/dev-release-monitor.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user