Block internal-flavored paths / Block forbidden paths (push) Skipped
Block integration-tester contamination artifacts / Block staging-trigger / invalid manifest contamination (push) Skipped
boot-to-registration-e2e / Minimal cell (SSOT runtime + molecules-server) (push) Skipped
ci-required-drift / drift (push) Skipped
CI / Detect changes (push) Skipped
CI / Canvas (Next.js) (push) Skipped
CI / Platform (Go) (push) Skipped
CI / Python Lint & Test (push) Skipped
CI / all-required (push) Skipped
Concierge Creates Workspace Hermetic / Concierge Creates Workspace Hermetic (push) Skipped
contract-ssot-sync / Verify vendored plugin-manifest schema matches the molecule-ai-sdk SSOT (push) Skipped
cron-fixtures-ssot-drift / Verify vendored cron fixtures are byte-identical to the molecule-ai-sdk SSOT (push) Skipped
design-token-drift / Canvas ↔ app design-token SSOT drift (push) Skipped
E2E API Smoke Test / detect-changes (push) Skipped
E2E API Smoke Test / E2E API Smoke Test (push) Skipped
E2E Chat / E2E Chat (push) Skipped
E2E Ephemeral CP Happy Path / detect (push) Skipped
E2E Ephemeral CP Happy Path / E2E Ephemeral CP Happy Path (push) Skipped
E2E External Connect-Snippet + Leak-Lint / E2E External Connect-Snippet + Leak-Lint (push) Skipped
E2E Workspace Lifecycle (staginge2e) / E2E Workspace Lifecycle (compile+skip) (push) Skipped
Handlers Postgres Integration / detect-changes (push) Skipped
Harness Replays / Harness Replays (push) Skipped
lint-continue-on-error-tracking / lint-continue-on-error-tracking (push) Skipped
CI / Shellcheck (E2E scripts) (push) Skipped
CI / Canvas Deploy Status (push) Skipped
lint-no-coe-on-required / lint-no-coe-on-required (push) Skipped
lint-phantom-gate / lint-phantom-gate (push) Skipped
Lint publish-runner timeout-minutes / Lint publish-runner timeout-minutes (push) Skipped
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (push) Skipped
Handlers Postgres Integration / Handlers Postgres Integration (push) Skipped
Harness Replays / detect-changes (push) Skipped
lint-bp-context-emit-match / lint-bp-context-emit-match (push) Skipped
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Skipped
lint-env-coupling-dismissal / lint-env-coupling-dismissal (push) Skipped
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Skipped
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (push) Skipped
Lint forbidden hand-written mcp__ tool-id literals / Scan for hand-written mcp__ tool-id literals (push) Skipped
Check migration collisions / Migration version collision check (pull_request) Skipped
cron-fixtures-ssot-drift / Verify vendored cron fixtures are byte-identical to the molecule-ai-sdk SSOT (pull_request) Skipped
E2E Ephemeral CP Happy Path / detect (pull_request) Successful in 9s
manifest-entry-existence-check / Verify manifest entries exist on Gitea (pull_request) Skipped
SECRET_PATTERNS drift lint / Detect SECRET_PATTERNS drift (pull_request) Skipped
sync-providers-yaml / Verify SDK registry dependency (pull_request) Skipped
Lint schedule budget / Zero-cron ratchet (push) Skipped
lint-setup-go-cache / lint-setup-go-cache (push) Skipped
lint-staging-tenant-cd-gate-chain / lint-staging-tenant-cd-gate-chain (push) Skipped
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (push) Skipped
mcp-plugin-delivery-contract-drift / Compare MCP plugin delivery contract against template and runtime canonicals (push) Skipped
publish-desktop-image / Build & push desktop image (push) Skipped
sdk-pin-drift / SDK consumer pin vs molecule-ai-sdk main (push) Skipped
Secret scan / Scan diff for credential-shaped strings (push) Skipped
staging-tenant-cd / redeploy-fleet (roll staging tenants, session-preserving) (push) Skipped
staging-tenant-cd / rollback-audit (prove rollback-pin itself actually executed) (push) Skipped
sync-providers-yaml / Verify SDK registry dependency (push) Skipped
template-delivery-e2e / detect-changes (push) Skipped
Lint workflow YAML (repository compatibility policy) / Lint workflow YAML for repository compatibility policy (push) Skipped
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (push) Skipped
main-canary / canary (push) Skipped
mcp-verb-published-manifest / Published mcp-server manifest ⊇ contract required verbs (push) Skipped
merge-commit-guard / Verify merged PR commits are on main (push) Skipped
nodup-lint / no-duplication (push) Skipped
pin-provenance-guard / Pin provenance (push) Skipped
provision-request-ssot-sync / Verify core's provision-request mirror matches the molecule-ai-sdk SSOT (push) Skipped
publish-canvas-image / Build & push canvas image (push) Skipped
publish-desktop-image / Promote desktop :latest to CI-green build (push) Skipped
publish-workspace-server-image / Build & push tenant image (push) Skipped
publish-workspace-server-image / Promote tenant :latest to CI-green build (push) Skipped
redeploy-tenants-on-main / redeploy (push) Skipped
redeploy-tenants-on-main / redeploy-k8s (push) Skipped
runtime-default-flip-gate / flip-gate (push) Skipped
sdk-route-milestone-contract-drift / SDK route + milestone derive-gates (push) Skipped
SECRET_PATTERNS drift lint / Detect SECRET_PATTERNS drift (push) Skipped
Self-Host Concierge Schedules E2E / Self-Host Concierge Schedules E2E (stub) (push) Skipped
staging-tenant-cd / await-image (staging-<sha> published) (push) Skipped
staging-tenant-cd / advance-pin (staging tenant image DB pin) (push) Skipped
staging-tenant-cd / runtime-image-readiness (exact promoted digests on provisioner daemon) (push) Skipped
staging-tenant-cd / e2e-smoke (staging real provision + A2A HARD GATE) (push) Skipped
staging-tenant-cd / rollback-pin (revert pin if the fleet roll failed) (push) Skipped
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Skipped
template-delivery-e2e / Template-asset delivery (fresh seo-agent — config+prompts via asset channel, seo-all via plugin reconcile) (push) Skipped
Ops Scripts Tests / Ops scripts (unittest) (push) Skipped
verify-providers-gen / Regenerate providers artifact and fail on drift (push) Skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
Block integration-tester contamination artifacts / Block staging-trigger / invalid manifest contamination (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 6s
boot-to-registration-e2e / Minimal cell (SSOT runtime + molecules-server) (pull_request) Skipped
contract-ssot-sync / Verify vendored plugin-manifest schema matches the molecule-ai-sdk SSOT (pull_request) Skipped
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
design-token-drift / Canvas ↔ app design-token SSOT drift (pull_request) Skipped
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Skipped
mcp-plugin-delivery-contract-drift / Compare MCP plugin delivery contract against template and runtime canonicals (pull_request) Skipped
mcp-verb-published-manifest / Published mcp-server manifest ⊇ contract required verbs (pull_request) Skipped
provision-request-ssot-sync / Verify core's provision-request mirror matches the molecule-ai-sdk SSOT (pull_request) Skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
verify-providers-gen / Regenerate providers artifact and fail on drift (pull_request) Skipped
Harness Replays / detect-changes (pull_request) Successful in 6s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 6s
ci-required-drift / drift (pull_request) Successful in 20s
E2E Workspace Lifecycle (staginge2e) / E2E Workspace Lifecycle (compile+skip) (pull_request) Successful in 22s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 10s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 9s
lint-bp-context-emit-match / lint-bp-context-emit-match (pull_request) Successful in 22s
Concierge Creates Workspace Hermetic / Concierge Creates Workspace Hermetic (pull_request) Successful in 29s
Lint forbidden hand-written mcp__ tool-id literals / Scan for hand-written mcp__ tool-id literals (pull_request) Successful in 5s
lint-env-coupling-dismissal / lint-env-coupling-dismissal (pull_request) Successful in 22s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 25s
E2E External Connect-Snippet + Leak-Lint / E2E External Connect-Snippet + Leak-Lint (pull_request) Successful in 34s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 6s
lint-no-coe-on-required / lint-no-coe-on-required (pull_request) Successful in 19s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 19s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 16s
Lint publish-runner timeout-minutes / Lint publish-runner timeout-minutes (pull_request) Successful in 21s
lint-phantom-gate / lint-phantom-gate (pull_request) Successful in 29s
Lint schedule budget / Zero-cron ratchet (pull_request) Successful in 21s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 29s
lint-setup-go-cache / lint-setup-go-cache (pull_request) Successful in 20s
lint-staging-tenant-cd-gate-chain / lint-staging-tenant-cd-gate-chain (pull_request) Successful in 14s
nodup-lint / no-duplication (pull_request) Successful in 25s
sdk-route-milestone-contract-drift / SDK route + milestone derive-gates (pull_request) Successful in 18s
sdk-pin-drift / SDK consumer pin vs molecule-ai-sdk main (pull_request) Successful in 22s
lint-context-set-shrink / lint-context-set-shrink (pull_request) Successful in 1m17s
Secret scan / Scan diff for credential-shaped strings (pull_request) No credential-shaped strings (deterministic re-assert)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
pin-provenance-guard / Pin provenance (pull_request) Successful in 30s
template-delivery-e2e / detect-changes (pull_request) Successful in 6s
Lint workflow YAML (repository compatibility policy) / Lint workflow YAML for repository compatibility policy (pull_request) Successful in 36s
Self-Host Concierge Schedules E2E / Self-Host Concierge Schedules E2E (stub) (pull_request) Successful in 37s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (stub) (pull_request) Successful in 1m13s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m47s
Local Provision Lifecycle E2E / Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory) (pull_request) Successful in 1m7s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 2m8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m46s
Harness Replays / Harness Replays (pull_request) Successful in 3m10s
template-delivery-e2e / Template-asset delivery (fresh seo-agent — config+prompts via asset channel, seo-all via plugin reconcile) (pull_request) Successful in 2m45s
CI / Canvas (Next.js) (pull_request) Successful in 6m13s
E2E Chat / E2E Chat (pull_request) Successful in 2m12s
CI / Canvas Deploy Status (pull_request) Successful in 2s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6m25s
CI / Platform (Go) (pull_request) Successful in 8m25s
CI / all-required (pull_request) Successful in 3s
E2E Ephemeral CP Happy Path / E2E Ephemeral CP Happy Path (pull_request) Successful in 25m54s
gitea-merge-queue / queue (pull_request_review) Successful in 30s
audit-force-merge / audit (pull_request_target) Successful in 8s
Brings #5206 up to date with maine6a8e008(60 commits behind). One conflict, in markProvisionFailed (workspace_provision_shared.go): main rewrote it (82f9d328/a7ed7f65) to write the failed verdict FIRST on a detached, reserved-budget context and broadcast second. Resolved by taking main's function as-is and carrying over this branch's only change there: the `AND status != 'removed'` guard on the failed-status UPDATE (RC09: a provision that fails after a DELETE marked the row must not flip the removed workspace back to 'failed'). SQL text and argument order are unchanged from this branch, so TestMarkProvisionFailed_NeverResurrectsARemovedWorkspace still pins it; main's own tests match that UPDATE by the prefix `UPDATE workspaces SET status =` and are unaffected. Everything else merged cleanly; main's changes to provisioner.go (comments plus brand-prefix parsing in the list methods), workspace_provision.go (CP mode only) and the two e2e workflows do not touch the lines this branch changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1116 lines
68 KiB
YAML
1116 lines
68 KiB
YAML
name: Local Provision Lifecycle E2E
|
|
|
|
# MANDATORY coverage for the LOCAL Docker provisioner (MOLECULE_ENV=development,
|
|
# docker.sock) — the path self-hosters + dev runs use. Every OTHER e2e exercises
|
|
# the SaaS/EC2 (control-plane) provisioner; nothing mandatory drove the local
|
|
# Docker path, which is why a config-volume restart-survival bug went undetected.
|
|
# This workflow provisions a REAL workspace via the local Docker provisioner and
|
|
# asserts the full lifecycle, INCLUDING the restart-survival assertion.
|
|
#
|
|
# Two jobs:
|
|
# * lifecycle-stub (REQUIRED gate) — builds the tiny stub runtime image, tags
|
|
# it to the provisioner's RegistryModeLocal cache tag, and runs the full
|
|
# lifecycle e2e (provision -> online -> restart-survive -> proxy-reach). Fast
|
|
# (seconds of agent boot, no LLM, no 2.5GB image).
|
|
# * lifecycle-real (ADVISORY, continue-on-error) — runs the SAME script against
|
|
# the real claude-code template image with a REAL MiniMax BYOK credential
|
|
# (LIFECYCLE_LLM=minimax). The proxy-reach step asserts an ACTUAL model reply
|
|
# (real round-trip through the ws-<id>:8000 proxy), not just reachability.
|
|
# MiniMax is the cheapest LLM the platform offers, and its `minimax` provider
|
|
# dials api.minimax.io directly (no CP proxy needed on this local stack).
|
|
# Heavy + network-dependent (pulls/builds the template + a real LLM call), so
|
|
# it is non-blocking. Needs the MOLECULE_STAGING_MINIMAX_API_KEY CI secret:
|
|
# when ABSENT the script SKIPS loud (exit 0) — it never reds on a missing
|
|
# secret (serving-e2e skip-if-absent pattern).
|
|
#
|
|
# SUBSTRATE REQUIREMENT (read before wiring into branch protection)
|
|
# -----------------------------------------------------------------
|
|
# This workflow provisions SIBLING docker containers from a HOST Go binary via
|
|
# the runner's docker.sock — exactly like e2e-api.yml, which already provisions
|
|
# the `mock` + `priority-runtimes` arms on `docker-host`. So the docker-in-runner
|
|
# capability IS available on the molecule-runner-* (docker-host) lane. If the
|
|
# operator ever moves these to a runner WITHOUT docker.sock access for the
|
|
# platform binary, this lane will red — keep it on `docker-host`.
|
|
#
|
|
# Both jobs pin `runs-on: docker-host` (self-hosted Linux runners with the
|
|
# molecule-core-net bridge + a working docker.sock). The bare `ubuntu-latest`
|
|
# label is also advertised by the Windows act_runner, where docker.sock-bound
|
|
# steps fail non-deterministically — see lint-required-workflows-docker-host-
|
|
# pinned.yml + internal#512.
|
|
|
|
on:
|
|
push:
|
|
branches: [main, staging]
|
|
pull_request:
|
|
branches: [main, staging]
|
|
|
|
concurrency:
|
|
# Per-SHA grouping (mirrors e2e-api.yml). cancel-in-progress:false so a queued
|
|
# run for an older SHA isn't cancelled by a newer push (auto-promote brittleness).
|
|
group: local-provision-e2e-${{ github.event.pull_request.head.sha || github.sha }}
|
|
cancel-in-progress: false
|
|
|
|
env:
|
|
GITHUB_SERVER_URL: https://git.moleculesai.app
|
|
# #2520: GCP-class runners (e2-standard-8, shared Intel) boot the platform
|
|
# ~33% slower than AMD/dedicated runners. Keep the default generous so a
|
|
# slow provisioner/image-pull does not false-positive as a platform failure.
|
|
PLATFORM_BOOT_DEADLINE: 480
|
|
|
|
jobs:
|
|
# ===========================================================================
|
|
# Gating merge check — stub runtime, fast; the only mandatory coverage for
|
|
# the LOCAL Docker provisioner.
|
|
#
|
|
# CORRECTED 2026-08-10. This block previously claimed enforcement on "two
|
|
# layers" and told the reader "do not read the `pending` directive below as
|
|
# 'not enforced'". Layer 1 of that claim was FALSE, and the reassuring
|
|
# wording is what made it durable. What actually holds:
|
|
#
|
|
# 1. NOT presence-required. This context sits BELOW the `# pending-#2409`
|
|
# marker in .gitea/required-contexts.txt, in the documented-but-NOT-
|
|
# enforced half, so the merge queue does not require it to post. It has
|
|
# been there since the entry was introduced (08f75b5e6, 2026-06-28) —
|
|
# original parking, not drift.
|
|
# 2. TRUE: branch_protections/main uses status_check_contexts=['*'], so
|
|
# every POSTED context must be success (#4363 '*' semantics). This job
|
|
# is continue-on-error: false, so a red here does block.
|
|
#
|
|
# The gap between 1 and 2 is not academic: ['*'] constrains statuses that
|
|
# EXIST. On 2026-08-10 a duplicate mapping key in this file made Gitea
|
|
# refuse to schedule the whole workflow; both contexts stopped posting and
|
|
# the PR read 43/43 success with all 8 enforced contexts present. Nothing
|
|
# required this context to exist, so nothing noticed it stop. Rule 11 in
|
|
# lint-workflow-yaml.py now catches that specific cause.
|
|
#
|
|
# Promotion above the marker is deliberately NOT taken here. The stated
|
|
# precondition is an OPERATOR confirmation that docker-host runners reliably
|
|
# provision sibling containers (mc#2409, still open), and the measured
|
|
# record does not discharge it: over 2026-08-01..10, 276 runs of this lane,
|
|
# 274 green and 2 genuine failures, both the same signature —
|
|
# `FAIL: proxy reached stub (canned reply)` with the platform logging
|
|
# `workspace agent restarting — retry after a short backoff
|
|
# [workspace_settling]`. That is a ~0.7% substrate/timing race at the A2A
|
|
# proxy-reach step, i.e. exactly the reliability question mc#2409 parks on.
|
|
# Evidence posted to mc#2409; root-fix the settling race, then promote.
|
|
# bp-required: pending #2409
|
|
# ===========================================================================
|
|
lifecycle-stub:
|
|
name: Local Provision Lifecycle E2E (stub)
|
|
runs-on: docker-host
|
|
continue-on-error: false
|
|
timeout-minutes: 15
|
|
env:
|
|
PG_CONTAINER: pg-lpe2e-${{ github.run_id }}-${{ github.run_attempt }}
|
|
REDIS_CONTAINER: redis-lpe2e-${{ github.run_id }}-${{ github.run_attempt }}
|
|
# Hard-code dev mode at the job level so the platform server ALWAYS sees it,
|
|
# even if the runner's $GITHUB_ENV propagation is flaky (#2468 RCA).
|
|
MOLECULE_ENV: development
|
|
SECRETS_ENCRYPTION_KEY: lpe2e-test-encryption-key-32bytes!!
|
|
# act_runner runs the job inside a Docker container, so /.dockerenv exists
|
|
# and the platform auto-detects platformInDocker=true. But the job container
|
|
# is NOT on molecule-core-net, so it cannot resolve workspace container
|
|
# hostnames (ws-<id>:8000). Force false so the proxy keeps using the
|
|
# host-mapped 127.0.0.1:<ephemeral_port> URL, which IS reachable.
|
|
MOLECULE_IN_DOCKER: false
|
|
# The platform this job starts provisions ws-<id> containers on the shared
|
|
# host daemon. Production keeps them unless-stopped; here one that outlives
|
|
# the run must exit once and stay collectable, not crash-loop on the runner
|
|
# host (RC09).
|
|
MOLECULE_WORKSPACE_RESTART_POLICY: "no"
|
|
# core#5031: this job tags a STUB over
|
|
# molecule-local/workspace-template-claude-code:<template-HEAD-sha>-<arch>
|
|
# (+ :latest) — a name derived entirely from the world, so every process on
|
|
# this shared docker.sock computes it identically. The self-host schedules
|
|
# e2e does the same, and on core#5030 its cleanup restored the name while
|
|
# THIS job was still using it: the required lifecycle gate failed asserting
|
|
# on LLM credentials, which is nowhere near the cause.
|
|
#
|
|
# This token gives the job a PRIVATE tag namespace. JOB level, not step
|
|
# level: the platform-server started below RESOLVES the tag
|
|
# (provisioner/local_image_isolation.go) and the e2e script WRITES it, so
|
|
# both halves must read one variable or they drift.
|
|
#
|
|
# Only the STUB jobs get a token. lifecycle-real builds the genuine
|
|
# template for the same sha, so it writes content-identical bytes under the
|
|
# shared name — isolating it would force a cold 2.5GB rebuild every run to
|
|
# fix a race that job does not have.
|
|
MOLECULE_LOCAL_IMAGE_ISOLATION: e2e-${{ github.run_id }}-${{ github.run_attempt }}-lifestub
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
|
with:
|
|
go-version: 'stable'
|
|
# cache:false — the self-hosted runner bind-mounts a persistent
|
|
# GOCACHE/GOMODCACHE (/var/cache/ci-go-{build,mod}); actions/cache is
|
|
# redundant and corrupts it by untarring over the bind mount ("File
|
|
# exists" -> "Failed to restore" -> partial cache -> linker/typecheck
|
|
# errors on heavy jobs, e.g. test -race link "too many errors" and
|
|
# go-arch-lint "without types"). Fleet sweep after the cp ci.yml find.
|
|
cache: false
|
|
cache-dependency-path: workspace-server/go.sum
|
|
|
|
- name: Init owned-workspace manifest (run-scoped teardown identity)
|
|
run: |
|
|
# #2883: this job provisions ws-<uuid> workspace containers via the platform
|
|
# binary's docker.sock. The e2e script's own EXIT trap deletes the workspace it
|
|
# created, but a CANCELLED / TIMED-OUT job (act_runner SIGKILLs the job
|
|
# container, so the bash trap never fires) or a platform crash mid-provision
|
|
# leaks the ws-<uuid> container — it then runs forever and pegs CPU on the
|
|
# shared docker-host runner (13 orphans found on ded-1, 11+3 on the prod
|
|
# robots).
|
|
#
|
|
# #4346: this used to be a BASELINE — snapshot the ws-* containers that exist
|
|
# before the job starts, and have the teardown delete anything NOT in that
|
|
# snapshot. That is inference by exclusion, and it is WRONG on a shared daemon:
|
|
# "not in my baseline" is not the same as "mine". The concurrency group is
|
|
# per-SHA, so a DIFFERENT PR's run executes concurrently on this same docker
|
|
# host, and any container it creates AFTER our snapshot is — by construction —
|
|
# absent from it. Our teardown then found that run's LIVE workspace, called it
|
|
# our own leak, and `docker rm -f`'d it mid-test. The victim saw its container
|
|
# simply cease to exist, with nothing wrong anywhere in its provisioning path
|
|
# (last_sample_error=<none>, container_running=<none>): indistinguishable from a
|
|
# platform bug, and it reads exactly like a flake.
|
|
#
|
|
# So we record POSITIVE identity instead. The e2e script knows the id the
|
|
# moment POST /workspaces returns it, and the container is named ws-<id>; it
|
|
# appends that id here (E2E_WS_MANIFEST). The teardown removes those and
|
|
# nothing else, which is correct no matter what any concurrent run does or when
|
|
# it started.
|
|
: > /tmp/ws_owned_${{ github.run_id }}_${{ github.run_attempt }}.txt
|
|
echo "owned-workspace manifest initialised (empty)."
|
|
|
|
- name: Build e2e-names SSOT CLI
|
|
working-directory: workspace-server
|
|
run: go build -o /usr/local/bin/e2e-names ./cmd/e2e-names
|
|
|
|
- name: Ensure provisioner network + pre-pull alpine
|
|
run: |
|
|
# The local provisioner attaches workspace containers to
|
|
# molecule-core-net and seeds /configs via an alpine helper; the
|
|
# lifecycle script also uses alpine to seed config.yaml into the
|
|
# named config volume. Pre-pull + ensure the bridge (idempotent).
|
|
docker pull alpine:3 >/dev/null
|
|
# Pre-pull the exact digest-pinned alpine image provisioner.go's
|
|
# alpineImageDefault uses for token-write helper containers
|
|
# (deliberately NOT workspace-server/Dockerfile's base digest, which
|
|
# is the single-arch mirror re-pin; do not "re-sync" them). Without
|
|
# this, the real-image advisory job can fail when the runner cache
|
|
# does not already contain the pinned SHA.
|
|
docker pull alpine:3.20@sha256:c64c687cbea9300178b30c95835354e34c4e4febc4badfe27102879de0483b5e >/dev/null
|
|
docker network create molecule-core-net >/dev/null 2>&1 || true
|
|
# SEV-2499 / mc#2499: connect the act_runner job container to
|
|
# molecule-core-net so workspace containers can reach the platform
|
|
# server. Without this, the platform runs in the job container's
|
|
# namespace and workspace containers on molecule-core-net time out
|
|
# trying to reach the gateway IP (the host cannot forward to an
|
|
# unpublished container port).
|
|
JOB_CID="$(hostname)"
|
|
echo "JOB_CID=${JOB_CID}" >> "$GITHUB_ENV"
|
|
if docker network connect molecule-core-net "$JOB_CID" >/dev/null 2>&1; then
|
|
echo "Connected job container ($JOB_CID) to molecule-core-net."
|
|
else
|
|
echo "WARN: could not connect job container to molecule-core-net (fallback to gateway)."
|
|
fi
|
|
echo "alpine:3 pre-pulled; molecule-core-net ensured."
|
|
|
|
- name: Verify digest pins are multi-arch (amd64+arm64)
|
|
run: |
|
|
# Guards the invariant documented in docker-compose.infra.yml: every
|
|
# fixed image pin must be a multi-arch manifest-list/index digest. A
|
|
# platform-specific digest reads identically in review but breaks
|
|
# arm64 dev hosts (redis-cli segfaults under qemu, ClickHouse
|
|
# aborts). FAIL-CLOSED: a runner without buildx fails the job rather
|
|
# than silently downgrading this gate to advisory — fix the runner,
|
|
# not the check (docker-host runners already run buildx imagetools
|
|
# in the publish workflows).
|
|
if ! docker buildx version >/dev/null 2>&1; then
|
|
echo "::error::docker buildx unavailable on this runner — multi-arch pin verification cannot run (gate is fail-closed)"
|
|
exit 1
|
|
fi
|
|
# Scope: the two compose files only. provisioner.go's alpine helper
|
|
# pin is EXCLUDED — it is knowingly single-arch until a multi-arch
|
|
# index is hosted on the internal mirror (see the comment on
|
|
# alpineImageDefault); adding it here would red this gate on a known,
|
|
# documented exception.
|
|
refs=$(grep -hoE 'image: [^ ]+@sha256:[a-f0-9]{64}' docker-compose.yml docker-compose.infra.yml | sed 's/^image: //' | sort -u)
|
|
rc=0
|
|
for ref in $refs; do
|
|
# Inspect failures are caught explicitly. Previously, under the
|
|
# runner's `set -e -o pipefail`, an image that could not be
|
|
# inspected (removed upstream, auth wall) killed this step inside
|
|
# the `plats=$(...)` assignment with NO message (core#5208).
|
|
if ! out=$(docker buildx imagetools inspect "$ref" 2>&1); then
|
|
echo "::error::CANNOT INSPECT: $ref — $(printf '%s' "$out" | tail -1)"
|
|
rc=1
|
|
continue
|
|
fi
|
|
plats=$(printf '%s\n' "$out" | grep -E '^[[:space:]]+Platform:' | awk '{print $2}' || true)
|
|
if ! printf '%s\n' "$plats" | grep -qx 'linux/amd64' \
|
|
|| ! printf '%s\n' "$plats" | grep -q '^linux/arm64'; then
|
|
echo "::error::NOT MULTI-ARCH: $ref [$(printf '%s' "$plats" | tr '\n' ' ')]"
|
|
rc=1
|
|
else
|
|
echo "ok: $ref"
|
|
fi
|
|
done
|
|
exit $rc
|
|
|
|
- name: Start Postgres (docker, ephemeral host port)
|
|
run: |
|
|
# tmpfs PGDATA + run label + `rm -fv` pre-clean: see the script header.
|
|
bash .gitea/scripts/ci-service-container.sh postgres "$PG_CONTAINER" postgres:16 \
|
|
-e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \
|
|
-p 127.0.0.1::5432 >/dev/null
|
|
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^(0\.0\.0\.0|127\.0\.0\.1):/ {print $2; exit}')
|
|
[ -z "$PG_PORT" ] && PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
|
|
if [ -z "$PG_PORT" ]; then echo "::error::no host port for $PG_CONTAINER"; docker logs "$PG_CONTAINER" || true; exit 1; fi
|
|
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
|
|
for i in $(seq 1 30); do
|
|
docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1 && { echo "pg ready ${i}s"; exit 0; }
|
|
sleep 1
|
|
done
|
|
echo "::error::Postgres not ready in 30s"; docker logs "$PG_CONTAINER" || true; exit 1
|
|
|
|
- name: Start Redis (docker, ephemeral host port)
|
|
run: |
|
|
bash .gitea/scripts/ci-service-container.sh redis "$REDIS_CONTAINER" redis:7 -p 127.0.0.1::6379 >/dev/null
|
|
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^(0\.0\.0\.0|127\.0\.0\.1):/ {print $2; exit}')
|
|
[ -z "$REDIS_PORT" ] && REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
|
|
if [ -z "$REDIS_PORT" ]; then echo "::error::no host port for $REDIS_CONTAINER"; docker logs "$REDIS_CONTAINER" || true; exit 1; fi
|
|
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
|
|
for i in $(seq 1 15); do
|
|
docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG && { echo "redis ready ${i}s"; exit 0; }
|
|
sleep 1
|
|
done
|
|
echo "::error::Redis not ready in 15s"; docker logs "$REDIS_CONTAINER" || true; exit 1
|
|
|
|
- name: Configure platform env (admin token + local Docker provisioner)
|
|
run: |
|
|
# Allocate an unused ephemeral port to avoid collision with concurrent
|
|
# jobs or stale processes from prior cancelled runs (see #2450).
|
|
PORT=$(python3 -c "import socket; s=socket.socket(); s.bind(('', 0)); print(s.getsockname()[1]); s.close()")
|
|
echo "PORT=${PORT}" >> "$GITHUB_ENV"
|
|
echo "BASE=http://127.0.0.1:${PORT}" >> "$GITHUB_ENV"
|
|
# Discover an IP that Docker containers can use to reach the host platform.
|
|
# host.docker.internal is not reliably available on Linux (act_runner), so
|
|
# workspace containers cannot resolve it and fail to register/heartbeat.
|
|
# Workspace containers join molecule-core-net; the host is reachable via that
|
|
# network's gateway. Ensure the network exists first (the provisioner creates
|
|
# it lazily, but we need the gateway BEFORE starting the platform).
|
|
docker network inspect molecule-core-net >/dev/null 2>&1 || docker network create molecule-core-net >/dev/null
|
|
# Parse Gateway from raw JSON because --format '{{.IPAM.Config}}' is
|
|
# inconsistent across Docker versions (sometimes omits Gateway field).
|
|
PLATFORM_HOST_IP=$(docker network inspect molecule-core-net 2>/dev/null | sed -n 's/.*"Gateway": "\([^"]*\)".*/\1/p' | head -1)
|
|
if [ -z "$PLATFORM_HOST_IP" ]; then
|
|
PLATFORM_HOST_IP=$(docker network inspect bridge 2>/dev/null | sed -n 's/.*"Gateway": "\([^"]*\)".*/\1/p' | head -1)
|
|
fi
|
|
if [ -z "$PLATFORM_HOST_IP" ]; then
|
|
PLATFORM_HOST_IP=$(ip route | awk '/default/ {print $3}' | head -1 || true)
|
|
fi
|
|
if [ -z "$PLATFORM_HOST_IP" ]; then
|
|
echo "::error::Could not determine PLATFORM_HOST_IP for Docker containers to reach the platform"
|
|
exit 1
|
|
fi
|
|
echo "PLATFORM_HOST_IP=${PLATFORM_HOST_IP}"
|
|
echo "PLATFORM_URL=http://${PLATFORM_HOST_IP}:${PORT}" >> "$GITHUB_ENV"
|
|
# #2851: the workspace runtime must advertise an address the platform can
|
|
# resolve and that /registry/register will accept. In self-hosted dev
|
|
# mode SSRF validation only allows "localhost" by name (RFC-1918 gateway
|
|
# IPs are rejected). The act_runner job container runs with host-network,
|
|
# so localhost:<host-port> reaches the host-mapped workspace port.
|
|
# Without this the runtime may fall back to its Docker container
|
|
# hostname (e.g. 30e9e720fbc2), which the platform cannot resolve and
|
|
# the A2A proxy rejects.
|
|
echo "MOLECULE_WORKSPACE_ADVERTISE_HOST=localhost" >> "$GITHUB_ENV"
|
|
# Deterministic admin token: the script sends MOLECULE_ADMIN_TOKEN as the
|
|
# bearer; the platform checks ADMIN_TOKEN. Set both to the same value.
|
|
T="lpe2e-admin-${{ github.run_id }}-${{ github.run_attempt }}"
|
|
echo "ADMIN_TOKEN=${T}" >> "$GITHUB_ENV"
|
|
echo "MOLECULE_ADMIN_TOKEN=${T}" >> "$GITHUB_ENV"
|
|
# MOLECULE_ENV=development: dev posture. MOLECULE_ORG_ID is left UNSET so
|
|
# main.go wires the LOCAL Docker provisioner (not the CP provisioner), and
|
|
# MOLECULE_IMAGE_REGISTRY is left UNSET so image resolution uses
|
|
# RegistryModeLocal (the dockerHasTag cache-check the stub pre-tags into).
|
|
echo "MOLECULE_ENV=development" >> "$GITHUB_ENV"
|
|
echo "SECRETS_ENCRYPTION_KEY=lpe2e-test-encryption-key-32bytes!!" >> "$GITHUB_ENV"
|
|
|
|
- name: Build platform
|
|
working-directory: workspace-server
|
|
run: go build -o platform-server ./cmd/server
|
|
|
|
# The "Kill stale platform-server before start" step here is DELETED. Its
|
|
# own comment conceded the point: "Dynamic port allocation (see #2450)
|
|
# eliminates the fixed-port race... We still sweep by process name to
|
|
# avoid leaking platform-server processes on the shared runner." That is a
|
|
# SWEEPER standing in for a leak fix — and it swept processes it did not
|
|
# own. e2e-api, e2e-peer-visibility, e2e-chat and this lane share the
|
|
# `docker-host` runner and demonstrably overlap across PRs, so a host-wide
|
|
# kill-by-name reaches into a concurrent PR's live run and kills ITS
|
|
# platform-server mid-test. A required context then reds on a PR that
|
|
# changed nothing, and under branch protection status_check_contexts=['*']
|
|
# the merge queue freezes.
|
|
#
|
|
# The leak itself is now fixed where it happens: the server runs under
|
|
# `timeout`, so an orphan from a hard-cancelled run reaps ITSELF. Nothing
|
|
# signals a process it did not start.
|
|
- name: Start platform (background)
|
|
working-directory: workspace-server
|
|
run: |
|
|
# Bind to the dynamically allocated port (see #2450).
|
|
# DATABASE_URL/REDIS_URL/ADMIN_TOKEN/MOLECULE_ENV are inherited from
|
|
# $GITHUB_ENV. PLATFORM_URL is also passed explicitly because
|
|
# $GITHUB_ENV propagation can be flaky on act_runner (#2468 RCA).
|
|
#
|
|
# `timeout` bound == this job's own timeout-minutes (15): the clock
|
|
# starts here, after checkout/docker/go-build, so it can never fire
|
|
# during a healthy run — the job is already dead by then. It reaps only
|
|
# OUR child, never a foreign run's process.
|
|
echo "starting platform with PLATFORM_URL=${PLATFORM_URL:-<fallback>} PORT=$PORT BIND_ADDR=0.0.0.0"
|
|
PORT=$PORT BIND_ADDR=0.0.0.0 PLATFORM_URL="${PLATFORM_URL:-http://host.docker.internal:$PORT}" \
|
|
timeout --signal=TERM --kill-after=30s 15m ./platform-server > platform.log 2>&1 &
|
|
echo $! > platform.pid
|
|
|
|
- name: Wait for /health (+ migrations applied)
|
|
run: |
|
|
DEADLINE=${PLATFORM_BOOT_DEADLINE:-300}; PID="$(cat workspace-server/platform.pid 2>/dev/null || true)"; start=$(date +%s)
|
|
while :; do
|
|
# Verify OUR server is still alive before trusting /health. Our server
|
|
# binds the allocated port or exits FATAL, so "our PID alive" <=>
|
|
# "we own the port"; checking it first stops a squatter that answers
|
|
# /health on the same port (our bind having failed) from false-positiving
|
|
# the gate (no-flakes RCA).
|
|
if [ -n "$PID" ] && ! kill -0 "$PID" 2>/dev/null; then
|
|
echo "::error::platform-server exited early (failed to bind or crashed)"; cat workspace-server/platform.log || true; exit 1
|
|
fi
|
|
if curl -sf "$BASE/health" >/dev/null; then
|
|
tables=$(docker exec "$PG_CONTAINER" psql -U dev -d molecule -tAc \
|
|
"SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='workspaces'" 2>/dev/null || echo 0)
|
|
[ "$tables" = "1" ] && { echo "healthy + migrated after $(( $(date +%s) - start ))s"; exit 0; }
|
|
fi
|
|
[ "$(( $(date +%s) - start ))" -ge "$DEADLINE" ] && { echo "::error::platform not healthy in ${DEADLINE}s"; cat workspace-server/platform.log || true; exit 1; }
|
|
sleep 1
|
|
done
|
|
|
|
- name: Verify platform reachable from molecule-core-net
|
|
run: |
|
|
echo "Testing platform reachability from molecule-core-net container..."
|
|
docker run --rm --network molecule-core-net alpine:latest sh -c "wget -qO- http://${PLATFORM_URL#http://}/health" || echo "WARN: platform not reachable from molecule-core-net"
|
|
|
|
- name: Run local-provision lifecycle E2E (stub — REQUIRED)
|
|
env:
|
|
# The script appends every workspace id it provisions here, so the teardown
|
|
# can delete OUR containers and only ours. See #4346.
|
|
E2E_WS_MANIFEST: /tmp/ws_owned_${{ github.run_id }}_${{ github.run_attempt }}.txt
|
|
run: bash tests/e2e/test_local_provision_lifecycle_e2e.sh
|
|
|
|
- name: Dump platform log on failure
|
|
if: failure()
|
|
run: cat workspace-server/platform.log || true
|
|
|
|
- name: Dump workspace container logs on failure
|
|
if: failure()
|
|
run: |
|
|
# READ FROM THE MANIFEST, NOT FROM `docker ps | head -1`.
|
|
#
|
|
# This is a shared docker host with concurrent runs on it, so the first ws-*
|
|
# container the daemon lists is very often NOT ours. Dumping it meant that when
|
|
# a run failed, the logs it printed to explain WHY could belong to a completely
|
|
# different PR's workspace — and someone would then debug their failure against
|
|
# a stranger's stack trace. A diagnostic that lies is worse than none (#4346).
|
|
MANIFEST="/tmp/ws_owned_${{ github.run_id }}_${{ github.run_attempt }}.txt"
|
|
[ -s "$MANIFEST" ] || { echo "(no workspace was provisioned by this run)"; exit 0; }
|
|
while read -r wsid; do
|
|
[ -n "$wsid" ] || continue
|
|
name="ws-${wsid}"
|
|
echo "=== Workspace container logs for $name (OURS) ==="
|
|
docker logs "$name" 2>&1 | tail -n 80 || echo "(container $name is gone)"
|
|
done < "$MANIFEST"
|
|
|
|
- name: Stop platform
|
|
if: always()
|
|
run: |
|
|
[ -f workspace-server/platform.pid ] && kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
|
|
|
|
- name: Remove this run's workspace containers + volumes (platform-instance scoped)
|
|
# Positive identity, like the manifest teardown below, but for every
|
|
# container THIS run's platform provisioned — including ones no manifest
|
|
# knows about (a provision that raced a delete, RC09). They carry
|
|
# molecule.platform.instance=sha256(DATABASE_URL)[:16]; the DSN holds this
|
|
# run's own ephemeral PG port, so a concurrent run's workspaces never
|
|
# match. The listing is inside the reviewed script, filtered by that
|
|
# label only (test_ws_orphan_reap.py); this workflow still lists nothing.
|
|
# Volumes are derived from the containers (the bind-created ones carry no
|
|
# labels), so this runs BEFORE the manifest teardown removes them.
|
|
if: always()
|
|
run: |
|
|
if [ -z "${DATABASE_URL:-}" ]; then
|
|
echo "No DATABASE_URL — the platform never started; nothing to remove."
|
|
exit 0
|
|
fi
|
|
# "Stop platform" only signals it. Wait (bounded) until our own server
|
|
# has exited, so nothing it is still doing creates a container after
|
|
# the listing below. Not `kill -0`: the pid is the `timeout` wrapper,
|
|
# reparented to the job container's PID 1 (`sleep`, which reaps
|
|
# nothing), so once it exits it stays a zombie that `kill -0` still
|
|
# finds and the loop always ran its full 30s. Read the state instead.
|
|
PID="$(cat workspace-server/platform.pid 2>/dev/null || true)"
|
|
for _ in $(seq 1 30); do
|
|
[ -n "$PID" ] || break
|
|
st="$(awk '/^State:/ {print $2}' "/proc/$PID/status" 2>/dev/null || true)"
|
|
case "$st" in ''|Z|X) break ;; esac
|
|
sleep 1
|
|
done
|
|
bash .gitea/scripts/ws-orphan-reap.sh instance "$DATABASE_URL" \
|
|
|| echo "::warning::instance-scoped workspace teardown did not complete — see above"
|
|
|
|
- name: Stop service containers
|
|
if: always()
|
|
run: |
|
|
docker rm -fv "$PG_CONTAINER" 2>/dev/null || true
|
|
docker rm -fv "$REDIS_CONTAINER" 2>/dev/null || true
|
|
|
|
- name: Teardown OUR ws-* workspace containers (positive identity, #2883 / #4346)
|
|
if: always()
|
|
run: |
|
|
# Remove exactly the workspace containers THIS run provisioned — the ids the
|
|
# e2e script recorded in the manifest as POST /workspaces handed them back.
|
|
# Nothing else on this daemon is ours to delete.
|
|
#
|
|
# This deletes by NAME (ws-<id>), never by "every ^ws- container I don't
|
|
# recognise". A concurrent run's workspace is not in our manifest, so it cannot
|
|
# be touched — which is the whole of #4346. The previous baseline-exclusion
|
|
# version protected us only from runs that started BEFORE us, and offered zero
|
|
# protection against the ones that start AFTER, which is the common case
|
|
# because this step runs at the END of the job.
|
|
#
|
|
# Anything this step misses entirely (the runner container SIGKILLed before it
|
|
# runs, so nothing here executes) is the job of the standing
|
|
# sweep-stale-ws-orphans.yml janitor. That is the right layer for it: it is
|
|
# AGE-GUARDED, so unlike this step it cannot delete a container that is still
|
|
# in use.
|
|
# Delete by EXACT NAME, and NEVER enumerate the daemon here. `docker rm`
|
|
# takes a container name directly, so the id from the manifest is all we
|
|
# need — there is no `docker ps`/`docker container ls` in this step at all.
|
|
# That is deliberate and load-bearing: the moment a teardown step LISTS the
|
|
# daemon (by any idiom — `--filter name=ws-`, `--format | grep ws-`,
|
|
# `-a | awk /ws-/`) it can select a CONCURRENT run's container, which is the
|
|
# entire #4346 failure. A destructive step that never lists cannot pick the
|
|
# wrong victim, and the guard (test_ws_teardown_positive_identity.py) enforces
|
|
# exactly that property rather than blacklisting listing spellings.
|
|
MANIFEST="/tmp/ws_owned_${{ github.run_id }}_${{ github.run_attempt }}.txt"
|
|
[ -f "$MANIFEST" ] || : > "$MANIFEST"
|
|
removed=0
|
|
while read -r wsid; do
|
|
[ -n "$wsid" ] || continue
|
|
echo "removing our workspace container: ws-${wsid}"
|
|
docker rm -f "ws-${wsid}" >/dev/null 2>&1 || true
|
|
docker volume rm -f "ws-${wsid}-configs" "ws-${wsid}-claude-sessions" "ws-${wsid}-workspace" >/dev/null 2>&1 || true
|
|
removed=$((removed + 1))
|
|
done < "$MANIFEST"
|
|
echo "ws-* teardown removed $removed of $(wc -l < "$MANIFEST" | tr -d ' ') owned container(s)."
|
|
rm -f "$MANIFEST" 2>/dev/null || true
|
|
|
|
# ===========================================================================
|
|
# Real claude-code image, lifecycle-only. It pulls/builds the 2.5GB template
|
|
# image and makes a real (cheap) MiniMax LLM call. It proves the REAL runtime
|
|
# survives a restart AND serves a genuine LLM round-trip on the local
|
|
# provisioner (proxy-reach asserts a real MiniMax reply, not just reachability).
|
|
#
|
|
# PROMOTED out of the advisory mask (mc#4603 close-out). mc#2408's promotion
|
|
# criterion was "one further full renewal cycle green on a settled runner
|
|
# pool". Measured over 2026-08-01 -> 2026-08-09 (9 days, 300 main commits):
|
|
# 279 runs of this lane, 279 job logs retrieved, of which
|
|
# * 278 ended `=== Results: 18 passed, 0 failed ===`
|
|
# * 1 (run 634912, PR#5127 head b27707c206) was CANCELLED on a superseded
|
|
# head — its log stops mid-Step-5 with no failure output and even its
|
|
# `if: always()` steps read `failure`, the act_runner SIGKILL signature.
|
|
# It already posted `failure` WITH the mask on, so the mask never
|
|
# protected against it.
|
|
# * 0 runs took the `MINIMAX_API_KEY` skip path
|
|
# * 0 runs took an `infra_skip_advisory` path
|
|
# * 0 `--- FAIL` / go-test failures
|
|
# The evidence is from the LOGS, deliberately: a masked job rolls a failed
|
|
# step up to a `success` commit status (Gitea Quirk #10), so the status rows
|
|
# this lane produced could not distinguish "green" from "silently skipped".
|
|
# That is the exact trap PR#656 / mc#664 fell into.
|
|
#
|
|
# Residual coupling is stated rather than masked: this lane needs Infisical,
|
|
# api.minimax.io and a template build. Those still fail closed, and BP is
|
|
# ['*'], so a genuine outage reds this context. The shock absorbers that
|
|
# remain are explicit and capped — infra_skip_advisory() hard-fails on 2+
|
|
# distinct A2A degradation signatures — and the guard step below refuses to
|
|
# accept any of them as a pass now that the lane gates.
|
|
# ===========================================================================
|
|
# bp-exempt: not in .gitea/required-contexts.txt (the stub lane is the
|
|
# presence-required one), so this context is not demanded at merge time; it is
|
|
# no longer continue-on-error, so when it posts it must be green.
|
|
lifecycle-real:
|
|
name: Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory)
|
|
runs-on: docker-host
|
|
# Serialise behind the gating stub job: both jobs share the same docker-host
|
|
# runner and provision sibling containers. `needs:` forces this job to start
|
|
# only AFTER lifecycle-stub finishes, avoiding resource contention.
|
|
# (Dynamic ports eliminated the fixed-port race; serialisation remains for
|
|
# docker-host capacity hygiene.) `needs:` does NOT gate on the stub's
|
|
# success — `if: always()` keeps this lane running even when the required
|
|
# stub gate reds, so the two lanes fail independently.
|
|
needs: lifecycle-stub
|
|
if: ${{ always() }}
|
|
timeout-minutes: 30
|
|
env:
|
|
PG_CONTAINER: pg-lpe2e-real-${{ github.run_id }}-${{ github.run_attempt }}
|
|
REDIS_CONTAINER: redis-lpe2e-real-${{ github.run_id }}-${{ github.run_attempt }}
|
|
# Hard-code dev mode at the job level so the platform server ALWAYS sees it,
|
|
# even if the runner's $GITHUB_ENV propagation is flaky (#2468 RCA).
|
|
MOLECULE_ENV: development
|
|
SECRETS_ENCRYPTION_KEY: lpe2e-test-encryption-key-32bytes!!
|
|
# #2851 fix follow-up: act_runner executes this job container in host-network
|
|
# mode, so the platform process shares the host's loopback. The A2A proxy
|
|
# must therefore keep the stored http://127.0.0.1:<host-port> URL as-is;
|
|
# enabling Docker-aware rewrite would synthesize ws-<id>:8000, which the
|
|
# host-network platform cannot resolve. Leave MOLECULE_IN_DOCKER=false so
|
|
# the proxy uses the host-mapped port directly (same as lifecycle-stub).
|
|
MOLECULE_IN_DOCKER: false
|
|
# The platform this job starts provisions ws-<id> containers on the shared
|
|
# host daemon. Production keeps them unless-stopped; here one that outlives
|
|
# the run must exit once and stay collectable, not crash-loop on the runner
|
|
# host (RC09).
|
|
MOLECULE_WORKSPACE_RESTART_POLICY: "no"
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
|
with:
|
|
go-version: 'stable'
|
|
# cache:false — the self-hosted runner bind-mounts a persistent
|
|
# GOCACHE/GOMODCACHE (/var/cache/ci-go-{build,mod}); actions/cache is
|
|
# redundant and corrupts it by untarring over the bind mount ("File
|
|
# exists" -> "Failed to restore" -> partial cache -> linker/typecheck
|
|
# errors on heavy jobs, e.g. test -race link "too many errors" and
|
|
# go-arch-lint "without types"). Fleet sweep after the cp ci.yml find.
|
|
cache: false
|
|
cache-dependency-path: workspace-server/go.sum
|
|
|
|
# KMS wave-B (Gitea-Actions-secrets -> Infisical SSOT): source the staging
|
|
# MiniMax key (MINIMAX_API_KEY @ staging /shared/controlplane) from Infisical
|
|
# via the CI machine identity instead of the duplicated
|
|
# MOLECULE_STAGING_MINIMAX_API_KEY Gitea Actions secret. Exported to
|
|
# $GITHUB_ENV under the same MINIMAX_API_KEY name the advisory lifecycle E2E
|
|
# reads.
|
|
#
|
|
# TRUSTED-REF GATING: molecule-core is a PUBLIC repo and this workflow runs
|
|
# on pull_request, so the INFISICAL_CI_* machine-identity secrets must NEVER
|
|
# be reachable from an untrusted-fork PR. This step runs only on protected-
|
|
# branch push / operator workflow_dispatch / schedule, or a same-repo
|
|
# (non-fork) PR. On an untrusted-fork PR it SKIPS cleanly — MINIMAX_API_KEY
|
|
# stays empty and the advisory script SKIPS loud (exit 0, never reds),
|
|
# identical to today's fork-PR skip-if-absent behaviour.
|
|
- name: Fetch staging MiniMax key from Infisical KMS
|
|
id: kms_minimax
|
|
if: >-
|
|
github.event_name == 'push' ||
|
|
github.event_name == 'workflow_dispatch' ||
|
|
github.event_name == 'schedule' ||
|
|
(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 }}
|
|
TRANSIENT_LEDGER: /tmp/lpe2e_transient_${{ github.run_id }}_${{ github.run_attempt }}.txt
|
|
run: |
|
|
set -uo pipefail
|
|
BASE="https://key.moleculesai.app"
|
|
# Capture curl's exit status SEPARATELY from the body (the
|
|
# lint-curl-status-capture pattern). Piping straight into python
|
|
# loses it: a connection failure and a 200-with-empty-value both
|
|
# arrive as an empty string, and those two are not the same event.
|
|
# - transport failure => Infisical is UNREACHABLE. External
|
|
# outage, not our defect. Record a transient and let the guard
|
|
# step adjudicate against the budget.
|
|
# - 200 with an empty/missing value => MISCONFIGURATION. Ours.
|
|
# Fail closed exactly as before.
|
|
LOGIN_BODY=$(curl -fsS --max-time 30 -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\"}") || LOGIN_RC=$?
|
|
LOGIN_RC=${LOGIN_RC:-0}
|
|
if [ "$LOGIN_RC" -ne 0 ]; then
|
|
echo "::warning::Infisical login unreachable (curl rc=$LOGIN_RC) — external dependency, recording a transient."
|
|
echo "infisical-unreachable login_curl_rc=$LOGIN_RC" >> "$TRANSIENT_LEDGER"
|
|
exit 0
|
|
fi
|
|
TOK=$(printf '%s' "$LOGIN_BODY" | 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 "")')
|
|
if [ -z "$TOK" ]; then
|
|
echo "::error::Infisical login returned success with an empty accessToken — misconfiguration, failing closed."
|
|
exit 1
|
|
fi
|
|
SECRET_BODY=$(curl -fsS --max-time 30 "$BASE/api/v3/secrets/raw/MINIMAX_API_KEY?workspaceId=$INFISICAL_PROJECT_ID&environment=staging&secretPath=%2Fshared%2Fcontrolplane" \
|
|
-H "Authorization: Bearer $TOK") || READ_RC=$?
|
|
READ_RC=${READ_RC:-0}
|
|
if [ "$READ_RC" -ne 0 ]; then
|
|
echo "::warning::Infisical secret read unreachable (curl rc=$READ_RC) — external dependency, recording a transient."
|
|
echo "infisical-unreachable read_curl_rc=$READ_RC" >> "$TRANSIENT_LEDGER"
|
|
exit 0
|
|
fi
|
|
MINIMAX_API_KEY=$(printf '%s' "$SECRET_BODY" | 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 "")')
|
|
echo "::add-mask::$MINIMAX_API_KEY"
|
|
if [ -z "$MINIMAX_API_KEY" ]; then
|
|
echo "::error::Infisical returned empty MINIMAX_API_KEY at staging /shared/controlplane — failing closed."
|
|
exit 1
|
|
fi
|
|
echo "MINIMAX_API_KEY=$MINIMAX_API_KEY" >> "$GITHUB_ENV"
|
|
echo "MINIMAX_API_KEY loaded from Infisical staging SSOT (len=${#MINIMAX_API_KEY})"
|
|
|
|
- name: Init owned-workspace manifest (run-scoped teardown identity)
|
|
run: |
|
|
# #2883: this job provisions ws-<uuid> workspace containers via the platform
|
|
# binary's docker.sock. The e2e script's own EXIT trap deletes the workspace it
|
|
# created, but a CANCELLED / TIMED-OUT job (act_runner SIGKILLs the job
|
|
# container, so the bash trap never fires) or a platform crash mid-provision
|
|
# leaks the ws-<uuid> container — it then runs forever and pegs CPU on the
|
|
# shared docker-host runner (13 orphans found on ded-1, 11+3 on the prod
|
|
# robots).
|
|
#
|
|
# #4346: this used to be a BASELINE — snapshot the ws-* containers that exist
|
|
# before the job starts, and have the teardown delete anything NOT in that
|
|
# snapshot. That is inference by exclusion, and it is WRONG on a shared daemon:
|
|
# "not in my baseline" is not the same as "mine". The concurrency group is
|
|
# per-SHA, so a DIFFERENT PR's run executes concurrently on this same docker
|
|
# host, and any container it creates AFTER our snapshot is — by construction —
|
|
# absent from it. Our teardown then found that run's LIVE workspace, called it
|
|
# our own leak, and `docker rm -f`'d it mid-test. The victim saw its container
|
|
# simply cease to exist, with nothing wrong anywhere in its provisioning path
|
|
# (last_sample_error=<none>, container_running=<none>): indistinguishable from a
|
|
# platform bug, and it reads exactly like a flake.
|
|
#
|
|
# So we record POSITIVE identity instead. The e2e script knows the id the
|
|
# moment POST /workspaces returns it, and the container is named ws-<id>; it
|
|
# appends that id here (E2E_WS_MANIFEST). The teardown removes those and
|
|
# nothing else, which is correct no matter what any concurrent run does or when
|
|
# it started.
|
|
: > /tmp/ws_owned_${{ github.run_id }}_${{ github.run_attempt }}.txt
|
|
echo "owned-workspace manifest initialised (empty)."
|
|
|
|
- name: Build e2e-names SSOT CLI
|
|
working-directory: workspace-server
|
|
run: go build -o /usr/local/bin/e2e-names ./cmd/e2e-names
|
|
|
|
- name: Ensure provisioner network + pre-pull alpine
|
|
run: |
|
|
docker pull alpine:3 >/dev/null
|
|
# Pre-pull the exact digest-pinned alpine image provisioner.go's
|
|
# alpineImageDefault uses for token-write helper containers
|
|
# (deliberately differs from the Dockerfile's single-arch mirror
|
|
# re-pin).
|
|
docker pull alpine:3.20@sha256:c64c687cbea9300178b30c95835354e34c4e4febc4badfe27102879de0483b5e >/dev/null
|
|
docker network create molecule-core-net >/dev/null 2>&1 || true
|
|
# Workspace containers join molecule-core-net. The act_runner job
|
|
# container runs host-network, so connecting it to the bridge is not
|
|
# possible, but the bridge itself must exist for sibling workspace
|
|
# containers to communicate.
|
|
|
|
- name: Start Postgres (docker, ephemeral host port)
|
|
run: |
|
|
# tmpfs PGDATA + run label + `rm -fv` pre-clean: see the script header.
|
|
bash .gitea/scripts/ci-service-container.sh postgres "$PG_CONTAINER" postgres:16 \
|
|
-e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \
|
|
-p 127.0.0.1::5432 >/dev/null
|
|
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^(0\.0\.0\.0|127\.0\.0\.1):/ {print $2; exit}')
|
|
[ -z "$PG_PORT" ] && PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
|
|
if [ -z "$PG_PORT" ]; then echo "::error::no host port"; docker logs "$PG_CONTAINER" || true; exit 1; fi
|
|
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
|
|
for i in $(seq 1 30); do
|
|
docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1 && { echo "pg ready ${i}s"; exit 0; }
|
|
sleep 1
|
|
done
|
|
echo "::error::Postgres not ready"; docker logs "$PG_CONTAINER" || true; exit 1
|
|
|
|
- name: Start Redis (docker, ephemeral host port)
|
|
run: |
|
|
bash .gitea/scripts/ci-service-container.sh redis "$REDIS_CONTAINER" redis:7 -p 127.0.0.1::6379 >/dev/null
|
|
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^(0\.0\.0\.0|127\.0\.0\.1):/ {print $2; exit}')
|
|
[ -z "$REDIS_PORT" ] && REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
|
|
if [ -z "$REDIS_PORT" ]; then echo "::error::no host port"; docker logs "$REDIS_CONTAINER" || true; exit 1; fi
|
|
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
|
|
for i in $(seq 1 15); do
|
|
docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG && { echo "redis ready ${i}s"; exit 0; }
|
|
sleep 1
|
|
done
|
|
echo "::error::Redis not ready"; docker logs "$REDIS_CONTAINER" || true; exit 1
|
|
|
|
- name: Configure platform env
|
|
run: |
|
|
# Allocate an unused ephemeral port to avoid collision with concurrent
|
|
# jobs or stale processes from prior cancelled runs (see #2450).
|
|
PORT=$(python3 -c "import socket; s=socket.socket(); s.bind(('', 0)); print(s.getsockname()[1]); s.close()")
|
|
echo "PORT=${PORT}" >> "$GITHUB_ENV"
|
|
echo "BASE=http://127.0.0.1:${PORT}" >> "$GITHUB_ENV"
|
|
# Discover an IP that Docker containers can use to reach the host platform.
|
|
# host.docker.internal is not reliably available on Linux (act_runner), so
|
|
# workspace containers cannot resolve it and fail to register/heartbeat.
|
|
# Workspace containers join molecule-core-net; the host is reachable via that
|
|
# network's gateway. Ensure the network exists first (the provisioner creates
|
|
# it lazily, but we need the gateway BEFORE starting the platform).
|
|
docker network inspect molecule-core-net >/dev/null 2>&1 || docker network create molecule-core-net >/dev/null
|
|
# Parse Gateway from raw JSON because --format '{{.IPAM.Config}}' is
|
|
# inconsistent across Docker versions (sometimes omits Gateway field).
|
|
PLATFORM_HOST_IP=$(docker network inspect molecule-core-net 2>/dev/null | sed -n 's/.*"Gateway": "\([^"]*\)".*/\1/p' | head -1)
|
|
if [ -z "$PLATFORM_HOST_IP" ]; then
|
|
PLATFORM_HOST_IP=$(docker network inspect bridge 2>/dev/null | sed -n 's/.*"Gateway": "\([^"]*\)".*/\1/p' | head -1)
|
|
fi
|
|
if [ -z "$PLATFORM_HOST_IP" ]; then
|
|
PLATFORM_HOST_IP=$(ip route | awk '/default/ {print $3}' | head -1 || true)
|
|
fi
|
|
if [ -z "$PLATFORM_HOST_IP" ]; then
|
|
echo "::error::Could not determine PLATFORM_HOST_IP for Docker containers to reach the platform"
|
|
exit 1
|
|
fi
|
|
echo "PLATFORM_HOST_IP=${PLATFORM_HOST_IP}"
|
|
echo "PLATFORM_URL=http://${PLATFORM_HOST_IP}:${PORT}" >> "$GITHUB_ENV"
|
|
# #2851: the workspace runtime must advertise an address the platform can
|
|
# resolve and that /registry/register will accept. In self-hosted dev
|
|
# mode SSRF validation only allows "localhost" by name (RFC-1918 gateway
|
|
# IPs are rejected). The act_runner job container runs with host-network,
|
|
# so localhost:<host-port> reaches the host-mapped workspace port.
|
|
# Without this the runtime may fall back to its Docker container
|
|
# hostname (e.g. 30e9e720fbc2), which the platform cannot resolve and
|
|
# the A2A proxy rejects.
|
|
echo "MOLECULE_WORKSPACE_ADVERTISE_HOST=localhost" >> "$GITHUB_ENV"
|
|
T="lpe2e-real-admin-${{ github.run_id }}-${{ github.run_attempt }}"
|
|
echo "ADMIN_TOKEN=${T}" >> "$GITHUB_ENV"
|
|
echo "MOLECULE_ADMIN_TOKEN=${T}" >> "$GITHUB_ENV"
|
|
echo "MOLECULE_ENV=development" >> "$GITHUB_ENV"
|
|
echo "SECRETS_ENCRYPTION_KEY=lpe2e-test-encryption-key-32bytes!!" >> "$GITHUB_ENV"
|
|
|
|
- name: Build platform
|
|
working-directory: workspace-server
|
|
run: go build -o platform-server ./cmd/server
|
|
|
|
# Host-wide "Kill stale platform-server" sweep DELETED here too — same
|
|
# reasoning as the first job in this file (see the note above the other
|
|
# "Start platform" step): the port is already dynamic, so the sweep bought
|
|
# nothing, and on the shared `docker-host` runner it killed concurrent
|
|
# PRs' platform-servers, reddening required contexts on unrelated PRs and
|
|
# wedging the ['*'] merge queue.
|
|
- name: Start platform (background)
|
|
working-directory: workspace-server
|
|
run: |
|
|
# `timeout` bound == this job's own timeout-minutes (30). Reaps only
|
|
# our own orphan if the job is hard-cancelled before the `always()`
|
|
# stop step; cannot fire during a healthy run.
|
|
echo "starting platform with PLATFORM_URL=${PLATFORM_URL:-<fallback>} PORT=$PORT BIND_ADDR=0.0.0.0"
|
|
PORT=$PORT BIND_ADDR=0.0.0.0 PLATFORM_URL="${PLATFORM_URL:-http://host.docker.internal:$PORT}" \
|
|
timeout --signal=TERM --kill-after=30s 30m ./platform-server > platform.log 2>&1 &
|
|
echo $! > platform.pid
|
|
|
|
- name: Wait for /health (+ migrations applied)
|
|
run: |
|
|
DEADLINE=${PLATFORM_BOOT_DEADLINE:-300}; PID="$(cat workspace-server/platform.pid 2>/dev/null || true)"; start=$(date +%s)
|
|
while :; do
|
|
# Verify OUR server is still alive before trusting /health. Our server
|
|
# binds the allocated port or exits FATAL, so checking our PID first
|
|
# stops a squatter from false-positiving the gate (no-flakes RCA).
|
|
if [ -n "$PID" ] && ! kill -0 "$PID" 2>/dev/null; then
|
|
echo "::error::platform-server exited early (failed to bind or crashed)"; cat workspace-server/platform.log || true; exit 1
|
|
fi
|
|
if curl -sf "$BASE/health" >/dev/null; then
|
|
tables=$(docker exec "$PG_CONTAINER" psql -U dev -d molecule -tAc \
|
|
"SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='workspaces'" 2>/dev/null || echo 0)
|
|
[ "$tables" = "1" ] && { echo "healthy after $(( $(date +%s) - start ))s"; exit 0; }
|
|
fi
|
|
[ "$(( $(date +%s) - start ))" -ge "$DEADLINE" ] && { echo "::error::platform not healthy in ${DEADLINE}s"; cat workspace-server/platform.log || true; exit 1; }
|
|
sleep 1
|
|
done
|
|
|
|
- name: Verify platform reachable from molecule-core-net
|
|
run: |
|
|
echo "Testing platform reachability from molecule-core-net container..."
|
|
docker run --rm --network molecule-core-net alpine:latest sh -c "wget -qO- http://${PLATFORM_URL#http://}/health" || echo "WARN: platform not reachable from molecule-core-net"
|
|
|
|
- name: Run local-provision lifecycle E2E (real image + MiniMax LLM — ADVISORY)
|
|
env:
|
|
# LIFECYCLE_LLM=minimax: provision the REAL claude-code template image
|
|
# (the mode forces LIFECYCLE_PROVISIONER_BUILDS=1 — the provisioner
|
|
# clones + docker-builds the template from Gitea via RegistryModeLocal)
|
|
# with a real MiniMax BYOK credential, and assert an ACTUAL model reply
|
|
# at the proxy-reach step (a genuine round-trip through ws-<id>:8000).
|
|
# MiniMax is the cheapest LLM the platform offers; its `minimax`
|
|
# provider dials api.minimax.io directly, so no CP proxy env is needed.
|
|
#
|
|
# Key wiring (DO NOT hardcode): the script reads MINIMAX_API_KEY from
|
|
# the env; we feed it from Infisical (MINIMAX_API_KEY @ staging
|
|
# /shared/controlplane) via the "Fetch staging MiniMax key from Infisical
|
|
# KMS" step above (exported to $GITHUB_ENV). When that fetch is gated off
|
|
# (untrusted-fork PR) MINIMAX_API_KEY is empty and the script exits 0
|
|
# having asserted nothing. That was harmless while the job was masked;
|
|
# now that the lane gates, the guard step below turns any such
|
|
# zero-assertion exit into a loud, named red.
|
|
LIFECYCLE_LLM: minimax
|
|
# The script appends every workspace id it provisions here, so the teardown can
|
|
# delete OUR containers and only ours. See #4346 — this is the job whose live
|
|
# container a concurrent run's teardown deleted mid-restart.
|
|
E2E_WS_MANIFEST: /tmp/ws_owned_${{ github.run_id }}_${{ github.run_attempt }}.txt
|
|
E2E_REAL_LOG: /tmp/lpe2e_real_${{ github.run_id }}_${{ github.run_attempt }}.log
|
|
run: |
|
|
# Gitea runs a step as `bash --noprofile --norc -e -o pipefail {0}`, but
|
|
# a script cannot read its own flags off a `set` line, so state them.
|
|
set -eo pipefail
|
|
bash tests/e2e/test_local_provision_lifecycle_e2e.sh 2>&1 | tee "$E2E_REAL_LOG"
|
|
|
|
# A green exit is NOT evidence this lane exercised anything: the e2e script
|
|
# has three distinct `exit 0` paths — the normal pass, the
|
|
# MINIMAX_API_KEY-absent skip, and infra_skip_advisory(). Only the first
|
|
# prints a `=== Results: <N> passed, 0 failed ===` line. While the job was
|
|
# `continue-on-error: true` that difference was invisible; now that the
|
|
# lane gates, a zero-assertion run must not be able to report success
|
|
# identically to a full one. Assert the COUNT.
|
|
# PROPORTIONALITY (mc#5155). This workflow has NO `paths:` filter and BP is
|
|
# ['*'], so every posted context must be green on every PR. Unmasking made
|
|
# a real defect red the lane -- which is the point -- but it also meant a
|
|
# MiniMax or Infisical OUTAGE would red every open PR at once, freezing the
|
|
# whole merge queue. "A check that can fail" and "an external outage blocks
|
|
# all merges" are different bargains, and only the first was the argument
|
|
# for unmasking.
|
|
#
|
|
# A `paths:` filter was considered and rejected: `paths:` is workflow-level,
|
|
# so it would also stop the REQUIRED `lifecycle-stub` context (listed in
|
|
# .gitea/required-contexts.txt) from posting. That is the
|
|
# `path_filtered_workflow_cant_be_required` class that lint-required-no-paths
|
|
# exists to prevent -- it only misses it here because BP ['*'] resolves to
|
|
# zero enumerable contexts in that lint.
|
|
#
|
|
# Instead: a BOUNDED, ENUMERATED allowance, adjudicated in one place.
|
|
# * Only the signatures in E2E_TRANSIENT_CLASSES may consume it, and each
|
|
# one is an EXTERNAL dependency being unreachable -- never our code.
|
|
# `a2a-gateway-error` and `a2a-queue-timeout` are deliberately NOT in
|
|
# the set: those can indicate a real proxy/daemon regression.
|
|
# * The budget is ONE event per run. A second consumes nothing and reds.
|
|
# * A consumed budget is a loud ::warning:: naming the class, not silence.
|
|
# This is bounded in a way an open-ended `continue-on-error` never was: it
|
|
# cannot absorb an assertion failure, a thin pass, or an unknown skip.
|
|
#
|
|
# Budget sizing: the transient trigger fired 0 times in 279 runs over 9 days
|
|
# (2026-08-01..09), independently re-measured as 0 in 286 runs over 308 main
|
|
# commits. Budgeting for 1 per run against a measured rate of 0 is therefore
|
|
# deliberately generous; if it ever actually fires, the warning is visible in
|
|
# the run and mc#5155 forces the review rather than letting it normalise.
|
|
- name: Assert the real-image lane actually asserted something
|
|
env:
|
|
E2E_REAL_LOG: /tmp/lpe2e_real_${{ github.run_id }}_${{ github.run_attempt }}.log
|
|
TRANSIENT_LEDGER: /tmp/lpe2e_transient_${{ github.run_id }}_${{ github.run_attempt }}.txt
|
|
# Floor, not equality: adding a 19th assertion to the e2e must not red
|
|
# this. 18 is what every one of the 279 logs sampled 2026-08-01..09 printed.
|
|
E2E_MIN_ASSERTIONS: '18'
|
|
# Space-separated. EXTERNAL-unreachable signatures only.
|
|
E2E_TRANSIENT_CLASSES: 'infisical-unreachable a2a-connect-timeout'
|
|
E2E_TRANSIENT_BUDGET: '1'
|
|
run: |
|
|
set -uo pipefail
|
|
|
|
# Count transients recorded by earlier steps (Infisical transport).
|
|
ledger_n=0
|
|
if [ -s "$TRANSIENT_LEDGER" ]; then
|
|
ledger_n=$(grep -c . "$TRANSIENT_LEDGER" || true)
|
|
ledger_n=${ledger_n:-0}
|
|
fi
|
|
|
|
# `transient_ok <class>` — is this signature allowed to spend budget?
|
|
transient_ok() {
|
|
case " $E2E_TRANSIENT_CLASSES " in
|
|
*" $1 "*) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
# `spend <class> <detail>` — consume the budget or red.
|
|
spend() {
|
|
_class="$1"; _detail="${2:-}"
|
|
_total=$((ledger_n + 1))
|
|
if ! transient_ok "$_class"; then
|
|
echo "::error::real-image lane stopped on '$_class'${_detail:+ ($_detail)} and asserted nothing."
|
|
echo " '$_class' is NOT an allowed transient class ($E2E_TRANSIENT_CLASSES) —"
|
|
echo " it can indicate a real regression, so it reds. Root-fix per no-flaky; do not re-mask."
|
|
exit 1
|
|
fi
|
|
if [ "$_total" -gt "$E2E_TRANSIENT_BUDGET" ]; then
|
|
echo "::error::real-image lane hit $_total transient event(s), over the budget of $E2E_TRANSIENT_BUDGET."
|
|
echo " Repeated external failure in one run is not transient — it is an outage to fix (mc#5155)."
|
|
[ -s "$TRANSIENT_LEDGER" ] && sed 's/^/ ledger: /' "$TRANSIENT_LEDGER"
|
|
exit 1
|
|
fi
|
|
echo "::warning::real-image lane consumed its transient budget: $_class${_detail:+ ($_detail)}."
|
|
echo "::warning::An EXTERNAL dependency was unreachable, so this run asserted nothing and is NOT"
|
|
echo "::warning::evidence the lane works. Tracked by mc#5155; budget $_total/$E2E_TRANSIENT_BUDGET."
|
|
exit 0
|
|
}
|
|
|
|
if [ ! -s "$E2E_REAL_LOG" ]; then
|
|
# No log at all. If an earlier step recorded a transient (e.g. the
|
|
# Infisical fetch never exported a key), that is the explanation.
|
|
if [ "$ledger_n" -gt 0 ]; then
|
|
_first=$(head -1 "$TRANSIENT_LEDGER" | awk '{print $1}')
|
|
ledger_n=$((ledger_n - 1)) # `spend` re-adds this one
|
|
spend "$_first" "$(head -1 "$TRANSIENT_LEDGER")"
|
|
fi
|
|
echo "::error::advisory lane produced no log at $E2E_REAL_LOG — cannot confirm it ran."
|
|
exit 1
|
|
fi
|
|
# The tee'd file is the script's raw stdout, but the same text read back
|
|
# through the Actions job-log API is prefixed with an ISO-8601 stamp
|
|
# ("2026-08-09T02:10:05.9495407Z === Results: ..."). Strip an optional
|
|
# leading stamp and any trailing CR so this guard matches BOTH shapes.
|
|
# Anchoring at start-of-line after that strip is deliberate: it stops a
|
|
# line of echoed script SOURCE from counting as an emitted result — the
|
|
# same false-positive class that makes a naive `::error::` grep match
|
|
# un-executed `echo "::error::..."` lines in a step body.
|
|
norm() {
|
|
sed -e 's/\r$//' -e 's/^[0-9][0-9-]*T[0-9:.]*Z[[:space:]]*//' "$E2E_REAL_LOG"
|
|
}
|
|
if norm | grep -q '^=== Results: advisory infra-skip'; then
|
|
reason=$(norm | sed -n 's/^=== Results: advisory infra-skip (\(.*\)) ===$/\1/p' | tail -1)
|
|
# An unparseable reason must NOT be treated as an allowed class:
|
|
# `spend` fails closed on anything outside E2E_TRANSIENT_CLASSES,
|
|
# and the empty string is outside it.
|
|
spend "${reason:-<unparsed>}" "infra-skip"
|
|
fi
|
|
# NOTE: `n` may be empty if no Results line matched. Test emptiness
|
|
# FIRST — `[ "" -lt 18 ]` is a bash ERROR, not false, and an
|
|
# unguarded numeric test on an empty string has silently skipped an
|
|
# entire check in this repo before.
|
|
n=$(norm | sed -n 's/^=== Results: \([0-9][0-9]*\) passed, 0 failed ===$/\1/p' | tail -1)
|
|
if [ -z "${n:-}" ]; then
|
|
# No clean Results line. If an earlier step recorded an external
|
|
# transient, that explains it (an unreachable Infisical means no key,
|
|
# which makes the e2e take its missing-key exit). Otherwise this is a
|
|
# genuine early exit and reds.
|
|
if [ "$ledger_n" -gt 0 ]; then
|
|
_first=$(head -1 "$TRANSIENT_LEDGER" | awk '{print $1}')
|
|
ledger_n=$((ledger_n - 1))
|
|
spend "$_first" "$(head -1 "$TRANSIENT_LEDGER")"
|
|
fi
|
|
echo "::error::no clean '=== Results: <N> passed, 0 failed ===' line in the real-image lane log."
|
|
echo " The script exited without completing its assertions (missing MINIMAX_API_KEY, or an early exit)."
|
|
tail -n 25 "$E2E_REAL_LOG" || true
|
|
exit 1
|
|
fi
|
|
if [ "$n" -lt "$E2E_MIN_ASSERTIONS" ]; then
|
|
echo "::error::real-image lane asserted only $n of an expected >=$E2E_MIN_ASSERTIONS checks — refusing a thin pass."
|
|
exit 1
|
|
fi
|
|
echo "real-image lane asserted $n checks, 0 failed (floor $E2E_MIN_ASSERTIONS); transient budget used 0/$E2E_TRANSIENT_BUDGET."
|
|
if [ "$ledger_n" -gt 0 ]; then
|
|
# Belt-and-braces: a clean Results line alongside a recorded transient
|
|
# is not an expected combination (an unreachable Infisical means no
|
|
# key, which makes the e2e exit before asserting). Surface it rather
|
|
# than let it pass unremarked.
|
|
echo "::warning::lane passed cleanly BUT $ledger_n transient(s) were recorded earlier this run:"
|
|
sed 's/^/::warning:: /' "$TRANSIENT_LEDGER"
|
|
fi
|
|
|
|
- name: Dump platform log on failure
|
|
if: failure()
|
|
run: cat workspace-server/platform.log || true
|
|
|
|
- name: Dump workspace container logs on failure
|
|
if: failure()
|
|
run: |
|
|
# READ FROM THE MANIFEST, NOT FROM `docker ps | head -1`.
|
|
#
|
|
# This is a shared docker host with concurrent runs on it, so the first ws-*
|
|
# container the daemon lists is very often NOT ours. Dumping it meant that when
|
|
# a run failed, the logs it printed to explain WHY could belong to a completely
|
|
# different PR's workspace — and someone would then debug their failure against
|
|
# a stranger's stack trace. A diagnostic that lies is worse than none (#4346).
|
|
MANIFEST="/tmp/ws_owned_${{ github.run_id }}_${{ github.run_attempt }}.txt"
|
|
[ -s "$MANIFEST" ] || { echo "(no workspace was provisioned by this run)"; exit 0; }
|
|
while read -r wsid; do
|
|
[ -n "$wsid" ] || continue
|
|
name="ws-${wsid}"
|
|
echo "=== Workspace container logs for $name (OURS) ==="
|
|
docker logs "$name" 2>&1 | tail -n 80 || echo "(container $name is gone)"
|
|
done < "$MANIFEST"
|
|
|
|
- name: Stop platform
|
|
if: always()
|
|
run: |
|
|
[ -f workspace-server/platform.pid ] && kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
|
|
|
|
- name: Remove this run's workspace containers + volumes (platform-instance scoped)
|
|
# Positive identity, like the manifest teardown below, but for every
|
|
# container THIS run's platform provisioned — including ones no manifest
|
|
# knows about (a provision that raced a delete, RC09). They carry
|
|
# molecule.platform.instance=sha256(DATABASE_URL)[:16]; the DSN holds this
|
|
# run's own ephemeral PG port, so a concurrent run's workspaces never
|
|
# match. The listing is inside the reviewed script, filtered by that
|
|
# label only (test_ws_orphan_reap.py); this workflow still lists nothing.
|
|
# Volumes are derived from the containers (the bind-created ones carry no
|
|
# labels), so this runs BEFORE the manifest teardown removes them.
|
|
if: always()
|
|
run: |
|
|
if [ -z "${DATABASE_URL:-}" ]; then
|
|
echo "No DATABASE_URL — the platform never started; nothing to remove."
|
|
exit 0
|
|
fi
|
|
# "Stop platform" only signals it. Wait (bounded) until our own server
|
|
# has exited, so nothing it is still doing creates a container after
|
|
# the listing below. Not `kill -0`: the pid is the `timeout` wrapper,
|
|
# reparented to the job container's PID 1 (`sleep`, which reaps
|
|
# nothing), so once it exits it stays a zombie that `kill -0` still
|
|
# finds and the loop always ran its full 30s. Read the state instead.
|
|
PID="$(cat workspace-server/platform.pid 2>/dev/null || true)"
|
|
for _ in $(seq 1 30); do
|
|
[ -n "$PID" ] || break
|
|
st="$(awk '/^State:/ {print $2}' "/proc/$PID/status" 2>/dev/null || true)"
|
|
case "$st" in ''|Z|X) break ;; esac
|
|
sleep 1
|
|
done
|
|
bash .gitea/scripts/ws-orphan-reap.sh instance "$DATABASE_URL" \
|
|
|| echo "::warning::instance-scoped workspace teardown did not complete — see above"
|
|
|
|
- name: Stop service containers
|
|
if: always()
|
|
run: |
|
|
docker rm -fv "$PG_CONTAINER" 2>/dev/null || true
|
|
docker rm -fv "$REDIS_CONTAINER" 2>/dev/null || true
|
|
|
|
- name: Teardown OUR ws-* workspace containers (positive identity, #2883 / #4346)
|
|
if: always()
|
|
run: |
|
|
# Remove exactly the workspace containers THIS run provisioned — the ids the
|
|
# e2e script recorded in the manifest as POST /workspaces handed them back.
|
|
# Nothing else on this daemon is ours to delete.
|
|
#
|
|
# This deletes by NAME (ws-<id>), never by "every ^ws- container I don't
|
|
# recognise". A concurrent run's workspace is not in our manifest, so it cannot
|
|
# be touched — which is the whole of #4346. The previous baseline-exclusion
|
|
# version protected us only from runs that started BEFORE us, and offered zero
|
|
# protection against the ones that start AFTER, which is the common case
|
|
# because this step runs at the END of the job.
|
|
#
|
|
# Anything this step misses entirely (the runner container SIGKILLed before it
|
|
# runs, so nothing here executes) is the job of the standing
|
|
# sweep-stale-ws-orphans.yml janitor. That is the right layer for it: it is
|
|
# AGE-GUARDED, so unlike this step it cannot delete a container that is still
|
|
# in use.
|
|
# Delete by EXACT NAME, and NEVER enumerate the daemon here. `docker rm`
|
|
# takes a container name directly, so the id from the manifest is all we
|
|
# need — there is no `docker ps`/`docker container ls` in this step at all.
|
|
# That is deliberate and load-bearing: the moment a teardown step LISTS the
|
|
# daemon (by any idiom — `--filter name=ws-`, `--format | grep ws-`,
|
|
# `-a | awk /ws-/`) it can select a CONCURRENT run's container, which is the
|
|
# entire #4346 failure. A destructive step that never lists cannot pick the
|
|
# wrong victim, and the guard (test_ws_teardown_positive_identity.py) enforces
|
|
# exactly that property rather than blacklisting listing spellings.
|
|
MANIFEST="/tmp/ws_owned_${{ github.run_id }}_${{ github.run_attempt }}.txt"
|
|
[ -f "$MANIFEST" ] || : > "$MANIFEST"
|
|
removed=0
|
|
while read -r wsid; do
|
|
[ -n "$wsid" ] || continue
|
|
echo "removing our workspace container: ws-${wsid}"
|
|
docker rm -f "ws-${wsid}" >/dev/null 2>&1 || true
|
|
docker volume rm -f "ws-${wsid}-configs" "ws-${wsid}-claude-sessions" "ws-${wsid}-workspace" >/dev/null 2>&1 || true
|
|
removed=$((removed + 1))
|
|
done < "$MANIFEST"
|
|
echo "ws-* teardown removed $removed of $(wc -l < "$MANIFEST" | tr -d ' ') owned container(s)."
|
|
rm -f "$MANIFEST" 2>/dev/null || true
|