Skip to content

fix(i18n): resolve ISO 639-2/B language codes and stop gating validit… #706

fix(i18n): resolve ISO 639-2/B language codes and stop gating validit…

fix(i18n): resolve ISO 639-2/B language codes and stop gating validit… #706

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 }}