Skip to content

Publish Firmware

Publish Firmware #161

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}"