From 8e0f4782eea57403728aca8d5206a7d17f472f25 Mon Sep 17 00:00:00 2001 From: yuanhe Date: Thu, 1 Oct 2026 16:03:00 +0800 Subject: [PATCH] Publish release images to GHCR from release workflow --- .github/workflows/release.yml | 12 +++ docs/maintainers.md | 10 ++- scripts/core-distribution-manifest.py | 7 +- scripts/publish-core-release.py | 107 +++++++++++++++++++++++ scripts/publish-core-release.test.py | 118 ++++++++++++++++++++++++++ 5 files changed, 252 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97f3c14d6..9ae5689b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -133,6 +133,7 @@ jobs: runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} permissions: contents: write + packages: write steps: - uses: actions/checkout@v7 with: @@ -142,6 +143,14 @@ jobs: with: name: core-release-${{ needs.build.outputs.revision }} path: release-upload + - name: Sign in to GHCR for version releases + if: github.event_name == 'push' + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + export DOCKER_CONFIG="$RUNNER_TEMP/oac-release-docker" + echo "DOCKER_CONFIG=$DOCKER_CONFIG" >> "$GITHUB_ENV" + printf '%s' "$GHCR_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin - name: Publish the version tag or create a manual draft env: GH_TOKEN: ${{ github.token }} @@ -150,3 +159,6 @@ jobs: RELEASE_TAG: ${{ needs.build.outputs.release_tag }} RELEASE_MODE: ${{ github.event_name == 'push' && 'publish' || 'draft' }} run: python3 scripts/publish-core-release.py --assets release-upload + - name: Remove registry credentials + if: always() && github.event_name == 'push' + run: rm -f "$RUNNER_TEMP/oac-release-docker/config.json" diff --git a/docs/maintainers.md b/docs/maintainers.md index 3612c097f..8f33f88e8 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -124,7 +124,15 @@ git push origin v1.2.3 Tags use `vMAJOR.MINOR.PATCH`, optionally with a prerelease suffix such as `-rc.1` and build metadata such as `+build.1`. A prerelease suffix creates a GitHub prerelease. Pushing the tag is the release decision. Automated checks establish build and test results, not real-model qualification: assess live execution evidence before you push the tag. Model credentials and private certificate authorities never enter CI or release inputs, including acceptance images that contain them. -The workflow runs `check` on the tagged commit, including the full local gate, official-client and image acceptance, and the native matrix with its packaging artifacts enabled. `build` starts after `check` succeeds and reuses those native artifacts. `build` prepares the pinned Runtime inputs, assembles the native catalog and builds the distribution with the offline archive, and adds `deploy/install-release.sh` as `install.sh` with its checksum. The `release` job runs only after `check` and `build` succeed. It is the only job with `contents: write`. It verifies the archive checksums and the native installer checksums against the catalog, resolves the repository's current name from GitHub before any write (Actions can keep an old name after a rename), refuses an existing Release or draft for the tag, uploads everything to a new draft on `uploads.github.com` bound to that draft's ID without retrying failed uploads, confirms the tag still points at the built commit, and publishes that draft by its ID. Images ship as archives; no registry is pushed. Downloads are anonymous. +The workflow runs `check` on the tagged commit, including the full local gate, official-client and image acceptance, and the native matrix with its packaging artifacts enabled. `build` starts after `check` succeeds and reuses those native artifacts. `build` prepares the pinned Runtime inputs, assembles the native catalog and builds the distribution with the offline archive, and adds `deploy/install-release.sh` as `install.sh` with its checksum. The `release` job runs only after `check` and `build` succeed. It is the only job with `contents: write`. It verifies the archive checksums and the native installer checksums against the catalog, resolves the repository's current name from GitHub before any write (Actions can keep an old name after a rename), refuses an existing Release or draft for the tag, uploads everything to a new draft on `uploads.github.com` bound to that draft's ID without retrying failed uploads, confirms the tag still points at the built commit, and publishes that draft by its ID. Before publishing the draft, it also loads the same release image archives and pushes the Core, Web, Runtime and ingress images to GHCR, verifies their image config digests and records their registry manifest references in the Actions job summary. A registry failure leaves the Release as a draft. Archive downloads remain anonymous. + +### Container registry + +Version releases publish Linux amd64 images as `ghcr.io/minimax-ai/openagentcore/:`, where `` is `core`, `web`, `runtime` or `ingress`. For example, `ghcr.io/minimax-ai/openagentcore/core:v1.2.3`. PostgreSQL uses its upstream image and is not republished. The registry images are loaded from the release archives without rebuilding. Existing tags are reused only when their image config digest matches the release; a different image stops publication. No floating `latest` tag is published. SemVer build metadata uses `_` in place of `+` in container tags; version strings longer than 128 characters cannot be published to GHCR. Manual draft builds do not push images. + +The release job uses `GITHUB_TOKEN` with `packages: write`. On the first publication, GitHub creates each container package as private: a package administrator must change all four packages to **Public** in their package settings before users can pull anonymously. See [GitHub container visibility](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry). Verify an unauthenticated pull after changing visibility. Repository visibility alone does not make a new container package public. + +GHCR and GitHub Releases do not share a transaction. A failed release may leave some matching version tags in GHCR; preserve those images and follow the draft recovery procedure below using the original artifacts. Registry failures other than a missing manifest stop publication. The job summary records digest-pinned references; the installation archives and their checksums remain unchanged. These images still require the configuration, secrets and routing described in [Configuration](./configuration.md); publishing them does not provide a platform deployment template. `install.sh` resolves the latest stable release once, or the release named by `--version`, verifies the control archive and runs that bundle's installer; the [installation guide](./getting-started/install.md#install) covers its use. diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index 6ea3d2d15..c01163160 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -86,6 +86,11 @@ def built_image(metadata_file): # by its manifest digest; the other value never resolves to itself there. config = metadata.get("containerimage.config.digest") manifest = metadata.get("containerimage.digest", config) + print(resolve_image(config, manifest)) + + +def resolve_image(config, manifest): + """Resolve the archive identities in either supported Docker image store.""" if not all(isinstance(value, str) and DIGEST.fullmatch(value) for value in (config, manifest)): raise ValueError("Build metadata lacks valid image digests") resolved = [] @@ -97,7 +102,7 @@ def built_image(metadata_file): if len(resolved) != 1: raise ValueError("The local image store does not identify the built image by exactly one of its digests") verify_image(resolved[0]) - print(resolved[0]) + return resolved[0] def image_identities(archive, build_id): diff --git a/scripts/publish-core-release.py b/scripts/publish-core-release.py index c64c433ed..98ccbf68a 100644 --- a/scripts/publish-core-release.py +++ b/scripts/publish-core-release.py @@ -8,6 +8,9 @@ import pathlib import re import subprocess +import shutil +import gzip +import tempfile import tarfile from urllib.parse import quote @@ -57,6 +60,101 @@ def verify_draft(release, tag, revision): raise ValueError("Release draft identity changed") +IMAGE_NAMES = ("core", "web", "runtime", "ingress") + + +def registry_manifest(reference): + result = subprocess.run(["docker", "manifest", "inspect", reference], + text=True, capture_output=True) + if result.returncode: + # Authentication, transport and registry failures must not authorize a push. + if "manifest unknown" in result.stderr.lower() or "no such manifest:" in result.stderr.lower(): + return None + raise RuntimeError("Cannot inspect registry image " + reference + ": " + result.stderr) + return json.loads(result.stdout) + + +def registry_image(reference): + manifest = registry_manifest(reference) + selected = reference + if manifest is not None and "manifests" in manifest: + descriptors = manifest["manifests"] + if len(descriptors) != 1: + raise ValueError("Expected one Linux amd64 registry image: " + reference) + digest = descriptors[0]["digest"] + if not distribution.DIGEST.fullmatch(digest): + raise ValueError("Invalid registry image descriptor") + selected = reference.rsplit(":", 1)[0] + "@" + digest + manifest = registry_manifest(selected) + if manifest is None: + raise ValueError("Registry index refers to a missing image") + return manifest, selected + + +def publish_images(assets, repository, revision, tag): + """Load the checked release archives; never rebuild or replace another image.""" + image_tag = tag.replace("+", "_") + if not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}", image_tag): + raise ValueError("Release version exceeds the container tag format") + stem = "oac-" + revision + "-linux-amd64" + # Extract named regular members only, never archive-controlled paths. + with tempfile.TemporaryDirectory(prefix="oac-ghcr-") as directory: + directory = pathlib.Path(directory) + with tarfile.open(assets / (stem + ".tar.gz"), "r:gz") as archive: + manifest = json.load(archive.extractfile(stem + "/manifest.json")) + if manifest["source_commit"] != revision or manifest["platform"] != "linux/amd64": + raise ValueError("Registry images do not match the release") + for name in IMAGE_NAMES: + if name == "runtime": + continue + member = archive.getmember(stem + "/images/" + name + ".tar") + if not member.isfile(): + raise ValueError("Expected a regular image archive") + with archive.extractfile(member) as source, (directory / (name + ".tar")).open("wb") as target: + shutil.copyfileobj(source, target) + runtime = manifest["artifacts"]["images/runtime.tar.gz"] + filename = runtime["filename"] + if pathlib.Path(filename).name != filename: + raise ValueError("Invalid Runtime asset filename") + runtime_path = assets / filename + if runtime_path.is_symlink() or distribution.sha256(runtime_path) != runtime["sha256"]: + raise ValueError("Runtime image checksum mismatch") + with gzip.open(runtime_path, "rb") as source, (directory / "runtime.tar").open("wb") as target: + shutil.copyfileobj(source, target) + for name in IMAGE_NAMES: + expected = (manifest["images"][name], manifest["image_manifest_digests"][name]) + if distribution.image_identities(directory / (name + ".tar"), expected[0]) != expected: + raise ValueError("Release image identity mismatch: " + name) + references = {} + # Validate every local image and every existing tag before the first push. + for name in IMAGE_NAMES: + path = directory / (name + ".tar") + subprocess.run(["docker", "load", "--input", str(path)], check=True) + config = manifest["images"][name] + local = distribution.resolve_image(config, manifest["image_manifest_digests"][name]) + reference = "ghcr.io/" + repository.lower() + "/" + name + ":" + image_tag + remote, selected = registry_image(reference) + if remote is not None and remote.get("config", {}).get("digest") != config: + raise ValueError("Registry tag already names a different image: " + reference) + references[name] = (reference, config, local, remote) + result = {} + for name, (reference, config, local, remote) in references.items(): + if remote is None: + subprocess.run(["docker", "tag", local, reference], check=True) + subprocess.run(["docker", "push", reference], check=True) + remote, selected = registry_image(reference) + if remote is None or remote.get("config", {}).get("digest") != config: + raise ValueError("Registry image verification failed: " + reference) + # Inspect the registry's descriptor, not the local Docker image ID. + details = json.loads(subprocess.check_output( + ["docker", "manifest", "inspect", "--verbose", selected], text=True)) + digest = details["Descriptor"]["digest"] + if not distribution.DIGEST.fullmatch(digest): + raise ValueError("Invalid registry manifest digest") + result[name] = {"tag": reference, "digest": reference.rsplit(":", 1)[0] + "@" + digest} + return result + + def publish(assets, repository, revision, tag, mode): if not REPOSITORY.fullmatch(repository): raise ValueError("Expected an owner/repository") @@ -136,6 +234,15 @@ def publish(assets, repository, revision, tag, mode): raise ValueError("Release asset inventory differs from the build") if mode == "draft": return + # GHCR is not transactional with Releases. Keep the Release a draft until + # every versioned image has been pushed and verified. Matching tags are reusable. + images = publish_images(assets, repository, revision, tag) + inventory = json.dumps({"source_commit": revision, "images": images}, indent=2) + "\n" + # Keep digest receipts in the Actions summary without changing release assets. + if os.environ.get("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary: + summary.write("## GHCR images\n\n```json\n" + inventory + "```\n") + print(inventory) # Uploads can take minutes. Recheck immediately before the one publish request. verify_tag(repository, tag, revision) result = api(repository, endpoint, "--method", "PATCH", "-F", "draft=false") diff --git a/scripts/publish-core-release.test.py b/scripts/publish-core-release.test.py index 7da741a5b..f7e8a0259 100644 --- a/scripts/publish-core-release.test.py +++ b/scripts/publish-core-release.test.py @@ -1,6 +1,7 @@ """Publication failure tests using the documented GitHub REST response shapes.""" import contextlib import hashlib +import gzip import importlib.util import json import io @@ -17,6 +18,7 @@ publisher = importlib.util.module_from_spec(spec) spec.loader.exec_module(publisher) REAL_API = publisher.api +REAL_REGISTRY_MANIFEST = publisher.registry_manifest class PublicationTests(unittest.TestCase): @@ -49,8 +51,20 @@ def setUp(self): self.context_repository = self.canonical_repository = "MiniMax-AI/OpenAgentCore" stack = contextlib.ExitStack() self.addCleanup(stack.close) + self.images = stack.enter_context(mock.patch.object(publisher, "publish_images", return_value={})) self.api = stack.enter_context(mock.patch.object(publisher, "api", side_effect=self.response)) + def test_registry_failure_leaves_release_draft(self): + self.images.side_effect = RuntimeError("registry unavailable") + with self.assertRaisesRegex(RuntimeError, "registry unavailable"): + self.publish() + self.assertTrue(self.release["draft"]) + self.assertFalse(any("PATCH" in call.args for call in self.writes())) + + def test_draft_does_not_publish_images(self): + self.publish(tag="build-" + self.revision, mode="draft") + self.images.assert_not_called() + def test_missing_native_asset_refuses_release_creation(self): (self.assets / f"oac-native-{self.revision}-windows-amd64.tar.gz").unlink() with self.assertRaises(FileNotFoundError): @@ -287,5 +301,109 @@ def test_api_uses_full_upload_url_and_binary_input(self): ["gh", "api", url, "--method", "POST", "--input", "/tmp/asset"]) +class RegistryTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.assets = pathlib.Path(self.temp.name) + self.revision = "a" * 40 + self.config = "sha256:" + "b" * 64 + self.digest = "sha256:" + "c" * 64 + runtime = self.assets / "runtime.tar.gz" + runtime.write_bytes(gzip.compress(b"runtime")) + self.manifest = { + "source_commit": self.revision, "platform": "linux/amd64", + "images": dict.fromkeys(publisher.IMAGE_NAMES, self.config), + "image_manifest_digests": dict.fromkeys(publisher.IMAGE_NAMES, self.digest), + "artifacts": {"images/runtime.tar.gz": { + "filename": runtime.name, "sha256": publisher.distribution.sha256(runtime)}}} + with tarfile.open(self.assets / ("oac-" + self.revision + "-linux-amd64.tar.gz"), "w:gz") as archive: + for name, data in [("manifest.json", json.dumps(self.manifest).encode())] + [ + ("images/" + name + ".tar", b"image") for name in ("core", "web", "ingress")]: + member = tarfile.TarInfo("oac-" + self.revision + "-linux-amd64/" + name) + member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + stack = contextlib.ExitStack() + self.addCleanup(stack.close) + self.identities = stack.enter_context(mock.patch.object(publisher.distribution, "image_identities", return_value=(self.config, self.digest))) + stack.enter_context(mock.patch.object(publisher.distribution, "resolve_image", return_value=self.digest)) + self.run = stack.enter_context(mock.patch.object(publisher.subprocess, "run")) + stack.enter_context(mock.patch.object(publisher.subprocess, "check_output", return_value=json.dumps({"Descriptor": {"digest": self.digest}}))) + self.remote = stack.enter_context(mock.patch.object(publisher, "registry_manifest", return_value={"config": {"digest": self.config}})) + + def publish(self, tag="v1.2.3"): + return publisher.publish_images(self.assets, "MiniMax-AI/OpenAgentCore", self.revision, tag) + + def pushes(self): + return [c.args[0] for c in self.run.call_args_list if c.args[0][1] == "push"] + + def test_matching_tags_are_reused_and_receipts_use_registry_digest(self): + result = self.publish() + self.assertEqual(self.pushes(), []) + self.assertEqual(result["core"]["digest"], "ghcr.io/minimax-ai/openagentcore/core@" + self.digest) + + def test_new_images_use_resolved_store_identity_and_version_only(self): + self.remote.side_effect = [None] * 4 + [{"config": {"digest": self.config}}] * 4 + result = self.publish("v1.2.3-rc.1+build.2") + self.assertEqual(len(self.pushes()), 4) + self.assertTrue(all(c[-1].endswith(":v1.2.3-rc.1_build.2") for c in self.pushes())) + tags = [c.args[0] for c in self.run.call_args_list if c.args[0][1] == "tag"] + self.assertTrue(all(c[2] == self.digest for c in tags)) + self.assertEqual(len(result), 4) + + def test_single_platform_indexes_are_verified_by_child_config(self): + index = {"manifests": [{"digest": self.digest}]} + image = {"config": {"digest": self.config}} + self.remote.side_effect = [index, image] * 8 + result = self.publish() + self.assertEqual(self.pushes(), []) + self.assertEqual(result["core"]["digest"], "ghcr.io/minimax-ai/openagentcore/core@" + self.digest) + self.assertTrue(any("@" in call.args[0] for call in self.remote.call_args_list)) + + def test_multi_image_index_is_rejected(self): + self.remote.return_value = {"manifests": [{"digest": self.digest}] * 2} + with self.assertRaisesRegex(ValueError, "Expected one"): + self.publish() + self.assertEqual(self.pushes(), []) + + def test_conflicting_tag_prevents_all_pushes(self): + self.remote.side_effect = [None, {"config": {"digest": "different"}}] + with self.assertRaisesRegex(ValueError, "different image"): + self.publish() + self.assertEqual(self.pushes(), []) + + def test_corrupt_runtime_prevents_loading(self): + (self.assets / "runtime.tar.gz").write_bytes(b"corrupt") + with self.assertRaisesRegex(ValueError, "checksum"): + self.publish() + self.run.assert_not_called() + + def test_archive_identity_mismatch_prevents_loading(self): + self.identities.return_value = (self.config, "different") + with self.assertRaisesRegex(ValueError, "identity mismatch"): + self.publish() + self.run.assert_not_called() + + def test_failed_push_stops_publication(self): + self.remote.return_value = None + def run(command, **kwargs): + if command[1] == "push": + raise subprocess.CalledProcessError(1, command) + self.run.side_effect = run + with self.assertRaises(subprocess.CalledProcessError): + self.publish() + self.assertEqual(len(self.pushes()), 1) + + def test_registry_auth_failure_is_not_missing_image(self): + # Test the actual inspection function separately from the publication fixture. + with mock.patch.object(publisher.subprocess, "run", return_value=subprocess.CompletedProcess([], 1, "", "unauthorized")): + with self.assertRaisesRegex(RuntimeError, "Cannot inspect"): + REAL_REGISTRY_MANIFEST("ghcr.io/example/core:v1") + + def test_registry_missing_manifest(self): + with mock.patch.object(publisher.subprocess, "run", return_value=subprocess.CompletedProcess([], 1, "", "manifest unknown")): + self.assertIsNone(REAL_REGISTRY_MANIFEST("ghcr.io/example/core:v1")) + + if __name__ == "__main__": unittest.main()