Files
hassio-addons/.github/workflows/onpr_check-pr.yaml
claude-ai-fix[bot] c2dad6c88e ci: fail a PR that changes an add-on without bumping its version
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>
2026-08-23 14:36:09 +02:00

350 lines
15 KiB
YAML
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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