Files
molecule-ai-workspace-templ…/.gitea/workflows/publish-image.yml
T
hongming b97f07a153
CI / Template validation (static) (pull_request) Successful in 11s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
CI / Adapter unit tests (pull_request) Successful in 10s
verify-providers-projection / Regenerate projection, fail on drift, assert registry ⊆ template (pull_request) Successful in 17s
CI / Template validation (runtime) (pull_request) Successful in 2m16s
CI / T4 tier-4 conformance (live) (pull_request) Successful in 2m13s
CI / validate (pull_request) Successful in 1s
chore(ci): consolidate publish to canonical MOL_PACKAGE_TOKEN (pypi-publisher, write:package)
2026-07-04 16:03:20 +00:00

376 lines
20 KiB
YAML

name: publish-image
# Builds the claude-code workspace template Dockerfile and pushes it to the Gitea registry (registry.moleculesai.app) as
# `<REGISTRY>/workspace-template-claude-code:latest` + `:sha-<7>`.
#
# Ported/inlined from molecule-ci's publish-template-image.yml reusable
# workflow. Cross-repo `uses:` is BLOCKED on Gitea 1.22.6 because
# DEFAULT_ACTIONS_URL=github causes the runner to attempt the lookup against
# github.com, which always 404s even for same-instance repos.
# (feedback_gitea_cross_repo_uses_blocked)
#
# Registry: production uses ECR (MOLECULE_IMAGE_REGISTRY env var on EC2 /
# Railway) backed by org-level AWS creds. The OSS default in registry.go is
# ghcr.io/molecule-ai but the ECR repo `molecule-ai/workspace-template-claude-code`
# already exists (created by the migration sweep). No GHCR token is in the
# credentials store — Gitea's GITHUB_TOKEN cannot authenticate to ghcr.io.
#
# Gitea 1.22.6 hostile-shape checklist applied:
# - No workflow_dispatch.inputs (silently rejected on 1.22.6)
# - No merge_group: trigger
# - No cross-repo uses:
# - GITHUB_SERVER_URL pinned at workflow level
# (feedback_act_runner_github_server_url)
# - No on.push.paths: (would permanently block path-excluded pushes)
# - timeout-minutes on every job
#
# Cascade signal: molecule-core/publish-runtime.yml fans out by git-pushing
# an updated `.runtime-version` file to this repo's main branch, which trips
# the `on: push: branches: [main]` trigger here. The resolve-version job reads
# that file and forwards the version as a RUNTIME_VERSION docker build-arg so
# pip install resolves the exact fresh version.
on:
push:
branches: [main]
workflow_dispatch:
env:
# Belt-and-suspenders for act_runner runners regenerated without the
# config.yaml envs block. (feedback_act_runner_github_server_url)
GITHUB_SERVER_URL: https://git.moleculesai.app
# CANONICAL content-addressed registry host (Cloudflare-fronted alias) -
# used for the promoted PIN ref and the image labels (IMAGE_NAME@<digest>).
IMAGE_NAME: registry.moleculesai.app/molecule-ai/workspace-template-claude-code
# CF-BYPASS local push endpoint. registry.moleculesai.app is Cloudflare-
# fronted with a ~100MB request-body cap, but buildx/docker uploads the
# 1.7-2.4GB template layers as a monolithic blob PUT -> Cloudflare 413
# Payload Too Large. Push straight to the local Gitea OCI endpoint instead:
# the runner mounts the host docker.sock, so docker login/push execute
# against the HOST docker daemon, where `localhost:3200` is the host's
# Gitea port-map (an insecure-OK localhost registry - no TLS / insecure-
# registries config required). It is the SAME Gitea backend that serves
# registry.moleculesai.app, so the pushed manifest is content-addressed
# and host-independent and the canonical-host pin resolves + pulls
# identically. Mirrors the Track-A Phase-1 host-side seed (blobs pushed
# direct to the local Gitea, not via the Cloudflare alias).
PUSH_HOST: localhost:3200
PUSH_IMAGE_NAME: localhost:3200/molecule-ai/workspace-template-claude-code
permissions:
contents: read
jobs:
resolve-version:
name: Resolve runtime version
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
version: ${{ steps.read.outputs.version }}
sha: ${{ steps.read.outputs.sha }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- id: read
shell: bash
run: |
if [ -f .runtime-version ]; then
v="$(head -n1 .runtime-version | tr -d '[:space:]')"
echo "version=${v}" >> "$GITHUB_OUTPUT"
echo "resolved runtime version from .runtime-version: ${v}"
else
echo "version=" >> "$GITHUB_OUTPUT"
echo "no .runtime-version file — will use Dockerfile/requirements.txt pin"
fi
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
publish:
name: Build & push workspace-template-claude-code image
# CF-bypass (Cloudflare 413): this job PUSHES the 1.7-2.4GB image to the
# Gitea OCI registry. registry.moleculesai.app is Cloudflare-fronted with a
# ~100MB request-body cap, so a monolithic blob PUT of the layers 413s. The
# push therefore has to run on a runner CO-LOCATED with the local Gitea,
# where `localhost:3200` is the host's registry port and docker login/push
# execute against the HOST docker daemon (this runner mounts the host
# /var/run/docker.sock; act_runner docker_host = that sock). The local
# `ci-meta` runner satisfies this. The dedicated remote `[publish, release]`
# runners CANNOT reach the host's localhost:3200 (verified: `docker login
# localhost:3200` -> dial tcp 127.0.0.1:3200: connection refused), so they
# must not run this job while the registry is served locally + CF-fronted.
runs-on: ci-meta
timeout-minutes: 30
needs: resolve-version
# Bubble the Push step's digest to the job level so the downstream
# `promote-pin` job can consume `needs.publish.outputs.digest`. Without
# this block the expression resolves to "" at runtime. Job-level outputs
# are the canonical Gitea/GHA mechanism for surfacing a step output across
# a `needs:` edge — `steps.push.outputs.digest` is only visible WITHIN
# this job's expression context. Mirrors the hermes sibling template.
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Lint — no bare imports of runtime modules
# Catches `from <runtime_module> import ...` style bare imports that work in
# the monorepo layout but explode at startup in the published container
# (a failed import). Runs before Docker login so a bad import
# returns red in seconds.
# Fallback module list mirrors the runtime build script's published
# top-level module list as of 2026-04-27.
shell: bash
run: |
set -eu
FALLBACK_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'
RUNTIME_MODULES=""
mkdir -p /tmp/runtime-wheel
if pip download --quiet molecule-ai-workspace-runtime --no-deps -d /tmp/runtime-wheel 2>/dev/null; then
WHEEL=$(ls /tmp/runtime-wheel/*.whl 2>/dev/null | head -1)
if [ -n "$WHEEL" ]; then
RUNTIME_MODULES=$(unzip -p "$WHEEL" molecule_runtime/_runtime_modules.json 2>/dev/null \
| python3 -c "import sys,json; m=json.load(sys.stdin); print('|'.join(sorted(set(m['top_level_modules']) | set(m['subpackages']))))" 2>/dev/null || echo "")
fi
fi
if [ -n "$RUNTIME_MODULES" ]; then
echo "::notice::lint module list from published wheel"
else
RUNTIME_MODULES="$FALLBACK_MODULES"
echo "::warning::could not read _runtime_modules.json from wheel — using inline fallback"
fi
if HITS=$(grep -nE "^\s*from (${RUNTIME_MODULES}) import" *.py 2>/dev/null); then
echo "::error::Bare imports of runtime modules found — use 'from molecule_runtime.<module> import'"
echo "$HITS" | sed 's/^/ /'
exit 1
fi
echo "::notice::no bare imports of runtime modules in *.py files"
- name: Log in to our container registry (Gitea OCI - CF-bypass local push endpoint)
# ECR retired (operator directive: use our own registry). Push to the
# Gitea-backed OCI registry at registry.moleculesai.app. The push
# credential is the Gitea package-publisher, fetched from the Infisical
# SSOT via the CI bootstrap trio (INFISICAL_CI_*) — never hardcoded.
# FORK SAFETY: this workflow has no pull_request trigger (push:[main] +
# workflow_dispatch only) so the trio is never exposed to a fork; the
# conditional is belt-and-suspenders (Gitea also withholds secrets from
# fork runners).
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }}
env:
INFISICAL_CI_CLIENT_ID: ${{ secrets.INFISICAL_CI_CLIENT_ID }}
INFISICAL_CI_CLIENT_SECRET: ${{ secrets.INFISICAL_CI_CLIENT_SECRET }}
INFISICAL_PROJECT_ID: ${{ secrets.INFISICAL_CI_PROJECT_ID }}
run: |
set -uo pipefail
BASE="https://key.moleculesai.app"
TOK=$(curl -fsS -X POST "$BASE/api/v1/auth/universal-auth/login" \
-H 'Content-Type: application/json' \
-d "{\"clientId\":\"$INFISICAL_CI_CLIENT_ID\",\"clientSecret\":\"$INFISICAL_CI_CLIENT_SECRET\"}" \
| python3 -c 'import sys,json; d=json.load(sys.stdin); v=d.get("accessToken"); sys.stdout.write(v if isinstance(v,str) and v else "")')
read_secret() {
curl -fsS "$BASE/api/v3/secrets/raw/$1?workspaceId=$INFISICAL_PROJECT_ID&environment=prod&secretPath=$2" \
-H "Authorization: Bearer $TOK" \
| python3 -c 'import sys,json; d=json.load(sys.stdin); v=(d.get("secret") or {}).get("secretValue"); sys.stdout.write(v if isinstance(v,str) and v else "")'
}
GITEA_USER="pypi-publisher" # canonical least-priv publisher (write:package)
GITEA_TOKEN=$(read_secret MOL_PACKAGE_TOKEN %2Fci%2Fgitea-pypi-publisher)
echo "::add-mask::$GITEA_TOKEN"
if [ -z "$GITEA_USER" ] || [ -z "$GITEA_TOKEN" ]; then
echo "::error::Infisical returned empty registry publisher creds at /ci/gitea-pypi-publisher"
exit 1
fi
echo "$GITEA_TOKEN" | docker login "$PUSH_HOST" -u "$GITEA_USER" --password-stdin \
|| { echo "::error::docker login to $PUSH_HOST failed"; exit 1; }
echo "::notice::logged in to $PUSH_HOST (CF-bypass local Gitea OCI) as $GITEA_USER"
- name: Verify Docker daemon access
run: |
set -euo pipefail
docker info >/dev/null 2>&1 || {
echo "::error::Docker daemon is not accessible — check runner sock mount"
exit 1
}
echo "Docker daemon OK"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build image (load for smoke test, do not push yet)
# Build into runner-local docker first. Smoke test runs before push so
# a broken adapter module never poisons :latest.
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
load: true
push: false
tags: ${{ env.PUSH_IMAGE_NAME }}:sha-${{ needs.resolve-version.outputs.sha }}
build-args: |
RUNTIME_VERSION=${{ needs.resolve-version.outputs.version }}
labels: |
org.opencontainers.image.source=https://git.moleculesai.app/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.description=Molecule AI workspace template — claude-code runtime
- name: Smoke test — import every top-level module
# Boot the locally-loaded image and import each top-level module to verify
# all module-level imports resolve against the pip-installed runtime.
shell: bash
env:
IMAGE: ${{ env.PUSH_IMAGE_NAME }}:sha-${{ needs.resolve-version.outputs.sha }}
run: |
set -eu
docker run --rm \
-e WORKSPACE_ID=smoke-test \
-e CLAUDE_CODE_OAUTH_TOKEN=sk-fake-smoke-token \
-e ANTHROPIC_API_KEY=sk-fake-smoke-key \
-e OPENAI_API_KEY=sk-fake-smoke-key \
--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 " import $mod OK"
done
'
echo "::notice::${IMAGE}: all top-level modules import cleanly"
- name: Push image to registry (CF-bypass local endpoint, post-smoke)
id: push
# Smoke passed. Push the locally-loaded image straight to the local Gitea
# OCI endpoint via the HOST docker daemon with a plain `docker push` (NOT
# buildx): the blob upload travels host->localhost:3200 and never crosses
# Cloudflare, so the 1.7-2.4GB layers no longer hit the ~100MB CF request-
# body cap (413 Payload Too Large). The manifest digest is content-
# addressed and host-independent, so promote-pin still pins the CANONICAL
# host ref (IMAGE_NAME@<digest>), which resolves + pulls via Cloudflare
# all the same. Image labels are already baked by the build-load step, so
# no rebuild happens here. `steps.push.outputs.digest` is preserved for
# the downstream promote-pin job.
shell: bash
run: |
set -euo pipefail
SHA_REF="${PUSH_IMAGE_NAME}:sha-${{ needs.resolve-version.outputs.sha }}"
LATEST_REF="${PUSH_IMAGE_NAME}:latest"
# Push the IMMUTABLE sha tag FIRST and derive the pin digest from it;
# only advance the floating :latest AFTER the sha publish + digest are
# confirmed. A partial publish (latest ok, sha/digest fails) must never
# leave :latest ahead of a promoted sha pin. (review #201/#141)
PUSHOUT="$(docker push "${SHA_REF}")"
printf '%s\n' "${PUSHOUT}"
# docker push stdout is not a stable API: prefer its digest line, but
# fall back to an inspect of the just-pushed immutable ref so a missing/
# reformatted line can't drop the digest (BuildKit treats localhost as
# insecure, so the HTTP inspect works). (review)
DIGEST="$(printf '%s\n' "${PUSHOUT}" | sed -n 's/.*digest: \(sha256:[0-9a-f]\{64\}\).*/\1/p' | head -1)"
if [ -z "${DIGEST}" ]; then
DIGEST="$(docker buildx imagetools inspect "${SHA_REF}" --format '{{json .Manifest.Digest}}' 2>/dev/null | tr -d '"[:space:]' || true)"
fi
if ! printf '%s' "${DIGEST}" | grep -Eq '^sha256:[0-9a-f]{64}$'; then
echo "::error::could not determine the pushed manifest digest for ${SHA_REF}"
exit 1
fi
docker tag "${SHA_REF}" "${LATEST_REF}"
docker push "${LATEST_REF}"
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
echo "::notice::pushed ${SHA_REF} (digest ${DIGEST}) then :latest to ${PUSH_HOST} (pins as ${IMAGE_NAME}@${DIGEST})"
promote-pin:
# RFC internal#529 Layer A — auto-promote the just-pushed digest to
# runtime_image_pins via the control-plane admin endpoint. Gitea 1.22.6
# has no `workflow_run`, so promotion has to be a job in this same
# workflow (cannot be a separate workflow listening on publish-image
# success). Gated on `if: success()` so a failed publish (smoke red,
# push red) never bumps any pin — promotion only ever runs AFTER a
# green build/push.
#
# Dual-environment fan-out (this PR): the staging CP `runtime_image_pins`
# is a SEPARATE SSOT from prod, so the canary `E2E Staging SaaS` boots
# whatever staging's pin points at. Before this change the claude-code
# template had NO promote job at all and hermes promoted ONLY to prod,
# so staging pins never advanced past the pre-#85/#75 executor and the
# canary failed reasoning models with "message contained no text
# content". We promote to staging (staging-api.moleculesai.app). The prod
# leg is currently de-required under the prod-freeze (see the matrix
# comment below).
#
# Per-target ISOLATION: `strategy.fail-fast: false` keeps each matrix
# leg independent and fail-LOUD on a non-2xx for ITS OWN target so a
# stranded pin is never hidden behind a green workflow.
name: Promote runtime_image_pins (CP admin)
runs-on: ubuntu-latest
timeout-minutes: 2
needs: [resolve-version, publish]
if: ${{ success() && github.ref == 'refs/heads/main' }}
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
# prod leg removed (prod-freeze). Prod cutover is pending-operator and
# the prod CP admin bearer is not provisioned to match any token these
# template repos hold, so POST /cp/admin/runtime-image/promote to
# api.moleculesai.app returns HTTP 401 by design (AdminGate constant-
# time bearer compare, internal/auth/middleware.go). Auto-promoting to
# prod during the freeze is exactly what the freeze prohibits, so this
# leg is de-required rather than papered over. Restore the prod include
# once prod cutover completes and a matching prod CP admin token is
# provisioned. Staging stays the live promote target.
- env_name: staging
cp_host: staging-api.moleculesai.app
steps:
- name: POST /cp/admin/runtime-image/promote (${{ matrix.env_name }})
env:
# Staging promote reads the ORG-level CP_STAGING_ADMIN_API_TOKEN —
# the same secret the google-adk / openclaw templates use and which
# matches the staging CP admin bearer (verified HTTP 200). The old
# per-repo CP_ADMIN_API_TOKEN_STAGING held a stale value that the
# staging AdminGate rejected with HTTP 401; converging onto the org
# secret is the fix. PROD_TOKEN is retained only so the prod leg can
# be restored verbatim once the prod-freeze lifts (see matrix above).
PROD_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
STAGING_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
ENV_NAME: ${{ matrix.env_name }}
CP_HOST: ${{ matrix.cp_host }}
TEMPLATE_NAME: claude-code
IMAGE_DIGEST: ${{ needs.publish.outputs.digest }}
GIT_SHA: ${{ needs.resolve-version.outputs.sha }}
run: |
set -euo pipefail
if [ "${ENV_NAME}" = "prod" ]; then
CP_ADMIN_API_TOKEN="${PROD_TOKEN}"
TOKEN_SECRET_NAME="CP_ADMIN_API_TOKEN"
else
CP_ADMIN_API_TOKEN="${STAGING_TOKEN}"
TOKEN_SECRET_NAME="CP_STAGING_ADMIN_API_TOKEN"
fi
if [ -z "${CP_ADMIN_API_TOKEN}" ]; then
echo "::error::${TOKEN_SECRET_NAME} secret not configured on this repo — cannot promote ${ENV_NAME} pin"
exit 1
fi
if [ -z "${IMAGE_DIGEST}" ]; then
echo "::error::needs.publish.outputs.digest is empty — Push step did not expose digest"
exit 1
fi
body=$(printf '{"template_name":"%s","image_digest":"%s","git_sha":"%s","notes":"auto-promote via publish-image.yml @ %s -> %s"}' \
"${TEMPLATE_NAME}" "${IMAGE_DIGEST}" "${GIT_SHA}" "${GITHUB_SHA}" "${ENV_NAME}")
echo "::notice::POST runtime-image/promote env=${ENV_NAME} host=${CP_HOST} template=${TEMPLATE_NAME} digest=${IMAGE_DIGEST} git_sha=${GIT_SHA}"
# Capture body + status separately; fail-loud on non-2xx so a
# silent 4xx never strands the pin behind a green workflow.
resp=$(mktemp)
code=$(curl -sS -o "${resp}" -w '%{http_code}' \
-X POST \
-H "Authorization: Bearer ${CP_ADMIN_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "${body}" \
"https://${CP_HOST}/cp/admin/runtime-image/promote")
echo "HTTP ${code}"
cat "${resp}"
echo
if [ "${code}" != "200" ] && [ "${code}" != "201" ]; then
echo "::error::${ENV_NAME} promote failed (HTTP ${code})"
exit 1
fi
echo "::notice::${ENV_NAME} runtime_image_pins.${TEMPLATE_NAME} bumped to ${IMAGE_DIGEST}"