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
2 changes: 1 addition & 1 deletion .github/workflows/build-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ on:
required: true
type: string
build_reason:
description: 'Why this build was triggered (new_caddy_version/module_update/base_image_update/manual)'
description: 'Why this build was triggered (new_caddy_version/module_update/missing_variant/base_image_update/manual)'
required: false
type: string
default: ''
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ workflows, so a fix lands in every repo on its next run.
An image repo needs four files besides its README.

**`Dockerfile`**, complete and buildable on its own. The checker reads the modules from the
`xcaddy build` line, so this is the only place they are listed. Name the final stage
`default`; a `slim` stage is optional.
`xcaddy build` line, so this is the only place they are listed. Name the main stage
`default` and keep it last, so a plain `docker build .` produces it. A `slim` stage is
optional: `FROM ghcr.io/caddybuilds/caddy-base:latest` and copy the binary from the builder
(see [`examples/Dockerfile`](examples/Dockerfile)). The checker reads the slim base from that
`FROM` line. The build workflow builds each stage by name, and slim only when the config
enables it.

```dockerfile
ARG CADDY_VERSION
Expand Down Expand Up @@ -122,7 +126,11 @@ editing the `FROM` line.
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 (for the platforms we ship) with our build time; if upstream republished, build (`base_image_update`).
4. With `slim` enabled, if the `X.Y.Z-slim` tag is missing or incomplete, build (`missing_variant`).
5. Compare each variant's base image with our build time, looking only at the platforms we ship:
the default image against `library/caddy:X.Y.Z` on Docker Hub, the slim image against the base
named in its Dockerfile stage (the caddy-base image on GHCR). If a base was 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
10 changes: 9 additions & 1 deletion examples/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ RUN GOOS=$TARGETOS GOARCH=$TARGETARCH GOARM=${TARGETVARIANT#v} CGO_ENABLED=0 XCA
--with github.com/fvbommel/caddy-combine-ip-ranges \
--with github.com/mholt/caddy-ratelimit

# Default image: the official image for the target platform with the custom-built binary
# Slim image: our own Alpine base (the official image minus its stock binary) with the
# custom-built binary. Built when caddy-image.json has "slim": true; the daily check reads
# the base image from this line to rebuild when it is republished. The builder sets
# XCADDY_SETCAP=1, so the binary already has cap_net_bind_service.
FROM ghcr.io/caddybuilds/caddy-base:latest AS slim
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

# Default image: the official image for the target platform with the custom-built binary.
# Kept last so a plain `docker build .` produces it.
FROM caddy:${CADDY_VERSION} AS default
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
2 changes: 1 addition & 1 deletion examples/caddy-image.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"description": "Caddy with Cloudflare DNS module",
"verify_module": "dns.providers.cloudflare",
"alpine_alias": true,
"slim": false,
"slim": true,
"create_release": true,
"platforms": [
"linux/amd64",
Expand Down
71 changes: 47 additions & 24 deletions scripts/check_caddy_status/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from .base_image import check_base_image_updates
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 .dockerfile import DockerfileError, parse_slim_base, parse_xcaddy_modules
from .ghcr import check_ghcr_tag
from .github_api import get_latest_caddy_release
from .http_client import TagCheckResult
Expand Down Expand Up @@ -44,9 +44,11 @@ def main():
sys.exit(1)

root = Path(args.repo_root)
dockerfile = root / args.dockerfile
try:
image = load_image_config(root / args.config)
modules = parse_xcaddy_modules(root / args.dockerfile)
modules = parse_xcaddy_modules(dockerfile)
slim_base = parse_slim_base(dockerfile) if image.slim else None
except (ImageConfigError, DockerfileError) as e:
log.error(f"Configuration error: {e}")
sys.exit(1)
Expand All @@ -56,6 +58,7 @@ def main():
log.info(f"Image: {image.title} — {image.description}")
log.info(f"Modules: {', '.join(m['module'] for m in modules)}")
log.info(f"Platforms: {', '.join(image.platforms)}")
log.info(f"Variants: default{', slim' if image.slim else ''}")
log.info(f"GHCR image: {GHCR_IMAGE or '(not set)'}")
log.info(f"Docker Hub image: {CUSTOM_IMAGE or '(not set)'}")

Expand All @@ -75,9 +78,11 @@ def main():
log.print_summary()
sys.exit(0)

# Step 2: Check custom image on GHCR + Docker Hub
# Step 2: Check our tags on GHCR + Docker Hub. The slim variant has its own tag.
log.info(f"\nStep 2: Checking custom image status...")
custom_image_complete = _check_custom_image(docker_tag, platforms)
required_tags = [docker_tag] + ([f"{docker_tag}-slim"] if image.slim else [])
missing_tags = [tag for tag in required_tags if not _check_custom_image(tag, platforms)]
custom_image_complete = not missing_tags

# Step 3: Check module updates
log.info(f"\nStep 3: Checking module updates...")
Expand All @@ -89,25 +94,29 @@ def main():
else:
log.set_summary("Module Changes", "None")

# Step 4: Check if the official image was republished (base OS / Go fixes).
# Step 4: Check if a base image was republished (base OS / Go fixes).
# Only worth the requests when nothing above already forces a build.
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, platforms)
log.info(f"\nStep 4: Checking base image updates...")
base_details = check_base_image_updates(_variant_bases(image, slim_base, docker_tag), platforms)

# Final decision
needs_build = False
build_reason = "none"
build_details = ""

if not custom_image_complete:
if docker_tag in missing_tags:
needs_build = True
build_reason = "new_caddy_version"
elif modules_changed:
needs_build = True
build_reason = "module_update"
build_details = "; ".join(module_details)
elif missing_tags:
needs_build = True
build_reason = "missing_variant"
build_details = f"{', '.join(missing_tags)} not published"
elif base_details:
needs_build = True
build_reason = "base_image_update"
Expand Down Expand Up @@ -145,65 +154,79 @@ def _check_official_image(docker_tag, platforms):
return False


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

# 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, platforms)
log.info(f" Checking GHCR: ghcr.io/{GHCR_IMAGE}:{tag}")
ghcr_result, ghcr_data = check_ghcr_tag(GHCR_IMAGE, tag, platforms)

if ghcr_result == TagCheckResult.FOUND:
missing = platforms - ghcr_data
if not missing:
custom_complete = True
log.info(" GHCR image complete.")
log.set_summary("Custom (GHCR)", "Complete")
log.set_summary(ghcr_key, "Complete")
else:
log.info(f" GHCR image missing: {missing}")
log.set_summary("Custom (GHCR)", f"Missing: {missing}")
log.set_summary(ghcr_key, f"Missing: {missing}")
elif ghcr_result == TagCheckResult.NOT_FOUND:
log.info(" GHCR tag not found.")
log.set_summary("Custom (GHCR)", "Not found")
log.set_summary(ghcr_key, "Not found")
else:
log.warn(f" GHCR check failed: {ghcr_data}")
log.set_summary("Custom (GHCR)", f"Error: {ghcr_data}")
log.set_summary(ghcr_key, f"Error: {ghcr_data}")
else:
log.info(" GHCR image not configured, skipping.")
log.set_summary("Custom (GHCR)", "Not configured")
log.set_summary(ghcr_key, "Not configured")

# Docker Hub (secondary)
if CUSTOM_IMAGE:
log.info(f" Checking Docker Hub: {CUSTOM_IMAGE}:{docker_tag}")
dh_result, dh_data = check_docker_hub_tag(CUSTOM_IMAGE, docker_tag)
log.info(f" Checking Docker Hub: {CUSTOM_IMAGE}:{tag}")
dh_result, dh_data = check_docker_hub_tag(CUSTOM_IMAGE, tag)

if dh_result == TagCheckResult.FOUND:
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")
log.set_summary(dh_key, "Complete")
# Fallback if GHCR errored
if not custom_complete and ghcr_result == TagCheckResult.ERROR:
custom_complete = True
log.info(" Using Docker Hub as fallback (GHCR errored).")
else:
log.info(f" Docker Hub image missing: {dh_missing}")
log.set_summary("Custom (Docker Hub)", f"Missing: {dh_missing}")
log.set_summary(dh_key, f"Missing: {dh_missing}")
elif dh_result == TagCheckResult.NOT_FOUND:
log.info(" Docker Hub tag not found.")
log.set_summary("Custom (Docker Hub)", "Not found")
log.set_summary(dh_key, "Not found")
else:
log.set_summary("Custom (Docker Hub)", f"Error: {dh_data}")
log.set_summary(dh_key, f"Error: {dh_data}")
else:
log.info(" Docker Hub image not configured, skipping.")
log.set_summary("Custom (Docker Hub)", "Not configured")
log.set_summary(dh_key, "Not configured")

return custom_complete


def _variant_bases(image, slim_base, docker_tag):
"""``[(our_tag, base)]`` for the base-image check.

The default image (and its -alpine aliases) is built FROM the official image of the
same version. The slim image is built FROM the GHCR image its Dockerfile stage names.
"""
variants = [(f"{docker_tag}{suffix}", f"{OFFICIAL_CADDY_IMAGE}:{docker_tag}{suffix}") for suffix in image.official_variant_suffixes]
if slim_base:
repository, tag = slim_base
variants.append((f"{docker_tag}-slim", f"ghcr.io/{repository}:{tag}"))
return variants


def _set_outputs(needs_build, version, reason, module_update, dry_run, details=""):
set_action_output("NEEDS_BUILD", "true" if needs_build else "false", dry_run)
set_action_output("LATEST_VERSION", version, dry_run)
Expand Down
49 changes: 31 additions & 18 deletions scripts/check_caddy_status/base_image.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,40 @@
"""Detects when the official Caddy image was republished after our last build.
"""Detects when the base image of one of our variants was republished after our last build.

Docker Official Images are rebuilt for base OS and Go toolchain fixes without a new
Caddy release. Rebuilding on top of them picks those fixes up.
Caddy release, and the caddy-base image is rebuilt when its Alpine tag is. Rebuilding
on top of them picks those fixes up.
"""

from .config import CUSTOM_IMAGE, GHCR_IMAGE, OFFICIAL_CADDY_IMAGE
from .config import CUSTOM_IMAGE, GHCR_IMAGE
from .docker_hub import get_docker_hub_pushed
from .ghcr import get_ghcr_created
from .logger import log


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).
def check_base_image_updates(variants, platforms):
"""Returns descriptions of variants whose base 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"]``).
variants: ``[(our_tag, base)]``. ``base`` is a Docker Hub ``repository:tag`` such as
``library/caddy:2.11.7``, or a GHCR one such as ``ghcr.io/caddybuilds/caddy-base:latest``.
platforms: the platforms we ship; pushes of other platforms are ignored.
"""
stale = []
unknown = []

for suffix in variant_suffixes:
tag = f"{docker_tag}{suffix}"
official = get_docker_hub_pushed(OFFICIAL_CADDY_IMAGE, tag, platforms)
ours = _custom_build_time(tag, platforms)
for our_tag, base in variants:
upstream = _base_time(base, platforms)
ours = _custom_build_time(our_tag, platforms)
base = base.removeprefix("library/")

if official is None or ours is None:
log.warn(f" Could not compare '{tag}' (official: {official}, ours: {ours}), skipping.")
unknown.append(tag)
if upstream is None or ours is None:
log.warn(f" Could not compare '{our_tag}' with {base} (base: {upstream}, ours: {ours}), skipping.")
unknown.append(our_tag)
continue

log.info(f" {tag}: official pushed {official.isoformat()}, ours built {ours.isoformat()}")
if official > ours:
stale.append(f"{OFFICIAL_CADDY_IMAGE.removeprefix('library/')}:{tag} republished {official:%Y-%m-%d}")
log.info(f" {our_tag}: {base} pushed {upstream.isoformat()}, ours built {ours.isoformat()}")
if upstream > ours:
stale.append(f"{base} republished {upstream:%Y-%m-%d}")

if stale:
log.set_summary("Base Image", f"Updated upstream: {', '.join(stale)}")
Expand All @@ -45,12 +46,24 @@ def check_base_image_updates(docker_tag, variant_suffixes, platforms):
return stale


def _base_time(base, platforms):
"""When a base image was last pushed (Docker Hub) or built (GHCR), or None.

GHCR has no push time; the image config's ``created`` is the build time, and the
caddy-base image is pushed by the run that builds it.
"""
repository, _, tag = base.rpartition(":")
if repository.startswith("ghcr.io/"):
return get_ghcr_created(repository.removeprefix("ghcr.io/"), tag)
return get_docker_hub_pushed(repository, tag, platforms)


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
only knows the push time, which can postdate an upstream re-push that happened while
our (long, emulated) build was still running.
our build was still running.
"""
if GHCR_IMAGE:
created = get_ghcr_created(GHCR_IMAGE, tag)
Expand Down
31 changes: 28 additions & 3 deletions scripts/check_caddy_status/dockerfile.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"""Reads the module list from the Dockerfile's ``xcaddy build`` command.
"""Reads what the daily check needs from the Dockerfile: the ``xcaddy build`` module list
and the slim stage's base image.

The Dockerfile is the single source of truth for which modules an image contains, so
forks only edit the ``--with`` lines and the daily check follows.
The Dockerfile is the single source of truth for both, so forks only edit it and the
daily check follows.
"""

import re
import shlex

_WITH_RE = re.compile(r"^--with(?:=(.+))?$")
_SLIM_FROM_RE = re.compile(r"^\s*FROM\s+(?:--\S+\s+)*(\S+)\s+AS\s+slim\s*$", re.IGNORECASE | re.MULTILINE)
_GHCR_REF_RE = re.compile(r"^ghcr\.io/([^:\s]+):([^:\s]+)$")


class DockerfileError(ValueError):
Expand Down Expand Up @@ -48,6 +51,28 @@ def parse_xcaddy_modules(path):
return modules


def parse_slim_base(path):
"""Returns the slim stage's base as ``(repository, tag)`` on GHCR, e.g.
``("caddybuilds/caddy-base", "latest")``.

The base-image check reads its build time from GHCR, so the stage must be
``FROM ghcr.io/<owner>/<name>:<tag> AS slim``.
"""
try:
with open(path, "r", encoding="utf-8") as f:
text = f.read()
except OSError as e:
raise DockerfileError(f"Cannot read Dockerfile '{path}': {e}") from e

m = _SLIM_FROM_RE.search(text)
if not m:
raise DockerfileError(f"slim is enabled but '{path}' has no 'AS slim' stage")
ref = _GHCR_REF_RE.match(m.group(1))
if not ref:
raise DockerfileError(f"The slim stage in '{path}' must be FROM ghcr.io/<owner>/<name>:<tag>, got {m.group(1)}")
return ref.group(1), ref.group(2)


def _run_commands(text):
"""Yields each RUN instruction's shell text with line continuations joined.

Expand Down
Loading
Loading