mirror of
https://github.com/alexbelgium/hassio-addons.git
synced 2026-09-06 01:43:32 +02:00
Every push to master has failed to build since 2026-08-19 05:15. The
prebuild-sanitize job dies before running a single step:
EndBug/add-and-commit/v11/action.yml (Line: 25, Col: 18):
Unrecognized named-value: 'github'. Located at position 1 within
expression: github.workspace
Failed to load EndBug/add-and-commit/v11/action.yml
Upstream's v11.1.0, published 2026-08-18 22:44 UTC, put a literal
"${{ github.workspace }}" inside the description of the `cwd` input. Action
metadata descriptions are still parsed as expressions and the `github` context
does not exist there, so the action no longer loads at all. The floating v11
tag was moved to it, which is why nothing changed in this repo and every
workflow using the action broke at once - the builder, the README and stats
refreshers, the CRLF sweep, the image compressor and the issue labeller.
v11.0.0 does not contain that line and loads normally, so pinning to it keeps
the version Dependabot moved us to in #2985 while stepping off the tag. It also
took out an unrelated add-on fix: the builder's revert-on-failure job reverted
the seerr merge (#2993) as collateral, and that is being reapplied separately.
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@v11.0.0
|
|
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@v11.0.0
|
|
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
|