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:
34
.github/workflows/dev-release.yml
vendored
34
.github/workflows/dev-release.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Dev CI
|
name: Develop Pre-Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -7,12 +7,34 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: dev-prerelease
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
dev-ci:
|
prepare:
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
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:
|
with:
|
||||||
upload: false
|
version_suffix: ${{ needs.prepare.outputs.version_suffix }}
|
||||||
set_versions: false
|
dist_tag: dev
|
||||||
|
prerelease: true
|
||||||
|
release_ui: false
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
10
.github/workflows/manual-npm-publish.yml
vendored
10
.github/workflows/manual-npm-publish.yml
vendored
@@ -67,6 +67,16 @@ jobs:
|
|||||||
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Publish server package with provenance
|
- 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:
|
env:
|
||||||
NPM_CONFIG_PROVENANCE: true
|
NPM_CONFIG_PROVENANCE: true
|
||||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
|
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
|
||||||
|
|||||||
18
.github/workflows/reusable-release.yml
vendored
18
.github/workflows/reusable-release.yml
vendored
@@ -13,6 +13,16 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: dev
|
default: dev
|
||||||
type: string
|
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:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
@@ -53,11 +63,16 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG: ${{ steps.versions.outputs.tag }}
|
TAG: ${{ steps.versions.outputs.tag }}
|
||||||
|
IS_PRERELEASE: ${{ inputs.prerelease }}
|
||||||
run: |
|
run: |
|
||||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||||
echo "Release $TAG already exists"
|
echo "Release $TAG already exists"
|
||||||
else
|
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
|
fi
|
||||||
|
|
||||||
build-and-upload:
|
build-and-upload:
|
||||||
@@ -71,6 +86,7 @@ jobs:
|
|||||||
|
|
||||||
release-ui:
|
release-ui:
|
||||||
needs: prepare-release
|
needs: prepare-release
|
||||||
|
if: ${{ inputs.release_ui }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
uses: ./.github/workflows/release-ui.yml
|
uses: ./.github/workflows/release-ui.yml
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ For dev version
|
|||||||
npx @neuralnomads/codenomad@dev --launch
|
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.
|
This command starts the server and opens the web client in your default browser.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|||||||
3
packages/server/.gitignore
vendored
3
packages/server/.gitignore
vendored
@@ -1 +1,4 @@
|
|||||||
public/
|
public/
|
||||||
|
|
||||||
|
# Local developer config (may contain secrets)
|
||||||
|
config-*.json
|
||||||
|
|||||||
@@ -286,6 +286,8 @@ export interface ServerMeta {
|
|||||||
serverVersion?: string
|
serverVersion?: string
|
||||||
ui?: UiMeta
|
ui?: UiMeta
|
||||||
support?: SupportMeta
|
support?: SupportMeta
|
||||||
|
/** Optional update info (dev channel only). */
|
||||||
|
update?: LatestReleaseInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackgroundProcessStatus = "running" | "stopped" | "error"
|
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 { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||||
import { resolveHttpsOptions } from "./server/tls"
|
import { resolveHttpsOptions } from "./server/tls"
|
||||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||||
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -348,6 +349,21 @@ async function main() {
|
|||||||
minServerVersion: uiResolution.minServerVersion,
|
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) {
|
if (uiResolution.uiDevServerUrl && options.https) {
|
||||||
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
|
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
|
// no-op: remote UI manifest replaces GitHub release monitor
|
||||||
|
|
||||||
|
devReleaseMonitor?.stop()
|
||||||
|
|
||||||
logger.info("Exiting process")
|
logger.info("Exiting process")
|
||||||
process.exit(0)
|
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> {
|
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
|
||||||
const response = await fetch(RELEASES_API_URL, {
|
const response = await fetch(RELEASES_API_URL, {
|
||||||
headers: {
|
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
|
if (!tag) return null
|
||||||
const trimmed = tag.trim()
|
const trimmed = tag.trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export const appMessages = {
|
|||||||
"releases.uiUpdated.title": "UI updated",
|
"releases.uiUpdated.title": "UI updated",
|
||||||
"releases.uiUpdated.message": "UI is now updated to {version}.",
|
"releases.uiUpdated.message": "UI is now updated to {version}.",
|
||||||
|
|
||||||
|
"releases.devUpdateAvailable.title": "Dev build available",
|
||||||
|
"releases.devUpdateAvailable.message": "A new dev build is available: {version}.",
|
||||||
|
"releases.devUpdateAvailable.action": "View release",
|
||||||
|
|
||||||
"theme.mode.system": "System",
|
"theme.mode.system": "System",
|
||||||
"theme.mode.light": "Light",
|
"theme.mode.light": "Light",
|
||||||
"theme.mode.dark": "Dark",
|
"theme.mode.dark": "Dark",
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ export const appMessages = {
|
|||||||
"releases.upgradeRequired.message.withVersion": "Actualiza a CodeNomad {version} para usar la UI más reciente.",
|
"releases.upgradeRequired.message.withVersion": "Actualiza a CodeNomad {version} para usar la UI más reciente.",
|
||||||
"releases.upgradeRequired.message.noVersion": "Actualiza CodeNomad para usar la UI más reciente.",
|
"releases.upgradeRequired.message.noVersion": "Actualiza CodeNomad para usar la UI más reciente.",
|
||||||
"releases.upgradeRequired.action.getUpdate": "Obtener actualización",
|
"releases.upgradeRequired.action.getUpdate": "Obtener actualización",
|
||||||
|
|
||||||
|
"releases.uiUpdated.title": "UI actualizada",
|
||||||
|
"releases.uiUpdated.message": "La UI ahora está actualizada a {version}.",
|
||||||
|
|
||||||
|
"releases.devUpdateAvailable.title": "Compilación dev disponible",
|
||||||
|
"releases.devUpdateAvailable.message": "Hay una nueva compilación dev disponible: {version}.",
|
||||||
|
"releases.devUpdateAvailable.action": "Ver release",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ export const appMessages = {
|
|||||||
"releases.upgradeRequired.message.withVersion": "Mettez à jour vers CodeNomad {version} pour utiliser la dernière UI.",
|
"releases.upgradeRequired.message.withVersion": "Mettez à jour vers CodeNomad {version} pour utiliser la dernière UI.",
|
||||||
"releases.upgradeRequired.message.noVersion": "Mettez à jour CodeNomad pour utiliser la dernière UI.",
|
"releases.upgradeRequired.message.noVersion": "Mettez à jour CodeNomad pour utiliser la dernière UI.",
|
||||||
"releases.upgradeRequired.action.getUpdate": "Obtenir la mise à jour",
|
"releases.upgradeRequired.action.getUpdate": "Obtenir la mise à jour",
|
||||||
|
|
||||||
|
"releases.uiUpdated.title": "UI mise à jour",
|
||||||
|
"releases.uiUpdated.message": "L'UI est maintenant mise à jour vers {version}.",
|
||||||
|
|
||||||
|
"releases.devUpdateAvailable.title": "Build dev disponible",
|
||||||
|
"releases.devUpdateAvailable.message": "Un nouveau build dev est disponible : {version}.",
|
||||||
|
"releases.devUpdateAvailable.action": "Voir la release",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ export const appMessages = {
|
|||||||
"releases.upgradeRequired.message.withVersion": "最新の UI を使うには CodeNomad {version} に更新してください。",
|
"releases.upgradeRequired.message.withVersion": "最新の UI を使うには CodeNomad {version} に更新してください。",
|
||||||
"releases.upgradeRequired.message.noVersion": "最新の UI を使うには CodeNomad を更新してください。",
|
"releases.upgradeRequired.message.noVersion": "最新の UI を使うには CodeNomad を更新してください。",
|
||||||
"releases.upgradeRequired.action.getUpdate": "更新を取得",
|
"releases.upgradeRequired.action.getUpdate": "更新を取得",
|
||||||
|
|
||||||
|
"releases.uiUpdated.title": "UI を更新しました",
|
||||||
|
"releases.uiUpdated.message": "UI が {version} に更新されました。",
|
||||||
|
|
||||||
|
"releases.devUpdateAvailable.title": "開発版が利用可能",
|
||||||
|
"releases.devUpdateAvailable.message": "新しい開発版が利用可能です: {version}。",
|
||||||
|
"releases.devUpdateAvailable.action": "リリースを見る",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ export const appMessages = {
|
|||||||
"releases.upgradeRequired.message.withVersion": "Обновите CodeNomad до версии {version}, чтобы использовать последний UI.",
|
"releases.upgradeRequired.message.withVersion": "Обновите CodeNomad до версии {version}, чтобы использовать последний UI.",
|
||||||
"releases.upgradeRequired.message.noVersion": "Обновите CodeNomad, чтобы использовать последний UI.",
|
"releases.upgradeRequired.message.noVersion": "Обновите CodeNomad, чтобы использовать последний UI.",
|
||||||
"releases.upgradeRequired.action.getUpdate": "Получить обновление",
|
"releases.upgradeRequired.action.getUpdate": "Получить обновление",
|
||||||
|
|
||||||
|
"releases.uiUpdated.title": "UI обновлён",
|
||||||
|
"releases.uiUpdated.message": "UI теперь обновлён до {version}.",
|
||||||
|
|
||||||
|
"releases.devUpdateAvailable.title": "Доступна dev-сборка",
|
||||||
|
"releases.devUpdateAvailable.message": "Доступна новая dev-сборка: {version}.",
|
||||||
|
"releases.devUpdateAvailable.action": "Открыть релиз",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ export const appMessages = {
|
|||||||
"releases.upgradeRequired.message.withVersion": "更新到 CodeNomad {version} 以使用最新的 UI。",
|
"releases.upgradeRequired.message.withVersion": "更新到 CodeNomad {version} 以使用最新的 UI。",
|
||||||
"releases.upgradeRequired.message.noVersion": "更新 CodeNomad 以使用最新的 UI。",
|
"releases.upgradeRequired.message.noVersion": "更新 CodeNomad 以使用最新的 UI。",
|
||||||
"releases.upgradeRequired.action.getUpdate": "获取更新",
|
"releases.upgradeRequired.action.getUpdate": "获取更新",
|
||||||
|
|
||||||
|
"releases.uiUpdated.title": "UI 已更新",
|
||||||
|
"releases.uiUpdated.message": "UI 已更新到 {version}。",
|
||||||
|
|
||||||
|
"releases.devUpdateAvailable.title": "可用的开发版",
|
||||||
|
"releases.devUpdateAvailable.message": "有新的开发版可用:{version}。",
|
||||||
|
"releases.devUpdateAvailable.action": "查看发布",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ const log = getLogger("actions")
|
|||||||
const [supportInfo, setSupportInfo] = createSignal<SupportMeta | null>(null)
|
const [supportInfo, setSupportInfo] = createSignal<SupportMeta | null>(null)
|
||||||
|
|
||||||
const UI_VERSION_STORAGE_KEY = "codenomad:lastSeenUiVersion"
|
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 initialized = false
|
||||||
let visibilityEffectInitialized = false
|
let visibilityEffectInitialized = false
|
||||||
let activeToast: ToastHandle | null = null
|
let activeToast: ToastHandle | null = null
|
||||||
let activeToastKey: string | null = null
|
let activeToastKey: string | null = null
|
||||||
let uiUpdateToasted = false
|
let uiUpdateToasted = false
|
||||||
|
let metaRefreshInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
function dismissActiveToast() {
|
function dismissActiveToast() {
|
||||||
if (activeToast) {
|
if (activeToast) {
|
||||||
@@ -80,6 +83,8 @@ async function refreshFromMeta() {
|
|||||||
const meta = await getServerMeta(true)
|
const meta = await getServerMeta(true)
|
||||||
setSupportInfo(meta.support ?? null)
|
setSupportInfo(meta.support ?? null)
|
||||||
maybeNotifyUiUpdated(meta)
|
maybeNotifyUiUpdated(meta)
|
||||||
|
maybeNotifyDevReleaseAvailable(meta)
|
||||||
|
ensureMetaRefresh(meta)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn("Unable to load server metadata for support info", 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 {
|
function safeReadLocalStorage(key: string): string | null {
|
||||||
try {
|
try {
|
||||||
if (typeof window === "undefined" || !window.localStorage) return null
|
if (typeof window === "undefined" || !window.localStorage) return null
|
||||||
|
|||||||
Reference in New Issue
Block a user