Merge pull request #1247 from getlarge/moltnet/ed000a70-a362-4561-895… #2566
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) }} |