From 38ec5d00c29059c5097de44153ff3f6806e4b498 Mon Sep 17 00:00:00 2001 From: Bryan Date: Fri, 28 Aug 2026 14:32:11 +0000 Subject: [PATCH] ci: publish BIND package updates safely --- .../ci-tests/test_package_release_workflow.py | 39 ++++- .../ci-tests/test_release_channel_archive.py | 150 +++++++++++++++++- .github/ci/release_channel.py | 51 +++++- .github/workflows/package-release.yml | 27 ++-- docs/building.md | 6 +- docs/package-channel-distribution-design.md | 18 ++- docs/package-repository.md | 33 ++-- 7 files changed, 276 insertions(+), 48 deletions(-) diff --git a/.github/ci/ci-tests/test_package_release_workflow.py b/.github/ci/ci-tests/test_package_release_workflow.py index b587917edb..2cb6f685bf 100644 --- a/.github/ci/ci-tests/test_package_release_workflow.py +++ b/.github/ci/ci-tests/test_package_release_workflow.py @@ -30,6 +30,24 @@ def test_workflow_selects_an_immutable_release_source(): assert 'git checkout "$SOURCE_COMMIT" -- .resolver-plugins/upstream.json Mk dns/bind' in workflow +def test_package_affecting_master_pushes_publish_the_active_series(): + workflow = workflow_text() + + assert 'push:\n branches: [master]' in workflow + for path in ( + "'.github/ci/**'", + "'.github/workflows/package-release.yml'", + "'.resolver-plugins/**'", + "'docs/package-repository/resolver-plugins.pub'", + "'dns/bind/**'", + "'Mk/**'", + ): + assert path in workflow + assert "group: package-release-${{ inputs.series || '26.7' }}" in workflow + assert "INPUT_MODE: ${{ github.event_name == 'push' && 'production' || inputs.mode }}" in workflow + assert "INPUT_SERIES: ${{ github.event_name == 'push' && '26.7' || inputs.series }}" in workflow + + def test_production_runs_only_from_the_master_control_plane(): workflow = workflow_text() select = workflow.split(' select:', 1)[1].split(' profile:', 1)[0] @@ -43,7 +61,7 @@ def test_production_runs_only_from_the_master_control_plane(): assert 'ref: ${{ needs.select.outputs.control_ref }}' in profile for job in (test, bind, build): assert 'ref: ${{ needs.profile.outputs.control_commit }}' in job - assert 'group: package-release-${{ inputs.series }}' in workflow + assert "group: package-release-${{ inputs.series || '26.7' }}" in workflow assert 'cancel-in-progress: false' in workflow @@ -99,7 +117,7 @@ def test_production_signing_and_publication_are_separate_from_builds(): assert 'python3 .github/ci/release_channel.py stage-channel' in workflow assert 'python3 .github/ci/release_channel.py validate-bind-provenance' in workflow assert '--profile .resolver-plugins/bind920.json' in workflow - assert "--series '${{ needs.select.outputs.series }}'" in workflow + assert '--series "$SERIES"' in workflow assert "--freebsd-release '${{ needs.profile.outputs.freebsd_release }}'" in workflow assert 'python3 .github/ci/release_channel.py publish-channels' in workflow assert 'permissions:\n contents: write' in workflow @@ -121,9 +139,16 @@ def test_signer_uses_master_control_plane_and_self_contained_channel_layout(): assert 'cmp -s docs/package-repository/resolver-plugins.pub "$output/resolver-plugins.pub"' in signer assert 'trusted-upstream.json' in signer assert 'validate-build-metadata' in signer - assert signer.count('--target-pkg-metadata .resolver-plugins/target-pkg.json') == 3 + assert signer.count('--target-pkg-metadata .resolver-plugins/target-pkg.json') == 4 assert 'id: reuse-snapshot' in signer assert 'reuse-snapshot --repository resolver-plugins/repository' in signer + assert '--provenance "$output/bind920-provenance.json"' in signer + reuse = signer.split('- name: Reuse existing immutable snapshot', 1)[1].split( + '- name: Sign package repository', 1 + )[0] + reuse_command = 'release_channel.py reuse-snapshot' + assert reuse.index('validate-bind-provenance') < reuse.index(reuse_command) + assert reuse.index('validate-build-metadata') < reuse.index(reuse_command) assert "if: steps.reuse-snapshot.outputs.reused != 'true'" in signer assert '--public-key docs/package-repository/resolver-plugins.pub' in signer assert 'repository/bind920' not in signer @@ -311,11 +336,19 @@ def test_source_release_contains_only_plugin_and_build_metadata(): assert 'gh release view "$tag"' not in source_release assert 'gh release create "$tag"' not in source_release assert 'set -- "$output"/os-bind-rp-*.pkg' in source_release + assert 'source-release-tag "$SERIES" "$version" --provenance "$output/bind920-provenance.json"' in source_release assert 'cp "$1" "$output/build-metadata.txt" "$source_output/"' in source_release assert 'bind920-*.pkg' not in source_release assert 'os-bind-rp-build-production-' not in source_release +def test_immutable_package_snapshot_is_scoped_to_the_bind_build(): + workflow = workflow_text() + publisher = workflow.split(' publish:', 1)[1].split(' verify:', 1)[0] + + assert 'snapshot-tag "$SERIES" "$version" --provenance "$root/current/bind920-provenance.json"' in publisher + + def test_all_freebsd_install_gates_pin_pkg_and_test_the_official_replacement_path(): workflow = workflow_text() verifiers = { diff --git a/.github/ci/ci-tests/test_release_channel_archive.py b/.github/ci/ci-tests/test_release_channel_archive.py index f7f2755e2c..116e523f0e 100644 --- a/.github/ci/ci-tests/test_release_channel_archive.py +++ b/.github/ci/ci-tests/test_release_channel_archive.py @@ -19,6 +19,26 @@ SPEC.loader.exec_module(release_channel) +def target_creator_record(digest: str = "a" * 64) -> dict[str, str]: + return { + "abi": "FreeBSD:15:amd64", + "filename": "pkg-2.3.1_1.pkg", + "name": "pkg", + "origin": "ports-mgmt/pkg", + "pkg_static_sha256": digest, + "sha256": digest, + "version": "2.3.1_1", + } + + +def bind_provenance_record(fingerprint: str = "f" * 64) -> dict[str, object]: + return { + "architecture": "x86_64", + "fingerprint": fingerprint, + "freebsd_release": "15.1", + } + + class ChannelTagTest(unittest.TestCase): def test_series_abi_path_uses_exact_freebsd_amd64_package_abi_and_series(self) -> None: """A package ABI and OPNsense series select one static repository path.""" @@ -51,17 +71,20 @@ def test_package_release_title_rejects_non_channel_tags(self) -> None: with self.assertRaisesRegex(ValueError, "invalid package release tag"): release_channel.package_release_title(tag) - def test_source_release_tag_identifies_the_series_and_plugin_version(self) -> None: + def test_source_release_tag_identifies_the_series_plugin_and_bind_build(self) -> None: + fingerprint = "f" * 64 self.assertEqual( - "os-bind-rp-26.7-1.36_7", release_channel.source_release_tag("26.7", "1.36_7") + f"os-bind-rp-26.7-1.36_7-bind-{fingerprint}", + release_channel.source_release_tag("26.7", "1.36_7", fingerprint), ) def test_channel_tags_are_series_scoped(self) -> None: """Current and immutable snapshot channels must never share a tag.""" + fingerprint = "f" * 64 self.assertEqual("pkg-26.7", release_channel.channel_tag("26.7")) self.assertEqual( - "pkg-26.7-os-bind-rp-1.36_2", - release_channel.snapshot_channel_tag("26.7", "1.36_2"), + f"pkg-26.7-os-bind-rp-1.36_2-bind-{fingerprint}", + release_channel.snapshot_channel_tag("26.7", "1.36_2", fingerprint), ) def test_channel_tags_reject_invalid_series(self) -> None: @@ -69,7 +92,9 @@ def test_channel_tags_reject_invalid_series(self) -> None: with self.assertRaisesRegex(ValueError, "invalid series"): release_channel.channel_tag("26.7/archive") with self.assertRaisesRegex(ValueError, "invalid package version"): - release_channel.snapshot_channel_tag("26.7", "1.36/2") + release_channel.snapshot_channel_tag("26.7", "1.36/2", "f" * 64) + with self.assertRaisesRegex(ValueError, "invalid BIND fingerprint"): + release_channel.snapshot_channel_tag("26.7", "1.36_2", "not-a-fingerprint") class GitHubCliTest(unittest.TestCase): @@ -805,6 +830,8 @@ def test_existing_snapshot_is_materialized_for_an_exact_release_retry(self) -> N (remote / "channel.json").write_text( json.dumps( { + "bind": bind_provenance_record(), + "package_creator": target_creator_record(), "series": "26.7", "plugin_version": "1.36_2", "source_commit": "a" * 40, @@ -816,12 +843,18 @@ def test_existing_snapshot_is_materialized_for_an_exact_release_retry(self) -> N public_key = root / "resolver-plugins.pub" public_key.write_bytes(b"trusted key") (remote / public_key.name).write_bytes(public_key.read_bytes()) + fingerprint = bind_provenance_record()["fingerprint"] snapshot = release_channel.ReleaseSnapshot( - "pkg-26.7-os-bind-rp-1.36_2", True, remote, root / "manifest.json" + f"pkg-26.7-os-bind-rp-1.36_2-bind-{fingerprint}", + True, + remote, + root / "manifest.json", ) with ( - patch.object(release_channel, "snapshot_release", return_value=snapshot), + patch.object( + release_channel, "snapshot_release", return_value=snapshot + ) as snapshot_release, patch.object(release_channel, "validate_channel_directory") as validate, ): reused = release_channel.materialize_existing_snapshot( @@ -831,9 +864,18 @@ def test_existing_snapshot_is_materialized_for_an_exact_release_retry(self) -> N "a" * 40, root / "repository", public_key, + target_creator_record(), + bind_provenance_record(), ) self.assertTrue(reused) + self.assertEqual( + ( + "resolver-plugins/repository", + f"pkg-26.7-os-bind-rp-1.36_2-bind-{fingerprint}", + ), + snapshot_release.call_args.args[:2], + ) validate.assert_called_once_with(remote) for channel in ("current", "snapshot"): self.assertEqual( @@ -860,6 +902,8 @@ def test_absent_snapshot_leaves_signing_output_unmodified(self) -> None: "b" * 40, root / "repository", public_key, + target_creator_record(), + bind_provenance_record(), ) self.assertFalse(reused) @@ -873,6 +917,8 @@ def test_snapshot_reuse_rejects_different_release_source(self) -> None: (remote / "channel.json").write_text( json.dumps( { + "bind": bind_provenance_record(), + "package_creator": target_creator_record(), "series": "26.7", "plugin_version": "1.36_2", "source_commit": "a" * 40, @@ -898,6 +944,86 @@ def test_snapshot_reuse_rejects_different_release_source(self) -> None: "b" * 40, root / "repository", public_key, + target_creator_record(), + bind_provenance_record(), + ) + + def test_snapshot_reuse_rejects_different_bind_provenance(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + remote = root / "remote" + remote.mkdir() + (remote / "channel.json").write_text( + json.dumps( + { + "bind": bind_provenance_record("b" * 64), + "package_creator": target_creator_record(), + "series": "26.7", + "plugin_version": "1.36_2", + "source_commit": "a" * 40, + } + ), + encoding="utf-8", + ) + public_key = root / "resolver-plugins.pub" + public_key.write_bytes(b"trusted key") + (remote / public_key.name).write_bytes(public_key.read_bytes()) + snapshot = release_channel.ReleaseSnapshot( + "pkg-26.7-os-bind-rp-1.36_2", True, remote, root / "manifest.json" + ) + with ( + patch.object(release_channel, "snapshot_release", return_value=snapshot), + patch.object(release_channel, "validate_channel_directory"), + ): + with self.assertRaisesRegex(ValueError, "does not match requested release"): + release_channel.materialize_existing_snapshot( + "resolver-plugins/repository", + "26.7", + "1.36_2", + "a" * 40, + root / "repository", + public_key, + target_creator_record(), + bind_provenance_record("c" * 64), + ) + + def test_snapshot_reuse_rejects_different_target_pkg_creator(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + remote = root / "remote" + remote.mkdir() + (remote / "channel.json").write_text( + json.dumps( + { + "bind": bind_provenance_record(), + "package_creator": target_creator_record("b" * 64), + "series": "26.7", + "plugin_version": "1.36_2", + "source_commit": "a" * 40, + } + ), + encoding="utf-8", + ) + public_key = root / "resolver-plugins.pub" + public_key.write_bytes(b"trusted key") + (remote / public_key.name).write_bytes(public_key.read_bytes()) + snapshot = release_channel.ReleaseSnapshot( + "pkg-26.7-os-bind-rp-1.36_2", True, remote, root / "manifest.json" + ) + with ( + patch.object(release_channel, "snapshot_release", return_value=snapshot), + patch.object(release_channel, "validate_channel_directory"), + ): + with self.assertRaisesRegex(ValueError, "does not match requested release"): + release_channel.materialize_existing_snapshot( + "resolver-plugins/repository", + "26.7", + "1.36_2", + "a" * 40, + root / "repository", + public_key, + target_creator_record("c" * 64), + bind_provenance_record(), ) def test_recovery_channel_rejects_an_audit_checksum_mismatch(self) -> None: @@ -1315,8 +1441,16 @@ def test_repository_latest_is_the_current_channel_for_the_highest_series(self) - def test_snapshot_pruning_keeps_the_newest_five_immutable_tags(self) -> None: """Only a successful promotion may remove the sixth-oldest snapshot.""" + fingerprint = "f" * 64 releases = [ - {"tag_name": f"pkg-26.7-os-bind-rp-1.36_{number}", "created_at": f"2026-01-0{number}T00:00:00Z"} + { + "tag_name": ( + f"pkg-26.7-os-bind-rp-1.36_{number}-bind-{fingerprint}" + if number % 2 == 0 + else f"pkg-26.7-os-bind-rp-1.36_{number}" + ), + "created_at": f"2026-01-0{number}T00:00:00Z", + } for number in range(1, 7) ] # `gh api --paginate --slurp` returns one JSON array per fetched page. diff --git a/.github/ci/release_channel.py b/.github/ci/release_channel.py index df7e098337..675a23188e 100644 --- a/.github/ci/release_channel.py +++ b/.github/ci/release_channel.py @@ -29,6 +29,7 @@ PLUGIN_PATTERN = re.compile(r"os-bind-rp-(?!devel-).+\.pkg") PROVENANCE_NAME = "bind920-provenance.json" PACKAGE_VERSION_PATTERN = re.compile(r"[0-9][0-9A-Za-z._-]*") +SHA256_PATTERN = re.compile(r"[0-9a-f]{64}") SERIES_PLUGIN_VERSION_PATTERN = re.compile(r"(?P[0-9]+\.[0-9]+)_(?P[1-9][0-9]*)") PACKAGE_ABI_PATTERN = re.compile(r"FreeBSD:[0-9]+:amd64") BUILD_METADATA_FIELDS = { @@ -61,20 +62,29 @@ def channel_tag(series: str) -> str: return f"pkg-{series}" -def snapshot_channel_tag(series: str, version: str) -> str: +def validate_bind_fingerprint(fingerprint: object) -> str: + """Return one canonical BIND compatibility fingerprint.""" + if not isinstance(fingerprint, str) or SHA256_PATTERN.fullmatch(fingerprint) is None: + raise ValueError("invalid BIND fingerprint") + return fingerprint + + +def snapshot_channel_tag(series: str, version: str, fingerprint: object) -> str: """Return an immutable, self-contained rollback snapshot tag.""" if PACKAGE_VERSION_PATTERN.fullmatch(version) is None: raise ValueError("invalid package version") - return f"{channel_tag(series)}-os-bind-rp-{version}" + fingerprint = validate_bind_fingerprint(fingerprint) + return f"{channel_tag(series)}-os-bind-rp-{version}-bind-{fingerprint}" -def source_release_tag(series: str, version: str) -> str: +def source_release_tag(series: str, version: str, fingerprint: object) -> str: """Return the immutable, human-facing plugin release tag.""" if SERIES_PATTERN.fullmatch(series) is None: raise ValueError("invalid series") if PACKAGE_VERSION_PATTERN.fullmatch(version) is None: raise ValueError("invalid package version") - return f"os-bind-rp-{series}-{version}" + fingerprint = validate_bind_fingerprint(fingerprint) + return f"os-bind-rp-{series}-{version}-bind-{fingerprint}" def validate_package_abi(abi: str) -> None: @@ -718,9 +728,15 @@ def materialize_existing_snapshot( source_commit: str, output: Path, public_key: Path, + package_creator: dict[str, str], + provenance: dict[str, object], ) -> bool: """Reuse the signed immutable bytes for an already-published package version.""" - tag = snapshot_channel_tag(series, version) + expected_bind = { + field: provenance.get(field) + for field in ("fingerprint", "freebsd_release", "architecture") + } + tag = snapshot_channel_tag(series, version, expected_bind["fingerprint"]) with tempfile.TemporaryDirectory() as temporary_directory: snapshot = snapshot_release(repository, tag, Path(temporary_directory)) if not snapshot.existed: @@ -737,6 +753,9 @@ def materialize_existing_snapshot( or channel.get("series") != series or channel.get("plugin_version") != version or channel.get("source_commit") != source_commit + or channel.get("package_creator") != package_creator + or channel.get("bind") != expected_bind + or any(value is None for value in expected_bind.values()) ): raise ValueError("immutable snapshot does not match requested release") try: @@ -1222,9 +1241,11 @@ def main() -> None: snapshot_tag = commands.add_parser("snapshot-tag") snapshot_tag.add_argument("series") snapshot_tag.add_argument("version") + snapshot_tag.add_argument("--provenance", type=Path, required=True) source_tag = commands.add_parser("source-release-tag") source_tag.add_argument("series") source_tag.add_argument("version") + source_tag.add_argument("--provenance", type=Path, required=True) stage_channel = commands.add_parser("stage-channel") stage_channel.add_argument("--packages-directory", type=Path, required=True) stage_channel.add_argument("--output", type=Path, required=True) @@ -1238,6 +1259,8 @@ def main() -> None: reuse_snapshot.add_argument("--source-commit", required=True) reuse_snapshot.add_argument("--output", type=Path, required=True) reuse_snapshot.add_argument("--public-key", type=Path, required=True) + reuse_snapshot.add_argument("--target-pkg-metadata", type=Path, required=True) + reuse_snapshot.add_argument("--provenance", type=Path, required=True) publish_parser = commands.add_parser("publish") publish_parser.add_argument("--repository", required=True) publish_parser.add_argument("--series", required=True) @@ -1300,9 +1323,19 @@ def main() -> None: arguments.source_commit, ) elif arguments.command == "snapshot-tag": - print(snapshot_channel_tag(arguments.series, arguments.version)) + provenance = json.loads(arguments.provenance.read_text(encoding="utf-8")) + print( + snapshot_channel_tag( + arguments.series, arguments.version, provenance.get("fingerprint") + ) + ) elif arguments.command == "source-release-tag": - print(source_release_tag(arguments.series, arguments.version)) + provenance = json.loads(arguments.provenance.read_text(encoding="utf-8")) + print( + source_release_tag( + arguments.series, arguments.version, provenance.get("fingerprint") + ) + ) elif arguments.command == "stage-channel": for asset in stage_channel_repository( arguments.packages_directory, @@ -1320,6 +1353,10 @@ def main() -> None: arguments.source_commit, arguments.output, arguments.public_key, + target_pkg.load_target( + arguments.target_pkg_metadata, arguments.series + ).record(), + json.loads(arguments.provenance.read_text(encoding="utf-8")), ) print("true" if reused else "false") elif arguments.command == "publish": diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index c5d7420c11..5587f24b1c 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -6,6 +6,15 @@ name: Publish os-bind-rp package release # install the publisher App on resolver-plugins/repository with Contents: write. on: + push: + branches: [master] + paths: + - '.github/ci/**' + - '.github/workflows/package-release.yml' + - '.resolver-plugins/**' + - 'docs/package-repository/resolver-plugins.pub' + - 'dns/bind/**' + - 'Mk/**' workflow_dispatch: inputs: mode: @@ -26,7 +35,7 @@ permissions: pull-requests: read concurrency: - group: package-release-${{ inputs.series }} + group: package-release-${{ inputs.series || '26.7' }} cancel-in-progress: false jobs: @@ -61,8 +70,8 @@ jobs: env: GITHUB_REF: ${{ github.ref }} GITHUB_SHA: ${{ github.sha }} - INPUT_MODE: ${{ inputs.mode }} - INPUT_SERIES: ${{ inputs.series }} + INPUT_MODE: ${{ github.event_name == 'push' && 'production' || inputs.mode }} + INPUT_SERIES: ${{ github.event_name == 'push' && '26.7' || inputs.series }} INPUT_PULL_NUMBER: ${{ inputs.pull_number }} GH_TOKEN: ${{ github.token }} run: | @@ -384,11 +393,14 @@ jobs: run: | set -euo pipefail output="artifacts/$SERIES" + grep -Fqx "source_commit=$SOURCE_COMMIT" "$output/build-metadata.txt" + python3 .github/ci/release_channel.py validate-bind-provenance --provenance "$output/bind920-provenance.json" --profile .resolver-plugins/bind920.json --series "$SERIES" --freebsd-release '${{ needs.profile.outputs.freebsd_release }}' --target-pkg-metadata .resolver-plugins/target-pkg.json + python3 .github/ci/release_channel.py validate-build-metadata --metadata "$output/build-metadata.txt" --upstream "$output/trusted-upstream.json" --provenance "$output/bind920-provenance.json" --series "$SERIES" --source-commit "$SOURCE_COMMIT" --target-pkg-metadata .resolver-plugins/target-pkg.json set -- "$output"/os-bind-rp-*.pkg [[ "$#" -eq 1 && -f "$1" ]] version=${1##*/os-bind-rp-} version=${version%.pkg} - reused=$(python3 .github/ci/release_channel.py reuse-snapshot --repository resolver-plugins/repository --series "$SERIES" --version "$version" --source-commit "$SOURCE_COMMIT" --output "$output/repository" --public-key docs/package-repository/resolver-plugins.pub) + reused=$(python3 .github/ci/release_channel.py reuse-snapshot --repository resolver-plugins/repository --series "$SERIES" --version "$version" --source-commit "$SOURCE_COMMIT" --output "$output/repository" --public-key docs/package-repository/resolver-plugins.pub --target-pkg-metadata .resolver-plugins/target-pkg.json --provenance "$output/bind920-provenance.json") printf 'reused=%s\n' "$reused" >> "$GITHUB_OUTPUT" - name: Sign package repository if: steps.reuse-snapshot.outputs.reused != 'true' @@ -412,9 +424,6 @@ jobs: trap 'rm -f "$key"' EXIT HUP INT TERM printf '%s' "$RP_PKG_SIGNING_KEY" | base64 -d > "$key" output="artifacts/${{ needs.select.outputs.series }}" - grep -Fqx 'source_commit=${{ needs.profile.outputs.source_commit }}' "$output/build-metadata.txt" - python3 .github/ci/release_channel.py validate-bind-provenance --provenance "$output/bind920-provenance.json" --profile .resolver-plugins/bind920.json --series '${{ needs.select.outputs.series }}' --freebsd-release '${{ needs.profile.outputs.freebsd_release }}' --target-pkg-metadata .resolver-plugins/target-pkg.json - python3 .github/ci/release_channel.py validate-build-metadata --metadata "$output/build-metadata.txt" --upstream "$output/trusted-upstream.json" --provenance "$output/bind920-provenance.json" --series '${{ needs.select.outputs.series }}' --source-commit '${{ needs.profile.outputs.source_commit }}' --target-pkg-metadata .resolver-plugins/target-pkg.json mkdir -p "$output/repository" python3 .github/ci/release_channel.py stage-channel --packages-directory "$output" --output "$output/repository/current" --private-key "$key" --target-pkg-metadata .resolver-plugins/target-pkg.json cp -R "$output/repository/current" "$output/repository/snapshot" @@ -482,7 +491,7 @@ jobs: set -- "$root"/snapshot/os-bind-rp-*.pkg version=${1##*/os-bind-rp-} version=${version%.pkg} - snapshot_tag=$(python3 .github/ci/release_channel.py snapshot-tag "$SERIES" "$version") + snapshot_tag=$(python3 .github/ci/release_channel.py snapshot-tag "$SERIES" "$version" --provenance "$root/current/bind920-provenance.json") channels=(--channel "$snapshot_tag=$root/snapshot") channels+=(--channel "pkg-$SERIES=$root/current") python3 .github/ci/release_channel.py publish-channels --repository resolver-plugins/repository --recovery "$RUNNER_TEMP/recovery" "${channels[@]}" @@ -769,7 +778,7 @@ jobs: [ "$#" -eq 1 ] && [ -f "$1" ] version=${1##*/os-bind-rp-} version=${version%.pkg} - tag=$(python3 .github/ci/release_channel.py source-release-tag "$SERIES" "$version") + tag=$(python3 .github/ci/release_channel.py source-release-tag "$SERIES" "$version" --provenance "$output/bind920-provenance.json") source_output="$RUNNER_TEMP/source-release" mkdir "$source_output" cp "$1" "$output/build-metadata.txt" "$source_output/" diff --git a/docs/building.md b/docs/building.md index c29fe3f46f..a75f08e747 100644 --- a/docs/building.md +++ b/docs/building.md @@ -40,8 +40,10 @@ legacy release branches, which intentionally do not carry the control-plane scripts. In particular, the release `Mk` files prevent a development-branch marker from adding an unintended `-devel` package suffix. -Production release runs are manually dispatched from `master`; merging a -release-source pull request does not execute helpers from that release branch. +Package-affecting pushes to `master` automatically run production for the +active `26.7` series. Manual dispatch remains available for an explicit series +or development build. Release-source branches provide immutable inputs and do +not execute publication helpers themselves. Reproduce that split in a disposable worktree when building locally. Start from `master`, fetch the selected release branch, and overlay only its release diff --git a/docs/package-channel-distribution-design.md b/docs/package-channel-distribution-design.md index 755e287c18..9686dcf7c1 100644 --- a/docs/package-channel-distribution-design.md +++ b/docs/package-channel-distribution-design.md @@ -111,11 +111,14 @@ baseline. ## Publication and retention -1. A reviewed change lands on `release/bind-rp/`. -2. A maintainer explicitly dispatches the production workflow from `master` - for that series. CI validates immutable source provenance and builds a production - `os-bind-rp` package from that exact source. It reuses a compatible BIND - pair or performs the pinned BIND build only on an expected cache miss. +1. A reviewed change lands on `release/bind-rp/` or changes a package + control input on `master`. +2. A package-affecting push to `master` automatically starts production for + the active `26.7` series. A maintainer can also dispatch production from + `master` for an explicit series. CI validates immutable source provenance + and builds an `os-bind-rp` package from that exact release source. It reuses + a compatible BIND pair or performs the pinned BIND build only on an expected + cache miss. 3. The build obtains the BIND pair from the current distribution channel when its provenance matches, or builds the pinned pair once on a cache miss. It does so in a clean BIND-materialization environment rather than installing @@ -126,7 +129,10 @@ baseline. archive whose file checksums are not fully readable by that target. The trusted signing job then stages one complete current channel, writes `channel.json`, runs `pkg repo` with the private key, and copies those exact - signed bytes to the immutable snapshot publication path. + signed bytes to the immutable snapshot publication path. Immutable package + and source release tags contain the full BIND compatibility fingerprint, so + a BIND-only or package-creator change receives a new identity even when the + plugin package version is unchanged. 5. It verifies that the generated catalogue, public key, manifest checksums, and package dependency graph exactly match the intended set. 6. A final distribution job writes the staged assets to diff --git a/docs/package-repository.md b/docs/package-repository.md index d4660a9a42..7e213d5368 100644 --- a/docs/package-repository.md +++ b/docs/package-repository.md @@ -188,11 +188,15 @@ cp /conf/config.xml "/conf/config.xml.os-bind-rp.$(date +%Y%m%d%H%M%S).bak" ``` Configure `resolver-plugins-rollback` with the same key and the exact snapshot -URL, for example `pkg-26.7-os-bind-rp-26.7_1`. Dry-run and then install the -only plugin package exposed by that snapshot: +URL. Snapshot tags include the plugin version and full BIND compatibility +fingerprint, for example +`pkg-26.7-os-bind-rp-26.7_5-bind-<64-character-fingerprint>`. Copy the complete +tag from the `resolver-plugins/repository` GitHub Releases page; the fingerprint +also appears in that snapshot's `bind920-provenance.json`. Then dry-run and +install the only plugin package exposed by that snapshot: ```sh -snapshot="pkg-26.7-os-bind-rp-26.7_1" +snapshot="pkg-26.7-os-bind-rp-26.7_5-bind-<64-character-fingerprint>" cat > /usr/local/etc/pkg/repos/resolver-plugins-rollback.conf <