Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
44 changes: 27 additions & 17 deletions .github/workflows/build-image.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Reusable: builds an image repo's Dockerfile for every required platform and pushes it
# to GHCR (always) and Docker Hub (when credentials exist). Repo-specific values come from
# the caller's caddy-image.json; image names come from the caller's repository name.
# Reusable: builds an image repo's Dockerfile for every platform it ships and pushes it
# to GHCR (always) and Docker Hub (when credentials exist). Repo-specific values, the
# platform list included, come from the caller's caddy-image.json; image names come from
# the caller's repository name.
#
# Caller:
# jobs:
Expand Down Expand Up @@ -40,6 +41,11 @@ on:
required: false
type: string
default: Dockerfile
base_repository:
description: 'Repository that hosts this workflow and the config loader (override in forks of caddy-base)'
required: false
type: string
default: CaddyBuilds/caddy-base
secrets:
DOCKERHUB_USERNAME:
required: false
Expand All @@ -52,9 +58,6 @@ permissions:
contents: write
packages: write

env:
PLATFORMS: linux/amd64,linux/arm64,linux/arm/v7,linux/ppc64le,linux/s390x

jobs:
build-and-push:
runs-on: ubuntu-latest
Expand All @@ -63,6 +66,16 @@ jobs:
- name: Checkout
uses: actions/checkout@v7

# The config schema (keys, defaults, allowed platforms) lives in the Python package
# next to this workflow, so the loader is checked out at this same commit and the
# checker and the build can never disagree. Removed again to keep the build context clean.
- name: Checkout caddy-base
uses: actions/checkout@v7
with:
repository: ${{ inputs.base_repository }}
ref: ${{ job.workflow_sha || github.job_workflow_sha }}
path: .caddy-base

