diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 00000000..82ffded8 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,138 @@ +name: Publish Docker image + +on: + workflow_dispatch: + push: + branches: + - main + release: + types: + - published + +concurrency: + group: publish-docker-image-${{ github.event.release.tag_name || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +jobs: + publish: + runs-on: ubuntu-latest + env: + BUILD_ARGS: | + NEXT_PUBLIC_APP_PLATFORM=web + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Detect Docker Hub credentials + id: dockerhub + run: | + if [ -n "${{ secrets.DOCKERHUB_USERNAME }}" ] && [ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to Docker Hub + if: steps.dockerhub.outputs.enabled == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract image metadata (GHCR only) + id: meta-ghcr + if: steps.dockerhub.outputs.enabled != 'true' + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/readest + tags: | + type=raw,value=main,enable={{is_default_branch}} + type=raw,value=latest,enable=${{ github.event_name == 'release' }} + type=raw,value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' }} + type=sha,prefix=sha- + + - name: Extract image metadata (GHCR + Docker Hub) + id: meta-all + if: steps.dockerhub.outputs.enabled == 'true' + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/${{ github.repository_owner }}/readest + docker.io/${{ secrets.DOCKERHUB_USERNAME }}/readest + tags: | + type=raw,value=main,enable={{is_default_branch}} + type=raw,value=latest,enable=${{ github.event_name == 'release' }} + type=raw,value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' }} + type=sha,prefix=sha- + + - name: Build and push (GHCR only) + id: build-ghcr + if: steps.dockerhub.outputs.enabled != 'true' + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + target: production-stage + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta-ghcr.outputs.tags }} + labels: ${{ steps.meta-ghcr.outputs.labels }} + build-args: ${{ env.BUILD_ARGS }} + + - name: Published image summary (GHCR only) + if: steps.dockerhub.outputs.enabled != 'true' + run: | + echo "## Published Images" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Registry: GHCR" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Tags:" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "${{ steps.meta-ghcr.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "Digest: \`${{ steps.build-ghcr.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY" + + - name: Build and push (GHCR + Docker Hub) + id: build-all + if: steps.dockerhub.outputs.enabled == 'true' + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + target: production-stage + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta-all.outputs.tags }} + labels: ${{ steps.meta-all.outputs.labels }} + build-args: ${{ env.BUILD_ARGS }} + + - name: Published image summary (GHCR + Docker Hub) + if: steps.dockerhub.outputs.enabled == 'true' + run: | + echo "## Published Images" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Registries: GHCR, Docker Hub" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Tags:" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "${{ steps.meta-all.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + echo "Digest: \`${{ steps.build-all.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/apps/readest-app/src/__tests__/services/environment.test.ts b/apps/readest-app/src/__tests__/services/environment.test.ts index 27861727..bb869eee 100644 --- a/apps/readest-app/src/__tests__/services/environment.test.ts +++ b/apps/readest-app/src/__tests__/services/environment.test.ts @@ -19,6 +19,7 @@ beforeEach(() => { Object.assign(env, originalEnv); // Clean up any window globals we set delete (window as unknown as Record)['__READEST_CLI_ACCESS']; + delete (window as unknown as Record)['__READEST_RUNTIME_CONFIG']; }); describe('environment', () => { @@ -106,6 +107,22 @@ describe('environment', () => { // ── getBaseUrl ───────────────────────────────────────────────── describe('getBaseUrl', () => { + test('returns runtime-configured apiBaseUrl when set', async () => { + window.__READEST_RUNTIME_CONFIG = { + apiBaseUrl: 'https://runtime-api.example.com', + }; + env['NEXT_PUBLIC_API_BASE_URL'] = 'https://custom-api.example.com'; + const { getBaseUrl } = await import('@/services/environment'); + expect(getBaseUrl()).toBe('https://runtime-api.example.com'); + }); + + test('returns API_BASE_URL when set', async () => { + env['API_BASE_URL'] = 'https://runtime-api.example.com'; + delete env['NEXT_PUBLIC_API_BASE_URL']; + const { getBaseUrl } = await import('@/services/environment'); + expect(getBaseUrl()).toBe('https://runtime-api.example.com'); + }); + test('returns NEXT_PUBLIC_API_BASE_URL when set', async () => { env['NEXT_PUBLIC_API_BASE_URL'] = 'https://custom-api.example.com'; const { getBaseUrl } = await import('@/services/environment'); diff --git a/apps/readest-app/src/app/layout.tsx b/apps/readest-app/src/app/layout.tsx index 48dc0899..59d769fb 100644 --- a/apps/readest-app/src/app/layout.tsx +++ b/apps/readest-app/src/app/layout.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import type { Metadata, Viewport } from 'next'; +import Script from 'next/script'; import { ViewTransitions } from 'next-view-transitions'; import { EnvProvider } from '@/context/EnvContext'; import Providers from '@/components/Providers'; @@ -128,11 +129,12 @@ export default function RootLayout({ children }: { children: React.ReactNode }) lang='en' className={process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'tauri' ? 'edge-to-edge' : ''} > - {shouldInjectDevHmrPatch ? ( - + +