From 3c3002400dc4a384ec8993752778a93918b8a656 Mon Sep 17 00:00:00 2001 From: vkartk <53650724+vkartk@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:42:24 +0530 Subject: [PATCH 1/3] feat: track the slim variant in the daily check With `slim` enabled the check now requires the `X.Y.Z-slim` tag to exist with every shipped platform (new reason `missing_variant`, so enabling slim on an already-built version triggers the build), and the base-image comparison works per variant: the default image against library/caddy:X.Y.Z as before, the slim image against the base named in its Dockerfile stage, i.e. the caddy-base image on GHCR, whose build time stands in for its push time. Enabling slim without an `AS slim` stage is a configuration error. The example Dockerfile gains the slim stage and the example config enables it. --- .github/workflows/build-image.yml | 2 +- README.md | 10 +++- examples/Dockerfile | 7 +++ examples/caddy-image.json | 2 +- scripts/check_caddy_status/__main__.py | 73 ++++++++++++++++-------- scripts/check_caddy_status/base_image.py | 57 ++++++++++++------ scripts/tests/test_base_image.py | 68 ++++++++++++++++++++++ 7 files changed, 173 insertions(+), 46 deletions(-) create mode 100644 scripts/tests/test_base_image.py diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index c9f8f8d..06fe6ba 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -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: '' diff --git a/README.md b/README.md index 565565c..fcd1b55 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,9 @@ 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. +`default`. A `slim` stage is optional: `FROM ghcr.io/caddybuilds/caddy-base:latest`, copy the +binary from the builder and `setcap` it (see [`examples/Dockerfile`](examples/Dockerfile)); +the checker reads the slim base from that `FROM` line. ```dockerfile ARG CADDY_VERSION @@ -120,7 +122,11 @@ check without pinning package versions. The pin is bumped by editing the `FROM` 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. diff --git a/examples/Dockerfile b/examples/Dockerfile index 064227a..d7b7049 100644 --- a/examples/Dockerfile +++ b/examples/Dockerfile @@ -15,3 +15,10 @@ RUN GOOS=$TARGETOS GOARCH=$TARGETARCH GOARM=${TARGETVARIANT#v} CGO_ENABLED=0 XCA # Default image: the official image for the target platform with the custom-built binary FROM caddy:${CADDY_VERSION} AS default COPY --from=builder /usr/bin/caddy /usr/bin/caddy + +# 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. +FROM ghcr.io/caddybuilds/caddy-base:latest AS slim +COPY --from=builder /usr/bin/caddy /usr/bin/caddy +RUN setcap cap_net_bind_service=+ep /usr/bin/caddy diff --git a/examples/caddy-image.json b/examples/caddy-image.json index 221232d..d2ff7fd 100644 --- a/examples/caddy-image.json +++ b/examples/caddy-image.json @@ -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", diff --git a/scripts/check_caddy_status/__main__.py b/scripts/check_caddy_status/__main__.py index 86a2080..d83d128 100644 --- a/scripts/check_caddy_status/__main__.py +++ b/scripts/check_caddy_status/__main__.py @@ -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_stages, parse_xcaddy_modules from .ghcr import check_ghcr_tag from .github_api import get_latest_caddy_release from .http_client import TagCheckResult @@ -44,9 +44,12 @@ 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) + if image.slim and "slim" not in {s.name for s in parse_stages(dockerfile)}: + raise DockerfileError(f"slim is enabled but '{dockerfile}' has no 'AS slim' stage") except (ImageConfigError, DockerfileError) as e: log.error(f"Configuration error: {e}") sys.exit(1) @@ -56,6 +59,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)'}") @@ -75,9 +79,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...") @@ -89,25 +95,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, dockerfile, 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" @@ -145,65 +155,80 @@ 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, dockerfile, docker_tag): + """``[(our_tag, base_image_ref)]`` 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's base is whatever its Dockerfile stage says, with + ``CADDY_VERSION`` expanded, so a fork using its own base is tracked too. + """ + variants = [(f"{docker_tag}{suffix}", f"{OFFICIAL_CADDY_IMAGE}:{docker_tag}{suffix}") for suffix in image.official_variant_suffixes] + if image.slim: + stages = {stage.name: stage.image for stage in parse_stages(dockerfile, {"CADDY_VERSION": docker_tag})} + variants.append((f"{docker_tag}-slim", stages["slim"])) + 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) diff --git a/scripts/check_caddy_status/base_image.py b/scripts/check_caddy_status/base_image.py index ee4010d..1b91d3e 100644 --- a/scripts/check_caddy_status/base_image.py +++ b/scripts/check_caddy_status/base_image.py @@ -1,39 +1,46 @@ -"""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 .image_ref import parse_image_ref 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_image_ref)]``, e.g. ``("2.11.7", "caddy:2.11.7")`` for the + default image and ``("2.11.7-slim", "ghcr.io/caddybuilds/caddy-base:latest")`` for slim. 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_ref in variants: + try: + base = parse_image_ref(base_ref) + except ValueError as e: + log.warn(f" Cannot check the base of '{our_tag}': {e}") + unknown.append(our_tag) + continue + upstream = base_image_time(base, platforms) + ours = _custom_build_time(our_tag, platforms) - 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)}") @@ -45,12 +52,26 @@ def check_base_image_updates(docker_tag, variant_suffixes, platforms): return stale +def base_image_time(ref, 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. + """ + if ref.is_docker_hub: + return get_docker_hub_pushed(ref.repository, ref.tag, platforms) + if ref.registry == "ghcr.io": + return get_ghcr_created(ref.repository, ref.tag) + log.warn(f" Cannot read push times from {ref.registry}, skipping {ref}") + return None + + 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) diff --git a/scripts/tests/test_base_image.py b/scripts/tests/test_base_image.py new file mode 100644 index 0000000..28a742c --- /dev/null +++ b/scripts/tests/test_base_image.py @@ -0,0 +1,68 @@ +import unittest +from datetime import datetime, timezone +from unittest import mock + +from check_caddy_status import base_image +from check_caddy_status.base_image import check_base_image_updates + +PLATFORMS = {"linux/amd64", "linux/arm64"} +OLD = datetime(2026, 10, 20, tzinfo=timezone.utc) +NEW = datetime(2026, 10, 25, tzinfo=timezone.utc) +NEWER = datetime(2026, 10, 26, tzinfo=timezone.utc) + + +def _patch(hub, ghcr): + """hub/ghcr map ``repository:tag`` to a time (or None).""" + return mock.patch.multiple( + base_image, + GHCR_IMAGE="o/caddy-x", + CUSTOM_IMAGE="", + get_docker_hub_pushed=mock.Mock(side_effect=lambda repo, tag, platforms: hub.get(f"{repo}:{tag}")), + get_ghcr_created=mock.Mock(side_effect=lambda repo, tag: ghcr.get(f"{repo}:{tag}")), + ) + + +class CheckBaseImageUpdatesTest(unittest.TestCase): + VARIANTS = [ + ("2.11.7", "caddy:2.11.7"), + ("2.11.7-alpine", "caddy:2.11.7-alpine"), + ("2.11.7-slim", "ghcr.io/caddybuilds/caddy-base:latest"), + ] + + def test_up_to_date(self): + hub = {"library/caddy:2.11.7": OLD, "library/caddy:2.11.7-alpine": OLD} + ghcr = {"o/caddy-x:2.11.7": NEW, "o/caddy-x:2.11.7-alpine": NEW, "o/caddy-x:2.11.7-slim": NEW, "caddybuilds/caddy-base:latest": OLD} + with _patch(hub, ghcr): + self.assertEqual(check_base_image_updates(self.VARIANTS, PLATFORMS), []) + + def test_slim_base_on_ghcr_republished(self): + hub = {"library/caddy:2.11.7": OLD, "library/caddy:2.11.7-alpine": OLD} + ghcr = {"o/caddy-x:2.11.7": NEW, "o/caddy-x:2.11.7-alpine": NEW, "o/caddy-x:2.11.7-slim": NEW, "caddybuilds/caddy-base:latest": NEWER} + with _patch(hub, ghcr): + self.assertEqual(check_base_image_updates(self.VARIANTS, PLATFORMS), ["ghcr.io/caddybuilds/caddy-base:latest republished 2026-10-26"]) + + def test_official_image_republished_only_affects_default(self): + hub = {"library/caddy:2.11.7": NEWER, "library/caddy:2.11.7-alpine": NEWER} + ghcr = {"o/caddy-x:2.11.7": NEW, "o/caddy-x:2.11.7-alpine": NEW, "o/caddy-x:2.11.7-slim": NEW, "caddybuilds/caddy-base:latest": OLD} + with _patch(hub, ghcr): + self.assertEqual( + check_base_image_updates(self.VARIANTS, PLATFORMS), + ["caddy:2.11.7 republished 2026-10-26", "caddy:2.11.7-alpine republished 2026-10-26"], + ) + + def test_unknown_times_are_skipped(self): + with _patch({}, {}): + self.assertEqual(check_base_image_updates(self.VARIANTS, PLATFORMS), []) + + def test_docker_hub_fallback_for_our_build_time(self): + hub = {"library/caddy:2.11.7": NEWER, "me/caddy-x:2.11.7": NEW} + with _patch(hub, {}), mock.patch.object(base_image, "CUSTOM_IMAGE", "me/caddy-x"): + self.assertEqual(check_base_image_updates([("2.11.7", "caddy:2.11.7")], PLATFORMS), ["caddy:2.11.7 republished 2026-10-26"]) + + def test_unsupported_registry_and_bad_ref(self): + with _patch({}, {"o/caddy-x:2.11.7-slim": NEW}): + self.assertEqual(check_base_image_updates([("2.11.7-slim", "quay.io/x/y:1"), ("2.11.7", "bad@sha256:0")], PLATFORMS), []) + + +if __name__ == "__main__": + unittest.main() From 28d5514c602c5e7521ccf4a372ba54a1f0a44dbd Mon Sep 17 00:00:00 2001 From: vkartk <53650724+vkartk@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:49:35 +0530 Subject: [PATCH 2/3] refactor: read the slim base with one regex instead of general parsers The FROM-stage and image-reference parsers were removed from the caddy-base image change. The check now only needs the slim stage's base, which must be FROM ghcr.io//: AS slim; parse_slim_base reads it. --- scripts/check_caddy_status/__main__.py | 20 +++++++------- scripts/check_caddy_status/base_image.py | 28 +++++++------------- scripts/check_caddy_status/dockerfile.py | 31 +++++++++++++++++++--- scripts/tests/test_base_image.py | 10 +++---- scripts/tests/test_dockerfile.py | 33 +++++++++++++++++++++++- 5 files changed, 82 insertions(+), 40 deletions(-) diff --git a/scripts/check_caddy_status/__main__.py b/scripts/check_caddy_status/__main__.py index d83d128..9d91483 100644 --- a/scripts/check_caddy_status/__main__.py +++ b/scripts/check_caddy_status/__main__.py @@ -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_stages, 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 @@ -48,8 +48,7 @@ def main(): try: image = load_image_config(root / args.config) modules = parse_xcaddy_modules(dockerfile) - if image.slim and "slim" not in {s.name for s in parse_stages(dockerfile)}: - raise DockerfileError(f"slim is enabled but '{dockerfile}' has no 'AS slim' stage") + 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) @@ -100,7 +99,7 @@ def main(): base_details = [] if custom_image_complete and not modules_changed: log.info(f"\nStep 4: Checking base image updates...") - base_details = check_base_image_updates(_variant_bases(image, dockerfile, docker_tag), platforms) + base_details = check_base_image_updates(_variant_bases(image, slim_base, docker_tag), platforms) # Final decision needs_build = False @@ -215,17 +214,16 @@ def _check_custom_image(tag, platforms): return custom_complete -def _variant_bases(image, dockerfile, docker_tag): - """``[(our_tag, base_image_ref)]`` for the base-image check. +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's base is whatever its Dockerfile stage says, with - ``CADDY_VERSION`` expanded, so a fork using its own base is tracked too. + 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 image.slim: - stages = {stage.name: stage.image for stage in parse_stages(dockerfile, {"CADDY_VERSION": docker_tag})} - variants.append((f"{docker_tag}-slim", stages["slim"])) + if slim_base: + repository, tag = slim_base + variants.append((f"{docker_tag}-slim", f"ghcr.io/{repository}:{tag}")) return variants diff --git a/scripts/check_caddy_status/base_image.py b/scripts/check_caddy_status/base_image.py index 1b91d3e..024be5b 100644 --- a/scripts/check_caddy_status/base_image.py +++ b/scripts/check_caddy_status/base_image.py @@ -8,7 +8,6 @@ from .config import CUSTOM_IMAGE, GHCR_IMAGE from .docker_hub import get_docker_hub_pushed from .ghcr import get_ghcr_created -from .image_ref import parse_image_ref from .logger import log @@ -16,22 +15,17 @@ def check_base_image_updates(variants, platforms): """Returns descriptions of variants whose base image is newer than our build (empty if none). Args: - variants: ``[(our_tag, base_image_ref)]``, e.g. ``("2.11.7", "caddy:2.11.7")`` for the - default image and ``("2.11.7-slim", "ghcr.io/caddybuilds/caddy-base:latest")`` for slim. + 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 our_tag, base_ref in variants: - try: - base = parse_image_ref(base_ref) - except ValueError as e: - log.warn(f" Cannot check the base of '{our_tag}': {e}") - unknown.append(our_tag) - continue - upstream = base_image_time(base, platforms) + for our_tag, base in variants: + upstream = _base_time(base, platforms) ours = _custom_build_time(our_tag, platforms) + base = base.removeprefix("library/") if upstream is None or ours is None: log.warn(f" Could not compare '{our_tag}' with {base} (base: {upstream}, ours: {ours}), skipping.") @@ -52,18 +46,16 @@ def check_base_image_updates(variants, platforms): return stale -def base_image_time(ref, platforms): +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. """ - if ref.is_docker_hub: - return get_docker_hub_pushed(ref.repository, ref.tag, platforms) - if ref.registry == "ghcr.io": - return get_ghcr_created(ref.repository, ref.tag) - log.warn(f" Cannot read push times from {ref.registry}, skipping {ref}") - return None + 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): diff --git a/scripts/check_caddy_status/dockerfile.py b/scripts/check_caddy_status/dockerfile.py index 3e6533d..f9a9169 100644 --- a/scripts/check_caddy_status/dockerfile.py +++ b/scripts/check_caddy_status/dockerfile.py @@ -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): @@ -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//: 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//:, 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. diff --git a/scripts/tests/test_base_image.py b/scripts/tests/test_base_image.py index 28a742c..1a878a6 100644 --- a/scripts/tests/test_base_image.py +++ b/scripts/tests/test_base_image.py @@ -24,8 +24,8 @@ def _patch(hub, ghcr): class CheckBaseImageUpdatesTest(unittest.TestCase): VARIANTS = [ - ("2.11.7", "caddy:2.11.7"), - ("2.11.7-alpine", "caddy:2.11.7-alpine"), + ("2.11.7", "library/caddy:2.11.7"), + ("2.11.7-alpine", "library/caddy:2.11.7-alpine"), ("2.11.7-slim", "ghcr.io/caddybuilds/caddy-base:latest"), ] @@ -57,11 +57,7 @@ def test_unknown_times_are_skipped(self): def test_docker_hub_fallback_for_our_build_time(self): hub = {"library/caddy:2.11.7": NEWER, "me/caddy-x:2.11.7": NEW} with _patch(hub, {}), mock.patch.object(base_image, "CUSTOM_IMAGE", "me/caddy-x"): - self.assertEqual(check_base_image_updates([("2.11.7", "caddy:2.11.7")], PLATFORMS), ["caddy:2.11.7 republished 2026-10-26"]) - - def test_unsupported_registry_and_bad_ref(self): - with _patch({}, {"o/caddy-x:2.11.7-slim": NEW}): - self.assertEqual(check_base_image_updates([("2.11.7-slim", "quay.io/x/y:1"), ("2.11.7", "bad@sha256:0")], PLATFORMS), []) + self.assertEqual(check_base_image_updates([("2.11.7", "library/caddy:2.11.7")], PLATFORMS), ["caddy:2.11.7 republished 2026-10-26"]) if __name__ == "__main__": diff --git a/scripts/tests/test_dockerfile.py b/scripts/tests/test_dockerfile.py index 0313ea7..5faf6e9 100644 --- a/scripts/tests/test_dockerfile.py +++ b/scripts/tests/test_dockerfile.py @@ -2,7 +2,7 @@ import unittest from pathlib import Path -from check_caddy_status.dockerfile import DockerfileError, parse_xcaddy_modules +from check_caddy_status.dockerfile import DockerfileError, parse_slim_base, parse_xcaddy_modules def _write(text): @@ -83,5 +83,36 @@ def test_missing_file_is_an_error(self): parse_xcaddy_modules("/nonexistent/Dockerfile") +class ParseSlimBaseTest(unittest.TestCase): + STAGES = ( + "ARG CADDY_VERSION\n" + "FROM --platform=$BUILDPLATFORM caddy:${CADDY_VERSION}-builder AS builder\n" + "RUN xcaddy build --with github.com/caddy-dns/bunny\n" + "FROM caddy:${CADDY_VERSION} AS default\n" + "COPY --from=builder /usr/bin/caddy /usr/bin/caddy\n" + ) + + def test_ghcr_base(self): + path = _write(self.STAGES + "\n# Slim image\nFROM ghcr.io/caddybuilds/caddy-base:latest AS slim\nRUN setcap x\n") + self.assertEqual(parse_slim_base(path), ("caddybuilds/caddy-base", "latest")) + + def test_lowercase_as_and_platform_flag(self): + path = _write(self.STAGES + "FROM --platform=$TARGETPLATFORM ghcr.io/me/base:alpine3.23 as slim\n") + self.assertEqual(parse_slim_base(path), ("me/base", "alpine3.23")) + + def test_missing_stage(self): + with self.assertRaisesRegex(DockerfileError, "no 'AS slim' stage"): + parse_slim_base(_write(self.STAGES)) + + def test_commented_out_stage_does_not_count(self): + with self.assertRaises(DockerfileError): + parse_slim_base(_write(self.STAGES + "# FROM ghcr.io/caddybuilds/caddy-base:latest AS slim\n")) + + def test_base_must_be_ghcr_with_tag(self): + for base in ("alpine:3.23", "ghcr.io/caddybuilds/caddy-base", "ghcr.io/x/y:${TAG}x:y"): + with self.subTest(base=base), self.assertRaisesRegex(DockerfileError, "must be FROM ghcr.io"): + parse_slim_base(_write(self.STAGES + f"FROM {base} AS slim\n")) + + if __name__ == "__main__": unittest.main() From 58c8dbe2a7b657c206ed6141dcc607688161f1fd Mon Sep 17 00:00:00 2001 From: vkartk <53650724+vkartk@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:07:08 +0530 Subject: [PATCH 3/3] docs: drop setcap from the example slim stage and keep default last The builder sets XCADDY_SETCAP=1, so the copied binary already has cap_net_bind_service. The default stage stays last so a plain `docker build .` produces it. --- README.md | 10 ++++++---- examples/Dockerfile | 13 +++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 223943e..3b18149 100644 --- a/README.md +++ b/README.md @@ -26,10 +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: `FROM ghcr.io/caddybuilds/caddy-base:latest`, copy the -binary from the builder and `setcap` it (see [`examples/Dockerfile`](examples/Dockerfile)); -the checker reads the slim base from that `FROM` line. +`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 diff --git a/examples/Dockerfile b/examples/Dockerfile index d7b7049..7189b03 100644 --- a/examples/Dockerfile +++ b/examples/Dockerfile @@ -12,13 +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 -FROM caddy:${CADDY_VERSION} AS default -COPY --from=builder /usr/bin/caddy /usr/bin/caddy - # 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 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 -RUN setcap cap_net_bind_service=+ep /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