From 713abe863a5ca4f8ced011045b79ab359847c0e3 Mon Sep 17 00:00:00 2001 From: Bryan Date: Wed, 23 Sep 2026 17:19:05 +0000 Subject: [PATCH 1/2] ci: refresh 26.7 target pkg archive pin --- .resolver-plugins/target-pkg.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.resolver-plugins/target-pkg.json b/.resolver-plugins/target-pkg.json index c5cf30cc96..c7d4b94022 100644 --- a/.resolver-plugins/target-pkg.json +++ b/.resolver-plugins/target-pkg.json @@ -16,7 +16,7 @@ "origin": "ports-mgmt/pkg", "abi": "FreeBSD:15:amd64", "filename": "pkg-2.3.1_1.pkg", - "sha256": "11f4eeb2e93b3bf621d5e9a5dbe42d5b748e2462168571a4fa93799d8e18cbb7", + "sha256": "20148990850075a716feb0b4c5fc9a603ebc7f93d30b238bac428c856c4f5fad", "pkg_static_sha256": "406774d610e27f83cbfdb30a6d711e9d4deff25cc0e67c8f08c6294649b91a36" } } From fb5a2884ea842e5ea5a181e814a6fdb6c9b55814 Mon Sep 17 00:00:00 2001 From: Bryan Date: Wed, 23 Sep 2026 17:41:26 +0000 Subject: [PATCH 2/2] ci: propose safe recovery from target pkg repacks --- .../test_bind_pull_request_workflow.py | 1 + .../ci-tests/test_package_release_workflow.py | 36 ++- .github/ci/ci-tests/test_target_pkg.py | 205 +++++++++++++++++- .github/ci/target_pkg.py | 165 ++++++++++++-- .github/workflows/bind-tests.yml | 3 +- .github/workflows/package-release.yml | 122 +++++++++++ .resolver-plugins/target-pkg-content.json | 13 ++ docs/building.md | 18 ++ 8 files changed, 543 insertions(+), 20 deletions(-) create mode 100644 .resolver-plugins/target-pkg-content.json 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 4a362c2c66..4591a14666 100644 --- a/.github/ci/ci-tests/test_bind_pull_request_workflow.py +++ b/.github/ci/ci-tests/test_bind_pull_request_workflow.py @@ -25,6 +25,7 @@ def test_workflow_runs_only_for_relevant_pull_request_changes(): assert "- 'dns/bind/**'" in workflow assert "- '.github/ci/**'" in workflow assert "- '.resolver-plugins/target-pkg.json'" in workflow + assert "- '.resolver-plugins/target-pkg-content.json'" in workflow assert "- '.github/workflows/bind-tests.yml'" in workflow diff --git a/.github/ci/ci-tests/test_package_release_workflow.py b/.github/ci/ci-tests/test_package_release_workflow.py index 5475c65fbb..2e50797158 100644 --- a/.github/ci/ci-tests/test_package_release_workflow.py +++ b/.github/ci/ci-tests/test_package_release_workflow.py @@ -68,6 +68,20 @@ def test_package_affecting_master_pushes_publish_the_newest_release_series(): assert "'26.7'" not in select +def test_merging_a_repack_recovery_rebuilds_its_exact_series(): + select = workflow_text().split(' select:', 1)[1].split(' profile:', 1)[0] + + assert 'ref: ${{ github.workflow_sha }}' in select + assert 'fetch-depth: 0' in select + assert 'persist-credentials: false' in select + assert 'BEFORE_SHA: ${{ github.event.before }}' in select + assert 'changed=$(git diff --name-only "$BEFORE_SHA" "$GITHUB_SHA")' in select + assert '[ "$changed" = .resolver-plugins/target-pkg.json ]' in select + assert 'git show "$BEFORE_SHA:.resolver-plugins/target-pkg.json"' in select + assert 'target_pkg.py changed-series "$before" "$after"' in select + assert 'series=$recovered_series' in select + + def test_production_runs_only_from_the_master_control_plane(): workflow = workflow_text() select = workflow.split(' select:', 1)[1].split(' profile:', 1)[0] @@ -112,12 +126,32 @@ def test_workflow_materializes_the_distribution_bind_pair_before_building_the_pl ) +def test_failed_production_bind_job_can_only_propose_a_content_identical_pkg_repack(): + workflow = workflow_text() + recovery = workflow.split(' recover-target-pkg:', 1)[1].split(' build:', 1)[0] + validator = recovery.split(' propose-target-pkg:', 1)[0] + proposer = recovery.split(' propose-target-pkg:', 1)[1] + + assert "needs.bind.result == 'failure'" in validator + assert "needs.select.outputs.mode == 'production'" in validator + assert 'contents: read' in validator + assert 'contents: write' not in validator + assert 'persist-credentials: false' in validator + assert '.resolver-plugins/target-pkg-content.json' in validator + assert 'target_pkg.py refresh' in validator + assert "needs.recover-target-pkg.result == 'success'" in proposer + assert 'contents: write\n pull-requests: write' in proposer + assert '[ "$changed" = .resolver-plugins/target-pkg.json ]' in proposer + assert 'gh pr create' in proposer + assert 'gh pr merge' not in recovery + + def test_workflow_uses_sha_pinned_actions_and_nonpersistent_checkout_credentials(): workflow = workflow_text() references = action_references(workflow) assert references assert all(PINNED_ACTION.fullmatch(reference) for reference in references) - assert workflow.count('persist-credentials: false') == 10 + assert workflow.count('persist-credentials: false') == 12 assert 'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' in references assert 'vmactions/freebsd-vm@77ed28d336d03fe19a3f4f7266c1d2c4714dd79d' in references diff --git a/.github/ci/ci-tests/test_target_pkg.py b/.github/ci/ci-tests/test_target_pkg.py index b59cb3c169..bbb59a8ef1 100644 --- a/.github/ci/ci-tests/test_target_pkg.py +++ b/.github/ci/ci-tests/test_target_pkg.py @@ -5,7 +5,10 @@ import hashlib import importlib.util import json +import shutil +import subprocess import sys +import tarfile import tempfile from contextlib import contextmanager from collections.abc import Iterator @@ -47,20 +50,62 @@ def write_metadata(path: Path, archive: Path, pkg_static: Path) -> None: ) +def write_content_metadata(path: Path, archive: Path) -> None: + digest = target_pkg.package_content_sha256(archive) + record = { + "baseline_archive_sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + "content_sha256": digest, + } + path.write_text( + json.dumps({"schema": 2, "series": {"26.1": record, "26.7": record}}), + encoding="utf-8", + ) + + +def write_archive( + path: Path, + pkg_static: Path, + payload: bytes = b"payload\n", + *, + payload_mode: int = 0o644, + link_target: str = "payload", + hardlink_payload: bool = True, +) -> None: + content = path.parent / "archive-content" + if content.exists(): + shutil.rmtree(content) + executable = content / "usr/local/sbin/pkg-static" + executable.parent.mkdir(parents=True, exist_ok=True) + executable.write_bytes(pkg_static.read_bytes()) + executable.chmod(0o755) + payload_path = content / "payload" + payload_path.write_bytes(payload) + payload_path.chmod(payload_mode) + hardlink_path = content / "payload-hardlink" + if hardlink_payload: + hardlink_path.hardlink_to(payload_path) + else: + hardlink_path.write_bytes(payload) + hardlink_path.chmod(payload_mode) + (content / "payload-link").symlink_to(link_target) + with tarfile.open(path, "w") as archive: + archive.add(content, arcname="") + + @contextmanager def pkg_fixture() -> Iterator[tuple[Path, Path, Path, Path]]: """Create a stateful pkg boundary with real archive/hash side effects.""" FIXTURE_ROOT.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(dir=FIXTURE_ROOT) as directory_text: directory = Path(directory_text) - archive = directory / "source-pkg-2.3.1_1.pkg" - archive.write_bytes(b"pinned target package archive\n") pkg_static = directory / "pkg-static" pkg_static.write_text( "#!/bin/sh\n[ \"$1\" = -v ] || exit 64\nprintf '%s\\n' '2.3.1'\n", encoding="utf-8", ) pkg_static.chmod(0o755) + archive = directory / "source-pkg-2.3.1_1.pkg" + write_archive(archive, pkg_static) log = directory / "commands.log" lock = directory / "locked" executable = directory / "pkg" @@ -147,3 +192,159 @@ def test_rejects_unknown_or_malformed_series_metadata(tmp_path: Path) -> None: with pytest.raises(target_pkg.TargetPackageError, match="26.1"): target_pkg.load_target(metadata, "26.1") + + +def test_refreshes_only_the_outer_archive_hash_for_identical_contents(tmp_path: Path) -> None: + with pkg_fixture() as (pkg, archive, pkg_static, _): + metadata = tmp_path / "target-pkg.json" + content_metadata = tmp_path / "target-pkg-content.json" + write_metadata(metadata, archive, pkg_static) + write_content_metadata(content_metadata, archive) + archive.write_bytes(archive.read_bytes() + b"repacked\n") + + digest = target_pkg.refresh_archive_sha256( + metadata, + content_metadata, + "26.1", + str(pkg), + "OPNsense", + metadata, + ) + + assert digest == hashlib.sha256(archive.read_bytes()).hexdigest() + assert target_pkg.load_target(metadata, "26.1").sha256 == digest + + +def test_refresh_rejects_changed_extracted_contents(tmp_path: Path) -> None: + with pkg_fixture() as (pkg, archive, pkg_static, _): + metadata = tmp_path / "target-pkg.json" + content_metadata = tmp_path / "target-pkg-content.json" + write_metadata(metadata, archive, pkg_static) + write_content_metadata(content_metadata, archive) + write_archive(archive, pkg_static, b"changed\n") + + with pytest.raises(target_pkg.TargetPackageError, match="extracted contents"): + target_pkg.refresh_archive_sha256( + metadata, + content_metadata, + "26.1", + str(pkg), + "OPNsense", + metadata, + ) + + +def test_refresh_rejects_changed_file_mode(tmp_path: Path) -> None: + with pkg_fixture() as (pkg, archive, pkg_static, _): + metadata = tmp_path / "target-pkg.json" + content_metadata = tmp_path / "target-pkg-content.json" + write_metadata(metadata, archive, pkg_static) + write_content_metadata(content_metadata, archive) + write_archive(archive, pkg_static, payload_mode=0o755) + + with pytest.raises(target_pkg.TargetPackageError, match="extracted contents"): + target_pkg.refresh_archive_sha256( + metadata, content_metadata, "26.1", str(pkg), "OPNsense", metadata + ) + + +def test_refresh_rejects_changed_symlink_target(tmp_path: Path) -> None: + with pkg_fixture() as (pkg, archive, pkg_static, _): + metadata = tmp_path / "target-pkg.json" + content_metadata = tmp_path / "target-pkg-content.json" + write_metadata(metadata, archive, pkg_static) + write_content_metadata(content_metadata, archive) + write_archive(archive, pkg_static, link_target="usr/local/sbin/pkg-static") + + with pytest.raises(target_pkg.TargetPackageError, match="extracted contents"): + target_pkg.refresh_archive_sha256( + metadata, content_metadata, "26.1", str(pkg), "OPNsense", metadata + ) + + +def test_refresh_rejects_changed_hardlink_relationship(tmp_path: Path) -> None: + with pkg_fixture() as (pkg, archive, pkg_static, _): + metadata = tmp_path / "target-pkg.json" + content_metadata = tmp_path / "target-pkg-content.json" + write_metadata(metadata, archive, pkg_static) + write_content_metadata(content_metadata, archive) + write_archive(archive, pkg_static, hardlink_payload=False) + + with pytest.raises(target_pkg.TargetPackageError, match="extracted contents"): + target_pkg.refresh_archive_sha256( + metadata, content_metadata, "26.1", str(pkg), "OPNsense", metadata + ) + + +def test_refresh_rejects_changed_identity(tmp_path: Path) -> None: + with pkg_fixture() as (pkg, archive, pkg_static, _): + metadata = tmp_path / "target-pkg.json" + content_metadata = tmp_path / "target-pkg-content.json" + write_metadata(metadata, archive, pkg_static) + write_content_metadata(content_metadata, archive) + document = json.loads(metadata.read_text(encoding="utf-8")) + document["series"]["26.1"]["version"] = "2.3.2" + metadata.write_text(json.dumps(document), encoding="utf-8") + + with pytest.raises(target_pkg.TargetPackageError, match="identity"): + target_pkg.refresh_archive_sha256( + metadata, content_metadata, "26.1", str(pkg), "OPNsense", metadata + ) + + +def test_identifies_a_single_archive_only_change(tmp_path: Path) -> None: + with pkg_fixture() as (_, archive, pkg_static, _): + before = tmp_path / "before.json" + after = tmp_path / "after.json" + write_metadata(before, archive, pkg_static) + write_metadata(after, archive, pkg_static) + document = json.loads(after.read_text(encoding="utf-8")) + document["series"]["26.1"]["sha256"] = "0" * 64 + after.write_text(json.dumps(document), encoding="utf-8") + + assert target_pkg.changed_archive_series(before, after) == "26.1" + + document["series"]["26.1"]["version"] = "2.3.2" + after.write_text(json.dumps(document), encoding="utf-8") + assert target_pkg.changed_archive_series(before, after) is None + + +def test_changed_series_command_fails_when_provenance_is_unavailable(tmp_path: Path) -> None: + result = subprocess.run( + [ + sys.executable, + str(MODULE_PATH), + "changed-series", + str(tmp_path / "missing-before.json"), + str(tmp_path / "missing-after.json"), + ], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "target pkg selection failed" in result.stderr + + +def test_content_hash_rejects_archive_path_traversal(tmp_path: Path) -> None: + archive_path = tmp_path / "traversal.pkg" + with tarfile.open(archive_path, "w") as archive: + entry = tarfile.TarInfo("../escape") + entry.size = 0 + archive.addfile(entry) + + with pytest.raises(subprocess.CalledProcessError): + target_pkg.package_content_sha256(archive_path) + assert not (tmp_path / "escape").exists() + + +def test_content_hash_rejects_special_entries(tmp_path: Path) -> None: + archive_path = tmp_path / "special.pkg" + with tarfile.open(archive_path, "w") as archive: + entry = tarfile.TarInfo("named-pipe") + entry.type = tarfile.FIFOTYPE + archive.addfile(entry) + + with pytest.raises(target_pkg.TargetPackageError, match="unsupported entry"): + target_pkg.package_content_sha256(archive_path) diff --git a/.github/ci/target_pkg.py b/.github/ci/target_pkg.py index 18df26a005..eed00053b6 100644 --- a/.github/ci/target_pkg.py +++ b/.github/ci/target_pkg.py @@ -6,7 +6,9 @@ import argparse import hashlib import json +import os import re +import stat import subprocess import sys import tempfile @@ -67,6 +69,82 @@ def sha256(path: Path) -> str: return digest.hexdigest() +def package_content_sha256(archive: Path) -> str: + """Hash the installation-relevant state extracted from a package archive.""" + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + run(["tar", "-xf", str(archive), "-C", str(root)]) + digest = hashlib.sha256() + hardlinks: dict[tuple[int, int], int] = {} + for path in sorted(root.rglob("*"), key=lambda item: item.relative_to(root).as_posix()): + relative = path.relative_to(root).as_posix().encode() + attributes = path.lstat() + hardlink = 0 + if path.is_symlink(): + kind = b"link" + content = os.readlink(path).encode() + elif path.is_file(): + kind = b"file" + content = path.read_bytes() + if attributes.st_nlink > 1: + inode = (attributes.st_dev, attributes.st_ino) + hardlink = hardlinks.setdefault(inode, len(hardlinks) + 1) + elif path.is_dir(): + kind = b"directory" + content = b"" + else: + raise TargetPackageError(f"target pkg contains unsupported entry {relative.decode()}") + metadata = ( + f"{stat.S_IMODE(attributes.st_mode):o}|{attributes.st_uid}|" + f"{attributes.st_gid}|{getattr(attributes, 'st_flags', 0)}|{hardlink}" + ).encode() + for value in (kind, relative, metadata, content): + digest.update(len(value).to_bytes(8, "big")) + digest.update(value) + return digest.hexdigest() + + +def load_content_sha256(metadata: Path, series: str) -> str: + """Load the canonical extracted-content digest used for safe archive repacks.""" + try: + document = json.loads(metadata.read_text(encoding="utf-8")) + records = document["series"] + record = records[series] + digest = record["content_sha256"] + except (OSError, json.JSONDecodeError, KeyError, TypeError) as error: + raise TargetPackageError(f"target pkg content metadata does not define {series}") from error + if ( + not isinstance(document, dict) + or set(document) != {"schema", "series"} + or document["schema"] != 2 + or not isinstance(records, dict) + or set(records) != {"26.1", "26.7"} + or not isinstance(record, dict) + or set(record) != {"baseline_archive_sha256", "content_sha256"} + or not isinstance(record["baseline_archive_sha256"], str) + or SHA256_PATTERN.fullmatch(record["baseline_archive_sha256"]) is None + or not isinstance(digest, str) + or SHA256_PATTERN.fullmatch(digest) is None + ): + raise TargetPackageError(f"target pkg content metadata is invalid for {series}") + return digest + + +def changed_archive_series(before: Path, after: Path) -> str | None: + """Return the sole series whose outer hash changed without other target changes.""" + changed: list[str] = [] + for series in ("26.1", "26.7"): + previous = load_target(before, series).record() + current = load_target(after, series).record() + previous_sha256 = previous.pop("sha256") + current_sha256 = current.pop("sha256") + if previous != current: + return None + if previous_sha256 != current_sha256: + changed.append(series) + return changed[0] if len(changed) == 1 else None + + def load_target(metadata: Path, series: str) -> TargetPackage: """Load one exact target record from strict immutable metadata.""" try: @@ -126,6 +204,22 @@ def downloaded_archive(directory: Path, filename: str) -> Path: return matches[0] +def fetch_target_pkg(target: TargetPackage, pkg_command: str, repository: str, output: Path) -> Path: + run( + [ + pkg_command, + "fetch", + "-y", + "-r", + repository, + "-o", + str(output), + target.filename.removesuffix(".pkg"), + ] + ) + return downloaded_archive(output, target.filename) + + def verify_target_pkg( target: TargetPackage, pkg_command: str, @@ -160,19 +254,7 @@ def select_target_pkg( target = load_target(metadata, series) with tempfile.TemporaryDirectory() as temporary_directory: downloads = Path(temporary_directory) - run( - [ - pkg_command, - "fetch", - "-y", - "-r", - repository, - "-o", - str(downloads), - target.filename.removesuffix(".pkg"), - ] - ) - archive = downloaded_archive(downloads, target.filename) + archive = fetch_target_pkg(target, pkg_command, repository, downloads) if sha256(archive) != target.sha256: raise TargetPackageError("target pkg archive SHA-256 does not match metadata") archive_identity = query_identity(pkg_command, ["query", "-F", str(archive)]) @@ -184,6 +266,31 @@ def select_target_pkg( return target +def refresh_archive_sha256( + metadata: Path, + content_metadata: Path, + series: str, + pkg_command: str, + repository: str, + output: Path, +) -> str: + """Refresh only an archive digest whose identity and extracted contents are unchanged.""" + target = load_target(metadata, series) + expected_content = load_content_sha256(content_metadata, series) + with tempfile.TemporaryDirectory() as temporary_directory: + downloads = Path(temporary_directory) + archive = fetch_target_pkg(target, pkg_command, repository, downloads) + if query_identity(pkg_command, ["query", "-F", str(archive)]) != target.identity: + raise TargetPackageError("target pkg archive identity does not match metadata") + if package_content_sha256(archive) != expected_content: + raise TargetPackageError("target pkg extracted contents differ from the pinned package") + archive_sha256 = sha256(archive) + document = json.loads(metadata.read_text(encoding="utf-8")) + document["series"][series]["sha256"] = archive_sha256 + output.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8") + return archive_sha256 + + def main() -> int: parser = argparse.ArgumentParser() commands = parser.add_subparsers(dest="command", required=True) @@ -198,10 +305,30 @@ def main() -> int: field.add_argument("metadata", type=Path) field.add_argument("series") field.add_argument("field", choices=sorted(TARGET_FIELDS)) + refresh = commands.add_parser("refresh") + refresh.add_argument("metadata", type=Path) + refresh.add_argument("content_metadata", type=Path) + refresh.add_argument("series") + refresh.add_argument("--pkg-command", default="pkg") + refresh.add_argument("--repository", default="OPNsense") + refresh.add_argument("--output", type=Path, required=True) + changed = commands.add_parser("changed-series") + changed.add_argument("before", type=Path) + changed.add_argument("after", type=Path) arguments = parser.parse_args() try: - target = load_target(arguments.metadata, arguments.series) - if arguments.command == "install": + if arguments.command == "refresh": + print( + refresh_archive_sha256( + arguments.metadata, + arguments.content_metadata, + arguments.series, + arguments.pkg_command, + arguments.repository, + arguments.output, + ) + ) + elif arguments.command == "install": target = select_target_pkg( arguments.metadata, arguments.series, @@ -211,11 +338,17 @@ def main() -> int: ) print(json.dumps(target.record(), sort_keys=True, separators=(",", ":"))) elif arguments.command == "verify": + target = load_target(arguments.metadata, arguments.series) verify_target_pkg( target, arguments.pkg_command, pkg_static_path=arguments.pkg_static ) - else: + elif arguments.command == "field": + target = load_target(arguments.metadata, arguments.series) print(target.record()[arguments.field]) + elif arguments.command == "changed-series": + series = changed_archive_series(arguments.before, arguments.after) + if series is not None: + print(series) except (TargetPackageError, OSError, subprocess.CalledProcessError) as error: print(f"target pkg selection failed: {error}", file=sys.stderr) return 1 diff --git a/.github/workflows/bind-tests.yml b/.github/workflows/bind-tests.yml index a5717c0438..5bb8b06442 100644 --- a/.github/workflows/bind-tests.yml +++ b/.github/workflows/bind-tests.yml @@ -6,6 +6,7 @@ on: - 'dns/bind/**' - '.github/ci/**' - '.resolver-plugins/target-pkg.json' + - '.resolver-plugins/target-pkg-content.json' - '.github/workflows/bind-tests.yml' - '.resolver-plugins/bind920.json' workflow_call: @@ -53,7 +54,7 @@ jobs: bind_source=true ci_helpers=true ;; - .resolver-plugins/bind920.json) + .resolver-plugins/bind920.json|.resolver-plugins/target-pkg.json|.resolver-plugins/target-pkg-content.json) ci_helpers=true ;; esac diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index b4262b2f6b..8e3ffc5307 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -74,11 +74,17 @@ jobs: pull_number: ${{ steps.select.outputs.pull_number }} control_ref: ${{ steps.select.outputs.control_ref }} steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + ref: ${{ github.workflow_sha }} + fetch-depth: 0 + persist-credentials: false - id: select shell: bash env: GITHUB_REF: ${{ github.ref }} GITHUB_SHA: ${{ github.sha }} + BEFORE_SHA: ${{ github.event.before }} GITHUB_WORKFLOW_SHA: ${{ github.workflow_sha }} EVENT_NAME: ${{ github.event_name }} PR_BASE_REF: ${{ github.event.pull_request.base.ref }} @@ -97,6 +103,19 @@ jobs: sed -nE 's#^refs/heads/release/bind-rp/([0-9]+\.[0-9]+)$#\1#p' | sort -V | tail -n 1) + if [[ "$BEFORE_SHA" =~ ^[0-9a-f]{40}$ && "$BEFORE_SHA" != 0000000000000000000000000000000000000000 ]]; then + changed=$(git diff --name-only "$BEFORE_SHA" "$GITHUB_SHA") + if [ "$changed" = .resolver-plugins/target-pkg.json ]; then + before=$(mktemp) + after=$(mktemp) + git show "$BEFORE_SHA:.resolver-plugins/target-pkg.json" > "$before" + git show "$GITHUB_SHA:.resolver-plugins/target-pkg.json" > "$after" + recovered_series=$(python3 .github/ci/target_pkg.py changed-series "$before" "$after") + if [ -n "$recovered_series" ]; then + series=$recovered_series + fi + fi + fi elif [[ "$EVENT_NAME" == pull_request_target ]]; then [[ "$PR_MERGED" == true ]] [[ "$PR_BASE_REF" =~ ^release/bind-rp/([0-9]+\.[0-9]+)$ ]] @@ -215,6 +234,109 @@ jobs: path: artifacts/${{ needs.select.outputs.series }} retention-days: 7 + recover-target-pkg: + if: always() && needs.select.result == 'success' && needs.profile.result == 'success' && needs.bind.result == 'failure' && needs.select.outputs.mode == 'production' + needs: [select, profile, bind] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + ref: ${{ needs.profile.outputs.control_commit }} + persist-credentials: false + - name: Materialize selected release metadata + shell: bash + env: + SOURCE_COMMIT: ${{ needs.profile.outputs.source_commit }} + run: | + set -euo pipefail + git fetch --no-tags origin "$SOURCE_COMMIT" + git checkout "$SOURCE_COMMIT" -- .resolver-plugins/upstream.json + - name: Check for a packaging-only target pkg change + uses: vmactions/freebsd-vm@77ed28d336d03fe19a3f4f7266c1d2c4714dd79d + with: + release: ${{ needs.profile.outputs.freebsd_release }} + arch: x86_64 + usesh: true + disable-cache: true + copyback: true + run: | + set -eu + export IGNORE_OSVERSION=yes + pkg update -f + pkg install -y python3 git + series='${{ needs.select.outputs.series }}' + RP_UPSTREAM_METADATA=.resolver-plugins/upstream.json .github/ci/setup-opnsense-repository.sh "$series" >/dev/null + pkg update -f + python3 .github/ci/target_pkg.py refresh \ + .resolver-plugins/target-pkg.json \ + .resolver-plugins/target-pkg-content.json \ + "$series" --output .resolver-plugins/target-pkg.json + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: target-pkg-recovery-${{ needs.select.outputs.series }} + path: .resolver-plugins/target-pkg.json + retention-days: 1 + + propose-target-pkg: + if: always() && needs.recover-target-pkg.result == 'success' + needs: [select, profile, recover-target-pkg] + runs-on: ubuntu-24.04 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + ref: ${{ needs.profile.outputs.control_commit }} + fetch-depth: 0 + persist-credentials: true + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: target-pkg-recovery-${{ needs.select.outputs.series }} + path: ${{ runner.temp }}/target-pkg-recovery + - name: Open or update target pkg recovery PR + shell: bash + env: + GH_TOKEN: ${{ github.token }} + SERIES: ${{ needs.select.outputs.series }} + run: | + set -euo pipefail + cp "$RUNNER_TEMP/target-pkg-recovery/target-pkg.json" .resolver-plugins/target-pkg.json + changed=$(git diff --name-only) + if [ -z "$changed" ]; then + echo "The target pkg pin is current; the BIND failure requires separate investigation." + exit 0 + fi + [ "$changed" = .resolver-plugins/target-pkg.json ] + branch="sync/target-pkg/$SERIES" + title="ci: refresh $SERIES target pkg archive pin" + expected= + if git fetch --no-tags origin "$branch:refs/remotes/origin/target-pkg-recovery"; then + expected=$(git rev-parse refs/remotes/origin/target-pkg-recovery) + fi + git config user.name github-actions + git config user.email github-actions@github.com + git checkout -B "$branch" + git add .resolver-plugins/target-pkg.json + git commit -m "$title" + git push --force-with-lease="$branch:$expected" origin "HEAD:$branch" + cat > "$RUNNER_TEMP/pr-body.md" <