From 500660c1655c16acee3b601d5c31dce804f2006f Mon Sep 17 00:00:00 2001 From: Bryan Date: Fri, 28 Aug 2026 14:42:23 +0000 Subject: [PATCH 1/2] ci: discover the active release series --- .../ci-tests/test_package_release_workflow.py | 15 +++++++++----- .github/workflows/package-release.yml | 20 ++++++++++++++----- docs/building.md | 6 +++--- docs/package-channel-distribution-design.md | 13 ++++++------ docs/package-repository.md | 20 +++++++++---------- 5 files changed, 45 insertions(+), 29 deletions(-) diff --git a/.github/ci/ci-tests/test_package_release_workflow.py b/.github/ci/ci-tests/test_package_release_workflow.py index 2cb6f685bf..fd204f5581 100644 --- a/.github/ci/ci-tests/test_package_release_workflow.py +++ b/.github/ci/ci-tests/test_package_release_workflow.py @@ -30,7 +30,7 @@ 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(): +def test_package_affecting_master_pushes_publish_the_newest_release_series(): workflow = workflow_text() assert 'push:\n branches: [master]' in workflow @@ -43,9 +43,14 @@ def test_package_affecting_master_pushes_publish_the_active_series(): "'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 + assert ' group: package-release\n' in workflow + select = workflow.split(' select:', 1)[1].split(' profile:', 1)[0] + assert 'EVENT_NAME: ${{ github.event_name }}' in select + assert 'git/matching-refs/heads/release/bind-rp/' in select + assert "sed -nE 's#^refs/heads/release/bind-rp/([0-9]+\\.[0-9]+)$#\\1#p'" in select + assert 'sort -V' in select + assert 'mode=production' in select + assert "'26.7'" not in select def test_production_runs_only_from_the_master_control_plane(): @@ -61,7 +66,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 || '26.7' }}" in workflow + assert ' group: package-release\n' in workflow assert 'cancel-in-progress: false' in workflow diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index 5587f24b1c..ce3e155f67 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -35,7 +35,7 @@ permissions: pull-requests: read concurrency: - group: package-release-${{ inputs.series || '26.7' }} + group: package-release cancel-in-progress: false jobs: @@ -70,14 +70,24 @@ jobs: env: GITHUB_REF: ${{ github.ref }} GITHUB_SHA: ${{ github.sha }} - INPUT_MODE: ${{ github.event_name == 'push' && 'production' || inputs.mode }} - INPUT_SERIES: ${{ github.event_name == 'push' && '26.7' || inputs.series }} + EVENT_NAME: ${{ github.event_name }} + INPUT_MODE: ${{ inputs.mode }} + INPUT_SERIES: ${{ inputs.series }} INPUT_PULL_NUMBER: ${{ inputs.pull_number }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - mode="$INPUT_MODE" - series="$INPUT_SERIES" + if [[ "$EVENT_NAME" == push ]]; then + [[ "$GITHUB_REF" == refs/heads/master ]] + mode=production + series=$(gh api --paginate "repos/$GITHUB_REPOSITORY/git/matching-refs/heads/release/bind-rp/" --jq '.[].ref' | + sed -nE 's#^refs/heads/release/bind-rp/([0-9]+\.[0-9]+)$#\1#p' | + sort -V | + tail -n 1) + else + mode="$INPUT_MODE" + series="$INPUT_SERIES" + fi [[ "$mode" == development || "$mode" == production ]] [[ "$series" =~ ^[0-9]+\.[0-9]+$ ]] if [[ "$mode" == development ]]; then diff --git a/docs/building.md b/docs/building.md index a75f08e747..62b663b37c 100644 --- a/docs/building.md +++ b/docs/building.md @@ -41,9 +41,9 @@ scripts. In particular, the release `Mk` files prevent a development-branch marker from adding an unintended `-devel` package suffix. 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. +newest numeric `release/bind-rp/` branch. 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 9686dcf7c1..1c8409cde7 100644 --- a/docs/package-channel-distribution-design.md +++ b/docs/package-channel-distribution-design.md @@ -114,9 +114,10 @@ baseline. 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 + the newest numeric `release/bind-rp/` branch. 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 @@ -164,9 +165,9 @@ existing channel is malformed, package checksums differ unexpectedly, source provenance is invalid, a dependency is unavailable, or the generated catalogue is incomplete. -Production runs are serialized per series. Before mutation, the publisher -checks that the remote assets still match the locally preserved recovery -snapshot; after upload, it downloads every asset and verifies its checksum. +Production runs share one global lock. Before mutation, the publisher checks +that the remote assets still match the locally preserved recovery snapshot; +after upload, it downloads every asset and verifies its checksum. No rollback snapshot is removed until it falls outside the newest-five set and the current/snapshot publication has succeeded. diff --git a/docs/package-repository.md b/docs/package-repository.md index 7e213d5368..9129c17230 100644 --- a/docs/package-repository.md +++ b/docs/package-repository.md @@ -238,15 +238,15 @@ The `Publish os-bind-rp package release` workflow builds from the selected from the current distribution channel or builds the pinned pair on a verified cache miss. The plugin is built against that exact pair. -Package-affecting pushes to `master` automatically publish the active `26.7` -series after review and merge. The trigger covers the BIND plugin, release -helpers and workflow, control-plane metadata, package framework, and committed -repository public key. `workflow_dispatch` remains available for development -builds and explicit production rebuilds or series selection; a production +Package-affecting pushes to `master` automatically publish the newest numeric +`release/bind-rp/` branch after review and merge. The trigger covers +the BIND plugin, release helpers and workflow, control-plane metadata, package +framework, and committed repository public key. `workflow_dispatch` remains +available for development builds and explicit production rebuilds or series selection; a production dispatch from any ref other than `master` is rejected. Release branches supply -immutable build inputs only and never run publication helpers. Runs are -serialized per series so two promotions cannot replace or restore the same -current channel concurrently. +immutable build inputs only and never run publication helpers. Publication +runs share one global lock so automatic and manual promotions cannot replace +or restore the same current channel concurrently. The source repository must define this Actions variable and these Actions secrets before production: @@ -254,8 +254,8 @@ secrets before production: The `Propose bind920 candidate` workflow may open PRs that update only the pinned BIND profile. Those PRs provide review evidence and CI status and do not alter a stable package channel while open. Merging a package-affecting candidate -to `master` starts the automatic `26.7` production run; maintainers can still -dispatch another series explicitly. +to `master` starts production for the newest numeric release branch; +maintainers can still dispatch another series explicitly. The `RP_PKG_SIGNING_KEY` GitHub Actions secret contains the base64-encoded private key. It is decoded only in the disposable FreeBSD VM, used by `pkg From 6c57efc8c8c7c8d590f037751f30148fe5e81029 Mon Sep 17 00:00:00 2001 From: Bryan Date: Fri, 28 Aug 2026 15:01:07 +0000 Subject: [PATCH 2/2] ci: order BIND-only channel promotions --- .../ci-tests/test_package_release_workflow.py | 1 + .../ci-tests/test_release_channel_archive.py | 209 +++++++++++++++++- .github/ci/release_channel.py | 116 +++++++--- .github/workflows/package-release.yml | 2 +- docs/package-channel-distribution-design.md | 19 +- docs/package-repository.md | 9 +- 6 files changed, 306 insertions(+), 50 deletions(-) diff --git a/.github/ci/ci-tests/test_package_release_workflow.py b/.github/ci/ci-tests/test_package_release_workflow.py index fd204f5581..4b2ec70fdb 100644 --- a/.github/ci/ci-tests/test_package_release_workflow.py +++ b/.github/ci/ci-tests/test_package_release_workflow.py @@ -140,6 +140,7 @@ def test_signer_uses_master_control_plane_and_self_contained_channel_layout(): assert 'repository/current' in signer assert 'repository/snapshot' in signer assert signer.count('stage-channel') == 1 + assert "--control-commit '${{ needs.profile.outputs.control_commit }}'" in signer assert 'cp -R "$output/repository/current" "$output/repository/snapshot"' in signer assert 'cmp -s docs/package-repository/resolver-plugins.pub "$output/resolver-plugins.pub"' in signer assert 'trusted-upstream.json' in signer diff --git a/.github/ci/ci-tests/test_release_channel_archive.py b/.github/ci/ci-tests/test_release_channel_archive.py index 116e523f0e..1521aac0f4 100644 --- a/.github/ci/ci-tests/test_release_channel_archive.py +++ b/.github/ci/ci-tests/test_release_channel_archive.py @@ -118,6 +118,27 @@ def fake_run(command: list[str], **_: object) -> subprocess.CompletedProcess[str calls, ) + def test_release_asset_download_retries_after_removing_partial_bytes(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + directory = Path(temporary_directory) + downloaded = directory / "meta.conf" + calls: list[list[str]] = [] + + def fake_run_gh(arguments: list[str]) -> None: + calls.append(arguments) + if len(calls) == 1: + downloaded.write_bytes(b"partial") + raise subprocess.CalledProcessError(1, ["gh", *arguments]) + downloaded.write_bytes(b"complete") + + with patch.object(release_channel, "run_gh", side_effect=fake_run_gh): + release_channel.download_release_asset( + "resolver-plugins/repository", "pkg-26.7", "meta.conf", directory + ) + + self.assertEqual(b"complete", downloaded.read_bytes()) + self.assertEqual(2, len(calls)) + class PullRequestReleaseCleanupTest(unittest.TestCase): def test_pull_request_release_selection_rejects_near_matches(self) -> None: @@ -363,7 +384,12 @@ def fake_repo(command: list[str], **_: object) -> subprocess.CompletedProcess[st patch.object(release_channel.subprocess, "run", side_effect=fake_repo), ): assets = release_channel.stage_channel_repository( - packages, root / "channel", key, "pkg", target_metadata + packages, + root / "channel", + key, + "pkg", + target_metadata, + "c" * 40, ) names = {asset.name for asset in assets} @@ -380,7 +406,8 @@ def fake_repo(command: list[str], **_: object) -> subprocess.CompletedProcess[st names, ) manifest = json.loads((root / "channel/channel.json").read_text(encoding="utf-8")) - self.assertEqual(3, manifest["schema"]) + self.assertEqual(4, manifest["schema"]) + self.assertEqual("c" * 40, manifest["control_commit"]) self.assertEqual("26.7", manifest["series"]) self.assertEqual("FreeBSD:15:amd64", manifest["package_abi"]) self.assertEqual("26.7_1", manifest["plugin_version"]) @@ -397,14 +424,21 @@ def fake_repo(command: list[str], **_: object) -> subprocess.CompletedProcess[st (root / "channel/packagesite.pkg").touch() release_channel.validate_channel_directory(root / "channel") - legacy_v2_manifest = dict(manifest, schema=2) + legacy_v3_manifest = dict(manifest, schema=3) + legacy_v3_manifest.pop("control_commit") + (root / "channel/channel.json").write_text( + json.dumps(legacy_v3_manifest), encoding="utf-8" + ) + release_channel.validate_channel_directory(root / "channel") + + legacy_v2_manifest = dict(legacy_v3_manifest, schema=2) legacy_v2_manifest.pop("package_abi") (root / "channel/channel.json").write_text( json.dumps(legacy_v2_manifest), encoding="utf-8" ) release_channel.validate_channel_directory(root / "channel") - legacy_manifest = dict(manifest, schema=1) + legacy_manifest = dict(legacy_v3_manifest, schema=1) legacy_manifest.pop("package_creator") legacy_manifest.pop("package_abi") (root / "channel/channel.json").write_text( @@ -503,7 +537,12 @@ def test_staging_rejects_a_common_abi_that_differs_from_the_trusted_target(self) ), patch.object(release_channel, "stage_selected_repository"): with self.assertRaisesRegex(ValueError, "trusted target package profile"): release_channel.stage_channel_repository( - packages, root / "channel", key, "pkg", target_metadata + packages, + root / "channel", + key, + "pkg", + target_metadata, + "c" * 40, ) def test_asset_order_puts_repository_metadata_after_packages(self) -> None: @@ -1291,6 +1330,166 @@ def test_retry_only_updates_titles_when_snapshot_and_current_are_identical(self) mutations, ) + def test_schema_four_migration_allows_new_control_for_the_same_source(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + current = root / "current" + staged = root / "staged" + current.mkdir() + staged.mkdir() + source_commit = "a" * 40 + (current / "channel.json").write_text( + json.dumps({"schema": 3, "source_commit": source_commit}), encoding="utf-8" + ) + (staged / "channel.json").write_text( + json.dumps( + { + "schema": 4, + "source_commit": source_commit, + "control_commit": "c" * 40, + } + ), + encoding="utf-8", + ) + + with patch.object(release_channel.subprocess, "run") as run: + self.assertTrue( + release_channel.staged_source_descends_from_current(current, staged) + ) + + run.assert_not_called() + + def test_same_source_uses_control_commit_ancestry(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + current = root / "current" + staged = root / "staged" + current.mkdir() + staged.mkdir() + source_commit = "a" * 40 + current_control = "b" * 40 + staged_control = "c" * 40 + for directory, control_commit in ( + (current, current_control), + (staged, staged_control), + ): + (directory / "channel.json").write_text( + json.dumps( + { + "schema": 4, + "source_commit": source_commit, + "control_commit": control_commit, + } + ), + encoding="utf-8", + ) + + result = subprocess.CompletedProcess(["git"], 0) + with patch.object(release_channel.subprocess, "run", return_value=result) as run: + self.assertTrue( + release_channel.staged_source_descends_from_current(current, staged) + ) + + run.assert_called_once_with( + ["git", "merge-base", "--is-ancestor", current_control, staged_control], + capture_output=True, + text=True, + ) + + def test_identical_source_and_control_cannot_replace_different_bytes(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + current = root / "current" + staged = root / "staged" + current.mkdir() + staged.mkdir() + channel = { + "schema": 4, + "source_commit": "a" * 40, + "control_commit": "b" * 40, + } + for directory in (current, staged): + (directory / "channel.json").write_text( + json.dumps(channel), encoding="utf-8" + ) + + with patch.object(release_channel.subprocess, "run") as run: + self.assertFalse( + release_channel.staged_source_descends_from_current(current, staged) + ) + + run.assert_not_called() + + def test_source_advance_cannot_hide_a_control_rollback(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + current = root / "current" + staged = root / "staged" + current.mkdir() + staged.mkdir() + current_source = "a" * 40 + staged_source = "b" * 40 + current_control = "d" * 40 + staged_control = "c" * 40 + for directory, source_commit, control_commit in ( + (current, current_source, current_control), + (staged, staged_source, staged_control), + ): + (directory / "channel.json").write_text( + json.dumps( + { + "schema": 4, + "source_commit": source_commit, + "control_commit": control_commit, + } + ), + encoding="utf-8", + ) + + results = [ + subprocess.CompletedProcess(["git"], 0), + subprocess.CompletedProcess(["git"], 1), + ] + with patch.object(release_channel.subprocess, "run", side_effect=results) as run: + self.assertFalse( + release_channel.staged_source_descends_from_current(current, staged) + ) + + self.assertEqual( + [ + ["git", "merge-base", "--is-ancestor", current_source, staged_source], + ["git", "merge-base", "--is-ancestor", current_control, staged_control], + ], + [call.args[0] for call in run.call_args_list], + ) + + def test_legacy_staged_channel_cannot_replace_schema_four_current(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + current = root / "current" + staged = root / "staged" + current.mkdir() + staged.mkdir() + current_source = "a" * 40 + staged_source = "b" * 40 + (current / "channel.json").write_text( + json.dumps( + { + "schema": 4, + "source_commit": current_source, + "control_commit": "c" * 40, + } + ), + encoding="utf-8", + ) + (staged / "channel.json").write_text( + json.dumps({"schema": 3, "source_commit": staged_source}), encoding="utf-8" + ) + + self.assertFalse( + release_channel.staged_source_descends_from_current(current, staged) + ) + def test_absent_snapshot_cannot_let_a_stale_run_replace_current(self) -> None: """A pruned or recovered snapshot must not let an old run downgrade current.""" with tempfile.TemporaryDirectory() as temporary_directory: diff --git a/.github/ci/release_channel.py b/.github/ci/release_channel.py index 675a23188e..2363e7da2d 100644 --- a/.github/ci/release_channel.py +++ b/.github/ci/release_channel.py @@ -30,6 +30,7 @@ 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}") +COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}") 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 = { @@ -381,8 +382,11 @@ def stage_channel_repository( private_key: Path, pkg_command: str, target_pkg_metadata: Path, + control_commit: str, ) -> list[Path]: """Create one signed, self-contained current or rollback channel.""" + if COMMIT_PATTERN.fullmatch(control_commit) is None: + raise ValueError("invalid control commit") provenance = packages_directory / PROVENANCE_NAME if not provenance.is_file(): raise ValueError("BIND provenance does not exist") @@ -421,7 +425,8 @@ def stage_channel_repository( packages, output, private_key, pkg_command, [provenance, build_metadata] ) channel_manifest = { - "schema": 3, + "schema": 4, + "control_commit": control_commit, "series": metadata["series"], "package_abi": package_abi, "plugin_version": packages[2].name.removeprefix("os-bind-rp-").removesuffix(".pkg"), @@ -461,13 +466,20 @@ def run_gh(arguments: list[str]) -> None: raise -def upload_release_assets(repository: str, tag: str, assets: list[Path]) -> None: - """Upload release assets one at a time to avoid partial multi-asset CLI failures.""" - for asset in assets: - run_gh([ - "release", "upload", tag, str(asset), - "--clobber", "--repo", repository, - ]) +def download_release_asset(repository: str, tag: str, name: str, directory: Path) -> None: + """Download one Release asset, discarding partial bytes before a retry.""" + downloaded = directory / name + for attempt in range(1, GH_ATTEMPTS + 1): + try: + run_gh([ + "release", "download", tag, "--repo", repository, "--pattern", name, + "--dir", str(directory), + ]) + return + except subprocess.CalledProcessError: + downloaded.unlink(missing_ok=True) + if attempt == GH_ATTEMPTS: + raise def upload_release_assets(repository: str, tag: str, assets: list[Path]) -> None: @@ -664,8 +676,12 @@ def validate_channel_directory(directory: Path) -> None: required_channel_fields = base_channel_fields elif schema == 2: required_channel_fields = base_channel_fields | {"package_creator"} - else: + elif schema == 3: required_channel_fields = base_channel_fields | {"package_creator", "package_abi"} + else: + required_channel_fields = base_channel_fields | { + "control_commit", "package_creator", "package_abi", + } required_build_fields = {"upstream_commit", "core_commit", "tools_tag", "freebsd_release"} expected_bind = { field: provenance.get(field) @@ -684,7 +700,14 @@ def validate_channel_directory(directory: Path) -> None: raise ValueError("prior channel audit metadata is inconsistent") from error if ( set(channel) != required_channel_fields - or schema not in {1, 2, 3} + or schema not in {1, 2, 3, 4} + or ( + schema == 4 + and ( + not isinstance(channel.get("control_commit"), str) + or COMMIT_PATTERN.fullmatch(channel["control_commit"]) is None + ) + ) or channel["series"] != metadata["series"] or channel["plugin_version"] != packages[2].name.removeprefix("os-bind-rp-").removesuffix(".pkg") @@ -695,11 +718,11 @@ def validate_channel_directory(directory: Path) -> None: or channel["bind"] != expected_bind or any(value is None for value in expected_bind.values()) or ( - schema in {2, 3} + schema in {2, 3, 4} and ( channel.get("package_creator") != provenance.get("package_creator") or ( - schema == 3 + schema in {3, 4} and package_abi != channel.get("package_creator", {}).get("abi") ) or metadata.get("pkg_creator") @@ -806,36 +829,60 @@ def snapshot_matches_directory(snapshot: ReleaseSnapshot, directory: Path) -> bo return checksums == directory_checksums(directory) +def commit_is_ancestor_or_equal(current_commit: str, staged_commit: str) -> bool: + """Return whether one validated commit is equal to or precedes another.""" + if current_commit == staged_commit: + return True + result = subprocess.run( + ["git", "merge-base", "--is-ancestor", current_commit, staged_commit], + capture_output=True, + text=True, + ) + if result.returncode not in (0, 1): + raise RuntimeError( + result.stderr.strip() or "cannot compare package channel source history" + ) + return result.returncode == 0 + + def staged_source_descends_from_current(current: Path, staged: Path) -> bool: - """Return whether staged source is a strict descendant of current source.""" + """Return whether staged source and control lineages are non-decreasing.""" try: - current_commit = json.loads( + current_channel = json.loads( (current / "channel.json").read_text(encoding="utf-8") - )["source_commit"] - staged_commit = json.loads( + ) + staged_channel = json.loads( (staged / "channel.json").read_text(encoding="utf-8") - )["source_commit"] + ) + current_commit = current_channel["source_commit"] + staged_commit = staged_channel["source_commit"] except (OSError, json.JSONDecodeError, KeyError, TypeError) as error: raise RuntimeError("cannot compare package channel source history") from error - commit_pattern = re.compile(r"[0-9a-f]{40}") if ( not isinstance(current_commit, str) or not isinstance(staged_commit, str) - or commit_pattern.fullmatch(current_commit) is None - or commit_pattern.fullmatch(staged_commit) is None - or current_commit == staged_commit + or COMMIT_PATTERN.fullmatch(current_commit) is None + or COMMIT_PATTERN.fullmatch(staged_commit) is None ): return False - result = subprocess.run( - ["git", "merge-base", "--is-ancestor", current_commit, staged_commit], - capture_output=True, - text=True, - ) - if result.returncode not in (0, 1): - raise RuntimeError( - result.stderr.strip() or "cannot compare package channel source history" - ) - return result.returncode == 0 + if staged_channel.get("schema") != 4: + return False + staged_control = staged_channel.get("control_commit") + if not isinstance(staged_control, str) or COMMIT_PATTERN.fullmatch(staged_control) is None: + return False + if not commit_is_ancestor_or_equal(current_commit, staged_commit): + return False + if current_channel.get("schema") in {1, 2, 3}: + return True + current_control = current_channel.get("control_commit") + if ( + current_channel.get("schema") != 4 + or not isinstance(current_control, str) + or COMMIT_PATTERN.fullmatch(current_control) is None + or not commit_is_ancestor_or_equal(current_control, staged_control) + ): + return False + return current_commit != staged_commit or current_control != staged_control def snapshot_release(repository: str, tag: str, recovery: Path) -> ReleaseSnapshot: @@ -862,10 +909,7 @@ def snapshot_release(repository: str, tag: str, recovery: Path) -> ReleaseSnapsh checksums = {} for asset in assets: name = asset["name"] - run_gh([ - "release", "download", tag, "--repo", repository, "--pattern", name, - "--dir", str(directory), - ]) + download_release_asset(repository, tag, name, directory) downloaded = directory / name if not downloaded.is_file(): raise RuntimeError(f"cannot preserve GitHub Release asset {tag}/{name}") @@ -1252,6 +1296,7 @@ def main() -> None: stage_channel.add_argument("--private-key", type=Path, required=True) stage_channel.add_argument("--pkg-command", default="pkg") stage_channel.add_argument("--target-pkg-metadata", type=Path, required=True) + stage_channel.add_argument("--control-commit", required=True) reuse_snapshot = commands.add_parser("reuse-snapshot") reuse_snapshot.add_argument("--repository", required=True) reuse_snapshot.add_argument("--series", required=True) @@ -1343,6 +1388,7 @@ def main() -> None: arguments.private_key, arguments.pkg_command, arguments.target_pkg_metadata, + arguments.control_commit, ): print(asset) elif arguments.command == "reuse-snapshot": diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index ce3e155f67..e278f23cf3 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -435,7 +435,7 @@ jobs: printf '%s' "$RP_PKG_SIGNING_KEY" | base64 -d > "$key" output="artifacts/${{ needs.select.outputs.series }}" 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 + 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 --control-commit '${{ needs.profile.outputs.control_commit }}' cp -R "$output/repository/current" "$output/repository/snapshot" openssl pkey -in "$key" -pubout -out "$output/resolver-plugins.pub" cmp -s docs/package-repository/resolver-plugins.pub "$output/resolver-plugins.pub" || { diff --git a/docs/package-channel-distribution-design.md b/docs/package-channel-distribution-design.md index 1c8409cde7..d409dc1046 100644 --- a/docs/package-channel-distribution-design.md +++ b/docs/package-channel-distribution-design.md @@ -133,7 +133,11 @@ baseline. 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. + plugin package version is unchanged. Schema 4 channels also record the + trusted `master` control commit. When the release-source commit is unchanged, + promotion requires the staged control commit to descend from the current + one; this permits BIND-only updates without allowing stale workflow retries + to roll the channel back. 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 @@ -162,8 +166,8 @@ configuration changes to propagate to the active peer during the canary. The publisher fails before changing the distribution repository if the existing channel is malformed, package checksums differ unexpectedly, source -provenance is invalid, a dependency is unavailable, or the generated -catalogue is incomplete. +or control provenance is invalid, a dependency is unavailable, or the +generated catalogue is incomplete. Production runs share one global lock. Before mutation, the publisher checks that the remote assets still match the locally preserved recovery snapshot; @@ -175,9 +179,12 @@ An existing immutable snapshot may be reused by a full workflow retry only when its complete asset set is byte-identical to the staged snapshot. If the current channel exists, it must also be byte-identical; a mismatch is a hard failure so an older retry cannot roll current back. -If the snapshot is absent and current differs, the staged source commit must -be a strict descendant of the source recorded by current. This preserves -forward promotion while rejecting stale runs after recovery or pruning. +If the snapshot is absent and current differs, the staged source and control +commits must each be equal to or descend from the commits recorded by current, +and at least one lineage must advance. The one-time migration from a legacy +channel without control lineage to schema 4 permits an equal source commit. +This preserves BIND-only and source promotions while rejecting stale runs +after recovery or pruning. ## Migration diff --git a/docs/package-repository.md b/docs/package-repository.md index 9129c17230..b3570cf032 100644 --- a/docs/package-repository.md +++ b/docs/package-repository.md @@ -307,9 +307,12 @@ plugin version, release-source commit, and the committed public key, then uses those exact bytes for both staged paths. An existing current channel must also be byte-identical; different bytes fail before any channel is changed, preventing an older retry from moving current backward. -When the target snapshot is absent and current differs, the staged release -source must be a strict descendant of current's recorded source commit. This -allows a new promotion while rejecting stale runs even after snapshot pruning. +When the target snapshot is absent and current differs, the staged source and +control commits must each be equal to or descend from current, and at least one +lineage must advance. The one-time migration from a legacy channel without a +control commit to schema 4 permits an equal source commit. This allows +BIND-only and source promotions while rejecting stale runs even after snapshot +pruning. After promotion, a fresh FreeBSD VM configures the pinned OPNsense repository, installs its matching core package, and runs `scripts/install-os-bind-rp.sh`