Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/_main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ permissions:
contents: read
discussions: write
statuses: write
packages: write
Comment thread
rickbrouwer marked this conversation as resolved.
Outdated

concurrency:
group: ${{github.workflow}}-${{ github.ref }}
Expand Down
122 changes: 100 additions & 22 deletions .github/workflows/_releases.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ permissions:
contents: write
packages: write

env:
IMAGE_BASE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}

jobs:
prep-release:
name: Prepare release
Expand All @@ -35,15 +38,14 @@ jobs:
is_valid_release: ${{ steps.validate.outputs.is_valid }}
semver_tag: ${{ steps.validate.outputs.version }}
target_run_id: ${{ steps.validate.outputs.run_id }}
head_sha: ${{ steps.validate.outputs.head_sha }}
steps:
- name: Checkout Code
uses: actions/checkout@v7

- name: Configure uv environment
uses: ./.github/actions/setup-uv-local
with:
python-version: "3.14"

- name: Validate release conditions
id: validate
env:
Expand All @@ -64,6 +66,77 @@ jobs:
MANUAL_UPSTREAM_RUN_ID: ${{ inputs.override_run_id }}
run: uv run glf-dev release gh-workflow-check

publish-to-ghcr-main:
name: Publish GHCR from main build
if: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.name == 'Main branch' }}
runs-on: ubuntu-latest
steps:
- name: Set SHA tags
id: meta
run: |
echo "full_sha=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT"
echo "short_sha=$(echo '${{ github.event.workflow_run.head_sha }}' | cut -c1-12)" >> "$GITHUB_OUTPUT"
- name: Download docker image artifacts (amd64)
uses: actions/download-artifact@v8
with:
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
name: docker-image-amd64
path: .docker
- name: Download docker image artifacts (arm64)
uses: actions/download-artifact@v8
with:
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
name: docker-image-arm64
path: .docker
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push arch-specific images and create multi-arch manifest
run: |
set -euo pipefail
FULL_SHA=${{ steps.meta.outputs.full_sha }}
SHORT_SHA=${{ steps.meta.outputs.short_sha }}
for TAR in .docker/docker-image-*.tar; do
echo "Loading $TAR"
IMAGE_REF=$(docker load --input "$TAR" | sed -n 's/Loaded image: //p')
echo "Loaded: $IMAGE_REF"
ARCH=$(basename "$TAR" | sed -E 's/^docker-image-(.*)\.tar$/\1/')
TARGET_ARCH_TAG_FULL=${IMAGE_BASE}:sha-${FULL_SHA}-${ARCH}
TARGET_ARCH_TAG_SHORT=${IMAGE_BASE}:sha-${SHORT_SHA}-${ARCH}
echo "Tagging $IMAGE_REF -> $TARGET_ARCH_TAG_FULL"
docker tag "$IMAGE_REF" "$TARGET_ARCH_TAG_FULL"
echo "Pushing $TARGET_ARCH_TAG_FULL"
docker push "$TARGET_ARCH_TAG_FULL"
echo "Also tagging $IMAGE_REF -> $TARGET_ARCH_TAG_SHORT"
docker tag "$IMAGE_REF" "$TARGET_ARCH_TAG_SHORT"
echo "Pushing $TARGET_ARCH_TAG_SHORT"
docker push "$TARGET_ARCH_TAG_SHORT"
done

echo "Creating multi-arch manifest: sha-${FULL_SHA}"
docker buildx imagetools create --tag ${IMAGE_BASE}:sha-${FULL_SHA} \
${IMAGE_BASE}:sha-${FULL_SHA}-amd64 \
${IMAGE_BASE}:sha-${FULL_SHA}-arm64

echo "Creating multi-arch manifest: sha-${SHORT_SHA}"
docker buildx imagetools create --tag ${IMAGE_BASE}:sha-${SHORT_SHA} \
${IMAGE_BASE}:sha-${SHORT_SHA}-amd64 \
${IMAGE_BASE}:sha-${SHORT_SHA}-arm64

echo "Creating multi-arch manifest: latest"
docker buildx imagetools create --tag ${IMAGE_BASE}:latest \
${IMAGE_BASE}:sha-${SHORT_SHA}-amd64 \
${IMAGE_BASE}:sha-${SHORT_SHA}-arm64

echo "Published commit image tags for ${FULL_SHA} (short: ${SHORT_SHA})"

publish-docs:
name: Publish Docs
if: ${{ needs.prep-release.outputs.is_valid_release == 'true' }}
Expand Down Expand Up @@ -139,53 +212,58 @@ jobs:
run: |
uv run --no-sync glf-dev release pypi

