ad8a76e339
CI / Template validation (static) (push) Successful in 28s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 16s
CI / Template validation (static) (pull_request) Successful in 25s
CI / Adapter-socket conformance (push) Successful in 1m10s
verify-providers-projection / Regenerate projection, fail on drift, assert registry ⊆ template (pull_request) Successful in 23s
CI / Shell unit tests (push) Successful in 19s
CI / Adapter-socket conformance (pull_request) Successful in 54s
CI / Shell unit tests (pull_request) Successful in 25s
CI / T4 tier-4 conformance (live) (pull_request) Successful in 4m26s
CI / T4 tier-4 conformance (live) (push) Successful in 5m28s
CI / Template validation (runtime) (pull_request) Successful in 6m9s
CI / Template validation (runtime) (push) Successful in 7m14s
CI / validate (push) Successful in 8s
CI / validate (pull_request) Successful in 9s
CI / all-required (push) Successful in 12s
CI / all-required (pull_request) Successful in 21s
585 lines
29 KiB
YAML
585 lines
29 KiB
YAML
name: publish-image
|
|
|
|
# Builds the hermes workspace template Dockerfile and pushes it to the Gitea registry (registry.moleculesai.app) as
|
|
# `<REGISTRY>/workspace-template-hermes:latest` + `:sha-<7>`.
|
|
#
|
|
# The publish flow remains repository-local because its co-located registry
|
|
# endpoint, smoke gate, and pin promotion are template-specific.
|
|
#
|
|
# Images publish to the Gitea OCI backend. The canonical pull host is
|
|
# registry.moleculesai.app; the co-located runner pushes through
|
|
# localhost:3200 to bypass Cloudflare's request-body limit for large layers.
|
|
#
|
|
# 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:
|
|
|
|
# The publish job uses shared host-Docker state and fixed local tags. Serialize
|
|
# every Hermes publish so candidate cleanup cannot race another run.
|
|
concurrency:
|
|
group: publish-image-hermes
|
|
cancel-in-progress: false
|
|
|
|
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-hermes
|
|
# 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-hermes
|
|
|
|
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-hermes 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
|
|
# task #393: 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 resolved to "" at runtime and the
|
|
# promote job failed with "needs.publish.outputs.digest is empty — Push
|
|
# step did not expose digest" even though docker/build-push-action@v7
|
|
# internally captured the digest on the `push` step (run #89759 after
|
|
# PR#43 merged at 6fdae22; the image published but the pin never bumped).
|
|
# Job-level outputs are the canonical Gitea/GHA mechanism for surfacing
|
|
# step outputs across `needs:` edges — `steps.push.outputs.digest` is
|
|
# only visible WITHIN this job's expression context.
|
|
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 plugins import ...` style bare imports that work in the
|
|
# monorepo layout but explode at startup in the published container
|
|
# (ModuleNotFoundError). Runs before Docker login so a bad adapter
|
|
# returns red in seconds.
|
|
# Fallback module list mirrors scripts/build_runtime_package.py:
|
|
# TOP_LEVEL_MODULES 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)
|
|
# 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: Reclaim stale runner-local Hermes candidates
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
mapfile -t stale_refs < <(
|
|
docker image ls \
|
|
--filter "reference=${PUSH_IMAGE_NAME}:sha-*" \
|
|
--format '{{.Repository}}:{{.Tag}}'
|
|
)
|
|
if docker image inspect "${PUSH_IMAGE_NAME}:latest" >/dev/null 2>&1; then
|
|
stale_refs+=("${PUSH_IMAGE_NAME}:latest")
|
|
fi
|
|
if [ "${#stale_refs[@]}" -gt 0 ]; then
|
|
printf 'Removing stale local candidate: %s\n' "${stale_refs[@]}"
|
|
docker image rm -f "${stale_refs[@]}"
|
|
fi
|
|
|
|
# An ambiguous promote/read-back result deliberately preserves its
|
|
# pending alias. Never stack another multi-GiB candidate on top of it:
|
|
# remove only aliases already identical to the live tag, otherwise
|
|
# fail closed for operator reconciliation.
|
|
mapfile -t pending_refs < <(
|
|
docker image ls \
|
|
--filter "reference=workspace-template-hermes:pending-*" \
|
|
--format '{{.Repository}}:{{.Tag}}'
|
|
)
|
|
if [ "${#pending_refs[@]}" -gt 0 ]; then
|
|
live_id="$(docker image inspect --format '{{.Id}}' workspace-template-hermes:latest 2>/dev/null || true)"
|
|
unresolved_pending_refs=()
|
|
for ref in "${pending_refs[@]}"; do
|
|
pending_id="$(docker image inspect --format '{{.Id}}' "${ref}" 2>/dev/null || true)"
|
|
if [ -n "${live_id}" ] && [ "${pending_id}" = "${live_id}" ]; then
|
|
echo "Removing finalized pending alias: ${ref}"
|
|
docker image rm "${ref}" >/dev/null
|
|
else
|
|
unresolved_pending_refs+=("${ref}")
|
|
fi
|
|
done
|
|
if [ "${#unresolved_pending_refs[@]}" -gt 0 ]; then
|
|
printf '::error::refusing publish while unresolved local candidate exists: %s\n' "${unresolved_pending_refs[@]}" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
# Do not globally prune images, volumes, containers, or BuildKit
|
|
# caches used by other jobs on this shared runner.
|
|
|
|
- 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.py 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 — hermes runtime
|
|
|
|
- name: Smoke test — import every /app/*.py
|
|
# Boot the locally-loaded image and import each *.py 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 /app/*.py modules import cleanly"
|
|
|
|
- name: Smoke test — capless child liveness
|
|
shell: bash
|
|
env:
|
|
IMAGE: ${{ env.PUSH_IMAGE_NAME }}:sha-${{ needs.resolve-version.outputs.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
probe_name="hermes-capless-${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
|
|
docker rm -f "${probe_name}" >/dev/null 2>&1 || true
|
|
docker run --rm \
|
|
--name "${probe_name}" \
|
|
--cap-drop ALL \
|
|
--cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER \
|
|
--cap-add SETGID --cap-add SETUID \
|
|
--security-opt no-new-privileges \
|
|
--pids-limit 512 \
|
|
--entrypoint /bin/bash "${IMAGE}" -c '
|
|
set -euo pipefail
|
|
. /app/scripts/process-liveness.sh
|
|
gosu agent timeout 2s sleep 30 &
|
|
child_pid=$!
|
|
sleep 0.2
|
|
process_is_running "$child_pid" || {
|
|
echo "uid-1000 child falsely reported dead without CAP_KILL" >&2
|
|
exit 1
|
|
}
|
|
set +e
|
|
wait "$child_pid"
|
|
child_rc=$?
|
|
set -e
|
|
[ "$child_rc" -eq 124 ] || exit 1
|
|
! process_is_running "$child_pid"
|
|
'
|
|
echo "::notice::${IMAGE}: child liveness works without CAP_KILL"
|
|
|
|
- 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"
|
|
LOCAL_CANDIDATE_REF="workspace-template-hermes:pending-${GITHUB_SHA}"
|
|
push_with_retry() {
|
|
local ref="$1" attempt output
|
|
for attempt in 1 2 3; do
|
|
if output="$(docker push "${ref}" 2>&1)"; then
|
|
printf '%s\n' "${output}"
|
|
return 0
|
|
fi
|
|
printf '%s\n' "${output}" >&2
|
|
if [ "${attempt}" -eq 3 ]; then
|
|
echo "::error::docker push ${ref} failed after ${attempt} attempts" >&2
|
|
return 1
|
|
fi
|
|
delay=$((attempt * 10))
|
|
echo "::warning::docker push ${ref} failed (attempt ${attempt}/3); retrying in ${delay}s" >&2
|
|
sleep "${delay}"
|
|
done
|
|
}
|
|
# 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 #141/#201)
|
|
PUSHOUT="$(push_with_retry "${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 #141)
|
|
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}"
|
|
push_with_retry "${LATEST_REF}"
|
|
if [ "${GITHUB_REF}" = "refs/heads/main" ]; then
|
|
# ci-meta and the local control plane share this host Docker daemon.
|
|
# Stage a unique local alias, but do not move the live runtime tag
|
|
# until promote-pin + verify-pin have both succeeded.
|
|
docker tag "${SHA_REF}" "${LOCAL_CANDIDATE_REF}"
|
|
CANDIDATE_IMAGE_META="$(docker image inspect --format '{{.Id}}|{{json .RepoDigests}}' "${LOCAL_CANDIDATE_REF}")"
|
|
if ! printf '%s\n' "${CANDIDATE_IMAGE_META}" | grep -Fq "@${DIGEST}"; then
|
|
echo "::error::${LOCAL_CANDIDATE_REF} does not record the pushed digest ${DIGEST}"
|
|
exit 1
|
|
fi
|
|
echo "::notice::staged ${LOCAL_CANDIDATE_REF} locally with verified digest ${DIGEST}"
|
|
else
|
|
echo "::notice::skipped local runtime staging for non-main ref ${GITHUB_REF}"
|
|
fi
|
|
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
|
|
echo "::notice::pushed ${SHA_REF} (digest ${DIGEST}) then :latest to ${PUSH_HOST} (pins as ${IMAGE_NAME}@${DIGEST})"
|
|
|
|
- name: Release runner-local candidate image
|
|
if: always()
|
|
shell: bash
|
|
run: |
|
|
docker image rm -f \
|
|
"${PUSH_IMAGE_NAME}:sha-${{ needs.resolve-version.outputs.sha }}" \
|
|
"${PUSH_IMAGE_NAME}:latest" >/dev/null 2>&1 || true
|
|
|
|
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 test
|
|
# 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]
|
|
if: ${{ success() && github.ref == 'refs/heads/main' }}
|
|
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: hermes
|
|
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:
|
|
# Keystone fail-loud gate (the durable guard the CTO asked for). After the
|
|
# promote, READ BACK runtime_image_pins via GET /cp/admin/runtime-image and
|
|
# ASSERT the (hermes, global) row's image_digest == the digest we just
|
|
# pushed. If they differ (promote silently skipped, wrong template, region
|
|
# skew, a 4xx that somehow didn't abort, etc.) this job goes RED. A green
|
|
# build that did not move the pin is exactly the failure this catches.
|
|
# The assertion logic lives in the shared, byte-identical
|
|
# .gitea/scripts/verify-runtime-pin.sh present in every runtime-template
|
|
# repo, so the guard covers hermes and every sibling template alike.
|
|
name: Verify runtime_image_pins moved (fail-loud)
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 2
|
|
needs: [resolve-version, publish, promote-pin]
|
|
if: ${{ success() && github.ref == 'refs/heads/main' }}
|
|
permissions:
|
|
contents: read
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
# Verification follows the same staging-only target contract as
|
|
# promote-pin. Production verification belongs to its separate,
|
|
# reviewed control-plane change.
|
|
- env_name: staging
|
|
cp_host: staging-api.moleculesai.app
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
- name: Assert pin == pushed digest (${{ matrix.env_name }})
|
|
env:
|
|
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: hermes
|
|
IMAGE_DIGEST: ${{ needs.publish.outputs.digest }}
|
|
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
|
|
chmod +x .gitea/scripts/verify-runtime-pin.sh
|
|
CP_HOST="${CP_HOST}" \
|
|
CP_ADMIN_API_TOKEN="${CP_ADMIN_API_TOKEN}" \
|
|
TOKEN_SECRET_NAME="${TOKEN_SECRET_NAME}" \
|
|
TEMPLATE_NAME="${TEMPLATE_NAME}" \
|
|
EXPECTED_DIGEST="${IMAGE_DIGEST}" \
|
|
ENV_NAME="${ENV_NAME}" \
|
|
.gitea/scripts/verify-runtime-pin.sh
|
|
|
|
retain-local-runtime:
|
|
name: Retain promoted runtime image on local Docker host
|
|
runs-on: ci-meta
|
|
timeout-minutes: 2
|
|
needs: [resolve-version, publish, promote-pin, verify-pin]
|
|
if: ${{ always() && github.ref == 'refs/heads/main' }}
|
|
permissions:
|
|
contents: read
|
|
steps:
|
|
- name: Finalize digest-verified local runtime tag
|
|
shell: bash
|
|
env:
|
|
PUBLISH_RESULT: ${{ needs.publish.result }}
|
|
PROMOTE_RESULT: ${{ needs.promote-pin.result }}
|
|
VERIFY_RESULT: ${{ needs.verify-pin.result }}
|
|
IMAGE_DIGEST: ${{ needs.publish.outputs.digest }}
|
|
run: |
|
|
set -euo pipefail
|
|
LOCAL_CANDIDATE_REF="workspace-template-hermes:pending-${GITHUB_SHA}"
|
|
LOCAL_RUNTIME_REF="workspace-template-hermes:latest"
|
|
|
|
if [ "${PUBLISH_RESULT}" != "success" ] || [ "${PROMOTE_RESULT}" != "success" ] || [ "${VERIFY_RESULT}" != "success" ]; then
|
|
echo "::notice::not moving ${LOCAL_RUNTIME_REF}; publish=${PUBLISH_RESULT} promote=${PROMOTE_RESULT} verify=${VERIFY_RESULT}"
|
|
echo "::warning::preserving ${LOCAL_CANDIDATE_REF}; pin outcome is ambiguous"
|
|
exit 0
|
|
fi
|
|
if ! printf '%s' "${IMAGE_DIGEST}" | grep -Eq '^sha256:[0-9a-f]{64}$'; then
|
|
echo "::error::invalid or empty promoted digest: ${IMAGE_DIGEST}"
|
|
exit 1
|
|
fi
|
|
|
|
CANDIDATE_IMAGE_META="$(docker image inspect --format '{{.Id}}|{{json .RepoDigests}}' "${LOCAL_CANDIDATE_REF}")"
|
|
if ! printf '%s\n' "${CANDIDATE_IMAGE_META}" | grep -Fq "@${IMAGE_DIGEST}"; then
|
|
echo "::error::${LOCAL_CANDIDATE_REF} does not record promoted digest ${IMAGE_DIGEST}"
|
|
exit 1
|
|
fi
|
|
docker tag "${LOCAL_CANDIDATE_REF}" "${LOCAL_RUNTIME_REF}"
|
|
LOCAL_IMAGE_META="$(docker image inspect --format '{{.Id}}|{{json .RepoDigests}}' "${LOCAL_RUNTIME_REF}")"
|
|
if ! printf '%s\n' "${LOCAL_IMAGE_META}" | grep -Fq "@${IMAGE_DIGEST}"; then
|
|
echo "::error::${LOCAL_RUNTIME_REF} does not record promoted digest ${IMAGE_DIGEST}"
|
|
exit 1
|
|
fi
|
|
echo "::notice::retained ${LOCAL_RUNTIME_REF} locally with verified digest ${IMAGE_DIGEST}"
|
|
docker image rm -f "${LOCAL_CANDIDATE_REF}" >/dev/null 2>&1 || true
|