fix(frontend): forwardAuth ergänzt X-Pangolin-Token + Remote-User (Ad… #233
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: Docker — publish to GHCR + Docker Hub | |
| # ============================================================================= | |
| # Two-phase build to keep arm64 off QEMU. | |
| # | |
| # Phase 1 (`build`): matrix = service × platform → 4 jobs, each on a NATIVE | |
| # runner (amd64 on ubuntu-24.04, arm64 on ubuntu-24.04-arm). Each job | |
| # builds its single-platform image, pushes it BY DIGEST (no tag), and | |
| # uploads the digest as an artifact for the merge phase. | |
| # | |
| # Phase 2 (`merge`): matrix = service → 2 jobs. Downloads all digests for | |
| # its service, asks the registry to compose a multi-arch manifest index | |
| # pointing at the per-platform digests, and applies every tag and the | |
| # index-level annotations in one shot via `docker buildx imagetools | |
| # create`. No re-build, no re-push of layers — just manifest assembly. | |
| # | |
| # Why: QEMU-emulated arm64 Python+Pillow builds took ~3 minutes per leg. | |
| # Native arm64 runners (free for public repos since 2025-01) bring that | |
| # down to ~30 seconds. End-to-end run time is now bounded by the slowest | |
| # single-platform build, not the sum of all platforms in series. | |
| # ============================================================================= | |
| on: | |
| push: | |
| branches: ['**'] | |
| # Only rebuild when something that affects an image actually changes — | |
| # avoids tying up runners on docs-only pushes. | |
| paths: | |
| - 'backend/**' | |
| - 'frontend/**' | |
| - '.github/workflows/docker-publish.yml' | |
| release: | |
| types: [published] | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: "Image tag (without v prefix), e.g. 0.1.0. Leave empty to build a dev/branch image instead." | |
| required: false | |
| permissions: | |
| contents: read | |
| packages: write | |
| id-token: write | |
| # Per-ref grouping: cancel an older in-progress run on the same branch when | |
| # a new push arrives. Release and manually-dispatched builds are NEVER | |
| # cancelled — losing a half-published release would leave the registry | |
| # in an inconsistent state. | |
| concurrency: | |
| group: docker-publish-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'push' }} | |
| env: | |
| REGISTRY_GHCR: ghcr.io | |
| # Annotate both per-platform manifests AND the index. GHCR's package UI | |
| # reads the package description from the index annotation; without this | |
| # the page shows "No description provided". | |
| DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index | |
| jobs: | |
| # --------------------------------------------------------------------------- | |
| # Phase 1: build each (service, platform) leg on its NATIVE runner. | |
| # --------------------------------------------------------------------------- | |
| build: | |
| name: Build (${{ matrix.service }} / ${{ matrix.arch }}) | |
| runs-on: ${{ matrix.runner }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| service: [backend, frontend] | |
| platform: | |
| - linux/amd64 | |
| - linux/arm64 | |
| # `include` adds the runner/arch fields to each existing matrix | |
| # combination that matches the `platform` key, without multiplying | |
| # the matrix further. Net result: 4 jobs (2 services × 2 platforms), | |
| # each with the right runner. | |
| include: | |
| - platform: linux/amd64 | |
| runner: ubuntu-24.04 | |
| arch: amd64 | |
| - platform: linux/arm64 | |
| runner: ubuntu-24.04-arm | |
| arch: arm64 | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Verify Dockerfile exists for ${{ matrix.service }} | |
| run: test -f "${{ matrix.service }}/Dockerfile" | |
| # GitHub's repo path retains the user's original capitalisation | |
| # (`strausmann/Label-Printer-Hub`), but Docker/OCI registry refs MUST | |
| # be all-lowercase. `docker/metadata-action` does this implicitly for | |
| # its own outputs, but `build-push-action`'s `outputs: name=…` is a | |
| # raw passthrough — we have to lowercase the path ourselves before | |
| # using it in the digest-push reference and in the merge step. | |
| - name: Compute lowercase image ref | |
| id: image | |
| env: | |
| REPO: ${{ github.repository }} | |
| run: | | |
| repo_lc="${REPO,,}" | |
| echo "ref=${{ env.REGISTRY_GHCR }}/${repo_lc}-${{ matrix.service }}" >> "$GITHUB_OUTPUT" | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ${{ env.REGISTRY_GHCR }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| # `secrets.*` is not allowed in step-level `if:` expressions — we | |
| # surface the value into the step env first, then condition on env.*. | |
| - name: Log in to Docker Hub | |
| env: | |
| DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} | |
| DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} | |
| if: env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != '' | |
| uses: docker/login-action@v4 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| # Classify what we're building AND derive a version-like value for | |
| # labels/build-args. Three flavors: | |
| # release semver published or manually dispatched with a tag — | |
| # image is tagged {{version}}, {{major.minor}}, {{major}}, | |
| # and latest (for stable releases). | |
| # dev push to the default branch — image is tagged "dev". | |
| # branch push to any other branch — image is tagged with the | |
| # sanitised branch slug ('/' replaced with '-'). | |
| - name: Determine flavor and tag | |
| id: tag | |
| env: | |
| EVENT: ${{ github.event_name }} | |
| REF_NAME: ${{ github.ref_name }} | |
| DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} | |
| INPUT_TAG: ${{ inputs.tag }} | |
| RELEASE_TAG: ${{ github.event.release.tag_name }} | |
| run: | | |
| set -eu | |
| if [ "$EVENT" = "release" ]; then | |
| flavor=release | |
| tag="${RELEASE_TAG#v}" | |
| elif [ "$EVENT" = "workflow_dispatch" ] && [ -n "$INPUT_TAG" ]; then | |
| flavor=release | |
| tag="${INPUT_TAG#v}" | |
| elif [ "$REF_NAME" = "$DEFAULT_BRANCH" ]; then | |
| flavor=dev | |
| tag=dev | |
| else | |
| flavor=branch | |
| # Slashes are illegal in OCI tags — mirror metadata-action's slug. | |
| tag="${REF_NAME//\//-}" | |
| fi | |
| echo "flavor=$flavor" >> "$GITHUB_OUTPUT" | |
| echo "tag=$tag" >> "$GITHUB_OUTPUT" | |
| # The build phase only needs labels (per-platform metadata). Tags | |
| # and index-level annotations are applied in the merge phase. | |
| - name: Docker metadata | |
| id: meta | |
| uses: docker/metadata-action@v6 | |
| with: | |
| images: | | |
| ${{ steps.image.outputs.ref }} | |
| labels: | | |
| org.opencontainers.image.title=label-printer-hub-${{ matrix.service }} | |
| org.opencontainers.image.description=Self-hosted label printer hub for Brother PT/QL series — ${{ matrix.service }} container | |
| org.opencontainers.image.url=https://github.com/strausmann/label-printer-hub | |
| org.opencontainers.image.source=https://github.com/strausmann/label-printer-hub | |
| org.opencontainers.image.licenses=MIT | |
| org.opencontainers.image.version=${{ steps.tag.outputs.tag }} | |
| org.opencontainers.image.revision=${{ github.sha }} | |
| - name: Compute build date | |
| id: builddate | |
| run: echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" | |
| # Push BY DIGEST: registry stores the blob and returns sha256:…, | |
| # but no tag is created yet. We capture the digest and hand it | |
| # off to the merge phase via an artifact. | |
| - name: Build and push by digest | |
| id: build | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: ./${{ matrix.service }} | |
| file: ./${{ matrix.service }}/Dockerfile | |
| platforms: ${{ matrix.platform }} | |
| labels: ${{ steps.meta.outputs.labels }} | |
| # `push-by-digest=true` skips the tag write and returns digest in | |
| # `steps.build.outputs.digest`. `name-canonical=true` makes the | |
| # registry reference the image by its canonical name. The image | |
| # ref MUST be lowercase — see the `Compute lowercase image ref` | |
| # step above. | |
| outputs: type=image,name=${{ steps.image.outputs.ref }},push-by-digest=true,name-canonical=true,push=true | |
| build-args: | | |
| VERSION=${{ steps.tag.outputs.tag }} | |
| REVISION=${{ github.sha }} | |
| BUILD_DATE=${{ steps.builddate.outputs.date }} | |
| # Cache scope is per (service, arch) so amd64 and arm64 builds | |
| # don't trash each other's caches. | |
| cache-from: type=gha,scope=${{ matrix.service }}-${{ matrix.arch }} | |
| cache-to: type=gha,mode=max,scope=${{ matrix.service }}-${{ matrix.arch }} | |
| provenance: true | |
| sbom: true | |
| # Each leg writes its digest into a per-leg file (no name collisions), | |
| # then uploads it as an artifact. The merge phase downloads all of | |
| # them at once. | |
| - name: Export digest | |
| run: | | |
| mkdir -p /tmp/digests | |
| digest="${{ steps.build.outputs.digest }}" | |
| # Strip the "sha256:" prefix — buildx imagetools wants just the hex. | |
| # Filename is the bare digest; we don't need the arch in the name | |
| # because the digest is already unique. | |
| touch "/tmp/digests/${digest#sha256:}" | |
| - name: Upload digest artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: digests-${{ matrix.service }}-${{ matrix.arch }} | |
| path: /tmp/digests/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| # --------------------------------------------------------------------------- | |
| # Phase 2: compose the multi-arch manifest index for each service. | |
| # --------------------------------------------------------------------------- | |
| merge: | |
| name: Merge manifest (${{ matrix.service }}) | |
| runs-on: ubuntu-24.04 | |
| needs: build | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| service: [backend, frontend] | |
| steps: | |
| - name: Download digests for ${{ matrix.service }} | |
| uses: actions/download-artifact@v8 | |
| with: | |
| # Wildcard merges artifacts from every (service, arch) leg. | |
| pattern: digests-${{ matrix.service }}-* | |
| path: /tmp/digests | |
| merge-multiple: true | |
| # Mirror of the build-phase lowercase step (see comment there). | |
| - name: Compute lowercase image ref | |
| id: image | |
| env: | |
| REPO: ${{ github.repository }} | |
| run: | | |
| repo_lc="${REPO,,}" | |
| echo "ref=${{ env.REGISTRY_GHCR }}/${repo_lc}-${{ matrix.service }}" >> "$GITHUB_OUTPUT" | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Log in to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ${{ env.REGISTRY_GHCR }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Log in to Docker Hub | |
| env: | |
| DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} | |
| DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} | |
| if: env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != '' | |
| uses: docker/login-action@v4 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| # Classify what we're building AND derive a version-like value for | |
| # labels/build-args. Three flavors: | |
| # release semver published or manually dispatched with a tag — | |
| # image is tagged {{version}}, {{major.minor}}, {{major}}, | |
| # and latest (for stable releases). | |
| # dev push to the default branch — image is tagged "dev". | |
| # branch push to any other branch — image is tagged with the | |
| # sanitised branch slug ('/' replaced with '-'). | |
| - name: Determine flavor and tag | |
| id: tag | |
| env: | |
| EVENT: ${{ github.event_name }} | |
| REF_NAME: ${{ github.ref_name }} | |
| DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} | |
| INPUT_TAG: ${{ inputs.tag }} | |
| RELEASE_TAG: ${{ github.event.release.tag_name }} | |
| run: | | |
| set -eu | |
| if [ "$EVENT" = "release" ]; then | |
| flavor=release | |
| tag="${RELEASE_TAG#v}" | |
| elif [ "$EVENT" = "workflow_dispatch" ] && [ -n "$INPUT_TAG" ]; then | |
| flavor=release | |
| tag="${INPUT_TAG#v}" | |
| elif [ "$REF_NAME" = "$DEFAULT_BRANCH" ]; then | |
| flavor=dev | |
| tag=dev | |
| else | |
| flavor=branch | |
| # Slashes are illegal in OCI tags — mirror metadata-action's slug. | |
| tag="${REF_NAME//\//-}" | |
| fi | |
| echo "flavor=$flavor" >> "$GITHUB_OUTPUT" | |
| echo "tag=$tag" >> "$GITHUB_OUTPUT" | |
| # Metadata again — the merge job needs the tag list and the | |
| # index-level annotations. Labels are NOT applied here (they live | |
| # on each per-platform manifest from the build phase). | |
| - name: Docker metadata | |
| id: meta | |
| uses: docker/metadata-action@v6 | |
| with: | |
| # GHCR is always a target. Docker Hub is only published for release | |
| # flavors so branch/dev builds don't pollute the public namespace. | |
| images: | | |
| ${{ steps.image.outputs.ref }} | |
| ${{ (steps.tag.outputs.flavor == 'release' && secrets.DOCKERHUB_USERNAME) && format('docker.io/{0}/label-printer-hub-{1}', secrets.DOCKERHUB_USERNAME, matrix.service) || '' }} | |
| # Tag scheme by flavor: | |
| # branch <branch-slug> feature-branch push | |
| # dev dev main-branch push | |
| # release {{version}}, {{major.minor}}, semver release | |
| # {{major}}, latest (stable only) | |
| tags: | | |
| type=ref,event=branch,enable=${{ steps.tag.outputs.flavor == 'branch' }} | |
| type=raw,value=dev,enable=${{ steps.tag.outputs.flavor == 'dev' }} | |
| type=semver,pattern={{version}},value=${{ steps.tag.outputs.tag }},enable=${{ steps.tag.outputs.flavor == 'release' }} | |
| type=semver,pattern={{major}}.{{minor}},value=${{ steps.tag.outputs.tag }},enable=${{ steps.tag.outputs.flavor == 'release' && !contains(steps.tag.outputs.tag, '-') }} | |
| type=semver,pattern={{major}},value=${{ steps.tag.outputs.tag }},enable=${{ steps.tag.outputs.flavor == 'release' && !contains(steps.tag.outputs.tag, '-') }} | |
| type=raw,value=latest,enable=${{ steps.tag.outputs.flavor == 'release' && !contains(steps.tag.outputs.tag, '-') }} | |
| # Annotations are emitted at both manifest and index level (see env | |
| # at workflow root). For a manifest-list-create operation only the | |
| # index level applies — the merge step filters them. | |
| annotations: | | |
| org.opencontainers.image.title=label-printer-hub-${{ matrix.service }} | |
| org.opencontainers.image.description=Self-hosted label printer hub for Brother PT/QL series — ${{ matrix.service }} container | |
| org.opencontainers.image.url=https://github.com/strausmann/label-printer-hub | |
| org.opencontainers.image.source=https://github.com/strausmann/label-printer-hub | |
| org.opencontainers.image.licenses=MIT | |
| org.opencontainers.image.version=${{ steps.tag.outputs.tag }} | |
| org.opencontainers.image.revision=${{ github.sha }} | |
| # `docker buildx imagetools create` composes a manifest list from | |
| # source digests and pushes it to the registry under one or more tags. | |
| # Annotations passed with `--annotation index:KEY=VAL` land on the | |
| # multi-arch index (the only place a list-create can write to). | |
| - name: Create manifest list and push | |
| working-directory: /tmp/digests | |
| env: | |
| DIGEST_REPO: ${{ steps.image.outputs.ref }} | |
| TAGS: ${{ steps.meta.outputs.tags }} | |
| ANNOTATIONS: ${{ steps.meta.outputs.annotations }} | |
| run: | | |
| set -euo pipefail | |
| tag_args=() | |
| while IFS= read -r tag; do | |
| [ -z "$tag" ] && continue | |
| tag_args+=( -t "$tag" ) | |
| done <<< "$TAGS" | |
| # Forward only `index:` annotations — `manifest:` annotations | |
| # come from the per-platform build phase and are already baked | |
| # into each leg's manifest. | |
| ann_args=() | |
| while IFS= read -r ann; do | |
| case "$ann" in | |
| index:*) ann_args+=( --annotation "$ann" ) ;; | |
| esac | |
| done <<< "$ANNOTATIONS" | |
| # Source manifest references: REPO@sha256:DIGEST for each digest | |
| # file present (filenames are the bare hex digests). | |
| src_args=() | |
| for d in *; do | |
| src_args+=( "${DIGEST_REPO}@sha256:${d}" ) | |
| done | |
| docker buildx imagetools create "${tag_args[@]}" "${ann_args[@]}" "${src_args[@]}" | |
| - name: Verify multi-arch manifest | |
| env: | |
| TAGS: ${{ steps.meta.outputs.tags }} | |
| run: | | |
| set -euo pipefail | |
| fail=0 | |
| while IFS= read -r tag; do | |
| [ -z "$tag" ] && continue | |
| echo "Inspecting $tag …" | |
| archs=$(docker buildx imagetools inspect "$tag" --raw \ | |
| | jq -r '.manifests[].platform | "\(.os)/\(.architecture)"' \ | |
| | sort -u) | |
| echo " architectures: $(echo "$archs" | tr '\n' ' ')" | |
| if ! echo "$archs" | grep -q "linux/amd64"; then | |
| echo "::error::Missing linux/amd64 in $tag" | |
| fail=1 | |
| fi | |
| if ! echo "$archs" | grep -q "linux/arm64"; then | |
| echo "::error::Missing linux/arm64 in $tag" | |
| fail=1 | |
| fi | |
| done <<< "$TAGS" | |
| exit "$fail" |