Compare commits
10 Commits
fix/instal
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6963378a89 | |||
| 289c65603e | |||
| d57392df4d | |||
| 06739ba9e0 | |||
| b29ce85137 | |||
| 4a8cd3648f | |||
|
|
d59d1f15ac | ||
| a4b3109e49 | |||
| cdf0892b2e | |||
| 2f7f7a36c8 |
95
.gitea/workflows/publish.yml
Normal file
95
.gitea/workflows/publish.yml
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
name: Publish to PyPI
|
||||||
|
|
||||||
|
# Triggered on tag push (vX.Y.Z). Tag-on-push instead of release-creation
|
||||||
|
# is the cheaper UX — `git tag v0.1.0 && git push origin v0.1.0` ships
|
||||||
|
# without leaving the terminal.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||||
|
- "v[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
|
||||||
|
|
||||||
|
# Post-2026-05-06 (Molecule-AI GitHub org suspension): PyPI's Trusted
|
||||||
|
# Publisher OIDC flow only accepts GitHub/GitLab/Google/ActiveState
|
||||||
|
# issuers — not Gitea. This workflow uses a long-lived PyPI API token
|
||||||
|
# stored as the repo-level secret PYPI_TOKEN, fanned out from the
|
||||||
|
# operator-host SSOT (/etc/molecule-bootstrap/all-credentials.env) by
|
||||||
|
# /opt/molecule-bootstrap/sync-pypi-token.sh.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# Serialize tag-driven publishes so two concurrent tag pushes don't both
|
||||||
|
# try to upload the same version and race PyPI.
|
||||||
|
concurrency:
|
||||||
|
group: publish-pypi
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Verify tag matches pyproject version
|
||||||
|
run: |
|
||||||
|
tag="${GITHUB_REF#refs/tags/v}"
|
||||||
|
pkg=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
||||||
|
if [ "$tag" != "$pkg" ]; then
|
||||||
|
echo "::error::tag $tag does not match pyproject version $pkg — aborting publish to keep PyPI in sync with git tags"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build sdist + wheel
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip build twine
|
||||||
|
python -m build
|
||||||
|
|
||||||
|
- name: Smoke-import the built wheel
|
||||||
|
run: |
|
||||||
|
python -m venv /tmp/install-test
|
||||||
|
/tmp/install-test/bin/pip install dist/*.whl
|
||||||
|
/tmp/install-test/bin/codex-channel-molecule --help
|
||||||
|
|
||||||
|
- name: Verify package metadata (twine check)
|
||||||
|
run: python -m twine check dist/*
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v3 # pinned to v3 for Gitea act_runner v0.6 compatibility (internal#46)
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v3 # pinned to v3 for Gitea act_runner v0.6 compatibility (internal#46)
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Publish to PyPI
|
||||||
|
# PYPI_TOKEN: repo-level Gitea Actions secret, written by
|
||||||
|
# /opt/molecule-bootstrap/sync-pypi-token.sh from the operator-host
|
||||||
|
# SSOT. Never set this by hand — rotate via the SSOT instead
|
||||||
|
# (ops/PYPI_TOKEN_ROTATION.md in operator-config).
|
||||||
|
env:
|
||||||
|
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$PYPI_TOKEN" ]; then
|
||||||
|
echo "::error::PYPI_TOKEN secret is not set. Run sync-pypi-token.sh on the operator host to fan it out from SSOT."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
python -m pip install --upgrade twine
|
||||||
|
python -m twine upload \
|
||||||
|
--repository pypi \
|
||||||
|
--username __token__ \
|
||||||
|
--password "$PYPI_TOKEN" \
|
||||||
|
dist/*
|
||||||
69
.github/workflows/publish.yml
vendored
69
.github/workflows/publish.yml
vendored
@ -1,69 +0,0 @@
|
|||||||
name: Publish to PyPI
|
|
||||||
|
|
||||||
# Triggered on tag push (vX.Y.Z). Tag-on-push instead of release-creation
|
|
||||||
# is the cheaper UX — `git tag v0.1.0 && git push origin v0.1.0` ships
|
|
||||||
# without leaving the terminal.
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
|
||||||
- "v[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
# OIDC token for PyPI trusted-publisher auth — no secret token needed.
|
|
||||||
# PyPI side: register
|
|
||||||
# github.com/Molecule-AI/codex-channel-molecule
|
|
||||||
# workflow=publish.yml environment=pypi
|
|
||||||
# under "Trusted publisher management" on the codex-channel-molecule
|
|
||||||
# PyPI project page (see README "Releasing" section).
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Verify tag matches pyproject version
|
|
||||||
run: |
|
|
||||||
tag="${GITHUB_REF#refs/tags/v}"
|
|
||||||
pkg=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
||||||
if [ "$tag" != "$pkg" ]; then
|
|
||||||
echo "::error::tag $tag does not match pyproject version $pkg — aborting publish to keep PyPI in sync with git tags"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build sdist + wheel
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip build
|
|
||||||
python -m build
|
|
||||||
|
|
||||||
- name: Smoke-import the built wheel
|
|
||||||
run: |
|
|
||||||
python -m venv /tmp/install-test
|
|
||||||
/tmp/install-test/bin/pip install dist/*.whl
|
|
||||||
/tmp/install-test/bin/codex-channel-molecule --help
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: dist
|
|
||||||
path: dist/
|
|
||||||
|
|
||||||
publish:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: pypi
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: dist
|
|
||||||
path: dist/
|
|
||||||
|
|
||||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
||||||
16
README.md
16
README.md
@ -80,7 +80,7 @@ Tests are entirely real-subprocess (no mocking the spawn boundary) so the boot p
|
|||||||
|
|
||||||
## Releasing
|
## Releasing
|
||||||
|
|
||||||
Tag-on-push triggers `publish.yml` which builds + publishes to PyPI via OIDC trusted publishing (no API token needed).
|
Tag-on-push triggers `.gitea/workflows/publish.yml` which builds + publishes to PyPI via `twine upload` using the `PYPI_TOKEN` repo-level Gitea Actions secret.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Bump pyproject.toml `version`, commit, then:
|
# Bump pyproject.toml `version`, commit, then:
|
||||||
@ -89,19 +89,11 @@ git tag v0.1.1 && git push origin v0.1.1
|
|||||||
|
|
||||||
The workflow refuses to publish if the tag doesn't match `pyproject.toml`'s `version` — keeps PyPI versions and git tags in lockstep.
|
The workflow refuses to publish if the tag doesn't match `pyproject.toml`'s `version` — keeps PyPI versions and git tags in lockstep.
|
||||||
|
|
||||||
**One-time PyPI setup** (before the first release):
|
### Why twine, not Trusted Publisher OIDC
|
||||||
|
|
||||||
1. Create the project on PyPI by uploading the first wheel manually, OR
|
Post-2026-05-06 (Molecule-AI GitHub-org suspension) the canonical SCM is Gitea. PyPI's Trusted-Publisher OIDC flow only recognises GitHub / GitLab / Google / ActiveState issuers — not Gitea — so this repo (and every other PyPI-publishing repo in `molecule-ai/*`) falls back to a long-lived API token.
|
||||||
2. Pre-register the project on PyPI under a "Pending publisher" config so the first tagged push creates it.
|
|
||||||
|
|
||||||
Either way, on the project's PyPI page → "Manage" → "Publishing" → "Add a new publisher", configure:
|
The `PYPI_TOKEN` secret is **not set by hand**. It is fanned out from the operator-host SSOT (`/etc/molecule-bootstrap/all-credentials.env`) by `/opt/molecule-bootstrap/sync-pypi-token.sh` (see [operator-config/etc/pypi-publishers.yaml](https://git.moleculesai.app/molecule-ai/operator-config/src/branch/main/etc/pypi-publishers.yaml)). Rotation procedure: [PYPI_TOKEN_ROTATION.md](https://git.moleculesai.app/molecule-ai/operator-config/src/branch/main/ops/PYPI_TOKEN_ROTATION.md).
|
||||||
|
|
||||||
- Owner: `Molecule-AI`
|
|
||||||
- Repository: `codex-channel-molecule`
|
|
||||||
- Workflow filename: `publish.yml`
|
|
||||||
- Environment name: `pypi`
|
|
||||||
|
|
||||||
After this, every `git push origin v*.*.*` ships the wheel to PyPI without any further intervention.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@ -4,14 +4,26 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "codex-channel-molecule"
|
name = "codex-channel-molecule"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
description = "Bridge daemon for codex CLI ↔ Molecule platform — long-polls the platform inbox, runs `codex exec --resume <session>` per inbound message, replies via send_message_to_user MCP tool. Counterpart to hermes-channel-molecule."
|
description = "Bridge daemon for codex CLI ↔ Molecule platform — long-polls the platform inbox, runs `codex exec --resume <session>` per inbound message, replies via send_message_to_user MCP tool. Counterpart to hermes-channel-molecule."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
license = { text = "Apache-2.0" }
|
license = { text = "Apache-2.0" }
|
||||||
authors = [{ name = "Molecule AI" }]
|
authors = [{ name = "Molecule AI" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"molecule-ai-workspace-runtime>=0.1.110",
|
# Floor raised from 0.1.110 → 0.1.129 to pull in the SSOT A2A response
|
||||||
|
# parser (a2a_response.py, introduced 0.1.129). Pre-0.1.129 the legacy
|
||||||
|
# inline sniffer in a2a_client.send_a2a_message treated the poll-mode
|
||||||
|
# ``{"status": "queued", "delivery_mode": "poll", "method": "..."}``
|
||||||
|
# envelope as malformed and surfaced ``[A2A_ERROR] unexpected response
|
||||||
|
# shape (no result, no error): ...`` on every reply attempt — that
|
||||||
|
# then propagated to canvas as the workspace's task label and triggered
|
||||||
|
# a ~3s retry storm. The 0.1.129+ runtime classifies the envelope as
|
||||||
|
# ``Queued`` and short-circuits to the durable /delegate-poll path
|
||||||
|
# (a2a_tools_delegation._delegate_sync_via_polling). See
|
||||||
|
# ``molecule-ai/internal#424`` and ``molecule-core#2967`` for the
|
||||||
|
# full incident + fix history.
|
||||||
|
"molecule-ai-workspace-runtime>=0.1.129",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@ -24,8 +36,8 @@ test = [
|
|||||||
codex-channel-molecule = "codex_channel_molecule.daemon:main"
|
codex-channel-molecule = "codex_channel_molecule.daemon:main"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/Molecule-AI/codex-channel-molecule"
|
Homepage = "https://git.moleculesai.app/molecule-ai/codex-channel-molecule"
|
||||||
Repository = "https://github.com/Molecule-AI/codex-channel-molecule"
|
Repository = "https://git.moleculesai.app/molecule-ai/codex-channel-molecule"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["codex_channel_molecule*"]
|
include = ["codex_channel_molecule*"]
|
||||||
|
|||||||
106
tests/test_runtime_dependency_floor.py
Normal file
106
tests/test_runtime_dependency_floor.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"""Pin the minimum molecule-ai-workspace-runtime version that ships
|
||||||
|
the SSOT A2A response parser.
|
||||||
|
|
||||||
|
Background — see ``molecule-ai/internal#424``:
|
||||||
|
|
||||||
|
Pre-runtime-0.1.129 the inline sniffer in ``a2a_client.send_a2a_message``
|
||||||
|
checked for ``result`` or ``error`` keys and routed everything else to
|
||||||
|
``[A2A_ERROR] unexpected response shape (no result, no error): ...``.
|
||||||
|
That branch fired on every reply attempt to a poll-mode peer because the
|
||||||
|
platform proxy synthesizes the success envelope as
|
||||||
|
``{"status": "queued", "delivery_mode": "poll", "method": "..."}``
|
||||||
|
which has NEITHER ``result`` NOR ``error``. The retry loop hammered the
|
||||||
|
caller's canvas with the error string every ~3s.
|
||||||
|
|
||||||
|
Version 0.1.129 introduced ``a2a_response.py`` — a typed parser with an
|
||||||
|
explicit ``Queued`` variant — and the matching ``[A2A_QUEUED]`` sentinel
|
||||||
|
in ``a2a_client.py``. ``a2a_tools_delegation.tool_delegate_task`` then
|
||||||
|
falls back to the durable ``/delegate`` + ``/delegations`` polling path,
|
||||||
|
which IS the correct synchronous facade for poll-mode peers.
|
||||||
|
|
||||||
|
This test asserts the floor is held at ``>=0.1.129`` so a future
|
||||||
|
dependency-housekeeping pass cannot silently lower it back into the
|
||||||
|
broken range. If the floor is ever raised further (e.g. to require a
|
||||||
|
later SSOT parser feature), update the constant below — the lower bound
|
||||||
|
must never go below 0.1.129.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 11):
|
||||||
|
import tomllib
|
||||||
|
else: # pragma: no cover - Python 3.11+ required by pyproject anyway
|
||||||
|
import tomli as tomllib
|
||||||
|
|
||||||
|
|
||||||
|
# The earliest runtime version that ships the SSOT A2A response parser
|
||||||
|
# with the typed ``Queued`` variant. Bumping this floor MUST be paired
|
||||||
|
# with a comment update in pyproject.toml's dependencies block.
|
||||||
|
_MINIMUM_RUNTIME_VERSION = (0, 1, 129)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_version(spec: str) -> tuple[int, int, int]:
|
||||||
|
"""Extract the lower-bound version from a dependency specifier.
|
||||||
|
|
||||||
|
Supports the common shapes used in our pyproject files:
|
||||||
|
|
||||||
|
* ``pkg>=1.2.3`` → (1, 2, 3)
|
||||||
|
* ``pkg>=1.2.3,<2`` → (1, 2, 3)
|
||||||
|
* ``pkg~=1.2.3`` → (1, 2, 3)
|
||||||
|
|
||||||
|
Raises ``ValueError`` on anything else so the test fails loud rather
|
||||||
|
than silently passing on a malformed specifier.
|
||||||
|
"""
|
||||||
|
m = re.search(r"(?:>=|~=)\s*(\d+)\.(\d+)\.(\d+)", spec)
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f"no lower-bound version in specifier: {spec!r}")
|
||||||
|
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def runtime_specifier() -> str:
|
||||||
|
"""Return the raw dependency specifier for molecule-ai-workspace-runtime."""
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
pyproject = root / "pyproject.toml"
|
||||||
|
with pyproject.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
deps = data["project"]["dependencies"]
|
||||||
|
for dep in deps:
|
||||||
|
# match the package name allowing a hyphen-or-underscore in case
|
||||||
|
# the spec ever normalizes — PEP 503 treats them equivalently.
|
||||||
|
if re.match(r"^molecule[-_]ai[-_]workspace[-_]runtime\b", dep):
|
||||||
|
return dep
|
||||||
|
raise AssertionError(
|
||||||
|
"pyproject.toml is missing a molecule-ai-workspace-runtime dependency"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_floor_includes_a2a_response_parser(runtime_specifier: str) -> None:
|
||||||
|
"""The runtime floor must be at or above the SSOT parser release."""
|
||||||
|
bound = _parse_version(runtime_specifier)
|
||||||
|
assert bound >= _MINIMUM_RUNTIME_VERSION, (
|
||||||
|
f"runtime floor {bound} is below {_MINIMUM_RUNTIME_VERSION} — "
|
||||||
|
f"pre-0.1.129 the A2A response parser misclassifies the poll-mode "
|
||||||
|
f"queued envelope as malformed and surfaces "
|
||||||
|
f"'[A2A_ERROR] unexpected response shape' on every poll-mode peer "
|
||||||
|
f"reply. See molecule-ai/internal#424."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_specifier_uses_a_lower_bound(runtime_specifier: str) -> None:
|
||||||
|
"""A bare ``pkg`` or upper-only spec would silently install ANY version
|
||||||
|
on a fresh ``pip install`` — including the buggy pre-0.1.129 range.
|
||||||
|
|
||||||
|
Require an explicit lower bound (``>=`` or ``~=``).
|
||||||
|
"""
|
||||||
|
assert re.search(r">=|~=", runtime_specifier), (
|
||||||
|
f"runtime dependency {runtime_specifier!r} has no lower bound — "
|
||||||
|
f"a fresh install could resolve to a pre-0.1.129 wheel with the "
|
||||||
|
f"broken poll-mode parser"
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user