From 65b4ef508248c2724795cbd6acbfe6b5bd8b0e8a Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Fri, 14 Aug 2026 10:00:18 +0700 Subject: [PATCH 1/2] Cache Docker builds for api, fw-headless, hgweb, and ui images None of the image builds previously reused any layers across CI runs: they used the default docker driver (which can't export/import build cache) and set no cache-from/cache-to. Every run rebuilt from scratch. - Add docker/setup-buildx-action and a ghcr registry layer cache (:buildcache tag, mode=max) to all four build workflows. cache-to is gated behind each workflow's existing push condition so fork PRs don't fail exporting cache. provenance:false keeps the published image manifest shape unchanged. - Restructure backend/Dockerfile and backend/FwHeadless/Dockerfile to copy the project/props files and restore before COPY . ., so the NuGet restore becomes a cacheable layer that only re-runs when a .csproj or package version changes. hgweb and ui needed no Dockerfile change: their expensive layers (apt/pip/OTEL, and pnpm install) already precede the source copy. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/lexbox-api.yaml | 7 +++++++ .github/workflows/lexbox-fw-headless.yaml | 7 +++++++ .github/workflows/lexbox-hgweb.yaml | 7 +++++++ .github/workflows/lexbox-ui.yaml | 7 +++++++ backend/Dockerfile | 22 +++++++++++++--------- backend/FwHeadless/Dockerfile | 17 ++++++++++++++++- 6 files changed, 57 insertions(+), 10 deletions(-) diff --git a/.github/workflows/lexbox-api.yaml b/.github/workflows/lexbox-api.yaml index 7dd81f5549..acd390231d 100644 --- a/.github/workflows/lexbox-api.yaml +++ b/.github/workflows/lexbox-api.yaml @@ -96,6 +96,8 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: backend @@ -104,3 +106,8 @@ jobs: push: ${{ !env.ACT && github.repository == 'sillsdev/languageforge-lexbox' && github.event.pull_request.head.repo.fork != true }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + # Persist build layers across runs via a registry cache image on ghcr. + # Only export when we can push (repo, non-fork); import is always safe. + provenance: false + cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache + cache-to: ${{ (!env.ACT && github.repository == 'sillsdev/languageforge-lexbox' && github.event.pull_request.head.repo.fork != true) && format('type=registry,ref={0}:buildcache,mode=max', env.IMAGE_NAME) || '' }} diff --git a/.github/workflows/lexbox-fw-headless.yaml b/.github/workflows/lexbox-fw-headless.yaml index 706af1da46..a47565a924 100644 --- a/.github/workflows/lexbox-fw-headless.yaml +++ b/.github/workflows/lexbox-fw-headless.yaml @@ -86,6 +86,8 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: backend @@ -95,3 +97,8 @@ jobs: push: ${{ !env.ACT && github.repository == 'sillsdev/languageforge-lexbox' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + # Persist build layers across runs via a registry cache image on ghcr. + # Only export when we can push (repo); import is always safe. + provenance: false + cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache + cache-to: ${{ (!env.ACT && github.repository == 'sillsdev/languageforge-lexbox') && format('type=registry,ref={0}:buildcache,mode=max', env.IMAGE_NAME) || '' }} diff --git a/.github/workflows/lexbox-hgweb.yaml b/.github/workflows/lexbox-hgweb.yaml index 36d59f1b02..7b704c839e 100644 --- a/.github/workflows/lexbox-hgweb.yaml +++ b/.github/workflows/lexbox-hgweb.yaml @@ -69,6 +69,8 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: @@ -78,6 +80,11 @@ jobs: push: ${{ !env.ACT }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + # Persist build layers across runs via a registry cache image on ghcr. + # Only export when we can push (auth present); import is always safe. + provenance: false + cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache + cache-to: ${{ !env.ACT && format('type=registry,ref={0}:buildcache,mode=max', env.IMAGE_NAME) || '' }} deploy-hgweb: uses: ./.github/workflows/deploy.yaml if: ${{github.ref == 'refs/heads/develop' && github.event_name == 'push'}} diff --git a/.github/workflows/lexbox-ui.yaml b/.github/workflows/lexbox-ui.yaml index 654691a388..a5e1f092bf 100644 --- a/.github/workflows/lexbox-ui.yaml +++ b/.github/workflows/lexbox-ui.yaml @@ -69,6 +69,8 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: frontend @@ -77,3 +79,8 @@ jobs: push: ${{ !env.ACT && github.repository == 'sillsdev/languageforge-lexbox' && github.event.pull_request.head.repo.fork != true }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + # Persist build layers across runs via a registry cache image on ghcr. + # Only export when we can push (repo, non-fork); import is always safe. + provenance: false + cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache + cache-to: ${{ (!env.ACT && github.repository == 'sillsdev/languageforge-lexbox' && github.event.pull_request.head.repo.fork != true) && format('type=registry,ref={0}:buildcache,mode=max', env.IMAGE_NAME) || '' }} diff --git a/backend/Dockerfile b/backend/Dockerfile index c6eaba94a7..b78f4f5c26 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,16 +6,20 @@ EXPOSE 443 FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -COPY . . -# WORKDIR /src -# Copy the main source project files -# COPY */*.csproj ./ -# -# move them into the proper sub folders, based on the name of the project -# RUN for file in $(ls *.csproj); do mkdir -p ${file%.*}/ && mv $file ${file%.*}/; done +# Restore first, using only the project/props files, so this layer is cached and +# only re-runs when a .csproj or package version changes — not on every source +# edit. Keep this list in sync with LexBoxApi's ProjectReference graph; a missing +# entry makes the restore below fail loudly at build time. +COPY *.props ./ +COPY LexBoxApi/LexBoxApi.csproj LexBoxApi/ +COPY LexCore/LexCore.csproj LexCore/ +COPY LexData/LexData.csproj LexData/ +COPY LfClassicData/LfClassicData.csproj LfClassicData/ +COPY SyncReverseProxy/SyncReverseProxy.csproj SyncReverseProxy/ +COPY FwLite/MiniLcm/MiniLcm.csproj FwLite/MiniLcm/ RUN --mount=type=cache,target=/root/.nuget/packages dotnet restore "LexBoxApi/LexBoxApi.csproj" -# COPY . . -# WORKDIR "/LexBoxApi" + +COPY . . ARG APP_VERSION LABEL version=$APP_VERSION diff --git a/backend/FwHeadless/Dockerfile b/backend/FwHeadless/Dockerfile index e5f0eb552a..5706d39e3f 100644 --- a/backend/FwHeadless/Dockerfile +++ b/backend/FwHeadless/Dockerfile @@ -6,9 +6,24 @@ EXPOSE 443 FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -COPY . . +# Restore first, using only the project/props files, so this layer is cached and +# only re-runs when a .csproj or package version changes — not on every source +# edit. Keep this list in sync with FwHeadless's ProjectReference graph; a missing +# entry makes the restore below fail loudly at build time. +COPY *.props ./ +COPY FwHeadless/FwHeadless.csproj FwHeadless/ +COPY FixFwData/FixFwData.csproj FixFwData/ +COPY LexCore/LexCore.csproj LexCore/ +COPY LexData/LexData.csproj LexData/ +COPY WebServiceDefaults/WebServiceDefaults.csproj WebServiceDefaults/ +COPY FwLite/MiniLcm/MiniLcm.csproj FwLite/MiniLcm/ +COPY FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj FwLite/FwDataMiniLcmBridge/ +COPY FwLite/LcmCrdt/LcmCrdt.csproj FwLite/LcmCrdt/ +COPY FwLite/FwLiteProjectSync/FwLiteProjectSync.csproj FwLite/FwLiteProjectSync/ RUN --mount=type=cache,target=/root/.nuget/packages dotnet restore "FwHeadless/FwHeadless.csproj" +COPY . . + ARG APP_VERSION LABEL version=$APP_VERSION From a3c11f99c355c0a1af0a19063662de92a866db56 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 20 Aug 2026 10:28:45 +0700 Subject: [PATCH 2/2] Guard Docker cache export to branch pushes and fork PRs Export the shared :buildcache only on branch pushes (develop/main) so PR builds can't clobber it, and add the repo/non-fork guard to fw-headless and hgweb login/push/cache-to so fork PRs don't attempt GHCR writes with a read-only token. Add least-privilege permissions to each publish job. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/lexbox-api.yaml | 8 ++++++-- .github/workflows/lexbox-fw-headless.yaml | 13 +++++++++---- .github/workflows/lexbox-hgweb.yaml | 14 ++++++++++---- .github/workflows/lexbox-ui.yaml | 8 ++++++-- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/.github/workflows/lexbox-api.yaml b/.github/workflows/lexbox-api.yaml index acd390231d..9d9fd29c2f 100644 --- a/.github/workflows/lexbox-api.yaml +++ b/.github/workflows/lexbox-api.yaml @@ -21,6 +21,9 @@ jobs: publish-api: timeout-minutes: 60 runs-on: ubuntu-latest + permissions: + contents: read + packages: write # postgres db is for automated tests services: @@ -107,7 +110,8 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} # Persist build layers across runs via a registry cache image on ghcr. - # Only export when we can push (repo, non-fork); import is always safe. + # Only export on branch pushes (develop/main) so PR builds can't clobber + # the shared buildcache; import is always safe. provenance: false cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache - cache-to: ${{ (!env.ACT && github.repository == 'sillsdev/languageforge-lexbox' && github.event.pull_request.head.repo.fork != true) && format('type=registry,ref={0}:buildcache,mode=max', env.IMAGE_NAME) || '' }} + cache-to: ${{ (!env.ACT && github.event_name == 'push' && github.repository == 'sillsdev/languageforge-lexbox' && github.event.pull_request.head.repo.fork != true) && format('type=registry,ref={0}:buildcache,mode=max', env.IMAGE_NAME) || '' }} diff --git a/.github/workflows/lexbox-fw-headless.yaml b/.github/workflows/lexbox-fw-headless.yaml index a47565a924..1cb16f56ae 100644 --- a/.github/workflows/lexbox-fw-headless.yaml +++ b/.github/workflows/lexbox-fw-headless.yaml @@ -21,6 +21,9 @@ jobs: publish-fw-headless: timeout-minutes: 60 runs-on: ubuntu-latest + permissions: + contents: read + packages: write # postgres db is for automated tests services: @@ -78,9 +81,10 @@ jobs: type=raw,enable=${{ inputs.label-latest }},value=latest type=raw,value=${{ inputs.version }} + # Fork PR tokens cannot write org packages on ghcr.io - name: ghcr.io login uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - if: ${{ !env.ACT }} + if: ${{ !env.ACT && github.repository == 'sillsdev/languageforge-lexbox' && github.event.pull_request.head.repo.fork != true }} with: registry: ghcr.io username: ${{ github.actor }} @@ -94,11 +98,12 @@ jobs: file: backend/FwHeadless/Dockerfile build-args: | APP_VERSION=${{ inputs.version }} - push: ${{ !env.ACT && github.repository == 'sillsdev/languageforge-lexbox' }} + push: ${{ !env.ACT && github.repository == 'sillsdev/languageforge-lexbox' && github.event.pull_request.head.repo.fork != true }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} # Persist build layers across runs via a registry cache image on ghcr. - # Only export when we can push (repo); import is always safe. + # Only export on branch pushes (develop/main) so PR builds can't clobber + # the shared buildcache; import is always safe. provenance: false cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache - cache-to: ${{ (!env.ACT && github.repository == 'sillsdev/languageforge-lexbox') && format('type=registry,ref={0}:buildcache,mode=max', env.IMAGE_NAME) || '' }} + cache-to: ${{ (!env.ACT && github.event_name == 'push' && github.repository == 'sillsdev/languageforge-lexbox' && github.event.pull_request.head.repo.fork != true) && format('type=registry,ref={0}:buildcache,mode=max', env.IMAGE_NAME) || '' }} diff --git a/.github/workflows/lexbox-hgweb.yaml b/.github/workflows/lexbox-hgweb.yaml index 7b704c839e..1480df5a0f 100644 --- a/.github/workflows/lexbox-hgweb.yaml +++ b/.github/workflows/lexbox-hgweb.yaml @@ -33,6 +33,9 @@ env: jobs: publish-hgweb: runs-on: ubuntu-latest + permissions: + contents: read + packages: write outputs: version: ${{ inputs.version || steps.setVersion.outputs.VERSION }} @@ -63,8 +66,10 @@ jobs: type=ref,event=pr type=raw,enable=${{ env.PROD_RELEASE }},value=latest type=raw,value=${{ env.VERSION }} - - + # Fork PR tokens cannot write org packages on ghcr.io + - name: ghcr.io login uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + if: ${{ !env.ACT && github.repository == 'sillsdev/languageforge-lexbox' && github.event.pull_request.head.repo.fork != true }} with: registry: ghcr.io username: ${{ github.actor }} @@ -77,14 +82,15 @@ jobs: context: hgweb build-args: | APP_VERSION=${{ env.VERSION }} - push: ${{ !env.ACT }} + push: ${{ !env.ACT && github.repository == 'sillsdev/languageforge-lexbox' && github.event.pull_request.head.repo.fork != true }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} # Persist build layers across runs via a registry cache image on ghcr. - # Only export when we can push (auth present); import is always safe. + # Only export on branch pushes (develop/main) so PR builds can't clobber + # the shared buildcache; import is always safe. provenance: false cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache - cache-to: ${{ !env.ACT && format('type=registry,ref={0}:buildcache,mode=max', env.IMAGE_NAME) || '' }} + cache-to: ${{ (!env.ACT && github.event_name == 'push' && github.repository == 'sillsdev/languageforge-lexbox' && github.event.pull_request.head.repo.fork != true) && format('type=registry,ref={0}:buildcache,mode=max', env.IMAGE_NAME) || '' }} deploy-hgweb: uses: ./.github/workflows/deploy.yaml if: ${{github.ref == 'refs/heads/develop' && github.event_name == 'push'}} diff --git a/.github/workflows/lexbox-ui.yaml b/.github/workflows/lexbox-ui.yaml index a5e1f092bf..c6a72b8bb3 100644 --- a/.github/workflows/lexbox-ui.yaml +++ b/.github/workflows/lexbox-ui.yaml @@ -20,6 +20,9 @@ jobs: publish-ui: timeout-minutes: 60 runs-on: ubuntu-latest + permissions: + contents: read + packages: write env: # https://docs.docker.com/develop/develop-images/build_enhancements/ DOCKER_BUILDKIT: 1 @@ -80,7 +83,8 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} # Persist build layers across runs via a registry cache image on ghcr. - # Only export when we can push (repo, non-fork); import is always safe. + # Only export on branch pushes (develop/main) so PR builds can't clobber + # the shared buildcache; import is always safe. provenance: false cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache - cache-to: ${{ (!env.ACT && github.repository == 'sillsdev/languageforge-lexbox' && github.event.pull_request.head.repo.fork != true) && format('type=registry,ref={0}:buildcache,mode=max', env.IMAGE_NAME) || '' }} + cache-to: ${{ (!env.ACT && github.event_name == 'push' && github.repository == 'sillsdev/languageforge-lexbox' && github.event.pull_request.head.repo.fork != true) && format('type=registry,ref={0}:buildcache,mode=max', env.IMAGE_NAME) || '' }}