publish-to-ghcr:
name: Publish GHCR
publish-to-ghcr-release:
name: Publish GHCR (release)
if: ${{ needs.prep-release.outputs.is_valid_release == 'true' }}
needs:
- prep-release
- publish-to-pypi
- publish-to-ghcr-main
runs-on: ubuntu-latest
steps:
# TODO: In a separate PR, optimize docker image build to consume the built package artifact
# (package-dist) instead of copying and rebuilding from source if applicable.
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ needs.prep-release.outputs.semver_tag }}
fetch-depth: 2
- name: Docker metadata
id: metadata
uses: docker/metadata-action@v6
with:
# Get the ref and the sha from the checked out context - e.g. semver tag rather than triggering branch
context: git
images: |
ghcr.io/gitlabform/gitlabform
${{ env.IMAGE_BASE }}
flavor: |
latest=auto
tags: |
type=pep440,pattern={{version}}
type=pep440,pattern={{major}}.{{minor}}
type=pep440,pattern={{major}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# gitlabform/gitlabform image is going to be here
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
# This token is provided by Actions, you do not need to create your own token
password: ${{ secrets.GITHUB_TOKEN }}
# Docker image will use UV internally and builds the image from the Codebase at the current tag, rather than downloding from PyPi
- name: Publish image to registries
uses: docker/build-push-action@v7
with:
push: true
context: .
file: Dockerfile
platforms: linux/amd64,linux/arm64
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
- name: Verify source image exists before promotion
run: |
set -euo pipefail
SOURCE=${IMAGE_BASE}:sha-${{ needs.prep-release.outputs.head_sha }}
echo "Checking source image: ${SOURCE}"
docker buildx imagetools inspect "$SOURCE" >/dev/null
- name: Promote image to release tags
run: |
set -euo pipefail
SOURCE=${IMAGE_BASE}:sha-${{ needs.prep-release.outputs.head_sha }}
echo "Promoting ${SOURCE} to:"
echo "${{ steps.metadata.outputs.tags }}"

while IFS= read -r tag; do
[ -n "$tag" ] || continue
echo "Creating docker image tag: $tag"
docker buildx imagetools create --tag "$tag" "$SOURCE"
done <<EOF
${{ steps.metadata.outputs.tags }}
EOF
59 changes: 59 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,62 @@ jobs:
run: uv sync --frozen --no-dev --group test
- name: Run smoke tests
run: uv run --no-sync gitlabform -V

docker:
name: Docker image (${{ matrix.arch }})
runs-on: ubuntu-latest
needs: package
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
platform: linux/amd64
tag_suffix: amd64
tarball: docker-image-amd64.tar
- arch: arm64
platform: linux/arm64
tag_suffix: arm64
tarball: docker-image-arm64.tar
# PRs and branch validation must confirm the image is healthy without exposing
# registry credentials to the build job. The actual GHCR publication happens in
# the release workflow after a successful main-branch run.
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: ${{ inputs.BRANCH_REF }}
fetch-depth: 2
- name: Download built package artifact
uses: actions/download-artifact@v8
with:
name: package-dist
path: dist
- name: Configure uv environment
uses: ./.github/actions/setup-uv-local
with:
python-version: "3.14"
- name: Install dependencies
run: uv sync --frozen --no-dev
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Create archive directory
run: mkdir -p .docker
- name: Build image for PR validation
run: |
uv run --no-sync glf-dev docker build \
--tag localhost/gitlabform:latest \
--platform ${{ matrix.platform }} \
--output type=docker,dest=.docker/${{ matrix.tarball }}
- name: Verify image
run: |
uv run --no-sync glf-dev docker verify \
--tag localhost/gitlabform:latest \
--input .docker/${{ matrix.tarball }}
- name: Upload docker image archive
uses: actions/upload-artifact@v7
with:
name: docker-image-${{ matrix.tag_suffix }}
path: .docker/${{ matrix.tarball }}
7 changes: 3 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,9 @@ COPY pyproject.toml uv.lock ./
# Install dependencies into a virtualenv. This layer is cached based on uv.lock.
RUN uv sync --frozen --no-dev --no-install-project

# Now copy the application source code and install the project itself
COPY gitlabform ./gitlabform
COPY README.md ./
RUN uv sync --frozen --no-dev --no-editable
# Copy the built wheel artifact and install it into the builder virtualenv.
COPY dist /dist
RUN uv pip install --python /app/.venv/bin/python /dist/gitlabform-*.whl

# ---- Final Stage ----
FROM python:3.14-alpine AS final
Expand Down
61 changes: 45 additions & 16 deletions dev/docker.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,76 @@
"""Tasks related to building and verifying Docker images."""

import argparse
from pathlib import Path

from dev.common import REPO_ROOT, logger, run_command, get_executable
from dev.release import publish_docker


def build(extra_args: list[str] | None = None):
"""Builds the GitLabForm Docker image.
"""Builds the GitLabForm Docker image from a prebuilt wheel in dist/.

The Dockerfile installs the packaged wheel rather than building from the source tree,
so the local prerequisite is always: `uv run package build` before `uv run docker build`.

Local development usually builds for the host architecture only (for example, `docker buildx build`
with no explicit `--platform` override). CI uses the same toolkit commands but passes explicit
`--platform` values for multi-arch validation; the reusable build workflow validates each target
architecture without pushing registry credentials. This keeps the CLI surface consistent across
local development and GitHub Actions while the actual GHCR publication remains in the release
workflow.

Args:
extra_args: Arguments for the docker build command (e.g., --image, --tag, --push).
extra_args: Arguments for the docker build command (e.g., --tag, --push, --output).
"""
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--image", default="localhost/gitlabform")
parser.add_argument("--tag", default="latest")
parser.add_argument("--tag", default="localhost/gitlabform:latest")
parser.add_argument("--push", action="store_true", help="Automatically push after build")
parser.add_argument(
"--output", help="Write the built image to an archive file (for example: type=docker,dest=/tmp/image.tar)"
)

parsed, remaining = parser.parse_known_args(extra_args or [])
image_name = f"{parsed.image}:{parsed.tag}"
# image_name = f"{parsed.tag}"

# TODO: Add a check here if the Dockerfile is ever updated to require
# pre-built wheels from the 'dist/' directory.
# Ensure the Docker build has access to the prebuilt wheel artifact.
dist_dir = REPO_ROOT / "dist"
if not dist_dir.exists() or not any(dist_dir.glob("*.whl")):
logger.error("No Python wheel found in dist/. Run `uv run package build` before building the Docker image.")
raise SystemExit(1)

docker_bin = get_executable("docker")
# Note: REPO_ROOT is the context, so the Dockerfile can access dist/ if needed.
build_cmd = [docker_bin, "build", "--pull", "-t", image_name] + remaining + [str(REPO_ROOT)]
build_cmd = [docker_bin, "buildx", "build", "--pull", "-t", parsed.tag]
if parsed.output:
build_cmd.extend(["--output", parsed.output])
build_cmd.extend(remaining)
build_cmd.append(str(REPO_ROOT))

run_command(build_cmd, f"Building Docker image: [bold cyan]{image_name}[/bold cyan]")
# Log the exact docker command we are going to run.
logger.info(f"[bold blue]==>[/bold blue] Executing: {build_cmd}")
run_command(build_cmd, f"Building Docker image: [bold cyan]{parsed.tag}[/bold cyan]")

if parsed.push:
# Delegate to the release domain to ensure consistent push logic
publish_docker([f"--image={parsed.image}", f"--tag={parsed.tag}"])
publish_docker([f"--tag={parsed.tag}"])
Comment thread
rickbrouwer marked this conversation as resolved.
Outdated


def verify(extra_args: list[str] | None = None):
"""Verifies the built Docker image with a smoke test."""
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--image", default="localhost/gitlabform")
parser.add_argument("--tag", default="latest")
parser.add_argument("--tag", default="localhost/gitlabform:latest")
parser.add_argument("--input", help="Load a Docker image archive from disk before verifying it")

parsed, remaining = parser.parse_known_args(extra_args or [])
image_name = f"{parsed.image}:{parsed.tag}"

docker_bin = get_executable("docker")
cmd = [docker_bin, "run", "--rm"] + remaining + [image_name, "gitlabform", "--version"]
run_command(cmd, f"Verifying Docker image: [bold cyan]{image_name}[/bold cyan]")
if parsed.input:
archive = Path(parsed.input).expanduser()
if not archive.exists():
logger.error(f"Docker archive not found: {archive}")
raise SystemExit(1)
load_cmd = [docker_bin, "load", "--input", str(archive)]
run_command(load_cmd, f"Loading Docker image archive: [bold cyan]{archive}[/bold cyan]")

cmd = [docker_bin, "run", "--rm"] + remaining + [parsed.tag, "gitlabform", "--version"]
run_command(cmd, f"Verifying Docker image: [bold cyan]{parsed.tag}[/bold cyan]")
Loading
Loading