mirror of
https://github.com/alexbelgium/hassio-addons.git
synced 2026-08-22 04:43:33 +02:00
A single failing matrix leg in the builder reverts the entire push, so any transient error inside a build silently undoes a good version bump. Observed on zoneminder 1.38.4 (run 31678876409, attempt 1): the aarch64 leg failed after 44 s inside the ha_autoapps.sh layer with curl: (92) HTTP/2 stream 1 was not closed cleanly: REFUSED_STREAM (err 7) gzip: stdin: unexpected end of file tar: Error is not recoverable: exiting now while the amd64 leg built and pushed 1.38.4 to GHCR. revert-on-failure then pushed 0ee26fc72 reverting the bump; a manual re-run of the same source went fully green. Because that re-run flips the run conclusion to success, these incidents do not even show up in run-conclusion statistics. The build-image step is now run tolerantly (continue-on-error) and repeated once when the first attempt fails. Only a second failure reaches revert-on-failure, so genuinely broken add-ons are still reverted, one build later than before. The retry is unconditional rather than gated on the log text looking transient: BuildKit reformats error strings and registry/runner failures spell themselves many different ways, so a text classifier would eventually stop reverting real breakage. It is also cheap - the add-ons that fail deterministically on every push (ente, comixed, binance-trading-bot) each fail in 16-34 s. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
502 lines
19 KiB
YAML
502 lines
19 KiB
YAML
# yamllint disable rule:line-length
|
|
---
|
|
name: Builder
|
|
|
|
on:
|
|
workflow_call:
|
|
push:
|
|
branches:
|
|
- master
|
|
paths:
|
|
- "**/config.*"
|
|
|
|
jobs:
|
|
detect-changed-addons:
|
|
if: >-
|
|
${{
|
|
github.repository_owner == 'alexbelgium' &&
|
|
(github.event_name != 'push' || !contains(github.event.head_commit.message, 'nobuild'))
|
|
}}
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
changedAddons: ${{ steps.find_addons.outputs.changed_addons }}
|
|
steps:
|
|
- name: Checkout repo
|
|
uses: actions/checkout@v7.0.1
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Find add-on directories to process
|
|
id: find_addons
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
if [ "${{ github.event_name }}" = "push" ]; then
|
|
before="${{ github.event.before }}"
|
|
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
|
git fetch --no-tags --depth=1 origin "$before" || true
|
|
changed_config_files=$(git diff --name-only "$before" "${{ github.sha }}" | grep -E '^[^/]+/config\.(json|ya?ml)$' || true)
|
|
else
|
|
changed_config_files=$(git diff-tree --no-commit-id --name-only -r "${{ github.sha }}" | grep -E '^[^/]+/config\.(json|ya?ml)$' || true)
|
|
fi
|
|
echo "Changed config files:"
|
|
printf '%s\n' "$changed_config_files"
|
|
changed_addons=$(printf '%s\n' "$changed_config_files" | awk -F/ 'NF { print $1 }' | sort -u | jq -R -s -c 'split("\n") | map(select(length > 0))')
|
|
else
|
|
changed_addons=$(find . -maxdepth 2 \( -name 'config.json' -o -name 'config.yaml' -o -name 'config.yml' \) -printf '%h\n' | sed 's#^\./##' | sort -u | jq -R -s -c 'split("\n") | map(select(length > 0))')
|
|
fi
|
|
|
|
echo "Changed add-ons: $changed_addons"
|
|
echo "changed_addons=${changed_addons:-[]}" >> "$GITHUB_OUTPUT"
|
|
|
|
prebuild-sanitize:
|
|
if: ${{ needs.detect-changed-addons.outputs.changedAddons != '' && needs.detect-changed-addons.outputs.changedAddons != '[]' }}
|
|
needs: detect-changed-addons
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
outputs:
|
|
sanitizeCommitted: ${{ steps.sanitize_commit.outputs.committed }}
|
|
sanitizeCommitSha: ${{ steps.sanitize_commit.outputs.commit_long_sha }}
|
|
steps:
|
|
- uses: actions/checkout@v7.0.1
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Sanitize text files and script permissions
|
|
env:
|
|
ADDONS_JSON: ${{ needs.detect-changed-addons.outputs.changedAddons }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
UNICODE_SPACES_REGEX=$'[\u00A0\u2002\u2003\u2007\u2008\u2009\u202F\u205F\u3000\u200B]'
|
|
mapfile -t addons < <(jq -r '.[]' <<<"$ADDONS_JSON")
|
|
|
|
for addon in "${addons[@]}"; do
|
|
echo "Sanitizing ${addon}"
|
|
cd "$GITHUB_WORKSPACE/$addon"
|
|
|
|
while IFS= read -r -d '' file; do
|
|
mime_type=$(file --mime-type -b "$file")
|
|
[[ "$mime_type" != text/* ]] && continue
|
|
|
|
perl -i -CSD -pe "
|
|
s/${UNICODE_SPACES_REGEX}/ /g;
|
|
s/\r$//;
|
|
" "$file"
|
|
done < <(find . -type f -print0)
|
|
|
|
find . -type f -iname '*.sh' -exec chmod u+x {} \;
|
|
done
|
|
|
|
- name: Assert no mixed CRLF/LF remain
|
|
uses: ymwymw/check-mixed-line-endings@v2
|
|
|
|
- name: Commit sanitize changes
|
|
id: sanitize_commit
|
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
|
uses: EndBug/add-and-commit@v10
|
|
with:
|
|
commit: -u
|
|
message: "GitHub bot: sanitize (spaces + LF endings) & chmod [nobuild]"
|
|
default_author: github_actions
|
|
pull: --rebase --autostash
|
|
fetch: --tags --force
|
|
|
|
lint_config:
|
|
if: ${{ needs.detect-changed-addons.outputs.changedAddons != '' && needs.detect-changed-addons.outputs.changedAddons != '[]' }}
|
|
needs: [detect-changed-addons, prebuild-sanitize]
|
|
runs-on: ubuntu-latest
|
|
continue-on-error: true
|
|
strategy:
|
|
matrix:
|
|
addon: ${{ fromJSON(needs.detect-changed-addons.outputs.changedAddons) }}
|
|
steps:
|
|
- uses: actions/checkout@v7.0.1
|
|
- name: Run Home Assistant Add-on Lint
|
|
uses: frenck/action-addon-linter@v2
|
|
with:
|
|
path: "./${{ matrix.addon }}"
|
|
|
|
build:
|
|
if: ${{ needs.detect-changed-addons.outputs.changedAddons != '' && needs.detect-changed-addons.outputs.changedAddons != '[]' }}
|
|
needs: [detect-changed-addons, lint_config]
|
|
runs-on: ${{ matrix.runner }}
|
|
name: Build ${{ matrix.arch }} ${{ matrix.addon }} add-on
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
addon: ${{ fromJSON(needs.detect-changed-addons.outputs.changedAddons) }}
|
|
arch: [amd64, aarch64]
|
|
include:
|
|
- arch: amd64
|
|
runner: ubuntu-24.04
|
|
- arch: aarch64
|
|
runner: ubuntu-24.04-arm
|
|
steps:
|
|
- uses: actions/checkout@v7.0.1
|
|
with:
|
|
persist-credentials: false
|
|
|
|
- name: Resolve symlinks in repository copy
|
|
run: bash .github/scripts/resolve_symlinks.sh
|
|
|
|
- name: Copy templates into addon build context
|
|
env:
|
|
ADDON: ${{ matrix.addon }}
|
|
run: |
|
|
set -euo pipefail
|
|
TEMPLATES_DIR=".templates"
|
|
ADDON_DIR="./$ADDON"
|
|
|
|
# Copy all template scripts that Dockerfiles might reference
|
|
for script in ha_automodules.sh ha_autoapps.sh ha_entrypoint.sh bashio-standalone.sh ha_lsio.sh; do
|
|
if [ -f "$TEMPLATES_DIR/$script" ]; then
|
|
cp "$TEMPLATES_DIR/$script" "$ADDON_DIR/$script"
|
|
fi
|
|
done
|
|
|
|
- name: Install PyYAML
|
|
run: python3 -m pip install --disable-pip-version-check pyyaml
|
|
|
|
- name: Read add-on metadata
|
|
id: info
|
|
env:
|
|
ADDON: ${{ matrix.addon }}
|
|
ARCH: ${{ matrix.arch }}
|
|
REPOSITORY: ${{ github.repository }}
|
|
GITHUB_SHA_VALUE: ${{ github.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
python3 - <<'PY'
|
|
import json
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
addon = os.environ["ADDON"]
|
|
arch = os.environ["ARCH"]
|
|
repository = os.environ["REPOSITORY"]
|
|
github_sha = os.environ["GITHUB_SHA_VALUE"]
|
|
addon_dir = Path(addon)
|
|
output_path = Path(os.environ["GITHUB_OUTPUT"])
|
|
|
|
|
|
def load_file(path: Path):
|
|
text = path.read_text(encoding="utf-8")
|
|
if path.suffix == ".json":
|
|
return json.loads(text)
|
|
return yaml.safe_load(text) or {}
|
|
|
|
|
|
def first_existing(*names: str):
|
|
for name in names:
|
|
path = addon_dir / name
|
|
if path.exists():
|
|
return path
|
|
return None
|
|
|
|
|
|
config_path = first_existing("config.json", "config.yaml", "config.yml")
|
|
if config_path is None:
|
|
raise SystemExit(f"No config file found in {addon}")
|
|
|
|
build_path = first_existing("build.json", "build.yaml", "build.yml")
|
|
config = load_file(config_path)
|
|
build = load_file(build_path) if build_path else {}
|
|
|
|
build_from_map = build.get("build_from") or {}
|
|
arch_list = list(build_from_map.keys()) if build_from_map else list(config.get("arch") or [])
|
|
build_arch = arch in arch_list
|
|
|
|
image_raw = str(config.get("image") or "").strip()
|
|
image = image_raw.replace("{arch}", arch)
|
|
version = str(config.get("version") or "").strip()
|
|
name = str(config.get("name") or "").strip()
|
|
description = str(config.get("description") or "").strip()
|
|
url = str(config.get("url") or "").strip()
|
|
build_from = str(build_from_map.get(arch) or "").strip()
|
|
build_date = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
|
|
dockerfile = addon_dir / f"Dockerfile.{arch}"
|
|
if dockerfile.exists():
|
|
dockerfile_path = str(dockerfile)
|
|
has_dockerfile = True
|
|
else:
|
|
dockerfile = addon_dir / "Dockerfile"
|
|
dockerfile_path = str(dockerfile)
|
|
has_dockerfile = dockerfile.exists()
|
|
|
|
labels = [
|
|
f"io.hass.name={name}",
|
|
f"io.hass.description={description}",
|
|
"io.hass.type=addon",
|
|
]
|
|
if url:
|
|
labels.append(f"io.hass.url={url}")
|
|
|
|
build_args = [
|
|
f"BUILD_ARCH={arch}",
|
|
f"BUILD_VERSION={version}",
|
|
f"BUILD_DATE={build_date}",
|
|
f"BUILD_DESCRIPTION={description}",
|
|
f"BUILD_NAME={name}",
|
|
f"BUILD_REF={github_sha}",
|
|
f"BUILD_REPOSITORY={repository}",
|
|
]
|
|
if build_from:
|
|
build_args.insert(2, f"BUILD_FROM={build_from}")
|
|
|
|
|
|
def write_output(key: str, value: str):
|
|
with output_path.open("a", encoding="utf-8") as fh:
|
|
print(f"{key}<<__EOF__", file=fh)
|
|
print(value, file=fh)
|
|
print("__EOF__", file=fh)
|
|
|
|
|
|
write_output("architectures", json.dumps(arch_list))
|
|
write_output("build_arch", "true" if build_arch else "false")
|
|
write_output("has_dockerfile", "true" if has_dockerfile else "false")
|
|
write_output("dockerfile", dockerfile_path)
|
|
write_output("image", image)
|
|
write_output("version", version)
|
|
write_output("name", name)
|
|
write_output("description", description)
|
|
write_output("url", url)
|
|
write_output("build_from", build_from)
|
|
write_output("build_date", build_date)
|
|
write_output("labels", "\n".join(labels))
|
|
write_output("build_args", "\n".join(build_args))
|
|
PY
|
|
|
|
- name: Explain skipped builds
|
|
if: steps.info.outputs.build_arch != 'true' || steps.info.outputs.has_dockerfile != 'true'
|
|
run: |
|
|
if [ "${{ steps.info.outputs.has_dockerfile }}" != 'true' ]; then
|
|
echo "No Dockerfile or Dockerfile.${{ matrix.arch }} found in ${{ matrix.addon }}, skipping build."
|
|
elif [ "${{ steps.info.outputs.build_arch }}" != 'true' ]; then
|
|
echo "${{ matrix.arch }} is not a valid architecture for ${{ matrix.addon }}, skipping build."
|
|
fi
|
|
|
|
# A single failing leg here auto-reverts the whole push (revert-on-failure
|
|
# below), so a transient error inside the build - a truncated download in
|
|
# a RUN layer, a registry hiccup - silently undoes a perfectly good
|
|
# version bump. Seen on zoneminder 1.38.4 (run 31678876409): the aarch64
|
|
# leg died on "curl: (92) HTTP/2 stream 1 was not closed cleanly:
|
|
# REFUSED_STREAM", the commit was reverted, and a manual re-run of the
|
|
# identical source then went green. (A lost runner or a cancelled job is
|
|
# not covered by this - no later step gets to run at all.)
|
|
#
|
|
# So: build once tolerantly, and only let a second failure reach the
|
|
# revert. A genuinely broken add-on fails twice and is still reverted,
|
|
# one build later. Retrying is unconditional rather than gated on the
|
|
# log text looking "transient" - BuildKit reformats error strings and
|
|
# registry/runner failures spell themselves a dozen ways, so classifying
|
|
# by log text would silently stop reverting real breakage. It is also
|
|
# cheap: the add-ons that fail deterministically on every push each fail
|
|
# in 16-34 s.
|
|
- name: Build ${{ matrix.addon }} add-on
|
|
id: build
|
|
if: steps.info.outputs.build_arch == 'true' && steps.info.outputs.has_dockerfile == 'true'
|
|
continue-on-error: true
|
|
uses: home-assistant/builder/actions/build-image@2026.06.0
|
|
with:
|
|
arch: ${{ matrix.arch }}
|
|
cache-gha: "false"
|
|
cache-gha-scope: ${{ matrix.addon }}-${{ matrix.arch }}
|
|
context: ./${{ matrix.addon }}
|
|
file: ${{ steps.info.outputs.dockerfile }}
|
|
image: ${{ steps.info.outputs.image }}
|
|
image-tags: |
|
|
${{ steps.info.outputs.version }}
|
|
latest
|
|
version: ${{ steps.info.outputs.version }}
|
|
push: "true"
|
|
cosign: "false"
|
|
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
|
|
labels: ${{ steps.info.outputs.labels }}
|
|
build-args: ${{ steps.info.outputs.build_args }}
|
|
|
|
# Keep these inputs identical to the first attempt above - this step is
|
|
# that attempt, run a second time, and nothing else. There is deliberately
|
|
# no pause in between: re-running the earlier layers already spaces the
|
|
# two network windows apart, and a sleep step would only add somewhere
|
|
# else for the retry to be skipped from.
|
|
- name: Build ${{ matrix.addon }} add-on (retry after failed attempt)
|
|
if: steps.build.outcome == 'failure'
|
|
uses: home-assistant/builder/actions/build-image@2026.06.0
|
|
with:
|
|
arch: ${{ matrix.arch }}
|
|
cache-gha: "false"
|
|
cache-gha-scope: ${{ matrix.addon }}-${{ matrix.arch }}
|
|
context: ./${{ matrix.addon }}
|
|
file: ${{ steps.info.outputs.dockerfile }}
|
|
image: ${{ steps.info.outputs.image }}
|
|
image-tags: |
|
|
${{ steps.info.outputs.version }}
|
|
latest
|
|
version: ${{ steps.info.outputs.version }}
|
|
push: "true"
|
|
cosign: "false"
|
|
container-registry-password: ${{ secrets.GITHUB_TOKEN }}
|
|
labels: ${{ steps.info.outputs.labels }}
|
|
build-args: ${{ steps.info.outputs.build_args }}
|
|
|
|
make-changelog:
|
|
if: >-
|
|
${{
|
|
github.event_name == 'push' &&
|
|
github.ref == 'refs/heads/master' &&
|
|
needs.detect-changed-addons.outputs.changedAddons != '' &&
|
|
needs.detect-changed-addons.outputs.changedAddons != '[]'
|
|
}}
|
|
needs: [detect-changed-addons, build]
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- uses: actions/checkout@v7.0.1
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Update changelog for minor versions
|
|
env:
|
|
ADDONS_JSON: ${{ needs.detect-changed-addons.outputs.changedAddons }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
mapfile -t addons < <(jq -r '.[]' <<<"$ADDONS_JSON")
|
|
|
|
for addon in "${addons[@]}"; do
|
|
echo "Updating changelog for ${addon}"
|
|
cd "$GITHUB_WORKSPACE/$addon"
|
|
|
|
if [ -f config.yaml ]; then
|
|
version=$(sed -n 's/^version:[[:space:]]*//p' config.yaml | head -n 1)
|
|
elif [ -f config.yml ]; then
|
|
version=$(sed -n 's/^version:[[:space:]]*//p' config.yml | head -n 1)
|
|
elif [ -f config.json ]; then
|
|
version=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' config.json | head -n 1)
|
|
else
|
|
echo "No config file found in ${addon}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
version=${version//\"/}
|
|
version=${version//\'/}
|
|
version=$(echo "$version" | xargs)
|
|
|
|
if [[ "$version" == *test* ]]; then
|
|
continue
|
|
fi
|
|
|
|
touch CHANGELOG.md
|
|
if ! grep -q "^## ${version} (" CHANGELOG.md; then
|
|
first_line=$(sed -n '/./p' CHANGELOG.md | head -n 1 || true)
|
|
if [[ -n "$first_line" && "$first_line" != -* ]]; then
|
|
sed -i '1i\- Minor bugs fixed' CHANGELOG.md
|
|
elif [[ -z "$first_line" ]]; then
|
|
printf '%s\n' '- Minor bugs fixed' > CHANGELOG.md
|
|
fi
|
|
sed -i "1i\## ${version} ($(date '+%d-%m-%Y'))" CHANGELOG.md
|
|
fi
|
|
done
|
|
|
|
- name: Commit changelog changes
|
|
uses: EndBug/add-and-commit@v10
|
|
with:
|
|
commit: -u
|
|
message: "GitHub bot: changelog [nobuild]"
|
|
default_author: github_actions
|
|
pull: --rebase --autostash
|
|
fetch: --force
|
|
push: --force
|
|
|
|
revert-on-failure:
|
|
if: >-
|
|
${{
|
|
failure() &&
|
|
github.event_name == 'push' &&
|
|
github.ref == 'refs/heads/master' &&
|
|
github.repository_owner == 'alexbelgium'
|
|
}}
|
|
needs: [detect-changed-addons, prebuild-sanitize, lint_config, build]
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- name: Checkout repo
|
|
uses: actions/checkout@v7.0.1
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Revert commits from this failed push
|
|
env:
|
|
BEFORE: ${{ github.event.before }}
|
|
HEAD_SHA: ${{ github.sha }}
|
|
SANITIZE_COMMITTED: ${{ needs.prebuild-sanitize.outputs.sanitizeCommitted }}
|
|
SANITIZE_SHA: ${{ needs.prebuild-sanitize.outputs.sanitizeCommitSha }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
git config --global user.name "GitHub Actions"
|
|
git config --global user.email "actions@github.com"
|
|
git fetch origin master
|
|
|
|
# Revert exactly the commits THIS push introduced (before..HEAD_SHA).
|
|
# Do not diff against the live master tip: concurrent pushes (e.g. the
|
|
# updater bot committing one addon per push) can land on master while
|
|
# this job is running, and a moving HEAD would sweep their unrelated,
|
|
# successful commits into the revert too.
|
|
if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ]; then
|
|
mapfile -t commits < <(git rev-list "${BEFORE}..${HEAD_SHA}")
|
|
else
|
|
commits=("$HEAD_SHA")
|
|
fi
|
|
|
|
# The prebuild-sanitize job may have pushed its own [nobuild] commit
|
|
# on top of HEAD_SHA earlier in this same run. It's still this push's
|
|
# own fallout (not a neighboring push's), so revert it explicitly by
|
|
# SHA rather than widening the range to "whatever is on master now."
|
|
# It must be reverted first, since it sits on top of HEAD_SHA.
|
|
if [ "$SANITIZE_COMMITTED" = "true" ] && [ -n "$SANITIZE_SHA" ]; then
|
|
commits=("$SANITIZE_SHA" "${commits[@]}")
|
|
fi
|
|
|
|
if [ "${#commits[@]}" -eq 0 ]; then
|
|
echo "Nothing to revert."
|
|
exit 0
|
|
fi
|
|
|
|
git checkout -B master origin/master
|
|
for commit in "${commits[@]}"; do
|
|
git revert --no-edit "$commit"
|
|
done
|
|
|
|
# Master may have moved again since we fetched (e.g. another
|
|
# concurrent updater push), so retry the push with a rebase.
|
|
for attempt in 1 2 3 4 5; do
|
|
if git push origin HEAD:master; then
|
|
exit 0
|
|
fi
|
|
echo "Push rejected, rebasing onto latest master (attempt ${attempt})"
|
|
git fetch origin master
|
|
if ! git rebase origin/master; then
|
|
git rebase --abort
|
|
echo "Rebase hit a real conflict against latest master; aborting" \
|
|
"rather than pushing a partial/broken revert. This needs a" \
|
|
"human to look at it." >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
echo "Failed to push reverts after retries" >&2
|
|
exit 1
|