From cc70a642277fafe7728bfe28973781b81a68c687 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 04:19:32 +0000 Subject: [PATCH 1/5] feat: publish checked distributions automatically for version tags --- .github/workflows/check.yml | 10 +- .github/workflows/release.yml | 24 ++-- CONTRIBUTING.md | 12 +- Makefile | 1 + docs/maintainers.md | 172 +++++++-------------------- scripts/publish-core-release.py | 101 ++++++++++++++++ scripts/publish-core-release.test.py | 162 +++++++++++++++++++++++++ 7 files changed, 344 insertions(+), 138 deletions(-) create mode 100644 scripts/publish-core-release.py create mode 100644 scripts/publish-core-release.test.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index cf729e58e..f74c0951d 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.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..d4f12b777 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: @@ -104,24 +110,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..8e8bf4615 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1973,9 +1973,15 @@ 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. +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..101556199 100644 --- a/Makefile +++ b/Makefile @@ -129,6 +129,7 @@ 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/promote-qualified-release.test.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/qualification-control.test.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/config-reference.py --check diff --git a/docs/maintainers.md b/docs/maintainers.md index 594da8543..6ca28bced 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -32,135 +32,61 @@ 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. 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 +107,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/publish-core-release.py b/scripts/publish-core-release.py new file mode 100644 index 000000000..a920046c4 --- /dev/null +++ b/scripts/publish-core-release.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Upload a draft, verify its source, then publish once without cleanup on failure.""" + +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): + return json.loads(subprocess.check_output( + ["gh", "api", "repos/" + repository + "/" + endpoint, *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 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") + # Verify the archives after transfer between jobs. Their manifests bind the + # remaining Runtime assets; the builder validates that contract. + stem = "oac-" + revision + "-linux-amd64" + archives = [assets / (stem + ".tar.gz")] + 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") + + command = ["gh", "release", "create", tag, "--repo", repository, + "--target", revision, "--title", "OpenAgentCore " + tag, + "--notes", "Linux amd64 distribution from commit " + revision + "."] + if mode == "publish": + verify_tag(repository, tag, revision) + command.append("--verify-tag") + if "-" in tag.split("+", 1)[0]: + command.append("--prerelease") + # Explicit drafts keep gh's error cleanup away from the publication request. + # Existing releases fail creation; do not overwrite or replay an ambiguous write. + command.append("--draft") + subprocess.run(command + [str(p.resolve()) for p in files], check=True) + if mode == "draft": + return + + release = api(repository, "releases/tags/" + quote(tag, safe="")) + if (not release["draft"] or release["tag_name"] != tag + or release["target_commitish"] != revision): + raise ValueError("Release draft identity changed") + release_id = release["id"] + if type(release_id) is not int or release_id <= 0: + raise ValueError("Invalid Release ID") + # Uploads can take minutes. Recheck the tag immediately before publishing the + # fixed Release ID, so a moved tag cannot silently relabel this build. + verify_tag(repository, tag, revision) + result = api(repository, "releases/" + str(release_id), + "--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..a8be44a3f --- /dev/null +++ b/scripts/publish-core-release.test.py @@ -0,0 +1,162 @@ +"""Release publication contract tests; no GitHub writes or Docker builds.""" +import contextlib +import hashlib +import importlib.util +import json +import pathlib +import subprocess +import tempfile +import unittest +from unittest import mock + +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) + + +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 suffix in (".tar.gz", "-offline.tar.gz"): + name = self.stem + suffix + (self.assets / name).write_bytes(b"archive fixture") + (self.assets / (name + ".sha256")).write_text( + hashlib.sha256(b"archive fixture").hexdigest() + " " + name + "\n") + stack = contextlib.ExitStack() + self.addCleanup(stack.close) + self.gh_run = stack.enter_context(mock.patch.object(publisher.subprocess, "run")) + self.api = stack.enter_context(mock.patch.object(publisher.subprocess, "check_output", + side_effect=self.response)) + + def response(self, command, **kwargs): + if "git/" in command[2]: + return json.dumps({"object": {"type": "commit", "sha": self.revision}}) + return json.dumps({"id": 7, "draft": "--method" not in command, + "tag_name": "v1.2.3", "target_commitish": self.revision}) + + def publish(self, tag="v1.2.3", mode="publish"): + publisher.publish(self.assets, "MiniMax-AI/parsar-core", self.revision, tag, mode) + + def test_version_tag_publishes_all_assets(self): + self.publish() + command = self.gh_run.call_args.args[0] + self.assertIn("--verify-tag", command) + self.assertIn("--draft", command) + self.assertNotIn("--prerelease", command) + self.assertNotIn("--clobber", command) + self.assertEqual(command[-4:], sorted(str(p.resolve()) for p in self.assets.iterdir())) + self.assertEqual(self.api.call_args.args[0][2:], + ["repos/MiniMax-AI/parsar-core/releases/7", "--method", "PATCH", "-F", "draft=false"]) + + def test_annotated_tag_and_prerelease(self): + def response(command, **kwargs): + endpoint = command[2] + if "git/ref/" in endpoint: + return json.dumps({"object": {"type": "tag", "sha": "b" * 40}}) + return self.response(command).replace("v1.2.3", "v1.2.3-rc.1") + self.api.side_effect = response + self.publish("v1.2.3-rc.1") + self.assertIn("--prerelease", self.gh_run.call_args.args[0]) + self.assertEqual(sum("git/tags/" in c.args[0][2] for c in self.api.call_args_list), 2) + + def test_build_metadata_is_not_prerelease(self): + self.api.side_effect = lambda c, **kw: self.response(c).replace("v1.2.3", "v1.2.3+build-test") + self.publish("v1.2.3+build-test") + self.assertNotIn("--prerelease", self.gh_run.call_args.args[0]) + + def test_manual_draft_does_not_publish(self): + (self.assets / (self.stem + "-offline.tar.gz")).unlink() + (self.assets / (self.stem + "-offline.tar.gz.sha256")).unlink() + self.publish("build-" + self.revision, "draft") + self.assertIn("--draft", self.gh_run.call_args.args[0]) + self.api.assert_not_called() + + def test_wrong_tag_revision_is_refused(self): + self.api.side_effect = lambda *a, **kw: json.dumps({"object": {"type": "commit", "sha": "b" * 40}}) + with self.assertRaisesRegex(ValueError, "built source"): + self.publish() + self.gh_run.assert_not_called() + + def test_failed_tag_lookup_never_creates_release(self): + self.api.side_effect = subprocess.CalledProcessError(1, ["gh", "api"]) + with self.assertRaises(subprocess.CalledProcessError): + self.publish() + self.gh_run.assert_not_called() + + def test_failed_or_existing_release_is_not_overwritten_or_replayed(self): + self.gh_run.side_effect = subprocess.CalledProcessError(1, ["gh", "release", "create"]) + with self.assertRaises(subprocess.CalledProcessError): + self.publish() + self.assertEqual(self.gh_run.call_count, 1) + + def test_invalid_version_tag_is_refused(self): + with self.assertRaisesRegex(ValueError, "Version tags"): + self.publish("version1") + self.gh_run.assert_not_called() + + def test_missing_offline_archive_is_refused(self): + (self.assets / (self.stem + "-offline.tar.gz")).unlink() + with self.assertRaises(FileNotFoundError): + self.publish() + self.gh_run.assert_not_called() + + 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.gh_run.assert_not_called() + + 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.gh_run.assert_not_called() + + + def test_tag_moved_during_upload_keeps_draft_unpublished(self): + tag_reads = 0 + def response(command, **kwargs): + nonlocal tag_reads + if "git/" in command[2]: + tag_reads += 1 + if tag_reads == 2: + return json.dumps({"object": {"type": "commit", "sha": "b" * 40}}) + return self.response(command) + self.api.side_effect = response + with self.assertRaisesRegex(ValueError, "built source"): + self.publish() + self.assertEqual(self.gh_run.call_count, 1) + self.assertFalse(any("--method" in c.args[0] for c in self.api.call_args_list)) + + def test_lost_publication_response_never_deletes_or_retries(self): + def response(command, **kwargs): + if "--method" in command: + raise subprocess.CalledProcessError(1, command) + return self.response(command) + self.api.side_effect = response + with self.assertRaises(subprocess.CalledProcessError): + self.publish() + writes = [c.args[0] for c in self.api.call_args_list if "--method" in c.args[0]] + self.assertEqual(len(writes), 1) + self.assertIn("PATCH", writes[0]) + self.assertNotIn("DELETE", str(self.api.call_args_list)) + self.assertEqual(self.gh_run.call_count, 1) + + def test_changed_draft_identity_is_refused(self): + self.api.side_effect = lambda c, **kw: self.response(c).replace('"target_commitish": "' + self.revision, '"target_commitish": "' + "b" * 40) + with self.assertRaisesRegex(ValueError, "draft identity"): + self.publish() + self.assertFalse(any("--method" in c.args[0] for c in self.api.call_args_list)) + + +if __name__ == "__main__": + unittest.main() From 1d79947e308c676add3b9f495914355df4978fc9 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 04:30:45 +0000 Subject: [PATCH 2/5] fix: publish by release ID and add public download bootstrap --- .github/workflows/check.yml | 2 +- .github/workflows/release.yml | 2 + deploy/install-release.sh | 163 ++++++++++++++++++++ scripts/publish-core-release.py | 83 ++++++---- scripts/publish-core-release.test.py | 222 ++++++++++++++++++--------- 5 files changed, 367 insertions(+), 105 deletions(-) create mode 100755 deploy/install-release.sh diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index f74c0951d..a6770a079 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -15,7 +15,7 @@ permissions: contents: read concurrency: - group: core-check-${{ github.workflow }}-${{ inputs.ref || github.ref }} + group: core-check-${{ github.workflow }}-${{ inputs.ref && github.run_id || github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4f12b777..421028924 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,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 }} diff --git a/deploy/install-release.sh b/deploy/install-release.sh new file mode 100755 index 000000000..384d7380f --- /dev/null +++ b/deploy/install-release.sh @@ -0,0 +1,163 @@ +#!/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) + cache = pathlib.Path.home() / ".oac" / "releases" + cache.mkdir(mode=0o700, parents=True, 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/scripts/publish-core-release.py b/scripts/publish-core-release.py index a920046c4..c0ae53978 100644 --- a/scripts/publish-core-release.py +++ b/scripts/publish-core-release.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Upload a draft, verify its source, then publish once without cleanup on failure.""" +"""Create and upload one draft, then publish its fixed ID without automatic retries.""" import argparse import importlib.util @@ -17,8 +17,8 @@ def api(repository, endpoint, *args): - return json.loads(subprocess.check_output( - ["gh", "api", "repos/" + repository + "/" + endpoint, *args], text=True)) + 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): @@ -35,6 +35,25 @@ def verify_tag(repository, tag, revision): 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") @@ -51,10 +70,10 @@ def publish(assets, repository, revision, tag, mode): 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") - # Verify the archives after transfer between jobs. Their manifests bind the - # remaining Runtime assets; the builder validates that contract. + # 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")] + 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: @@ -62,33 +81,41 @@ def publish(assets, repository, revision, tag, mode): if checksum.read_text() != distribution.sha256(archive) + " " + archive.name + "\n": raise ValueError("Distribution archive checksum mismatch") - command = ["gh", "release", "create", tag, "--repo", repository, - "--target", revision, "--title", "OpenAgentCore " + tag, - "--notes", "Linux amd64 distribution from commit " + revision + "."] + refuse_existing(repository, tag) if mode == "publish": verify_tag(repository, tag, revision) - command.append("--verify-tag") - if "-" in tag.split("+", 1)[0]: - command.append("--prerelease") - # Explicit drafts keep gh's error cleanup away from the publication request. - # Existing releases fail creation; do not overwrite or replay an ambiguous write. - command.append("--draft") - subprocess.run(command + [str(p.resolve()) for p in files], check=True) + 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 - - release = api(repository, "releases/tags/" + quote(tag, safe="")) - if (not release["draft"] or release["tag_name"] != tag - or release["target_commitish"] != revision): - raise ValueError("Release draft identity changed") - release_id = release["id"] - if type(release_id) is not int or release_id <= 0: - raise ValueError("Invalid Release ID") - # Uploads can take minutes. Recheck the tag immediately before publishing the - # fixed Release ID, so a moved tag cannot silently relabel this build. + # Uploads can take minutes. Recheck immediately before the one publish request. verify_tag(repository, tag, revision) - result = api(repository, "releases/" + str(release_id), - "--method", "PATCH", "-F", "draft=false") + 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") diff --git a/scripts/publish-core-release.test.py b/scripts/publish-core-release.test.py index a8be44a3f..9e830565b 100644 --- a/scripts/publish-core-release.test.py +++ b/scripts/publish-core-release.test.py @@ -1,4 +1,4 @@ -"""Release publication contract tests; no GitHub writes or Docker builds.""" +"""Publication failure tests using the documented GitHub REST response shapes.""" import contextlib import hashlib import importlib.util @@ -8,11 +8,13 @@ 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): @@ -22,94 +24,165 @@ def setUp(self): self.assets = pathlib.Path(self.temp.name) self.revision = "a" * 40 self.stem = "oac-" + self.revision + "-linux-amd64" - for suffix in (".tar.gz", "-offline.tar.gz"): - name = self.stem + suffix + 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.gh_run = stack.enter_context(mock.patch.object(publisher.subprocess, "run")) - self.api = stack.enter_context(mock.patch.object(publisher.subprocess, "check_output", - side_effect=self.response)) - - def response(self, command, **kwargs): - if "git/" in command[2]: - return json.dumps({"object": {"type": "commit", "sha": self.revision}}) - return json.dumps({"id": 7, "draft": "--method" not in command, - "tag_name": "v1.2.3", "target_commitish": self.revision}) + 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 test_version_tag_publishes_all_assets(self): + 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() - command = self.gh_run.call_args.args[0] - self.assertIn("--verify-tag", command) - self.assertIn("--draft", command) - self.assertNotIn("--prerelease", command) - self.assertNotIn("--clobber", command) - self.assertEqual(command[-4:], sorted(str(p.resolve()) for p in self.assets.iterdir())) - self.assertEqual(self.api.call_args.args[0][2:], - ["repos/MiniMax-AI/parsar-core/releases/7", "--method", "PATCH", "-F", "draft=false"]) + 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(command, **kwargs): - endpoint = command[2] - if "git/ref/" in endpoint: - return json.dumps({"object": {"type": "tag", "sha": "b" * 40}}) - return self.response(command).replace("v1.2.3", "v1.2.3-rc.1") + 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.assertIn("--prerelease", self.gh_run.call_args.args[0]) - self.assertEqual(sum("git/tags/" in c.args[0][2] for c in self.api.call_args_list), 2) + 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.api.side_effect = lambda c, **kw: self.response(c).replace("v1.2.3", "v1.2.3+build-test") self.publish("v1.2.3+build-test") - self.assertNotIn("--prerelease", self.gh_run.call_args.args[0]) + self.assertFalse(self.release["prerelease"]) def test_manual_draft_does_not_publish(self): - (self.assets / (self.stem + "-offline.tar.gz")).unlink() - (self.assets / (self.stem + "-offline.tar.gz.sha256")).unlink() + for suffix in ("-offline.tar.gz", "-offline.tar.gz.sha256"): + (self.assets / (self.stem + suffix)).unlink() self.publish("build-" + self.revision, "draft") - self.assertIn("--draft", self.gh_run.call_args.args[0]) - self.api.assert_not_called() + 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): - self.api.side_effect = lambda *a, **kw: json.dumps({"object": {"type": "commit", "sha": "b" * 40}}) + 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.gh_run.assert_not_called() + self.assertEqual(self.writes(), []) - def test_failed_tag_lookup_never_creates_release(self): - self.api.side_effect = subprocess.CalledProcessError(1, ["gh", "api"]) + 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.gh_run.assert_not_called() + 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_failed_or_existing_release_is_not_overwritten_or_replayed(self): - self.gh_run.side_effect = subprocess.CalledProcessError(1, ["gh", "release", "create"]) + 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.assertEqual(self.gh_run.call_count, 1) + 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.gh_run.assert_not_called() + 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.gh_run.assert_not_called() + 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.gh_run.assert_not_called() + self.assertEqual(self.writes(), []) def test_empty_or_linked_payload_is_refused(self): for p in self.assets.iterdir(): @@ -119,43 +192,40 @@ def test_empty_or_linked_payload_is_refused(self): (self.assets / "link").symlink_to(__file__) with self.assertRaisesRegex(ValueError, "regular"): self.publish() - self.gh_run.assert_not_called() - + self.assertEqual(self.writes(), []) - def test_tag_moved_during_upload_keeps_draft_unpublished(self): - tag_reads = 0 - def response(command, **kwargs): - nonlocal tag_reads - if "git/" in command[2]: - tag_reads += 1 - if tag_reads == 2: - return json.dumps({"object": {"type": "commit", "sha": "b" * 40}}) - return self.response(command) - self.api.side_effect = response - with self.assertRaisesRegex(ValueError, "built source"): - self.publish() - self.assertEqual(self.gh_run.call_count, 1) - self.assertFalse(any("--method" in c.args[0] for c in self.api.call_args_list)) - def test_lost_publication_response_never_deletes_or_retries(self): - def response(command, **kwargs): - if "--method" in command: - raise subprocess.CalledProcessError(1, command) - return self.response(command) + 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() - writes = [c.args[0] for c in self.api.call_args_list if "--method" in c.args[0]] - self.assertEqual(len(writes), 1) - self.assertIn("PATCH", writes[0]) - self.assertNotIn("DELETE", str(self.api.call_args_list)) - self.assertEqual(self.gh_run.call_count, 1) - - def test_changed_draft_identity_is_refused(self): - self.api.side_effect = lambda c, **kw: self.response(c).replace('"target_commitish": "' + self.revision, '"target_commitish": "' + "b" * 40) - with self.assertRaisesRegex(ValueError, "draft identity"): + 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.assertFalse(any("--method" in c.args[0] for c in self.api.call_args_list)) + 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__": From 141e819421c92ac69694929bc04023c36ce72845 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 04:35:16 +0000 Subject: [PATCH 3/5] docs: install latest release with one public command --- CONTRIBUTING.md | 4 + Makefile | 3 +- README.md | 6 + README.zh-CN.md | 6 + apps/docs/content/docs/install.mdx | 63 +++++----- apps/docs/content/guide-sources.json | 4 +- docs/getting-started/install.md | 63 +++++----- docs/maintainers.md | 5 +- scripts/install-release.test.py | 164 +++++++++++++++++++++++++++ site/index.html | 5 +- 10 files changed, 248 insertions(+), 75 deletions(-) create mode 100644 scripts/install-release.test.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e8bf4615..ab8741c33 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1979,6 +1979,10 @@ 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. diff --git a/Makefile b/Makefile index 101556199..e54d3c41f 100644 --- a/Makefile +++ b/Makefile @@ -130,10 +130,11 @@ check-distribution: 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/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 6ca28bced..0c7b2b673 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -50,7 +50,10 @@ 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. Images are shipped as archives; this workflow does not push an image registry. +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 diff --git a/scripts/install-release.test.py b/scripts/install-release.test.py new file mode 100644 index 000000000..6ab3523a3 --- /dev/null +++ b/scripts/install-release.test.py @@ -0,0 +1,164 @@ +"""Public release bootstrap tests; fixtures never start Docker or a real installer.""" +import contextlib +import hashlib +import io +import json +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_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/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.

