Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}
Expand All @@ -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"
10 changes: 9 additions & 1 deletion docs/maintainers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<component>:<version>`, where `<component>` 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.

Expand Down
7 changes: 6 additions & 1 deletion scripts/core-distribution-manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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):
Expand Down
107 changes: 107 additions & 0 deletions scripts/publish-core-release.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
import pathlib
import re
import subprocess
import shutil
import gzip
import tempfile
import tarfile
from urllib.parse import quote

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading