CI / Template validation (static) (push) Skipped
CI / Template validation (runtime) (push) Skipped
CI / T4 tier-4 conformance (live) (push) Skipped
CI / validate (push) Skipped
CI / Adapter unit tests (push) Skipped
CI / Adapter conformance (ADR-004 socket) (push) Skipped
mcp-plugin-delivery-contract-drift / Compare MCP plugin delivery contract against SDK canonical (push) Skipped
meta-ci-advisory / meta (advisory) (push) Skipped
publish-image / Resolve runtime version (push) Skipped
publish-image / Classify change (decouple publish from promote) (push) Skipped
publish-image / Build & push workspace-template-claude-code image (push) Skipped
publish-image / Promote runtime_image_pins (CP admin) (staging-api.moleculesai.app, staging) (push) Skipped
publish-image / Verify runtime_image_pins moved (fail-loud) (staging-api.moleculesai.app, staging) (push) Skipped
Secret scan / Scan diff for credential-shaped strings (push) Skipped
sync-providers-yaml / Compare synced providers.yaml against controlplane canonical (push) Skipped
verify-providers-projection / Regenerate projection, fail on drift, assert registry ⊆ template (push) Skipped
mcp-plugin-delivery-contract-drift / Compare MCP plugin delivery contract against SDK canonical (pull_request) Skipped
sync-providers-yaml / Compare synced providers.yaml against controlplane canonical (pull_request) Skipped
CI / Template validation (static) (pull_request) Successful in 8s
CI / Adapter unit tests (pull_request) Successful in 10s
meta-ci-advisory / meta (advisory) (pull_request) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
verify-providers-projection / Regenerate projection, fail on drift, assert registry ⊆ template (pull_request) Successful in 18s
CI / Adapter conformance (ADR-004 socket) (pull_request) Successful in 33s
CI / Template validation (runtime) (pull_request) Successful in 2m10s
CI / T4 tier-4 conformance (live) (pull_request) Successful in 2m14s
CI / validate (pull_request) Successful in 0s
claude_sdk_executor._load_platform_mcp_required_tool() resolves contracts/mcp-plugin-delivery.contract.json RELATIVE TO ITSELF (/app/contracts/ in the image), but the Dockerfile never copied it. Every published image therefore logged a FileNotFoundError traceback at import and silently ran on the hard-coded fallback, so the bundled contract (and the SDK drift gate that keeps it in sync) never reached production. Seen on every publish-image run, e.g. 745012 and its 2026-10-08 rerun (job 1073363): Could not load platform MCP required tool from /app/contracts/mcp-plugin-delivery.contract.json; falling back to hard-coded default FileNotFoundError: [Errno 2] No such file or directory: '/app/contracts/mcp-plugin-delivery.contract.json' - Dockerfile: COPY the contract to /app/contracts/. - publish-image.yml: new smoke step that fails loudly if the file is missing from the built image, if the executor logged the fallback warning, or if its resolved required tool differs from the contract. - tests/test_extra_mcp_servers.py: pin the COPY line so a Dockerfile edit cannot drop the file again without failing PR CI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
513 lines
26 KiB
YAML
513 lines
26 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>`.
|
|
#
|
|
# The publish flow remains repository-local because its smoke gate and pin
|
|
# promotion are template-specific.
|
|
#
|
|
# Registry: images publish to the Gitea OCI backend at registry.moleculesai.app.
|
|
# The build runs OFF-BOX on robot-1's fleet and pushes the multi-GB layers
|
|
# THROUGH the Cloudflare tunnel via the SSOT parallel chunked pusher (uploads
|
|
# each blob in <=90MB chunks), which stays under Cloudflare's ~100MB request-body
|
|
# cap that 413s a plain `docker push`. (Was: ci-meta on the prod host pushing
|
|
# CF-bypass to localhost:3200; that host-coupling is now retired.)
|
|
#
|
|
# Workflow compatibility choices:
|
|
# - No workflow_dispatch.inputs
|
|
# - 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 build/smoke tag, the promoted PIN ref, and the image labels
|
|
# (IMAGE_NAME@<digest>). The 1.7-2.4GB image is published here THROUGH the
|
|
# Cloudflare tunnel by the SSOT parallel chunked pusher (see the publish job),
|
|
# which uploads each blob in <=90MB contiguous chunks so no request body hits
|
|
# the CF Free ~100MB cap (a plain `docker push` of the monolithic layers 413s).
|
|
# Uncapped GET pulls make this registry the artifact SSOT the runtime pin
|
|
# points at. (Was previously pushed CF-bypass to localhost:3200 on the prod
|
|
# host's ci-meta runner; that host-coupling is retired now the build is off-box.)
|
|
IMAGE_NAME: registry.moleculesai.app/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"
|
|
|
|
detect-image-change:
|
|
# Decouple publish from promote (task #261 build-off-box: split build from
|
|
# deploy). Classifies the triggering event so promote-pin + verify-pin move
|
|
# the STAGING runtime pin ONLY on a real image-content change or an explicit
|
|
# workflow_dispatch — NEVER as a side-effect of landing a workflow/docs-only
|
|
# change (e.g. this off-box runner-label edit). Logic lives in the shared,
|
|
# byte-identical .gitea/scripts/detect-image-change.sh (SSOT).
|
|
name: Classify change (decouple publish from promote)
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 2
|
|
outputs:
|
|
image_changed: ${{ steps.detect.outputs.image_changed }}
|
|
steps:
|
|
- name: Checkout (need the pushed range to diff)
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
with:
|
|
fetch-depth: 2
|
|
- id: detect
|
|
shell: bash
|
|
env:
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
BEFORE_SHA: ${{ github.event.before }}
|
|
AFTER_SHA: ${{ github.sha }}
|
|
run: |
|
|
chmod +x .gitea/scripts/detect-image-change.sh
|
|
.gitea/scripts/detect-image-change.sh
|
|
|
|
publish:
|
|
name: Build & push workspace-template-claude-code image
|
|
# OFF-BOX on robot-1's dedicated build fleet (was `ci-meta` on the prod
|
|
# host). `[docker-host, molecule-heavy]` = AND semantics, so it lands ONLY on a
|
|
# robot-1 fleet runner while still satisfying any docker-host-pinned lint.
|
|
# The 1.7-2.4GB image is built + `--load`ed into robot-1's daemon, smoke-
|
|
# tested, then published to registry.moleculesai.app via the SSOT parallel
|
|
# chunked pusher (molecule-ai/operator-config bin/registry-parallel-push.py)
|
|
# THROUGH the Cloudflare tunnel: each blob uploads in <=90MB contiguous
|
|
# chunks, so no PATCH hits the CF Free ~100MB request-body cap (a plain
|
|
# `docker push` of the monolithic layers 413s). Remote runners cannot reach
|
|
# the prod host's localhost:3200, so the registry (uncapped GET pulls) is the
|
|
# artifact SSOT that promote-pin points the runtime at.
|
|
runs-on: [docker-host, molecule-heavy]
|
|
timeout-minutes: 45
|
|
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
|
|
env:
|
|
RUNTIME_VERSION: ${{ needs.resolve-version.outputs.version }}
|
|
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=""
|
|
MOLECULE_RUNTIME_INDEX="https://git.moleculesai.app/api/packages/molecule-ai/pypi/simple/"
|
|
RUNTIME_REQUIREMENT="molecules-workspace-runtime"
|
|
if [ -n "$RUNTIME_VERSION" ]; then
|
|
RUNTIME_REQUIREMENT="molecules-workspace-runtime==${RUNTIME_VERSION}"
|
|
fi
|
|
RUNTIME_WHEEL_DIR=$(mktemp -d)
|
|
trap 'rm -rf "$RUNTIME_WHEEL_DIR"' EXIT
|
|
if python3 -m pip download --quiet --isolated --disable-pip-version-check \
|
|
--only-binary=:all: --no-deps \
|
|
--index-url "$MOLECULE_RUNTIME_INDEX" \
|
|
--dest "$RUNTIME_WHEEL_DIR" "$RUNTIME_REQUIREMENT" 2>/dev/null; then
|
|
WHEEL=$(find "$RUNTIME_WHEEL_DIR" -maxdepth 1 -type f \
|
|
-name 'molecules_workspace_runtime-*.whl' -print -quit)
|
|
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'"
|
|
while IFS= read -r hit; do
|
|
printf ' %s\n' "$hit"
|
|
done <<< "$HITS"
|
|
exit 1
|
|
fi
|
|
echo "::notice::no bare imports of runtime modules in *.py files"
|
|
|
|
- name: Prepare writable HOME + Docker config
|
|
# robot-1 fleet runners can have a host-owned, non-writable default HOME,
|
|
# so docker config/credential writes fail. Point HOME + DOCKER_CONFIG at
|
|
# the writable job temp dir (same fix as molecule-core's publish job).
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
H="$RUNNER_TEMP/wt-publish-home"
|
|
mkdir -p "$H/.docker"
|
|
echo "HOME=$H" >> "$GITHUB_ENV"
|
|
echo "DOCKER_CONFIG=$H/.docker" >> "$GITHUB_ENV"
|
|
|
|
- name: Fetch AUTO_SYNC_TOKEN from Infisical SSOT
|
|
# The scoped cross-repo read token used to fetch the SSOT chunked pusher
|
|
# from the PRIVATE molecule-ai/operator-config repo. Sourced from the
|
|
# Infisical SSOT (/shared/dev-utils::AUTO_SYNC_TOKEN, environment=prod)
|
|
# via the CI machine identity — the exact same source + step molecule-core's
|
|
# publish-workspace-server-image uses (NOT the stale org `AUTO_SYNC_TOKEN`
|
|
# secret, which 401s). FORK SAFETY: this workflow has no pull_request
|
|
# trigger, so the trio is never exposed to a fork; the conditional is
|
|
# belt-and-suspenders (Gitea also withholds secrets from fork runners).
|
|
id: auto_sync_token
|
|
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 "")'
|
|
}
|
|
AUTO_SYNC_TOKEN=$(read_secret AUTO_SYNC_TOKEN %2Fshared%2Fdev-utils)
|
|
echo "::add-mask::$AUTO_SYNC_TOKEN"
|
|
if [ -z "$AUTO_SYNC_TOKEN" ]; then
|
|
echo "::error::Infisical returned empty for AUTO_SYNC_TOKEN at /shared/dev-utils"
|
|
exit 1
|
|
fi
|
|
echo "AUTO_SYNC_TOKEN=$AUTO_SYNC_TOKEN" >> "$GITHUB_ENV"
|
|
echo "AUTO_SYNC_TOKEN loaded from Infisical (len=${#AUTO_SYNC_TOKEN})"
|
|
|
|
- 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.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.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: Smoke test — bundled MCP delivery contract reaches the executor
|
|
# The import smoke above passes even when claude_sdk_executor cannot
|
|
# find /app/contracts/mcp-plugin-delivery.contract.json: the loader
|
|
# catches the FileNotFoundError, logs a traceback and falls back to a
|
|
# hard-coded default, so the SSOT contract silently never reached
|
|
# production (every publish up to 2026-10-08 carried that traceback).
|
|
# Fail loudly here instead: the file must be bundled AND the executor
|
|
# must have resolved the required tool from it, not from the fallback.
|
|
shell: bash
|
|
env:
|
|
IMAGE: ${{ env.IMAGE_NAME }}:sha-${{ needs.resolve-version.outputs.sha }}
|
|
run: |
|
|
set -eu
|
|
docker run --rm -i \
|
|
-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 \
|
|
-w /app --entrypoint python3 "${IMAGE}" - <<'PY'
|
|
import io, json, logging, os, sys
|
|
|
|
CONTRACT = "/app/contracts/mcp-plugin-delivery.contract.json"
|
|
# Capture the executor's import-time logging so the fallback warning
|
|
# becomes a hard failure instead of noise in the job log.
|
|
buf = io.StringIO()
|
|
logging.basicConfig(stream=buf, level=logging.WARNING, force=True)
|
|
import claude_sdk_executor as executor # noqa: E402
|
|
|
|
if not os.path.isfile(CONTRACT):
|
|
print(f"::error::{CONTRACT} is not bundled in the image (Dockerfile COPY missing)")
|
|
sys.exit(1)
|
|
with open(CONTRACT, encoding="utf-8") as f:
|
|
want = json.load(f)["required_tool"]
|
|
captured = buf.getvalue()
|
|
if "falling back to hard-coded default" in captured:
|
|
print("::error::claude_sdk_executor fell back to its hard-coded required tool instead of the bundled contract")
|
|
print(captured)
|
|
sys.exit(1)
|
|
got = executor._PLATFORM_MCP_REQUIRED_TOOL
|
|
if got != want:
|
|
print(f"::error::executor required tool {got!r} != bundled contract required_tool {want!r}")
|
|
sys.exit(1)
|
|
print(f"bundled contract OK: {CONTRACT} required_tool={want}")
|
|
PY
|
|
echo "::notice::${IMAGE}: executor resolved the management-MCP required tool from the bundled contract"
|
|
|
|
- name: Publish image to registry (chunked pusher through CF tunnel)
|
|
id: push
|
|
# Smoke passed. Publish the locally-loaded 1.7-2.4GB image to
|
|
# registry.moleculesai.app THROUGH the Cloudflare tunnel using the SSOT
|
|
# parallel chunked pusher (molecule-ai/operator-config
|
|
# bin/registry-parallel-push.py, fetched at runtime — NOT vendored, so it
|
|
# stays the single source of truth). It uploads each blob in <=90MB
|
|
# contiguous PATCH chunks, so no request body hits the CF Free ~100MB cap
|
|
# that 413s a plain monolithic `docker push`. Then read back the
|
|
# content-addressed manifest digest the registry stored (GET manifest is
|
|
# uncapped through CF) and pin the CANONICAL ref (IMAGE_NAME@<digest>).
|
|
# `steps.push.outputs.digest` is preserved for the downstream promote-pin
|
|
# job. Pushes the IMMUTABLE :sha tag FIRST + confirms its digest, then
|
|
# advances the floating :latest, so a partial publish never leaves :latest
|
|
# ahead of a promoted sha pin. (review #201/#141)
|
|
shell: bash
|
|
env:
|
|
SHA: ${{ needs.resolve-version.outputs.sha }}
|
|
# Same registry credential SSOT molecule-core's publish uses (HTTP
|
|
# Basic for the pusher + the digest read-back). Org-level secret/var.
|
|
REG_USER: ${{ vars.MOLECULE_REGISTRY_USER || 'hongming' }}
|
|
REG_TOKEN: ${{ secrets.MOLECULE_REGISTRY_TOKEN }}
|
|
# Cross-repo read token to fetch the pusher from the private
|
|
# operator-config repo (org-level secret; same one molecule-core uses).
|
|
AUTO_SYNC_TOKEN: ${{ env.AUTO_SYNC_TOKEN }}
|
|
PUSHER_REF: ${{ vars.OPERATOR_CONFIG_PUSHER_REF || 'main' }}
|
|
run: |
|
|
set -euo pipefail
|
|
REG_HOST="${IMAGE_NAME%%/*}" # registry.moleculesai.app
|
|
IMAGE_REPO="${IMAGE_NAME#*/}" # molecule-ai/workspace-template-claude-code
|
|
SHA_TAG="sha-${SHA}"
|
|
LOCAL_IMAGE="${IMAGE_NAME}:${SHA_TAG}"
|
|
if [ -z "${REG_TOKEN:-}" ]; then
|
|
echo "::error::MOLECULE_REGISTRY_TOKEN (org secret) not set — cannot publish"; exit 1
|
|
fi
|
|
if [ -z "${AUTO_SYNC_TOKEN:-}" ]; then
|
|
echo "::error::AUTO_SYNC_TOKEN (org secret) not set — cannot fetch the SSOT chunked pusher"; exit 1
|
|
fi
|
|
echo "::add-mask::$REG_TOKEN"
|
|
export REG_USER REG_TOKEN
|
|
|
|
# Fetch the SSOT chunked pusher from operator-config (private repo).
|
|
PUSHER="$RUNNER_TEMP/registry-parallel-push.py"
|
|
url="${GITHUB_SERVER_URL}/api/v1/repos/molecule-ai/operator-config/raw/bin/registry-parallel-push.py?ref=${PUSHER_REF}"
|
|
curl -fsSL -H "Authorization: token ${AUTO_SYNC_TOKEN}" "$url" -o "$PUSHER"
|
|
test -s "$PUSHER" || { echo "::error::fetched pusher is empty from operator-config@${PUSHER_REF}"; exit 1; }
|
|
|
|
# 1) Immutable :sha tag first (verbatim molecule-core invocation).
|
|
echo "::group::chunked push ${REG_HOST}/${IMAGE_REPO}:${SHA_TAG}"
|
|
python3 "$PUSHER" --image "$LOCAL_IMAGE" --registry "$REG_HOST" \
|
|
--repo "$IMAGE_REPO" --tag "$SHA_TAG" --user "$REG_USER"
|
|
echo "::endgroup::"
|
|
|
|
# 2) Read the content-addressed manifest digest the registry stored
|
|
# (Docker-Content-Digest header; GET manifest through CF is uncapped).
|
|
DIGEST="$(curl -fsS -u "${REG_USER}:${REG_TOKEN}" \
|
|
-H 'Accept: application/vnd.oci.image.manifest.v1+json' \
|
|
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
|
|
-H 'Accept: application/vnd.oci.image.index.v1+json' \
|
|
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
|
|
-o /dev/null -D - \
|
|
"https://${REG_HOST}/v2/${IMAGE_REPO}/manifests/${SHA_TAG}" \
|
|
| tr -d '\r' | awk 'tolower($1)=="docker-content-digest:"{print $2}' | head -1)"
|
|
if ! printf '%s' "${DIGEST}" | grep -Eq '^sha256:[0-9a-f]{64}$'; then
|
|
echo "::error::could not read the pushed manifest digest for ${IMAGE_REPO}:${SHA_TAG}"
|
|
exit 1
|
|
fi
|
|
|
|
# 3) Advance floating :latest only after the sha publish + digest confirm.
|
|
docker tag "$LOCAL_IMAGE" "${IMAGE_NAME}:latest"
|
|
echo "::group::chunked push ${REG_HOST}/${IMAGE_REPO}:latest"
|
|
python3 "$PUSHER" --image "${IMAGE_NAME}:latest" --registry "$REG_HOST" \
|
|
--repo "$IMAGE_REPO" --tag "latest" --user "$REG_USER"
|
|
echo "::endgroup::"
|
|
|
|
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
|
|
echo "::notice::chunked-pushed ${REG_HOST}/${IMAGE_REPO}:${SHA_TAG} (digest ${DIGEST}) then :latest through the CF tunnel (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. Promotion stays
|
|
# in this workflow so it consumes the exact published digest across the
|
|
# `needs:` edge. 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.
|
|
#
|
|
# Target contract: staging `runtime_image_pins` is independent from
|
|
# production, and the staging canary boots the digest stored there. Template
|
|
# publication promotes only staging (staging-api.moleculesai.app).
|
|
# Production pin changes are separate, reviewed control-plane work and are
|
|
# intentionally outside both this job and the verification matrix 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, detect-image-change]
|
|
if: ${{ success() && github.ref == 'refs/heads/main' && needs.detect-image-change.outputs.image_changed == 'true' }}
|
|
permissions:
|
|
contents: read
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
# Template publication owns the staging target only. Production pin
|
|
# changes require a separate reviewed control-plane change.
|
|
- env_name: staging
|
|
cp_host: staging-api.moleculesai.app
|
|
steps:
|
|
- name: POST /cp/admin/runtime-image/promote (${{ matrix.env_name }})
|
|
env:
|
|
# The sole current matrix target authenticates with the org-level
|
|
# CP_STAGING_ADMIN_API_TOKEN. PROD_TOKEN remains declared for the
|
|
# generic environment selector below, but no production matrix entry
|
|
# invokes it.
|
|
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}"
|
|
|
|
verify-pin:
|
|
# Fail closed unless staging's runtime_image_pins row reads back as the
|
|
# exact digest emitted by this workflow's publish job. Production pin
|
|
# verification belongs to its separate reviewed control-plane change.
|
|
name: Verify runtime_image_pins moved (fail-loud)
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 2
|
|
needs: [resolve-version, publish, promote-pin, detect-image-change]
|
|
if: ${{ success() && github.ref == 'refs/heads/main' && needs.detect-image-change.outputs.image_changed == 'true' }}
|
|
permissions:
|
|
contents: read
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- env_name: staging
|
|
cp_host: staging-api.moleculesai.app
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
- name: Verify runtime_image_pins[ claude-code, ${{ matrix.env_name }} ]
|
|
env:
|
|
CP_HOST: ${{ matrix.cp_host }}
|
|
CP_ADMIN_API_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
|
TEMPLATE_NAME: claude-code
|
|
EXPECTED_DIGEST: ${{ needs.publish.outputs.digest }}
|
|
ENV_NAME: ${{ matrix.env_name }}
|
|
TOKEN_SECRET_NAME: CP_STAGING_ADMIN_API_TOKEN
|
|
run: bash .gitea/scripts/verify-runtime-pin.sh
|