From abcffeab2c97d5890185af97a8ffb8f2df62a68d Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:09:21 +0100 Subject: [PATCH 1/3] Add substitutions input to support ESPHome build-time substitutions Closes #58 Adds a `substitutions` input to the action, allowing build-time substitutions to be passed to ESPHome via the `-s` flag. Each substitution is provided as a `key=value` pair on its own line. ## Example ```yaml - uses: esphome/build-action@v6 with: yaml-file: firmware/config.yaml substitutions: | name=my-device rfid1_miso_pin=GPIO37 rfid1_clk_pin=GPIO36 ``` --- README.md | 12 ++++++++++++ action.yml | 22 ++++++++++++++++++++++ entrypoint.py | 47 ++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 72d500f..0b75ec0 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,17 @@ with: yaml-file: my_configuration.yaml ``` +To pass build-time substitutions: + +```yaml +uses: esphome/build-action@v6 +with: + yaml-file: my_configuration.yaml + substitutions: | + name=my-device + board_pin=GPIO4 +``` + This action is used by the [ESPHome publish workflow](https://github.com/esphome/workflows/blob/main/.github/workflows/publish.yml) that is used to compile firmware and publish simple GitHub pages sites for projects. ## Inputs @@ -25,6 +36,7 @@ This action is used by the [ESPHome publish workflow](https://github.com/esphome | `release-summary` | _None_ | A small summary of the release that will be added to the manifest file. | | `release-url` | _None_ | A URL to the release page that will be added to the manifest file. | | `complete-manifest` | `false` | Whether to output a complete manifest file. Defaults to output a partial manifest only. | +| `substitutions` | _None_ | Build-time substitutions passed to ESPHome via the `-s` flag. One `key=value` pair per line. | ## Outputs diff --git a/action.yml b/action.yml index c97583d..6af8ac1 100644 --- a/action.yml +++ b/action.yml @@ -29,6 +29,16 @@ inputs: description: Output complete esp-web-tools manifest.json required: false default: false + substitutions: + description: | + Build-time substitutions to pass to ESPHome via the `-s` flag. + Provide one `key=value` pair per line. Values may contain `=` signs. + Example: + substitutions: | + name=my-device + board_pin=GPIO4 + required: false + default: "" outputs: name: @@ -71,6 +81,17 @@ runs: ENDOFSUMMARY ) + # Parse the multiline `substitutions` input into repeated + # `--substitution key=value` arguments for entrypoint.py. + # Blank lines (e.g. trailing newlines from a YAML block scalar) are skipped. + substitution_args=() + while IFS= read -r line; do + [[ -z "${line}" ]] && continue + substitution_args+=(--substitution "${line}") + done <<'ENDOFSUBSTITUTIONS' + ${{ inputs.substitutions }} + ENDOFSUBSTITUTIONS + docker run --rm \ --workdir /github/workspace \ -v "$(pwd)":"/github/workspace" -v "$HOME:$HOME" \ @@ -78,6 +99,7 @@ runs: -e HOME \ esphome:${{ inputs.version }} \ ${{ inputs.yaml-file }} \ + "${substitution_args[@]}" \ --release-summary "$summary" \ --release-url "${{ inputs.release-url }}" \ --outputs-file "$GITHUB_OUTPUT" \ diff --git a/entrypoint.py b/entrypoint.py index 53cd562..1ea07e1 100755 --- a/entrypoint.py +++ b/entrypoint.py @@ -46,14 +46,34 @@ def parse_args(argv): parser.add_argument("--outputs-file", help="GitHub Outputs file", nargs="?") + parser.add_argument( + "--substitution", + metavar="KEY=VALUE", + action="append", + default=[], + dest="substitutions", + help=( + "Build-time substitution in KEY=VALUE format (repeatable). " + "Values may themselves contain '=' signs." + ), + ) + return parser.parse_args(argv[1:]) -def compile_firmware(filename: Path) -> int: +def _substitution_args(substitutions: dict[str, str]) -> list[str]: + """Convert a substitutions dict into ESPHome ``-s key value`` CLI args.""" + args: list[str] = [] + for key, value in substitutions.items(): + args += ["-s", key, value] + return args + + +def compile_firmware(filename: Path, substitutions: dict[str, str]) -> int: """Compile the firmware.""" print("::group::Compile firmware") rc = subprocess.run( - ["esphome", "compile", filename], + ["esphome"] + _substitution_args(substitutions) + ["compile", filename], stdout=sys.stdout, stderr=sys.stderr, check=False, @@ -120,12 +140,13 @@ def source_ota_bin(self, elf: Path) -> Path: return elf.with_name("firmware.ota.bin") -def get_config(filename: Path, outputs_file: str | None) -> tuple[Config | None, int]: +def get_config(filename: Path, outputs_file: str | None, substitutions: dict[str, str]) -> tuple[Config | None, int]: """Get the configuration.""" print("::group::Get config") try: config = subprocess.check_output( - ["esphome", "config", filename], stderr=sys.stderr + ["esphome"] + _substitution_args(substitutions) + ["config", filename], + stderr=sys.stderr, ) except subprocess.CalledProcessError as e: return None, e.returncode @@ -179,12 +200,13 @@ def get_config(filename: Path, outputs_file: str | None) -> tuple[Config | None, ), 0 -def get_idedata(filename: Path) -> tuple[dict | None, int]: +def get_idedata(filename: Path, substitutions: dict[str, str]) -> tuple[dict | None, int]: """Get the IDEData.""" print("::group::Get IDEData") try: idedata = subprocess.check_output( - ["esphome", "idedata", filename], stderr=sys.stderr + ["esphome"] + _substitution_args(substitutions) + ["idedata", filename], + stderr=sys.stderr, ) except subprocess.CalledProcessError as e: return None, e.returncode @@ -267,14 +289,21 @@ def main(argv) -> int: filename = Path(args.configuration) - if (rc := compile_firmware(filename)) != 0: + # Parse the list of "KEY=VALUE" strings into a dict, splitting on the + # first '=' only so that values containing '=' are preserved correctly. + substitutions: dict[str, str] = {} + for item in args.substitutions: + key, _, value = item.partition("=") + substitutions[key] = value + + if (rc := compile_firmware(filename, substitutions)) != 0: return rc esphome_version, rc = get_esphome_version(args.outputs_file) if rc != 0: return rc - config, rc = get_config(filename, args.outputs_file) + config, rc = get_config(filename, args.outputs_file, substitutions) if rc != 0: return rc @@ -282,7 +311,7 @@ def main(argv) -> int: file_base = Path(config.name) - idedata, rc = get_idedata(filename) + idedata, rc = get_idedata(filename, substitutions) if rc != 0: return rc From ea4ddd897d6c6afc6c5a87e02f8a9af0fe3e4ae9 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:46:20 +0100 Subject: [PATCH 2/3] Add tests --- .github/workflows/ci.yml | 78 +++++++++++++++++++ ...plete-substitutions-manifest-template.json | 26 +++++++ ...rtial-substitutions-manifest-template.json | 18 +++++ tests/test-substitutions.yaml | 8 ++ 4 files changed, 130 insertions(+) create mode 100644 tests/complete-substitutions-manifest-template.json create mode 100644 tests/partial-substitutions-manifest-template.json create mode 100644 tests/test-substitutions.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6cbf9c..87b4365 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,3 +100,81 @@ jobs: --arg factory_sha256 "$(sha256sum test-esp32.factory.bin | head -c 64)" \ -f tests/${{ matrix.manifest }}-manifest-template.json > /tmp/manifest.json diff <(jq --sort-keys . /tmp/manifest.json) <(jq --sort-keys . manifest.json) + + build-substitutions: + name: substitutions / esphome:${{ matrix.esphome-version }} / ${{ matrix.manifest }} / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + max-parallel: 2 + matrix: + esphome-version: + - stable + - beta + - dev + manifest: + - complete + - partial + os: + - ubuntu-24.04 + - ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + - name: Run action + uses: ./ + id: esphome-build + with: + yaml-file: tests/test-substitutions.yaml + version: ${{ matrix.esphome-version }} + release-summary: ${{ env.TEST_RELEASE_SUMMARY }} + release-url: "https://github.com/esphome/build-action" + complete-manifest: ${{ matrix.manifest == 'complete' }} + substitutions: | + device_name=test-substitutions + board=esp32dev + - 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.6.2 + with: + name: build-output-substitutions-${{ matrix.esphome-version }}-${{ matrix.manifest }}-${{ matrix.os }} + path: ${{ steps.esphome-build.outputs.name }} + + verify-substitutions: + name: Verify substitutions output for esphome:${{ matrix.esphome-version }} with ${{ matrix.manifest }} manifest + runs-on: ${{ matrix.os }} + needs: build-substitutions + strategy: + fail-fast: false + matrix: + esphome-version: + - stable + - beta + - dev + manifest: + - complete + - partial + os: + - ubuntu-24.04 + - ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + - name: Download files + uses: actions/download-artifact@v4.2.1 + with: + name: build-output-substitutions-${{ matrix.esphome-version }}-${{ matrix.manifest }}-${{ matrix.os }} + - name: List files + run: |- + ls -al + tree + - name: Validate json file matches ${{ matrix.manifest }} manifest-template.json + run: | + jq -n \ + --arg ota_md5 "$(md5sum test-substitutions-esp32.ota.bin | head -c 32)" \ + --arg ota_sha256 "$(sha256sum test-substitutions-esp32.ota.bin | head -c 64)" \ + --arg factory_md5 "$(md5sum test-substitutions-esp32.factory.bin | head -c 32)" \ + --arg factory_sha256 "$(sha256sum test-substitutions-esp32.factory.bin | head -c 64)" \ + -f tests/${{ matrix.manifest }}-substitutions-manifest-template.json > /tmp/manifest.json + diff <(jq --sort-keys . /tmp/manifest.json) <(jq --sort-keys . manifest.json) diff --git a/tests/complete-substitutions-manifest-template.json b/tests/complete-substitutions-manifest-template.json new file mode 100644 index 0000000..0fa7032 --- /dev/null +++ b/tests/complete-substitutions-manifest-template.json @@ -0,0 +1,26 @@ +{ + "name": "esphome.example-project", + "version": "3.5.0", + "home_assistant_domain": "esphome", + "new_install_prompt_erase": false, + "builds": [ + { + "chipFamily": "ESP32", + "ota": { + "path": "test-substitutions-esp32.ota.bin", + "md5": "\($ota_md5)", + "sha256": "\($ota_sha256)", + "summary": "Test \"release\" summary\n* Multiple lines", + "release_url": "https://github.com/esphome/build-action" + }, + "parts": [ + { + "path": "test-substitutions-esp32.factory.bin", + "offset": 0, + "md5": "\($factory_md5)", + "sha256": "\($factory_sha256)" + } + ] + } + ] +} diff --git a/tests/partial-substitutions-manifest-template.json b/tests/partial-substitutions-manifest-template.json new file mode 100644 index 0000000..2ddc342 --- /dev/null +++ b/tests/partial-substitutions-manifest-template.json @@ -0,0 +1,18 @@ +{ + "chipFamily": "ESP32", + "ota": { + "path": "test-substitutions-esp32.ota.bin", + "md5": "\($ota_md5)", + "sha256": "\($ota_sha256)", + "summary": "Test \"release\" summary\n* Multiple lines", + "release_url": "https://github.com/esphome/build-action" + }, + "parts": [ + { + "path": "test-substitutions-esp32.factory.bin", + "offset": 0, + "md5": "\($factory_md5)", + "sha256": "\($factory_sha256)" + } + ] +} diff --git a/tests/test-substitutions.yaml b/tests/test-substitutions.yaml new file mode 100644 index 0000000..eb094cb --- /dev/null +++ b/tests/test-substitutions.yaml @@ -0,0 +1,8 @@ +esphome: + name: "${device_name}" + project: + name: esphome.example-project + version: "3.5.0" + +esp32: + board: "${board}" From 9aa7dd1c518dc250c9611611230284ec4f349267 Mon Sep 17 00:00:00 2001 From: Edward Firmo <94725493+edwardtfn@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:47:04 +0100 Subject: [PATCH 3/3] Fix json file name reference --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87b4365..db94ec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,7 +169,7 @@ jobs: run: |- ls -al tree - - name: Validate json file matches ${{ matrix.manifest }} manifest-template.json + - name: Validate json file matches ${{ matrix.manifest }} substitutions-manifest-template.json run: | jq -n \ --arg ota_md5 "$(md5sum test-substitutions-esp32.ota.bin | head -c 32)" \ @@ -177,4 +177,4 @@ jobs: --arg factory_md5 "$(md5sum test-substitutions-esp32.factory.bin | head -c 32)" \ --arg factory_sha256 "$(sha256sum test-substitutions-esp32.factory.bin | head -c 64)" \ -f tests/${{ matrix.manifest }}-substitutions-manifest-template.json > /tmp/manifest.json - diff <(jq --sort-keys . /tmp/manifest.json) <(jq --sort-keys . manifest.json) + diff <(jq --sort-keys . /tmp/manifest.json) <(jq --sort-keys . manifest.json) \ No newline at end of file