From 5dd25934266c96ba01ba61d864533f1f8bca45fe Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 04:36:43 +0000 Subject: [PATCH 4/5] fix: create private release download directories --- deploy/install-release.sh | 6 ++++-- scripts/install-release.test.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/deploy/install-release.sh b/deploy/install-release.sh index 384d7380f..eaaf32902 100755 --- a/deploy/install-release.sh +++ b/deploy/install-release.sh @@ -113,8 +113,10 @@ def install(version, arguments): 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) - cache = pathlib.Path.home() / ".oac" / "releases" - cache.mkdir(mode=0o700, parents=True, exist_ok=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: diff --git a/scripts/install-release.test.py b/scripts/install-release.test.py index 6ab3523a3..871603fe8 100644 --- a/scripts/install-release.test.py +++ b/scripts/install-release.test.py @@ -3,6 +3,8 @@ import hashlib import io import json +import os +import stat import pathlib import subprocess import tarfile @@ -153,6 +155,15 @@ def test_installer_failure_is_returned_and_bundle_retained(self): 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) From f48c8d34536f5938b722ff0bd72f41053f7d6a16 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 29 Sep 2026 05:06:15 +0000 Subject: [PATCH 5/5] Remove retired isolation inputs from combined runtime image --- deploy/distribution/Runtime.Dockerfile | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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