Files
molecule-ai-workspace-templ…/.gitea/workflows/publish-image.yml
T
hongming bdb0042fd1 ci(off-box): fetch AUTO_SYNC_TOKEN from Infisical SSOT (match hermes)
The ported publish workflow used ${{ secrets.AUTO_SYNC_TOKEN }} — the stale
org secret that 401s when fetching the SSOT chunked pusher from the private
operator-config repo, so the off-box publish failed at the pusher fetch (build
succeeded; only the push auth failed). Port hermes's proven step: fetch
AUTO_SYNC_TOKEN from Infisical /shared/dev-utils (prod) via the INFISICAL_CI_*
machine identity, and reference it as env.AUTO_SYNC_TOKEN in the publish step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 18:33:07 -07:00

467 lines
24 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: 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