Compare commits

..

4 Commits

Author SHA1 Message Date
claude-ceo-assistant 96eec447de fix(ci): split publish-runtime into tags-only + autobump (closes #351) (#352)
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
2026-05-11 01:35:16 +00:00
hongming 90f9987e88 fix(ci): split publish-runtime into tags-only + autobump (closes #351)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Successful in 2s (run 5051 on pull_request_target:edited; manual refresh per go-gitea#33700)
audit-force-merge / audit (pull_request) Successful in 3s
publish-runtime.yml has never fired since the .gitea port (0 rows in
action_run.workflow_id='publish-runtime.yml' ever), which is why PyPI
is still at 0.1.129 despite Gitea having a runtime-v1.0.0 tag.

Root cause hypothesis: Gitea Actions evaluates the on.push.paths filter
against tag-push events too (no path diff → workflow skipped). PR #349
made this visible by adding the paths trigger, but the same defect
existed for the originally-ported tags-only trigger on this Gitea version
— hence the runtime-v1.0.0 tag also never published.

Fix: split into two files, each with a single unambiguous trigger shape.

  - publish-runtime.yml          : on.push.tags only       (the publisher)
  - publish-runtime-autobump.yml : on.push.branches+paths  (NEW; the bumper)

The autobump file computes next version from PyPI latest, pushes
'runtime-v$VERSION' tag via DISPATCH_TOKEN (not GITHUB_TOKEN — needed
to trigger downstream workflows on Gitea), and exits. The tag push
then triggers publish-runtime.yml.

Test plan after merge:
  1. Push no-op commit to workspace/. Observe autobump fire, push tag.
  2. Observe publish-runtime.yml fire on the tag, publish 0.1.130 to
     PyPI, cascade to template repos.
  3. Verify 'action_run' shows >0 rows for both workflow_ids.
2026-05-10 18:31:00 -07:00
claude-ceo-assistant 469f253c0d feat(ci): restore staging+main path-filter trigger on publish-runtime (closes #348 Q1) (#349)
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
2026-05-11 01:21:34 +00:00
hongming 269c08a5a1 feat(ci): restore staging+main path-filter trigger on publish-runtime (closes #348 Q1)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Successful in 2s (manual refresh: run 5030 on pull_request_label event succeeded; commit-status stale per go-gitea#33700)
audit-force-merge / audit (pull_request) Successful in 3s
Adds back the original GitHub workflow's auto-publish trigger that was
dropped during the 2026-05-10 .gitea port (#206). Push to main or
staging filtered by workspace/** falls into the existing PyPI-latest
auto-bump path — no logic changes, just the missing trigger and a
comment correction.

Caveat: the workflow still requires PYPI_TOKEN as a repository secret
(or org-level). Without it the publish step will fail loudly with a
descriptive error. Q2 follow-up tracks setting the secret.

Refs: molecule-core#348
2026-05-10 17:59:25 -07:00
10 changed files with 130 additions and 91 deletions
@@ -0,0 +1,100 @@
name: publish-runtime-autobump
# Auto-bump-on-workspace-edit half of the publish pipeline.
#
# Why this file exists (issue #351):
# Gitea Actions does not correctly disambiguate `paths:` from `tags:`
# when both are bundled under a single `on.push` key. The result is
# that tag pushes get filtered out and `publish-runtime.yml` never
# fires — `action_run` rows: 0. This was unnoticed pre-2026-05-11
# because PYPI_TOKEN was absent (publishes would have failed anyway).
#
# Split design:
# - publish-runtime.yml : on.push.tags only (the publisher)
# - publish-runtime-autobump.yml: on.push.branches+paths (this file — the version-bumper)
#
# This file computes the next version from PyPI's latest, pushes a
# `runtime-v$VERSION` tag, and exits. The tag push then triggers
# publish-runtime.yml via its tags-only trigger.
#
# Concurrency: shares the `publish-runtime` group with publish-runtime.yml
# so concurrent workspace pushes serialize at the bump step. Without
# this, two pushes minutes apart could both read PyPI latest=0.1.129
# and try to tag 0.1.130 simultaneously, only one of which would land.
on:
push:
branches:
- main
- staging
paths:
- "workspace/**"
permissions:
contents: write # required to push tags back
concurrency:
group: publish-runtime
cancel-in-progress: false
jobs:
autobump-and-tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Fetch full tag list so the bump logic can sanity-check against
# what's already in this repo (catches collision with prior
# manual tag pushes).
fetch-depth: 0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Compute next version from PyPI latest
id: bump
run: |
set -eu
LATEST=$(curl -fsS --retry 3 https://pypi.org/pypi/molecule-ai-workspace-runtime/json \
| python -c "import sys,json; print(json.load(sys.stdin)['info']['version'])")
MAJOR=$(echo "$LATEST" | cut -d. -f1)
MINOR=$(echo "$LATEST" | cut -d. -f2)
PATCH=$(echo "$LATEST" | cut -d. -f3)
VERSION="${MAJOR}.${MINOR}.$((PATCH+1))"
echo "PyPI latest=$LATEST -> next=$VERSION"
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::computed version $VERSION does not match PEP 440 X.Y.Z"
exit 1
fi
if git tag --list | grep -qx "runtime-v$VERSION"; then
echo "::error::tag runtime-v$VERSION already exists in this repo. Manual intervention required (PyPI and Gitea tag history are out of sync)."
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Push runtime-v$VERSION tag
env:
DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
VERSION: ${{ steps.bump.outputs.version }}
GITEA_URL: https://git.moleculesai.app
run: |
set -eu
if [ -z "$DISPATCH_TOKEN" ]; then
echo "::error::DISPATCH_TOKEN secret is not set — needed to push the tag back to molecule-core."
exit 1
fi
git config user.name "publish-runtime autobump"
git config user.email "publish-runtime@moleculesai.app"
git tag -a "runtime-v$VERSION" \
-m "Auto-bump on workspace/** edit on $GITHUB_REF" \
-m "Triggered by: $GITHUB_REF @ $GITHUB_SHA" \
-m "publish-runtime.yml will pick up this tag and upload to PyPI"
# Push via DISPATCH_TOKEN (a Gitea PAT). Using the bot identity
# ensures the resulting tag-push event is dispatched to
# publish-runtime.yml; act_runner's default GITHUB_TOKEN cannot
# trigger downstream workflows.
git remote set-url origin "${GITEA_URL#https://}"
git remote set-url origin "https://x-access-token:${DISPATCH_TOKEN}@${GITEA_URL#https://}/molecule-ai/molecule-core.git"
git push origin "runtime-v$VERSION"
echo "✓ pushed runtime-v$VERSION — publish-runtime.yml should fire next"
+21 -5
View File
@@ -12,7 +12,24 @@ name: publish-runtime
# - Replaced `github.ref_name` (GitHub-only) with `${GITHUB_REF#refs/tags/}`
# — Gitea Actions exposes github.ref (the full ref) but not ref_name
# - Dropped `merge_group` trigger (Gitea has no merge queue)
# - Dropped `staging` branch trigger (no staging branch exists in this repo)
#
# 2026-05-10 (issue #348): originally restored `staging`/`main` branch +
# `workspace/**` path-filter trigger in PR #349.
#
# 2026-05-11 (issue #351): REVERTED the branches+paths trigger from THIS
# file. Bundling `paths` with `tags` under a single `on.push` key caused
# Gitea Actions to never dispatch the workflow for tag-push events (0
# runs in `action_run` for workflow_id='publish-runtime.yml' since the
# port, including the runtime-v1.0.0 tag — which is why PyPI is still at
# 0.1.129 despite a v1.0.0 Gitea tag existing).
#
# The auto-bump-on-workspace-edit trigger now lives in
# `.gitea/workflows/publish-runtime-autobump.yml`. That file computes the
# next version from PyPI's latest and pushes a `runtime-v$VERSION` tag,
# which THIS file then picks up via the tags-only trigger below.
#
# This decoupling means Gitea's path-vs-tag evaluator never has to
# disambiguate — each file has a single unambiguous trigger shape.
#
# PyPI publishing: requires PYPI_TOKEN repository secret (or org-level secret).
# Set via: repo Settings → Actions → Variables and Secrets → New Secret.
@@ -65,10 +82,9 @@ jobs:
VERSION="${GITHUB_REF#refs/tags/runtime-v}"
else
# Fallback: derive from PyPI latest + patch bump.
# (The staging-push auto-bump trigger is dropped on Gitea —
# no staging branch exists. This fallback path is kept for
# robustness if a future automation uses workflow_dispatch without
# an explicit version input.)
# Used by the restored `push.branches: [main, staging]` +
# `paths: workspace/**` auto-bump trigger (issue #348). Also kept
# for workflow_dispatch invocations that omit the version input.
LATEST=$(curl -fsS --retry 3 https://pypi.org/pypi/molecule-ai-workspace-runtime/json \
| python -c "import sys,json; print(json.load(sys.stdin)['info']['version'])")
MAJOR=$(echo "$LATEST" | cut -d. -f1)
+1 -1
View File
@@ -28,7 +28,7 @@ WORKSPACE_ID = _WORKSPACE_ID_raw
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
else:
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
async def discover(target_id: str) -> dict | None:
+1 -1
View File
@@ -29,7 +29,7 @@ WORKSPACE_ID = _WORKSPACE_ID_raw
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
else:
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
# Cache workspace ID → name mappings (populated by list_peers calls)
_peer_names: dict[str, str] = {}
+4 -14
View File
@@ -54,16 +54,6 @@ import httpx
logger = logging.getLogger(__name__)
def _platform_url() -> str:
"""Return the platform URL, defaulting to host.docker.internal when running
inside a Docker container (where localhost refers to the container, not the
host). External callers can always override via the PLATFORM_URL env var.
"""
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
return os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
return os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
# ─────────────────────────────────────────────────────────────────────────────
# Constants
# ─────────────────────────────────────────────────────────────────────────────
@@ -89,12 +79,12 @@ async def _fetch_latest_checkpoint(workspace_id: str) -> Optional[dict]:
workspace_id: The workspace to query.
Reads:
PLATFORM_URL Platform base URL (default ``http://host.docker.internal:8080``).
PLATFORM_URL Platform base URL (default ``http://localhost:8080``).
"""
try:
from platform_auth import auth_headers as _auth_headers # type: ignore[import]
platform_url = _platform_url()
platform_url = os.environ.get("PLATFORM_URL", "http://localhost:8080")
url = f"{platform_url}/workspaces/{workspace_id}/checkpoints/latest"
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(url, headers=_auth_headers())
@@ -135,12 +125,12 @@ async def _save_checkpoint(
payload: Optional JSON-serialisable dict stored as JSONB.
Reads:
PLATFORM_URL Platform base URL (default ``http://host.docker.internal:8080``).
PLATFORM_URL Platform base URL (default ``http://localhost:8080``).
"""
try:
from platform_auth import auth_headers as _auth_headers # type: ignore[import]
platform_url = _platform_url()
platform_url = os.environ.get("PLATFORM_URL", "http://localhost:8080")
url = f"{platform_url}/workspaces/{workspace_id}/checkpoints"
body: dict = {
"workflow_id": workflow_id,
+1 -1
View File
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
else:
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
_WORKSPACE_ID_raw = os.environ.get("WORKSPACE_ID")
if not _WORKSPACE_ID_raw:
raise RuntimeError("WORKSPACE_ID environment variable is required but not set")
+1 -1
View File
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
else:
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
PLATFORM_URL = os.environ.get("PLATFORM_URL", "http://localhost:8080")
_WORKSPACE_ID_raw = os.environ.get("WORKSPACE_ID")
if not _WORKSPACE_ID_raw:
raise RuntimeError("WORKSPACE_ID environment variable is required but not set")
+1 -1
View File
@@ -63,7 +63,7 @@ async def main(): # pragma: no cover
if os.path.exists("/.dockerenv") or os.environ.get("DOCKER_VERSION"):
platform_url = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
else:
platform_url = os.environ.get("PLATFORM_URL", "http://host.docker.internal:8080")
platform_url = os.environ.get("PLATFORM_URL", "http://localhost:8080")
awareness_config = get_awareness_config()
# 0. Initialise OpenTelemetry (no-op if packages not installed)
-26
View File
@@ -51,32 +51,6 @@ class AdaptorSource:
def _load_module_from_path(module_name: str, path: Path):
"""Import a Python file by absolute path. Returns the module or None on failure."""
# KI-296: Before exec'ing plugin-adapter files (which import
# ``from plugins_registry import ...`` as a top-level name), register
# the molecule-runtime subpackage as ``plugins_registry`` in sys.modules.
# In the molecule-core workspace source this is already a top-level package,
# so the setdefault is a no-op. In the PyPI-installed runtime wheel
# (molecule-ai-workspace-runtime 0.1.129+), the package ships as
# ``molecule_runtime.plugins_registry`` and without this shim every
# plugin adapter would fail with ModuleNotFoundError.
import sys as _sys
if "plugins_registry" not in _sys.modules:
try:
_mr_pr = __import__("molecule_runtime.plugins_registry", fromlist=[""])
_sys.modules["plugins_registry"] = _mr_pr
# Also register submodules the adapters commonly import directly.
for _sub in ("builtins", "protocol", "raw_drop"):
_submod = getattr(_mr_pr, _sub, None)
if _submod is not None:
_sys.modules[f"plugins_registry.{_sub}"] = _submod
except ImportError:
# molecule-runtime not installed (e.g. test environment with
# workspace/ on sys.path directly) — skip shim; the top-level
# workspace/plugins_registry package is already findable.
pass
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
return None
-41
View File
@@ -325,44 +325,3 @@ def test_resolve_registry_missing_module_falls_through(monkeypatch, tmp_path: Pa
monkeypatch.setattr(pr, "_REGISTRY_ROOT", tmp_path / "empty-registry")
_, source = pr.resolve("demo-plugin", "test_runtime", plugin_root)
assert source == AdaptorSource.RAW_DROP
def test_load_module_from_path_registers_plugins_registry_sys_modules(tmp_path: Path):
"""KI-296: _load_module_from_path registers ``plugins_registry`` in sys.modules
before exec'ing the adapter, so adapter files that do
``from plugins_registry import ...`` resolve correctly when the runtime is
installed from the PyPI wheel (where the package ships as
``molecule_runtime.plugins_registry`` rather than a top-level ``plugins_registry``).
"""
import sys as _sys
import plugins_registry as pr
# Create a fake adapter that imports plugins_registry at top level.
adapter_file = tmp_path / "fake_runtime_adapter.py"
adapter_file.write_text(
"from plugins_registry import InstallContext # noqa: F401\n"
"from plugins_registry.builtins import AgentskillsAdaptor as Adaptor # noqa: F401\n"
)
# Evict any pre-existing sys.modules entries for the shim keys so the
# import inside _load_module_from_path actually runs.
_saved = {
k: _sys.modules.pop(k, None)
for k in (
"plugins_registry", "plugins_registry.builtins",
"plugins_registry.protocol", "plugins_registry.raw_drop",
"_plugin_adaptor.test.fake_runtime",
)
}
try:
result = pr._load_module_from_path("_plugin_adaptor.test.fake_runtime", adapter_file)
assert result is not None, "module should load without ImportError"
assert hasattr(result, "Adaptor"), "AgentskillsAdaptor alias should be in namespace"
finally:
# Restore sys.modules state.
for k, v in _saved.items():
if v is None:
_sys.modules.pop(k, None)
else:
_sys.modules[k] = v