- name: Read image config
id: cfg
env:
Expand All @@ -71,24 +84,20 @@ jobs:
run: |
test -f "$CONFIG" || { echo "::error::Image config '$CONFIG' not found."; exit 1; }
test -f "$DOCKERFILE" || { echo "::error::Dockerfile '$DOCKERFILE' not found."; exit 1; }
for key in title description verify_module; do
value=$(jq -er --arg k "$key" '.[$k] // empty' "$CONFIG") || { echo "::error::'$key' missing in $CONFIG"; exit 1; }
echo "$key=$value" >> "$GITHUB_OUTPUT"
done
for key in alpine_alias:true slim:false create_release:true; do
name=${key%%:*}; default=${key##*:}
echo "$name=$(jq -r --arg k "$name" --argjson d "$default" 'if has($k) then .[$k] else $d end' "$CONFIG")" >> "$GITHUB_OUTPUT"
done
PYTHONPATH=.caddy-base/scripts python3 -m check_caddy_status.image_config "$CONFIG" > "$RUNNER_TEMP/image-config.env"
cat "$RUNNER_TEMP/image-config.env" >> "$GITHUB_OUTPUT"
slim=$(sed -n 's/^slim=//p' "$RUNNER_TEMP/image-config.env")
# Build the named "default" stage when the Dockerfile has one, else the last stage.
if grep -qiE '^\s*FROM\s.*\sAS\s+default\s*$' "$DOCKERFILE"; then
echo "default_target=default" >> "$GITHUB_OUTPUT"
else
echo "default_target=" >> "$GITHUB_OUTPUT"
if [ "$(jq -r '.slim // false' "$CONFIG")" = "true" ]; then
if [ "$slim" = "true" ]; then
echo "::error::slim is enabled but '$DOCKERFILE' has no 'AS default' stage."; exit 1
fi
fi
echo "base_ref=${{ job.workflow_sha || github.job_workflow_sha }}" >> "$GITHUB_OUTPUT"
rm -rf .caddy-base
cat "$GITHUB_OUTPUT"

- name: Set up QEMU
Expand Down Expand Up @@ -184,7 +193,7 @@ jobs:
push: true
build-args: |
CADDY_VERSION=${{ env.VERSION }}
platforms: ${{ env.PLATFORMS }}
platforms: ${{ steps.cfg.outputs.platforms }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
Expand Down Expand Up @@ -233,7 +242,7 @@ jobs:
push: true
build-args: |
CADDY_VERSION=${{ env.VERSION }}
platforms: ${{ env.PLATFORMS }}
platforms: ${{ steps.cfg.outputs.platforms }}
tags: ${{ steps.meta_slim.outputs.tags }}
labels: ${{ steps.meta_slim.outputs.labels }}
annotations: ${{ steps.meta_slim.outputs.annotations }}
Expand All @@ -244,6 +253,7 @@ jobs:
env:
MODULE: ${{ steps.cfg.outputs.verify_module }}
SLIM: ${{ steps.cfg.outputs.slim }}
PLATFORMS: ${{ steps.cfg.outputs.platforms }}
run: |
images="${GHCR_IMAGE}:${VERSION}"
[ "$SLIM" = "true" ] && images="$images ${GHCR_IMAGE}:${VERSION}-slim"
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ emulated; only the final `COPY` stage runs per platform. See [`examples/Dockerfi
"verify_module": "dns.providers.cloudflare",
"alpine_alias": true,
"slim": false,
"create_release": true
"create_release": true,
"platforms": ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/ppc64le", "linux/s390x"]
}
```

Expand All @@ -63,6 +64,10 @@ emulated; only the final `COPY` stage runs per platform. See [`examples/Dockerfi
| `alpine_alias` | no | `true` | Also push `X.Y.Z-alpine`, `X.Y-alpine`, `X-alpine` and `alpine` as aliases of the default image |
| `slim` | no | `false` | Also build the Dockerfile's `slim` stage and push `X.Y.Z-slim`, `X.Y-slim`, `X-slim`, `slim` |
| `create_release` | no | `true` | Create a GitHub Release per Caddy version and append rebuild notes to it |
| `platforms` | no | the five above | Platforms to build, verify and require upstream. Any non-empty subset of what the official image publishes: `linux/amd64`, `linux/arm64`, `linux/arm/v6`, `linux/arm/v7`, `linux/ppc64le`, `linux/riscv64`, `linux/s390x` |

The schema lives in `scripts/check_caddy_status/image_config.py`; the build workflow runs
that module to read the file, so the checker and the build always agree on it.

**Two caller workflows**, copied from [`examples/.github/workflows/`](examples/.github/workflows/):

Expand Down Expand Up @@ -91,10 +96,10 @@ Docker Hub is used when `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` secrets exist

## How the check decides to build

1. Fetch the latest Caddy release and confirm the official image has every required platform.
1. Fetch the latest Caddy release and confirm the official image has every platform we ship.
2. If our image for that version is missing a platform on GHCR, build (`new_caddy_version`).
3. Compare each module's latest commit with `module-versions.json`; on change, build (`module_update`) and commit the new state.
4. Compare the official image's push time with our build time; if upstream republished, build (`base_image_update`).
4. Compare the official image's push time (for the platforms we ship) with our build time; if upstream republished, build (`base_image_update`).

The reason and details are passed to the build and recorded in the image labels
(`caddy-build.reason`, `caddy-build.details`, `caddy-build.base-workflow`) and the release notes.
Expand Down
9 changes: 8 additions & 1 deletion examples/caddy-image.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,12 @@
"verify_module": "dns.providers.cloudflare",
"alpine_alias": true,
"slim": false,
"create_release": true
"create_release": true,
"platforms": [
"linux/amd64",
"linux/arm64",
"linux/arm/v7",
"linux/ppc64le",
"linux/s390x"
]
}
34 changes: 16 additions & 18 deletions scripts/check_caddy_status/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from pathlib import Path

from .base_image import check_base_image_updates
from .config import CUSTOM_IMAGE, GHCR_IMAGE, OFFICIAL_CADDY_IMAGE, REQUIRED_PLATFORMS
from .config import CUSTOM_IMAGE, GHCR_IMAGE, OFFICIAL_CADDY_IMAGE
from .docker_hub import check_docker_hub_tag, get_platforms_from_tag_data
from .dockerfile import DockerfileError, parse_xcaddy_modules
from .ghcr import check_ghcr_tag
Expand All @@ -39,9 +39,6 @@ def main():
if dry_run:
log.info("*** DRY-RUN MODE — no outputs will be set, no files written ***")

if not REQUIRED_PLATFORMS:
log.error("Configuration error: REQUIRED_PLATFORMS set is empty.")
sys.exit(1)
if not GHCR_IMAGE and not CUSTOM_IMAGE:
log.error("Configuration error: set GITHUB_REPOSITORY, GHCR_IMAGE, or DOCKERHUB_REPOSITORY_NAME.")
sys.exit(1)
Expand All @@ -54,10 +51,11 @@ def main():
log.error(f"Configuration error: {e}")
sys.exit(1)
versions_file = root / args.versions_file
platforms = set(image.platforms)

log.info(f"Image: {image.title} — {image.description}")
log.info(f"Modules: {', '.join(m['module'] for m in modules)}")
log.info(f"Required platforms: {REQUIRED_PLATFORMS}")
log.info(f"Platforms: {', '.join(image.platforms)}")
log.info(f"GHCR image: {GHCR_IMAGE or '(not set)'}")
log.info(f"Docker Hub image: {CUSTOM_IMAGE or '(not set)'}")

Expand All @@ -68,7 +66,7 @@ def main():

# Step 1: Check official Caddy image on Docker Hub
log.info(f"\nStep 1: Checking official image '{OFFICIAL_CADDY_IMAGE}:{docker_tag}'...")
official_image_ready = _check_official_image(docker_tag)
official_image_ready = _check_official_image(docker_tag, platforms)

if not official_image_ready:
log.info("Result: Official image is not ready. No build triggered.")
Expand All @@ -79,7 +77,7 @@ def main():

# Step 2: Check custom image on GHCR + Docker Hub
log.info(f"\nStep 2: Checking custom image status...")
custom_image_complete = _check_custom_image(docker_tag)
custom_image_complete = _check_custom_image(docker_tag, platforms)

# Step 3: Check module updates
log.info(f"\nStep 3: Checking module updates...")
Expand All @@ -96,7 +94,7 @@ def main():
base_details = []
if custom_image_complete and not modules_changed:
log.info(f"\nStep 4: Checking official base image updates...")
base_details = check_base_image_updates(docker_tag, image.official_variant_suffixes)
base_details = check_base_image_updates(docker_tag, image.official_variant_suffixes, platforms)

# Final decision
needs_build = False
Expand All @@ -123,14 +121,14 @@ def main():
log.info(f"\n--- Check finished at {end_time.isoformat()} (Duration: {end_time - start_time}) ---")


def _check_official_image(docker_tag):
"""Returns True if the official Caddy image has all required platforms."""
def _check_official_image(docker_tag, platforms):
"""Returns True if the official Caddy image has every platform we ship."""
result, data = check_docker_hub_tag(OFFICIAL_CADDY_IMAGE, docker_tag)

if result == TagCheckResult.FOUND:
found = get_platforms_from_tag_data(data)
found = get_platforms_from_tag_data(data, platforms)
log.info(f" Found platforms: {found or '{}'}")
missing = REQUIRED_PLATFORMS - found
missing = platforms - found
if not missing:
log.info(" Official image has all required platforms.")
log.set_summary("Official Image", "Ready (all platforms)")
Expand All @@ -147,18 +145,18 @@ def _check_official_image(docker_tag):
return False


def _check_custom_image(docker_tag):
"""Returns True if the custom image exists with all platforms on GHCR or Docker Hub."""
def _check_custom_image(docker_tag, platforms):
"""Returns True if the custom image exists with every platform we ship on GHCR or Docker Hub."""
custom_complete = False
ghcr_result = None

# GHCR (primary)
if GHCR_IMAGE:
log.info(f" Checking GHCR: ghcr.io/{GHCR_IMAGE}:{docker_tag}")
ghcr_result, ghcr_data = check_ghcr_tag(GHCR_IMAGE, docker_tag)
ghcr_result, ghcr_data = check_ghcr_tag(GHCR_IMAGE, docker_tag, platforms)

if ghcr_result == TagCheckResult.FOUND:
missing = REQUIRED_PLATFORMS - ghcr_data
missing = platforms - ghcr_data
if not missing:
custom_complete = True
log.info(" GHCR image complete.")
Expand All @@ -182,8 +180,8 @@ def _check_custom_image(docker_tag):
dh_result, dh_data = check_docker_hub_tag(CUSTOM_IMAGE, docker_tag)

if dh_result == TagCheckResult.FOUND:
dh_platforms = get_platforms_from_tag_data(dh_data)
dh_missing = REQUIRED_PLATFORMS - dh_platforms
dh_platforms = get_platforms_from_tag_data(dh_data, platforms)
dh_missing = platforms - dh_platforms
if not dh_missing:
log.info(" Docker Hub image complete.")
log.set_summary("Custom (Docker Hub)", "Complete")
Expand Down
29 changes: 7 additions & 22 deletions scripts/check_caddy_status/base_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,26 @@
"""

from .config import CUSTOM_IMAGE, GHCR_IMAGE, OFFICIAL_CADDY_IMAGE
from .docker_hub import check_docker_hub_tag, get_images_by_platform
from .docker_hub import get_docker_hub_pushed
from .ghcr import get_ghcr_created
from .http_client import TagCheckResult
from .logger import log
from .timeutil import parse_timestamp


def check_base_image_updates(docker_tag, variant_suffixes):
def check_base_image_updates(docker_tag, variant_suffixes, platforms):
"""Returns descriptions of variants whose official image is newer than our build (empty if none).

Args:
docker_tag: Caddy version without the ``v`` prefix.
variant_suffixes: our tag suffixes that are built FROM ``library/caddy`` (e.g. ``["", "-alpine"]``).
platforms: the platforms we ship; pushes of other platforms are ignored.
"""
stale = []
unknown = []

for suffix in variant_suffixes:
tag = f"{docker_tag}{suffix}"
official = _docker_hub_pushed(OFFICIAL_CADDY_IMAGE, tag)
ours = _custom_build_time(tag)
official = get_docker_hub_pushed(OFFICIAL_CADDY_IMAGE, tag, platforms)
ours = _custom_build_time(tag, platforms)

if official is None or ours is None:
log.warn(f" Could not compare '{tag}' (official: {official}, ours: {ours}), skipping.")
Expand All @@ -46,7 +45,7 @@ def check_base_image_updates(docker_tag, variant_suffixes):
return stale


def _custom_build_time(tag):
def _custom_build_time(tag, platforms):
"""Returns when our image was built.

GHCR is preferred because the image config's ``created`` is the build time. Docker Hub
Expand All @@ -58,19 +57,5 @@ def _custom_build_time(tag):
if created:
return created
if CUSTOM_IMAGE:
return _docker_hub_pushed(CUSTOM_IMAGE, tag)
return get_docker_hub_pushed(CUSTOM_IMAGE, tag, platforms)
return None


def _docker_hub_pushed(image_name, tag):
"""Returns the most recent push among the tag's required platforms, or None.

The tag-level ``last_updated`` also moves when platforms we don't ship (Windows,
riscv64) are re-pushed, which would trigger needless rebuilds.
"""
result, data = check_docker_hub_tag(image_name, tag)
if result != TagCheckResult.FOUND:
return None
pushed = [parse_timestamp(img.get("last_pushed")) for img in get_images_by_platform(data).values()]
pushed = [p for p in pushed if p]
return max(pushed) if pushed else None
13 changes: 2 additions & 11 deletions scripts/check_caddy_status/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Environment-derived settings shared by every image repo.

Repo-specific values (title, modules, variants) come from the image config file and the
Dockerfile, loaded at runtime — see ``image_config`` and ``dockerfile``.
Repo-specific values (title, modules, variants, platforms) come from the image config file
and the Dockerfile, loaded at runtime — see ``image_config`` and ``dockerfile``.
"""

import os
Expand All @@ -21,15 +21,6 @@
# GHCR — always matches the GitHub repo
GHCR_IMAGE = os.environ.get("GHCR_IMAGE", "") or _github_repository

# Platforms required in both official and custom images
REQUIRED_PLATFORMS = {
"linux/amd64",
"linux/arm64",
"linux/arm/v7",
"linux/ppc64le",
"linux/s390x",
}

# HTTP retry settings
MAX_RETRIES = 3
BACKOFF_BASE = 2
Loading
Loading