diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index cf729e58e..a6770a079 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,6 +1,12 @@ name: core-check on: + workflow_call: + inputs: + ref: + description: Source commit to check + required: false + type: string push: branches: [main] pull_request: @@ -9,7 +15,7 @@ permissions: contents: read concurrency: - group: core-check-${{ github.ref }} + group: core-check-${{ github.workflow }}-${{ inputs.ref && github.run_id || github.ref }} cancel-in-progress: true jobs: @@ -32,6 +38,8 @@ jobs: --health-retries 10 steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.sha }} - uses: actions/setup-go@v7 with: go-version-file: go.mod diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9697fb88a..421028924 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,11 +22,17 @@ permissions: contents: read concurrency: - group: core-release-${{ inputs.ref || github.sha }} + group: core-release-${{ github.event_name == 'push' && github.ref || inputs.ref }} cancel-in-progress: false jobs: + check: + uses: ./.github/workflows/check.yml + with: + ref: ${{ inputs.ref || github.sha }} + build: + needs: check runs-on: ubuntu-22.04 timeout-minutes: 120 outputs: @@ -97,6 +103,8 @@ jobs: for asset in "$HOME/.oac/build/core-distribution/"*; do if [[ -f "$asset" ]]; then ln "$asset" "$HOME/.oac/build/release-upload/"; fi done + cp deploy/install-release.sh "$HOME/.oac/build/release-upload/install.sh" + (cd "$HOME/.oac/build/release-upload" && sha256sum install.sh > install.sh.sha256) - uses: actions/upload-artifact@v6 with: name: core-release-${{ steps.source.outputs.revision }} @@ -104,24 +112,26 @@ jobs: compression-level: 0 if-no-files-found: error - draft: - if: github.event_name == 'workflow_dispatch' && inputs.draft_release + release: + if: github.event_name == 'push' || inputs.draft_release needs: build runs-on: ubuntu-22.04 permissions: contents: 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: Create an unpublished draft + - name: Publish the version tag or create a manual draft env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} RELEASE_REVISION: ${{ needs.build.outputs.revision }} RELEASE_TAG: ${{ needs.build.outputs.release_tag }} - run: | - printf 'Matched Linux amd64 artifacts from commit %s. Build output is not live execution qualification.\n' "$RELEASE_REVISION" > release-notes.md - gh release create "$RELEASE_TAG" --draft --target "$RELEASE_REVISION" \ - --title "OpenAgentCore $RELEASE_REVISION" --notes-file release-notes.md release-upload/* + RELEASE_MODE: ${{ github.event_name == 'push' && 'publish' || 'draft' }} + run: python3 scripts/publish-core-release.py --assets release-upload diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19142361d..ab8741c33 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1973,9 +1973,19 @@ refers to. `make build-core-distribution` builds from clean committed source and reuses the existing API, Runtime, SDK, helper and Web builders. Artifacts record source and immutable image identities, the actual Runtime manifest digest, checksums and -microsandbox runtime/firmware hashes and executable native payloads. Release generation is not publication or -qualification. A release must be tested from fresh extraction with real models; -no synthetic result may substitute for native execution acceptance. +microsandbox runtime/firmware hashes and executable native payloads. Local distribution builds do not publish. The tag-triggered release workflow +reuses the full repository check on the exact build source, then publishes the +matched assets automatically; manual runs remain artifact-only or draft-only. +Only publication receives repository write permission. Never overwrite release +assets or move an existing version tag. The [maintainer guide](docs/maintainers.md#publish-a-version) +owns tag syntax, prereleases and failed-publication recovery. +Release assets include `deploy/install-release.sh` as standalone `install.sh` +with a checksum. This public downloader resolves latest once (or a selected tag), +verifies the offline archive before safe extraction, and delegates to that bundle's +installer. It introduces no separate installation state, upgrade path or login flow. +Build/test success is distinct from real-model qualification; maintainers assess +that evidence before pushing a release tag, and no synthetic result substitutes +for native execution acceptance. Distribution `images` records each exported image's config digest; `image_manifest_digests` records its OCI manifest/index digest. Derive and verify diff --git a/Makefile b/Makefile index 23c43bda5..e54d3c41f 100644 --- a/Makefile +++ b/Makefile @@ -129,10 +129,12 @@ check-distribution: go test ./services/core-console -count=1 PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s deploy/install -p 'test_*.py' PYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py + PYTHONDONTWRITEBYTECODE=1 python3 scripts/publish-core-release.test.py + PYTHONDONTWRITEBYTECODE=1 python3 scripts/install-release.test.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/promote-qualified-release.test.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/qualification-control.test.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/config-reference.py --check - bash -n deploy/install/install.sh scripts/build-core-console.sh scripts/build-core-distribution.sh scripts/prepare-release-runtimes.sh + bash -n deploy/install/install.sh deploy/install-release.sh scripts/build-core-console.sh scripts/build-core-distribution.sh scripts/prepare-release-runtimes.sh ./scripts/build-core-console.sh build-core-distribution: diff --git a/README.md b/README.md index 84caffb1c..34da05780 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,12 @@ administrator console. ## Quick start +On a Linux amd64 host with Docker and Python 3.9+, install the latest stable release: + +```sh +curl -fsSL https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh | bash +``` + 1. [Install Core and Web](docs/getting-started/install.md) on a Linux host. The guide covers prerequisites, release download, local trials and HTTPS setup. 2. Sign in to Web with the installer-created Core key. Configure a model provider, diff --git a/README.zh-CN.md b/README.zh-CN.md index 34afd29ec..d887f9525 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,6 +10,12 @@ Web 提供管理员控制台。 ## 快速开始 +在已准备 Docker 和 Python 3.9+ 的 Linux amd64 主机上,一条命令安装最新正式版: + +```sh +curl -fsSL https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh | bash +``` + 1. 在 Linux 主机上[安装 Core 和 Web](docs/getting-started/install.md)。安装指南包含环境要求、 发行包下载、本地试用和 HTTPS 配置。 2. 用安装器生成的 Core key 登录 Web,配置模型提供方,创建 Project 并签发应用 API key。 diff --git a/apps/docs/content/docs/install.mdx b/apps/docs/content/docs/install.mdx index 099b8bc18..1d0c8d865 100644 --- a/apps/docs/content/docs/install.mdx +++ b/apps/docs/content/docs/install.mdx @@ -16,7 +16,7 @@ add nodes. Applications call Core's API with those keys. 6. [Add a node](/hosted-providers). These pages describe the current source. Every bundle carries the docs that match it -under `docs/`. This private project supports fresh installation and repair of the +under `docs/`. Core supports fresh installation and repair of the same release; historical-version upgrades and conversion are unsupported. ## Prerequisites @@ -28,7 +28,7 @@ same release; historical-version upgrades and conversion are unsupported. - A non-root user who can run `docker`. The installer refuses root. - Free loopback ports 8091 (Core) and 8080 (Web), or [other ports](#ports-and-directory). -- The GitHub CLI, `gh`, to download the bundle. +- curl to fetch the installation script; no GitHub CLI or login is required. The Core host needs no KVM and no systemd user services, unless you choose [native Core](#native-core). @@ -45,48 +45,43 @@ public URL and set it later. ## Download a release -The repository is internal for now, so GitHub asks you to sign in first: +Every Release includes a standalone [install.sh](https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh). +It selects the latest published stable release by default, downloads that release's +offline bundle and checksum, verifies the archive, and runs its bundled installer. +You do not need to find a tag, commit SHA or archive filename. -```sh -gh auth login -``` +## Install -Pick a release, download its offline bundle and check it: +Install the latest stable release: ```sh -gh release list --repo MiniMax-AI/parsar-core -mkdir -p "$HOME/.oac/releases" && cd "$HOME/.oac/releases" -gh release download --repo MiniMax-AI/parsar-core --pattern '*-linux-amd64-offline.tar.gz*' -sha256sum -c oac--linux-amd64-offline.tar.gz.sha256 -tar -xzf oac--linux-amd64-offline.tar.gz -cd oac--linux-amd64 +curl -fsSL https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh | bash ``` -`` is the release tag from the list, and `` is the source commit in the -asset names. The installer also checks every file in the bundle against its -`SHA256SUMS` before it changes anything. - -Each release has two bundles: - -| Bundle | Contains | Use it for | -| --- | --- | --- | -| `oac--linux-amd64-offline.tar.gz` | Core, Web and PostgreSQL images, the installers and every node and Runtime file | Any installation. Web serves the node files to your nodes and self-hosted executors | -| `oac--linux-amd64.tar.gz` | The same without the node and Runtime files | Core-only hosts, and installations that use only E2B. Web can't add nodes or connect self-hosted executors until the files are present | - -To add the node files to the smaller bundle, create an `artifacts/` directory in the -extracted bundle, download the release's other `oac--linux-amd64-*` -assets (not the two bundles) into it, then run `./install.sh`, or rerun it if the -installation already exists. Nodes download these files only from your Web console, -never from GitHub, so node hosts need no GitHub access. - -## Install - -From the extracted bundle: +For an HTTPS deployment, pass the public address: ```sh -./install.sh --public-url https://core.example +curl -fsSL https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh | bash -s -- --public-url https://core.example ``` +To select a version, add `--version v1.2.3` after `bash -s --`. Explicit versions +may include prereleases; the default never selects one. Other arguments go unchanged +to the bundled installer. You can also download `install.sh` from the Release page +and run `bash install.sh --version v1.2.3`. The examples below use this saved script +or the bundle's existing `./install.sh`. + +The downloader resolves the release once, verifies SHA-256 before extraction, and +retains the verified bundle under `~/.oac/releases/` for repair. It never replaces +an existing installation or upgrades it. To repair an installed version, select +that same version explicitly. The bundled installer also checks its own +`SHA256SUMS` before changing installation state. + +For machines without GitHub access, transfer the Release's +`*-linux-amd64-offline.tar.gz` and matching `.sha256` file, verify and extract +them locally, then run the bundled `./install.sh`. The smaller non-offline archive +omits node and Runtime files and is intended for advanced Core-only or E2B setups; +use the offline bundle for the ordinary installation path. + The installer: 1. checks the host and the bundle, and loads the Core, Web and PostgreSQL images; diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index b15cf52b9..a1595b3d9 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -4,7 +4,7 @@ "docs/getting-started/README.md": "cdf8046c616574102a0ffc7644ac0fe82e63eb1906e6c9f1f6b635810e223757", "docs/design-principles.md": "a33acd6e7bc80105a5c934d0be214c0cff59103e96c098f5b09179c86156c01d", "docs/web/architecture.md": "80bdb8053c06c2b2030b193aae17d5434545f9c72af50e9d3df3ade522fe25ed", - "docs/getting-started/install.md": "e2043e0abdd7fc77bbad8d7fe3e17310421a94497c3b73236e653d525c8d34c1", + "docs/getting-started/install.md": "93213e3d2f8829ff1d5d32d89c30168c210a63d8073ede80d5e9382677ece239", "docs/configuration.md": "0c15f6253233f4766cae5dda4d33a120c7c53a4770353a86db4890e2a8e7e078", "docs/web/core-connection.md": "46f86520e70bf41079708e2c398655aa9087ad9488c8e661d9a73035d11098c4", "docs/getting-started/quickstart.md": "238b4de1137edb8c2998b38f4525fea345a19fd4de7a455aa9c4c9e4fc3c9b36", @@ -31,7 +31,7 @@ "content/docs/index.mdx": "13c2f2274ba4a7d2845e003a92b6816e0ae4a8c216029183acd0079c9b5ba094", "content/docs/concepts.mdx": "bdb3d5e96d2c9c7912a52a3cda48bf3c120b8d7518b7e429213c1ca02e7be2f2", "content/docs/execution-model.mdx": "c76a992e8f4ca175e8db7192a1c5eba4e3baf4889f47b001bc3749ee708b3e54", - "content/docs/install.mdx": "56dc38120a2ac7974c08497c0a18bff16df4b9c521575f2c7fbe16a27055e575", + "content/docs/install.mdx": "41d44ed9e8df1a4123815348151bb052ef39aad503b2e90c945e6a3e82d28d3c", "content/docs/configure.mdx": "af63fb2ff5d61c3198fd54f1610f3782513682c3c0e13cfea425ba0de1dc4ca1", "content/docs/bootstrap-projects-keys.mdx": "db119f9a3ad91fadd2558fa17681a6045910d0d02991db4b75e87d73e72c2bd5", "content/docs/quickstart.mdx": "8b4794b3ec34aa068f41b4fdc7dadd5daebcbf35e9cbd37964f4a30ba20808b4", diff --git a/deploy/distribution/Runtime.Dockerfile b/deploy/distribution/Runtime.Dockerfile index 58f25df95..913c1f4a0 100644 --- a/deploy/distribution/Runtime.Dockerfile +++ b/deploy/distribution/Runtime.Dockerfile @@ -1,5 +1,5 @@ # Each input is an immutable Linux amd64 image built from the same Core revision. -# Reuse the native packages and isolation configuration from existing profiles. +# Reuse the native packages from existing profiles. ARG CODEX_IMAGE ARG CLAUDE_IMAGE ARG MCODE_IMAGE @@ -7,23 +7,19 @@ FROM ${CODEX_IMAGE} AS codex FROM ${CLAUDE_IMAGE} AS claude FROM ${MCODE_IMAGE} -# Keep the shared daemon, helpers and prebuilt tool-system seed from this base. -# Native harness packages remain outside the tool-system seed and workspace. +# Keep the shared daemon and dependencies from the MiniMax base. +# Native harness packages remain outside the workspace. COPY --from=codex /usr/local/bin/codex /usr/local/bin/codex COPY --from=codex /usr/local/codex-resources /usr/local/codex-resources -COPY --from=codex /etc/codex /etc/codex COPY --from=claude /opt/claude-sdk /opt/claude-sdk -COPY --from=claude /usr/local/bin/oac-claude-shell-prefix /usr/local/bin/oac-claude-shell-prefix ENV OAC_RUNTIME_CODEX_BIN=/usr/local/bin/codex \ - OAC_RUNTIME_CODEX_PERMISSION_PROFILE=managed-workspace \ OAC_RUNTIME_CLAUDE_SDK_NODE=/usr/local/bin/node \ OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT=/opt/claude-sdk/dist/main.js \ OAC_RUNTIME_CLAUDE_SDK_WORKSPACE=managed USER 1000:1000 RUN test "$(codex --version)" = "codex-cli 0.153.4" \ - && test -r /etc/codex/requirements.toml \ && node /opt/claude-sdk/dist/runtime_check.js /opt/claude-sdk/dist/main.js \ && node /opt/mcode-harness/check.mjs \ && /opt/mcode-harness/native/cli.js --version diff --git a/deploy/install-release.sh b/deploy/install-release.sh new file mode 100755 index 000000000..eaaf32902 --- /dev/null +++ b/deploy/install-release.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Download one matched release and delegate installation to its bundled installer. +set -euo pipefail +command -v python3 >/dev/null || { echo 'Python 3.9+ is required.' >&2; exit 1; } +# Keep the caller's stdin available to the bundled installer. +exec python3 /dev/fd/3 "$@" 3<<'PY' +import argparse +import hashlib +import json +import os +import pathlib +import platform +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import urllib.error +import urllib.parse +import urllib.request + +REPOSITORY = "MiniMax-AI/parsar-core" +API = "https://api.github.com/repos/" + REPOSITORY +ARCHIVE = re.compile(r"oac-([0-9a-f]{40})-linux-amd64-offline\.tar\.gz") + + +class ReleaseError(Exception): + pass + + +class DownloadRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, fp, code, msg, headers, newurl): + if urllib.parse.urlsplit(newurl).scheme != "https": + raise ReleaseError("Release downloads require HTTPS") + redirected = super().redirect_request(request, fp, code, msg, headers, newurl) + return redirected + + +def open_url(url, binary=False): + headers = {"Accept": "application/octet-stream" if binary else "application/vnd.github+json", + "User-Agent": "OpenAgentCore-installer", "X-GitHub-Api-Version": "2022-11-28"} + request = urllib.request.Request(url, headers=headers) + try: + return urllib.request.build_opener(DownloadRedirect()).open(request, timeout=60) + except urllib.error.HTTPError as error: + if error.code in (401, 403, 404): + raise ReleaseError("Release unavailable. Check the version and GitHub access limits.") from None + raise ReleaseError("GitHub download failed (HTTP " + str(error.code) + "). Retry later.") from None + except urllib.error.URLError: + raise ReleaseError("Could not reach GitHub. Check the network and retry.") from None + + +def select_release(version): + endpoint = "/releases/latest" if version == "latest" else "/releases/tags/" + urllib.parse.quote(version, safe="") + with open_url(API + endpoint) as response: + release = json.load(response) + if release.get("draft") or (version == "latest" and release.get("prerelease")): + raise ReleaseError("Select a published release; latest excludes prereleases") + assets = release.get("assets", []) + bundles = [asset for asset in assets if ARCHIVE.fullmatch(asset["name"])] + if len(bundles) != 1: + raise ReleaseError("This release must contain exactly one Linux amd64 offline bundle") + bundle = bundles[0] + sums = [asset for asset in assets if asset["name"] == bundle["name"] + ".sha256"] + if len(sums) != 1: + raise ReleaseError("This release is missing its unique bundle checksum") + for asset in (bundle, sums[0]): + if type(asset.get("id")) is not int or asset["id"] <= 0: + raise ReleaseError("Invalid release asset identity") + return release["tag_name"], bundle, sums[0] + + +def download(asset, destination, limit=None): + # Resolve latest once, then download only those immutable asset IDs. + with open_url(API + "/releases/assets/" + str(asset["id"]), binary=True) as response: + with destination.open("wb") as output: + size = 0 + for chunk in iter(lambda: response.read(1024 * 1024), b""): + size += len(chunk) + if limit is not None and size > limit: + raise ReleaseError("Release checksum file is too large") + output.write(chunk) + + +def extract(archive, destination, stem): + seen = set() + with tarfile.open(archive, "r:gz") as source: + for member in source: + path = pathlib.PurePosixPath(member.name) + if (not member.isfile() or path.is_absolute() or ".." in path.parts + or "\\" in member.name or len(path.parts) < 2 or path.parts[0] != stem + or path in seen): + raise ReleaseError("Release archive contains an unsafe or duplicate path") + seen.add(path) + target = destination.joinpath(*path.parts) + target.parent.mkdir(parents=True, exist_ok=True) + with source.extractfile(member) as data, target.open("xb") as output: + shutil.copyfileobj(data, output) + target.chmod(member.mode & 0o777) + root = destination / stem + if not (root / "install.sh").is_file() or not (root / "manifest.json").is_file(): + raise ReleaseError("Release archive is missing its installer or manifest") + return root + + +def install(version, arguments): + if sys.version_info < (3, 9): + raise ReleaseError("Python 3.9+ is required") + if platform.system() != "Linux" or platform.machine() not in ("x86_64", "amd64"): + raise ReleaseError("Core installation currently requires Linux amd64") + if os.geteuid() == 0: + raise ReleaseError("Run the installer as a non-root user with Docker access") + tag, bundle, sums = select_release(version) + print("Installing OpenAgentCore " + tag, flush=True) + home = pathlib.Path.home() / ".oac" + home.mkdir(mode=0o700, exist_ok=True) + cache = home / "releases" + cache.mkdir(mode=0o700, exist_ok=True) + extracted = pathlib.Path(tempfile.mkdtemp(prefix="release-", dir=cache)) + try: + with tempfile.TemporaryDirectory(prefix=".download-", dir=cache) as temporary: + archive = pathlib.Path(temporary) / bundle["name"] + checksum = pathlib.Path(temporary) / sums["name"] + download(sums, checksum, limit=1024) + expected = checksum.read_text().strip() + match = re.fullmatch(r"([0-9a-f]{64}) " + re.escape(bundle["name"]), expected) + if not match: + raise ReleaseError("Invalid release checksum file") + download(bundle, archive) + digest = hashlib.sha256() + with archive.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + if digest.hexdigest() != match[1]: + raise ReleaseError("Release checksum mismatch; installation was not started") + stem = bundle["name"].removesuffix("-offline.tar.gz") + root = extract(archive, extracted, stem) + manifest = json.loads((root / "manifest.json").read_text()) + if manifest.get("source_commit") != ARCHIVE.fullmatch(bundle["name"])[1]: + raise ReleaseError("Release source does not match its bundle") + except BaseException: + shutil.rmtree(extracted) + raise + # Keep the verified extracted bundle for same-version repair. + print("Verified bundle: " + str(root), flush=True) + return subprocess.call(["bash", str(root / "install.sh"), *arguments]) + + +def main(argv): + parser = argparse.ArgumentParser( + prog="install.sh", description="Install the latest OpenAgentCore release. Other arguments go to its installer.", + allow_abbrev=False) + parser.add_argument("--version", default="latest", help="Release tag (default: latest stable release)") + options, arguments = parser.parse_known_args(argv) + return install(options.version, arguments) + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv[1:])) + except (ReleaseError, OSError, ValueError, tarfile.TarError) as error: + print("Installation failed: " + str(error), file=sys.stderr) + sys.exit(1) +PY diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index 0ce3b7a5a..86f4f1df8 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -13,7 +13,7 @@ add nodes. Applications call Core's API with those keys. 6. [Add a node](nodes.md). These pages describe the current source. Every bundle carries the docs that match it -under `docs/`. This private project supports fresh installation and repair of the +under `docs/`. Core supports fresh installation and repair of the same release; historical-version upgrades and conversion are unsupported. ## Prerequisites @@ -25,7 +25,7 @@ same release; historical-version upgrades and conversion are unsupported. - A non-root user who can run `docker`. The installer refuses root. - Free loopback ports 8091 (Core) and 8080 (Web), or [other ports](#ports-and-directory). -- The GitHub CLI, `gh`, to download the bundle. +- curl to fetch the installation script; no GitHub CLI or login is required. The Core host needs no KVM and no systemd user services, unless you choose [native Core](#native-core). @@ -42,48 +42,43 @@ public URL and set it later. ## Download a release -The repository is internal for now, so GitHub asks you to sign in first: +Every Release includes a standalone [install.sh](https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh). +It selects the latest published stable release by default, downloads that release's +offline bundle and checksum, verifies the archive, and runs its bundled installer. +You do not need to find a tag, commit SHA or archive filename. -```sh -gh auth login -``` +## Install -Pick a release, download its offline bundle and check it: +Install the latest stable release: ```sh -gh release list --repo MiniMax-AI/parsar-core -mkdir -p "$HOME/.oac/releases" && cd "$HOME/.oac/releases" -gh release download --repo MiniMax-AI/parsar-core --pattern '*-linux-amd64-offline.tar.gz*' -sha256sum -c oac--linux-amd64-offline.tar.gz.sha256 -tar -xzf oac--linux-amd64-offline.tar.gz -cd oac--linux-amd64 +curl -fsSL https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh | bash ``` -`` is the release tag from the list, and `` is the source commit in the -asset names. The installer also checks every file in the bundle against its -`SHA256SUMS` before it changes anything. - -Each release has two bundles: - -| Bundle | Contains | Use it for | -| --- | --- | --- | -| `oac--linux-amd64-offline.tar.gz` | Core, Web and PostgreSQL images, the installers and every node and Runtime file | Any installation. Web serves the node files to your nodes and self-hosted executors | -| `oac--linux-amd64.tar.gz` | The same without the node and Runtime files | Core-only hosts, and installations that use only E2B. Web can't add nodes or connect self-hosted executors until the files are present | - -To add the node files to the smaller bundle, create an `artifacts/` directory in the -extracted bundle, download the release's other `oac--linux-amd64-*` -assets (not the two bundles) into it, then run `./install.sh`, or rerun it if the -installation already exists. Nodes download these files only from your Web console, -never from GitHub, so node hosts need no GitHub access. - -## Install - -From the extracted bundle: +For an HTTPS deployment, pass the public address: ```sh -./install.sh --public-url https://core.example +curl -fsSL https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh | bash -s -- --public-url https://core.example ``` +To select a version, add `--version v1.2.3` after `bash -s --`. Explicit versions +may include prereleases; the default never selects one. Other arguments go unchanged +to the bundled installer. You can also download `install.sh` from the Release page +and run `bash install.sh --version v1.2.3`. The examples below use this saved script +or the bundle's existing `./install.sh`. + +The downloader resolves the release once, verifies SHA-256 before extraction, and +retains the verified bundle under `~/.oac/releases/` for repair. It never replaces +an existing installation or upgrades it. To repair an installed version, select +that same version explicitly. The bundled installer also checks its own +`SHA256SUMS` before changing installation state. + +For machines without GitHub access, transfer the Release's +`*-linux-amd64-offline.tar.gz` and matching `.sha256` file, verify and extract +them locally, then run the bundled `./install.sh`. The smaller non-offline archive +omits node and Runtime files and is intended for advanced Core-only or E2B setups; +use the offline bundle for the ordinary installation path. + The installer: 1. checks the host and the bundle, and loads the Core, Web and PostgreSQL images; diff --git a/docs/maintainers.md b/docs/maintainers.md index 594da8543..0c7b2b673 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -32,135 +32,64 @@ A bundle carries a fixed set of docs (the build lists them). Links between them relative; every other relative link is rewritten to the same file on GitHub at the bundle's commit, and the build fails if a link or anchor does not resolve. -## Produce and qualify a release +## Publish a version -The `core-release` GitHub Actions workflow builds production assets from a full -committed source SHA with the pinned Runtime builders. Acceptance credentials and -private test certificate authorities must never enter its inputs. Run it from the -repository's Actions page, or: +Push a version tag on the reviewed commit to run `core-release`: ```sh -revision=$(git rev-parse HEAD) -gh workflow run core-release --repo MiniMax-AI/parsar-core --ref main \ - -f ref="$revision" -f offline=true -f draft_release=true +git tag -a v1.2.3 FULL_REVIEWED_COMMIT_SHA -m "OpenAgentCore v1.2.3" +git push origin v1.2.3 ``` -The workflow uploads the matched files as an Actions artifact and, with -`draft_release`, creates a draft Release tagged `build-`. The manifest records -the same tag in every asset URL. Don't mix files across releases or resolve components -through `latest`. - -Download the draft assets with repository access, verify their checksums, and qualify a -fresh installation plus the node and self-hosted connection paths before publishing the -draft. A workflow build alone is not live acceptance. Publish exactly the tested assets; -never rebuild or replace files under the same release identity. Publishing a Release -does not change the repository's visibility. - -The workflow defaults to offline output, including tag-triggered builds. It remains -a candidate builder; it does not publish merely because a build succeeded. - -For the current installation batch, the promotion controller runs on the existing -operator host with gh authentication and SSH access to the qualification host: +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. Tag creation is the maintainer's release decision. + +The workflow checks that exact source with the shared `make check` workflow, +then builds the Linux amd64 Core, Web, Runtime and database images, installation +archives and versioned Runtime assets. Tag builds include the offline archive. +It uploads the matched files as an Actions artifact and automatically publishes +them in the same tag's GitHub Release. Downloads in the manifest refer to that +tag. Each Release also includes the standalone `install.sh` bootstrap and its +checksum. It defaults to the latest stable release and accepts `--version`; +the [installation guide](getting-started/install.md#install) owns its usage. +Images are shipped as archives; this workflow does not push an image registry. +Publishing a Release does not change the repository's visibility. + +Build and check jobs have read-only repository permissions. Only the publication +job receives `contents: write`. Before publication it verifies archive checksums +and confirms that the remote lightweight or annotated tag still resolves to the +built commit after uploading the draft assets. Publication sends one request for +that fixed Release ID. An upload failure cannot expose an incomplete public Release; +an ambiguous publication response leaves the Release intact for inspection. + +Do not move release tags or overwrite published assets. A rerun refuses an +existing Release, including a partial draft, rather than replacing files. If the +publication job fails, inspect the Release first: it may have completed despite +a lost response. Leave a complete published Release intact. For an incomplete +draft, reconcile or remove only that draft before rerunning the failed publication +job, which reuses the original Actions artifact. Do not rerun the successful build +or recreate the tag to recover a failed upload. + +Automated checks establish build and test results, not real-model qualification. +Keep live execution evidence separate and assess it before pushing the version +tag. No model credentials or private certificate authorities belong in CI inputs. + +### Build a candidate without publishing + +Manual runs accept a full source commit SHA. They run the same checks and build +steps, default to offline output, and never publish automatically: ```sh -python3 scripts/promote-qualified-release.py \ - --source FULL_CANDIDATE_SOURCE_SHA \ - --assets /absolute/path/to/flat-candidate-assets \ - --qualification-package /absolute/path/to/reviewed-private-package \ - --qualification-manifest-sha256 FULL_REVIEWED_MANIFEST_SHA256 \ - --merge-wait-seconds 86400 \ - --state /absolute/path/to/new-promotion-evidence \ - --host zju_a100_2 --remote-root /absolute/path/to/existing-isolated-acceptance-root \ - --promotion-commit FULL_REVIEWED_TOOLING_COMMIT_SHA +revision=$(git rev-parse HEAD) +gh workflow run core-release --repo MiniMax-AI/parsar-core --ref main \ + -f ref="$revision" -f offline=true -f draft_release=true ``` -This command is an operational publication command, not a dry run. It does not -build, merge PRs or create host accounts. Its directly supervised qualification -scripts perform the reviewed fresh installation and real execution actions. Before -starting, independently review the private package and record its manifest hash. -The package is separate from the candidate. `ready=true` describes the generic -adapter protocol only; it does not establish package review, host readiness or -successful live qualification. - -Stage only the build's flat files, including thin/offline archives, both checksum -files and every versioned Runtime asset; exclude the extracted bundle directory. -Before staging files from the builder, compare their full inventory byte total plus -64 MiB of working reserve with actual local free space. The controller uses only -that existing single asset set and hashes subsequent GitHub downloads as streams; -it checks the additional reserve and records both byte counts. It never removes -user files to make room. -The remote parent directory must already exist within the batch's authorized scope. -The controller creates one new run directory, copies verified draft assets there, -and invokes the reviewed adapter over SSH. For this batch, zju coordinates managed stages and reaches mx2 through existing -SSH for the fresh Core/node stages; no new service or credentials are required. -GitHub credentials stay on the operator host; nodes continue to download from their console. - -The adapter protocol is documented in `scripts/qualify-core-release.py`: one JSON -request as the first stdin line followed by `ping` heartbeat lines, one bound JSON -result on stdout, redacted diagnostics on stderr, -and a nonzero exit for any failed or skipped required check. The five current-batch -checks cover one fresh container installation, its current lifecycle, one managed -native Session, read-only diagnostics/observations for that Session, and one -current Runtime Session on one new node. The full multi-host, generation and GC -matrix is not rerun for this promotion. Their actual -commands must operate on freshly extracted supplied assets and respect the agreed -resource ownership. The controller verifies remote asset hashes before and after -execution; it has no pass-file option. Execution commands come only from the -maintainer-pinned package, never from candidate metadata or a stage result. - -The package root contains `manifest.json` and exactly its enumerated regular files; -symlinks, extra files and changed bytes are rejected. Manifest version 1 has `files` -(relative path to SHA256/size), `configuration` (reviewed resource bounds and private -file paths, no credential values), and five ordered `stages`. Each stage has `name`, -absolute `python` interpreter path, package-relative `.py` `script`, structured -string `args`, and `timeout_seconds` (1–14400). No shell command or candidate-driven -substitution is used. The explicit SHA256 covers the exact manifest bytes. Preserve -the reviewed package together with all evidence. - -The supervisor gives each child the unchanged controller identity and inventory, -`qualification_package`, `qualification_manifest_sha256`, `package_configuration`, -`owned_resources`, and `previous_stage_result`. The latter two come from the actual -preceding child, initially empty/null. Private wrappers adapt host-specific harness -interfaces and must verify their detailed subchecks before returning -`status: passed`, exact `checks: {stage-name: passed}`, the five identity fields -(source/tree/run_id/inventory_sha256/adapter_sha256), and `owned_resources`. -The run ID is a canonical UUID string. `qualification_control.py` is part of the -reviewed tooling bytes and the pinned private package. The sender holds the same -SSH stdin open, sends heartbeats every five seconds and never reloads a pass file. -The receiver stops later work on EOF or a thirty-second heartbeat timeout. Signal -handlers enter the same cleanup path. Each stage or remote worker has a foreground -process group with a waiting owner outside it. The owner cleans the group on every -exit, including success and nonzero exit, so an inner timeout or SIGKILL cannot -leave same-group foreground descendants running. Foreground commands inherit the -group; only recorded background resources may create separate sessions and remain -running. Nested SSH workers use this protocol too; closing a local SSH process alone is insufficient. A write already -sent may still have an unknown result; retain its intent and resources without -replay or a rollback claim. Child stdout/stderr remain private files; -nonzero exit, timeout, changed bytes or mismatched identity stops the sequence. - -After all five stages pass, the same controller waits up to `--merge-wait-seconds` -for main to reach the exact reviewed promotion tree. While main is an ancestor of -that reviewed commit it continues waiting; divergent changes stop publication. -Do not restart merely because the merge is pending. Cancellation or timeout retains -resources and evidence but cannot resume from a recorded pass. The command never -merges the batch itself. - -This finite command publishes automatically when all checks pass and the batch is -landed. Candidate source and tag remain -`48ed8158e134207d15cdd14ae0a30e10f070eb5c` / `build-48ed8158e134207d15cdd14ae0a30e10f070eb5c`. -The reviewed tooling commit can add only the enumerated release files, the -node-generation protocol wording correction, shared component documentation in -AGENTS.md/CONTRIBUTING.md and test registration; main must have that commit's tree and include the candidate source. -Later release documentation is not retroactively inserted into the tested bundle. -Any product change blocks publication of this candidate instead of silently -publishing an obsolete product or relabeling old bytes. - -On failure, retain the new evidence and resources and reconcile the Release state. -Never overwrite assets or use saved check results to resume publication. A release -already published is refused before any new acceptance; a failed final verification -needs investigation, not automatic deletion or replacement. Run only one controller -for this batch. This path does not require unattended cloud scheduling for future -pushes, public repository visibility or historical-installation upgrades. +With `draft_release=true`, the result is an unpublished `build-` +Release. With `draft_release=false`, files remain in the Actions artifact only. +Use the exact matched asset set; do not mix builds or resolve components through +`latest`. The historical batch qualification tools are not part of tag publication. ## Run Core without the installer @@ -181,9 +110,3 @@ Core reads only its environment; the lists the variables. The Docker-hosted variant of the standalone archive is retired: it could not describe a complete Runtime release by itself. Docker-hosted deployments use the Core distribution and its installer, whose manifest carries the complete release. - -Immediately after the final asset download, the controller repeats the exact -main/tree, tag, draft and Release ID checks. Publication uses the verified Release -ID through the existing authenticated API, so a replacement tag lookup cannot -select another release. Published bytes and final release/tag identity are checked -again afterward. diff --git a/scripts/install-release.test.py b/scripts/install-release.test.py new file mode 100644 index 000000000..871603fe8 --- /dev/null +++ b/scripts/install-release.test.py @@ -0,0 +1,175 @@ +"""Public release bootstrap tests; fixtures never start Docker or a real installer.""" +import contextlib +import hashlib +import io +import json +import os +import stat +import pathlib +import subprocess +import tarfile +import tempfile +import types +import unittest +from unittest import mock + +SCRIPT = pathlib.Path(__file__).parents[1] / "deploy/install-release.sh" +bootstrap = types.ModuleType("bootstrap") +exec(compile(SCRIPT.read_text().split("3<<'PY'\n", 1)[1].rsplit("\nPY", 1)[0], + str(SCRIPT), "exec"), bootstrap.__dict__) + + +class BootstrapTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = pathlib.Path(self.temp.name) + self.sha = "a" * 40 + self.stem = "oac-" + self.sha + "-linux-amd64" + self.name = self.stem + "-offline.tar.gz" + self.metadata = {"tag_name": "v1.2.3", "draft": False, "prerelease": False, + "assets": [{"id": 7, "name": self.name}, + {"id": 8, "name": self.name + ".sha256"}]} + self.archive = self.make_archive({ + "install.sh": b"#!/bin/sh\nexit 0\n", + "manifest.json": json.dumps({"source_commit": self.sha}).encode()}) + stack = contextlib.ExitStack() + self.addCleanup(stack.close) + self.http = stack.enter_context(mock.patch.object(bootstrap, "open_url", side_effect=self.response)) + stack.enter_context(mock.patch.object(bootstrap.pathlib.Path, "home", return_value=self.root)) + stack.enter_context(mock.patch.object(bootstrap.platform, "system", return_value="Linux")) + stack.enter_context(mock.patch.object(bootstrap.platform, "machine", return_value="x86_64")) + stack.enter_context(mock.patch.object(bootstrap.os, "geteuid", return_value=1000)) + self.invoke = stack.enter_context(mock.patch.object(bootstrap.subprocess, "call", return_value=0)) + + def make_archive(self, files): + data = io.BytesIO() + with tarfile.open(fileobj=data, mode="w:gz") as archive: + for name, content in files.items(): + member = tarfile.TarInfo(self.stem + "/" + name) + member.mode, member.size = 0o755, len(content) + archive.addfile(member, io.BytesIO(content)) + return data.getvalue() + + def response(self, url, binary=False): + if url.endswith("/releases/latest") or "/releases/tags/" in url: + return io.BytesIO(json.dumps(self.metadata).encode()) + if url.endswith("/assets/7"): + return io.BytesIO(self.archive) + if url.endswith("/assets/8"): + return io.BytesIO((hashlib.sha256(self.archive).hexdigest() + " " + self.name + "\n").encode()) + raise AssertionError("Unexpected URL: " + url) + + def test_default_latest_is_resolved_once_and_installer_arguments_forwarded(self): + self.assertEqual(bootstrap.main(["--public-url", "https://core.example"]), 0) + urls = [c.args[0] for c in self.http.call_args_list] + self.assertEqual(sum(url.endswith("/releases/latest") for url in urls), 1) + self.assertTrue(urls[1].endswith("/assets/8")) + self.assertTrue(urls[2].endswith("/assets/7")) + command = self.invoke.call_args.args[0] + self.assertEqual(command[0], "bash") + self.assertEqual(command[2:], ["--public-url", "https://core.example"]) + self.assertTrue(pathlib.Path(command[1]).is_file()) + self.assertFalse(list((self.root / ".oac/releases").glob(".download-*"))) + + def test_selected_prerelease_is_allowed(self): + self.metadata.update(tag_name="v2.0.0-rc.1", prerelease=True) + bootstrap.main(["--version", "v2.0.0-rc.1", "--core-only"]) + self.assertTrue(self.http.call_args_list[0].args[0].endswith("/releases/tags/v2.0.0-rc.1")) + self.assertEqual(self.invoke.call_args.args[0][-1], "--core-only") + + def test_latest_never_selects_prerelease_or_draft(self): + for key in ("prerelease", "draft"): + with self.subTest(key=key): + self.metadata[key] = True + with self.assertRaises(bootstrap.ReleaseError): + bootstrap.main([]) + self.metadata[key] = False + self.invoke.assert_not_called() + + def test_missing_or_duplicate_asset_is_refused(self): + self.metadata["assets"].append(dict(self.metadata["assets"][0])) + with self.assertRaisesRegex(bootstrap.ReleaseError, "exactly one"): + bootstrap.main([]) + self.metadata["assets"] = self.metadata["assets"][:1] + with self.assertRaisesRegex(bootstrap.ReleaseError, "checksum"): + bootstrap.main([]) + self.invoke.assert_not_called() + + def test_checksum_failure_never_executes_and_removes_only_own_download(self): + existing = self.root / ".oac/releases/existing" + existing.mkdir(parents=True) + (existing / "data").write_text("keep") + def response(url, binary=False): + if url.endswith("/assets/8"): + return io.BytesIO(("b" * 64 + " " + self.name + "\n").encode()) + return self.response(url, binary) + self.http.side_effect = response + with self.assertRaisesRegex(bootstrap.ReleaseError, "checksum mismatch"): + bootstrap.main([]) + self.invoke.assert_not_called() + self.assertEqual(list(existing.parent.iterdir()), [existing]) + self.assertEqual((existing / "data").read_text(), "keep") + + def test_archive_source_mismatch_is_refused(self): + self.archive = self.make_archive({"install.sh": b"exit 0", "manifest.json": b'{"source_commit":"wrong"}'}) + with self.assertRaisesRegex(bootstrap.ReleaseError, "source"): + bootstrap.main([]) + self.invoke.assert_not_called() + + def test_unsafe_tar_paths_and_links_are_rejected(self): + for name, kind in (("../escape", tarfile.REGTYPE), ("/tmp/escape", tarfile.REGTYPE), + (self.stem + "/link", tarfile.SYMTYPE), + (self.stem + "/hard", tarfile.LNKTYPE)): + with self.subTest(name=name): + path = self.root / "unsafe.tar.gz" + with tarfile.open(path, "w:gz") as archive: + member = tarfile.TarInfo(name) + member.type, member.linkname = kind, "/tmp/escape" + archive.addfile(member, io.BytesIO()) + with self.assertRaisesRegex(bootstrap.ReleaseError, "unsafe"): + bootstrap.extract(path, self.root, self.stem) + + def test_duplicate_paths_are_rejected(self): + path = self.root / "duplicate.tar.gz" + with tarfile.open(path, "w:gz") as archive: + for _ in range(2): + archive.addfile(tarfile.TarInfo(self.stem + "/same"), io.BytesIO()) + with self.assertRaisesRegex(bootstrap.ReleaseError, "duplicate"): + bootstrap.extract(path, self.root, self.stem) + + def test_unsupported_platform_does_not_download(self): + with mock.patch.object(bootstrap.platform, "system", return_value="Darwin"): + with self.assertRaisesRegex(bootstrap.ReleaseError, "Linux amd64"): + bootstrap.main([]) + self.http.assert_not_called() + + def test_nonroot_requirement_is_preserved(self): + with mock.patch.object(bootstrap.os, "geteuid", return_value=0): + with self.assertRaisesRegex(bootstrap.ReleaseError, "non-root"): + bootstrap.main([]) + self.http.assert_not_called() + + def test_installer_failure_is_returned_and_bundle_retained(self): + self.invoke.return_value = 17 + self.assertEqual(bootstrap.main([]), 17) + self.assertTrue(pathlib.Path(self.invoke.call_args.args[0][1]).is_file()) + + def test_new_private_directories_do_not_inherit_public_umask(self): + previous = os.umask(0o022) + try: + bootstrap.main([]) + finally: + os.umask(previous) + for path in (self.root / ".oac", self.root / ".oac/releases"): + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o700) + + def test_shell_entrypoint_help_needs_no_network(self): + result = subprocess.run(["bash", str(SCRIPT), "--help"], capture_output=True, text=True) + self.assertEqual(result.returncode, 0) + self.assertIn("--version", result.stdout) + self.assertIn("latest stable release", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/publish-core-release.py b/scripts/publish-core-release.py new file mode 100644 index 000000000..c0ae53978 --- /dev/null +++ b/scripts/publish-core-release.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Create and upload one draft, then publish its fixed ID without automatic retries.""" + +import argparse +import importlib.util +import json +import os +import pathlib +import re +import subprocess +from urllib.parse import quote + +spec = importlib.util.spec_from_file_location( + "distribution", pathlib.Path(__file__).with_name("core-distribution-manifest.py")) +distribution = importlib.util.module_from_spec(spec) +spec.loader.exec_module(distribution) + + +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)) + + +def verify_tag(repository, tag, revision): + endpoint = "git/ref/tags/" + quote(tag, safe="") + for _ in range(10): + obj = api(repository, endpoint)["object"] + if obj["type"] == "commit": + if obj["sha"] != revision: + raise ValueError("Version tag no longer points to the built source") + return + if obj["type"] != "tag": + raise ValueError("Version tag does not resolve to a commit") + endpoint = "git/tags/" + obj["sha"] + raise ValueError("Too many nested annotated tags") + + +def refuse_existing(repository, tag): + # The tag lookup endpoint omits drafts. Listing includes authenticated drafts. + page = 1 + while True: + releases = api(repository, "releases?per_page=100&page=" + str(page)) + if any(release["tag_name"] == tag for release in releases): + raise ValueError("Release or draft already exists; inspect it before retrying") + if len(releases) < 100: + return + page += 1 + + +def verify_draft(release, tag, revision): + if (not release["draft"] or release["tag_name"] != tag + or release["target_commitish"] != revision + or type(release["id"]) is not int or release["id"] <= 0): + raise ValueError("Release draft identity changed") + + +def publish(assets, repository, revision, tag, mode): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("Expected an owner/repository") + if not re.fullmatch(r"[0-9a-f]{40}", revision): + raise ValueError("Expected a full source commit SHA") + if mode not in ("publish", "draft"): + raise ValueError("Expected publish or draft mode") + if mode == "publish": + if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?", tag): + raise ValueError("Version tags must use vMAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]") + elif tag != "build-" + revision: + raise ValueError("Manual builds use build-") + + files = sorted(assets.iterdir()) + if not files or any(p.is_symlink() or not p.is_file() for p in files): + raise ValueError("Expected a nonempty directory containing only regular asset files") + # The builder validates the manifest and Runtime assets. Verify archives again + # after the Actions artifact transfer between jobs. + stem = "oac-" + revision + "-linux-amd64" + archives = [assets / (stem + ".tar.gz"), assets / "install.sh"] + if mode == "publish" or (assets / (stem + "-offline.tar.gz")).exists(): + archives.append(assets / (stem + "-offline.tar.gz")) + for archive in archives: + checksum = archive.with_name(archive.name + ".sha256") + if checksum.read_text() != distribution.sha256(archive) + " " + archive.name + "\n": + raise ValueError("Distribution archive checksum mismatch") + + refuse_existing(repository, tag) + if mode == "publish": + verify_tag(repository, tag, revision) + prerelease = mode == "publish" and "-" in tag.split("+", 1)[0] + release = api(repository, "releases", "--method", "POST", + "-f", "tag_name=" + tag, "-f", "target_commitish=" + revision, + "-f", "name=OpenAgentCore " + tag, + "-f", "body=Linux amd64 distribution from commit " + revision + ".", + "-F", "draft=true", "-F", "prerelease=" + str(prerelease).lower()) + verify_draft(release, tag, revision) + release_id = release["id"] + endpoint = "releases/" + str(release_id) + # 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: + uploaded = api(repository, "https://uploads.github.com/repos/" + repository + + "/" + endpoint + "/assets?name=" + quote(path.name, safe=""), + "--method", "POST", "-H", "Content-Type: application/octet-stream", + "-H", "Content-Length: " + str(expected[path.name]), "--input", str(path.resolve())) + 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") + release = api(repository, endpoint) + verify_draft(release, tag, revision) + actual = release["assets"] + if (len(actual) != len(expected) + or any(a["state"] != "uploaded" for a in actual) + or {a["name"]: a["size"] for a in actual} != expected): + raise ValueError("Release asset inventory differs from the build") + if mode == "draft": + return + # 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") + if result["id"] != release_id or result["draft"] or result["tag_name"] != tag: + raise ValueError("Publication result is unknown; inspect the existing Release") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--assets", required=True, type=pathlib.Path) + args = parser.parse_args() + publish(args.assets, os.environ["GH_REPO"], os.environ["RELEASE_REVISION"], + os.environ["RELEASE_TAG"], os.environ["RELEASE_MODE"]) diff --git a/scripts/publish-core-release.test.py b/scripts/publish-core-release.test.py new file mode 100644 index 000000000..9e830565b --- /dev/null +++ b/scripts/publish-core-release.test.py @@ -0,0 +1,232 @@ +"""Publication failure tests using the documented GitHub REST response shapes.""" +import contextlib +import hashlib +import importlib.util +import json +import pathlib +import subprocess +import tempfile +import unittest +from unittest import mock +from urllib.parse import unquote + +spec = importlib.util.spec_from_file_location( + "publisher", pathlib.Path(__file__).with_name("publish-core-release.py")) +publisher = importlib.util.module_from_spec(spec) +spec.loader.exec_module(publisher) +REAL_API = publisher.api + + +class PublicationTests(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.stem = "oac-" + self.revision + "-linux-amd64" + for name in (self.stem + ".tar.gz", self.stem + "-offline.tar.gz", "install.sh"): + (self.assets / name).write_bytes(b"archive fixture") + (self.assets / (name + ".sha256")).write_text( + hashlib.sha256(b"archive fixture").hexdigest() + " " + name + "\n") + self.release = None + self.existing = [] + stack = contextlib.ExitStack() + self.addCleanup(stack.close) + self.api = stack.enter_context(mock.patch.object(publisher, "api", side_effect=self.response)) + + def response(self, repository, endpoint, *args): + if endpoint.startswith("git/"): + return {"object": {"type": "commit", "sha": self.revision}} + if endpoint.startswith("releases?"): + return self.existing + if endpoint == "releases" and args[:2] == ("--method", "POST"): + fields = dict(v.split("=", 1) for v in args if "=" in v) + self.release = {"id": 7, "draft": fields["draft"] == "true", + "prerelease": fields["prerelease"] == "true", + "tag_name": fields["tag_name"], "target_commitish": fields["target_commitish"], + "assets": []} + return dict(self.release) + if endpoint.startswith("https://uploads.github.com/"): + self.assertIn("/releases/7/assets?", endpoint) + self.assertEqual(args[:2], ("--method", "POST")) + self.assertIn("Content-Type: application/octet-stream", args) + name = unquote(endpoint.split("?name=", 1)[1]) + path = pathlib.Path(args[args.index("--input") + 1]) + self.assertEqual(path.name, name) + self.assertIn("Content-Length: " + str(path.stat().st_size), args) + asset = {"name": name, "size": path.stat().st_size, "state": "uploaded"} + self.release["assets"].append(asset) + return dict(asset) + if endpoint == "releases/7": + if args: + self.assertEqual(args, ("--method", "PATCH", "-F", "draft=false")) + self.release["draft"] = False + return dict(self.release) + # GET /releases/tags does not return drafts. + raise subprocess.CalledProcessError(1, ["gh", "api", endpoint]) + + def publish(self, tag="v1.2.3", mode="publish"): + publisher.publish(self.assets, "MiniMax-AI/parsar-core", self.revision, tag, mode) + + def writes(self): + return [c for c in self.api.call_args_list if "--method" in c.args] + + def test_version_tag_publishes_complete_fixed_id(self): + self.publish() + self.assertFalse(self.release["draft"]) + self.assertFalse(self.release["prerelease"]) + self.assertEqual(len(self.release["assets"]), 6) + self.assertEqual(self.api.call_args.args[1:], + ("releases/7", "--method", "PATCH", "-F", "draft=false")) + + def test_annotated_tag_and_prerelease(self): + def response(repo, endpoint, *args): + if endpoint.startswith("git/ref/"): + return {"object": {"type": "tag", "sha": "b" * 40}} + return self.response(repo, endpoint, *args) + self.api.side_effect = response + self.publish("v1.2.3-rc.1") + self.assertTrue(self.release["prerelease"]) + self.assertEqual(sum(c.args[1] == "git/tags/" + "b" * 40 for c in self.api.call_args_list), 2) + + def test_build_metadata_is_not_prerelease(self): + self.publish("v1.2.3+build-test") + self.assertFalse(self.release["prerelease"]) + + def test_manual_draft_does_not_publish(self): + for suffix in ("-offline.tar.gz", "-offline.tar.gz.sha256"): + (self.assets / (self.stem + suffix)).unlink() + self.publish("build-" + self.revision, "draft") + self.assertTrue(self.release["draft"]) + self.assertFalse(any(c.args[1].startswith("git/") for c in self.api.call_args_list)) + + def test_existing_public_or_draft_release_is_refused(self): + for draft in (True, False): + with self.subTest(draft=draft): + self.existing = [{"tag_name": "v1.2.3", "draft": draft}] + with self.assertRaisesRegex(ValueError, "already exists"): + self.publish() + self.assertEqual(self.writes(), []) + + def test_existing_release_on_later_page_is_refused(self): + self.api.side_effect = lambda repo, endpoint, *args: ( + [{"tag_name": "other"}] * 100 if endpoint.endswith("page=1") else [{"tag_name": "v1.2.3"}]) + with self.assertRaisesRegex(ValueError, "already exists"): + self.publish() + self.assertEqual(self.writes(), []) + + def test_failed_lookup_never_creates_release(self): + self.api.side_effect = subprocess.CalledProcessError(1, ["gh", "api"]) + with self.assertRaises(subprocess.CalledProcessError): + self.publish() + self.assertEqual(self.writes(), []) + + def test_wrong_tag_revision_is_refused(self): + def response(repo, endpoint, *args): + if endpoint.startswith("git/"): + return {"object": {"type": "commit", "sha": "b" * 40}} + return self.response(repo, endpoint, *args) + self.api.side_effect = response + with self.assertRaisesRegex(ValueError, "built source"): + self.publish() + self.assertEqual(self.writes(), []) + + def test_tag_moved_during_upload_keeps_draft_unpublished(self): + def response(repo, endpoint, *args): + if endpoint.startswith("git/") and self.release: + return {"object": {"type": "commit", "sha": "b" * 40}} + return self.response(repo, endpoint, *args) + self.api.side_effect = response + with self.assertRaisesRegex(ValueError, "built source"): + self.publish() + self.assertTrue(self.release["draft"]) + self.assertFalse(any("PATCH" in c.args for c in self.writes())) + + def test_upload_failure_preserves_draft_and_stops(self): + def response(repo, endpoint, *args): + if endpoint.startswith("https://uploads."): + raise subprocess.CalledProcessError(1, ["gh", "api"]) + return self.response(repo, endpoint, *args) + self.api.side_effect = response + with self.assertRaises(subprocess.CalledProcessError): + self.publish() + self.assertTrue(self.release["draft"]) + self.assertEqual(len(self.writes()), 2) + 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): + def response(repo, endpoint, *args): + result = self.response(repo, endpoint, *args) + if "PATCH" in args: + raise subprocess.CalledProcessError(1, ["gh", "api"]) + return result + self.api.side_effect = response + with self.assertRaises(subprocess.CalledProcessError): + self.publish() + self.assertFalse(self.release["draft"]) + self.assertEqual(sum("PATCH" in c.args for c in self.writes()), 1) + self.assertFalse(any("DELETE" in c.args for c in self.writes())) + + def test_invalid_version_tag_is_refused(self): + with self.assertRaisesRegex(ValueError, "Version tags"): + self.publish("version1") + self.assertEqual(self.writes(), []) + + def test_missing_offline_archive_is_refused(self): + (self.assets / (self.stem + "-offline.tar.gz")).unlink() + with self.assertRaises(FileNotFoundError): + self.publish() + self.assertEqual(self.writes(), []) + + def test_changed_archive_is_refused(self): + (self.assets / (self.stem + ".tar.gz")).write_bytes(b"changed") + with self.assertRaisesRegex(ValueError, "checksum"): + self.publish() + self.assertEqual(self.writes(), []) + + def test_empty_or_linked_payload_is_refused(self): + for p in self.assets.iterdir(): + p.unlink() + with self.assertRaisesRegex(ValueError, "nonempty"): + self.publish() + (self.assets / "link").symlink_to(__file__) + with self.assertRaisesRegex(ValueError, "regular"): + self.publish() + self.assertEqual(self.writes(), []) + + + def test_lost_create_response_is_not_replayed(self): + def response(repo, endpoint, *args): + result = self.response(repo, endpoint, *args) + if endpoint == "releases": + raise subprocess.CalledProcessError(1, ["gh", "api"]) + return result + self.api.side_effect = response + with self.assertRaises(subprocess.CalledProcessError): + self.publish() + self.assertTrue(self.release["draft"]) + self.assertEqual(len(self.writes()), 1) + + def test_incomplete_remote_inventory_blocks_publication(self): + def response(repo, endpoint, *args): + result = self.response(repo, endpoint, *args) + if endpoint == "releases/7" and not args: + result["assets"] = result["assets"][:-1] + return result + self.api.side_effect = response + with self.assertRaisesRegex(ValueError, "inventory"): + self.publish() + self.assertTrue(self.release["draft"]) + self.assertFalse(any("PATCH" in c.args for c in self.writes())) + + def test_api_uses_full_upload_url_and_binary_input(self): + with mock.patch.object(publisher.subprocess, "check_output", return_value='{"id": 7}') as command: + url = "https://uploads.github.com/repos/MiniMax-AI/parsar-core/releases/7/assets?name=x" + self.assertEqual(REAL_API("MiniMax-AI/parsar-core", url, "--method", "POST", + "--input", "/tmp/asset"), {"id": 7}) + self.assertEqual(command.call_args.args[0], + ["gh", "api", url, "--method", "POST", "--input", "/tmp/asset"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/site/index.html b/site/index.html index bf93779e1..ae661f88a 100644 --- a/site/index.html +++ b/site/index.html @@ -75,12 +75,11 @@

One Core.
Many agents.

Install. Sign in.
Add a node.

Install Core and Web together with one command. Add nodes when you need them.

Read the installation guide -
BEFORE YOU START

A Linux amd64 host with Docker. For nodes, an HTTPS address behind your reverse proxy; you can add it after installing.

Download a bundle from GitHub Releases with gh release download. The repository is internal for now, so run gh auth login first. For unreleased changes, build a distribution.

+
BEFORE YOU START

A Linux amd64 host with Docker and Python 3.9+. For nodes, an HTTPS address behind your reverse proxy; you can add it after installing.

The command downloads and verifies the latest stable release. Add --version v1.2.3 to select a version. For unreleased changes, build a distribution.

    -
  1. Install Core + Web

    ZERO NODES

    Run from your extracted bundle. Core, Web and PostgreSQL start together. No model key required.

    ./install.sh \
    -    --public-url https://core.example
  2. +
  3. Install Core + Web

    ZERO NODES

    Install the latest stable release. Core, Web and PostgreSQL start together. No model key required.

    curl -fsSL https://github.com/MiniMax-AI/parsar-core/releases/latest/download/install.sh | bash
  4. Sign in and issue a key

    Open the address printed by the installer. Sign in with the Core key in this private file:

    ~/.oac/core/secrets/core.key

    Set a default model on System, then create a project and issue a key on Projects and keys.

  5. Add a node

    Open Nodes, choose Add node, then Generate command. Paste the command on a Linux host with sudo; it prepares the host itself.

    Dockermicrosandbox

    Web shows when the node is online and ready. All nodes use the same provider.