diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7bbde54a..5e32a9c1 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -177,6 +177,8 @@ jobs: node-version: '22' - name: Verify Harness catalog and installer schema run: make check-harness-catalog + - name: Install distribution compressor + run: sudo apt-get update && sudo apt-get install -y pigz - name: Test distribution, installer and console packaging run: make check-distribution diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ae5689b..ab1e3606 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,11 +34,11 @@ jobs: build: needs: check - runs-on: ${{ vars.OAC_USE_GITHUB_RUNNERS == 'true' && 'ubuntu-22.04' || 'blacksmith-2vcpu-ubuntu-2204' }} + runs-on: ubuntu-22.04 timeout-minutes: 120 - outputs: - revision: ${{ steps.source.outputs.revision }} - release_tag: ${{ steps.source.outputs.release_tag }} + permissions: + contents: write + packages: write steps: - uses: actions/checkout@v7 with: @@ -88,7 +88,16 @@ jobs: - name: Install build prerequisites run: | sudo apt-get update - sudo apt-get install -y build-essential pkg-config libssl-dev + sudo apt-get install -y build-essential pkg-config libssl-dev pigz + - name: Cache pinned release downloads + uses: actions/cache@v6 + with: + path: | + ~/.npm/_cacache + ~/.oac/cache/microsandbox-v0.7.2-linux-x86_64.tar.gz + key: release-downloads-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('scripts/prepare-release-runtimes.sh', 'packages/mcode-harness/source.json', 'scripts/core-distribution-manifest.py') }} + restore-keys: | + release-downloads-v1-${{ runner.os }}-${{ runner.arch }}- - name: Check release metadata and prepare pinned harnesses run: | PYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py @@ -127,22 +136,6 @@ jobs: compression-level: 0 if-no-files-found: error - release: - if: github.event_name == 'push' || inputs.draft_release - needs: [check, build] - 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: - ref: ${{ needs.build.outputs.revision }} - persist-credentials: false - - uses: actions/download-artifact@v6 - 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: @@ -152,13 +145,14 @@ jobs: 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 + if: github.event_name == 'push' || inputs.draft_release env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - RELEASE_REVISION: ${{ needs.build.outputs.revision }} - RELEASE_TAG: ${{ needs.build.outputs.release_tag }} + RELEASE_REVISION: ${{ steps.source.outputs.revision }} + RELEASE_TAG: ${{ steps.source.outputs.release_tag }} RELEASE_MODE: ${{ github.event_name == 'push' && 'publish' || 'draft' }} - run: python3 scripts/publish-core-release.py --assets release-upload + run: python3 scripts/publish-core-release.py --assets "$HOME/.oac/build/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/Makefile b/Makefile index 128b8473..e229d5f6 100644 --- a/Makefile +++ b/Makefile @@ -190,7 +190,7 @@ check-sandbox-provider-contract: .PHONY: check-docs check-ci check-docs: - PYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py + PYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py BundledDocsTests check-ci: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts -p 'ci_*test.py' diff --git a/docs/development.md b/docs/development.md index d2c35664..71a0e45c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -15,7 +15,7 @@ git worktree add ../openagentcore-change -b codex/my-change main cd ../openagentcore-change ``` -Install Go at the version in [go.mod](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/go.mod), Node 22.13 or newer, pnpm at the version in [package.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/package.json), and Python 3.9 or newer. The complete gate runs on Linux and needs a dedicated PostgreSQL database, OpenSSL development libraries for the microsandbox helper, and a Playwright browser. Provider and Runtime builds have additional prerequisites in their component guides. +Install Go at the version in [go.mod](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/go.mod), Node 22.13 or newer, pnpm at the version in [package.json](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/package.json), and Python 3.9 or newer. The complete gate runs on Linux and needs a dedicated PostgreSQL database, OpenSSL development libraries for the microsandbox helper, pigz for distribution compression, and a Playwright browser. Provider and Runtime builds have additional prerequisites in their component guides. ```sh pnpm install --frozen-lockfile diff --git a/docs/maintainers.md b/docs/maintainers.md index 35760550..9434b170 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -8,7 +8,7 @@ This guide is for maintainers who build and publish OpenAgentCore. To install Co A distribution is the matched set of Linux amd64 release assets built from one commit: the control archive (the installer, the `oac` command, and the Core, Web, gateway and PostgreSQL images), the Runtime image and node artifacts as separate files, and the native installers. -Build on Linux x86_64 with a glibc compatible with Debian 12, Docker, the Go version in `go.mod`, a C compiler (the microsandbox helper is a CGO build), Node, pnpm, Python 3.9 or newer, curl, tar and sha256sum. The source must be clean and committed. First prepare the pinned Codex package and MiniMax Code companion, then build: +Build on Linux x86_64 with a glibc compatible with Debian 12, Docker, the Go version in `go.mod`, a C compiler (the microsandbox helper is a CGO build), Node, pnpm, Python 3.9 or newer, curl, tar, pigz and sha256sum. The source must be clean and committed. First prepare the pinned Codex package and MiniMax Code companion, then build: ```sh bash scripts/prepare-release-runtimes.sh @@ -124,21 +124,23 @@ 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. 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. +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. After checks succeed, one `build` job on GitHub-hosted `ubuntu-22.04` prepares the pinned Runtime inputs, reuses the native installers, builds the distribution and publishes directly from its local files. This combined job has `contents: write` and `packages: write`; checkout does not persist credentials. It retains an uncompressed Actions artifact before publication for recovery, without downloading that artifact again during normal publication. + +Distribution and Runtime archives use `pigz` level 6 with at most four compression workers and no filename or timestamp in the gzip header. The publisher verifies archive and native installer checksums, resolves the repository identity, refuses an existing Release or draft for the tag and creates one draft with a fixed ID. Up to four assets upload concurrently, largest first, without retries. After confirming the complete remote inventory, the publisher validates all image archives and existing registry tags before pushing up to four images concurrently. Each image config and registry manifest is verified; any error leaves the Release unpublished. In-flight transfers finish before a failed operation returns. The publisher rechecks the version tag before publishing the draft by its ID. ### 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. +The combined build/publication 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. -Go check and build jobs share Go module and compiler-cache directories under `~/.oac/cache/`, keyed by runner OS and architecture, all Go module files, the check/build partition and the commit. Partitioned keys prevent concurrent jobs from saving different compiler subsets under one key. Release builds can seed their cache from backend checks as well as earlier release builds. An older cache only seeds downloads and compilation; every check still runs. New keys are saved only after a successful job. +Go check and build jobs share Go module and compiler-cache directories under `~/.oac/cache/`, keyed by runner OS and architecture, all Go module files, the check/build partition and the commit. Partitioned keys prevent concurrent jobs from saving different compiler subsets under one key. Release builds can seed their cache from backend checks as well as earlier release builds. An older cache only seeds downloads and compilation; every check still runs. Release jobs also cache npm package downloads and the pinned microsandbox archive, whose checksum is verified on every build. Actions cache visibility follows GitHub ref scoping; a tag-specific cache is not shared with other release tags. New keys are saved only after a successful job. -Never move a release tag or overwrite published assets. If the `release` job fails, inspect the Release first: publication may have completed despite a lost response. Leave a complete published Release as it is. For an incomplete draft, delete that draft (the job refuses any existing Release or draft for the tag), then rerun the failed `release` job, which reuses the original Actions artifact. Do not rerun the build or recreate the tag to recover a failed upload. +Never move a release tag or overwrite published assets. If publication fails, inspect the Release first: publication may have completed despite a lost response. Leave a complete published Release as it is. For an incomplete draft, delete that draft only after inspection, download the original `core-release-` Actions artifact with `gh run download RUN_ID --name core-release-REVISION --dir ASSET_DIRECTORY`, and use a checkout of that exact source revision to run `python3 scripts/publish-core-release.py --assets ASSET_DIRECTORY`. Set `GH_REPO`, `GH_TOKEN`, `RELEASE_REVISION`, `RELEASE_TAG` and `RELEASE_MODE` to the original publication inputs and sign Docker into GHCR for version publication. The script revalidates the assets and refuses existing releases. Do not rerun the combined build job or recreate the tag to recover a failed upload. ### Build a candidate without publishing @@ -196,9 +198,9 @@ Measure completed runs with `python3 scripts/ci_metrics.py RUN_ID ...`. It repor ### CI runners and free allowance -Linux jobs use Blacksmith's 2-vCPU Ubuntu 22.04 or 24.04 runners; native Windows uses its 2-vCPU Windows 2025 runner. Blacksmith has no 2-vCPU macOS runner, so native macOS uses the standard GitHub `macos-15` ARM64 runner. Release building and publication also use 2-vCPU Blacksmith runners. +Linux jobs use Blacksmith's 2-vCPU Ubuntu 22.04 or 24.04 runners; native Windows uses its 2-vCPU Windows 2025 runner. Blacksmith has no 2-vCPU macOS runner, so native macOS uses the standard GitHub `macos-15` ARM64 runner. Release building and publication always share one GitHub-hosted `ubuntu-22.04` runner, independent of the runner switch. -Set the repository Actions variable `OAC_USE_GITHUB_RUNNERS` to `true` to run all jobs on standard GitHub-hosted runners instead. Linux keeps its matching Ubuntu version, Windows uses `windows-2025`, and macOS continues using `macos-15`. Remove the variable or set it to `false` to return to Blacksmith's 2-vCPU defaults. For example, maintainers can switch when the organization's free allowance is used up, then restore Blacksmith after the allowance resets: +Set the repository Actions variable `OAC_USE_GITHUB_RUNNERS` to `true` to run all jobs on standard GitHub-hosted runners instead. Linux keeps its matching Ubuntu version, Windows uses `windows-2025`, and macOS continues using `macos-15`. Remove the variable or set it to `false` to return switchable check jobs to Blacksmith's 2-vCPU defaults. For example, maintainers can switch when the organization's free allowance is used up, then restore Blacksmith after the allowance resets: ```sh gh variable set OAC_USE_GITHUB_RUNNERS --body true --repo MiniMax-AI/OpenAgentCore diff --git a/scripts/build-core-distribution.sh b/scripts/build-core-distribution.sh index 31bfd32c..45c4e381 100755 --- a/scripts/build-core-distribution.sh +++ b/scripts/build-core-distribution.sh @@ -36,7 +36,7 @@ if [[ "$(uname -s)" != Linux || "$(uname -m)" != x86_64 ]]; then printf 'Build the distribution on Linux x86_64 with a glibc compatible with Debian 12\n' >&2 exit 1 fi -for command in docker go node pnpm python3 curl tar sha256sum; do +for command in docker go node pnpm python3 curl tar sha256sum pigz; do command -v "$command" >/dev/null done build_network="${CORE_DISTRIBUTION_BUILD_NETWORK:-default}" diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index c0116316..5d9bd746 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Verify matched distribution inputs and package independently fetched artifacts.""" -import gzip +from contextlib import contextmanager import hashlib import json import os @@ -253,6 +253,20 @@ def native_offline(bundle, stage): os.link(source, path.parent / (platform + ".tar.gz")) +@contextmanager +def compressed_output(path): + """Stream deterministic gzip with bounded parallel compression.""" + command = ["pigz", "-n", "-6", "-p", str(min(4, os.cpu_count() or 1))] + with pathlib.Path(path).open("wb") as raw: + with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=raw) as compressor: + try: + yield compressor.stdin + finally: + compressor.stdin.close() + if compressor.wait(): + raise subprocess.CalledProcessError(compressor.returncode, command) + + def package_artifacts(bundle, stage, revision): """Move optional payload out of Core; the manifest owns every asset digest.""" assets = stage / "artifacts" @@ -260,9 +274,8 @@ def package_artifacts(bundle, stage, revision): runtime = bundle / "images/runtime.tar" compressed = bundle / "images/runtime.tar.gz" unpacked = {"unpacked_sha256": sha256(runtime), "unpacked_size": runtime.stat().st_size} - with runtime.open("rb") as source, compressed.open("wb") as raw: - with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0, compresslevel=6) as output: - shutil.copyfileobj(source, output, 1024 * 1024) + with runtime.open("rb") as source, compressed_output(compressed) as output: + shutil.copyfileobj(source, output, 1024 * 1024) runtime.unlink() result = {} for logical, suffix in ARTIFACTS.items(): @@ -355,8 +368,8 @@ def archive(bundle, epoch, variant=""): if variant not in ("", "offline"): raise ValueError("Unknown distribution archive variant") output = bundle.with_name(bundle.name + ("-" + variant if variant else "") + ".tar.gz") - with output.open("wb") as raw, gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: - with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as tar: + with compressed_output(output) as compressed: + with tarfile.open(fileobj=compressed, mode="w|", format=tarfile.PAX_FORMAT) as tar: for path in sorted(bundle.rglob("*")): if not path.is_file(): continue diff --git a/scripts/core-distribution-manifest.test.py b/scripts/core-distribution-manifest.test.py index 73f0e795..bf65d8f8 100644 --- a/scripts/core-distribution-manifest.test.py +++ b/scripts/core-distribution-manifest.test.py @@ -204,10 +204,12 @@ def test_archive_reproducible_and_installer_executable(self): native.write_bytes(b"native executable") native.chmod(0o555) self.manifest() - distribution.archive(self.bundle, "1700000000") + with mock.patch.object(distribution.os, "cpu_count", return_value=1): + distribution.archive(self.bundle, "1700000000") archive = self.bundle.with_name(self.bundle.name + ".tar.gz") first = archive.read_bytes() - distribution.archive(self.bundle, "1700000000") + with mock.patch.object(distribution.os, "cpu_count", return_value=4): + distribution.archive(self.bundle, "1700000000") self.assertEqual(first, archive.read_bytes()) self.assertEqual(archive.with_name(archive.name + ".sha256").read_text(), distribution.sha256(archive) + " " + archive.name + "\n") with tarfile.open(archive) as contents: @@ -215,6 +217,15 @@ def test_archive_reproducible_and_installer_executable(self): self.assertEqual(contents.getmember(self.bundle.name + "/manifest.json").mode, 0o644) self.assertEqual(contents.getmember(self.bundle.name + "/native/bin/oac-core").mode, 0o555) + def test_compressor_failure_propagates(self): + real_popen = subprocess.Popen + with mock.patch.object(distribution.subprocess, "Popen", side_effect=lambda *args, **kwargs: + real_popen(["python3", "-c", "raise SystemExit(7)"], **kwargs)): + with self.assertRaises(subprocess.CalledProcessError) as raised: + with distribution.compressed_output(self.stage / "failed.gz"): + pass + self.assertEqual(raised.exception.returncode, 7) + def test_bad_upstream_checksum_does_not_extract(self): archive = self.stage / "untrusted.tar.gz" archive.write_bytes(b"not the pinned release") diff --git a/scripts/publish-core-release.py b/scripts/publish-core-release.py index 98ccbf68..4ad8f8c3 100644 --- a/scripts/publish-core-release.py +++ b/scripts/publish-core-release.py @@ -2,6 +2,7 @@ """Create and upload one draft, then publish its fixed ID without automatic retries.""" import argparse +from concurrent.futures import ThreadPoolExecutor, as_completed import importlib.util import json import os @@ -22,6 +23,18 @@ REPOSITORY = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+") +def parallel_each(function, items): + """Bound network transfers and propagate failures before publication.""" + with ThreadPoolExecutor(max_workers=4) as pool: + futures = [pool.submit(function, item) for item in items] + try: + return [future.result() for future in as_completed(futures)] + except BaseException: + for future in futures: + future.cancel() + raise + + def api(repository, endpoint, *args): url = endpoint if endpoint.startswith("https://") else "repos/" + repository + "/" + endpoint return json.loads(subprocess.check_output(["gh", "api", url, *args], text=True)) @@ -137,8 +150,9 @@ def publish_images(assets, repository, revision, tag): 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(): + def push_image(item): + name, (reference, config, local, remote) = item + print("Publishing registry image " + name, flush=True) if remote is None: subprocess.run(["docker", "tag", local, reference], check=True) subprocess.run(["docker", "push", reference], check=True) @@ -151,8 +165,9 @@ def publish_images(assets, repository, revision, tag): 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 + print("Verified registry image " + name, flush=True) + return name, {"tag": reference, "digest": reference.rsplit(":", 1)[0] + "@" + digest} + return dict(sorted(parallel_each(push_image, references.items()))) def publish(assets, repository, revision, tag, mode): @@ -217,7 +232,8 @@ def publish(assets, repository, revision, tag, mode): # Keep every operation bound to the ID returned by creation. No tag lookup, # overwrite, deletion or automatic retry can select another release. expected = {p.name: p.stat().st_size for p in files} - for path in files: + def upload(path): + print(f"Uploading {path.name} ({expected[path.name]} bytes)", flush=True) uploaded = api(repository, "https://uploads.github.com/repos/" + repository + "/" + endpoint + "/assets?name=" + quote(path.name, safe=""), "--method", "POST", "-H", "Content-Type: application/octet-stream", @@ -225,6 +241,8 @@ def publish(assets, repository, revision, tag, mode): if (uploaded["state"] != "uploaded" or uploaded["name"] != path.name or uploaded["size"] != expected[path.name]): raise ValueError("Asset upload was not confirmed; inspect the draft") + print("Uploaded " + path.name, flush=True) + parallel_each(upload, sorted(files, key=lambda path: expected[path.name], reverse=True)) release = api(repository, endpoint) verify_draft(release, tag, revision) actual = release["assets"] diff --git a/scripts/publish-core-release.test.py b/scripts/publish-core-release.test.py index f7e8a025..b98b9314 100644 --- a/scripts/publish-core-release.test.py +++ b/scripts/publish-core-release.test.py @@ -10,6 +10,7 @@ import subprocess import tempfile import unittest +import threading from unittest import mock from urllib.parse import unquote @@ -116,6 +117,31 @@ def publish(self, tag="v1.2.3", mode="publish"): def writes(self): return [c for c in self.api.call_args_list if "--method" in c.args] + def test_uploads_overlap_and_inventory_waits_for_all_transfers(self): + barrier = threading.Barrier(4, timeout=5) + active = 0 + peak = 0 + lock = threading.Lock() + def response(repo, endpoint, *args): + nonlocal active, peak + if endpoint.startswith("https://uploads."): + with lock: + active += 1 + peak = max(peak, active) + barrier.wait() + result = self.response(repo, endpoint, *args) + with lock: + active -= 1 + return result + if endpoint == "releases/7": + self.assertEqual(active, 0) + self.assertEqual(len(self.release["assets"]), 12) + return self.response(repo, endpoint, *args) + self.api.side_effect = response + self.publish() + self.assertEqual(peak, 4) + self.assertFalse(self.release["draft"]) + def test_version_tag_publishes_complete_fixed_id(self): self.publish() self.assertFalse(self.release["draft"]) @@ -224,7 +250,10 @@ def response(repo, endpoint, *args): with self.assertRaises(subprocess.CalledProcessError): self.publish() self.assertTrue(self.release["draft"]) - self.assertEqual(len(self.writes()), 2) + uploads = [c for c in self.writes() if c.args[1].startswith("https://uploads.")] + self.assertGreaterEqual(len(uploads), 1) + self.assertEqual(len({c.args[1] for c in uploads}), len(uploads)) + self.images.assert_not_called() self.assertFalse(any("PATCH" in c.args or "DELETE" in c.args for c in self.writes())) def test_lost_publication_response_never_deletes_or_retries(self): @@ -354,7 +383,7 @@ def test_new_images_use_resolved_store_identity_and_version_only(self): 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 + self.remote.side_effect = lambda reference: image if "@" in reference else index result = self.publish() self.assertEqual(self.pushes(), []) self.assertEqual(result["core"]["digest"], "ghcr.io/minimax-ai/openagentcore/core@" + self.digest) @@ -392,7 +421,26 @@ def run(command, **kwargs): self.run.side_effect = run with self.assertRaises(subprocess.CalledProcessError): self.publish() - self.assertEqual(len(self.pushes()), 1) + self.assertGreaterEqual(len(self.pushes()), 1) + self.assertEqual(len({command[-1] for command in self.pushes()}), len(self.pushes())) + + def test_registry_pushes_overlap_after_all_preflight_checks(self): + barrier = threading.Barrier(4, timeout=5) + pushed = set() + lock = threading.Lock() + def remote(reference): + with lock: + return {"config": {"digest": self.config}} if reference in pushed else None + def run(command, **kwargs): + if command[1] == "push": + self.assertEqual(sum(c.args[0][1] == "load" for c in self.run.call_args_list), 4) + barrier.wait() + with lock: + pushed.add(command[-1]) + self.remote.side_effect = remote + self.run.side_effect = run + self.assertEqual(len(self.publish()), 4) + self.assertEqual(len(pushed), 4) def test_registry_auth_failure_is_not_missing_image(self): # Test the actual inspection function separately from the publication fixture.