diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml new file mode 100644 index 00000000..50970cab --- /dev/null +++ b/.github/workflows/docker-dev.yml @@ -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 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9cc0e762..a8bbd3fa 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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 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..4179c1a9 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 }) => { + 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?.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.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/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx index ad76c2a0..02008da2 100644 --- a/client/src/components/Admin/GitHubPanel.tsx +++ b/client/src/components/Admin/GitHubPanel.tsx @@ -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([]) 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) @@ -273,7 +279,7 @@ export default function GitHubPanel() {
- {releases.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/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/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index 9fc11f17..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 } = 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 } }) @@ -155,6 +148,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 */}