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,