diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..11a5634 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + push: + branches: + - main + workflow_dispatch: + pull_request: + schedule: + - cron: "0 5 * * 1" + +permissions: + contents: read + +concurrency: + # yamllint disable-line rule:line-length + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build test configuration for esphome:${{ matrix.esphome-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + esphome-version: + - stable + - beta + - dev + steps: + - name: Checkout + uses: actions/checkout@v4.1.7 + - name: Run action + uses: ./ + id: esphome-build + with: + yaml_file: tests/test.yaml + version: ${{ matrix.esphome-version }} + platform: linux/amd64 + cache: true + release_summary: "Test release summary" + release_url: "https://github.com/esphome/build-action" + - name: Write version to file + run: echo ${{ steps.esphome-build.outputs.version }} > ${{ steps.esphome-build.outputs.name }}/version + - name: Upload ESPHome binary + uses: actions/upload-artifact@v4.3.3 + with: + name: build-output-files-${{ matrix.esphome-version }} + path: ${{ steps.esphome-build.outputs.name }} + + verify: + name: Verify output files for esphome:${{ matrix.esphome-version }} + runs-on: ubuntu-latest + needs: build + strategy: + fail-fast: false + matrix: + esphome-version: + - stable + - beta + - dev + steps: + - name: Checkout + uses: actions/checkout@v4.1.7 + - name: Download files + uses: actions/download-artifact@v4.1.7 + with: + name: build-output-files-${{ matrix.esphome-version }} + + - name: List files + run: |- + ls -al + tree + + - name: Validate json file matches manifest-template.json + run: | + jq -n --arg md5 "$(md5sum test-esp32.ota.bin | head -c 32)" -f tests/manifest-template.json > /tmp/manifest.json + diff <(jq --sort-keys . /tmp/manifest.json) <(jq --sort-keys . manifest.json) diff --git a/.gitignore b/.gitignore index 9594b4d..2c0f109 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ -# Gitignore settings for ESPHome -# This is an example and may include too much for your use-case. -# You can modify this file to suit your needs. -/.esphome/ -/secrets.yaml +.esphome venv *.bin *.json +!/tests/manifest-template.json config/ diff --git a/action.yml b/action.yml index 7543829..601ea2e 100644 --- a/action.yml +++ b/action.yml @@ -89,14 +89,12 @@ runs: --workdir /github/workspace \ -v "$(pwd)":"/github/workspace" -v "$HOME:$HOME" \ --user $(id -u):$(id -g) \ - -e INPUT_YAML_FILE -e HOME \ - -e INPUT_RELEASE_SUMMARY -e INPUT_RELEASE_URL \ - -e GITHUB_JOB -e GITHUB_REF -e GITHUB_SHA -e GITHUB_REPOSITORY \ - -e GITHUB_REPOSITORY_OWNER -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER \ - -e GITHUB_ACTOR -e GITHUB_OUTPUT \ - -e GITHUB_ACTIONS=true -e CI=true \ + -e HOME \ esphome:${{ inputs.version }} \ - ${{ inputs.yaml_file }} + ${{ inputs.yaml_file }} \ + --release-summary "${{ inputs.release_summary }}" \ + --release-url "${{ inputs.release_url }}" \ + --outputs-file "$GITHUB_OUTPUT" branding: icon: "archive" diff --git a/entrypoint.py b/entrypoint.py index 90c6812..ccbe389 100755 --- a/entrypoint.py +++ b/entrypoint.py @@ -1,14 +1,17 @@ #!/usr/bin/python3 +from __future__ import annotations -import os -import subprocess -import sys +import argparse +from dataclasses import dataclass +import hashlib import json +import re import shutil +import subprocess +import sys from pathlib import Path -import re + import yaml -import hashlib ESP32_CHIP_FAMILIES = { @@ -19,152 +22,254 @@ "ESP32C6": "ESP32-C6", } -if len(sys.argv) != 2: - print("Usage: entrypoint.py ") - sys.exit(1) - -filename = Path(sys.argv[1]) - -print("::group::Compile firmware") -rc = subprocess.run( - ["esphome", "compile", filename], - stdout=sys.stdout, - stderr=sys.stderr, - check=False, -) -if rc.returncode != 0: - sys.exit(rc) - -print("::endgroup::") - -print("::group::Get ESPHome version") -try: - version = subprocess.check_output(["esphome", "version"]) -except subprocess.CalledProcessError as e: - sys.exit(e.returncode) -version = version.decode("utf-8") -print(version) -version = version.split(" ")[1] -with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as github_output: - print(f"esphome-version={version}", file=github_output) -print("::endgroup::") - -print("::group::Get config") -try: - config = subprocess.check_output(["esphome", "config", filename], stderr=sys.stderr) -except subprocess.CalledProcessError as e: - sys.exit(e.returncode) - -config = config.decode("utf-8") -print(config) - -yaml.add_multi_constructor("", lambda _, t, n: t + " " + n.value) -config = yaml.load(config, Loader=yaml.FullLoader) - -name = config["esphome"]["name"] - -with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as github_output: - print(f"original_name={name}", file=github_output) - -platform = "" -if "esp32" in config: - platform = config["esp32"]["variant"].lower() -elif "esp8266" in config: - platform = "esp8266" -elif "rp2040" in config: - platform = "rp2040" - -name += f"-{platform}" - -with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as github_output: - print(f"name={name}", file=github_output) -print("::endgroup::") - -file_base = Path(name) - -print("::group::Get IDEData") -try: - idedata = subprocess.check_output( - ["esphome", "idedata", filename], stderr=sys.stderr - ) -except subprocess.CalledProcessError as e: - sys.exit(e.returncode) - -data = json.loads(idedata.decode("utf-8")) -print(json.dumps(data, indent=2)) - -elf = Path(data["prog_path"]) -if platform == "rp2040": - source_factory_bin = elf.with_name("firmware.uf2") - dest_factory_bin = file_base / f"{name}.uf2" -else: - source_factory_bin = elf.with_name("firmware.factory.bin") - dest_factory_bin = file_base / f"{name}.factory.bin" -source_ota_bin = elf.with_name("firmware.ota.bin") -dest_ota_bin = file_base / f"{name}.ota.bin" +def parse_args(argv): + """Parse the arguments.""" + parser = argparse.ArgumentParser() + parser.add_argument("configuration", help="Path to the configuration file") + parser.add_argument("--release-summary", help="Release summary", nargs="?") + parser.add_argument("--release-url", help="Release URL", nargs="?") -print("::endgroup::") + parser.add_argument("--outputs-file", help="GitHub Outputs file", nargs="?") -print("::group::Copy firmware file(s) to folder") -file_base.mkdir(parents=True, exist_ok=True) + return parser.parse_args(argv[1:]) -shutil.copyfile(source_factory_bin, dest_factory_bin) -shutil.copyfile(source_ota_bin, dest_ota_bin) -print("::endgroup::") - - -print("::group::Write manifest.json file") - - -chip_family = None -define: str -has_factory_part = False -for define in data["defines"]: - if define == "USE_ESP8266": - chip_family = "ESP8266" - has_factory_part = True - break - if define == "USE_RP2040": - chip_family = "RP2040" - break - if m := re.match(r"USE_ESP32_VARIANT_(\w+)", define): - chip_family = m.group(1) - if chip_family not in ESP32_CHIP_FAMILIES: - raise Exception(f"Unsupported chip family: {chip_family}") - - chip_family = ESP32_CHIP_FAMILIES[chip_family] - has_factory_part = True - break - -ota_md5 = hashlib.md5(open(dest_ota_bin, "rb").read()).hexdigest() - -manifest = { - "chipFamily": chip_family, - "ota": { - "path": str(dest_ota_bin), - "md5": ota_md5, - }, -} - -if release_summary := os.environ.get("INPUT_RELEASE_SUMMARY"): - manifest["ota"]["summary"] = release_summary -if release_url := os.environ.get("INPUT_RELEASE_URL"): - manifest["ota"]["release_url"] = release_url +def compile_firmware(filename: Path) -> int: + """Compile the firmware.""" + print("::group::Compile firmware") + rc = subprocess.run( + ["esphome", "compile", filename], + stdout=sys.stdout, + stderr=sys.stderr, + check=False, + ) + print("::endgroup::") + return rc.returncode + + +def get_esphome_version(outputs_file: str | None) -> tuple[str, int]: + """Get the ESPHome version.""" + print("::group::Get ESPHome version") + try: + version = subprocess.check_output(["esphome", "version"]) + except subprocess.CalledProcessError as e: + print("::endgroup::") + return "", e.returncode + + version = version.decode("utf-8") + print(version) + version = version.split(" ")[1].strip() + if outputs_file: + with open(outputs_file, "a", encoding="utf-8") as output: + print(f"esphome-version={version}", file=output) + print("::endgroup::") + return version, 0 + + +@dataclass +class Config: + """Configuration data.""" + + name: str + platform: str + original_name: str + + def dest_factory_bin(self, file_base: Path) -> Path: + """Get the destination factory binary path.""" + if self.platform == "rp2040": + return file_base / f"{self.name}.uf2" + return file_base / f"{self.name}.factory.bin" + + def dest_ota_bin(self, file_base: Path) -> Path: + """Get the destination OTA binary path.""" + return file_base / f"{self.name}.ota.bin" + + def source_factory_bin(self, elf: Path) -> Path: + """Get the source factory binary path.""" + if self.platform == "rp2040": + return elf.with_name("firmware.uf2") + return elf.with_name("firmware.factory.bin") + + def source_ota_bin(self, elf: Path) -> Path: + """Get the source OTA binary path.""" + return elf.with_name("firmware.ota.bin") + + +def get_config(filename: Path, outputs_file: str | None) -> tuple[Config | None, int]: + """Get the configuration.""" + print("::group::Get config") + try: + config = subprocess.check_output( + ["esphome", "config", filename], stderr=sys.stderr + ) + except subprocess.CalledProcessError as e: + return None, e.returncode + + config = config.decode("utf-8") + print(config) + + yaml.add_multi_constructor("", lambda _, t, n: t + " " + n.value) + config = yaml.load(config, Loader=yaml.FullLoader) + + original_name = config["esphome"]["name"] + + if outputs_file: + with open(outputs_file, "a", encoding="utf-8") as output: + print(f"original_name={original_name}", file=output) + + platform = "" + if "esp32" in config: + platform = config["esp32"]["variant"].lower() + elif "esp8266" in config: + platform = "esp8266" + elif "rp2040" in config: + platform = "rp2040" + + name = f"{original_name}-{platform}" + + if outputs_file: + with open(outputs_file, "a", encoding="utf-8") as output: + print(f"name={name}", file=output) + print("::endgroup::") + return Config(name=name, platform=platform, original_name=original_name), 0 + + +def get_idedata(filename: Path) -> tuple[dict | None, int]: + """Get the IDEData.""" + print("::group::Get IDEData") + try: + idedata = subprocess.check_output( + ["esphome", "idedata", filename], stderr=sys.stderr + ) + except subprocess.CalledProcessError as e: + return None, e.returncode + + data = json.loads(idedata.decode("utf-8")) + print(json.dumps(data, indent=2)) + print("::endgroup::") + return data, 0 + + +def generate_manifest( + idedata: dict, + factory_bin: Path, + ota_bin: Path, + release_summary: str | None, + release_url: str | None, +) -> tuple[dict | None, int]: + """Generate the manifest.""" + print("::group::Generate manifest") + + chip_family = None + define: str + has_factory_part = False + for define in idedata["defines"]: + if define == "USE_ESP8266": + chip_family = "ESP8266" + has_factory_part = True + break + if define == "USE_RP2040": + chip_family = "RP2040" + break + if m := re.match(r"USE_ESP32_VARIANT_(\w+)", define): + chip_family = m.group(1) + if chip_family not in ESP32_CHIP_FAMILIES: + print(f"ERROR: Unsupported chip family: {chip_family}") + return None, 1 + + chip_family = ESP32_CHIP_FAMILIES[chip_family] + has_factory_part = True + break + + with open(ota_bin, "rb") as f: + ota_md5 = hashlib.md5(f.read()).hexdigest() + + manifest = { + "chipFamily": chip_family, + "ota": { + "path": str(ota_bin), + "md5": ota_md5, + }, + } + + if release_summary: + manifest["ota"]["summary"] = release_summary + if release_url: + manifest["ota"]["release_url"] = release_url + + if has_factory_part: + manifest["parts"] = [ + { + "path": str(factory_bin), + "offset": 0x00, + } + ] + + print("Writing manifest file:") + print(json.dumps(manifest, indent=2)) + + print("::endgroup::") + return manifest, 0 + + +def main(argv) -> int: + """Main entrypoint.""" + args = parse_args(argv) + + filename = Path(args.configuration) + + if (rc := compile_firmware(filename)) != 0: + return rc + + _, rc = get_esphome_version(args.outputs_file) + if rc != 0: + return rc + + config, rc = get_config(filename, args.outputs_file) + if rc != 0: + return rc + + assert config is not None + + file_base = Path(config.name) + + idedata, rc = get_idedata(filename) + if rc != 0: + return rc + + print("::group::Copy firmware file(s) to folder") + + elf = Path(idedata["prog_path"]) + + source_factory_bin = config.source_factory_bin(elf) + dest_factory_bin = config.dest_factory_bin(file_base) + + source_ota_bin = config.source_ota_bin(elf) + dest_ota_bin = config.dest_ota_bin(file_base) + + file_base.mkdir(parents=True, exist_ok=True) + + shutil.copyfile(source_factory_bin, dest_factory_bin) + shutil.copyfile(source_ota_bin, dest_ota_bin) + + print("::endgroup::") + + manifest, rc = generate_manifest( + idedata, + dest_factory_bin, + dest_ota_bin, + args.release_summary, + args.release_url, + ) + if rc != 0: + return rc -if has_factory_part: - manifest["parts"] = [ - { - "path": str(dest_factory_bin), - "offset": 0x00, - } - ] + with open(file_base / "manifest.json", "w", encoding="utf-8") as f: + json.dump(manifest, f, indent=2) -print("Writing manifest file:") -print(json.dumps(manifest, indent=2)) + return 0 -with open(file_base / "manifest.json", "w", encoding="utf-8") as f: - json.dump(manifest, f, indent=2) -print("::endgroup::") +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..d8b4157 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,5 @@ +# Gitignore settings for ESPHome +# This is an example and may include too much for your use-case. +# You can modify this file to suit your needs. +/.esphome/ +/secrets.yaml diff --git a/tests/manifest-template.json b/tests/manifest-template.json new file mode 100644 index 0000000..e705442 --- /dev/null +++ b/tests/manifest-template.json @@ -0,0 +1,15 @@ +{ + "chipFamily": "ESP32", + "ota": { + "path": "test-esp32/test-esp32.ota.bin", + "md5": "\($md5)", + "summary": "Test release summary", + "release_url": "https://github.com/esphome/build-action" + }, + "parts": [ + { + "path": "test-esp32/test-esp32.factory.bin", + "offset": 0 + } + ] +} diff --git a/tests/test.yaml b/tests/test.yaml new file mode 100644 index 0000000..cb9a6bb --- /dev/null +++ b/tests/test.yaml @@ -0,0 +1,5 @@ +esphome: + name: test + +esp32: + board: esp32dev