From c57b0ac570ba7b0e84769dad7de63c69074800f8 Mon Sep 17 00:00:00 2001 From: Andres Contreras Date: Fri, 30 Oct 2026 19:11:22 -0700 Subject: [PATCH] Recover containers from verified public release tags --- .github/workflows/container-recovery.yml | 75 ++++++++ scripts/container_release_binding.py | 146 +++++++++++++++ tests/test_container_release_binding.py | 223 +++++++++++++++++++++++ 3 files changed, 444 insertions(+) create mode 100644 .github/workflows/container-recovery.yml create mode 100644 scripts/container_release_binding.py create mode 100644 tests/test_container_release_binding.py diff --git a/.github/workflows/container-recovery.yml b/.github/workflows/container-recovery.yml new file mode 100644 index 000000000..2aae8250a --- /dev/null +++ b/.github/workflows/container-recovery.yml @@ -0,0 +1,75 @@ +# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0 +name: Recover published release container +on: + workflow_dispatch: + inputs: + release_tag: + description: Existing public stable release tag + required: true + type: string + expected_commit: + description: Reviewed full commit SHA to which the release tag must resolve + required: true + type: string +permissions: + contents: read +concurrency: + group: container-recovery-${{ inputs.release_tag }} + cancel-in-progress: false +jobs: + container: + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + env: + RELEASE_TAG: ${{ inputs.release_tag }} + EXPECTED_COMMIT: ${{ inputs.expected_commit }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Preserve the trusted publication guard outside the image context + run: cp scripts/container_release_binding.py "$RUNNER_TEMP/forge-container-release-binding.py" + - name: Validate the existing public release and exact tag commit + id: binding + env: + GH_TOKEN: ${{ github.token }} + run: python "$RUNNER_TEMP/forge-container-release-binding.py" --repository "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --expected-commit "$EXPECTED_COMMIT" + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ steps.binding.outputs.commit }} + persist-credentials: false + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - name: Recheck publication identity and clean checkout immediately before building + id: confirmed + env: + GH_TOKEN: ${{ github.token }} + TAG_OBJECT: ${{ steps.binding.outputs.tag_object }} + RELEASE_ID: ${{ steps.binding.outputs.release_id }} + run: python "$RUNNER_TEMP/forge-container-release-binding.py" --repository "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --expected-commit "$EXPECTED_COMMIT" --previous-tag-object "$TAG_OBJECT" --previous-release-id "$RELEASE_ID" --checkout "$GITHUB_WORKSPACE" + - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + id: image + with: + context: . + push: true + tags: ghcr.io/${{ github.repository }}:${{ steps.confirmed.outputs.tag }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ steps.confirmed.outputs.commit }} + org.opencontainers.image.version=${{ steps.confirmed.outputs.tag }} + org.opencontainers.image.licenses=Apache-2.0 + provenance: mode=max + sbom: true + - name: Record immutable image + env: + IMAGE: ghcr.io/${{ github.repository }} + DIGEST: ${{ steps.image.outputs.digest }} + run: printf '%s@%s\n' "$IMAGE" "$DIGEST" >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/container_release_binding.py b/scripts/container_release_binding.py new file mode 100644 index 000000000..46c6b5d79 --- /dev/null +++ b/scripts/container_release_binding.py @@ -0,0 +1,146 @@ +# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0 +"""Bind a container-only publication to an existing public stable release and exact tag commit.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +import re +import stat +import subprocess +import tomllib +from collections.abc import Callable +from pathlib import Path +from typing import Any + +SHA = re.compile(r"[0-9a-f]{40}") + + +def _project_version(body: bytes, tag: str) -> str: + try: + project = tomllib.loads(body.decode())["project"] + except (KeyError, UnicodeError, tomllib.TOMLDecodeError): + raise ValueError("Tagged project metadata is invalid") from None + if not isinstance(project, dict) or project.get("name") != "firefly-forge" or project.get("version") != tag[1:]: + raise ValueError("Published tag differs from the Forge project version") + return tag[1:] + + +def verify_release( + repository: str, + tag: str, + expected_commit: str, + read_api: Callable[[str], dict[str, Any]], + *, + previous: dict[str, str] | None = None, +) -> dict[str, str]: + if ( + not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository) + or any(part in {".", ".."} for part in repository.split("/")) + or not re.fullmatch(r"v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)", tag) + or not SHA.fullmatch(expected_commit) + ): + raise ValueError("Expected a repository, stable version tag and full lowercase commit SHA") + prefix = "repos/" + repository + public = read_api(prefix) + release = read_api(prefix + "/releases/tags/" + tag) + if ( + public.get("private") is not False + or str(public.get("full_name", "")).casefold() != repository.casefold() + or release.get("tag_name") != tag + or release.get("draft") is not False + or release.get("prerelease") is not False + or not isinstance(release.get("published_at"), str) + or not release["published_at"] + or type(release.get("id")) is not int + or release["id"] < 1 + ): + raise ValueError("An existing public published stable release is required") + target = read_api(prefix + "/git/ref/tags/" + tag).get("object", {}) + original_object = target.get("sha") + seen: set[str] = set() + while target.get("type") == "tag": + identity = target.get("sha", "") + if not isinstance(identity, str) or not SHA.fullmatch(identity) or identity in seen or len(seen) >= 8: + raise ValueError("Invalid or ambiguous published tag chain") + seen.add(identity) + annotation = read_api(prefix + "/git/tags/" + identity) + if annotation.get("sha") != identity: + raise ValueError("Published tag identity changed") + target = annotation.get("object", {}) + if target.get("type") != "commit" or target.get("sha") != expected_commit: + raise ValueError("Published tag does not resolve to the reviewed commit") + content = read_api(prefix + "/contents/pyproject.toml?ref=" + expected_commit) + if content.get("type") != "file" or content.get("path") != "pyproject.toml" or content.get("encoding") != "base64": + raise ValueError("Tagged project metadata must be an ordinary file") + try: + body = base64.b64decode("".join(content["content"].split()), validate=True) + except (KeyError, TypeError, ValueError, AttributeError): + raise ValueError("Tagged project metadata is invalid") from None + blob = hashlib.sha1(b"blob " + str(len(body)).encode() + b"\0" + body).hexdigest() + if content.get("sha") != blob or type(content.get("size")) is not int or content["size"] != len(body): + raise ValueError("Tagged project metadata failed its Git blob binding") + version = _project_version(body, tag) + binding = { + "repository": repository, + "tag": tag, + "commit": expected_commit, + "tag_object": str(original_object), + "release_id": str(release["id"]), + "version": version, + } + if previous is not None and any(binding[name] != previous.get(name) for name in ("tag_object", "release_id")): + raise ValueError("Published release or tag identity changed after initial validation") + return binding + + +def verify_checkout(checkout: Path, binding: dict[str, str]) -> None: + project = checkout / "pyproject.toml" + if checkout.is_symlink() or not stat.S_ISREG(project.lstat().st_mode): + raise ValueError("The publishing checkout metadata is not an ordinary file") + head = subprocess.check_output(["git", "-C", str(checkout), "rev-parse", "HEAD"]).decode().strip() + if head != binding["commit"]: + raise ValueError("The publishing checkout differs from the reviewed commit") + if subprocess.check_output(["git", "-C", str(checkout), "status", "--porcelain", "--untracked-files=all"]): + raise ValueError("The publishing checkout must remain clean") + _project_version(project.read_bytes(), binding["tag"]) + + +def read_github_api(path: str) -> dict[str, Any]: + try: + value = json.loads( + subprocess.check_output(["gh", "api", "--hostname", "github.com", path], stderr=subprocess.PIPE) + ) + except (subprocess.SubprocessError, json.JSONDecodeError): + raise ValueError("Published release metadata could not be read") from None + if not isinstance(value, dict): + raise ValueError("Invalid GitHub release metadata") + return value + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--expected-commit", required=True) + parser.add_argument("--previous-tag-object") + parser.add_argument("--previous-release-id") + parser.add_argument("--checkout", type=Path) + args = parser.parse_args() + if bool(args.previous_tag_object) != bool(args.previous_release_id): + raise SystemExit("Both previous publication identities are required") + previous = ( + {"tag_object": args.previous_tag_object, "release_id": args.previous_release_id} + if args.previous_tag_object + else None + ) + result = verify_release(args.repository, args.tag, args.expected_commit, read_github_api, previous=previous) + if args.checkout is not None: + verify_checkout(args.checkout, result) + if output := os.environ.get("GITHUB_OUTPUT"): + with Path(output).open("a") as handle: + handle.writelines(f"{name}={value}\n" for name, value in result.items()) + print(json.dumps(result, sort_keys=True)) diff --git a/tests/test_container_release_binding.py b/tests/test_container_release_binding.py new file mode 100644 index 000000000..78c0bad9d --- /dev/null +++ b/tests/test_container_release_binding.py @@ -0,0 +1,223 @@ +# Copyright 2026 Firefly Software Foundation. SPDX-License-Identifier: Apache-2.0 +"""A container-only recovery may publish only the explicitly verified stable tag.""" + +import base64 +import copy +import hashlib +import runpy +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts/container_release_binding.py" +WORKFLOW = ROOT / ".github/workflows/container-recovery.yml" +COMMIT, TAG_OBJECT = "a" * 40, "b" * 40 + + +class PublicAPI: + def __init__(self): + self.body = b'[project]\nname="firefly-forge"\nversion="0.2.49"\n' + self.data = { + "repos/acme/forge": {"private": False, "full_name": "acme/forge"}, + "repos/acme/forge/releases/tags/v0.2.49": { + "id": 123, + "tag_name": "v0.2.49", + "draft": False, + "prerelease": False, + "published_at": "2026-10-31T01:26:39Z", + }, + "repos/acme/forge/git/ref/tags/v0.2.49": {"object": {"type": "tag", "sha": TAG_OBJECT}}, + "repos/acme/forge/git/tags/" + TAG_OBJECT: {"sha": TAG_OBJECT, "object": {"type": "commit", "sha": COMMIT}}, + "repos/acme/forge/contents/pyproject.toml?ref=" + COMMIT: { + "type": "file", + "path": "pyproject.toml", + "encoding": "base64", + "size": len(self.body), + "content": base64.b64encode(self.body).decode(), + "sha": hashlib.sha1(b"blob " + str(len(self.body)).encode() + b"\0" + self.body).hexdigest(), + }, + } + self.calls = [] + + def __call__(self, path): + self.calls.append(path) + return copy.deepcopy(self.data[path]) + + +def helpers(): + assert SCRIPT.is_file(), "The container publication binding guard is missing" + return runpy.run_path(str(SCRIPT)) + + +class ContainerReleaseBindingTests(unittest.TestCase): + def test_annotated_and_lightweight_public_release_bind_the_exact_commit_and_version(self): + verify = helpers()["verify_release"] + for kind in ("annotated", "lightweight"): + with self.subTest(kind=kind): + api = PublicAPI() + if kind == "lightweight": + api.data["repos/acme/forge/git/ref/tags/v0.2.49"]["object"] = {"type": "commit", "sha": COMMIT} + value = verify("acme/forge", "v0.2.49", COMMIT, api) + self.assertEqual(value["commit"], COMMIT) + self.assertEqual(value["tag"], "v0.2.49") + self.assertEqual(value["version"], "0.2.49") + self.assertEqual(value["release_id"], "123") + self.assertIn("repos/acme/forge/contents/pyproject.toml?ref=" + COMMIT, api.calls) + + def test_private_draft_prerelease_unpublished_wrong_tag_or_moved_commit_refuse(self): + verify = helpers()["verify_release"] + changes = ( + ("repos/acme/forge", "private", True), + ("repos/acme/forge", "full_name", "another/repository"), + ("repos/acme/forge/releases/tags/v0.2.49", "draft", True), + ("repos/acme/forge/releases/tags/v0.2.49", "draft", "false"), + ("repos/acme/forge/releases/tags/v0.2.49", "prerelease", True), + ("repos/acme/forge/releases/tags/v0.2.49", "published_at", None), + ("repos/acme/forge/releases/tags/v0.2.49", "tag_name", "v0.2.48"), + ("repos/acme/forge/git/tags/" + TAG_OBJECT, "object", {"type": "commit", "sha": "c" * 40}), + ) + for path, field, value in changes: + with self.subTest(field=field, value=value): + api = PublicAPI() + api.data[path][field] = value + with self.assertRaises(ValueError): + verify("acme/forge", "v0.2.49", COMMIT, api) + + def test_bad_inputs_refuse_before_any_API_call(self): + verify = helpers()["verify_release"] + for repository, tag, commit in ( + ("acme/forge", "main", COMMIT), + ("acme/forge", "v0.2.49;echo bad", COMMIT), + ("acme/forge", "v0.2.50rc1", COMMIT), + ("../forge", "v0.2.49", COMMIT), + ("acme/forge", "v0.2.49", "main"), + ): + api = PublicAPI() + with self.subTest(tag=tag, commit=commit), self.assertRaises(ValueError): + verify(repository, tag, commit, api) + self.assertEqual(api.calls, []) + + def test_tagged_metadata_wrong_version_kind_or_blob_digest_refuse(self): + verify = helpers()["verify_release"] + path = "repos/acme/forge/contents/pyproject.toml?ref=" + COMMIT + for field, value in ( + ("content", base64.b64encode(b'[project]\nversion="0.2.50"').decode()), + ("sha", "c" * 40), + ("type", "symlink"), + ("path", "other.toml"), + ): + api = PublicAPI() + api.data[path][field] = value + with self.subTest(field=field), self.assertRaises(ValueError): + verify("acme/forge", "v0.2.49", COMMIT, api) + + def test_valid_git_blob_with_foreign_project_or_version_still_refuses(self): + verify = helpers()["verify_release"] + path = "repos/acme/forge/contents/pyproject.toml?ref=" + COMMIT + for body in ( + b'[project]\nname="firefly-forge"\nversion="0.2.50"\n', + b'[project]\nname="another-project"\nversion="0.2.49"\n', + ): + api = PublicAPI() + api.data[path].update( + content=base64.b64encode(body).decode(), + size=len(body), + sha=hashlib.sha1(b"blob " + str(len(body)).encode() + b"\0" + body).hexdigest(), + ) + with self.subTest(body=body), self.assertRaisesRegex(ValueError, "project version"): + verify("acme/forge", "v0.2.49", COMMIT, api) + + def test_second_validation_refuses_release_or_tag_identity_replacement(self): + verify = helpers()["verify_release"] + api = PublicAPI() + binding = verify("acme/forge", "v0.2.49", COMMIT, api) + api.data["repos/acme/forge/releases/tags/v0.2.49"]["id"] += 1 + with self.assertRaises(ValueError): + verify("acme/forge", "v0.2.49", COMMIT, api, previous=binding) + api.data["repos/acme/forge/releases/tags/v0.2.49"]["id"] -= 1 + api.data["repos/acme/forge/git/ref/tags/v0.2.49"]["object"] = {"type": "commit", "sha": COMMIT} + with self.assertRaises(ValueError): + verify("acme/forge", "v0.2.49", COMMIT, api, previous=binding) + + def test_checkout_requires_exact_HEAD_and_regular_matching_project_file(self): + verify, checkout = helpers()["verify_release"], helpers()["verify_checkout"] + api = PublicAPI() + binding = verify("acme/forge", "v0.2.49", COMMIT, api) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "pyproject.toml").write_bytes(api.body) + with patch("subprocess.check_output", side_effect=[(COMMIT + "\n").encode(), b""]): + checkout(root, binding) + with ( + patch("subprocess.check_output", side_effect=[(COMMIT + "\n").encode(), b" M Dockerfile\n"]), + self.assertRaisesRegex(ValueError, "clean"), + ): + checkout(root, binding) + with ( + patch("subprocess.check_output", return_value=("c" * 40 + "\n").encode()), + self.assertRaises(ValueError), + ): + checkout(root, binding) + (root / "pyproject.toml").write_text('[project]\nname="firefly-forge"\nversion="0.2.50"\n') + with ( + patch("subprocess.check_output", side_effect=[(COMMIT + "\n").encode(), b""]), + self.assertRaisesRegex(ValueError, "project version"), + ): + checkout(root, binding) + + def test_symlink_checkout_metadata_and_tag_cycles_refuse(self): + verify, checkout = helpers()["verify_release"], helpers()["verify_checkout"] + api = PublicAPI() + binding = verify("acme/forge", "v0.2.49", COMMIT, api) + api.data["repos/acme/forge/git/tags/" + TAG_OBJECT]["object"] = {"type": "tag", "sha": TAG_OBJECT} + with self.assertRaisesRegex(ValueError, "tag chain"): + verify("acme/forge", "v0.2.49", COMMIT, api) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "payload.toml").write_bytes(api.body) + (root / "pyproject.toml").symlink_to("payload.toml") + with patch("subprocess.check_output") as git, self.assertRaisesRegex(ValueError, "ordinary file"): + checkout(root, binding) + git.assert_not_called() + + def test_workflow_is_manual_only_and_reuses_pinned_container_steps_after_validation(self): + self.assertTrue(WORKFLOW.is_file(), "The bounded container-only workflow is missing") + workflow = yaml.load(WORKFLOW.read_text(), Loader=yaml.BaseLoader) + existing = yaml.load((ROOT / ".github/workflows/release.yml").read_text(), Loader=yaml.BaseLoader) + self.assertEqual(set(workflow["on"]), {"workflow_dispatch"}) + self.assertEqual(set(workflow["jobs"]), {"container"}) + job = workflow["jobs"]["container"] + self.assertIn("github.event.repository.default_branch", job["if"]) + self.assertEqual(job["permissions"], {"contents": "read", "packages": "write"}) + steps = job["steps"] + uses = [step["uses"] for step in steps if "uses" in step] + old_uses = [step["uses"] for step in existing["jobs"]["container"]["steps"] if "uses" in step] + self.assertEqual(uses, [old_uses[0], *old_uses]) + checkouts = [step for step in steps if step.get("uses") == old_uses[0]] + self.assertEqual(checkouts[0]["with"]["ref"], "${{ github.sha }}") + self.assertEqual(checkouts[0]["with"]["persist-credentials"], "false") + self.assertEqual(checkouts[1]["with"]["ref"], "${{ steps.binding.outputs.commit }}") + self.assertEqual(checkouts[1]["with"]["persist-credentials"], "false") + initial = next(step for step in steps if step.get("id") == "binding") + self.assertLess(steps.index(initial), steps.index(checkouts[1])) + preserved = next(step for step in steps if step.get("run", "").startswith("cp scripts/")) + self.assertIn("$RUNNER_TEMP/forge-container-release-binding.py", preserved["run"]) + self.assertLess(steps.index(preserved), steps.index(checkouts[1])) + image = next(step for step in steps if step.get("id") == "image") + self.assertEqual(image["with"]["provenance"], "mode=max") + self.assertEqual(image["with"]["sbom"], "true") + self.assertIn("${{ steps.confirmed.outputs.commit }}", image["with"]["labels"]) + self.assertNotIn("github.sha", image["with"]["labels"]) + self.assertIn("${{ steps.confirmed.outputs.tag }}", image["with"]["tags"]) + confirmed = next(i for i, step in enumerate(steps) if step.get("id") == "confirmed") + self.assertLess(confirmed, steps.index(image)) + self.assertIn('--checkout "$GITHUB_WORKSPACE"', steps[confirmed]["run"]) + self.assertIn('--previous-tag-object "$TAG_OBJECT"', steps[confirmed]["run"]) + self.assertIn('--previous-release-id "$RELEASE_ID"', steps[confirmed]["run"]) + for step in steps: + self.assertNotIn("${{ inputs.", step.get("run", "")) + self.assertNotIn("gh release create", WORKFLOW.read_text())