#!/usr/bin/env python3 import argparse from dataclasses import dataclass import re import shlex import subprocess import sys CHANNEL_DEV = "dev" CHANNEL_BETA = "beta" CHANNEL_RELEASE = "release" CHANNELS = [CHANNEL_DEV, CHANNEL_BETA, CHANNEL_RELEASE] ARCH_AMD64 = "amd64" ARCH_AARCH64 = "aarch64" ARCHS = [ARCH_AMD64, ARCH_AARCH64] TYPE_DOCKER = "docker" TYPE_HA_ADDON = "ha-addon" TYPE_LINT = "lint" TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT] REGISTRY_GHCR = "ghcr" REGISTRY_DOCKERHUB = "dockerhub" REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB] parser = argparse.ArgumentParser() parser.add_argument( "--tag", type=str, required=True, help="The main docker tag to push to. If a version number also adds latest and/or beta tag", ) parser.add_argument( "--arch", choices=ARCHS, required=False, help="The architecture to build for" ) parser.add_argument( "--build-type", choices=TYPES, required=True, help="The type of build to run" ) parser.add_argument( "--registry", choices=REGISTRIES, action="append", help="Restrict to specific registries (default: all). May be passed multiple times.", ) parser.add_argument( "--dry-run", action="store_true", help="Don't run any commands, just print them" ) subparsers = parser.add_subparsers( help="Action to perform", dest="command", required=True ) build_parser = subparsers.add_parser("build", help="Build the image") build_parser.add_argument("--push", help="Also push the images", action="store_true") build_parser.add_argument( "--load", help="Load the docker image locally", action="store_true" ) build_parser.add_argument( "--no-cache-to", help="Don't write the build cache (avoids polluting the shared cache)", action="store_true", ) manifest_parser = subparsers.add_parser( "manifest", help="Create a manifest from already pushed images" ) @dataclass(frozen=True) class DockerParams: build_to: str manifest_to: str build_type: str platform: str target: str @classmethod def for_type_arch(cls, build_type, arch): prefix = { TYPE_DOCKER: "esphome/esphome", TYPE_HA_ADDON: "esphome/esphome-hassio", TYPE_LINT: "esphome/esphome-lint", }[build_type] build_to = f"{prefix}-{arch}" platform = { ARCH_AMD64: "linux/amd64", ARCH_AARCH64: "linux/arm64", }[arch] target = { TYPE_DOCKER: "final", TYPE_HA_ADDON: "final", TYPE_LINT: "lint", }[build_type] return cls( build_to=build_to, manifest_to=prefix, build_type=build_type, platform=platform, target=target, ) def main(): args = parser.parse_args() def run_command(*cmd, ignore_error: bool = False): print(f"$ {shlex.join(list(cmd))}") if not args.dry_run: rc = subprocess.call(list(cmd), close_fds=False) if rc != 0 and not ignore_error: print("Command failed") sys.exit(1) registries = args.registry or REGISTRIES # detect channel from tag match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag) major_minor_version = None if match is None: # Custom tag (e.g. a branch name) -- push only the tag itself channel = None elif match.group(2) is None: major_minor_version = match.group(1) channel = CHANNEL_RELEASE else: channel = CHANNEL_BETA tags_to_push = [args.tag] if channel == CHANNEL_DEV: tags_to_push.append("dev") elif channel == CHANNEL_BETA: tags_to_push.append("beta") elif channel == CHANNEL_RELEASE: # Additionally push to beta tags_to_push.append("beta") tags_to_push.append("latest") # Compatibility with HA tags if major_minor_version: tags_to_push.append("stable") tags_to_push.append(major_minor_version) if args.command == "build": # 1. pull cache image params = DockerParams.for_type_arch(args.build_type, args.arch) cache_tag = { CHANNEL_DEV: "cache-dev", CHANNEL_BETA: "cache-beta", CHANNEL_RELEASE: "cache-latest", }.get(channel, "cache-dev") # Cache images live alongside the pushed images; prefer GHCR when it is # one of the selected registries, otherwise fall back to Docker Hub so a # registry-restricted build doesn't need GHCR auth. cache_prefix = "ghcr.io/" if REGISTRY_GHCR in registries else "" cache_img = f"{cache_prefix}{params.build_to}:{cache_tag}" imgs = [] if REGISTRY_DOCKERHUB in registries: imgs += [f"{params.build_to}:{tag}" for tag in tags_to_push] if REGISTRY_GHCR in registries: imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push] # 3. build cmd = [ "docker", "buildx", "build", "--build-arg", f"BUILD_TYPE={params.build_type}", "--build-arg", f"BUILD_VERSION={args.tag}", "--cache-from", f"type=registry,ref={cache_img}", "--file", "docker/Dockerfile", "--platform", params.platform, "--target", params.target, ] for img in imgs: cmd += ["--tag", img] if args.push: cmd += ["--push"] if not args.no_cache_to: cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"] if args.load: cmd += ["--load"] run_command(*cmd, ".") elif args.command == "manifest": manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to targets = [] if REGISTRY_DOCKERHUB in registries: targets += [f"{manifest}:{tag}" for tag in tags_to_push] if REGISTRY_GHCR in registries: targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push] # Use buildx imagetools (not `docker manifest`) so the per-arch sources, # which buildx pushes as single-platform manifest lists, are combined # and pushed correctly in one step. for target in targets: cmd = ["docker", "buildx", "imagetools", "create", "--tag", target] for arch in ARCHS: src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}" if target.startswith("ghcr.io"): src = f"ghcr.io/{src}" cmd.append(src) run_command(*cmd) if __name__ == "__main__": main()