Publish Firmware #156
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Publish Firmware | |
| on: | |
| schedule: | |
| - cron: '27 */6 * * *' | |
| workflow_dispatch: | |
| inputs: | |
| esphome_version: | |
| description: 'ESPHome version to build. Leave empty to use the latest stable release.' | |
| required: false | |
| type: string | |
| force: | |
| description: 'Recreate the GitHub release if it already exists.' | |
| required: false | |
| default: false | |
| type: boolean | |
| permissions: | |
| contents: write | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.inputs.esphome_version || 'latest-stable' }} | |
| cancel-in-progress: false | |
| jobs: | |
| prepare: | |
| name: Prepare Release | |
| runs-on: ubuntu-latest | |
| outputs: | |
| esphome-version: ${{ steps.release.outputs.esphome-version }} | |
| release-tag: ${{ steps.release.outputs.release-tag }} | |
| release-url: ${{ steps.release.outputs.release-url }} | |
| release-notes: ${{ steps.release.outputs.release-notes }} | |
| should-build: ${{ steps.release.outputs.should-build }} | |
| matrix: ${{ steps.files.outputs.matrix }} | |
| files: ${{ steps.files.outputs.files }} | |
| steps: | |
| - name: Checkout source code | |
| uses: actions/checkout@v6 | |
| - name: Resolve ESPHome release | |
| id: release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| INPUT_VERSION: ${{ inputs.esphome_version }} | |
| FORCE: ${{ inputs.force }} | |
| run: | | |
| set -euo pipefail | |
| if [ -n "${INPUT_VERSION}" ]; then | |
| esphome_version="${INPUT_VERSION#v}" | |
| else | |
| esphome_version="$( | |
| gh api repos/esphome/esphome/releases/latest --jq .tag_name | |
| )" | |
| esphome_version="${esphome_version#v}" | |
| fi | |
| if [ -z "${esphome_version}" ] || [ "${esphome_version}" = "null" ]; then | |
| echo "Unable to resolve an ESPHome release version." >&2 | |
| exit 1 | |
| fi | |
| release_tag="esphome-${esphome_version}" | |
| release_url="https://github.com/${GH_REPO}/releases/tag/${release_tag}" | |
| release_notes="Firmware built automatically from ESPHome ${esphome_version} stable release." | |
| should_build="true" | |
| if gh release view "${release_tag}" >/dev/null 2>&1; then | |
| if [ "${FORCE}" = "true" ]; then | |
| echo "Release ${release_tag} already exists and will be replaced after a successful build." | |
| else | |
| should_build="false" | |
| fi | |
| fi | |
| { | |
| echo "esphome-version=${esphome_version}" | |
| echo "release-tag=${release_tag}" | |
| echo "release-url=${release_url}" | |
| echo "release-notes=${release_notes}" | |
| echo "should-build=${should_build}" | |
| } >> "${GITHUB_OUTPUT}" | |
| if [ "${should_build}" = "true" ]; then | |
| echo "Building firmware with ESPHome ${esphome_version}." | |
| else | |
| echo "Release ${release_tag} already exists. Nothing to build." | |
| fi | |
| - name: Discover ESPHome YAML files | |
| id: files | |
| run: | | |
| set -euo pipefail | |
| mapfile -t files < <(find . -maxdepth 1 -type f -name '*.yaml' \ | |
| ! -name 'athom-ws2812b.yaml' \ | |
| ! -name 'athom-ls-4p-3wire.yaml' \ | |
| ! -name 'athom-ls-4p-4wire.yaml' \ | |
| -exec basename {} \; | sort) | |
| if [ "${#files[@]}" -eq 0 ]; then | |
| echo "No root-level ESPHome YAML files found." >&2 | |
| exit 1 | |
| fi | |
| json="$(printf '%s\n' "${files[@]}" | jq -R -s -c 'split("\n")[:-1]')" | |
| names="$(printf '%s\n' "${files[@]}" | sed 's/\.yaml$//' | jq -R -s -c 'split("\n")[:-1]')" | |
| { | |
| echo "matrix=${json}" | |
| echo "files=${names}" | |
| } >> "${GITHUB_OUTPUT}" | |
| build-firmware: | |
| name: Build ${{ matrix.file }} | |
| runs-on: ubuntu-latest | |
| needs: | |
| - prepare | |
| if: needs.prepare.outputs.should-build == 'true' | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 3 | |
| matrix: | |
| file: ${{ fromJson(needs.prepare.outputs.matrix) }} | |
| steps: | |
| - name: Checkout source code | |
| uses: actions/checkout@v6 | |
| - name: ESPHome ${{ needs.prepare.outputs.esphome-version }} | |
| id: build | |
| continue-on-error: true | |
| uses: esphome/build-action@v7 | |
| with: | |
| yaml-file: ${{ matrix.file }} | |
| version: ${{ needs.prepare.outputs.esphome-version }} | |
| complete-manifest: true | |
| release-summary: ${{ needs.prepare.outputs.release-notes }} | |
| release-url: ${{ needs.prepare.outputs.release-url }} | |
| - name: Collect firmware files | |
| run: | | |
| set -euo pipefail | |
| name="${{ matrix.file }}" | |
| name="${name%.yaml}" | |
| mkdir -p "firmware/${name}" | |
| build_name="${{ steps.build.outputs.name }}" | |
| build_dir="" | |
| if [ -n "${build_name}" ] && [ -d "${build_name}" ]; then | |
| build_dir="${build_name}" | |
| else | |
| mapfile -t candidates < <(find . -mindepth 2 -maxdepth 2 -name '*.factory.bin' -exec dirname {} \; | sed 's#^\./##' | sort -u) | |
| if [ "${#candidates[@]}" -eq 1 ]; then | |
| build_dir="${candidates[0]}" | |
| build_name="$(basename "${build_dir}")" | |
| fi | |
| fi | |
| if [ -z "${build_dir}" ] || [ ! -d "${build_dir}" ]; then | |
| echo "Expected ESPHome build output directory was not found." >&2 | |
| find . -maxdepth 3 -type f \( -name '*.bin' -o -name 'manifest.json' \) -print >&2 | |
| exit 1 | |
| fi | |
| cp "${build_dir}/"*.bin "firmware/${name}/" | |
| factory_bin="$(find "firmware/${name}" -maxdepth 1 -name '*.factory.bin' -print -quit)" | |
| ota_bin="$(find "firmware/${name}" -maxdepth 1 -name '*.ota.bin' -print -quit)" | |
| if [ -z "${factory_bin}" ] || [ -z "${ota_bin}" ]; then | |
| echo "Expected factory and OTA binaries in '${build_dir}'." >&2 | |
| exit 1 | |
| fi | |
| if [ ! -f "${build_dir}/manifest.json" ]; then | |
| case "${build_name}" in | |
| *-esp32) chip_family="ESP32" ;; | |
| *-esp32s2) chip_family="ESP32-S2" ;; | |
| *-esp32s3) chip_family="ESP32-S3" ;; | |
| *-esp32c3) chip_family="ESP32-C3" ;; | |
| *-esp32c5) chip_family="ESP32-C5" ;; | |
| *-esp32c6) chip_family="ESP32-C6" ;; | |
| *-esp8266) chip_family="ESP8266" ;; | |
| *) echo "Cannot infer chip family from build name '${build_name}'." >&2; exit 1 ;; | |
| esac | |
| jq -n --arg chipFamily "${chip_family}" \ | |
| --arg name "${name}" \ | |
| --arg version "${{ needs.prepare.outputs.esphome-version }}" \ | |
| --arg otaPath "$(basename "${ota_bin}")" \ | |
| --arg otaMd5 "$(md5sum "${ota_bin}" | awk '{print $1}')" \ | |
| --arg otaSha256 "$(sha256sum "${ota_bin}" | awk '{print $1}')" \ | |
| --arg factoryPath "$(basename "${factory_bin}")" \ | |
| --arg factoryMd5 "$(md5sum "${factory_bin}" | awk '{print $1}')" \ | |
| --arg factorySha256 "$(sha256sum "${factory_bin}" | awk '{print $1}')" \ | |
| '{ | |
| name: $name, | |
| version: $version, | |
| home_assistant_domain: "esphome", | |
| new_install_prompt_erase: false, | |
| builds: [ | |
| { | |
| chipFamily: $chipFamily, | |
| ota: { | |
| path: $otaPath, | |
| md5: $otaMd5, | |
| sha256: $otaSha256 | |
| }, | |
| parts: [ | |
| { | |
| path: $factoryPath, | |
| offset: 0, | |
| md5: $factoryMd5, | |
| sha256: $factorySha256 | |
| } | |
| ] | |
| } | |
| ] | |
| }' > "firmware/${name}/manifest.json" | |
| else | |
| cp "${build_dir}/manifest.json" "firmware/${name}/manifest.json" | |
| fi | |
| find "firmware/${name}" -maxdepth 1 -type f -print | sort | |
| test -f "firmware/${name}/manifest.json" | |
| - name: Upload firmware artifact | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: firmware-${{ matrix.file }} | |
| path: firmware/ | |
| if-no-files-found: error | |
| retention-days: 1 | |
| publish-release: | |
| name: Publish Release | |
| runs-on: ubuntu-latest | |
| needs: | |
| - prepare | |
| - build-firmware | |
| if: needs.prepare.outputs.should-build == 'true' | |
| steps: | |
| - name: Download firmware artifacts | |
| uses: actions/download-artifact@v8 | |
| with: | |
| path: downloaded-artifacts | |
| pattern: firmware-* | |
| merge-multiple: true | |
| - name: Prepare release assets | |
| env: | |
| RELEASE_TAG: ${{ needs.prepare.outputs.release-tag }} | |
| ESPHOME_VERSION: ${{ needs.prepare.outputs.esphome-version }} | |
| RELEASE_NOTES: ${{ needs.prepare.outputs.release-notes }} | |
| FILES_JSON: ${{ needs.prepare.outputs.files }} | |
| REPOSITORY: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| mkdir -p release-assets firmware | |
| if [ -d downloaded-artifacts/firmware ]; then | |
| cp -R downloaded-artifacts/firmware/. firmware/ | |
| else | |
| cp -R downloaded-artifacts/. firmware/ | |
| fi | |
| jq -n --arg version "${ESPHOME_VERSION}" \ | |
| --argjson devices "${FILES_JSON}" \ | |
| '{ | |
| version: $version, | |
| generated_at: (now | todateiso8601), | |
| devices: ($devices | map({ | |
| id: ., | |
| name: (. | split("-") | map(ascii_upcase[0:1] + .[1:]) | join(" ")), | |
| yaml: (. + ".yaml"), | |
| manifest: ("firmware/" + . + ".manifest.json") | |
| })) | |
| }' > release-assets/devices.json | |
| jq -n --arg name "Athom ESP8285 Device" \ | |
| --arg version "${ESPHOME_VERSION}" \ | |
| --arg homeAssistantDomain "esphome" \ | |
| '{name: $name, version: $version, home_assistant_domain: $homeAssistantDomain, new_install_prompt_erase: false, builds: []}' \ | |
| > release-assets/Athom-ESP8285-Device.manifest.json | |
| shopt -s nullglob | |
| mapfile -t devices < <(jq -r '.[]' <<< "${FILES_JSON}") | |
| for device in "${devices[@]}"; do | |
| device_dir="firmware/${device}" | |
| manifest_path="${device_dir}/manifest.json" | |
| if [ ! -d "${device_dir}" ]; then | |
| echo "Expected firmware directory '${device_dir}'." >&2 | |
| exit 1 | |
| fi | |
| if [ ! -f "${manifest_path}" ]; then | |
| echo "Expected manifest '${manifest_path}'." >&2 | |
| exit 1 | |
| fi | |
| bins=("${device_dir}/"*.bin) | |
| factory_bins=("${device_dir}/"*.factory.bin) | |
| ota_bins=("${device_dir}/"*.ota.bin) | |
| if [ "${#bins[@]}" -eq 0 ]; then | |
| echo "Expected at least one binary in '${device_dir}'." >&2 | |
| exit 1 | |
| fi | |
| if [ "${#factory_bins[@]}" -ne 1 ]; then | |
| echo "Expected one factory binary in '${device_dir}', found ${#factory_bins[@]}." >&2 | |
| exit 1 | |
| fi | |
| if [ "${#ota_bins[@]}" -ne 1 ]; then | |
| echo "Expected one OTA binary in '${device_dir}', found ${#ota_bins[@]}." >&2 | |
| exit 1 | |
| fi | |
| device_manifest="$(mktemp)" | |
| cp "${manifest_path}" "${device_manifest}" | |
| for binary in "${bins[@]}"; do | |
| binary_name="$(basename "${binary}")" | |
| asset_name="${device}-${binary_name}" | |
| if [ -e "release-assets/${asset_name}" ]; then | |
| echo "Release asset '${asset_name}' already exists." >&2 | |
| exit 1 | |
| fi | |
| cp "${binary}" "release-assets/${asset_name}" | |
| tmp_manifest="$(mktemp)" | |
| jq --arg from "${binary_name}" \ | |
| --arg to "${asset_name}" \ | |
| 'walk(if type == "object" and has("path") and (.path | split("/")[-1]) == $from then .path = $to else . end)' \ | |
| "${device_manifest}" > "${tmp_manifest}" | |
| mv "${tmp_manifest}" "${device_manifest}" | |
| done | |
| cp "${device_manifest}" "release-assets/${device}.manifest.json" | |
| rm -f "${device_manifest}" | |
| tmp_manifest="$(mktemp)" | |
| jq --slurpfile manifest "release-assets/${device}.manifest.json" \ | |
| '.builds += ($manifest[0].builds // [$manifest[0]])' \ | |
| release-assets/Athom-ESP8285-Device.manifest.json > "${tmp_manifest}" | |
| mv "${tmp_manifest}" release-assets/Athom-ESP8285-Device.manifest.json | |
| done | |
| cd release-assets | |
| for binary in *.bin; do | |
| md5sum "${binary}" > "${binary}.md5" | |
| sha256sum "${binary}" > "${binary}.sha256" | |
| done | |
| expected_count="$(jq 'length' <<< "${FILES_JSON}")" | |
| manifest_count="$(find . -maxdepth 1 -name '*.manifest.json' ! -name 'Athom-ESP8285-Device.manifest.json' | wc -l)" | |
| factory_count="$(find . -maxdepth 1 -name '*.factory.bin' | wc -l)" | |
| ota_count="$(find . -maxdepth 1 -name '*.ota.bin' | wc -l)" | |
| if [ "${manifest_count}" -ne "${expected_count}" ]; then | |
| echo "Expected ${expected_count} device manifests, found ${manifest_count}." >&2 | |
| exit 1 | |
| fi | |
| if [ "${factory_count}" -ne "${expected_count}" ]; then | |
| echo "Expected ${expected_count} factory binaries, found ${factory_count}." >&2 | |
| exit 1 | |
| fi | |
| if [ "${ota_count}" -ne "${expected_count}" ]; then | |
| echo "Expected ${expected_count} OTA binaries, found ${ota_count}." >&2 | |
| exit 1 | |
| fi | |
| for manifest in *.manifest.json; do | |
| while IFS= read -r asset_path; do | |
| if [ -z "${asset_path}" ]; then | |
| continue | |
| fi | |
| case "${asset_path}" in | |
| http://*|https://*) continue ;; | |
| esac | |
| if [ ! -f "${asset_path}" ]; then | |
| echo "Manifest '${manifest}' references missing asset '${asset_path}'." >&2 | |
| exit 1 | |
| fi | |
| done < <(jq -r '[.builds[]? | (.ota.path? // empty), (.parts[]?.path? // empty)] | .[]' "${manifest}") | |
| done | |
| - name: Create GitHub release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GH_REPO: ${{ github.repository }} | |
| FORCE: ${{ inputs.force }} | |
| RELEASE_TAG: ${{ needs.prepare.outputs.release-tag }} | |
| ESPHOME_VERSION: ${{ needs.prepare.outputs.esphome-version }} | |
| RELEASE_NOTES: ${{ needs.prepare.outputs.release-notes }} | |
| run: | | |
| set -euo pipefail | |
| if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then | |
| if [ "${FORCE}" = "true" ]; then | |
| gh release delete "${RELEASE_TAG}" --yes --cleanup-tag | |
| else | |
| echo "Release ${RELEASE_TAG} already exists." >&2 | |
| exit 1 | |
| fi | |
| fi | |
| gh release create "${RELEASE_TAG}" release-assets/* \ | |
| --title "ESPHome ${ESPHOME_VERSION} firmware" \ | |
| --notes "${RELEASE_NOTES}" |