mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 13:45:15 +00:00
[ci] Push branch-tagged docker images to ghcr.io for local testing (#16992)
This commit is contained in:
84
.github/workflows/ci-docker.yml
vendored
84
.github/workflows/ci-docker.yml
vendored
@@ -22,7 +22,7 @@ on:
|
|||||||
- "script/platformio_install_deps.py"
|
- "script/platformio_install_deps.py"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read # actions/checkout only; the build does not push images
|
contents: read # actions/checkout only
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
# yamllint disable-line rule:line-length
|
# yamllint disable-line rule:line-length
|
||||||
@@ -33,6 +33,9 @@ jobs:
|
|||||||
check-docker:
|
check-docker:
|
||||||
name: Build docker containers
|
name: Build docker containers
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
permissions:
|
||||||
|
contents: read # actions/checkout to load Dockerfile and build context
|
||||||
|
packages: write # push branch-tagged images to ghcr.io for local testing
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -41,6 +44,9 @@ jobs:
|
|||||||
- "ha-addon"
|
- "ha-addon"
|
||||||
- "docker"
|
- "docker"
|
||||||
# - "lint"
|
# - "lint"
|
||||||
|
outputs:
|
||||||
|
tag: ${{ steps.tag.outputs.tag }}
|
||||||
|
push: ${{ steps.tag.outputs.push }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
@@ -50,14 +56,82 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|
||||||
- name: Set TAG
|
- name: Determine tag and whether to push
|
||||||
|
id: tag
|
||||||
run: |
|
run: |
|
||||||
echo "TAG=check" >> $GITHUB_ENV
|
# Sanitize the branch name into a valid docker tag: replace invalid
|
||||||
|
# characters, ensure the first character is valid (tags must start
|
||||||
|
# with [A-Za-z0-9_]), and cap the length at 128 characters.
|
||||||
|
branch="${{ github.head_ref || github.ref_name }}"
|
||||||
|
tag="${branch//[^a-zA-Z0-9_.-]/-}"
|
||||||
|
case "$tag" in
|
||||||
|
[a-zA-Z0-9_]*) ;;
|
||||||
|
*) tag="pr-${tag}" ;;
|
||||||
|
esac
|
||||||
|
tag="${tag:0:128}"
|
||||||
|
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||||
|
# Only push branch images for same-repo pull requests. Push events
|
||||||
|
# only fire for dev/beta/release, whose images are owned by the
|
||||||
|
# release pipeline -- never overwrite those from here.
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ] \
|
||||||
|
&& [ "${{ github.repository }}" = "esphome/esphome" ] \
|
||||||
|
&& [ "${{ github.event.pull_request.head.repo.full_name }}" = "esphome/esphome" ]; then
|
||||||
|
echo "push=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Log in to the GitHub container registry
|
||||||
|
if: steps.tag.outputs.push == 'true'
|
||||||
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Run build
|
- name: Run build
|
||||||
run: |
|
run: |
|
||||||
docker/build.py \
|
docker/build.py \
|
||||||
--tag "${TAG}" \
|
--tag "${{ steps.tag.outputs.tag }}" \
|
||||||
--arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \
|
--arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \
|
||||||
--build-type "${{ matrix.build_type }}" \
|
--build-type "${{ matrix.build_type }}" \
|
||||||
build
|
--registry ghcr \
|
||||||
|
build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }}
|
||||||
|
|
||||||
|
manifest:
|
||||||
|
name: Push ${{ matrix.build_type }} manifest to ghcr.io
|
||||||
|
needs: [check-docker]
|
||||||
|
if: needs.check-docker.outputs.push == 'true'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read # actions/checkout to run docker/build.py
|
||||||
|
packages: write # buildx imagetools writes the multi-arch tag to ghcr.io
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
build_type:
|
||||||
|
- "ha-addon"
|
||||||
|
- "docker"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|
||||||
|
- name: Log in to the GitHub container registry
|
||||||
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create and push manifest
|
||||||
|
run: |
|
||||||
|
docker/build.py \
|
||||||
|
--tag "${{ needs.check-docker.outputs.tag }}" \
|
||||||
|
--build-type "${{ matrix.build_type }}" \
|
||||||
|
--registry ghcr \
|
||||||
|
manifest
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ TYPE_HA_ADDON = "ha-addon"
|
|||||||
TYPE_LINT = "lint"
|
TYPE_LINT = "lint"
|
||||||
TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT]
|
TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT]
|
||||||
|
|
||||||
|
REGISTRY_GHCR = "ghcr"
|
||||||
|
REGISTRY_DOCKERHUB = "dockerhub"
|
||||||
|
REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB]
|
||||||
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -34,6 +38,12 @@ parser.add_argument(
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--build-type", choices=TYPES, required=True, help="The type of build to run"
|
"--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(
|
parser.add_argument(
|
||||||
"--dry-run", action="store_true", help="Don't run any commands, just print them"
|
"--dry-run", action="store_true", help="Don't run any commands, just print them"
|
||||||
)
|
)
|
||||||
@@ -45,6 +55,11 @@ build_parser.add_argument("--push", help="Also push the images", action="store_t
|
|||||||
build_parser.add_argument(
|
build_parser.add_argument(
|
||||||
"--load", help="Load the docker image locally", action="store_true"
|
"--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_parser = subparsers.add_parser(
|
||||||
"manifest", help="Create a manifest from already pushed images"
|
"manifest", help="Create a manifest from already pushed images"
|
||||||
)
|
)
|
||||||
@@ -95,11 +110,14 @@ def main():
|
|||||||
print("Command failed")
|
print("Command failed")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
registries = args.registry or REGISTRIES
|
||||||
|
|
||||||
# detect channel from tag
|
# detect channel from tag
|
||||||
match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag)
|
match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag)
|
||||||
major_minor_version = None
|
major_minor_version = None
|
||||||
if match is None:
|
if match is None:
|
||||||
channel = CHANNEL_DEV
|
# Custom tag (e.g. a branch name) -- push only the tag itself
|
||||||
|
channel = None
|
||||||
elif match.group(2) is None:
|
elif match.group(2) is None:
|
||||||
major_minor_version = match.group(1)
|
major_minor_version = match.group(1)
|
||||||
channel = CHANNEL_RELEASE
|
channel = CHANNEL_RELEASE
|
||||||
@@ -128,10 +146,17 @@ def main():
|
|||||||
CHANNEL_DEV: "cache-dev",
|
CHANNEL_DEV: "cache-dev",
|
||||||
CHANNEL_BETA: "cache-beta",
|
CHANNEL_BETA: "cache-beta",
|
||||||
CHANNEL_RELEASE: "cache-latest",
|
CHANNEL_RELEASE: "cache-latest",
|
||||||
}[channel]
|
}.get(channel, "cache-dev")
|
||||||
cache_img = f"ghcr.io/{params.build_to}:{cache_tag}"
|
# 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 = [f"{params.build_to}:{tag}" for tag in tags_to_push]
|
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]
|
imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push]
|
||||||
|
|
||||||
# 3. build
|
# 3. build
|
||||||
@@ -155,7 +180,9 @@ def main():
|
|||||||
for img in imgs:
|
for img in imgs:
|
||||||
cmd += ["--tag", img]
|
cmd += ["--tag", img]
|
||||||
if args.push:
|
if args.push:
|
||||||
cmd += ["--push", "--cache-to", f"type=registry,ref={cache_img},mode=max"]
|
cmd += ["--push"]
|
||||||
|
if not args.no_cache_to:
|
||||||
|
cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"]
|
||||||
if args.load:
|
if args.load:
|
||||||
cmd += ["--load"]
|
cmd += ["--load"]
|
||||||
|
|
||||||
@@ -163,20 +190,22 @@ def main():
|
|||||||
elif args.command == "manifest":
|
elif args.command == "manifest":
|
||||||
manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to
|
manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to
|
||||||
|
|
||||||
targets = [f"{manifest}:{tag}" for tag in tags_to_push]
|
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]
|
targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push]
|
||||||
# 1. Create manifests
|
# 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:
|
for target in targets:
|
||||||
cmd = ["docker", "manifest", "create", target]
|
cmd = ["docker", "buildx", "imagetools", "create", "--tag", target]
|
||||||
for arch in ARCHS:
|
for arch in ARCHS:
|
||||||
src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}"
|
src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}"
|
||||||
if target.startswith("ghcr.io"):
|
if target.startswith("ghcr.io"):
|
||||||
src = f"ghcr.io/{src}"
|
src = f"ghcr.io/{src}"
|
||||||
cmd.append(src)
|
cmd.append(src)
|
||||||
run_command(*cmd)
|
run_command(*cmd)
|
||||||
# 2. Push manifests
|
|
||||||
for target in targets:
|
|
||||||
run_command("docker", "manifest", "push", target)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
169
tests/script/test_docker_build.py
Normal file
169
tests/script/test_docker_build.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""Unit tests for docker/build.py command generation."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_BUILD_PY = Path(__file__).parents[2] / "docker" / "build.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("docker_build", _BUILD_PY)
|
||||||
|
docker_build = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(docker_build)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> list[str]:
|
||||||
|
"""Run build.py main() in dry-run mode and return the emitted commands."""
|
||||||
|
full_argv = ["build.py", "--dry-run", *argv]
|
||||||
|
with pytest.MonkeyPatch.context() as mp:
|
||||||
|
mp.setattr(sys, "argv", full_argv)
|
||||||
|
docker_build.main()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
return [line[2:] for line in out.splitlines() if line.startswith("$ ")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_branch_build_pushes_single_ghcr_tag_without_cache_to(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
commands = _run(
|
||||||
|
capsys,
|
||||||
|
"--tag",
|
||||||
|
"my-branch",
|
||||||
|
"--arch",
|
||||||
|
"amd64",
|
||||||
|
"--build-type",
|
||||||
|
"docker",
|
||||||
|
"--registry",
|
||||||
|
"ghcr",
|
||||||
|
"build",
|
||||||
|
"--push",
|
||||||
|
"--no-cache-to",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(commands) == 1
|
||||||
|
cmd = commands[0]
|
||||||
|
# Custom tag -> only the tag itself, no companion "dev"/"latest" tags
|
||||||
|
assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd
|
||||||
|
assert ":dev" not in cmd
|
||||||
|
# ghcr only -> no Docker Hub image name
|
||||||
|
assert "--tag esphome/esphome-amd64:my-branch" not in cmd
|
||||||
|
# custom tag falls back to the dev cache for reads
|
||||||
|
assert (
|
||||||
|
"--cache-from type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-dev" in cmd
|
||||||
|
)
|
||||||
|
assert "--push" in cmd
|
||||||
|
# --no-cache-to must suppress the cache write
|
||||||
|
assert "--cache-to" not in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_branch_manifest_targets_ghcr_only(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
commands = _run(
|
||||||
|
capsys,
|
||||||
|
"--tag",
|
||||||
|
"my-branch",
|
||||||
|
"--build-type",
|
||||||
|
"ha-addon",
|
||||||
|
"--registry",
|
||||||
|
"ghcr",
|
||||||
|
"manifest",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert commands == [
|
||||||
|
"docker buildx imagetools create "
|
||||||
|
"--tag ghcr.io/esphome/esphome-hassio:my-branch "
|
||||||
|
"ghcr.io/esphome/esphome-hassio-amd64:my-branch "
|
||||||
|
"ghcr.io/esphome/esphome-hassio-aarch64:my-branch"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_build_keeps_both_registries_and_cache_to(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
commands = _run(
|
||||||
|
capsys,
|
||||||
|
"--tag",
|
||||||
|
"2025.6.0",
|
||||||
|
"--arch",
|
||||||
|
"amd64",
|
||||||
|
"--build-type",
|
||||||
|
"docker",
|
||||||
|
"build",
|
||||||
|
"--push",
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = commands[0]
|
||||||
|
# Default (no --registry) keeps both Docker Hub and ghcr image names
|
||||||
|
assert "--tag esphome/esphome-amd64:2025.6.0" in cmd
|
||||||
|
assert "--tag ghcr.io/esphome/esphome-amd64:2025.6.0" in cmd
|
||||||
|
# Release channel still gets its companion tags
|
||||||
|
assert "--tag esphome/esphome-amd64:latest" in cmd
|
||||||
|
# Without --no-cache-to the cache write is preserved
|
||||||
|
assert (
|
||||||
|
"--cache-to type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-latest,mode=max"
|
||||||
|
in cmd
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_no_push_omits_push_and_cache(
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
commands = _run(
|
||||||
|
capsys,
|
||||||
|
"--tag",
|
||||||
|
"my-branch",
|
||||||
|
"--arch",
|
||||||
|
"amd64",
|
||||||
|
"--build-type",
|
||||||
|
"docker",
|
||||||
|
"--registry",
|
||||||
|
"ghcr",
|
||||||
|
"build",
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = commands[0]
|
||||||
|
assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd
|
||||||
|
assert "--push" not in cmd
|
||||||
|
assert "--cache-to" not in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
commands = _run(
|
||||||
|
capsys,
|
||||||
|
"--tag",
|
||||||
|
"my-branch",
|
||||||
|
"--arch",
|
||||||
|
"amd64",
|
||||||
|
"--build-type",
|
||||||
|
"docker",
|
||||||
|
"--registry",
|
||||||
|
"dockerhub",
|
||||||
|
"build",
|
||||||
|
"--push",
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd = commands[0]
|
||||||
|
assert "--tag esphome/esphome-amd64:my-branch" in cmd
|
||||||
|
assert "ghcr.io" not in cmd
|
||||||
|
# Cache reference falls back to Docker Hub when GHCR isn't selected
|
||||||
|
assert "--cache-from type=registry,ref=esphome/esphome-amd64:cache-dev" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
commands = _run(
|
||||||
|
capsys,
|
||||||
|
"--tag",
|
||||||
|
"my-branch",
|
||||||
|
"--build-type",
|
||||||
|
"docker",
|
||||||
|
"--registry",
|
||||||
|
"dockerhub",
|
||||||
|
"manifest",
|
||||||
|
)
|
||||||
|
|
||||||
|
create = commands[0]
|
||||||
|
assert create.startswith(
|
||||||
|
"docker buildx imagetools create --tag esphome/esphome:my-branch "
|
||||||
|
)
|
||||||
|
assert "ghcr.io" not in create
|
||||||
Reference in New Issue
Block a user