fix(i18n): resolve ISO 639-2/B language codes and stop gating validit… #706
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build & Push - Dev - Split Strategy | |
| # Builds + pushes a multi-platform dev image. Pushes to GHCR always; | |
| # also pushes to DockerHub when the operator has set the repo variable | |
| # DOCKERHUB_PUSH_ENABLED='true' AND the secrets DOCKERHUB_USERNAME + | |
| # DOCKERHUB_PA_TOKEN are present. Without those, the workflow runs in | |
| # GHCR-only mode — useful on the standalone fork where DockerHub | |
| # credentials don't exist. Earlier runs failed at the DockerHub login | |
| # step because the secrets were empty; this gating fixes that. | |
| on: | |
| # Dev channel (release-train policy, 2026-06-06): every merge to main builds | |
| # and pushes :dev + :dev-<n> to GHCR. teenyverse (the operator's household | |
| # instance) tracks :dev as the canary; public releases batch via the daily | |
| # release train (see LOOP.md / CLAUDE.md "Release policy"). Docs- and | |
| # tests-only changes skip the build — they can't change the image behavior. | |
| push: | |
| branches: [main] | |
| paths-ignore: | |
| - '**.md' | |
| - 'docs/**' | |
| - 'tests/**' | |
| - '.github/**' | |
| workflow_dispatch: | |
| inputs: | |
| ref: | |
| description: "Commit SHA or branch to build (defaults to github.ref if empty)" | |
| required: false | |
| type: string | |
| branch: | |
| description: "Branch name to tag the dev image with (defaults to github.ref_name if empty)" | |
| required: false | |
| type: string | |
| # GITHUB_TOKEN is read-only on packages by default. Builds push to | |
| # ghcr.io/new-usemame/calibre-web-nextgen, so packages:write is | |
| # required. Without it the build silently failed at the GHCR push step | |
| # with "denied: installation not allowed to Write organization | |
| # package". Mirrors the release workflow minus id-token/attestations, | |
| # which the dev workflow explicitly disables (provenance: false). | |
| permissions: | |
| contents: read | |
| packages: write | |
| jobs: | |
| # -------------------------------------------------------------------------------- | |
| # JOB 0: Ensure the python-build-standalone GHCR mirror exists before building. | |
| # The image build COPYs Python from ghcr.io/new-usemame/pbs-cache instead of the | |
| # flaky GitHub release CDN. This job (idempotent — a no-op when the mirror is | |
| # already present) builds it the first time after a Python bump. See | |
| # scripts/ensure-python-mirror.sh + notes/PYTHON-BUILD-MIRROR.md. | |
| # -------------------------------------------------------------------------------- | |
| ensure-mirror: | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' | |
| steps: | |
| - name: Checkout (read Python pin from Dockerfile) | |
| uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ (github.event_name == 'workflow_dispatch' && inputs.ref) || github.ref }} | |
| - name: Ensure python-build-standalone mirror exists | |
| env: | |
| GHCR_TOKEN: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} | |
| run: bash scripts/ensure-python-mirror.sh | |
| # -------------------------------------------------------------------------------- | |
| # JOB 1: Build Intel (amd64) on GitHub Runners | |
| # -------------------------------------------------------------------------------- | |
| build-amd64: | |
| runs-on: ubuntu-latest | |
| needs: ensure-mirror | |
| if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' | |
| steps: | |
| - name: Set build ref | |
| run: | | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.ref }}" ]; then | |
| echo "BUILD_REF=${{ inputs.ref }}" >> $GITHUB_ENV | |
| else | |
| echo "BUILD_REF=${{ github.ref }}" >> $GITHUB_ENV | |
| fi | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.branch }}" ]; then | |
| echo "BRANCH_NAME=${{ inputs.branch }}" >> $GITHUB_ENV | |
| else | |
| echo "BRANCH_NAME=${{ github.ref_name }}" >> $GITHUB_ENV | |
| fi | |
| - name: Checkout correct ref | |
| uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ env.BUILD_REF }} | |
| # --- 1. Calculate Variables --- | |
| - name: Set repo name to lowercase | |
| run: echo "LOWER_REPO_NAME=$(echo ${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV | |
| - name: Determine Docker Image Tag | |
| id: tag | |
| run: | | |
| if [[ "${{ env.BRANCH_NAME }}" == "main" ]]; then | |
| echo "IMAGE_TAG=dev" >> $GITHUB_ENV | |
| else | |
| sanitized_branch=$(echo "${{ env.BRANCH_NAME }}" | sed 's/[^a-zA-Z0-9.-]/-/g') | |
| echo "IMAGE_TAG=dev-${sanitized_branch}" >> $GITHUB_ENV | |
| fi | |
| # --- 2. Logins --- | |
| - name: DockerHub Login | |
| # Skipped on the standalone fork where DOCKERHUB secrets aren't set. | |
| # Operator opts in by setting repo variable DOCKERHUB_PUSH_ENABLED='true'. | |
| if: vars.DOCKERHUB_PUSH_ENABLED == 'true' | |
| uses: docker/login-action@v4 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_PA_TOKEN }} | |
| - name: GitHub Container Registry Login | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| # GH_PAT (read:packages) so BuildKit can COPY --from our private | |
| # pbs-cache mirror; falls back to GITHUB_TOKEN for push-only contexts. | |
| password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} | |
| # --- 3. Build & Push (Digest Only) --- | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| with: | |
| driver-opts: network=host | |
| - name: Compose docker outputs (GHCR always; DockerHub when opted-in) | |
| run: | | |
| GHCR_LINE="type=image,name=ghcr.io/${{ github.repository_owner }}/${{ env.LOWER_REPO_NAME }},push-by-digest=true,name-canonical=true,push=true" | |
| if [ "${{ vars.DOCKERHUB_PUSH_ENABLED }}" = "true" ]; then | |
| DH_LINE="type=image,name=${{ secrets.DOCKERHUB_USERNAME }}/calibre-web-automated,push-by-digest=true,name-canonical=true,push=true" | |
| printf 'DOCKER_BUILD_OUTPUTS<<EOF\n%s\n%s\nEOF\n' "$DH_LINE" "$GHCR_LINE" >> "$GITHUB_ENV" | |
| else | |
| printf 'DOCKER_BUILD_OUTPUTS<<EOF\n%s\nEOF\n' "$GHCR_LINE" >> "$GITHUB_ENV" | |
| fi | |
| - name: Build and push by digest | |
| id: build | |
| uses: docker/build-push-action@v7 | |
| with: | |
| provenance: false | |
| context: . | |
| file: ./Dockerfile | |
| # We build AMD64 here natively | |
| platforms: linux/amd64 | |
| # CACHE STRATEGY: Use GitHub Actions Cache (Ephemeral Runner) | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| # GHCR is always a target. DockerHub is added by the | |
| # "Compose docker outputs" step above, which conditionally | |
| # includes the DockerHub image name when DOCKERHUB_PUSH_ENABLED. | |
| outputs: ${{ env.DOCKER_BUILD_OUTPUTS }} | |
| # PBS_SOURCE=ghcr pulls Python/kepubify from our GHCR mirror rather | |
| # than the release CDN, which intermittently 404s the Actions egress. | |
| # Contributors build without it and fall back to the CDN (see Dockerfile). | |
| build-args: | | |
| BUILD_DATE=${{ github.event.repository.updated_at }} | |
| VERSION=DEV_BUILD-${{ env.IMAGE_TAG }}-${{ vars.CURRENT_DEV_BUILD_NUM }} | |
| PBS_SOURCE=ghcr | |
| # --- 4. Export Digest for Merge Job --- | |
| - 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@v7 | |
| with: | |
| name: digests-amd64 | |
| path: /tmp/digests/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| # -------------------------------------------------------------------------------- | |
| # JOB 2: Build ARM (arm64 Only) on Oracle Runner | |
| # -------------------------------------------------------------------------------- | |
| build-arm: | |
| # GitHub's free native ARM64 hosted runner. The workflow originally | |
| # ran on a self-hosted Oracle Cloud ARM VM (inherited from upstream | |
| # CWA), but no such runner exists on Calibre-Web-NextGen — the job | |
| # had been queuing indefinitely. A self-hosted runner on a *public* | |
| # repo is also a meaningful attack surface (any PR can execute on | |
| # operator hardware), so GitHub-hosted is the safer default. | |
| runs-on: ubuntu-24.04-arm | |
| needs: ensure-mirror | |
| if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' | |
| steps: | |
| - name: Set build ref | |
| run: | | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.ref }}" ]; then | |
| echo "BUILD_REF=${{ inputs.ref }}" >> $GITHUB_ENV | |
| else | |
| echo "BUILD_REF=${{ github.ref }}" >> $GITHUB_ENV | |
| fi | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.branch }}" ]; then | |
| echo "BRANCH_NAME=${{ inputs.branch }}" >> $GITHUB_ENV | |
| else | |
| echo "BRANCH_NAME=${{ github.ref_name }}" >> $GITHUB_ENV | |
| fi | |
| - name: Checkout correct ref | |
| uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ env.BUILD_REF }} | |
| # --- 1. Calculate Variables --- | |
| - name: Set repo name to lowercase | |
| run: echo "LOWER_REPO_NAME=$(echo ${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV | |
| - name: Determine Docker Image Tag | |
| id: tag | |
| run: | | |
| if [[ "${{ env.BRANCH_NAME }}" == "main" ]]; then | |
| echo "IMAGE_TAG=dev" >> $GITHUB_ENV | |
| else | |
| sanitized_branch=$(echo "${{ env.BRANCH_NAME }}" | sed 's/[^a-zA-Z0-9.-]/-/g') | |
| echo "IMAGE_TAG=dev-${sanitized_branch}" >> $GITHUB_ENV | |
| fi | |
| # --- 2. Logins --- | |
| - name: DockerHub Login | |
| # Skipped on the standalone fork where DOCKERHUB secrets aren't set. | |
| # Operator opts in by setting repo variable DOCKERHUB_PUSH_ENABLED='true'. | |
| if: vars.DOCKERHUB_PUSH_ENABLED == 'true' | |
| uses: docker/login-action@v4 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_PA_TOKEN }} | |
| - name: GitHub Container Registry Login | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| # GH_PAT (read:packages) so BuildKit can COPY --from our private | |
| # pbs-cache mirror; falls back to GITHUB_TOKEN for push-only contexts. | |
| password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} | |
| # --- 3. Build & Push (Digest Only) --- | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| with: | |
| driver-opts: network=host | |
| - name: Compose docker outputs (GHCR always; DockerHub when opted-in) | |
| run: | | |
| GHCR_LINE="type=image,name=ghcr.io/${{ github.repository_owner }}/${{ env.LOWER_REPO_NAME }},push-by-digest=true,name-canonical=true,push=true" | |
| if [ "${{ vars.DOCKERHUB_PUSH_ENABLED }}" = "true" ]; then | |
| DH_LINE="type=image,name=${{ secrets.DOCKERHUB_USERNAME }}/calibre-web-automated,push-by-digest=true,name-canonical=true,push=true" | |
| printf 'DOCKER_BUILD_OUTPUTS<<EOF\n%s\n%s\nEOF\n' "$DH_LINE" "$GHCR_LINE" >> "$GITHUB_ENV" | |
| else | |
| printf 'DOCKER_BUILD_OUTPUTS<<EOF\n%s\nEOF\n' "$GHCR_LINE" >> "$GITHUB_ENV" | |
| fi | |
| - name: Build and push by digest | |
| id: build | |
| uses: docker/build-push-action@v7 | |
| with: | |
| provenance: false | |
| context: . | |
| file: ./Dockerfile | |
| # We build ARM64 here natively | |
| platforms: linux/arm64 | |
| # CACHE STRATEGY: Use Local Disk Cache (Persistent Runner) | |
| # Saves bandwidth and time by storing cache directly on the Oracle VM | |
| cache-from: type=local,src=/tmp/.buildx-cache | |
| cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max | |
| # GHCR is always a target. DockerHub is added by the | |
| # "Compose docker outputs" step below, which conditionally | |
| # includes the DockerHub image name when DOCKERHUB_PUSH_ENABLED. | |
| outputs: ${{ env.DOCKER_BUILD_OUTPUTS }} | |
| # PBS_SOURCE=ghcr pulls Python/kepubify from our GHCR mirror rather | |
| # than the release CDN, which intermittently 404s the Actions egress. | |
| # Contributors build without it and fall back to the CDN (see Dockerfile). | |
| build-args: | | |
| BUILD_DATE=${{ github.event.repository.updated_at }} | |
| VERSION=DEV_BUILD-${{ env.IMAGE_TAG }}-${{ vars.CURRENT_DEV_BUILD_NUM }} | |
| PBS_SOURCE=ghcr | |
| # --- 4. Rotate Cache (Prevent Infinite Growth) --- | |
| - name: Move cache | |
| run: | | |
| rm -rf /tmp/.buildx-cache | |
| mv /tmp/.buildx-cache-new /tmp/.buildx-cache | |
| # --- 5. Export Digest for Merge Job --- | |
| - name: Export digest | |
| run: | | |
| rm -rf /tmp/digests | |
| mkdir -p /tmp/digests | |
| digest="${{ steps.build.outputs.digest }}" | |
| touch "/tmp/digests/${digest#sha256:}" | |
| - name: Upload digest | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: digests-arm | |
| path: /tmp/digests/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| # --- 6. Janitor: Clean up Docker Garbage --- | |
| - name: Prune Docker Garbage | |
| if: always() # Run even if build fails to keep disk clean | |
| run: | | |
| echo "Pruning dangling images..." | |
| docker image prune -f | |
| echo "Pruning old build cache (older than 1 week)..." | |
| docker builder prune -f --filter "until=168h" | |
| # -------------------------------------------------------------------------------- | |
| # JOB 3: Merge & Tag (Runs on GitHub) | |
| # -------------------------------------------------------------------------------- | |
| merge: | |
| runs-on: ubuntu-latest | |
| needs: [build-amd64, build-arm] | |
| if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' | |
| steps: | |
| # --- 1. Calculate Variables --- | |
| - name: Set build ref | |
| run: | | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.ref }}" ]; then | |
| echo "BUILD_REF=${{ inputs.ref }}" >> $GITHUB_ENV | |
| else | |
| echo "BUILD_REF=${{ github.ref }}" >> $GITHUB_ENV | |
| fi | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.branch }}" ]; then | |
| echo "BRANCH_NAME=${{ inputs.branch }}" >> $GITHUB_ENV | |
| else | |
| echo "BRANCH_NAME=${{ github.ref_name }}" >> $GITHUB_ENV | |
| fi | |
| - name: Checkout correct ref | |
| uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ env.BUILD_REF }} | |
| - name: Set repo name to lowercase | |
| run: echo "LOWER_REPO_NAME=$(echo ${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV | |
| - name: Determine Docker Image Tags | |
| id: tag | |
| run: | | |
| # 1. Determine the "Floating" Tag (e.g., 'dev' or 'dev-featurebranch') | |
| if [[ "${{ env.BRANCH_NAME }}" == "main" ]]; then | |
| echo "FLOAT_TAG=dev" >> $GITHUB_ENV | |
| else | |
| sanitized_branch=$(echo "${{ env.BRANCH_NAME }}" | sed 's/[^a-zA-Z0-9.-]/-/g') | |
| echo "FLOAT_TAG=dev-${sanitized_branch}" >> $GITHUB_ENV | |
| fi | |
| # 2. Determine the "Unique" Tag (e.g., 'dev-371') | |
| # We use the build number that was passed to the build jobs | |
| # Note: We append the build number to the 'dev' prefix to keep them grouped | |
| echo "UNIQUE_TAG=dev-${{ vars.CURRENT_DEV_BUILD_NUM }}" >> $GITHUB_ENV | |
| # --- 2. Logins --- | |
| - name: DockerHub Login | |
| # Skipped on the standalone fork where DOCKERHUB secrets aren't set. | |
| # Operator opts in by setting repo variable DOCKERHUB_PUSH_ENABLED='true'. | |
| if: vars.DOCKERHUB_PUSH_ENABLED == 'true' | |
| uses: docker/login-action@v4 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_PA_TOKEN }} | |
| - name: GitHub Container Registry Login | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| # GH_PAT (read:packages) so BuildKit can COPY --from our private | |
| # pbs-cache mirror; falls back to GITHUB_TOKEN for push-only contexts. | |
| password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} | |
| # --- 3. Merge Digests --- | |
| - name: Download digests | |
| uses: actions/download-artifact@v8 | |
| with: | |
| path: /tmp/digests | |
| pattern: digests-* | |
| merge-multiple: true | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Create manifest list and push | |
| working-directory: /tmp/digests | |
| run: | | |
| # 1. Push to DockerHub (only when DOCKERHUB_PUSH_ENABLED) — both tags | |
| if [ "${{ vars.DOCKERHUB_PUSH_ENABLED }}" = "true" ]; then | |
| docker buildx imagetools create \ | |
| -t ${{ secrets.DOCKERHUB_USERNAME }}/calibre-web-automated:${{ env.FLOAT_TAG }} \ | |
| -t ${{ secrets.DOCKERHUB_USERNAME }}/calibre-web-automated:${{ env.UNIQUE_TAG }} \ | |
| $(printf '${{ secrets.DOCKERHUB_USERNAME }}/calibre-web-automated@sha256:%s ' *) | |
| else | |
| echo "DOCKERHUB_PUSH_ENABLED is not 'true' — skipping DockerHub manifest push (GHCR-only mode)." | |
| fi | |
| # 2. Push to GHCR (Both Tags) — always | |
| docker buildx imagetools create \ | |
| -t ghcr.io/${{ github.repository_owner }}/${{ env.LOWER_REPO_NAME }}:${{ env.FLOAT_TAG }} \ | |
| -t ghcr.io/${{ github.repository_owner }}/${{ env.LOWER_REPO_NAME }}:${{ env.UNIQUE_TAG }} \ | |
| $(printf 'ghcr.io/${{ github.repository_owner }}/${{ env.LOWER_REPO_NAME }}@sha256:%s ' *) | |
| # --- 4. Increment Build Number (Last Step) --- | |
| # Inherited from upstream CWA. Bumps repo variable CURRENT_DEV_BUILD_NUM | |
| # so each dev image gets a unique VERSION=DEV_BUILD-dev-N string. Purely | |
| # cosmetic — the :dev floating tag and :sha-* tags don't depend on it, | |
| # and the image works regardless. Prefer GH_PAT (already configured for | |
| # the translation workflow) over the missing DEV_BUILD_NUM_INCREMENTOR_TOKEN | |
| # secret, and fail-soft: a hiccup in this purely-bookkeeping step must | |
| # never mark an otherwise-successful build red. | |
| - name: Increment dev build number | |
| uses: action-pack/increment@v2 | |
| id: increment | |
| continue-on-error: true | |
| with: | |
| name: 'CURRENT_DEV_BUILD_NUM' | |
| token: ${{ secrets.GH_PAT || secrets.DEV_BUILD_NUM_INCREMENTOR_TOKEN }} |