From 0fc6e9bf134d28511e5906eb646b4dbe96677ce4 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Mon, 27 Apr 2026 05:32:42 -0700 Subject: [PATCH] feat(publish-template-image): bare-import lint + import-every-app-py smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new gates that would have prevented today's post-#87 template-extraction bug parade: 1. **Bare-import lint** — fail-fast pre-build check that grep's template *.py files for `from import` (where is in the closed list mirroring workspace/*.py basenames). When the runtime was bundled into workspace/, bare imports resolved against sibling files; in standalone template repos they explode at startup. Five separate templates shipped broken on 2026-04-27 because of this exact pattern (claude-code: plugins, executor_helpers, heartbeat, a2a_client, platform_auth; langgraph: agent, a2a_executor; deepagents: a2a_executor; gemini-cli: config, executor_helpers x2). The lint runs before docker login + buildx setup so a bad PR returns red in seconds. 2. **Import every /app/*.py at boot** (deeper smoke) — replaces `python -c "import adapter"` with a loop importing every Python module at /app/. The old single-import didn't traverse to sibling modules adapter.py imports lazily inside `create_executor()` (the executor.py family). That's why the hermes a2a-sdk migration bug and langgraph's bare a2a_executor import slipped through every prior gate even though the boot smoke "passed." Importing every module module-level forces all imports to resolve, including those in executor.py. Both gates use the closed-list pattern (deliberate, easy to update, no false-positives on legit third-party imports). The runtime module list mirrors the equivalent in scripts/build_runtime_package.py; both should be updated together when a new top-level workspace module ships. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/publish-template-image.yml | 73 ++++++++++++++++---- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish-template-image.yml b/.github/workflows/publish-template-image.yml index 572d15c..1a80269 100644 --- a/.github/workflows/publish-template-image.yml +++ b/.github/workflows/publish-template-image.yml @@ -90,6 +90,35 @@ jobs: echo "sha=${SHA}" >> "$GITHUB_OUTPUT" echo "::notice::Publishing runtime='${RUNTIME}' → ${IMAGE}:latest + :sha-${SHA}" + - name: Lint — no bare imports of runtime modules + # Templates that bare-import a workspace/ runtime module + # (e.g. `from plugins import load_plugins` instead of + # `from molecule_runtime.plugins import load_plugins`) work in + # the monorepo's bundled-runtime layout but explode at startup + # with `ModuleNotFoundError` once the runtime is installed as a + # package. This bit claude-code (5 imports), langgraph, + # deepagents, and gemini-cli on 2026-04-27 — each one a + # separate workspace-stuck-in-provisioning incident. + # The set of names mirrors workspace/*.py basenames at the time + # this lint was added; if a new runtime module ships, the + # build will fail loud with a clear message instead of + # silently shipping broken templates. Fail-fast: this runs + # before docker login + buildx setup so a bad PR returns red + # in seconds, not minutes. + shell: bash + run: | + set -eu + RUNTIME_MODULES='plugins|adapter_base|config|main|preflight|prompt|coordinator|consolidation|events|heartbeat|transcript_auth|runtime_wedge|watcher|skill_loader|policies|adapters|builtin_tools|executor_helpers|a2a_executor|a2a_client|a2a_tools|a2a_cli|a2a_mcp_server|agent|agents_md|initial_prompt|molecule_ai_status|platform_auth|shared_runtime' + # Match `from import` at start of line OR after any whitespace + # (function-scope imports inside if/try blocks count too). + if HITS=$(grep -nE "^\s*from (${RUNTIME_MODULES}) import" *.py 2>/dev/null); then + echo "::error::Bare imports of runtime modules found — must use \`from molecule_runtime. import\`" + echo "$HITS" | sed 's/^/ /' + echo "::error::Fix: prefix each match with 'molecule_runtime.' (e.g. 'from plugins' → 'from molecule_runtime.plugins')." + exit 1 + fi + echo "::notice::✓ no bare imports of runtime modules in template *.py files" + - name: Log in to GHCR uses: docker/login-action@v3 with: @@ -124,25 +153,41 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.description=Molecule AI workspace template — ${{ steps.tags.outputs.runtime }} runtime - - name: Smoke test — boot image and import adapter.py - # The real boot test. Runs `python -c "import adapter"` inside the - # image, which exercises: - # - adapter.py exists at /app/ - # - all `from molecule_runtime...` imports resolve against the - # pip-installed runtime version (catches the version skew - # class of bug — symbol added to runtime but PyPI not yet - # republished, or template pinned to old runtime, etc.) - # - no syntax errors in adapter.py - # We bypass the gosu/agent entrypoint with --entrypoint sh because - # we don't need workspace permissions for an import check. + - name: Smoke test — boot image and import every /app/*.py + # The real boot test. Imports every Python module at /app/ inside + # the image, which exercises: + # - adapter.py exists, no syntax errors, all module-level + # imports resolve against the pip-installed runtime version + # (catches version skew — symbol added to runtime but PyPI + # not yet republished, etc.) + # - executor.py / cli_executor.py / claude_sdk_executor.py / + # etc. — sibling modules adapter.py imports lazily inside + # create_executor(). Plain `import adapter` doesn't catch + # bugs there because they're behind `def create_executor`. + # This bit hermes (a2a-sdk migration) and langgraph + # (LangGraphA2AExecutor bare import) on 2026-04-27. + # - cross-cutting: any bare `from ` (the lint + # above catches these statically; this catches them at + # resolution time too, plus any imports of third-party + # packages that the lint can't reason about). + # We bypass the gosu/agent entrypoint with --entrypoint sh + # because import smoke doesn't need workspace permissions. shell: bash env: IMAGE: ${{ steps.tags.outputs.image }}:sha-${{ steps.tags.outputs.sha }} run: | set -eu - docker run --rm --entrypoint sh "${IMAGE}" -c \ - "cd /app && python3 -c 'import adapter; print(\"adapter imports cleanly:\", adapter.__name__)'" - echo "::notice::✓ ${IMAGE} adapter.py imports cleanly against installed runtime" + docker run --rm --entrypoint sh "${IMAGE}" -c ' + set -e + cd /app + for f in *.py; do + [ "$f" = "__init__.py" ] && continue + mod="${f%.py}" + python3 -c "import $mod" || { echo "::error::failed to import $mod"; exit 1; } + echo " ✓ $mod" + done + ' + echo "::notice::✓ ${IMAGE} all /app/*.py modules import cleanly against installed runtime" - name: Push image to GHCR (post-smoke) # Now that the smoke test passed, push both tags. build-push-action