diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml index fed0f7ab5..be159b67b 100644 --- a/.github/workflows/container-image.yml +++ b/.github/workflows/container-image.yml @@ -82,6 +82,7 @@ jobs: packages: read outputs: source_sha: ${{ steps.ctx.outputs.source_sha }} + source_sha_short: ${{ steps.ctx.outputs.source_sha_short }} update_main: ${{ steps.ctx.outputs.update_main }} skip_build: ${{ steps.ctx.outputs.skip_build }} steps: @@ -108,14 +109,20 @@ jobs: SOURCE_SHA="$GH_SHA" echo "update_main=false" >> "$GITHUB_OUTPUT" fi + SOURCE_SHA_SHORT="${SOURCE_SHA:0:12}" echo "source_sha=${SOURCE_SHA}" >> "$GITHUB_OUTPUT" + echo "source_sha_short=${SOURCE_SHA_SHORT}" >> "$GITHUB_OUTPUT" IMAGE_LOWER=$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]') - HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + HTTP_STATUS_SHORT=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.oci.image.index.v1+json" \ + "https://ghcr.io/v2/${IMAGE_LOWER}/manifests/sha-${SOURCE_SHA_SHORT}") + HTTP_STATUS_FULL=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer ${GH_TOKEN}" \ -H "Accept: application/vnd.oci.image.index.v1+json" \ "https://ghcr.io/v2/${IMAGE_LOWER}/manifests/sha-${SOURCE_SHA}") - if [[ "$HTTP_STATUS" == "200" && "${FORCE_REBUILD}" != "true" ]]; then - echo "Image sha-${SOURCE_SHA} already exists in GHCR, skipping rebuild." + if [[ ("$HTTP_STATUS_SHORT" == "200" || "$HTTP_STATUS_FULL" == "200") && "${FORCE_REBUILD}" != "true" ]]; then + echo "Image sha-${SOURCE_SHA_SHORT} already exists in GHCR, skipping rebuild." echo "skip_build=true" >> "$GITHUB_OUTPUT" else echo "skip_build=false" >> "$GITHUB_OUTPUT" @@ -159,6 +166,7 @@ jobs: file: ./Dockerfile outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true platforms: linux/amd64 + build-args: APP_VERSION=sha-${{ needs.prepare.outputs.source_sha_short }} cache-from: type=gha,scope=container-image-yml-amd64 cache-to: type=gha,mode=max,scope=container-image-yml-amd64 @@ -212,6 +220,7 @@ jobs: file: ./Dockerfile outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true platforms: linux/arm64 + build-args: APP_VERSION=sha-${{ needs.prepare.outputs.source_sha_short }} cache-from: type=gha,scope=container-image-yml-arm64 cache-to: type=gha,mode=max,scope=container-image-yml-arm64 @@ -255,13 +264,13 @@ jobs: env: REGISTRY: ${{ env.REGISTRY }} IMAGE: ${{ env.IMAGE_NAME }} - SHA: ${{ needs.prepare.outputs.source_sha }} + SHA_SHORT: ${{ needs.prepare.outputs.source_sha_short }} AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }} ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }} run: | FULL_IMAGE="${REGISTRY}/${IMAGE}" docker buildx imagetools create \ - --tag "${FULL_IMAGE}:sha-${SHA}" \ + --tag "${FULL_IMAGE}:sha-${SHA_SHORT}" \ "${FULL_IMAGE}@${AMD64_DIGEST}" \ "${FULL_IMAGE}@${ARM64_DIGEST}" @@ -271,7 +280,6 @@ jobs: env: REGISTRY: ${{ env.REGISTRY }} IMAGE: ${{ env.IMAGE_NAME }} - SHA: ${{ needs.prepare.outputs.source_sha }} AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }} ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }} run: | @@ -286,5 +294,5 @@ jobs: env: REGISTRY: ${{ env.REGISTRY }} IMAGE: ${{ env.IMAGE_NAME }} - SHA: ${{ needs.prepare.outputs.source_sha }} - run: docker buildx imagetools inspect "${REGISTRY}/${IMAGE}:sha-${SHA}" + SHA_SHORT: ${{ needs.prepare.outputs.source_sha_short }} + run: docker buildx imagetools inspect "${REGISTRY}/${IMAGE}:sha-${SHA_SHORT}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d304bd55..c98a1993e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,12 +142,13 @@ jobs: IMAGE_NAME: ${{ github.repository }} run: | set -euo pipefail - SOURCE_TAG="ghcr.io/${IMAGE_NAME}:sha-${COMMIT_SHA}" + SHORT_SHA="${COMMIT_SHA:0:12}" + SOURCE_TAG="ghcr.io/${IMAGE_NAME}:sha-${SHORT_SHA}" if ! docker buildx imagetools inspect "${SOURCE_TAG}" > /dev/null 2>&1; then - echo "::error::Multi-arch image sha-${COMMIT_SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing." + echo "::error::Multi-arch image sha-${SHORT_SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing." exit 1 fi - echo "Image sha-${COMMIT_SHA} verified in GHCR." + echo "Image sha-${SHORT_SHA} verified in GHCR." - name: Set up Node.js and pnpm uses: ./.github/actions/setup-node-pnpm @@ -192,7 +193,7 @@ jobs: needs: [release] if: needs.release.outputs.published == 'true' runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 60 permissions: contents: write packages: write @@ -200,6 +201,14 @@ jobs: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} steps: + - name: Checkout release tag + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ needs.release.outputs.tag }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 @@ -210,46 +219,21 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Verify SHA image and resolve digest - id: source - shell: bash - env: - REGISTRY: ${{ env.REGISTRY }} - IMAGE: ${{ env.IMAGE_NAME }} - SHA: ${{ github.sha }} - run: | - FULL_IMAGE="${REGISTRY}/${IMAGE}" - SOURCE_TAG="${FULL_IMAGE}:sha-${SHA}" - if ! docker buildx imagetools inspect "${SOURCE_TAG}" > /dev/null 2>&1; then - echo "::error::Multi-arch image sha-${SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing." - exit 1 - fi - DIGEST=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "${SOURCE_TAG}" | jq -r '.digest') - echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" - echo "image=${FULL_IMAGE}" >> "$GITHUB_OUTPUT" - - - name: Promote to version and latest - shell: bash - env: - VERSION: ${{ needs.release.outputs.version }} - DIGEST: ${{ steps.source.outputs.digest }} - IMAGE: ${{ steps.source.outputs.image }} - run: | - docker buildx imagetools create \ - --tag "${IMAGE}:${VERSION}" \ - "${IMAGE}@${DIGEST}" - docker buildx imagetools create \ - --tag "${IMAGE}:latest" \ - "${IMAGE}@${DIGEST}" - - - name: Inspect promoted manifests - shell: bash - env: - VERSION: ${{ needs.release.outputs.version }} - IMAGE: ${{ steps.source.outputs.image }} - run: | - docker buildx imagetools inspect "${IMAGE}:${VERSION}" - docker buildx imagetools inspect "${IMAGE}:latest" + - name: Build and push versioned image + id: build + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release.outputs.version }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + build-args: APP_VERSION=v${{ needs.release.outputs.version }} + cache-from: | + type=gha,scope=container-image-yml-amd64 + type=gha,scope=container-image-yml-arm64 - name: Enrich release notes shell: bash @@ -259,8 +243,8 @@ jobs: TAG: ${{ needs.release.outputs.tag }} VERSION: ${{ needs.release.outputs.version }} SHA: ${{ github.sha }} - IMAGE: ${{ steps.source.outputs.image }} - DIGEST: ${{ steps.source.outputs.digest }} + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + DIGEST: ${{ steps.build.outputs.digest }} run: | set -euo pipefail BODY_FILE="$(mktemp)" @@ -300,7 +284,8 @@ jobs: VERSION: ${{ needs.release.outputs.version }} SHA: ${{ github.sha }} run: | - echo "## Docker Image Promoted" >> "$GITHUB_STEP_SUMMARY" + echo "## Docker Image Published" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" - echo "- **Source:** \`sha-${SHA}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- **Version:** \`${VERSION}\`" >> "$GITHUB_STEP_SUMMARY" echo "- **Tags:** \`${VERSION}\`, \`latest\` (linux/amd64, linux/arm64)" >> "$GITHUB_STEP_SUMMARY" + echo "- **APP_VERSION baked:** \`v${VERSION}\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile index 7b9755456..11641e60d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,9 @@ RUN mkdir -p /deploy/migrations && cp -r /app/server/src/db/migrations/. /deploy FROM ${NODE_IMAGE} AS runtime WORKDIR /app +ARG APP_VERSION=dev +ENV APP_VERSION=${APP_VERSION} + RUN apk upgrade --no-cache && \ apk add --no-cache poppler-utils su-exec ffmpeg && \ rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx diff --git a/client/src/components/AppSidebar.vue b/client/src/components/AppSidebar.vue index cc21d6f42..078d68a45 100644 --- a/client/src/components/AppSidebar.vue +++ b/client/src/components/AppSidebar.vue @@ -29,8 +29,9 @@ import CreateSmartScopeDialog from '@/features/smart-scope/components/CreateSmar import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue' import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue' import { useLibraryCreationRedirect } from '@/features/library/composables/useLibraryCreationRedirect' -import { useInstallPrompt } from '@/features/pwa/composables/useInstallPrompt' import { useThemeStore } from '@/stores/theme' +import { useAppInfo } from '@/features/settings/composables/useAppInfo' +import { buildSidebarVersionUi } from '@/components/sidebar/versionUi' function resolveIcon(name: string | null | undefined, fallback: Component): Component { if (name && name in Icons) return (Icons as Record)[name] as Component @@ -56,9 +57,12 @@ const { hasPermission } = usePermissions() const { subscribeLibrary, getProgress, progressMap } = useScanProgress() const { handleLibraryCreated } = useLibraryCreationRedirect() const themeStore = useThemeStore() -const { isInstallable, installApp } = useInstallPrompt() +const { version, updateAvailable, latestVersion, loadAppInfo } = useAppInfo() const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full')) +const versionUi = computed(() => { + return buildSidebarVersionUi(version.value, updateAvailable.value, latestVersion.value) +}) const refreshedFor = new Set() watch(progressMap, (map) => { @@ -147,6 +151,7 @@ onMounted(async () => { } fetchSmartScopes() fetchCollections() + loadAppInfo() }) const stopLibraryUploadListener = onLibraryUploadCompleted((event) => { @@ -433,10 +438,33 @@ onUnmounted(() => stopLibraryUploadListener()) - - - - + +
+ +
diff --git a/client/src/components/sidebar/__tests__/versionUi.spec.ts b/client/src/components/sidebar/__tests__/versionUi.spec.ts new file mode 100644 index 000000000..8df6ff9c9 --- /dev/null +++ b/client/src/components/sidebar/__tests__/versionUi.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' + +import { buildSidebarVersionUi } from '../versionUi' + +describe('buildSidebarVersionUi', () => { + it('builds a single current-tag link when no update is available', () => { + const ui = buildSidebarVersionUi('v1.2.3', false, 'v1.3.0') + + expect(ui).toEqual({ + currentLabel: 'v1.2.3', + currentHref: 'https://github.com/bookorbit/bookorbit/releases/tag/v1.2.3', + showLatest: false, + latestLabel: 'v1.3.0', + latestHref: 'https://github.com/bookorbit/bookorbit/releases/tag/v1.3.0', + }) + }) + + it('shows both current and latest links when an update is available', () => { + const ui = buildSidebarVersionUi('v1.2.3', true, 'v1.4.0') + + expect(ui.currentLabel).toBe('Current v1.2.3') + expect(ui.currentHref).toBe('https://github.com/bookorbit/bookorbit/releases/tag/v1.2.3') + expect(ui.showLatest).toBe(true) + expect(ui.latestLabel).toBe('v1.4.0') + expect(ui.latestHref).toBe('https://github.com/bookorbit/bookorbit/releases/tag/v1.4.0') + }) + + it('does not show latest when latestVersion is null', () => { + const ui = buildSidebarVersionUi('v1.2.3', true, null) + + expect(ui.showLatest).toBe(false) + expect(ui.currentLabel).toBe('v1.2.3') + }) + + it('does not show latest when latestVersion is blank after trimming', () => { + const ui = buildSidebarVersionUi('v1.2.3', true, ' ') + + expect(ui.showLatest).toBe(false) + expect(ui.currentLabel).toBe('v1.2.3') + expect(ui.latestLabel).toBe('') + }) + + it('shows local build labels as-is', () => { + const ui = buildSidebarVersionUi('Local build', null, null) + + expect(ui.currentLabel).toBe('Local build') + expect(ui.currentHref).toBeNull() + }) + + it('trims version strings before building labels and URLs', () => { + const ui = buildSidebarVersionUi(' v2.0.1 ', true, ' v2.1.0 ') + + expect(ui.currentLabel).toBe('Current v2.0.1') + expect(ui.currentHref).toBe('https://github.com/bookorbit/bookorbit/releases/tag/v2.0.1') + expect(ui.latestLabel).toBe('v2.1.0') + expect(ui.latestHref).toBe('https://github.com/bookorbit/bookorbit/releases/tag/v2.1.0') + }) + + it('falls back latest link to /releases/latest for non-tag latest versions', () => { + const ui = buildSidebarVersionUi('v1.2.3', true, 'main-abc123') + + expect(ui.showLatest).toBe(true) + expect(ui.latestLabel).toBe('main-abc123') + expect(ui.latestHref).toBe('https://github.com/bookorbit/bookorbit/releases/latest') + }) + + it('links sha versions to commits and does not enable latest mode', () => { + const ui = buildSidebarVersionUi('sha-abc1234', true, 'v1.2.4') + + expect(ui.currentLabel).toBe('sha-abc1234') + expect(ui.currentHref).toBe('https://github.com/bookorbit/bookorbit/commit/abc1234') + expect(ui.showLatest).toBe(false) + }) + + it('does not enable latest mode for local builds', () => { + const ui = buildSidebarVersionUi('Local build', true, 'v1.2.4') + + expect(ui.currentLabel).toBe('Local build') + expect(ui.showLatest).toBe(false) + }) + + it('shortens long sha labels to 12 chars for readability', () => { + const ui = buildSidebarVersionUi('sha-1234567890abcdef1234567890abcdef12345678', null, null) + + expect(ui.currentLabel).toBe('sha-1234567890ab') + expect(ui.currentHref).toBe('https://github.com/bookorbit/bookorbit/commit/1234567890abcdef1234567890abcdef12345678') + }) + + it('links short sha versions to the matching commit page', () => { + const ui = buildSidebarVersionUi('sha-1234567890ab', null, null) + + expect(ui.currentLabel).toBe('sha-1234567890ab') + expect(ui.currentHref).toBe('https://github.com/bookorbit/bookorbit/commit/1234567890ab') + }) + + it('does not prefix current label when updateAvailable is null', () => { + const ui = buildSidebarVersionUi('v1.2.3', null, 'v1.3.0') + + expect(ui.currentLabel).toBe('v1.2.3') + expect(ui.showLatest).toBe(false) + }) + + it('suppresses latest presentation when current version is blank', () => { + const ui = buildSidebarVersionUi(' ', true, 'v1.3.0') + + expect(ui.currentLabel).toBe('') + expect(ui.showLatest).toBe(false) + }) +}) diff --git a/client/src/components/sidebar/versionUi.ts b/client/src/components/sidebar/versionUi.ts new file mode 100644 index 000000000..50ff32c30 --- /dev/null +++ b/client/src/components/sidebar/versionUi.ts @@ -0,0 +1,44 @@ +export type SidebarVersionUi = { + currentLabel: string + currentHref: string | null + showLatest: boolean + latestLabel: string + latestHref: string +} + +const GITHUB_RELEASES_BASE = 'https://github.com/bookorbit/bookorbit/releases' +const GITHUB_COMMIT_BASE = 'https://github.com/bookorbit/bookorbit/commit' + +function isVersionTag(value: string): boolean { + return /^v\d+\.\d+\.\d+$/.test(value) +} + +function extractSha(value: string): string | null { + const match = value.match(/^(?:sha-)?([0-9a-f]{7,40})$/i) + return match ? (match[1] ?? null) : null +} + +function normalizeVersionLabel(value: string): string { + const normalized = value.trim() + if (!normalized) return '' + const sha = extractSha(normalized) + if (sha) return `sha-${sha.slice(0, 12)}` + return normalized +} + +export function buildSidebarVersionUi(version: string, updateAvailable: boolean | null, latestVersion: string | null): SidebarVersionUi { + const currentRaw = version.trim() + const latestRaw = (latestVersion ?? '').trim() + const currentIsTag = isVersionTag(currentRaw) + const currentSha = extractSha(currentRaw) + const currentLabelBase = normalizeVersionLabel(currentRaw) + const showLatest = Boolean(updateAvailable && latestRaw && currentLabelBase && currentIsTag) + + return { + currentLabel: showLatest ? `Current ${currentLabelBase}` : currentLabelBase, + currentHref: currentIsTag ? `${GITHUB_RELEASES_BASE}/tag/${currentRaw}` : currentSha ? `${GITHUB_COMMIT_BASE}/${currentSha}` : null, + showLatest, + latestLabel: normalizeVersionLabel(latestRaw), + latestHref: isVersionTag(latestRaw) ? `${GITHUB_RELEASES_BASE}/tag/${latestRaw}` : `${GITHUB_RELEASES_BASE}/latest`, + } +} diff --git a/client/src/features/settings/MaintenanceSettings.vue b/client/src/features/settings/MaintenanceSettings.vue index a2c2adb0e..4fa272029 100644 --- a/client/src/features/settings/MaintenanceSettings.vue +++ b/client/src/features/settings/MaintenanceSettings.vue @@ -1,7 +1,8 @@