From 1b45571e63da39bd872532153f11c887ca9341ec Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 12 Apr 2026 16:24:20 +0200 Subject: [PATCH 1/7] feat: prerelease workflow with major version support and version propagation - Add docker-dev.yml: prerelease CI for dev branch with minor/major bump inputs; auto-continues in-flight major line via existing pre tags; publishes floating major-pre Docker tag (e.g. 2-pre) - Rewrite docker.yml version-bump: tag-based versioning, manual bump inputs (auto/patch/minor/major), major guarded by confirm_major=MAJOR, auto-finalizes in-flight prereleases; publishes floating major tag (e.g. 2) - Inject APP_VERSION build-arg through Dockerfile so the running container knows its real version instead of reading package.json - Server reads APP_VERSION env in authService/adminService; exposes is_prerelease in app config and update-check response; prerelease builds compare against GitHub prerelease releases rather than latest stable - Client stores isPrerelease from config; navbar shows amber version badge on prerelease builds (left of dark-mode toggle); GitHubPanel filters out prerelease releases unless the running build is itself a prerelease Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/docker-dev.yml | 160 ++++++++++++++++++++ .github/workflows/docker.yml | 79 +++++++--- Dockerfile | 2 + client/src/App.tsx | 5 +- client/src/components/Admin/GitHubPanel.tsx | 4 +- client/src/components/Layout/Navbar.tsx | 13 +- client/src/pages/AdminPage.tsx | 3 +- client/src/store/authStore.ts | 4 + server/src/services/adminService.ts | 44 ++++-- server/src/services/authService.ts | 3 +- 10 files changed, 278 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/docker-dev.yml diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml new file mode 100644 index 00000000..91800bec --- /dev/null +++ b/.github/workflows/docker-dev.yml @@ -0,0 +1,160 @@ +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 + +jobs: + version-bump: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.bump.outputs.VERSION }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine prerelease version and tag + id: bump + run: | + git fetch --tags + + # 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}" + 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" + + # Count existing prerelease tags for this target and increment + N=$(git tag -l "v${TARGET}-pre.*" | wc -l) + N=$((N + 1)) + + NEW_VERSION="${TARGET}-pre.${N}" + echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "$STABLE → $NEW_VERSION" + + # Tag only — no file changes, no commit + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "v$NEW_VERSION" + git push origin "v$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: dev + + - 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: dev + + - 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 \ + -t mauriceboe/nomad:latest-pre \ + -t mauriceboe/nomad:$MAJOR_TAG \ + -t mauriceboe/nomad:$VERSION \ + "${digests[@]}" + + - name: Inspect manifest + run: docker buildx imagetools inspect mauriceboe/trek:latest-pre diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9cc0e762..a4b5ea31 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -7,6 +7,16 @@ 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 @@ -20,39 +30,61 @@ 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}" + + 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.*}" + # 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 +134,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: | @@ -144,10 +178,13 @@ jobs: run: | 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:$MAJOR_TAG \ -t mauriceboe/trek:$VERSION \ -t mauriceboe/nomad:latest \ + -t mauriceboe/nomad:$MAJOR_TAG \ -t mauriceboe/nomad:$VERSION \ "${digests[@]}" diff --git a/Dockerfile b/Dockerfile index 1dd5707b..44c3d531 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/client/src/App.tsx b/client/src/App.tsx index 5f95965f..a45351f1 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -88,16 +88,17 @@ function RootRedirect() { } export default function App() { - const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, 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 }) => { + 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 }) => { if (config?.demo_mode) setDemoMode(true) if (config?.dev_mode) setDevMode(true) + if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease) 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) diff --git a/client/src/components/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx index ad76c2a0..2c8d5d07 100644 --- a/client/src/components/Admin/GitHubPanel.tsx +++ b/client/src/components/Admin/GitHubPanel.tsx @@ -6,7 +6,7 @@ import apiClient from '../../api/client' const REPO = 'mauriceboe/TREK' const PER_PAGE = 10 -export default function GitHubPanel() { +export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) { const { t, language } = useTranslation() const [releases, setReleases] = useState([]) const [loading, setLoading] = useState(true) @@ -273,7 +273,7 @@ export default function GitHubPanel() {
- {releases.map((release, idx) => { + {(isPrerelease ? releases : releases.filter((r: any) => !r.prerelease)).map((release, idx) => { const isLatest = idx === 0 const isExpanded = expanded[release.id] diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index 9fc11f17..b4fb5bd4 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -27,7 +27,7 @@ interface Addon { } export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement { - const { user, logout } = useAuthStore() + const { user, logout, isPrerelease } = useAuthStore() const { settings, updateSetting } = useSettingsStore() const { addons: allAddons, loadAddons } = useAddonStore() const { t, locale } = useTranslation() @@ -155,6 +155,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: )} + {/* Prerelease badge */} + {isPrerelease && appVersion && ( + + + {appVersion} + + )} + {/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index d7a11de1..291677e7 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -22,6 +22,7 @@ interface AuthState { error: string | null demoMode: boolean devMode: boolean + isPrerelease: boolean hasMapsKey: boolean serverTimezone: string /** Server policy: all users must enable MFA */ @@ -41,6 +42,7 @@ interface AuthState { deleteAvatar: () => Promise setDemoMode: (val: boolean) => void setDevMode: (val: boolean) => void + setIsPrerelease: (val: boolean) => void setHasMapsKey: (val: boolean) => void setServerTimezone: (tz: string) => void setAppRequireMfa: (val: boolean) => void @@ -58,6 +60,7 @@ export const useAuthStore = create((set, get) => ({ error: null, demoMode: localStorage.getItem('demo_mode') === 'true', devMode: false, + isPrerelease: false, hasMapsKey: false, serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, appRequireMfa: false, @@ -222,6 +225,7 @@ export const useAuthStore = create((set, get) => ({ }, setDevMode: (val: boolean) => set({ devMode: val }), + setIsPrerelease: (val: boolean) => set({ isPrerelease: val }), setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }), setServerTimezone: (tz: string) => set({ serverTimezone: tz }), setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }), diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index 41a595c9..3a947a59 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -304,19 +304,41 @@ export async function getGithubReleases(perPage: string = '10', page: string = ' } export async function checkVersion() { - const { version: currentVersion } = require('../../package.json'); + const currentVersion: string = process.env.APP_VERSION ?? require('../../package.json').version; + const isPrerelease = currentVersion.includes('-pre.'); + const fallback = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease }; 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 }; + if (isPrerelease) { + // Fetch release list and find the newest prerelease + const resp = await fetch( + 'https://api.github.com/repos/mauriceboe/TREK/releases?per_page=20', + { 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; + // Sort by version descending and pick highest + const sorted = prereleases.sort((a, b) => compareVersions( + (b.tag_name || '').replace(/^v/, ''), + (a.tag_name || '').replace(/^v/, '') + )); + const latest = (sorted[0].tag_name || '').replace(/^v/, ''); + const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0; + return { current: currentVersion, latest, update_available, release_url: sorted[0].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; + return { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false }; + } } catch { - return { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker }; + return fallback; } } diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 4a05c8ff..5d475306 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -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, From 981b667fbb68e9f4d0a6e9d91af99b1d31c55439 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 12 Apr 2026 16:24:20 +0200 Subject: [PATCH 2/7] feat: prerelease workflow with major version support and version propagation - Add docker-dev.yml: prerelease CI for dev branch with minor/major bump inputs; auto-continues in-flight major line via existing pre tags; publishes floating major-pre Docker tag (e.g. 2-pre) - Rewrite docker.yml version-bump: tag-based versioning, manual bump inputs (auto/patch/minor/major), major guarded by confirm_major=MAJOR, auto-finalizes in-flight prereleases; publishes floating major tag (e.g. 2) - Inject APP_VERSION build-arg through Dockerfile so the running container knows its real version instead of reading package.json - Server reads APP_VERSION env in authService/adminService; exposes is_prerelease in app config and update-check response; prerelease builds compare against GitHub prerelease releases rather than latest stable - Client stores isPrerelease from config; navbar shows amber version badge on prerelease builds (left of dark-mode toggle); GitHubPanel filters out prerelease releases unless the running build is itself a prerelease --- .github/workflows/docker-dev.yml | 160 ++++++++++++++++++++ .github/workflows/docker.yml | 79 +++++++--- Dockerfile | 2 + client/src/App.tsx | 5 +- client/src/components/Admin/GitHubPanel.tsx | 4 +- client/src/components/Layout/Navbar.tsx | 13 +- client/src/pages/AdminPage.tsx | 3 +- client/src/store/authStore.ts | 4 + server/src/services/adminService.ts | 44 ++++-- server/src/services/authService.ts | 3 +- 10 files changed, 278 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/docker-dev.yml diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml new file mode 100644 index 00000000..91800bec --- /dev/null +++ b/.github/workflows/docker-dev.yml @@ -0,0 +1,160 @@ +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 + +jobs: + version-bump: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.bump.outputs.VERSION }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine prerelease version and tag + id: bump + run: | + git fetch --tags + + # 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}" + 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" + + # Count existing prerelease tags for this target and increment + N=$(git tag -l "v${TARGET}-pre.*" | wc -l) + N=$((N + 1)) + + NEW_VERSION="${TARGET}-pre.${N}" + echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "$STABLE → $NEW_VERSION" + + # Tag only — no file changes, no commit + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "v$NEW_VERSION" + git push origin "v$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: dev + + - 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: dev + + - 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 \ + -t mauriceboe/nomad:latest-pre \ + -t mauriceboe/nomad:$MAJOR_TAG \ + -t mauriceboe/nomad:$VERSION \ + "${digests[@]}" + + - name: Inspect manifest + run: docker buildx imagetools inspect mauriceboe/trek:latest-pre diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9cc0e762..a4b5ea31 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -7,6 +7,16 @@ 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 @@ -20,39 +30,61 @@ 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}" + + 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.*}" + # 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 +134,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: | @@ -144,10 +178,13 @@ jobs: run: | 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:$MAJOR_TAG \ -t mauriceboe/trek:$VERSION \ -t mauriceboe/nomad:latest \ + -t mauriceboe/nomad:$MAJOR_TAG \ -t mauriceboe/nomad:$VERSION \ "${digests[@]}" diff --git a/Dockerfile b/Dockerfile index 1dd5707b..44c3d531 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/client/src/App.tsx b/client/src/App.tsx index 5f95965f..a45351f1 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -88,16 +88,17 @@ function RootRedirect() { } export default function App() { - const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, 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 }) => { + 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 }) => { if (config?.demo_mode) setDemoMode(true) if (config?.dev_mode) setDevMode(true) + if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease) 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) diff --git a/client/src/components/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx index ad76c2a0..2c8d5d07 100644 --- a/client/src/components/Admin/GitHubPanel.tsx +++ b/client/src/components/Admin/GitHubPanel.tsx @@ -6,7 +6,7 @@ import apiClient from '../../api/client' const REPO = 'mauriceboe/TREK' const PER_PAGE = 10 -export default function GitHubPanel() { +export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) { const { t, language } = useTranslation() const [releases, setReleases] = useState([]) const [loading, setLoading] = useState(true) @@ -273,7 +273,7 @@ export default function GitHubPanel() {
- {releases.map((release, idx) => { + {(isPrerelease ? releases : releases.filter((r: any) => !r.prerelease)).map((release, idx) => { const isLatest = idx === 0 const isExpanded = expanded[release.id] diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index 9fc11f17..b4fb5bd4 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -27,7 +27,7 @@ interface Addon { } export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement { - const { user, logout } = useAuthStore() + const { user, logout, isPrerelease } = useAuthStore() const { settings, updateSetting } = useSettingsStore() const { addons: allAddons, loadAddons } = useAddonStore() const { t, locale } = useTranslation() @@ -155,6 +155,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: )} + {/* Prerelease badge */} + {isPrerelease && appVersion && ( + + + {appVersion} + + )} + {/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index d7a11de1..291677e7 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -22,6 +22,7 @@ interface AuthState { error: string | null demoMode: boolean devMode: boolean + isPrerelease: boolean hasMapsKey: boolean serverTimezone: string /** Server policy: all users must enable MFA */ @@ -41,6 +42,7 @@ interface AuthState { deleteAvatar: () => Promise setDemoMode: (val: boolean) => void setDevMode: (val: boolean) => void + setIsPrerelease: (val: boolean) => void setHasMapsKey: (val: boolean) => void setServerTimezone: (tz: string) => void setAppRequireMfa: (val: boolean) => void @@ -58,6 +60,7 @@ export const useAuthStore = create((set, get) => ({ error: null, demoMode: localStorage.getItem('demo_mode') === 'true', devMode: false, + isPrerelease: false, hasMapsKey: false, serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, appRequireMfa: false, @@ -222,6 +225,7 @@ export const useAuthStore = create((set, get) => ({ }, setDevMode: (val: boolean) => set({ devMode: val }), + setIsPrerelease: (val: boolean) => set({ isPrerelease: val }), setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }), setServerTimezone: (tz: string) => set({ serverTimezone: tz }), setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }), diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index 41a595c9..3a947a59 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -304,19 +304,41 @@ export async function getGithubReleases(perPage: string = '10', page: string = ' } export async function checkVersion() { - const { version: currentVersion } = require('../../package.json'); + const currentVersion: string = process.env.APP_VERSION ?? require('../../package.json').version; + const isPrerelease = currentVersion.includes('-pre.'); + const fallback = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease }; 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 }; + if (isPrerelease) { + // Fetch release list and find the newest prerelease + const resp = await fetch( + 'https://api.github.com/repos/mauriceboe/TREK/releases?per_page=20', + { 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; + // Sort by version descending and pick highest + const sorted = prereleases.sort((a, b) => compareVersions( + (b.tag_name || '').replace(/^v/, ''), + (a.tag_name || '').replace(/^v/, '') + )); + const latest = (sorted[0].tag_name || '').replace(/^v/, ''); + const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0; + return { current: currentVersion, latest, update_available, release_url: sorted[0].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; + return { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false }; + } } catch { - return { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker }; + return fallback; } } diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 4a05c8ff..5d475306 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -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, From e1987911392f833b9e2c6b527d733e751e8a2170 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 12 Apr 2026 16:39:42 +0200 Subject: [PATCH 3/7] fix: address prerelease workflow review issues - Remove stale mauriceboe/nomad tags from docker-dev.yml - Fix APP_VERSION empty string fallback (?? -> ||) - Fix compareVersions to handle -pre.N suffixes correctly - Use highest existing N instead of tag count to avoid collision after cleanup - Add cleanup step to keep only last 5 prerelease tags per base version --- .github/workflows/docker-dev.yml | 27 +++++++++++++++++++++------ server/src/services/adminService.ts | 22 +++++++++++++++++----- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml index 91800bec..88a2de16 100644 --- a/.github/workflows/docker-dev.yml +++ b/.github/workflows/docker-dev.yml @@ -54,9 +54,9 @@ jobs: fi echo "Target: $TARGET" - # Count existing prerelease tags for this target and increment - N=$(git tag -l "v${TARGET}-pre.*" | wc -l) - N=$((N + 1)) + # 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 @@ -151,10 +151,25 @@ jobs: -t mauriceboe/trek:latest-pre \ -t mauriceboe/trek:$MAJOR_TAG \ -t mauriceboe/trek:$VERSION \ - -t mauriceboe/nomad:latest-pre \ - -t mauriceboe/nomad:$MAJOR_TAG \ - -t mauriceboe/nomad:$VERSION \ "${digests[@]}" - name: Inspect manifest run: docker buildx imagetools inspect mauriceboe/trek:latest-pre + + - 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 + mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | sort -t. -k4 -n) + 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 diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index 3a947a59..d96289f8 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -21,13 +21,25 @@ 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 preN = pre !== undefined ? parseInt(pre, 10) : 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; } @@ -304,7 +316,7 @@ export async function getGithubReleases(perPage: string = '10', page: string = ' } export async function checkVersion() { - const currentVersion: string = process.env.APP_VERSION ?? require('../../package.json').version; + const currentVersion: string = process.env.APP_VERSION || require('../../package.json').version; const isPrerelease = currentVersion.includes('-pre.'); const fallback = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease }; try { From 62453ebefaee5e7edde5c8771396fe96febb68a2 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 12 Apr 2026 16:50:20 +0200 Subject: [PATCH 4/7] fix: harden prerelease workflow against races, orphan tags, and edge cases - Add concurrency groups to both workflows to prevent parallel version-bump races - Defer git tag push to merge job so orphan tags can't exist without a live image - Pin build/merge jobs to the SHA captured in version-bump to prevent TOCTOU - Guard auto-finalize in docker.yml against cross-major prereleases (requires bump=major + confirm_major=MAJOR) - Add STABLE fallback to 0.0.0 for fresh repos with no stable tag - Fix cleanup sort to extract numeric N via awk instead of fragile sort -t. -k4 -n - Add 5-minute in-memory cache to checkVersion to avoid GitHub API rate limits - Type GitHubPanel releases state; remove any cast on filter - Quote all $VERSION/$MAJOR_TAG vars in imagetools create calls --- .github/workflows/docker-dev.yml | 45 +++++++++++------ .github/workflows/docker.yml | 25 ++++++--- client/src/components/Admin/GitHubPanel.tsx | 10 +++- server/src/services/adminService.ts | 56 ++++++++++++++------- 4 files changed, 93 insertions(+), 43 deletions(-) diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml index 88a2de16..50970cab 100644 --- a/.github/workflows/docker-dev.yml +++ b/.github/workflows/docker-dev.yml @@ -17,26 +17,34 @@ on: 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 - fetch-tags: true token: ${{ secrets.GITHUB_TOKEN }} - - name: Determine prerelease version and tag + - 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" @@ -62,12 +70,6 @@ jobs: echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT echo "$STABLE → $NEW_VERSION" - # Tag only — no file changes, no commit - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag "v$NEW_VERSION" - git push origin "v$NEW_VERSION" - build: runs-on: ${{ matrix.runner }} needs: version-bump @@ -85,7 +87,7 @@ jobs: - uses: actions/checkout@v4 with: - ref: dev + ref: ${{ needs.version-bump.outputs.sha }} - uses: docker/setup-buildx-action@v3 @@ -125,7 +127,9 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: dev + ref: ${{ needs.version-bump.outputs.sha }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - name: Download build digests uses: actions/download-artifact@v4 @@ -144,27 +148,36 @@ 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)-pre" docker buildx imagetools create \ - -t mauriceboe/trek:latest-pre \ - -t mauriceboe/trek:$MAJOR_TAG \ - -t mauriceboe/trek:$VERSION \ + -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 }} + VERSION="${{ needs.version-bump.outputs.version }}" BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')" git fetch --tags - mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | sort -t. -k4 -n) + # 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 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a4b5ea31..e1880116 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -21,6 +21,10 @@ on: permissions: contents: write +concurrency: + group: stable-build + cancel-in-progress: false + jobs: version-bump: runs-on: ubuntu-latest @@ -41,6 +45,7 @@ jobs: # 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) @@ -65,6 +70,12 @@ jobs: 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 @@ -176,16 +187,16 @@ 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:$MAJOR_TAG \ - -t mauriceboe/trek:$VERSION \ - -t mauriceboe/nomad:latest \ - -t mauriceboe/nomad:$MAJOR_TAG \ - -t mauriceboe/nomad:$VERSION \ + -t "mauriceboe/trek:latest" \ + -t "mauriceboe/trek:$MAJOR_TAG" \ + -t "mauriceboe/trek:$VERSION" \ + -t "mauriceboe/nomad:latest" \ + -t "mauriceboe/nomad:$MAJOR_TAG" \ + -t "mauriceboe/nomad:$VERSION" \ "${digests[@]}" - name: Inspect manifest diff --git a/client/src/components/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx index 2c8d5d07..e4b9a578 100644 --- a/client/src/components/Admin/GitHubPanel.tsx +++ b/client/src/components/Admin/GitHubPanel.tsx @@ -6,9 +6,15 @@ import apiClient from '../../api/client' const REPO = 'mauriceboe/TREK' const PER_PAGE = 10 +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([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [expanded, setExpanded] = useState({}) @@ -273,7 +279,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
- {(isPrerelease ? releases : releases.filter((r: any) => !r.prerelease)).map((release, idx) => { + {(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => { const isLatest = idx === 0 const isExpanded = expanded[release.id] diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index d96289f8..e31ab346 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -315,10 +315,18 @@ export async function getGithubReleases(perPage: string = '10', page: string = ' } } +const VERSION_CACHE_TTL = 5 * 60 * 1000; // 5 minutes +let _versionCache: { data: object; expiresAt: number } | null = null; + export async function checkVersion() { + 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 = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease }; + let result: object = fallback; try { if (isPrerelease) { // Fetch release list and find the newest prerelease @@ -326,32 +334,44 @@ export async function checkVersion() { 'https://api.github.com/repos/mauriceboe/TREK/releases?per_page=20', { 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; - // Sort by version descending and pick highest - const sorted = prereleases.sort((a, b) => compareVersions( - (b.tag_name || '').replace(/^v/, ''), - (a.tag_name || '').replace(/^v/, '') - )); - const latest = (sorted[0].tag_name || '').replace(/^v/, ''); - const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0; - return { current: currentVersion, latest, update_available, release_url: sorted[0].html_url || '', is_docker: isDocker, is_prerelease: true }; + if (!resp.ok) { + result = fallback; + } else { + 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) { + result = fallback; + } else { + // Sort by version descending and pick highest + const sorted = prereleases.sort((a, b) => compareVersions( + (b.tag_name || '').replace(/^v/, ''), + (a.tag_name || '').replace(/^v/, '') + )); + const latest = (sorted[0].tag_name || '').replace(/^v/, ''); + const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0; + result = { current: currentVersion, latest, update_available, release_url: sorted[0].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; - return { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false }; + if (!resp.ok) { + result = fallback; + } else { + 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; + result = fallback; } + + _versionCache = { data: result, expiresAt: Date.now() + VERSION_CACHE_TTL }; + return result; } export async function checkAndNotifyVersion(): Promise { From a2c05f3caa6fdb0b48c32bd8973e1f88c1866116 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 12 Apr 2026 16:51:33 +0200 Subject: [PATCH 5/7] fix: Remove nomad references in stable build workflow --- .github/workflows/docker.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e1880116..a8bbd3fa 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -194,9 +194,6 @@ jobs: -t "mauriceboe/trek:latest" \ -t "mauriceboe/trek:$MAJOR_TAG" \ -t "mauriceboe/trek:$VERSION" \ - -t "mauriceboe/nomad:latest" \ - -t "mauriceboe/nomad:$MAJOR_TAG" \ - -t "mauriceboe/nomad:$VERSION" \ "${digests[@]}" - name: Inspect manifest From 86be4d79975d087184334338841a1a06b946be05 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 12 Apr 2026 17:05:17 +0200 Subject: [PATCH 6/7] fix: address prerelease workflow review bugs - Type checkVersion() with VersionInfo interface; fixes TS errors in checkAndNotifyVersion() where object type blocked property access - Don't cache fallback on !resp.ok or fetch throw; prevents a transient GitHub outage from poisoning the 5-min version cache - Guard parseInt result with Number.isFinite() in compareVersions; malformed -pre.abc tags no longer silently compare as equal via NaN - Pre-compute stripped versions before sort in checkVersion(); avoids mutating input array and redundant replace() calls in comparator - Bump GitHub releases fetch from per_page=20 to per_page=100 - Store appVersion in authStore; populate from App.tsx getAppConfig call and remove redundant getAppConfig fetch in Navbar useEffect - Type GitHubPanel error/expanded state as string|null and Record --- client/src/App.tsx | 3 +- client/src/components/Admin/GitHubPanel.tsx | 4 +- client/src/components/Layout/Navbar.tsx | 9 +-- client/src/store/authStore.ts | 4 ++ server/src/services/adminService.ts | 67 +++++++++++---------- 5 files changed, 45 insertions(+), 42 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index a45351f1..4179c1a9 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -88,7 +88,7 @@ function RootRedirect() { } export default function App() { - const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() useEffect(() => { @@ -99,6 +99,7 @@ export default function App() { 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) diff --git a/client/src/components/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx index e4b9a578..02008da2 100644 --- a/client/src/components/Admin/GitHubPanel.tsx +++ b/client/src/components/Admin/GitHubPanel.tsx @@ -16,8 +16,8 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b const { t, language } = useTranslation() const [releases, setReleases] = useState([]) const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [expanded, setExpanded] = useState({}) + const [error, setError] = useState(null) + const [expanded, setExpanded] = useState>({}) const [page, setPage] = useState(1) const [hasMore, setHasMore] = useState(true) const [loadingMore, setLoadingMore] = useState(false) diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index b4fb5bd4..a6bd7a92 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -27,14 +27,13 @@ interface Addon { } export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement { - const { user, logout, isPrerelease } = 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(false) - const [appVersion, setAppVersion] = useState(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 } }) diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 291677e7..f677889b 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -23,6 +23,7 @@ interface AuthState { demoMode: boolean devMode: boolean isPrerelease: boolean + appVersion: string hasMapsKey: boolean serverTimezone: string /** Server policy: all users must enable MFA */ @@ -43,6 +44,7 @@ interface AuthState { 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 @@ -61,6 +63,7 @@ export const useAuthStore = create((set, get) => ({ demoMode: localStorage.getItem('demo_mode') === 'true', devMode: false, isPrerelease: false, + appVersion: '', hasMapsKey: false, serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, appRequireMfa: false, @@ -226,6 +229,7 @@ export const useAuthStore = create((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 }), diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index e31ab346..3a5a9321 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -24,7 +24,8 @@ export function compareVersions(a: string, b: string): number { const parse = (v: string) => { const [base, pre] = v.split('-pre.'); const parts = base.split('.').map(Number); - const preN = pre !== undefined ? parseInt(pre, 10) : null; + 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); @@ -315,59 +316,63 @@ export async function getGithubReleases(perPage: string = '10', page: string = ' } } -const VERSION_CACHE_TTL = 5 * 60 * 1000; // 5 minutes -let _versionCache: { data: object; expiresAt: number } | null = null; +interface VersionInfo { + current: string; + latest: string; + update_available: boolean; + release_url?: string; + is_docker: boolean; + is_prerelease: boolean; +} -export async function checkVersion() { +const VERSION_CACHE_TTL = 5 * 60 * 1000; // 5 minutes +let _versionCache: { data: VersionInfo; expiresAt: number } | null = null; + +export async function checkVersion(): Promise { 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 = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease }; - let result: object = fallback; + 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=20', + '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) { - result = fallback; - } else { - 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) { - result = fallback; - } else { - // Sort by version descending and pick highest - const sorted = prereleases.sort((a, b) => compareVersions( - (b.tag_name || '').replace(/^v/, ''), - (a.tag_name || '').replace(/^v/, '') - )); - const latest = (sorted[0].tag_name || '').replace(/^v/, ''); - const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0; - result = { current: currentVersion, latest, update_available, release_url: sorted[0].html_url || '', is_docker: isDocker, is_prerelease: true }; - } + 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) { - result = fallback; - } else { - 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 }; + 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 { - result = fallback; + return fallback; } _versionCache = { data: result, expiresAt: Date.now() + VERSION_CACHE_TTL }; @@ -389,7 +394,7 @@ export async function checkAndNotifyVersion(): Promise { actorId: null, scope: 'admin', targetId: 0, - params: { version: result.latest as string }, + params: { version: result.latest }, }); } catch { // Silently ignore — version check is non-critical From ad27c5f6be8f02d7ced1e2dcaac6528f5dfd2850 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 12 Apr 2026 17:19:24 +0200 Subject: [PATCH 7/7] fix: restore broken tests after prerelease workflow refactor - Export __clearVersionCacheForTests() from adminService; call in versionNotification beforeEach to reset module-scoped cache between tests (VNOTIF-002..006 failed because VNOTIF-001 cached update_available:false, short-circuiting all subsequent test fetches) - Seed appVersion:'2.9.10' in Navbar test authStore; appVersion moved from local useEffect state to authStore in last commit so the test render no longer fetches it independently (FE-COMP-NAVBAR-016) - Add data-testid="weekend-days" to VacaySettings weekend-days container; use within() in tests to scope button count to that section, fixing false positives from the week-start buttons which share the same inline styles (FE-COMP-VACAYSETTINGS-003/004) - Pass isPrerelease={true} in GitHubPanel FE-ADMIN-GH-007; component filters out prerelease releases when isPrerelease=false so the badge was never rendered (pre-existing, unrelated to last commit) --- .../src/components/Admin/GitHubPanel.test.tsx | 2 +- client/src/components/Layout/Navbar.test.tsx | 2 +- .../components/Vacay/VacaySettings.test.tsx | 24 ++++--------------- client/src/components/Vacay/VacaySettings.tsx | 2 +- server/src/services/adminService.ts | 5 ++++ .../unit/services/versionNotification.test.ts | 3 ++- 6 files changed, 14 insertions(+), 24 deletions(-) diff --git a/client/src/components/Admin/GitHubPanel.test.tsx b/client/src/components/Admin/GitHubPanel.test.tsx index edf45fdf..617bdd88 100644 --- a/client/src/components/Admin/GitHubPanel.test.tsx +++ b/client/src/components/Admin/GitHubPanel.test.tsx @@ -133,7 +133,7 @@ describe('GitHubPanel', () => { server.use( http.get('/api/admin/github-releases', () => HttpResponse.json([r])), ); - render(); + render(); await screen.findByText('v3.0.0-beta.1'); expect(screen.getByText('Pre-release')).toBeInTheDocument(); }); diff --git a/client/src/components/Layout/Navbar.test.tsx b/client/src/components/Layout/Navbar.test.tsx index e76f70f0..b0f8df24 100644 --- a/client/src/components/Layout/Navbar.test.tsx +++ b/client/src/components/Layout/Navbar.test.tsx @@ -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() }); }); diff --git a/client/src/components/Vacay/VacaySettings.test.tsx b/client/src/components/Vacay/VacaySettings.test.tsx index c2f4a5cc..efcc310a 100644 --- a/client/src/components/Vacay/VacaySettings.test.tsx +++ b/client/src/components/Vacay/VacaySettings.test.tsx @@ -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() // 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() - // 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 () => { diff --git a/client/src/components/Vacay/VacaySettings.tsx b/client/src/components/Vacay/VacaySettings.tsx index 14efe606..124a3a1f 100644 --- a/client/src/components/Vacay/VacaySettings.tsx +++ b/client/src/components/Vacay/VacaySettings.tsx @@ -51,7 +51,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { {/* Weekend days selector */} {plan.block_weekends !== false && ( -
+

{t('vacay.weekendDays')}

{[ diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index 3a5a9321..eec43ecd 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -328,6 +328,11 @@ interface VersionInfo { 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 { if (_versionCache && Date.now() < _versionCache.expiresAt) { return _versionCache.data; diff --git a/server/tests/unit/services/versionNotification.test.ts b/server/tests/unit/services/versionNotification.test.ts index bbe1c15d..c43dba98 100644 --- a/server/tests/unit/services/versionNotification.test.ts +++ b/server/tests/unit/services/versionNotification.test.ts @@ -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(); });