From 4a003a17ebb6ff63c66ca494d8cae0d8f865a585 Mon Sep 17 00:00:00 2001 From: Alexandre <44178713+alexbelgium@users.noreply.github.com> Date: Thu, 26 Mar 2026 08:50:22 +0100 Subject: [PATCH] Enhance GitHub Actions workflow with addons_json input Added input parameter 'addons_json' for specifying add-on directories in workflow. Enhanced conditional checks and improved error handling for processing changed add-ons. --- .github/workflows/onpush_builder.yaml | 472 ++++++++++++++++---------- 1 file changed, 286 insertions(+), 186 deletions(-) diff --git a/.github/workflows/onpush_builder.yaml b/.github/workflows/onpush_builder.yaml index 46fba6045..fb5526f4a 100644 --- a/.github/workflows/onpush_builder.yaml +++ b/.github/workflows/onpush_builder.yaml @@ -1,10 +1,15 @@ # yamllint disable rule:line-length -# inspired by Poeschl/Hassio-Addons, optimized by ChatGPT --- name: Builder on: workflow_call: + inputs: + addons_json: + description: JSON array of add-on directories to process. If empty, all add-ons are discovered. + required: false + type: string + default: "" push: branches: - master @@ -12,81 +17,102 @@ on: - "**/config.*" jobs: - # 1. Detect which add-on folders changed (by config.json|yaml|yml modification) detect-changed-addons: - if: ${{ github.repository_owner == 'alexbelgium' && !contains(github.event.head_commit.message, 'nobuild') }} + 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@v6 - - name: Find changed addon directories - id: find_addons - run: | - git fetch origin "${{ github.event.before }}" || true - changed_config_files=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" | grep -E '^[^/]+/config\.(json|ya?ml)$' || true) - echo "Changed config files:" - echo "$changed_config_files" - changed_addons=$(printf '%s' "$changed_config_files" | awk -F/ '{print $1}' | sort -u | jq -R -s -c 'split("\n") | map(select(length > 0))') - echo "Changed addons: $changed_addons" - echo "changed_addons=$changed_addons" >> "$GITHUB_OUTPUT" + with: + fetch-depth: 0 + + - name: Find add-on directories to process + id: find_addons + env: + INPUT_ADDONS_JSON: ${{ inputs.addons_json }} + run: | + set -euo pipefail + + if [ -n "${INPUT_ADDONS_JSON:-}" ]; then + if ! jq -e 'type == "array" and all(.[]; type == "string")' <<<"$INPUT_ADDONS_JSON" >/dev/null; then + echo "workflow_call input addons_json must be a JSON array of strings" >&2 + exit 1 + fi + changed_addons=$(jq -c 'map(select(length > 0)) | unique' <<<"$INPUT_ADDONS_JSON") + elif [ "${{ 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" - # 2. Pre-build sanitize: normalize spaces, fix script permissions, single commit per add-on prebuild-sanitize: if: ${{ needs.detect-changed-addons.outputs.changedAddons != '' && needs.detect-changed-addons.outputs.changedAddons != '[]' }} needs: detect-changed-addons runs-on: ubuntu-latest - strategy: - matrix: - addon: ${{ fromJSON(needs.detect-changed-addons.outputs.changedAddons) }} + permissions: + contents: write steps: - uses: actions/checkout@v6 - # ──────────────────────────────────────────────────────────────── - # 1. Replace non-printable Unicode spaces ␣ and - # convert Windows line endings (CRLF) → Unix (LF) - # ──────────────────────────────────────────────────────────────── - - name: Sanitize text files (Unicode spaces + CRLF → LF) + with: + fetch-depth: 0 + + - name: Sanitize text files and script permissions + env: + ADDONS_JSON: ${{ needs.detect-changed-addons.outputs.changedAddons }} run: | - cd "${{ matrix.addon }}" + set -euo pipefail + UNICODE_SPACES_REGEX=$'[\u00A0\u2002\u2003\u2007\u2008\u2009\u202F\u205F\u3000\u200B]' + mapfile -t addons < <(jq -r '.[]' <<<"$ADDONS_JSON") - find . -type f | while read -r file; do - MIME_TYPE=$(file --mime-type -b "$file") - [[ "$MIME_TYPE" != text/* ]] && continue + for addon in "${addons[@]}"; do + echo "Sanitizing ${addon}" + cd "$GITHUB_WORKSPACE/$addon" - echo "Sanitizing $file" - # Edit in place, preserving file permissions and metadata - perl -i -CSD -pe " - s/${UNICODE_SPACES_REGEX}/ /g; # space normalization - s/\r$//; # CRLF → LF - " "$file" + 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 - # ──────────────────────────────────────────────────────────────── - # 2. Ensure every *.sh script is executable - # ──────────────────────────────────────────────────────────────── - - name: Make all .sh scripts executable - run: | - cd "${{ matrix.addon }}" - find . -type f -iname "*.sh" -exec chmod u+x {} \; - # ──────────────────────────────────────────────────────────────── - # 3. Verify nothing with mixed line endings slipped through - # ──────────────────────────────────────────────────────────────── + - name: Assert no mixed CRLF/LF remain uses: ymwymw/check-mixed-line-endings@v2 - # ──────────────────────────────────────────────────────────────── - # 4. Commit any changes we made - # ──────────────────────────────────────────────────────────────── - - name: Commit if needed + + - name: Commit sanitize changes + 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" + message: "GitHub bot: sanitize (spaces + LF endings) & chmod [nobuild]" default_author: github_actions pull: --rebase --autostash fetch: --tags --force - # 3. Lint add-on configs lint_config: if: ${{ needs.detect-changed-addons.outputs.changedAddons != '' && needs.detect-changed-addons.outputs.changedAddons != '[]' }} needs: [detect-changed-addons, prebuild-sanitize] @@ -102,14 +128,10 @@ jobs: with: path: "./${{ matrix.addon }}" - # 4. Build images for changed addons/arches - # Uses composable actions from home-assistant/builder (2026.03.2+) - # which replaced the deprecated home-assistant/builder action. build: if: ${{ needs.detect-changed-addons.outputs.changedAddons != '' && needs.detect-changed-addons.outputs.changedAddons != '[]' }} needs: [detect-changed-addons, lint_config] runs-on: ${{ matrix.runner }} - environment: CR_PAT name: Build ${{ matrix.arch }} ${{ matrix.addon }} add-on permissions: contents: read @@ -118,7 +140,7 @@ jobs: fail-fast: false matrix: addon: ${{ fromJSON(needs.detect-changed-addons.outputs.changedAddons) }} - arch: ["aarch64", "amd64"] + arch: [amd64, aarch64] include: - arch: amd64 runner: ubuntu-24.04 @@ -128,14 +150,18 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - name: Resolve Symlinks (in repo) + + - name: Resolve symlinks in repository copy run: | + set -euo pipefail + find . -type l | while read -r link; do - target="$(readlink -f "$link")" + target=$(readlink -f "$link" || true) if [ -z "$target" ]; then echo "Skipping broken symlink: $link" continue fi + rm "$link" if [ -d "$target" ]; then mkdir -p "$link" @@ -144,105 +170,137 @@ jobs: cp "$target" "$link" fi done - - name: Get addon info + + - 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 }} run: | - cd "./$ADDON" + set -euo pipefail - # --- Read config file (json/yaml/yml) --- - version="" image="" name="" description="" arch_list="[]" - for ext in json yaml yml; do - if [ -f "config.$ext" ]; then - if [ "$ext" = "json" ]; then - version=$(jq -r '.version // ""' "config.$ext") - image=$(jq -r '.image // ""' "config.$ext") - name=$(jq -r '.name // ""' "config.$ext") - description=$(jq -r '.description // ""' "config.$ext") - arch_list=$(jq -c '.arch // []' "config.$ext") - else - version=$(grep -m1 '^version:' "config.$ext" | sed 's/^version:[[:space:]]*//' | tr -d "\"'") - image=$(grep -m1 '^image:' "config.$ext" | sed 's/^image:[[:space:]]*//' | tr -d "\"'") - name=$(grep -m1 '^name:' "config.$ext" | sed 's/^name:[[:space:]]*//' | tr -d "\"'") - description=$(grep -m1 '^description:' "config.$ext" | sed 's/^description:[[:space:]]*//' | tr -d "\"'") - arch_list=$(awk ' - /^arch:/ { inblock=1; next } - /^[^[:space:]]/ { if (inblock) exit } - inblock && /^[[:space:]]/ { print } - ' "config.$ext" | sed 's/^ *- *//' | tr -d "\"'" | jq -R -s -c 'split("\n") | map(select(length > 0))') - fi - break - fi - done + 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"] + 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_name = dockerfile.name + has_dockerfile = True + else: + dockerfile = addon_dir / "Dockerfile" + dockerfile_name = dockerfile.name + 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_DATE={build_date}", + f"BUILD_DESCRIPTION={description}", + f"BUILD_NAME={name}", + f"BUILD_REF={os.environ.get('GITHUB_SHA', '')}", + f"BUILD_REPOSITORY={repository}", + ] + if build_from: + build_args.insert(0, 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_name) + 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 - # --- Read build config for build_from (json/yaml/yml) --- - build_from="" build_archs="" - for ext in json yaml yml; do - if [ -f "build.$ext" ]; then - if [ "$ext" = "json" ]; then - build_from=$(jq -r ".build_from.\"$ARCH\" // \"\"" "build.$ext") - build_archs=$(jq -c '.build_from | keys' "build.$ext") - else - build_block=$(awk ' - /^build_from:/ { inblock=1; next } - /^[^[:space:]]/ { if (inblock) exit } - inblock && /^[[:space:]]/ { print } - ' "build.$ext") - build_from=$(printf '%s\n' "$build_block" | grep -E "^[[:space:]]*${ARCH}:" | sed "s/^[[:space:]]*${ARCH}:[[:space:]]*//" | tr -d "\"'") - build_archs=$(printf '%s\n' "$build_block" | sed 's/^[[:space:]]*//; s/:.*//' | jq -R -s -c 'split("\n") | map(select(length > 0))') - fi - break - fi - done - - # Use build config architectures if available (overrides config arch list) - if [ -n "$build_archs" ] && [ "$build_archs" != "[]" ]; then - arch_list="$build_archs" + - 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 - # Resolve {arch} placeholder in image name - resolved_image="${image//\{arch\}/$ARCH}" - - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "image=$resolved_image" >> "$GITHUB_OUTPUT" - echo "name=$name" >> "$GITHUB_OUTPUT" - echo "description=$description" >> "$GITHUB_OUTPUT" - echo "architectures=$arch_list" >> "$GITHUB_OUTPUT" - echo "build_from=$build_from" >> "$GITHUB_OUTPUT" - echo "build_date=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT" - - echo "Addon: $ADDON, Arch: $ARCH" - echo " Version: $version" - echo " Image: $resolved_image" - echo " Build from: $build_from" - echo " Architectures: $arch_list" - - name: Check if Dockerfile exists - id: dockerfile_check - run: | - if [ -f "./${{ matrix.addon }}/Dockerfile" ]; then - echo "has_dockerfile=true" >> "$GITHUB_OUTPUT" - else - echo "No Dockerfile found in ${{ matrix.addon }}, skipping build." - echo "has_dockerfile=false" >> "$GITHUB_OUTPUT" - fi - - name: Check if add-on should be built for arch - id: check - run: | - if [[ "${{ steps.info.outputs.architectures }}" =~ ${{ matrix.arch }} ]]; then - echo "build_arch=true" >> "$GITHUB_OUTPUT"; - else - echo "${{ matrix.arch }} is not a valid arch for ${{ matrix.addon }}, skipping build"; - echo "build_arch=false" >> "$GITHUB_OUTPUT"; - fi - name: Build ${{ matrix.addon }} add-on - if: steps.check.outputs.build_arch == 'true' && steps.dockerfile_check.outputs.has_dockerfile == 'true' + if: steps.info.outputs.build_arch == 'true' && steps.info.outputs.has_dockerfile == 'true' uses: home-assistant/builder/actions/build-image@2026.03.2 with: arch: ${{ matrix.arch }} - cache-gha-scope: ${{ matrix.addon }} - context: "./${{ matrix.addon }}" + 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 }} @@ -251,79 +309,121 @@ jobs: push: "true" cosign: "false" container-registry-password: ${{ secrets.GITHUB_TOKEN }} - build-args: | - BUILD_FROM=${{ steps.info.outputs.build_from }} - BUILD_DATE=${{ steps.info.outputs.build_date }} - BUILD_DESCRIPTION=${{ steps.info.outputs.description }} - BUILD_NAME=${{ steps.info.outputs.name }} - BUILD_REF=${{ github.sha }} - BUILD_REPOSITORY=${{ github.repository }} + labels: ${{ steps.info.outputs.labels }} + build-args: ${{ steps.info.outputs.build_args }} - # 5. Update changelog if needed (for each changed add-on) make-changelog: - if: ${{ needs.detect-changed-addons.outputs.changedAddons != '' && needs.detect-changed-addons.outputs.changedAddons != '[]' }} + 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 - strategy: - matrix: - addon: ${{ fromJSON(needs.detect-changed-addons.outputs.changedAddons) }} + permissions: + contents: write steps: - uses: actions/checkout@v6 with: - fetch-depth: 1 + fetch-depth: 0 + - name: Update changelog for minor versions + env: + ADDONS_JSON: ${{ needs.detect-changed-addons.outputs.changedAddons }} run: | - echo "Starting" - git pull || true - cd "${{ matrix.addon }}" - if [ -f config.yaml ]; then - version="$(sed -e '/version/!d' -e 's/.*version: //' config.yaml)" - elif [ -f config.json ]; then - version="$(sed -e '/version/!d' -e 's/.*[^"]*"\([^"]*\)"/\1/' config.json)" - version="${version//,}" - else - exit 1 - fi - version="${version//\"/}" - version="${version//\'/}" - version="$(echo "$version" | xargs)" - if [[ "$version" == *"test"* ]]; then exit 0; fi - touch CHANGELOG.md - if ! grep -q "$version" CHANGELOG.md; then - first_line="$(sed -n '/./p' CHANGELOG.md | head -n 1)" - if [[ "$first_line" != "-"* ]]; then - sed -i "1i\- Minor bugs fixed" CHANGELOG.md + 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 - sed -i "1i\## $version ($(date '+%d-%m-%Y'))" CHANGELOG.md - fi - - name: Commit if needed + + 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" + message: "GitHub bot: changelog [nobuild]" default_author: github_actions pull: --rebase --autostash fetch: --force push: --force - # 6. Revert if workflow fails revert-on-failure: - if: 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@v6 with: fetch-depth: 0 - - - name: Revert the triggering commit + + - name: Revert commits from this failed push run: | + set -euo pipefail + git config --global user.name "GitHub Actions" git config --global user.email "actions@github.com" git fetch origin git checkout master - git pull origin master - git revert --no-commit ${{ github.sha }} - git commit -m "Revert '${{ github.event.head_commit.message }}' [nobuild]" - git push origin master + git pull --ff-only origin master + + before="${{ github.event.before }}" + if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then + mapfile -t commits < <(git rev-list "${before}..HEAD") + else + commits=("${{ github.sha }}") + fi + + if [ "${#commits[@]}" -eq 0 ]; then + echo "Nothing to revert." + exit 0 + fi + + for commit in "${commits[@]}"; do + git revert --no-edit "$commit" + done + + git push origin HEAD:master