diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3def8bd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + + - package-ecosystem: docker + directory: /unbound + schedule: + interval: weekly diff --git a/.github/scripts/package-update.sh b/.github/scripts/package-update.sh new file mode 100755 index 0000000..635744e --- /dev/null +++ b/.github/scripts/package-update.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Compares the Alpine packages of a freshly built image with unbound/apk-packages.txt. +# On a difference it updates the list, bumps the patch version and adds a changelog entry. +# Usage (from the repository root): .github/scripts/package-update.sh IMAGE +# Writes changed=true|false and version=X.Y.Z to $GITHUB_OUTPUT when that variable is set. +set -euo pipefail + +image=${1:?usage: .github/scripts/package-update.sh IMAGE} +lock=unbound/apk-packages.txt +config=unbound/config.yaml +changelog=unbound/CHANGELOG.md + +output() { + if [ -n "${GITHUB_OUTPUT:-}" ]; then + echo "$1" >>"$GITHUB_OUTPUT" + fi +} + +current=$(docker run --rm --entrypoint sh "$image" -c 'apk info -v 2>/dev/null | sort') +[ -n "$current" ] || { echo "error: no packages reported by $image" >&2; exit 1; } +previous=$(cat "$lock") + +if [ "$current" = "$previous" ]; then + echo "No package changes." + output changed=false + exit 0 +fi + +version=$(sed -nE 's/^version: "([0-9]+\.[0-9]+\.[0-9]+)"$/\1/p' "$config") +[ -n "$version" ] || { echo "error: cannot read version from $config" >&2; exit 1; } +IFS=. read -r major minor patch <<<"$version" +next="$major.$minor.$((patch + 1))" + +[ "$(head -n 1 "$changelog")" = "# Changelog" ] || { echo "error: unexpected header in $changelog" >&2; exit 1; } + +changes=$(awk ' + function pkg_name(s, n) { n = s; sub(/-[^-]+-r[0-9]+$/, "", n); return n } + NR == FNR { n = pkg_name($0); old[n] = substr($0, length(n) + 2); next } + { n = pkg_name($0); new[n] = substr($0, length(n) + 2) } + END { + for (n in new) { + if (!(n in old)) printf " - `%s` %s (added)\n", n, new[n] + else if (old[n] != new[n]) printf " - `%s` %s → %s\n", n, old[n], new[n] + } + for (n in old) if (!(n in new)) printf " - `%s` %s (removed)\n", n, old[n] + }' <(printf '%s\n' "$previous") <(printf '%s\n' "$current") | sort) + +printf '%s\n' "$current" >"$lock" +sed -i -E "s/^version: \"$version\"$/version: \"$next\"/" "$config" +{ + printf '# Changelog\n\n## %s\n\n- Rebuilt with updated Alpine packages:\n%s\n\n' "$next" "$changes" + tail -n +3 "$changelog" +} >"$changelog.tmp" +mv "$changelog.tmp" "$changelog" + +echo "Package changes detected, prepared $next:" +printf '%s\n' "$changes" +output changed=true +output "version=$next" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d483a75..c7e2cb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: path: "./unbound" - name: ShellCheck run: | - docker run --rm -v "$PWD:/mnt:ro" -w /mnt koalaman/shellcheck:v0.11.0 unbound/run.sh tests/smoke.sh + docker run --rm -v "$PWD:/mnt:ro" -w /mnt koalaman/shellcheck:v0.11.0 unbound/run.sh tests/smoke.sh .github/scripts/package-update.sh build: runs-on: ubuntu-latest diff --git a/.github/workflows/package-updates.yml b/.github/workflows/package-updates.yml new file mode 100644 index 0000000..4758c4f --- /dev/null +++ b/.github/workflows/package-updates.yml @@ -0,0 +1,50 @@ +name: Package updates + +on: + schedule: + - cron: "17 4 * * 1" + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: package-updates + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7.0.1 + + - name: Build image with current Alpine packages + run: docker build --pull --no-cache -t unbound-pkg:ci unbound + + - name: Compare package set + id: pkg + run: .github/scripts/package-update.sh unbound-pkg:ci + + # Pushes made with GITHUB_TOKEN do not trigger the CI workflow, so the release is verified here. + - name: Smoke test + if: steps.pkg.outputs.changed == 'true' + run: | + command -v dig >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq dnsutils; } + tests/smoke.sh unbound-pkg:ci --hardened + + - name: Home Assistant app linter + if: steps.pkg.outputs.changed == 'true' + uses: frenck/action-addon-linter@v2.21.1 + with: + path: "./unbound" + + - name: Commit release + if: steps.pkg.outputs.changed == 'true' + env: + VERSION: ${{ steps.pkg.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add unbound/apk-packages.txt unbound/config.yaml unbound/CHANGELOG.md + git commit -m "Release ${VERSION} with updated Alpine packages" + git push origin "HEAD:${GITHUB_REF_NAME}" diff --git a/README.md b/README.md index ebcf0fd..916b08e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,17 @@ Supports `amd64` and `aarch64`. See the [documentation](./unbound/DOCS.md). 2. Add `https://github.com/TimInTech/ha-addon-unbound` 3. Install **Unbound**, start it, then set `127.0.0.1#5335` as the only upstream DNS server in Pi-hole. +## Maintenance + +- **CI** (`ci.yml`): app linter, ShellCheck, amd64/aarch64 builds and `tests/smoke.sh --hardened` + (resolution, DNSSEC, restart, unprivileged start) on every push to `main` and every pull request. +- **Package updates** (`package-updates.yml`, Mondays and on demand): rebuilds the image, compares the installed + Alpine packages with `unbound/apk-packages.txt` and, after the smoke test, commits a patch release with changelog. +- **Dependabot**: weekly pull requests for the Alpine base image and pinned GitHub Actions. A new Alpine branch + changes the package set, so the next package-update run releases it. +- GitHub pauses scheduled workflows after 60 days without repository activity; re-enable *Package updates* + in the Actions tab if that happens. + ## License MIT diff --git a/unbound/DOCS.md b/unbound/DOCS.md index 7907acf..5b9b2b0 100644 --- a/unbound/DOCS.md +++ b/unbound/DOCS.md @@ -12,7 +12,8 @@ It resolves names directly from the root servers, so no third-party DNS provider - The DNSSEC trust anchor ships with the image (`/var/lib/unbound/root.key`), is refreshed with `unbound-anchor` on every start and follows key rollovers (RFC 5011) while running. The app data folder is not used; after an update or reinstall the anchor is bootstrapped again from the image. -- There are no options. Start the app and point your DNS server at it. +- There are no options, by design: the Supervisor writes `options.json` readable for root only, and this app + never runs as root. Start the app and point your DNS server at it. ## Use with Pi-hole @@ -37,3 +38,7 @@ dig @192.168.178.56 example.com # expected: NOERROR with an answer The image is built on your device from `alpine:3.24`. A new app version triggers a rebuild, which also pulls the current Unbound package of that Alpine branch. + +A weekly job rebuilds the image; when any Alpine package in it changed (for example `unbound` or `libssl3`), +it publishes a patch version after the smoke test passed, so Home Assistant offers the update. +`apk-packages.txt` lists the package versions of the latest release. diff --git a/unbound/apk-packages.txt b/unbound/apk-packages.txt new file mode 100644 index 0000000..4daeca8 --- /dev/null +++ b/unbound/apk-packages.txt @@ -0,0 +1,24 @@ +alpine-baselayout-3.7.2-r1 +alpine-baselayout-data-3.7.2-r1 +alpine-keys-2.6-r0 +alpine-release-3.24.1-r0 +apk-tools-3.0.6-r0 +busybox-1.37.0-r31 +busybox-binsh-1.37.0-r31 +ca-certificates-bundle-20260611-r0 +dnssec-root-20250524-r0 +hiredis-1.3.0-r0 +libapk-3.0.6-r0 +libcrypto3-3.5.7-r0 +libevent-2.1.13-r0 +libexpat-2.8.4-r0 +libssl3-3.5.7-r0 +musl-1.2.6-r2 +musl-utils-1.2.6-r2 +nghttp2-libs-1.69.0-r0 +protobuf-c-1.5.2-r2 +scanelf-1.3.9-r1 +ssl_client-1.37.0-r31 +unbound-1.25.2-r1 +unbound-libs-1.25.2-r1 +zlib-1.3.2-r0