mirror of
https://github.com/alexbelgium/hassio-addons.git
synced 2026-09-17 23:29:09 +02:00
Supervisor only offers a rebuild when `version` changes, so an add-on fix merged without one reaches nobody: the image stays put, the issue looks closed. Nothing in CI checked this. Not hypothetical — PR #2972 changed birdnet-pi's 99-run.sh with no bump and sat green. Worse, it also hid the rest of CI. check-addon-changes derives `changedAddons` from `^<addon>/config\.(json|ya?ml)$` alone, and the CHANGELOG gate, add-on linter and Docker build are all matrixed over it — so a PR that edits only an add-on's scripts produces an empty set and every one of them *skips*. #2972 was never linted or built, and birdnet-pi-zach turned out to have been failing to build for five weeks behind that same gap. This check therefore does NOT reuse `changedAddons`: gating on it would make the check a no-op in exactly the case it exists for. It runs its own scan and changes nothing about which add-ons get linted or built — widening that detection is a separate change with real build-time cost. Significance is an allowlist of ignorable paths anchored at the add-on root (CHANGELOG, top-level *.md, icon/logo/stats png, images/, updater.json) with everything else counting, so a new kind of file defaults to needing a bump. Note addon/images/ is artwork but addon/rootfs/**/images/ ships, hence the anchoring. Reads the manifest at HEAD by its own resolved filename, so renaming config.yaml -> config.yml mid-PR cannot slip through; parses config.json with jq so a minified file is read correctly; anchors the YAML `version:` match at column 0 so an indented key in a nested mapping is not mistaken for the manifest's; strips a trailing YAML comment so `version: "1.2.3" # note` is not read as a change. An unreadable version is an error, not a warning — the check refuses to pass where it could not be performed. The bypass label is read live inside the job rather than in its `if:`, because the workflow deliberately does not listen for `labeled`: adding that activity type would re-run the ~3 h add-on builds on every label change. Labelling the PR and re-running this one job is the intended sequence. Verified across 22 cases against real history and synthetic diffs: fails #2972 as originally opened and passes it after the bump, passes #2973/#2974 and the current #2972; stays quiet on CHANGELOG-, stats.png-, updater.json-, README-, .templates- and addon/images-only diffs; and fails on a changed Dockerfile/rootfs, a config.yaml option change, rootfs/**/images, a comment-only version edit, an indented nested version key, a mid-PR manifest rename, a config.json add-on, and a missing version key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
15 KiB
YAML
350 lines
15 KiB
YAML
# yamllint disable rule:line-length
|
||
# shellcheck disable=SC2043
|
||
---
|
||
name: PR Check Build
|
||
|
||
on:
|
||
pull_request:
|
||
branches:
|
||
- master
|
||
|
||
jobs:
|
||
# 1. Detect which add-on folders changed (by config.json|yaml|yml modification)
|
||
check-addon-changes:
|
||
if: ${{ github.repository_owner == 'alexbelgium' && !contains(github.event.head_commit.message, 'nobuild') }}
|
||
runs-on: ubuntu-latest
|
||
outputs:
|
||
changedAddons: ${{ steps.find_addons.outputs.changed_addons }}
|
||
changedChangelogFiles: ${{ steps.changed-files.outputs.changelogs_files }}
|
||
steps:
|
||
- name: Checkout repo
|
||
uses: actions/checkout@v7.0.1
|
||
with:
|
||
# Need the merge commit's parents resolvable (HEAD^1 below): a depth-1 shallow
|
||
# checkout truncates parent refs entirely at the boundary commit.
|
||
fetch-depth: 2
|
||
- name: Find changed addon directories
|
||
id: find_addons
|
||
run: |
|
||
# github.event.pull_request.base.sha is a snapshot from event-trigger time and can be
|
||
# stale if master advances before checkout; HEAD^1 is the actual base this merge
|
||
# commit was built against, so it's always correct.
|
||
base_sha=$(git rev-parse HEAD^1)
|
||
git fetch origin "$base_sha"
|
||
diff_files=$(git diff --name-only "$base_sha" "${{ github.sha }}")
|
||
changed_config_files=$(printf '%s\n' "$diff_files" | 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"
|
||
- name: Find changelog
|
||
id: changed-files
|
||
run: |
|
||
base_sha=$(git rev-parse HEAD^1)
|
||
git fetch origin "$base_sha"
|
||
diff_files=$(git diff --name-only "$base_sha" "${{ github.sha }}")
|
||
changed_changelog_files=$(printf '%s\n' "$diff_files" | grep -iE '^([^/]+/)?changelog\.(md|txt|ya?ml|json)$' || true)
|
||
echo "$changed_changelog_files"
|
||
{
|
||
echo "changelogs_files<<EOF_CHANGELOG_FILES"
|
||
echo "$changed_changelog_files"
|
||
echo "EOF_CHANGELOG_FILES"
|
||
} >> "$GITHUB_OUTPUT"
|
||
changed_config_files=$(printf '%s\n' "$diff_files" | grep -E '^[^/]+/config\.(json|ya?ml)$' || true)
|
||
echo "$changed_config_files"
|
||
all_changed_files=$(echo -e "$changed_config_files\n$changed_changelog_files" | sort -u)
|
||
changed_addons=$(printf '%s' "$all_changed_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"
|
||
|
||
# 1b. A pull request that changes what an add-on ships must bump that add-on's
|
||
# version. Supervisor only offers a rebuild when `version` changes, so without
|
||
# one the fix merges and no user ever receives it - inert, while the issue
|
||
# looks closed. Nothing else in CI checks this (the add-on linter validates
|
||
# the schema, not that the value moved).
|
||
#
|
||
# Deliberately independent of check-addon-changes above: that job derives
|
||
# `changedAddons` from `^<addon>/config.(json|ya?ml)$` alone, so it is empty
|
||
# for precisely the pull requests this catches - an add-on whose scripts
|
||
# changed while config.yaml did not. Gating this on its output would make it
|
||
# a no-op. It does its own scan and changes nothing about which add-ons are
|
||
# linted or built.
|
||
check-version-bump:
|
||
name: Check add-on version bumped
|
||
if: ${{ github.repository_owner == 'alexbelgium' }}
|
||
runs-on: ubuntu-latest
|
||
# Only reads git history and the PR's labels.
|
||
permissions:
|
||
contents: read
|
||
pull-requests: read
|
||
steps:
|
||
# The bypass label is read LIVE here rather than from the job's `if:`.
|
||
# The event payload is a snapshot from trigger time, and this workflow
|
||
# deliberately does not listen for `labeled` — adding that activity type
|
||
# would re-run the ~3 h add-on builds on every label change. Reading it
|
||
# at run time instead means "label the PR, then re-run this one job"
|
||
# works, which is the sequence the failure message asks for.
|
||
- name: Check for the bypass label
|
||
id: bypass
|
||
env:
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
PR: ${{ github.event.pull_request.number }}
|
||
REPO: ${{ github.repository }}
|
||
run: |
|
||
set -euo pipefail
|
||
skip=$(gh pr view "$PR" --repo "$REPO" --json labels \
|
||
--jq '[.labels[].name] | index("skip-version-check") != null')
|
||
echo "skip=$skip" >> "$GITHUB_OUTPUT"
|
||
[ "$skip" = "true" ] && echo "skip-version-check label present; skipping." || true
|
||
|
||
- name: Checkout repo
|
||
if: steps.bypass.outputs.skip != 'true'
|
||
uses: actions/checkout@v7.0.1
|
||
with:
|
||
# Same reason as check-addon-changes: HEAD^1 must be resolvable.
|
||
fetch-depth: 2
|
||
# Nothing here pushes, and the repo is public, so an anonymous fetch
|
||
# of the base commit is enough - do not leave a token in .git/config.
|
||
persist-credentials: false
|
||
|
||
- name: Check every changed add-on bumped its version
|
||
if: steps.bypass.outputs.skip != 'true'
|
||
env:
|
||
HEAD_SHA: ${{ github.sha }}
|
||
run: |
|
||
set -euo pipefail
|
||
# HEAD^1, not pull_request.base.sha, for the same reason as above: the
|
||
# event payload's base can be stale if master advanced since trigger.
|
||
BASE_SHA=$(git rev-parse HEAD^1)
|
||
git fetch origin "$BASE_SHA"
|
||
export BASE_SHA
|
||
bash .github/scripts/check_version_bump.sh
|
||
|
||
check-changed-changelog:
|
||
name: Check if CHANGELOG.md changed
|
||
needs: check-addon-changes
|
||
if: ${{ needs.check-addon-changes.outputs.changedAddons != '[]' }}
|
||
runs-on: ubuntu-latest
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
addon: ${{ fromJSON(needs.check-addon-changes.outputs.changedAddons) }}
|
||
steps:
|
||
- name: 🔎 Check for updated CHANGELOG.md
|
||
shell: bash
|
||
run: |
|
||
# shellcheck disable=SC2076,SC2059
|
||
if [[ ! "${{ needs.check-addon-changes.outputs.changedChangelogFiles }}" =~ "${{ matrix.addon }}/CHANGELOG.md" ]]; then
|
||
echo "::error::No new entries in ${{ matrix.addon }} CHANGELOG.md file!"
|
||
exit 1
|
||
fi
|
||
|
||
addon-linter:
|
||
name: Addon linting
|
||
needs: check-addon-changes
|
||
if: ${{ needs.check-addon-changes.outputs.changedAddons != '[]' }}
|
||
runs-on: ubuntu-latest
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
addon: ${{ fromJSON(needs.check-addon-changes.outputs.changedAddons) }}
|
||
steps:
|
||
- name: ↩️ Checkout
|
||
uses: actions/checkout@v7.0.1
|
||
|
||
- name: 🔎 Run Home Assistant Add-on Lint
|
||
uses: frenck/action-addon-linter@v2
|
||
with:
|
||
path: "./${{ matrix.addon }}"
|
||
|
||
check-build:
|
||
name: Test addon build
|
||
needs: check-addon-changes
|
||
if: ${{ needs.check-addon-changes.outputs.changedAddons != '[]' }}
|
||
runs-on: ubuntu-latest
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
addon: ${{ fromJSON(needs.check-addon-changes.outputs.changedAddons) }}
|
||
steps:
|
||
- name: ↩️ Checkout
|
||
uses: actions/checkout@v7.0.1
|
||
|
||
- 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"
|
||
|
||
# Keep PR builds aligned with the production builder.
|
||
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: ℹ️ Gather addon info
|
||
id: information
|
||
uses: frenck/action-addon-information@v1.4
|
||
with:
|
||
path: "./${{ matrix.addon }}/"
|
||
|
||
- name: 🗄️ Cache docker layers
|
||
uses: actions/cache@v6
|
||
with:
|
||
path: /tmp/buildx-cache
|
||
key: ${{ runner.os }}-buildx-${{ matrix.addon }}-${{ hashFiles('**/Dockerfile') }}
|
||
restore-keys: |
|
||
${{ runner.os }}-buildx-${{ matrix.addon }}-
|
||
|
||
- name: 🔖 Create addon image tags
|
||
id: tags
|
||
shell: bash
|
||
run: |
|
||
imagetemplate="${{ steps.information.outputs.image }}"
|
||
version="${{ steps.information.outputs.version }}"
|
||
echo "Using imagetemplate '${imagetemplate}'"
|
||
{
|
||
echo "armhf=${imagetemplate/\{arch\}/armhf}:${version}"
|
||
echo "armv7=${imagetemplate/\{arch\}/armv7}:${version}"
|
||
echo "aarch64=${imagetemplate/\{arch\}/aarch64}:${version}"
|
||
echo "amd64=${imagetemplate/\{arch\}/amd64}:${version}"
|
||
echo "i386=${imagetemplate/\{arch\}/i386}:${version}"
|
||
} >> "$GITHUB_OUTPUT"
|
||
|
||
- name: 🏷️ Create addon labels
|
||
id: labels
|
||
shell: bash
|
||
run: |
|
||
labels="io.hass.version=${{ steps.information.outputs.version }}"
|
||
labels=$(printf '%s' "$labels\nio.hass.name=${{ steps.information.outputs.name }}")
|
||
labels=$(printf '%s' "$labels\nio.hass.description=${{ steps.information.outputs.description }}")
|
||
labels=$(printf '%s' "$labels\nio.hass.type=addon")
|
||
labels=$(printf '%s' "$labels\nio.hass.url=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/tree/master/${{ matrix.addon }}")
|
||
labels=$(printf '%s' "$labels\norg.opencontainers.image.title=${{ steps.information.outputs.name }}")
|
||
labels=$(printf '%s' "$labels\norg.opencontainers.image.description=${{ steps.information.outputs.description }}")
|
||
labels=$(printf '%s' "$labels\norg.opencontainers.image.version=${{ steps.information.outputs.version }}")
|
||
labels=$(printf '%s' "$labels\norg.opencontainers.image.authors=Poeschl <Poeschl@users.noreply.github.com>")
|
||
labels=$(printf '%s' "$labels\norg.opencontainers.image.url=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}")
|
||
labels=$(printf '%s' "$labels\norg.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/tree/master/${{ matrix.addon }}")
|
||
labels=$(printf '%s' "$labels\norg.opencontainers.image.created=$(date -Is)")
|
||
labels=$(printf '%s' "$labels\norg.opencontainers.image.revision=${GITHUB_SHA}")
|
||
echo "Generic labels: $labels"
|
||
armhf_labels=$(printf '%s' "$labels\nio.hass.arch=armhf")
|
||
armv7_labels=$(printf '%s' "$labels\nio.hass.arch=armv7")
|
||
aarch64_labels=$(printf '%s' "$labels\nio.hass.arch=aarch64")
|
||
amd64_labels=$(printf '%s' "$labels\nio.hass.arch=amd64")
|
||
i386_labels=$(printf '%s' "$labels\nio.hass.arch=i386")
|
||
armhf_labels="${armhf_labels//$'\n'/'%0A'}"
|
||
armv7_labels="${armv7_labels//$'\n'/'%0A'}"
|
||
aarch64_labels="${aarch64_labels//$'\n'/'%0A'}"
|
||
amd64_labels="${amd64_labels//$'\n'/'%0A'}"
|
||
i386_labels="${i386_labels//$'\n'/'%0A'}"
|
||
{
|
||
echo "armhf=$armhf_labels"
|
||
echo "armv7=$armv7_labels"
|
||
echo "aarch64=$aarch64_labels"
|
||
echo "amd64=$amd64_labels"
|
||
echo "i386=$i386_labels"
|
||
} >> "$GITHUB_OUTPUT"
|
||
|
||
- name: 💽 Create addon build-args
|
||
id: build_args
|
||
run: |
|
||
build_file="${{ steps.information.outputs.build }}"
|
||
case "$build_file" in
|
||
*.yaml|*.yml) build_json="$(yq -o=json '.' "$build_file")" ;;
|
||
*) build_json="$(cat "$build_file")" ;;
|
||
esac
|
||
for arch in armhf armv7 aarch64 amd64 i386; do
|
||
echo "${arch}=BUILD_FROM=$(printf '%s' "$build_json" | jq -r --arg a "$arch" '.build_from[$a] // empty')"
|
||
done >> "$GITHUB_OUTPUT"
|
||
|
||
- name: 🏗️ Set up QEMU
|
||
uses: docker/setup-qemu-action@v4
|
||
|
||
- name: 🏗️ Set up Docker Buildx
|
||
uses: docker/setup-buildx-action@v4
|
||
|
||
- name: 💿 Build Addon - armhf
|
||
if: ${{ steps.information.outputs.armhf == 'true' }}
|
||
uses: docker/build-push-action@v7
|
||
with:
|
||
context: ${{ matrix.addon }}
|
||
push: false
|
||
load: true
|
||
file: ${{ matrix.addon }}/Dockerfile
|
||
tags: ${{ steps.tags.outputs.armhf }}
|
||
labels: ${{ steps.labels.outputs.armhf }}
|
||
build-args: ${{ steps.build_args.outputs.armhf }}
|
||
cache-from: type=local,src=/tmp/buildx-cache/armhf
|
||
cache-to: type=local,dest=/tmp/buildx-cache-new/armhf
|
||
|
||
- name: 💿 Build Addon - armv7
|
||
if: ${{ steps.information.outputs.armv7 == 'true' }}
|
||
uses: docker/build-push-action@v7
|
||
with:
|
||
context: ${{ matrix.addon }}
|
||
push: false
|
||
load: true
|
||
file: ${{ matrix.addon }}/Dockerfile
|
||
tags: ${{ steps.tags.outputs.armv7 }}
|
||
labels: ${{ steps.labels.outputs.armv7 }}
|
||
build-args: ${{ steps.build_args.outputs.armv7 }}
|
||
cache-from: type=local,src=/tmp/buildx-cache/armv7
|
||
cache-to: type=local,dest=/tmp/buildx-cache-new/armv7
|
||
|
||
- name: 💿 Build Addon - aarch64
|
||
if: ${{ steps.information.outputs.aarch64 == 'true' }}
|
||
uses: docker/build-push-action@v7
|
||
with:
|
||
context: ${{ matrix.addon }}
|
||
platforms: linux/arm64
|
||
push: false
|
||
load: true
|
||
file: ${{ matrix.addon }}/Dockerfile
|
||
tags: ${{ steps.tags.outputs.aarch64 }}
|
||
labels: ${{ steps.labels.outputs.aarch64 }}
|
||
build-args: ${{ steps.build_args.outputs.aarch64 }}
|
||
cache-from: type=local,src=/tmp/buildx-cache/aarch64
|
||
cache-to: type=local,dest=/tmp/buildx-cache-new/aarch64
|
||
|
||
- name: 💿 Build Addon - amd64
|
||
if: ${{ steps.information.outputs.amd64 == 'true' }}
|
||
uses: docker/build-push-action@v7
|
||
with:
|
||
context: ${{ matrix.addon }}
|
||
push: false
|
||
load: true
|
||
file: ${{ matrix.addon }}/Dockerfile
|
||
tags: ${{ steps.tags.outputs.amd64 }}
|
||
labels: ${{ steps.labels.outputs.amd64 }}
|
||
build-args: ${{ steps.build_args.outputs.amd64 }}
|
||
cache-from: type=local,src=/tmp/buildx-cache/amd64
|
||
cache-to: type=local,dest=/tmp/buildx-cache-new/amd64
|
||
|
||
- name: 💿 Build Addon - i386
|
||
if: ${{ steps.information.outputs.i386 == 'true' }}
|
||
uses: docker/build-push-action@v7
|
||
with:
|
||
context: ${{ matrix.addon }}
|
||
push: false
|
||
load: true
|
||
file: ${{ matrix.addon }}/Dockerfile
|
||
tags: ${{ steps.tags.outputs.i386 }}
|
||
labels: ${{ steps.labels.outputs.i386 }}
|
||
build-args: ${{ steps.build_args.outputs.i386 }}
|
||
cache-from: type=local,src=/tmp/buildx-cache/i386
|
||
cache-to: type=local,dest=/tmp/buildx-cache-new/i386
|
||
|
||
- name: 🗄️ Update cache Folder
|
||
run: |
|
||
rm -rf /tmp/buildx-cache
|
||
mv /tmp/buildx-cache-new /tmp/buildx-cache
|