Merge pull request #591 from mauriceboe/feat/prerelease-workflow

Feat/prerelease workflow
This commit is contained in:
Julien G.
2026-04-12 17:24:19 +02:00
committed by GitHub
15 changed files with 392 additions and 86 deletions
+188
View File
@@ -0,0 +1,188 @@
name: Build & Push Docker Image (Prerelease)
on:
push:
branches: [dev]
paths-ignore:
- 'docs/**'
- '**/*.md'
workflow_dispatch:
inputs:
bump:
description: 'Bump line for next prerelease (auto detects in-flight major)'
type: choice
options: [auto, minor, major]
default: auto
permissions:
contents: write
concurrency:
group: prerelease-build
cancel-in-progress: false
jobs:
version-bump:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.VERSION }}
sha: ${{ steps.bump.outputs.SHA }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine prerelease version
id: bump
run: |
git fetch --tags
# Capture the exact commit we're building so build/merge jobs are pinned to it
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
# Get latest stable tag (exclude prerelease tags)
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
echo "Latest stable: $STABLE"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
# Detect any in-flight major prerelease (v(MAJOR+1).0.0-pre.*). Stay on that line if found.
NEXT_MAJOR="$((MAJOR + 1)).0.0"
MAJOR_PRE_EXISTS=$(git tag -l "v${NEXT_MAJOR}-pre.*" | head -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
if [ "$BUMP_INPUT" = "major" ] || { [ "$BUMP_INPUT" = "auto" ] && [ -n "$MAJOR_PRE_EXISTS" ]; }; then
TARGET="$NEXT_MAJOR"
else
TARGET="${MAJOR}.$((MINOR + 1)).0"
fi
echo "Target: $TARGET"
# Find the highest existing prerelease N for this target and increment
LAST_N=$(git tag -l "v${TARGET}-pre.*" | sed 's/.*-pre\.//' | sort -n | tail -1)
N=$(( ${LAST_N:-0} + 1 ))
NEW_VERSION="${TARGET}-pre.${N}"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION"
build:
runs-on: ${{ matrix.runner }}
needs: version-bump
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Prepare platform tag-safe name
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
- uses: actions/checkout@v4
with:
ref: ${{ needs.version-bump.outputs.sha }}
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
no-cache: true
build-args: |
APP_VERSION=${{ needs.version-bump.outputs.version }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: [version-bump, build]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.version-bump.outputs.sha }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download build digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
docker buildx imagetools create \
-t "mauriceboe/trek:latest-pre" \
-t "mauriceboe/trek:$MAJOR_TAG" \
-t "mauriceboe/trek:$VERSION" \
"${digests[@]}"
- name: Inspect manifest
run: docker buildx imagetools inspect mauriceboe/trek:latest-pre
- name: Push git tag
run: |
VERSION="${{ needs.version-bump.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "v$VERSION"
git push origin "v$VERSION"
- name: Clean up old prerelease tags
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
KEEP=5
VERSION="${{ needs.version-bump.outputs.version }}"
BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')"
git fetch --tags
# Sort by numeric prerelease N (field after -pre.) to get correct ascending order
mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | awk -F'-pre\\.' '{print $2" "$0}' | sort -n | awk '{print $2}')
TOTAL=${#ALL_TAGS[@]}
DELETE_COUNT=$((TOTAL - KEEP))
if [ "$DELETE_COUNT" -gt 0 ]; then
for TAG in "${ALL_TAGS[@]:0:$DELETE_COUNT}"; do
echo "Deleting old prerelease tag: $TAG"
git push origin --delete "$TAG"
done
fi
+71 -26
View File
@@ -7,10 +7,24 @@ on:
- 'docs/**'
- '**/*.md'
workflow_dispatch:
inputs:
bump:
description: 'Force bump line (auto = patch/finalize as today)'
type: choice
options: [auto, patch, minor, major]
default: auto
confirm_major:
description: "Type MAJOR (all caps) to confirm a major release"
type: string
default: ''
permissions:
contents: write
concurrency:
group: stable-build
cancel-in-progress: false
jobs:
version-bump:
runs-on: ubuntu-latest
@@ -20,39 +34,68 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine bump type and update version
id: bump
run: |
# Check if this push is a merge commit from dev branch
COMMIT_MSG=$(git log -1 --pretty=%s)
PARENT_COUNT=$(git log -1 --pretty=%p | wc -w)
git fetch --tags
if echo "$COMMIT_MSG" | grep -qiE "^Merge (pull request|branch).*dev"; then
# Derive version from git tags — no package.json dependency
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
if [ "$BUMP_INPUT" = "major" ]; then
if [ "${{ github.event.inputs.confirm_major }}" != "MAJOR" ]; then
echo "::error::confirm_major must equal 'MAJOR' to cut a major release"
exit 1
fi
NEW_VERSION="$((MAJOR + 1)).0.0"
BUMP="major"
elif [ "$BUMP_INPUT" = "minor" ]; then
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
BUMP="minor"
elif [ "$PARENT_COUNT" -gt 1 ] && git log -1 --pretty=%P | xargs -n1 git branch -r --contains 2>/dev/null | grep -q "origin/dev"; then
BUMP="minor"
else
elif [ "$BUMP_INPUT" = "patch" ]; then
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
BUMP="patch"
else
# auto: finalize in-flight prerelease if one exists, else patch
if [ -n "$PRE_TAG" ]; then
PRE_BASE="${PRE_TAG#v}"
PRE_BASE="${PRE_BASE%-pre.*}"
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
# Refuse to auto-finalize a major bump — it bypasses confirm_major
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE → $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
exit 1
fi
# If prerelease base is strictly greater than stable, finalize it
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then
NEW_VERSION="$PRE_BASE"
BUMP="finalize"
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
fi
echo "Bump type: $BUMP"
# Read current version
CURRENT=$(node -p "require('./server/package.json').version")
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
if [ "$BUMP" = "minor" ]; then
MINOR=$((MINOR + 1))
PATCH=0
else
PATCH=$((PATCH + 1))
fi
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$CURRENT → $NEW_VERSION ($BUMP)"
echo "$STABLE → $NEW_VERSION ($BUMP)"
# Update package.json files and Helm chart
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
@@ -102,6 +145,8 @@ jobs:
platforms: ${{ matrix.platform }}
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
no-cache: true
build-args: |
APP_VERSION=${{ needs.version-bump.outputs.version }}
- name: Export digest
run: |
@@ -142,13 +187,13 @@ jobs:
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
VERSION=${{ needs.version-bump.outputs.version }}
VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
docker buildx imagetools create \
-t mauriceboe/trek:latest \
-t mauriceboe/trek:$VERSION \
-t mauriceboe/nomad:latest \
-t mauriceboe/nomad:$VERSION \
-t "mauriceboe/trek:latest" \
-t "mauriceboe/trek:$MAJOR_TAG" \
-t "mauriceboe/trek:$VERSION" \
"${digests[@]}"
- name: Inspect manifest
+2
View File
@@ -27,6 +27,8 @@ RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/
ENV NODE_ENV=production
ENV PORT=3000
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
EXPOSE 3000
+4 -2
View File
@@ -88,16 +88,18 @@ function RootRedirect() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
useEffect(() => {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
loadUser()
}
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
if (config?.demo_mode) setDemoMode(true)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
if (config?.version) setAppVersion(config.version)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
@@ -133,7 +133,7 @@ describe('GitHubPanel', () => {
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
render(<GitHubPanel />);
render(<GitHubPanel isPrerelease={true} />);
await screen.findByText('v3.0.0-beta.1');
expect(screen.getByText('Pre-release')).toBeInTheDocument();
});
+11 -5
View File
@@ -6,12 +6,18 @@ import apiClient from '../../api/client'
const REPO = 'mauriceboe/TREK'
const PER_PAGE = 10
export default function GitHubPanel() {
interface GithubRelease {
id: number
prerelease: boolean
[key: string]: unknown
}
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
const { t, language } = useTranslation()
const [releases, setReleases] = useState([])
const [releases, setReleases] = useState<GithubRelease[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [expanded, setExpanded] = useState({})
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
@@ -273,7 +279,7 @@ export default function GitHubPanel() {
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
<div className="space-y-0">
{releases.map((release, idx) => {
{(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
const isLatest = idx === 0
const isExpanded = expanded[release.id]
+1 -1
View File
@@ -16,7 +16,7 @@ beforeEach(() => {
http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })),
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
);
seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true });
seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true, appVersion: '2.9.10' });
seedStore(useSettingsStore, { settings: buildSettings() });
});
+12 -8
View File
@@ -27,14 +27,13 @@ interface Addon {
}
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
const { user, logout } = useAuthStore()
const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore()
const { t, locale } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
const [appVersion, setAppVersion] = useState<string | null>(null)
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
@@ -45,12 +44,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
if (user) loadAddons()
}, [user, location.pathname])
useEffect(() => {
import('../../api/client').then(({ authApi }) => {
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
})
}, [])
const handleLogout = () => {
logout()
navigate('/login', { state: { noRedirect: true } })
@@ -155,6 +148,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
</button>
)}
{/* Prerelease badge */}
{isPrerelease && appVersion && (
<span
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0"
style={{ background: 'rgba(245,158,11,0.15)', color: '#d97706', border: '1px solid rgba(245,158,11,0.3)' }}
>
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: '#f59e0b' }} />
{appVersion}
</span>
)}
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -75,17 +75,7 @@ describe('VacaySettings', () => {
render(<VacaySettings onClose={vi.fn()} />)
// Day buttons should be visible (Mon, Tue, Wed, Thu, Fri, Sat, Sun)
// They have text from translation keys; in test env they fallback to keys or English
// Check that 7 day-selector buttons exist (they are inside the paddingLeft:36 div)
const allButtons = screen.getAllByRole('button')
// The day buttons are not toggle buttons (no inline-flex/rounded-full class)
const dayButtons = allButtons.filter(b =>
!b.className.includes('inline-flex') &&
!b.className.includes('rounded-full') &&
!b.className.includes('rounded-md') &&
!b.className.includes('rounded-xl') &&
!b.className.includes('rounded-lg')
)
const dayButtons = within(screen.getByTestId('weekend-days')).getAllByRole('button')
// There should be 7 day buttons
expect(dayButtons.length).toBe(7)
})
@@ -98,14 +88,8 @@ describe('VacaySettings', () => {
})
render(<VacaySettings onClose={vi.fn()} />)
// When block_weekends is false, the day selector section is not rendered
// There should only be toggle buttons (4 toggles), no day buttons
const allButtons = screen.getAllByRole('button')
// None of the buttons should be day selectors (they have borderRadius:8 inline style)
const dayButtons = allButtons.filter(b =>
b.style.borderRadius === '8px' && b.style.padding === '4px 10px'
)
expect(dayButtons).toHaveLength(0)
// When block_weekends is false, the weekend-days container is not rendered
expect(screen.queryByTestId('weekend-days')).toBeNull()
})
it('FE-COMP-VACAYSETTINGS-005: clicking an active weekend day removes it', async () => {
@@ -51,7 +51,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
{/* Weekend days selector */}
{plan.block_weekends !== false && (
<div style={{ paddingLeft: 36 }}>
<div data-testid="weekend-days" style={{ paddingLeft: 36 }}>
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>{t('vacay.weekendDays')}</p>
<div className="flex flex-wrap gap-1.5">
{[
+2 -1
View File
@@ -55,6 +55,7 @@ interface UpdateInfo {
current: string
release_url?: string
is_docker?: boolean
is_prerelease?: boolean
}
const ADMIN_EVENT_LABEL_KEYS: Record<string, string> = {
@@ -1370,7 +1371,7 @@ export default function AdminPage(): React.ReactElement {
{activeTab === 'mcp-tokens' && <AdminMcpTokensPanel />}
{activeTab === 'github' && <GitHubPanel />}
{activeTab === 'github' && <GitHubPanel isPrerelease={updateInfo?.is_prerelease ?? false} />}
{activeTab === 'dev-notifications' && <DevNotificationsPanel />}
</div>
+8
View File
@@ -22,6 +22,8 @@ interface AuthState {
error: string | null
demoMode: boolean
devMode: boolean
isPrerelease: boolean
appVersion: string
hasMapsKey: boolean
serverTimezone: string
/** Server policy: all users must enable MFA */
@@ -41,6 +43,8 @@ interface AuthState {
deleteAvatar: () => Promise<void>
setDemoMode: (val: boolean) => void
setDevMode: (val: boolean) => void
setIsPrerelease: (val: boolean) => void
setAppVersion: (val: string) => void
setHasMapsKey: (val: boolean) => void
setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void
@@ -58,6 +62,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
error: null,
demoMode: localStorage.getItem('demo_mode') === 'true',
devMode: false,
isPrerelease: false,
appVersion: '',
hasMapsKey: false,
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
@@ -222,6 +228,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
setDevMode: (val: boolean) => set({ devMode: val }),
setIsPrerelease: (val: boolean) => set({ isPrerelease: val }),
setAppVersion: (val: string) => set({ appVersion: val }),
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
+83 -19
View File
@@ -21,13 +21,26 @@ export function utcSuffix(ts: string | null | undefined): string | null {
}
export function compareVersions(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = pa[i] || 0, nb = pb[i] || 0;
const parse = (v: string) => {
const [base, pre] = v.split('-pre.');
const parts = base.split('.').map(Number);
const n = pre !== undefined ? parseInt(pre, 10) : null;
const preN = n !== null && Number.isFinite(n) ? n : null;
return { parts, preN };
};
const pa = parse(a), pb = parse(b);
for (let i = 0; i < Math.max(pa.parts.length, pb.parts.length); i++) {
const na = pa.parts[i] || 0, nb = pb.parts[i] || 0;
if (na > nb) return 1;
if (na < nb) return -1;
}
// Equal base: stable > prerelease; higher preN wins among prereleases
if (pa.preN === null && pb.preN !== null) return 1;
if (pa.preN !== null && pb.preN === null) return -1;
if (pa.preN !== null && pb.preN !== null) {
if (pa.preN > pb.preN) return 1;
if (pa.preN < pb.preN) return -1;
}
return 0;
}
@@ -303,21 +316,72 @@ export async function getGithubReleases(perPage: string = '10', page: string = '
}
}
export async function checkVersion() {
const { version: currentVersion } = require('../../package.json');
try {
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/TREK/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
if (!resp.ok) return { current: currentVersion, latest: currentVersion, update_available: false };
const data = await resp.json() as { tag_name?: string; html_url?: string };
const latest = (data.tag_name || '').replace(/^v/, '');
const update_available = latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
return { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker };
} catch {
return { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker };
interface VersionInfo {
current: string;
latest: string;
update_available: boolean;
release_url?: string;
is_docker: boolean;
is_prerelease: boolean;
}
const VERSION_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
let _versionCache: { data: VersionInfo; expiresAt: number } | null = null;
/** Test-only: clear the in-memory version cache. */
export function __clearVersionCacheForTests(): void {
_versionCache = null;
}
export async function checkVersion(): Promise<VersionInfo> {
if (_versionCache && Date.now() < _versionCache.expiresAt) {
return _versionCache.data;
}
const currentVersion: string = process.env.APP_VERSION || require('../../package.json').version;
const isPrerelease = currentVersion.includes('-pre.');
const fallback: VersionInfo = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease };
let result: VersionInfo;
try {
if (isPrerelease) {
// Fetch release list and find the newest prerelease
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/TREK/releases?per_page=100',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
if (!resp.ok) {
return fallback;
}
const data = await resp.json() as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>;
const prereleases = Array.isArray(data) ? data.filter(r => r.prerelease) : [];
if (!prereleases.length) {
return fallback;
}
// Pre-compute stripped versions, then sort descending
const tagged = prereleases.map(r => ({ r, v: (r.tag_name || '').replace(/^v/, '') }));
tagged.sort((a, b) => compareVersions(b.v, a.v));
const latest = tagged[0].v;
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
result = { current: currentVersion, latest, update_available, release_url: tagged[0].r.html_url || '', is_docker: isDocker, is_prerelease: true };
} else {
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/TREK/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
if (!resp.ok) {
return fallback;
}
const data = await resp.json() as { tag_name?: string; html_url?: string };
const latest = (data.tag_name || '').replace(/^v/, '');
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
result = { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false };
}
} catch {
return fallback;
}
_versionCache = { data: result, expiresAt: Date.now() + VERSION_CACHE_TTL };
return result;
}
export async function checkAndNotifyVersion(): Promise<void> {
@@ -335,7 +399,7 @@ export async function checkAndNotifyVersion(): Promise<void> {
actorId: null,
scope: 'admin',
targetId: 0,
params: { version: result.latest as string },
params: { version: result.latest },
});
} catch {
// Silently ignore — version check is non-critical
+2 -1
View File
@@ -212,7 +212,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
const isDemo = process.env.DEMO_MODE === 'true';
const toggles = resolveAuthToggles();
const { version } = require('../../package.json');
const version: string = process.env.APP_VERSION ?? require('../../package.json').version;
const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
const oidcDisplayName = process.env.OIDC_DISPLAY_NAME ||
(db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get() as { value: string } | undefined)?.value || null;
@@ -244,6 +244,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
has_users: userCount > 0,
setup_complete: setupComplete,
version,
is_prerelease: version.includes('-pre.'),
has_maps_key: hasGoogleKey,
oidc_configured: oidcConfigured,
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
@@ -34,7 +34,7 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createAdmin } from '../../helpers/factories';
import { checkAndNotifyVersion } from '../../../src/services/adminService';
import { checkAndNotifyVersion, __clearVersionCacheForTests } from '../../../src/services/adminService';
// Helper: mock the GitHub releases/latest endpoint
function mockGitHubLatest(tagName: string, ok = true): void {
@@ -63,6 +63,7 @@ beforeAll(() => {
beforeEach(() => {
resetTestDb(testDb);
__clearVersionCacheForTests();
vi.unstubAllGlobals();
});