diff --git a/.github/workflows/dev-branch-images.yml b/.github/workflows/dev-branch-images.yml index 5a85779b11..48d153d2bb 100644 --- a/.github/workflows/dev-branch-images.yml +++ b/.github/workflows/dev-branch-images.yml @@ -33,29 +33,51 @@ concurrency: cancel-in-progress: true jobs: + # Each platform builds on a runner of its own architecture and pushes an + # untagged, digest-addressed manifest. Building linux/arm64 on an amd64 runner + # needs QEMU, which measured 5-6x slower than native on this image + # (npm ci 52.6s -> 333.2s, npm run frontend 90.4s -> 442.8s) and made the + # emulated leg ~84% of the build. The `merge` job below stitches the digests + # into the multi-platform manifest lists that consumers actually pull. build: - runs-on: ubuntu-latest - timeout-minutes: 130 + name: Build ${{ matrix.image_name }} (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 strategy: + fail-fast: false matrix: include: - target: api-build file: Dockerfile.multi image_name: lc-dev-api + platform: linux/amd64 + arch: amd64 + runner: ubuntu-latest + - target: api-build + file: Dockerfile.multi + image_name: lc-dev-api + platform: linux/arm64 + arch: arm64 + runner: ubuntu-24.04-arm - target: node file: Dockerfile image_name: lc-dev + platform: linux/amd64 + arch: amd64 + runner: ubuntu-latest + - target: node + file: Dockerfile + image_name: lc-dev + platform: linux/arm64 + arch: arm64 + runner: ubuntu-24.04-arm steps: # Check out the repository - name: Checkout uses: actions/checkout@v5 - # Set up QEMU - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - # Set up Docker Buildx + # Set up Docker Buildx (no QEMU: the runner is already the target arch) - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -85,21 +107,100 @@ jobs: echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV - # Build and push Docker images for each target - - name: Build and push Docker images + # Build and push a digest-only image for this one platform. The layer cache + # lives in GHCR beside the image (no 10GB Actions-cache cap, and readable + # from every branch and workflow, unlike type=gha which is branch-scoped). + # The cache ref is per-architecture so the two platform jobs cannot clobber + # each other's manifest. + - name: Build and push by digest + id: build uses: docker/build-push-action@v7 with: context: . file: ${{ matrix.file }} - push: true - tags: | - ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }} - ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest - ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }} - ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest - platforms: linux/amd64,linux/arm64 + platforms: ${{ matrix.platform }} target: ${{ matrix.target }} + cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:buildcache-${{ matrix.arch }} + cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:buildcache-${{ matrix.arch }},mode=max + outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }},docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}",push-by-digest=true,name-canonical=true,push=true build-args: | BUILD_COMMIT=${{ env.BUILD_COMMIT }} BUILD_BRANCH=${{ env.BUILD_BRANCH }} BUILD_DATE=${{ env.BUILD_DATE }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.image_name }}-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # Combine the per-architecture digests into one multi-platform manifest list + # per registry, and apply the tags consumers pull. + merge: + name: Merge manifests (${{ matrix.image_name }}) + runs-on: ubuntu-latest + needs: build + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + image_name: [lc-dev-api, lc-dev] + + steps: + - name: Download digests + uses: actions/download-artifact@v7 + with: + pattern: digests-${{ matrix.image_name }}-* + merge-multiple: true + path: /tmp/digests + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # The digests are identical across registries (the manifest is + # content-addressed), so the same set sources both manifest lists. + - name: Create manifest lists and push + working-directory: /tmp/digests + env: + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }} + DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }} + run: | + set -euo pipefail + shopt -s nullglob + digests=(*) + if [ "${#digests[@]}" -eq 0 ]; then + echo "No digests downloaded; refusing to publish an empty manifest." >&2 + exit 1 + fi + echo "Merging ${#digests[@]} platform digests: ${digests[*]}" + for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do + docker buildx imagetools create \ + -t "${image}:${{ github.sha }}" \ + -t "${image}:latest" \ + "${digests[@]/#/${image}@sha256:}" + done + + - name: Inspect image + run: | + docker buildx imagetools inspect ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }} diff --git a/client/vite.config.ts b/client/vite.config.ts index 8c7830dfdf..a260f96369 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,11 +1,12 @@ import react from '@vitejs/plugin-react'; import fs from 'fs'; import path from 'path'; +import { constants } from 'zlib'; import { defineConfig } from 'vite'; import { createRequire } from 'module'; import { VitePWA } from 'vite-plugin-pwa'; -import { compression } from 'vite-plugin-compression2'; import { nodePolyfills } from 'vite-plugin-node-polyfills'; +import { compression, defineAlgorithm } from 'vite-plugin-compression2'; import type { Plugin } from 'vite'; const require = createRequire(import.meta.url); @@ -178,6 +179,20 @@ export default defineConfig(({ command }) => ({ ...(buildSourceMap ? [sourcemapExclude({ excludeNodeModules: true })] : []), compression({ threshold: 10240, + /** + * Brotli's default quality of 11 costs ~13s of single-threaded CPU on this bundle + * against ~0.5s for gzip, and the plugin's scheduler serializes quality >= 10 as a + * high-memory operation. Quality 5 compresses in ~0.2s, still lands under gzip + * (4.3MB vs 5.0MB), and parallelizes. `.br` is only served when + * ENABLE_STATIC_ASSET_BROTLI is set, so the extra 0.5MB buys far less than it costs + * on every build of every platform. + */ + algorithms: [ + defineAlgorithm('gzip', { level: 9 }), + defineAlgorithm('brotliCompress', { + params: { [constants.BROTLI_PARAM_QUALITY]: 5 }, + }), + ], }), ], optimizeDeps: {