Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6963378a89 | |||
| 289c65603e | |||
| d57392df4d | |||
| 06739ba9e0 |
@@ -9,15 +9,20 @@ on:
|
||||
- "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
|
||||
# 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
|
||||
|
||||
# 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:
|
||||
@@ -40,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Build sdist + wheel
|
||||
run: |
|
||||
python -m pip install --upgrade pip build
|
||||
python -m pip install --upgrade pip build twine
|
||||
python -m build
|
||||
|
||||
- name: Smoke-import the built wheel
|
||||
@@ -49,6 +54,9 @@ jobs:
|
||||
/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
|
||||
@@ -57,13 +65,31 @@ jobs:
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v3 # pinned to v3 for Gitea act_runner v0.6 compatibility (internal#46)
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||
- 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/*
|
||||
@@ -80,7 +80,7 @@ Tests are entirely real-subprocess (no mocking the spawn boundary) so the boot p
|
||||
|
||||
## 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
|
||||
# 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.
|
||||
|
||||
**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
|
||||
2. Pre-register the project on PyPI under a "Pending publisher" config so the first tagged push creates it.
|
||||
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.
|
||||
|
||||
Either way, on the project's PyPI page → "Manage" → "Publishing" → "Add a new publisher", configure:
|
||||
|
||||
- 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.
|
||||
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).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+14
-2
@@ -4,14 +4,26 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
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."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "Apache-2.0" }
|
||||
authors = [{ name = "Molecule AI" }]
|
||||
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]
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user