Skip to content

Merge pull request #1247 from getlarge/moltnet/ed000a70-a362-4561-895… #2566

Merge pull request #1247 from getlarge/moltnet/ed000a70-a362-4561-895…

Merge pull request #1247 from getlarge/moltnet/ed000a70-a362-4561-895… #2566

Workflow file for this run

name: CI
on:
push:
branches: [main]
paths-ignore:
- '.claude/**'
- '**.md'
pull_request:
branches: [main]
paths-ignore:
- '.claude/**'
- '**.md'
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# nx@22 unconditionally loads workspace-root .env at bin startup via plain
# dotenv. Our .env is dotenvx-encrypted, so plain-dotenv pulls ciphertext
# into every variable it defines, which then leaks into every process Nx
# spawns. This flag is the documented opt-out (honored by per-task and
# per-executor env loading); we set it workflow-wide as a defense even
# though nx@22.7.0's bin-level load currently ignores it. When the bin
# eventually honors it (or on a future major version), this guards us.
env:
NX_LOAD_DOT_ENV_FILES: 'false'
jobs:
# Compute the affected project set once and expose per-suite / per-image
# flags. Downstream jobs gate on these so e2e and image builds only run
# when the projects they cover are actually affected by the diff.
affected:
name: Compute affected
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
base: ${{ steps.shas-fallback.outputs.base }}
head: ${{ steps.shas-fallback.outputs.head }}
e2e-node: ${{ steps.compute.outputs.e2e-node }}
e2e-browser: ${{ steps.compute.outputs.e2e-browser }}
e2e-mcp: ${{ steps.compute.outputs.e2e-mcp }}
e2e-go-cli: ${{ steps.compute.outputs.e2e-go-cli }}
e2e-any: ${{ steps.compute.outputs.e2e-any }}
landing: ${{ steps.compute.outputs.landing }}
agent-daemon-action: ${{ steps.compute.outputs.agent-daemon-action }}
agents-matrix: ${{ steps.matrix.outputs.matrix }}
has-agents: ${{ steps.matrix.outputs.has-agents }}
tier: ${{ steps.matrix.outputs.tier }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 50
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22.19.0
cache: pnpm
- run: pnpm install --frozen-lockfile
- id: shas
uses: nrwl/nx-set-shas@v5
with:
main-branch-name: main
# Fallback: if nx-set-shas couldn't resolve a base SHA (e.g. no prior
# successful CI run on main matches the current workflow shape — typical
# when the workflow itself is being introduced or restructured), compute
# one ourselves. PRs use origin/<base_ref>; main pushes use HEAD~1.
- name: Fallback base SHA
id: shas-fallback
env:
INITIAL_BASE: ${{ steps.shas.outputs.base }}
INITIAL_HEAD: ${{ steps.shas.outputs.head }}
run: |
BASE="$INITIAL_BASE"
HEAD="$INITIAL_HEAD"
if [ -z "$BASE" ]; then
if [ "${{ github.event_name }}" = "pull_request" ]; then
git fetch origin "${{ github.base_ref }}" --depth=50
BASE=$(git rev-parse "origin/${{ github.base_ref }}")
else
BASE=$(git rev-parse HEAD~1)
fi
echo "::notice::nx-set-shas returned empty base; falling back to $BASE"
fi
if [ -z "$HEAD" ]; then
HEAD="${{ github.sha }}"
fi
echo "base=$BASE" >> "$GITHUB_OUTPUT"
echo "head=$HEAD" >> "$GITHUB_OUTPUT"
echo "NX_BASE=$BASE" >> "$GITHUB_ENV"
echo "NX_HEAD=$HEAD" >> "$GITHUB_ENV"
- name: Compute affected projects
id: compute
run: |
set -euo pipefail
# nx-set-shas / fallback exports NX_BASE/NX_HEAD as env vars; nx
# affected reads them automatically. --json for stable parsing.
PROJECTS=$(pnpm exec nx show projects --affected --json)
echo "Affected projects: $PROJECTS"
# Bash helper: 1 if any of the named projects are in $PROJECTS.
# $PROJECTS is a JSON array, so jq is the safe way to check.
contains() {
for p in "$@"; do
if echo "$PROJECTS" | jq -e --arg p "$p" 'index($p)' > /dev/null; then
return 0
fi
done
return 1
}
# Node e2e: rest-api + agent-daemon (REST/queue suites). mcp-server
# e2e moved to the dedicated MCP job below.
if contains "@moltnet/rest-api" "@themoltnet/agent-daemon"; then
echo "e2e-node=true" >> "$GITHUB_OUTPUT"
else
echo "e2e-node=false" >> "$GITHUB_OUTPUT"
fi
if contains "@moltnet/console"; then
echo "e2e-browser=true" >> "$GITHUB_OUTPUT"
else
echo "e2e-browser=false" >> "$GITHUB_OUTPUT"
fi
# MCP e2e: the mcp-server node suite + the mcp-host browser suite. Any
# change to the server, the host fixture, or an MCP app UI lib must
# exercise these. (Previously the mcp-host browser suite lived in the
# console-gated browser job and silently skipped for MCP-only PRs.)
if contains "@moltnet/mcp-server" "@moltnet/mcp-host" "@moltnet/mcp-host-e2e" "@moltnet/entry-explore-mcp-app" "@moltnet/task-mcp-app" "@moltnet/mcp-test-harness"; then
echo "e2e-mcp=true" >> "$GITHUB_OUTPUT"
else
echo "e2e-mcp=false" >> "$GITHUB_OUTPUT"
fi
if contains "moltnet-cli" "moltnet-api-client"; then
echo "e2e-go-cli=true" >> "$GITHUB_OUTPUT"
else
echo "e2e-go-cli=false" >> "$GITHUB_OUTPUT"
fi
# e2e-any: true iff any suite should run. The e2e stack images
# (rest-api, mcp-server, db-migrate, console, mcp-host) are shared by
# all suites, so this single flag gates the image builds.
if contains "@moltnet/rest-api" "@moltnet/mcp-server" "@moltnet/mcp-host" "@moltnet/mcp-host-e2e" "@moltnet/entry-explore-mcp-app" "@moltnet/task-mcp-app" "@moltnet/mcp-test-harness" "@themoltnet/agent-daemon" "@moltnet/console" "moltnet-cli" "moltnet-api-client"; then
echo "e2e-any=true" >> "$GITHUB_OUTPUT"
else
echo "e2e-any=false" >> "$GITHUB_OUTPUT"
fi
if contains "@moltnet/landing"; then
echo "landing=true" >> "$GITHUB_OUTPUT"
else
echo "landing=false" >> "$GITHUB_OUTPUT"
fi
# The composite GitHub Action ships its bundled dist/main.js
# checked in to git (canonical pattern — actions/checkout,
# actions/setup-node, actions/javascript-action). A dedicated
# job below rebuilds and diffs it whenever the action package
# is affected.
if contains "@themoltnet/agent-daemon-action"; then
echo "agent-daemon-action=true" >> "$GITHUB_OUTPUT"
else
echo "agent-daemon-action=false" >> "$GITHUB_OUTPUT"
fi
- name: Compute Nx Cloud agent matrix
id: matrix
run: node .github/scripts/agents-changeset-matrix.cjs
# Nx Cloud DTE agents. Each matrix slot is a GitHub-hosted runner that
# calls `nx-cloud start-agent` and shuts down when the orchestrator
# marks the run complete. Tests use testcontainers internally for any
# Postgres they need — no GHA `services:` block required. Agents only
# need Docker (which `ubuntu-latest` ships with).
agents:
name: 'Agent #${{ matrix.agent }}'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
needs: [affected]
if: needs.affected.outputs.has-agents == 'true'
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.affected.outputs.agents-matrix) }}
env:
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
NX_BASE: ${{ needs.affected.outputs.base }}
NX_HEAD: ${{ needs.affected.outputs.head }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 50
# NX_BASE points at a SHA on the base branch (typically main); the
# PR-branch checkout above doesn't include it. Fetch it explicitly
# so `nx affected` can `git diff $NX_BASE $NX_HEAD` without
# `fatal: bad object`.
- name: Fetch NX_BASE SHA
run: |
git fetch --no-tags origin "$NX_BASE" --depth=1 || \
git fetch --no-tags origin "${{ github.base_ref || 'main' }}" --depth=50
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22.19.0
cache: pnpm
- uses: actions/setup-go@v6
with:
go-version: '1.25'
- name: Install golangci-lint
# Pin via the published binary install (the official curl|sh
# installer hits GitHub API rate limits; `go install` is the
# documented fallback and uses GOPROXY caching, more reliable
# for high-frequency CI).
run: |
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- run: pnpm install --frozen-lockfile
# Same flake guard as orchestrator: nx-cloud lazy-downloads its
# runner bundle; cloud.nx.app sometimes 5xx's with a 10s timeout.
# Retry the agent start (which blocks on tasks anyway, so a real
# mid-run failure won't loop — only initial bundle fetches).
- name: Start Nx Cloud agent
run: |
for attempt in 1 2 3; do
if pnpm exec nx-cloud start-agent; then
exit 0
fi
elapsed=$SECONDS
# Only retry if we failed within the first 30s (bundle
# download window). After that, a failure means the agent
# is genuinely broken; don't loop.
if [ "$elapsed" -gt 30 ]; then
echo "::error::nx-cloud start-agent failed after $elapsed s — not retrying"
exit 1
fi
echo "::warning::nx-cloud start-agent attempt $attempt failed early ($elapsed s), retrying in $((attempt * 10))s..."
sleep $((attempt * 10))
done
echo "::error::nx-cloud start-agent failed after 3 attempts"
exit 1
# Orchestrator: opens the Nx Cloud DTE run, fans tasks to the agents,
# waits for completion. Replaces the legacy lint / typecheck / test
# jobs. `build` is included so we have a real correctness gate on the
# SSR/lib build outputs (Docker image builds remain in build-and-push).
orchestrator:
name: Orchestrator (lint, typecheck, test, build)
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
needs: [affected]
env:
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
NX_BASE: ${{ needs.affected.outputs.base }}
NX_HEAD: ${{ needs.affected.outputs.head }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 50
# NX_BASE points at a SHA on the base branch (typically main); the
# PR-branch checkout above doesn't include it. Fetch it explicitly
# so `nx affected` can `git diff $NX_BASE $NX_HEAD` without
# `fatal: bad object`.
- name: Fetch NX_BASE SHA
run: |
git fetch --no-tags origin "$NX_BASE" --depth=1 || \
git fetch --no-tags origin "${{ github.base_ref || 'main' }}" --depth=50
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22.19.0
cache: pnpm
- run: pnpm install --frozen-lockfile
# check:quickstart + nx format:check cover concerns not modeled in
# the affected graph (docs/skill drift, formatting). Run them on the
# orchestrator regardless of has-agents.
- name: Run check:quickstart
run: pnpm run check:quickstart
- name: Check Vite package aliases
run: pnpm run check:vite-aliases
- name: Check formatting
run: pnpm exec nx format:check
# The Nx Cloud CLI lazy-downloads its runner bundle from
# cloud.nx.app on first invocation. The download is sometimes
# transiently unavailable; retry the start command itself.
- name: Start Nx Cloud CI run
if: needs.affected.outputs.has-agents == 'true'
run: |
for attempt in 1 2 3; do
if pnpm exec nx-cloud start-ci-run \
--distribute-on=manual \
--assignment-rules=.nx/workflows/assignment-rules.yml \
--require-explicit-completion; then
exit 0
fi
echo "::warning::nx-cloud start-ci-run attempt $attempt failed, retrying in $((attempt * 10))s..."
sleep $((attempt * 10))
done
echo "::error::nx-cloud start-ci-run failed after 3 attempts"
exit 1
- name: Run affected lint, typecheck, test-ci, build
if: needs.affected.outputs.has-agents == 'true'
# test-ci is the atomized variant of test (one task per file
# via @nx/vitest). Projects without per-file atomization (Go,
# vitest libs without a config) declare test-ci as a noop
# depending on test, so a single -t test-ci covers everything.
run: pnpm exec nx affected -t lint typecheck test-ci build
- name: Complete Nx Cloud CI run
if: always() && needs.affected.outputs.has-agents == 'true'
run: pnpm exec nx-cloud complete-ci-run
code-analysis:
name: Code Analysis (Knip)
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22.19.0
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Run Knip (JSON report)
run: pnpm run knip --reporter json > knip-report.json 2>&1 || true
- name: Run Knip (human-readable)
id: knip
run: |
set +e
OUTPUT=$(pnpm run knip 2>&1)
EXIT_CODE=$?
set -e
# Strip the pnpm script header line
CLEAN_OUTPUT=$(echo "$OUTPUT" | grep -v '> moltnet@' | grep -v '> knip' | sed '/^$/N;/^\n$/d')
echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
{
echo "output<<KNIP_EOF"
echo "$CLEAN_OUTPUT"
echo "KNIP_EOF"
} >> "$GITHUB_OUTPUT"
- name: Comment on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v8
with:
script: |
const marker = '<!-- knip-report -->';
const exitCode = '${{ steps.knip.outputs.exit_code }}';
const output = `${{ steps.knip.outputs.output }}`;
let body;
if (exitCode === '0') {
body = `${marker}\n## :white_check_mark: Knip — No issues found\n\nNo unused code, dependencies, or exports detected.`;
} else {
body = [
marker,
'## :warning: Knip — Unused code or dependencies found',
'',
'Run `pnpm run knip` locally to see details, or `pnpm run knip:fix` to auto-fix some of them.',
'',
'<details>',
'<summary>Full report</summary>',
'',
'```',
output,
'```',
'',
'</details>',
].join('\n');
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Upload report
if: always()
uses: actions/upload-artifact@v7
with:
name: knip-report
path: knip-report.json
retention-days: 30
check-dist-agent-daemon-action:
name: agent-daemon-action dist sync
needs: [affected]
if: needs.affected.outputs.agent-daemon-action == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22.19.0
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Rebuild bundle
run: |
rm -rf packages/agent-daemon-action/dist
pnpm --filter @themoltnet/agent-daemon-action run build
- name: Diff against committed dist
run: |
if ! git diff --ignore-space-at-eol --text \
--exit-code packages/agent-daemon-action/dist/main.js; then
echo "::error::packages/agent-daemon-action/dist/main.js is out of sync with src/."
echo "::error::Run \`pnpm --filter @themoltnet/agent-daemon-action run build\` and commit the result."
exit 1
fi
# The diff itself is printed to the job log by the command above,
# and the error message tells the developer what to run locally
# to reproduce. No artifact upload — committing a 1.18 MB bundle
# on every drift adds GH Actions storage with no diagnostic value
# beyond the inline diff.
cli-go-mod-sync:
name: CLI go.mod Sync
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
- name: Check moltnet-cli Go module pins
id: sync
shell: bash
run: |
set -euo pipefail
EXPECTED_API=$(jq -r '."libs/moltnet-api-client"' .release-please-manifest.json)
EXPECTED_DSPY=$(jq -r '."libs/dspy-adapters"' .release-please-manifest.json)
CURRENT_API=$(grep 'github.com/getlarge/themoltnet/libs/moltnet-api-client' apps/moltnet-cli/go.mod | awk '{print $2}' | sed 's/^v//')
CURRENT_DSPY=$(grep 'github.com/getlarge/themoltnet/libs/dspy-adapters' apps/moltnet-cli/go.mod | awk '{print $2}' | sed 's/^v//')
STALE=false
MESSAGES=()
COMMANDS=()
if [ "$CURRENT_API" != "$EXPECTED_API" ]; then
STALE=true
MESSAGES+=("- \`moltnet-api-client\`: go.mod has \`v$CURRENT_API\`, expected \`v$EXPECTED_API\`")
COMMANDS+=("GOWORK=off go get github.com/getlarge/themoltnet/libs/moltnet-api-client@v$EXPECTED_API")
fi
if [ "$CURRENT_DSPY" != "$EXPECTED_DSPY" ]; then
STALE=true
MESSAGES+=("- \`dspy-adapters\`: go.mod has \`v$CURRENT_DSPY\`, expected \`v$EXPECTED_DSPY\`")
COMMANDS+=("GOWORK=off go get github.com/getlarge/themoltnet/libs/dspy-adapters@v$EXPECTED_DSPY")
fi
if [ "$STALE" = true ]; then
COMMANDS+=("GOWORK=off go mod tidy")
{
echo "stale=true"
echo "message<<MSG_EOF"
printf '%s\n' "${MESSAGES[@]}"
echo "MSG_EOF"
echo "commands<<CMD_EOF"
printf '%s\n' "${COMMANDS[@]}"
echo "CMD_EOF"
} >> "$GITHUB_OUTPUT"
echo "::warning::apps/moltnet-cli/go.mod is behind the release manifest."
printf '%s\n' "${MESSAGES[@]}"
exit 0
fi
echo "stale=false" >> "$GITHUB_OUTPUT"
- name: Comment on PR
if: github.event_name == 'pull_request' && always()
uses: actions/github-script@v8
env:
CLI_GO_MOD_STALE: ${{ steps.sync.outputs.stale }}
CLI_GO_MOD_MESSAGE: ${{ steps.sync.outputs.message }}
CLI_GO_MOD_COMMANDS: ${{ steps.sync.outputs.commands }}
with:
script: |
const marker = '<!-- cli-go-mod-sync -->';
const stale = process.env.CLI_GO_MOD_STALE;
const message = process.env.CLI_GO_MOD_MESSAGE || '';
const commands = process.env.CLI_GO_MOD_COMMANDS || '';
let body;
if (stale === 'true') {
body = [
marker,
'## :warning: CLI go.mod is behind internal Go module releases',
'',
'The CLI release is intentionally decoupled from same-run Go lib releases, so `apps/moltnet-cli/go.mod` must already be bumped in a normal PR.',
'',
'Detected drift:',
message,
'',
'Run these commands from `apps/moltnet-cli`:',
'',
'```bash',
commands,
'```',
].join('\n');
} else {
body = [
marker,
'## :white_check_mark: CLI go.mod matches internal Go module releases',
'',
'`apps/moltnet-cli/go.mod` is aligned with the versions tracked in `.release-please-manifest.json`.',
].join('\n');
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
audit:
name: Dependency Audit
runs-on: ubuntu-latest
timeout-minutes: 5
continue-on-error: true
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22.19.0
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Run audit
id: audit
run: |
set +e
OUTPUT=$(pnpm audit --prod 2>&1)
EXIT_CODE=$?
set -e
echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
{
echo "output<<AUDIT_EOF"
echo "$OUTPUT"
echo "AUDIT_EOF"
} >> "$GITHUB_OUTPUT"
pnpm audit --prod --json > audit-report.json 2>&1 || true
- name: Comment on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v8
env:
AUDIT_EXIT_CODE: ${{ steps.audit.outputs.exit_code }}
AUDIT_OUTPUT: ${{ steps.audit.outputs.output }}
with:
script: |
const marker = '<!-- audit-report -->';
const exitCode = process.env.AUDIT_EXIT_CODE;
const output = process.env.AUDIT_OUTPUT;
let body;
if (exitCode === '0') {
body = `${marker}\n## :white_check_mark: Dependency Audit — No vulnerabilities found\n\nNo known vulnerabilities in production dependencies.`;
} else {
body = [
marker,
'## :rotating_light: Dependency Audit — Vulnerabilities found',
'',
'<details>',
'<summary>Full report</summary>',
'',
'```',
output,
'```',
'',
'</details>',
].join('\n');
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Upload report
if: always()
uses: actions/upload-artifact@v7
with:
name: audit-report
path: audit-report.json
retention-days: 30
openapi:
name: OpenAPI Spec
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22.19.0
cache: pnpm
- uses: actions/setup-go@v6
with:
go-version: '1.25'
- run: pnpm install --frozen-lockfile
- name: Regenerate OpenAPI spec and client
run: pnpm run generate
- name: Check for uncommitted changes (TS)
run: |
if ! git diff --exit-code apps/rest-api/public/openapi.json; then
echo "::error::OpenAPI spec is out of date. Run 'pnpm run generate' and commit the changes."
exit 1
fi
if ! git diff --exit-code libs/api-client/src/generated/; then
echo "::error::Generated API client is out of date. Run 'pnpm run generate' and commit the changes."
exit 1
fi
- name: Regenerate Go API client
run: go generate ./libs/moltnet-api-client/...
- name: Check for uncommitted changes (Go)
run: |
if ! git diff --exit-code -- 'libs/moltnet-api-client/*.go'; then
echo "::error::Go API client is out of date. Run 'go generate ./libs/moltnet-api-client/...' and commit the changes."
exit 1
fi
# E2E jobs gate on `orchestrator` (lint+typecheck+test+build via Nx
# Cloud DTE) and the docker images they consume. They manage their own
# docker compose stack and stay outside DTE per issue #985 scope.
e2e-node:
name: E2E Tests (Node)
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
packages: read
needs: [affected, orchestrator, build-and-push]
if: needs.affected.outputs.e2e-node == 'true'
env:
REST_API_IMAGE: ghcr.io/getlarge/themoltnet/rest-api:ci-${{ github.sha }}
MCP_SERVER_IMAGE: ghcr.io/getlarge/themoltnet/mcp-server:ci-${{ github.sha }}
DB_MIGRATE_IMAGE: ghcr.io/getlarge/themoltnet/db-migrate:ci-${{ github.sha }}
CONSOLE_IMAGE: ghcr.io/getlarge/themoltnet/console:ci-${{ github.sha }}
MCP_HOST_IMAGE: ghcr.io/getlarge/themoltnet/mcp-host:ci-${{ github.sha }}
# Pre-set the URLs the e2e tests need. Without this, Nx's bin/nx.js
# loads workspace-root .env at startup; our .env is dotenvx-encrypted,
# so plain-dotenv pulls ciphertext into DATABASE_URL and pg silently
# falls back to its ::1:5432 default. Values mirror docker-compose.base
# host-port mappings.
DATABASE_URL: postgresql://moltnet:moltnet_secret@localhost:5433/moltnet
ORY_HYDRA_ADMIN_URL: http://localhost:4445
ORY_HYDRA_PUBLIC_URL: http://localhost:4444
ORY_KETO_PUBLIC_URL: http://localhost:4466
ORY_KETO_ADMIN_URL: http://localhost:4467
ORY_KRATOS_ADMIN_URL: http://localhost:4434
ORY_KRATOS_PUBLIC_URL: http://localhost:4433
SERVER_BASE_URL: http://localhost:8080
# Must match the value injected into the rest-api container in
# docker-compose.e2e.yaml. The test reads it from process.env to sign
# webhook calls; if the encrypted .env gets loaded first, the test
# uses ciphertext and the server returns 403.
ORY_ACTION_API_KEY: local-dev-webhook-key
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22.19.0
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Start Docker Compose stack
env:
COMPOSE_DISABLE_ENV_FILE: true
run: docker compose -f docker-compose.e2e.yaml -f docker-compose.e2e.ci.yaml up -d
# rest-api e2e MUST run first: its setup restarts the rest-api container
# (sponsor flow), which would invalidate any in-flight test in another
# suite running against the same stack.
- name: Run REST API E2E tests
run: pnpm exec nx run @moltnet/rest-api:e2e
- name: Run agent-daemon E2E tests
run: pnpm exec nx run @themoltnet/agent-daemon:e2e
- name: Docker Compose logs
if: failure()
run: docker compose -f docker-compose.e2e.yaml -f docker-compose.e2e.ci.yaml logs --tail=50
- name: Teardown Docker Compose
if: always()
run: docker compose -f docker-compose.e2e.yaml -f docker-compose.e2e.ci.yaml down -v --remove-orphans
e2e-browser:
name: E2E Tests (Browser)
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
packages: read
needs: [affected, orchestrator, build-and-push]
if: needs.affected.outputs.e2e-browser == 'true'
env:
REST_API_IMAGE: ghcr.io/getlarge/themoltnet/rest-api:ci-${{ github.sha }}
MCP_SERVER_IMAGE: ghcr.io/getlarge/themoltnet/mcp-server:ci-${{ github.sha }}
DB_MIGRATE_IMAGE: ghcr.io/getlarge/themoltnet/db-migrate:ci-${{ github.sha }}
CONSOLE_IMAGE: ghcr.io/getlarge/themoltnet/console:ci-${{ github.sha }}
MCP_HOST_IMAGE: ghcr.io/getlarge/themoltnet/mcp-host:ci-${{ github.sha }}
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22.19.0
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Get Playwright version
id: pw-version
run: echo "version=$(pnpm --silent --filter @moltnet/console exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: pw-cache
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ steps.pw-version.outputs.version }}
- name: Install Playwright browsers
if: steps.pw-cache.outputs.cache-hit != 'true'
run: pnpm --filter @moltnet/console exec playwright install chromium
- name: Install Playwright system deps
run: pnpm --filter @moltnet/console exec playwright install-deps chromium
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Start Docker Compose stack
env:
COMPOSE_DISABLE_ENV_FILE: true
run: docker compose -f docker-compose.e2e.yaml -f docker-compose.e2e.ci.yaml up -d
- name: Run Console Browser E2E tests
env:
KRATOS_PUBLIC_URL: http://localhost:4433
REST_API_URL: http://localhost:8080
CONSOLE_BASE_URL: http://localhost:5174
MAILSLURPER_API_URL: http://localhost:4437
run: pnpm exec nx run @moltnet/console:e2e
- name: Docker Compose logs
if: failure()
run: docker compose -f docker-compose.e2e.yaml -f docker-compose.e2e.ci.yaml logs --tail=50
- name: Teardown Docker Compose
if: always()
run: docker compose -f docker-compose.e2e.yaml -f docker-compose.e2e.ci.yaml down -v --remove-orphans
# MCP e2e: the mcp-server node suite + the mcp-host browser suite, against a
# dedicated copy of the compose stack. Split out of the Node/Browser jobs so a
# change to the MCP server/host/app libs triggers it (the mcp-host browser
# suite previously lived in the console-gated browser job and skipped for
# MCP-only PRs — see issue #1236).
e2e-mcp:
name: E2E Tests (MCP)
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
packages: read
needs: [affected, orchestrator, build-and-push]
if: needs.affected.outputs.e2e-mcp == 'true'
env:
REST_API_IMAGE: ghcr.io/getlarge/themoltnet/rest-api:ci-${{ github.sha }}
MCP_SERVER_IMAGE: ghcr.io/getlarge/themoltnet/mcp-server:ci-${{ github.sha }}
DB_MIGRATE_IMAGE: ghcr.io/getlarge/themoltnet/db-migrate:ci-${{ github.sha }}
CONSOLE_IMAGE: ghcr.io/getlarge/themoltnet/console:ci-${{ github.sha }}
MCP_HOST_IMAGE: ghcr.io/getlarge/themoltnet/mcp-host:ci-${{ github.sha }}
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22.19.0
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Get Playwright version
id: pw-version
run: echo "version=$(pnpm --silent --filter @moltnet/mcp-host-e2e exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: pw-cache
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ steps.pw-version.outputs.version }}
- name: Install Playwright browsers
if: steps.pw-cache.outputs.cache-hit != 'true'
run: pnpm --filter @moltnet/mcp-host-e2e exec playwright install chromium
- name: Install Playwright system deps
run: pnpm --filter @moltnet/mcp-host-e2e exec playwright install-deps chromium
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Start Docker Compose stack
env:
COMPOSE_DISABLE_ENV_FILE: true
run: docker compose -f docker-compose.e2e.yaml -f docker-compose.e2e.ci.yaml up -d
- name: Run MCP server E2E tests
env:
DATABASE_URL: postgresql://moltnet:moltnet_secret@127.0.0.1:5433/moltnet
MCP_SERVER_URL: http://127.0.0.1:8001
REST_API_URL: http://127.0.0.1:8080
ORY_HYDRA_PUBLIC_URL: http://127.0.0.1:4444
ORY_HYDRA_ADMIN_URL: http://127.0.0.1:4445
ORY_KETO_PUBLIC_URL: http://127.0.0.1:4466
ORY_KETO_ADMIN_URL: http://127.0.0.1:4467
ORY_KRATOS_PUBLIC_URL: http://127.0.0.1:4433
ORY_KRATOS_ADMIN_URL: http://127.0.0.1:4434
run: pnpm exec nx run @moltnet/mcp-server:e2e
- name: Run MCP Host Browser E2E tests
env:
BASE_URL: http://127.0.0.1:8082
MCP_SERVER_URL: http://127.0.0.1:8001
REST_API_URL: http://127.0.0.1:8080
DATABASE_URL: postgresql://moltnet:moltnet_secret@127.0.0.1:5433/moltnet
ORY_HYDRA_PUBLIC_URL: http://127.0.0.1:4444
ORY_HYDRA_ADMIN_URL: http://127.0.0.1:4445
ORY_KETO_PUBLIC_URL: http://127.0.0.1:4466
ORY_KETO_ADMIN_URL: http://127.0.0.1:4467
ORY_KRATOS_PUBLIC_URL: http://127.0.0.1:4433
ORY_KRATOS_ADMIN_URL: http://127.0.0.1:4434
run: pnpm exec nx run @moltnet/mcp-host-e2e:e2e
- name: Docker Compose logs
if: failure()
run: docker compose -f docker-compose.e2e.yaml -f docker-compose.e2e.ci.yaml logs --tail=50
- name: Teardown Docker Compose
if: always()
run: docker compose -f docker-compose.e2e.yaml -f docker-compose.e2e.ci.yaml down -v --remove-orphans
# Go CLI e2e runs in parallel with the Node and browser e2e jobs. It brings
# up its own copy of the compose stack (~45s overhead) but does not contend
# with the rest-api restart sequence, saving wall-clock time on the critical
# path.
e2e-go-cli:
name: E2E Tests (Go CLI)
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
packages: read
needs: [affected, orchestrator, build-and-push]
if: needs.affected.outputs.e2e-go-cli == 'true'
env:
REST_API_IMAGE: ghcr.io/getlarge/themoltnet/rest-api:ci-${{ github.sha }}
MCP_SERVER_IMAGE: ghcr.io/getlarge/themoltnet/mcp-server:ci-${{ github.sha }}
DB_MIGRATE_IMAGE: ghcr.io/getlarge/themoltnet/db-migrate:ci-${{ github.sha }}
CONSOLE_IMAGE: ghcr.io/getlarge/themoltnet/console:ci-${{ github.sha }}
MCP_HOST_IMAGE: ghcr.io/getlarge/themoltnet/mcp-host:ci-${{ github.sha }}
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 22.19.0
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: actions/setup-go@v6
with:
go-version: '1.25'
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Start Docker Compose stack
env:
COMPOSE_DISABLE_ENV_FILE: true
run: docker compose -f docker-compose.e2e.yaml -f docker-compose.e2e.ci.yaml up -d
- name: Run Go CLI E2E tests
env:
API_URL: http://localhost:8080
run: go test -tags e2e -v -timeout 120s ./apps/moltnet-cli/...
- name: Docker Compose logs
if: failure()
run: docker compose -f docker-compose.e2e.yaml -f docker-compose.e2e.ci.yaml logs --tail=50
- name: Teardown Docker Compose
if: always()
run: docker compose -f docker-compose.e2e.yaml -f docker-compose.e2e.ci.yaml down -v --remove-orphans
go-otel-extension:
# The oryintrospectionauth extension deliberately lives OUTSIDE go.work
# (it's build-time infra for OCB, not part of the app's module graph).
# Tests there are not picked up by the `go` job above, which walks the
# workspace. Run them with GOWORK=off from their own directory so any
# future refactor that breaks them shows up in CI.
name: Go OTel extension
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.25'
- name: Test oryintrospectionauthextension
working-directory: infra/otel/custom-collector/oryintrospectionauthextension
env:
GOWORK: 'off'
run: go test -race ./...
skill-check:
name: Skill Validation
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- name: Validate SKILL.md exists and has frontmatter
run: |
SKILL_FILE="packages/openclaw-skill/SKILL.md"
if [ ! -f "$SKILL_FILE" ]; then
echo "::error::SKILL.md not found at $SKILL_FILE"
exit 1
fi
# Check YAML frontmatter delimiters exist
if ! head -1 "$SKILL_FILE" | grep -q '^---$'; then
echo "::error::SKILL.md missing YAML frontmatter (expected '---' on line 1)"
exit 1
fi
echo "SKILL.md: OK"
- name: Validate mcp.json is valid JSON
run: |
MCP_FILE="packages/openclaw-skill/mcp.json"
if [ ! -f "$MCP_FILE" ]; then
echo "::error::mcp.json not found at $MCP_FILE"
exit 1
fi
if ! python3 -m json.tool "$MCP_FILE" > /dev/null 2>&1; then
echo "::error::mcp.json is not valid JSON"
exit 1
fi
echo "mcp.json: OK"
- name: Validate version.txt
run: |
VERSION_FILE="packages/openclaw-skill/version.txt"
if [ ! -f "$VERSION_FILE" ]; then
echo "::error::version.txt not found"
exit 1
fi
VERSION=$(cat "$VERSION_FILE" | tr -d '[:space:]')
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::version.txt must contain a valid semver (got: '$VERSION')"
exit 1
fi
echo "version.txt: $VERSION OK"
- name: Test tarball packaging
run: |
packages/openclaw-skill/scripts/package.sh /tmp
tar -tzf /tmp/moltnet-skill-v*.tar.gz | sort
build-and-push:
name: Build & Push (${{ matrix.image }})
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
packages: write
needs: [affected]
# Per-image gating: rest-api/mcp-server/db-migrate/console are needed iff
# any e2e suite runs (they all share the compose stack). landing has no
# e2e consumer, so it's gated independently on whether the landing project
# is affected. The matrix `needed` field carries the per-image condition.
if: needs.affected.outputs.e2e-any == 'true' || needs.affected.outputs.landing == 'true'
strategy:
fail-fast: false
matrix:
include:
# rest-api and mcp-server use registry cache: their layers are large
# (full pnpm deps tree, native binaries) and GHA cache is too slow /
# size-limited. Registry cache survives across branches.
- image: rest-api
project: '@moltnet/rest-api'
dockerfile: apps/rest-api/Dockerfile
cache-ref: ghcr.io/getlarge/themoltnet/rest-api:buildcache
needed-when: e2e-any
- image: mcp-server
project: '@moltnet/mcp-server'
dockerfile: apps/mcp-server/Dockerfile
cache-ref: ghcr.io/getlarge/themoltnet/mcp-server:buildcache
needed-when: e2e-any
# Smaller images stay on GHA cache (good enough, no extra GHCR tag).
- image: db-migrate
project: '@moltnet/database'
dockerfile: libs/database/Dockerfile
needed-when: e2e-any
- image: landing
project: '@moltnet/landing'
dockerfile: apps/landing/Dockerfile
needed-when: landing
- image: console
project: '@moltnet/console'
dockerfile: apps/console/Dockerfile
needed-when: e2e-any
- image: mcp-host
project: '@moltnet/mcp-host'
dockerfile: apps/mcp-host/Dockerfile
needed-when: e2e-any
steps:
- name: Skip if image not needed
id: gate
run: |
NEEDED="${{ matrix.needed-when == 'e2e-any' && needs.affected.outputs.e2e-any || needs.affected.outputs.landing }}"
echo "needed=$NEEDED" >> "$GITHUB_OUTPUT"
if [ "$NEEDED" != "true" ]; then
echo "Image ${{ matrix.image }} not affected, skipping build."
fi
- uses: actions/checkout@v6
if: steps.gate.outputs.needed == 'true'
# Host-built artifacts (Nx Cloud-cached) that the Dockerfile COPYs into
# the image — e.g. apps/<app>/dist/, apps/rest-api/dist/models/, etc.
# See AGENTS.md → "Docker image contract" and issue #1223.
- uses: pnpm/action-setup@v5
if: steps.gate.outputs.needed == 'true'
- uses: actions/setup-node@v6
if: steps.gate.outputs.needed == 'true'
with:
node-version: 22.19.0
cache: pnpm
- name: Install dependencies
if: steps.gate.outputs.needed == 'true'
env:
MOLTNET_SKIP_NX_SYNC: '1'
HUSKY: '0'
run: pnpm install --frozen-lockfile
- name: Build host artifacts for ${{ matrix.project }}
if: steps.gate.outputs.needed == 'true'
env:
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
NX_BASE: ${{ needs.affected.outputs.base }}
NX_HEAD: ${{ needs.affected.outputs.head }}
# Materialize what apps/<app>/Dockerfile COPYs into the image. The
# docker:build target's dependsOn chain (build, build:migrate,
# download-model, …) is fanned out here as -t targets; Nx skips
# targets a project doesn't define. `docker build` itself runs in the
# next step via docker/build-push-action (cache + push semantics).
run: pnpm exec nx run-many -t build build:migrate download-model --projects="${{ matrix.project }}"
- name: Set up Docker Buildx
if: steps.gate.outputs.needed == 'true'
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
if: steps.gate.outputs.needed == 'true'
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push image
if: steps.gate.outputs.needed == 'true'
uses: docker/build-push-action@v7
with:
context: .
file: ${{ matrix.dockerfile }}
push: true
tags: ghcr.io/getlarge/themoltnet/${{ matrix.image }}:ci-${{ github.sha }}
cache-from: ${{ matrix.cache-ref && format('type=registry,ref={0}', matrix.cache-ref) || format('type=gha,scope={0}', matrix.image) }}
cache-to: ${{ matrix.cache-ref && format('type=registry,ref={0},mode=max', matrix.cache-ref) || format('type=gha,scope={0},mode=max', matrix.image) }}