Refactor GitHub Actions workflow for clarity and updates

Refactor GitHub Actions workflow for improved readability and functionality. Update paths, permissions, and steps for detecting changes and sanitizing files.
This commit is contained in:
Alexandre
2025-11-26 06:54:36 +01:00
committed by GitHub
parent 564384f6d7
commit 39af3e9453

View File

@@ -1,179 +1,280 @@
# yamllint disable rule:line-length
# inspired by Poeschl/Hassio-Addons, optimized by ChatGPT
---
name: Builder
on:
workflow_call:
push:
branches:
- master
branches: [master]
paths:
- "**/config.*"
permissions:
contents: write
packages: write
concurrency:
group: builder-${{ github.ref }}
cancel-in-progress: true
env:
BUILD_ARGS: ""
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') }}
runs-on: ubuntu-latest
outputs:
changedAddons: ${{ steps.find_addons.outputs.changed_addons }}
changedAddons: ${{ steps.find.outputs.changed_addons }}
hasChanges: ${{ steps.find.outputs.has_changes }}
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=$(echo "$changed_config_files" | awk -F/ '{print $1}' | sort -u | jq -R -s -c 'split("\n")[:-1]')
echo "Changed addons: $changed_addons"
echo "changed_addons=$changed_addons" >> "$GITHUB_OUTPUT"
uses: actions/checkout@v6 [oai_citation:0‡GitHub](https://github.com/actions/checkout?utm_source=chatgpt.com)
with:
fetch-depth: 0
- name: Find changed add-on directories (config.json|yaml|yml)
id: find
shell: bash
run: |
set -euo pipefail
BASE_SHA="${{ github.event.before }}"
if [[ -z "${BASE_SHA}" || "${BASE_SHA}" == "0000000000000000000000000000000000000000" ]]; then
# workflow_call or unusual event: fall back to previous commit
BASE_SHA="$(git rev-parse HEAD~1)"
fi
mapfile -t files < <(git diff --name-only "${BASE_SHA}" "${{ github.sha }}" \
| grep -E '^[^/]+/config\.(json|ya?ml)$' || true)
echo "Changed config files:"
printf '%s\n' "${files[@]:-}"
if (( ${#files[@]} == 0 )); then
echo 'changed_addons=[]' >> "$GITHUB_OUTPUT"
echo 'has_changes=false' >> "$GITHUB_OUTPUT"
exit 0
fi
changed_addons="$(printf '%s\n' "${files[@]}" | awk -F/ '{print $1}' | sort -u | jq -R -s -c 'split("\n")[:-1]')"
echo "Changed addons: ${changed_addons}"
echo "changed_addons=${changed_addons}" >> "$GITHUB_OUTPUT"
echo "has_changes=true" >> "$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 != '[]' }}
if: ${{ needs.detect-changed-addons.outputs.hasChanges == 'true' }}
needs: detect-changed-addons
runs-on: ubuntu-latest
strategy:
matrix:
addon: ${{ fromJSON(needs.detect-changed-addons.outputs.changedAddons) }}
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)
run: |
cd "${{ matrix.addon }}"
UNICODE_SPACES_REGEX=$'[\u00A0\u2002\u2003\u2007\u2008\u2009\u202F\u205F\u3000\u200B]'
find . -type f | while read -r file; do
MIME_TYPE=$(file --mime-type -b "$file")
[[ "$MIME_TYPE" != text/* ]] && continue
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"
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
uses: EndBug/add-and-commit@v9
- uses: actions/checkout@v6 [oai_citation:1‡GitHub](https://github.com/actions/checkout?utm_source=chatgpt.com)
with:
commit: -u
message: "GitHub bot: sanitize (spaces + LF endings) & chmod"
default_author: github_actions
pull: --rebase --autostash
fetch: --tags --force
fetch-depth: 0
- name: Sanitize tracked text files (Unicode spaces + CRLF→LF) without changing modes
shell: bash
run: |
set -euo pipefail
python3 - <<'PY'
import json, os, pathlib, re, stat, tempfile
changed_addons = json.loads(os.environ["CHANGED_ADDONS"])
repo = pathlib.Path(".").resolve()
# Unicode "space-like" chars you were targeting
space_re = re.compile(r"[\u00A0\u2002\u2003\u2007\u2008\u2009\u202F\u205F\u3000\u200B]")
def is_probably_text(data: bytes) -> bool:
# cheap binary check: NUL byte
return b"\0" not in data
def rewrite_preserving_metadata(path: pathlib.Path, new_bytes: bytes) -> None:
st = path.stat()
tmp = path.with_name(path.name + ".tmp_sanitize")
tmp.write_bytes(new_bytes)
os.chmod(tmp, stat.S_IMODE(st.st_mode))
os.utime(tmp, (st.st_atime, st.st_mtime))
os.replace(tmp, path)
for addon in changed_addons:
addon_path = repo / addon
if not addon_path.exists():
continue
# Only tracked files inside the addon
import subprocess
res = subprocess.run(
["git", "ls-files", "-z", "--", str(addon_path)],
check=True,
stdout=subprocess.PIPE,
)
files = [pathlib.Path(p.decode("utf-8")) for p in res.stdout.split(b"\0") if p]
for f in files:
try:
data = f.read_bytes()
except OSError:
continue
if not is_probably_text(data):
continue
# Decode "best effort" but keep bytes stable via surrogateescape
try:
text = data.decode("utf-8", errors="surrogateescape")
except Exception:
continue
new_text = space_re.sub(" ", text)
# Normalize CRLF and stray CR to LF
new_text = new_text.replace("\r\n", "\n").replace("\r", "\n")
if new_text != text:
rewrite_preserving_metadata(f, new_text.encode("utf-8", errors="surrogateescape"))
PY
env:
CHANGED_ADDONS: ${{ needs.detect-changed-addons.outputs.changedAddons }}
- name: Add exec bit only where appropriate (never remove exec)
shell: bash
run: |
set -euo pipefail
# Add +x only when:
# - file already executable (leave it alone), OR
# - it has a shebang, OR
# - it's a known s6/openrc-style run script location
python3 - <<'PY'
import json, os, pathlib, stat
changed_addons = json.loads(os.environ["CHANGED_ADDONS"])
repo = pathlib.Path(".").resolve()
def should_be_executable(p: pathlib.Path) -> bool:
rel = str(p.as_posix())
if rel.endswith(".sh"):
# .sh often sourced, so require shebang to enforce executability
pass
# common executable script locations in HA add-ons
if "/etc/cont-init.d/" in rel or "/etc/cont-finish.d/" in rel or "/etc/services.d/" in rel:
return True
if "/etc/s6-overlay/s6-rc.d/" in rel and rel.endswith("/run"):
return True
return False
def has_shebang(p: pathlib.Path) -> bool:
try:
with p.open("rb") as f:
return f.read(2) == b"#!"
except OSError:
return False
for addon in changed_addons:
addon_path = repo / addon
if not addon_path.exists():
continue
for p in addon_path.rglob("*"):
if not p.is_file():
continue
st = p.stat()
mode = stat.S_IMODE(st.st_mode)
is_exec = bool(mode & stat.S_IXUSR)
if is_exec:
continue # do not touch existing executable files
if has_shebang(p) or should_be_executable(p):
os.chmod(p, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
PY
env:
CHANGED_ADDONS: ${{ needs.detect-changed-addons.outputs.changedAddons }}
- name: Commit sanitize if needed
shell: bash
run: |
set -euo pipefail
if git diff --quiet; then
echo "No sanitize changes."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "GitHub bot: sanitize (spaces/LF/exec) [nobuild]"
git pull --rebase --autostash
git push
# 3. Lint add-on configs
lint_config:
if: ${{ needs.detect-changed-addons.outputs.changedAddons != '' && needs.detect-changed-addons.outputs.changedAddons != '[]' }}
if: ${{ needs.detect-changed-addons.outputs.hasChanges == 'true' }}
needs: [detect-changed-addons, prebuild-sanitize]
runs-on: ubuntu-latest
continue-on-error: true
strategy:
fail-fast: false
matrix:
addon: ${{ fromJSON(needs.detect-changed-addons.outputs.changedAddons) }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v6 [oai_citation:2‡GitHub](https://github.com/actions/checkout?utm_source=chatgpt.com)
- name: Run Home Assistant Add-on Lint
uses: frenck/action-addon-linter@v2
uses: frenck/action-addon-linter@v2 [oai_citation:3‡GitHub](https://github.com/frenck/action-addon-linter)
with:
path: "./${{ matrix.addon }}"
# 4. Build images for changed addons/arches
build:
if: ${{ needs.detect-changed-addons.outputs.changedAddons != '' && needs.detect-changed-addons.outputs.changedAddons != '[]' }}
if: ${{ needs.detect-changed-addons.outputs.hasChanges == 'true' }}
needs: [detect-changed-addons, lint_config]
runs-on: ubuntu-latest
environment: CR_PAT
name: Build ${{ matrix.arch }} ${{ matrix.addon }} add-on
strategy:
fail-fast: false
matrix:
addon: ${{ fromJSON(needs.detect-changed-addons.outputs.changedAddons) }}
arch: ["aarch64", "amd64", "armv7"]
steps:
- uses: actions/checkout@v6
- name: Resolve Symlinks (in repo)
run: |
find . -type l | while read -r link; do
target="$(readlink -f "$link")"
if [ -z "$target" ]; then
echo "Skipping broken symlink: $link"
continue
fi
rm "$link"
if [ -d "$target" ]; then
mkdir -p "$link"
cp -a "$target/." "$link/"
else
cp "$target" "$link"
fi
done
- uses: actions/checkout@v6 [oai_citation:4‡GitHub](https://github.com/actions/checkout?utm_source=chatgpt.com)
- name: Get information
id: info
uses: home-assistant/actions/helpers/info@master
with:
path: "./${{ matrix.addon }}"
- name: Check if Dockerfile exists
id: dockerfile_check
- name: Skip if no Dockerfile
id: dockerfile
shell: bash
run: |
if [ -f "./${{ matrix.addon }}/Dockerfile" ]; then
echo "has_dockerfile=true" >> "$GITHUB_OUTPUT"
if [[ -f "./${{ matrix.addon }}/Dockerfile" ]]; then
echo "has=true" >> "$GITHUB_OUTPUT"
else
echo "No Dockerfile found in ${{ matrix.addon }}, skipping build."
echo "has_dockerfile=false" >> "$GITHUB_OUTPUT"
echo "has=false" >> "$GITHUB_OUTPUT"
fi
- name: Check if add-on should be built for arch
- name: Check if add-on supports arch
id: check
env:
HEAD: "${{ github.head_ref }}"
shell: bash
run: |
if [[ "${{ steps.info.outputs.architectures }}" =~ ${{ matrix.arch }} ]]; then
echo "build_arch=true" >> "$GITHUB_OUTPUT";
echo "image=$(echo "${{ steps.info.outputs.image }}" | cut -d'/' -f3)" >> "$GITHUB_OUTPUT";
if [[ -z "$HEAD" ]] && [[ "${{ github.event_name }}" == "push" ]]; then
echo "BUILD_ARGS=" >> "$GITHUB_ENV";
fi
echo "build_arch=true" >> "$GITHUB_OUTPUT"
echo "image=$(echo "${{ steps.info.outputs.image }}" | cut -d'/' -f3)" >> "$GITHUB_OUTPUT"
else
echo "${{ matrix.arch }} is not a valid arch for ${{ matrix.addon }}, skipping build";
echo "build_arch=false" >> "$GITHUB_OUTPUT";
echo "build_arch=false" >> "$GITHUB_OUTPUT"
fi
- name: Login to GitHub Container Registry
if: env.BUILD_ARGS != '--test'
if: ${{ env.BUILD_ARGS != '--test' }}
uses: docker/login-action@v3.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build ${{ matrix.addon }} add-on
id: builderstep
if: steps.check.outputs.build_arch == 'true' && steps.dockerfile_check.outputs.has_dockerfile == 'true'
uses: home-assistant/builder@2025.09.0
if: ${{ steps.check.outputs.build_arch == 'true' && steps.dockerfile.outputs.has == 'true' }}
uses: home-assistant/builder@2025.09.0 [oai_citation:5‡GitHub](https://github.com/home-assistant/builder/releases?utm_source=chatgpt.com)
env:
CAS_API_KEY: ${{ secrets.CAS_API_KEY }}
with:
@@ -185,71 +286,59 @@ jobs:
--docker-hub "ghcr.io/${{ github.repository_owner }}" \
--addon
# 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: ${{ needs.detect-changed-addons.outputs.hasChanges == 'true' }}
needs: [detect-changed-addons, build]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
addon: ${{ fromJSON(needs.detect-changed-addons.outputs.changedAddons) }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Update changelog for minor versions
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
fi
sed -i "1i\## $version ($(date '+%d-%m-%Y'))" CHANGELOG.md
fi
- name: Commit if needed
uses: EndBug/add-and-commit@v9
with:
commit: -u
message: "GitHub bot: changelog"
default_author: github_actions
pull: --rebase --autostash
fetch: --force
push: --force
# 6. Revert if workflow fails
revert-on-failure:
if: failure()
needs: [detect-changed-addons, prebuild-sanitize, lint_config, build]
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v6
- uses: actions/checkout@v6 [oai_citation:6‡GitHub](https://github.com/actions/checkout?utm_source=chatgpt.com)
with:
fetch-depth: 0
- name: Revert the triggering commit
- name: Update changelog for minor versions
shell: bash
run: |
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
set -euo pipefail
cd "${{ matrix.addon }}"
if [[ -f config.yaml ]]; then
version="$(yq -r '.version // ""' config.yaml 2>/dev/null || true)"
elif [[ -f config.json ]]; then
version="$(jq -r '.version // ""' config.json 2>/dev/null || true)"
else
exit 0
fi
version="$(echo "$version" | xargs)"
[[ -z "$version" ]] && exit 0
[[ "$version" == *"test"* ]] && exit 0
touch CHANGELOG.md
if ! grep -qF "## $version " CHANGELOG.md; then
first_line="$(sed -n '/./p' CHANGELOG.md | head -n 1 || true)"
if [[ "$first_line" != "-"* ]]; then
sed -i "1i\\- Minor bugs fixed" CHANGELOG.md
fi
sed -i "1i\\## $version ($(date '+%d-%m-%Y'))" CHANGELOG.md
fi
- name: Commit changelog if needed
shell: bash
run: |
set -euo pipefail
if git diff --quiet; then
echo "No changelog changes."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "GitHub bot: changelog [nobuild]"
git pull --rebase --autostash
git push