diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 235e9bc0..520a806b 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -1,4 +1,4 @@ -name: Dev CI +name: Develop Pre-Release on: push: @@ -7,12 +7,34 @@ on: workflow_dispatch: permissions: - contents: read + id-token: write + contents: write + +concurrency: + group: dev-prerelease + cancel-in-progress: true jobs: - dev-ci: - uses: ./.github/workflows/build-and-upload.yml + prepare: + runs-on: ubuntu-latest + outputs: + version_suffix: ${{ steps.vars.outputs.version_suffix }} + steps: + - name: Compute version suffix + id: vars + shell: bash + run: | + set -euo pipefail + SHA8="${GITHUB_SHA::8}" + TS=$(date -u +%Y%m%d%H%M%S) + echo "version_suffix=-dev.${TS}.${SHA8}" >> "$GITHUB_OUTPUT" + + prerelease: + needs: prepare + uses: ./.github/workflows/reusable-release.yml with: - upload: false - set_versions: false + version_suffix: ${{ needs.prepare.outputs.version_suffix }} + dist_tag: dev + prerelease: true + release_ui: false secrets: inherit diff --git a/.github/workflows/manual-npm-publish.yml b/.github/workflows/manual-npm-publish.yml index 86b8768a..403ffffc 100644 --- a/.github/workflows/manual-npm-publish.yml +++ b/.github/workflows/manual-npm-publish.yml @@ -67,6 +67,16 @@ jobs: run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version - name: Publish server package with provenance + if: ${{ secrets.NPM_TOKEN != '' }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_PROVENANCE: true + NPM_CONFIG_REGISTRY: https://registry.npmjs.org + run: | + npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance + + - name: Publish server package with provenance (OIDC) + if: ${{ secrets.NPM_TOKEN == '' }} env: NPM_CONFIG_PROVENANCE: true NPM_CONFIG_REGISTRY: https://registry.npmjs.org diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 2f6da125..460aa7d5 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -13,6 +13,16 @@ on: required: false default: dev type: string + prerelease: + description: "Create GitHub prerelease" + required: false + default: false + type: boolean + release_ui: + description: "Publish remote UI + manifest" + required: false + default: true + type: boolean permissions: id-token: write @@ -53,11 +63,16 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.versions.outputs.tag }} + IS_PRERELEASE: ${{ inputs.prerelease }} run: | if gh release view "$TAG" >/dev/null 2>&1; then echo "Release $TAG already exists" else - gh release create "$TAG" --title "$TAG" --generate-notes + if [ "${IS_PRERELEASE}" = "true" ]; then + gh release create "$TAG" --title "$TAG" --generate-notes --prerelease + else + gh release create "$TAG" --title "$TAG" --generate-notes + fi fi build-and-upload: @@ -71,6 +86,7 @@ jobs: release-ui: needs: prepare-release + if: ${{ inputs.release_ui }} permissions: contents: read uses: ./.github/workflows/release-ui.yml diff --git a/README.md b/README.md index 71798d16..f1f7c4bc 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,11 @@ For dev version npx @neuralnomads/codenomad@dev --launch ``` +Dev builds are published as GitHub pre-releases: +https://github.com/shantur/CodeNomad/releases + +Dev releases are bleeding-edge builds, generated automatically every time a new commit is pushed to the `dev` branch. + This command starts the server and opens the web client in your default browser. ## Highlights diff --git a/packages/server/.gitignore b/packages/server/.gitignore index 364fdec1..531f28fe 100644 --- a/packages/server/.gitignore +++ b/packages/server/.gitignore @@ -1 +1,4 @@ public/ + +# Local developer config (may contain secrets) +config-*.json diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 41e8229b..a48cf096 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -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" diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 54753885..3f4461e3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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) } diff --git a/packages/server/src/releases/dev-release-monitor.ts b/packages/server/src/releases/dev-release-monitor.ts new file mode 100644 index 00000000..5fe405d8 --- /dev/null +++ b/packages/server/src/releases/dev-release-monitor.ts @@ -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 | 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 { + 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, + } +} diff --git a/packages/server/src/releases/release-monitor.ts b/packages/server/src/releases/release-monitor.ts index 2fd80c99..d84e6959 100644 --- a/packages/server/src/releases/release-monitor.ts +++ b/packages/server/src/releases/release-monitor.ts @@ -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 { const response = await fetch(RELEASES_API_URL, { headers: { @@ -92,7 +98,7 @@ async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise(null) const UI_VERSION_STORAGE_KEY = "codenomad:lastSeenUiVersion" +const DEV_RELEASE_STORAGE_KEY = "codenomad:lastSeenDevRelease" +const META_REFRESH_INTERVAL_MS = 10 * 60 * 1000 let initialized = false let visibilityEffectInitialized = false let activeToast: ToastHandle | null = null let activeToastKey: string | null = null let uiUpdateToasted = false +let metaRefreshInterval: ReturnType | null = null function dismissActiveToast() { if (activeToast) { @@ -80,6 +83,8 @@ async function refreshFromMeta() { const meta = await getServerMeta(true) setSupportInfo(meta.support ?? null) maybeNotifyUiUpdated(meta) + maybeNotifyDevReleaseAvailable(meta) + ensureMetaRefresh(meta) } catch (error) { log.warn("Unable to load server metadata for support info", error) } @@ -115,6 +120,46 @@ function maybeNotifyUiUpdated(meta: ServerMeta) { }) } +function maybeNotifyDevReleaseAvailable(meta: ServerMeta) { + const update = meta.update + if (!update || !update.version || !update.url) return + + const lastSeen = safeReadLocalStorage(DEV_RELEASE_STORAGE_KEY) + if (lastSeen === update.version) { + return + } + + safeWriteLocalStorage(DEV_RELEASE_STORAGE_KEY, update.version) + + showToastNotification({ + title: tGlobal("releases.devUpdateAvailable.title"), + message: tGlobal("releases.devUpdateAvailable.message", { version: update.version }), + variant: "info", + duration: 12000, + position: "bottom-right", + action: { + label: tGlobal("releases.devUpdateAvailable.action"), + href: update.url, + }, + }) +} + +function ensureMetaRefresh(meta: ServerMeta) { + if (metaRefreshInterval) return + + const version = meta.serverVersion?.trim() ?? "" + const looksLikeDev = version.includes("-dev.") + const hasDevUpdateChannel = Boolean(meta.update) + + if (!looksLikeDev && !hasDevUpdateChannel) { + return + } + + metaRefreshInterval = setInterval(() => { + void refreshFromMeta() + }, META_REFRESH_INTERVAL_MS) +} + function safeReadLocalStorage(key: string): string | null { try { if (typeof window === "undefined" || !window.localStorage) return null