Skip to content

chore(deps): bump the go-minor-and-patch group #239

chore(deps): bump the go-minor-and-patch group

chore(deps): bump the go-minor-and-patch group #239

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"