Files
hassio-addons/.github/workflows/onpush_builder.yaml
Alexandre bfe91cbeac refactor(webtop,webtop_kde,claude_desktop): share the Selkies startup scripts (#2920)
* refactor(webtop,webtop_kde,claude_desktop): share the Selkies startup scripts

All three add-ons are built on the LinuxServer Selkies base image and had
independently drifted copies of the same startup scripts. claude_desktop's
copies carry a set of fixes the two webtops never received, so make
claude_desktop the single source and symlink the shared scripts from
webtop_kde/rootfs (which webtop/rootfs already symlinks in full).

Shared by symlink: 20-folders.sh, 21-gpu_permissions.sh, 80-configuration.sh,
90-ingress.sh and the six etc/nginx/includes files.

Kept add-on specific: everything Claude-only stays in claude_desktop
(81/82/83/84 tool installs, 85-openbox_autostart.sh, defaults/, usr/local/bin,
svc-headroom), and everything webtop-only stays in webtop_kde (90-ssl.sh,
helpers/microsoft-edge-stable, and the new 81-microsoft_edge.sh).

To make the shared scripts add-on agnostic:
- 20-folders.sh derives its default data location from the home directory the
  Dockerfile baked into the abc user instead of hardcoding /data/data. That
  yields /data/data on claude_desktop and /config/data_kde on both webtops,
  matching each add-on's previous behaviour exactly.
- The permission_mode: bypass root guard is skipped on add-ons that do not
  declare that option.
- 80-configuration.sh falls back to pip when the image does not ship uv.
- The Microsoft Edge install moves out of 80-configuration.sh into a
  webtop-only 81-microsoft_edge.sh, which also absorbs the ownership fixup
  that used to run in 20-folders.sh before Edge was installed and so never
  matched anything.

CI: the builder's symlink-resolution step made a single pass over a
pre-computed file list, so resolving webtop/rootfs (a directory symlink)
could copy the symlinks inside it verbatim, leaving links that escape the
webtop build context. Verified on this tree: the old loop leaves 10 dangling
symlinks under webtop/. Extract it to .github/scripts/resolve_symlinks.sh,
repeat until a pass finds nothing, and run it in the PR check too, which had
no resolution step at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: guard two startup aborts found in review

Both are crash paths in code added by this PR, not hardening:

- 20-folders.sh: getent exits 2 when the user does not exist, and under
  bashio's `set -o pipefail` plus the script's `set -e` that aborts at the
  assignment, so the "could not read abc's home" fallback below it was
  unreachable. Same trap already documented in 21-gpu_permissions.sh.
  Verified: without the guard the shell exits 2; with it the fallback runs.

- 81-microsoft_edge.sh: the ownership fixup lost the `-f` guard the original
  had in 20-folders.sh. Without nullglob an unmatched /usr/bin/microsoft-edge*
  reaches chown as a literal and `set -e` kills container startup. Now a
  nullglob array with a warning when empty.

Also make resolve_symlinks.sh fail on a broken symlink instead of deleting it.
Dropping it silently yields an image that builds clean and misbehaves at
runtime; a red build is easier to diagnose. Verified both paths: the repo as-is
resolves to 0 symlinks and exit 0, and an injected broken link exits 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address review comments on data-location default and Edge downloads

Two findings from CodeRabbit, both correctness rather than hardening:

- 20-folders.sh rewrites abc's home in /etc/passwd further down, so re-reading
  it on the next boot returned the *previously selected* location as the image
  default. On a restart that reuses the container's writable layer, clearing
  data_location would strand the user on their old custom path instead of
  restoring the built-in one. Cache the value in /etc/.addon_image_home, which
  shares the writable layer's lifetime with the edit it compensates for: a
  rebuilt or recreated container starts from a pristine /etc/passwd and
  regenerates it. Simulated all three cases (first boot, reused container with
  a rewritten passwd, recreated container) under `set -e` + `set -o pipefail`.

- 81-microsoft_edge.sh: both curl calls were unbounded, so a stalled
  packages.microsoft.com would hang cont-init.d and with it the whole add-on.
  Add --fail/--connect-timeout/--max-time and skip the install with a logged
  error when version discovery or the download fails. The desktop is useful
  without Edge; an add-on wedged before Selkies starts is not.

Not addressed, deliberately: escaping $LOCATION/$DEFAULT_LOCATION for sed, and
validating symlink targets in resolve_symlinks.sh. Both are hardening against
inputs that are not reachable in normal use, both predate this PR, and the
maintainer has asked to prioritise usability over that class of change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 14:23:52 +02:00

458 lines
17 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
- name: Build ${{ matrix.addon }} add-on
if: steps.info.outputs.build_arch == 'true' && steps.info.outputs.has_dockerfile == '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 }}
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