diff --git a/.github/ci/check-bind-pkg-descr.sh b/.github/ci/check-bind-pkg-descr.sh
new file mode 100755
index 0000000000..2ed331d58c
--- /dev/null
+++ b/.github/ci/check-bind-pkg-descr.sh
@@ -0,0 +1,43 @@
+#!/bin/sh
+
+set -eu
+
+if [ "$#" -ne 2 ]; then
+ echo "usage: $0
" >&2
+ exit 2
+fi
+
+base=$1
+head=$2
+base=$(git merge-base "$base" "$head")
+description_changed=false
+runtime_changed=false
+changed_paths=$(git diff --no-renames --name-only "$base" "$head" -- dns/bind)
+
+while IFS= read -r path; do
+ case "$path" in
+ dns/bind/pkg-descr)
+ description_changed=true
+ ;;
+ dns/bind/src/*|dns/bind/+*)
+ runtime_changed=true
+ ;;
+ dns/bind/Makefile)
+ if git diff --unified=0 "$base" "$head" -- "$path" |
+ awk '
+ /^[+-]PLUGIN_[A-Z0-9_]+[[:space:]]*[?+:!]?=/ &&
+ $0 !~ /^[+-]PLUGIN_REVISION[[:space:]]*=/ { changed = 1 }
+ END { exit !changed }
+ '; then
+ runtime_changed=true
+ fi
+ ;;
+ esac
+done <&2
+ exit 1
+fi
diff --git a/.github/ci/ci-tests/test_bind_pkg_descr_guard.py b/.github/ci/ci-tests/test_bind_pkg_descr_guard.py
new file mode 100644
index 0000000000..a7dacf4787
--- /dev/null
+++ b/.github/ci/ci-tests/test_bind_pkg_descr_guard.py
@@ -0,0 +1,128 @@
+import pathlib
+import subprocess
+
+import pytest
+
+
+REPOSITORY_ROOT = pathlib.Path(__file__).resolve().parents[3]
+GUARD = REPOSITORY_ROOT / ".github/ci/check-bind-pkg-descr.sh"
+
+
+def git(repository: pathlib.Path, *arguments: str) -> str:
+ return subprocess.run(
+ ["git", "-C", repository, *arguments],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout.strip()
+
+
+def initialize_repository(repository: pathlib.Path) -> str:
+ repository.mkdir()
+ git(repository, "init")
+ git(repository, "config", "user.email", "tests@example.invalid")
+ git(repository, "config", "user.name", "Description guard tests")
+ initial = {
+ "dns/bind/Makefile": "PLUGIN_VERSION= 1.0\nPLUGIN_REVISION= 1\nPLUGIN_DEPENDS= bind920\n",
+ "dns/bind/pkg-descr": "Initial description\n",
+ "dns/bind/src/service": "initial\n",
+ "dns/bind/tests/test_service.py": "initial\n",
+ }
+ for name, contents in initial.items():
+ destination = repository / name
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_text(contents, encoding="utf-8")
+ git(repository, "add", ".")
+ git(repository, "commit", "-m", "initial")
+ return git(repository, "rev-parse", "HEAD")
+
+
+def run_guard(repository: pathlib.Path, base: str, head: str) -> int:
+ return subprocess.run(
+ [str(GUARD), base, head],
+ cwd=repository,
+ capture_output=True,
+ text=True,
+ ).returncode
+
+
+def check_case(repository: pathlib.Path, changes: dict[str, str]) -> int:
+ base = initialize_repository(repository)
+ for name, contents in changes.items():
+ destination = repository / name
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_text(contents, encoding="utf-8")
+ git(repository, "add", ".")
+ git(repository, "commit", "-m", "change")
+ return run_guard(repository, base, "HEAD")
+
+
+@pytest.mark.parametrize(
+ ("case", "changes", "expected"),
+ [
+ ("runtime", {"dns/bind/src/service": "changed\n"}, 1),
+ ("hook", {"dns/bind/+POST_INSTALL.post": "changed\n"}, 1),
+ (
+ "documented",
+ {
+ "dns/bind/src/service": "changed\n",
+ "dns/bind/pkg-descr": "Updated description\n",
+ },
+ 0,
+ ),
+ (
+ "dependency-formula",
+ {
+ "dns/bind/Makefile": (
+ "PLUGIN_VERSION= 1.0\nPLUGIN_REVISION= 1\nPLUGIN_DEPENDS= bind920\n"
+ "PLUGIN_DEPEND_FORMULA_DEPENDS= bind920\n"
+ )
+ },
+ 1,
+ ),
+ (
+ "manifest-dependency",
+ {
+ "dns/bind/Makefile": (
+ "PLUGIN_VERSION= 1.0\nPLUGIN_REVISION= 1\nPLUGIN_DEPENDS= bind920\n"
+ "PLUGIN_MANIFEST_DEPENDS= bind920\n"
+ )
+ },
+ 1,
+ ),
+ (
+ "revision",
+ {"dns/bind/Makefile": "PLUGIN_VERSION= 1.0\nPLUGIN_REVISION= 2\nPLUGIN_DEPENDS= bind920\n"},
+ 0,
+ ),
+ ("tests", {"dns/bind/tests/test_service.py": "changed\n"}, 0),
+ ],
+)
+def test_guard_requires_pkg_descr_only_for_publishable_changes(tmp_path, case, changes, expected):
+ assert check_case(tmp_path / case, changes) == expected
+
+
+def test_guard_uses_merge_base_when_the_release_branch_advances(tmp_path):
+ repository = tmp_path / "advanced-base"
+ original_base = initialize_repository(repository)
+ git(repository, "checkout", "-b", "feature")
+ (repository / "dns/bind/src/service").write_text("changed\n", encoding="utf-8")
+ git(repository, "commit", "-am", "runtime change")
+ head = git(repository, "rev-parse", "HEAD")
+
+ git(repository, "checkout", original_base)
+ (repository / "dns/bind/pkg-descr").write_text("Base advanced\n", encoding="utf-8")
+ git(repository, "commit", "-am", "advance base")
+ advanced_base = git(repository, "rev-parse", "HEAD")
+
+ assert original_base == git(repository, "merge-base", advanced_base, head)
+ assert run_guard(repository, advanced_base, head) == 1
+
+
+def test_guard_detects_runtime_files_moved_out_of_the_package(tmp_path):
+ repository = tmp_path / "runtime-rename"
+ base = initialize_repository(repository)
+ git(repository, "mv", "dns/bind/src/service", "dns/bind/tests/moved_service")
+ git(repository, "commit", "-m", "move runtime file")
+
+ assert run_guard(repository, base, "HEAD") == 1
diff --git a/.github/ci/ci-tests/test_bind_pull_request_workflow.py b/.github/ci/ci-tests/test_bind_pull_request_workflow.py
index 4591a14666..5482279a26 100644
--- a/.github/ci/ci-tests/test_bind_pull_request_workflow.py
+++ b/.github/ci/ci-tests/test_bind_pull_request_workflow.py
@@ -74,6 +74,18 @@ def test_release_source_pull_requests_materialize_master_ci_helpers():
assert '.resolver-plugins/bind920.json' in helper_job
+def test_workflow_requires_pkg_descr_for_publishable_bind_changes():
+ workflow = workflow_text()
+ changes_job = workflow.split(' changes:', 1)[1].split(' ci-helpers:', 1)[0]
+
+ assert 'Check BIND package description freshness' in changes_job
+ assert 'CALLER_SHA: ${{ inputs.pull_request_sha }}' in changes_job
+ assert 'if [ -n "$CALLER_SHA" ]; then' in changes_job
+ assert 'refs/heads/$PR_BASE:refs/remotes/origin/pr-base' in changes_job
+ assert 'refs/heads/master:refs/remotes/origin/control-plane' in changes_job
+ assert 'check-bind-pkg-descr.sh' in changes_job
+
+
def test_reusable_workflow_accepts_the_callers_pull_request_context():
workflow = workflow_text()
diff --git a/.github/workflows/bind-tests.yml b/.github/workflows/bind-tests.yml
index 5bb8b06442..994e365743 100644
--- a/.github/workflows/bind-tests.yml
+++ b/.github/workflows/bind-tests.yml
@@ -60,6 +60,26 @@ jobs:
esac
done < "$RUNNER_TEMP/changed-paths"
printf 'bind_source=%s\nci_helpers=%s\n' "$bind_source" "$ci_helpers" >> "$GITHUB_OUTPUT"
+ - name: Check BIND package description freshness
+ shell: bash
+ env:
+ CALLER_SHA: ${{ inputs.pull_request_sha }}
+ PR_BASE: ${{ inputs.pull_request_base || github.event.pull_request.base.ref }}
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ inputs.pull_request_sha || github.event.pull_request.head.sha }}
+ run: |
+ set -euo pipefail
+ guard=.github/ci/check-bind-pkg-descr.sh
+ if [ -n "$CALLER_SHA" ]; then
+ git fetch --no-tags origin \
+ "refs/heads/$PR_BASE:refs/remotes/origin/pr-base" \
+ 'refs/heads/master:refs/remotes/origin/control-plane'
+ BASE_SHA=$(git rev-parse refs/remotes/origin/pr-base)
+ guard="$RUNNER_TEMP/check-bind-pkg-descr.sh"
+ git show refs/remotes/origin/control-plane:.github/ci/check-bind-pkg-descr.sh > "$guard"
+ chmod +x "$guard"
+ fi
+ "$guard" "$BASE_SHA" "$HEAD_SHA"
ci-helpers:
needs: changes
diff --git a/dns/bind/pkg-descr b/dns/bind/pkg-descr
index 284434dc92..9984795802 100644
--- a/dns/bind/pkg-descr
+++ b/dns/bind/pkg-descr
@@ -7,6 +7,22 @@ necessary for asking and answering name service questions.
Plugin Changelog
================
+1.36
+
+* Bound BIND shutdown and log forced recovery steps in the General Log
+* Preserve dynamic BIND records while package upgrades regenerate zone files
+* Reconcile the supported BIND package pair after plugin and system upgrades
+* Add DHCP lease watcher and scoped dynamic DNS mappings
+* Add reverse DNS zone management
+* Source DNSBL lists from the Unbound definitions
+* Add Forward First / Forward Only mode
+
+1.35
+
+* Add per-forwarder destination port for plain DNS forwarders
+* Add DNS-over-TLS (DoT) forwarders with TLS hostname verification
+* Migrate legacy DNS Forwarders list to the new DNS Forwarders tab
+
1.34
* Add custom configuration include directory /usr/local/etc/namedb/named.conf.d (contributed by Nicholas Card)
diff --git a/docs/building.md b/docs/building.md
index fd322fc35a..a914333f1c 100644
--- a/docs/building.md
+++ b/docs/building.md
@@ -177,6 +177,10 @@ the CI helper tests for BIND profile changes, including generated
`.resolver-plugins/bind920.json` candidate PRs. Do not add a static release
matrix: a newly created release branch is included automatically.
+The workflow also requires `dns/bind/pkg-descr` to change with publishable
+BIND runtime, package-hook, or package-metadata changes. Test-only changes and
+`PLUGIN_REVISION`-only rebuilds do not require a changelog entry.
+
## BIND candidate updates
The `Propose bind920 candidate` workflow is manual-only. It inspects a