name: CI on: pull_request: push: branches: [main] # Callable, so publishing a release runs these same checks against the release commit rather than # trusting that they ran somewhere earlier. workflow_call: # Least privilege by default. Jobs that need more must declare it locally. permissions: contents: read # OpenBot's platform lives in openbot/, so every command runs there unless a step says otherwise. defaults: run: working-directory: openbot # The newest push on a branch wins; main runs are retained for badge and release history. concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: # Formatting, linting, typing and tests are independent checks, so each reports its own result. static: name: format, lint, types runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Checkout leaves a usable credential in the runner otherwise, which every later step and # every action it calls can read. Nothing here pushes. persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.3 - run: bun install --frozen-lockfile - run: bun run format:check - run: bun run lint - run: bun run typecheck # `typecheck` is `bun run --filter '*' typecheck`, and each workspace's `tsc --noEmit` reads # its own tsconfig, none of which includes a test tree. The suite is therefore the one part # of this repository that no type-checker has ever read: bun erases types rather than # checking them, so a test could pass an options object where a connection string belongs, # or import a type from a path that does not exist and silently get `any`, and every check # here stayed green. This reads `server/tests` under the server's own compiler options and # fails on any error that is not in `server/tests/typecheck-baseline.txt` — the errors that # were already there when the check was turned on. New ones are new defects. - name: server/tests types (baselined) run: bun run typecheck:tests - name: Desktop version tracks the release version run: bun desktop/scripts/desktop-version.ts check # The chart's default image tag is its appVersion, and a release must move both together. They # drifted once, appVersion left pointing at a tag that was never published, and a default # `helm install` sat in ImagePullBackOff. This refuses a tree where package.json (the last # version published) and the chart's appVersion have come apart. - name: Chart appVersion tracks the release version run: | pkg=$(bun -e 'console.log(require("./package.json").version)') chart=$(bun -e 'console.log(require("yaml").parse(require("fs").readFileSync("charts/openbot/Chart.yaml","utf8")).appVersion)') if [ "$pkg" != "$chart" ]; then echo "::error::package.json is $pkg but charts/openbot/Chart.yaml appVersion is $chart. The release workflow moves them together; bring them back into line." exit 1 fi # The two packages that are deployables in their own right rather than root workspaces. Root # `typecheck` is `bun run --filter '*' typecheck`, and `--filter '*'` enumerates `workspaces`, which # is app, server and worker. So both of these ship a `typecheck` script that nothing has ever run: # `agent-computer` holds the only `spawn` in the deployment and `supervisor` is the only thing # holding a Docker socket, which is a poor pair to leave untyped. Both are clean today, so this # changes nothing about main and only stops the next change being the first one a type checker sees. # # Not by adding them to `workspaces`: each is built from its own lockfile and the Dockerfile depends # on that, so they are installed separately here for the same reason they are installed separately # there. A matrix rather than two jobs, so each package reports its own result and a third one is a # line in the list below and nothing in `verify`, which sees the whole matrix as one entry. deployables: name: types (${{ matrix.package }}) runs-on: ubuntu-latest strategy: # One red package must not hide whether the other is red too. fail-fast: false matrix: package: [agent-computer, supervisor] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.3 # Two installs, and the root one is load-bearing. Both packages set `types: ["bun"]` in # their tsconfig while `@types/bun` is a root devDependency, so tsc resolves it by walking up # to the root node_modules. Without it the typecheck fails with TS2688 on a fresh checkout. # This is the order the image uses for the same reason: Dockerfile installs at the root # before installing agent-computer. - run: bun install --frozen-lockfile - run: bun install --frozen-lockfile working-directory: openbot/${{ matrix.package }} - run: bun run typecheck working-directory: openbot/${{ matrix.package }} mobile: name: native types runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '22' cache: npm cache-dependency-path: openbot/mobile/package-lock.json # The native app is an isolated npm package; adding it to Bun workspaces would change the # existing desktop/server installation contract. CI installs its own reviewed lockfile. - run: npm ci --prefix mobile --ignore-scripts - run: npm run --prefix mobile typecheck # The agent-computer tests that launch a real Chromium. They are asked for by name because a # machine running `bun test` is not required to have a browser, and these are the only tests that # would catch a password reaching the snapshot a model reads, a session cookie lost on restart, or # WebRTC leaving around the egress filter. The browser is the one the computer image installs, # pinned by agent-computer's own lockfile. computer-browser: name: computer (real browser) runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.3 - run: bun install --frozen-lockfile - run: bun install --frozen-lockfile working-directory: openbot/agent-computer - run: bunx playwright install --with-deps chromium working-directory: openbot/agent-computer # Headless here: these launch through the profile path without the computer's own X server. # The headless run of the Web Bot Auth test is also the one that checks the user agent fix. - run: >- bun test tests/sign-in.test.ts tests/session-cookies.test.ts tests/follows-popup.test.ts tests/demonstration-live.test.ts tests/webrtc-proxy.test.ts tests/web-bot-auth-browser.test.ts tests/handoff-page-restore.test.ts working-directory: openbot/agent-computer env: OPENBOT_COMPUTER_BROWSER: '1' OPENBOT_DEMONSTRATION_LIVE: '1' COMPUTER_BROWSER_MODE: headless # Headed is the default for hosted computers, so it is tested as one: the computer starting its # own Xvfb, and the signing again with cross-site frames in processes of their own. - run: bun test tests/headed-browser.test.ts working-directory: openbot/agent-computer env: OPENBOT_HEADED_BROWSER: '1' - run: xvfb-run --auto-servernum bun test tests/web-bot-auth-browser.test.ts working-directory: openbot/agent-computer env: OPENBOT_COMPUTER_BROWSER: '1' COMPUTER_BROWSER_MODE: headed chart: name: chart (${{ matrix.target }}) runs-on: ubuntu-latest strategy: # One red target must not hide whether another is red too. fail-fast: false matrix: target: [self-hosted, eks, eks-sandbox, gke, aks] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false # The new-values-key check compares this chart against the last released one, or against # main where the chart has not shipped yet. A shallow clone has neither to compare with. fetch-depth: 0 - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 with: version: v3.19.0 # For the coherence check below, which is a Bun script like everything else here. - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.3 # Nothing rendered this chart until now, which is how four values files that produce a server # unable to start were shipped and stayed shipped. Rendering is the cheap half; the refusals in # validation.yaml are the half that catches a missing value before a cluster does. - run: helm dependency build charts/openbot # Structure only. `helm lint` reports a template `fail` as an INFO line and exits 0 even under # `--strict`, which was driven and confirmed, so it cannot gate the refusals below. Rendering # does: `helm template` exits non-zero on one. - run: helm lint charts/openbot --values charts/openbot/ci/${{ matrix.target }}-values.yaml # A key encryption key is a real 32 bytes rather than a placeholder, because the chart checks # its shape. Generated here so no example key is ever a literal in this repository. - name: Render run: | helm template ci charts/openbot \ --values charts/openbot/ci/${{ matrix.target }}-values.yaml \ --set-string secrets.keyEncryptionKey="$(openssl rand -base64 32)" \ --api-versions agents.x-k8s.io/v1beta1/Sandbox \ --api-versions extensions.agents.x-k8s.io/v1beta1/SandboxTemplate \ > rendered.yaml # Rendering proves the templates run. This proves the result is coherent, which is a different # question: every secret key a container demands has to be one the chart actually writes. # Getting that wrong is invisible until a pod starts, and every shipped target had it wrong. - run: bun scripts/check-rendered-chart.ts rendered.yaml # No target names a Learning credential, so the setup Job renders nowhere above. Render it with # one of each, and hold it to the same coherence check. - name: Learning setup Job renders run: | helm template ci charts/openbot \ --values charts/openbot/ci/${{ matrix.target }}-values.yaml \ --set-string secrets.keyEncryptionKey="$(openssl rand -base64 32)" \ --api-versions agents.x-k8s.io/v1beta1/Sandbox \ --api-versions extensions.agents.x-k8s.io/v1beta1/SandboxTemplate \ --set-string config.learning.setup.cliLoginSecret.name=openbot-copilotkit-cli \ --set-string config.learning.setup.serviceTokenSecret.name=intelligence-service-token \ --set-string config.learning.setup.projectId=7 \ > rendered-learning.yaml grep -q 'app.kubernetes.io/component: learning-setup' rendered-learning.yaml bun scripts/check-rendered-chart.ts rendered-learning.yaml # And that the refusals are load-bearing rather than decorative. A chart full of `fail` # messages nothing ever triggers is a chart that has never been shown to refuse anything, and # every one of these describes a state that shipped in a values file at some point. - name: Refusals fire run: | set -uo pipefail refuses() { local why="$1"; shift if helm template ci charts/openbot \ --values charts/openbot/ci/${{ matrix.target }}-values.yaml \ --set-string secrets.keyEncryptionKey="$(openssl rand -base64 32)" \ --api-versions agents.x-k8s.io/v1beta1/Sandbox \ --api-versions extensions.agents.x-k8s.io/v1beta1/SandboxTemplate \ "$@" >/dev/null 2>&1; then echo "::error::The chart rendered $why, which it is supposed to refuse." return 1 fi echo "refused: $why" } # The public example key, which the server will not start with. Only where this chart # holds the secret: with a store, the value is not readable at template time, so the # refusal is deliberately not armed and asserting it here would be asserting a bug. if ! grep -qE '^ *enabled: true' <(sed -n '/^externalSecrets:/,/^[a-z]/p' charts/openbot/ci/${{ matrix.target }}-values.yaml); then refuses "the public example encryption key" \ --set-string secrets.keyEncryptionKey="$(head -c 32 /dev/zero | base64)" else echo "skipped: the example-key refusal is not armed when the secret comes from a store" fi # A Bot endpoint with nothing on the request that says who is calling. Armed whether the # secret is this chart's or a store's, because the key list is readable either way. refuses "a managed agent URL with no token" \ --set-string config.managedAgent.url=http://agent.default:8000/ag-ui # A browser inside every replica of a replicated API. refuses "an embedded browser across several replicas" \ --set server.embeddedComputer=true --set server.replicaCount=2 # A Bot's egress proxy on a port the computer's own network policy does not allow. The # variables reach the computer through extraEnv, so nothing else notices that the policy # then refuses to let it be reached. refuses "an egress proxy the network policy blocks" \ --set networkPolicy.enabled=true \ --set computers.extraEnv[0].name=EGRESS_PROXY_DEFAULT \ --set-string computers.extraEnv[0].value=http://proxy.internal:3128 # The residential proxy setting, whose address is a secret the template cannot read, with # network policies on and no path opened to it. refuses "a residential proxy with no path through the network policy" \ --set networkPolicy.enabled=true --set computers.egressProxy.enabled=true \ --set-string secrets.egressProxyUrl=http://user:pass@gateway.example:8000 # The same setting switched on with no URL behind it, where this chart holds the secret. if ! grep -qE '^ *enabled: true' <(sed -n '/^externalSecrets:/,/^[a-z]/p' charts/openbot/ci/${{ matrix.target }}-values.yaml) \ && ! grep -qE '^ *existingSecret: [^"]' <(sed -n '/^secrets:/,/^[a-z]/p' charts/openbot/ci/${{ matrix.target }}-values.yaml); then refuses "a residential proxy switched on with no URL" \ --set computers.egressProxy.enabled=true else echo "skipped: the proxy URL refusal is armed only where this chart writes the Secret" fi # A delivery or directory switch with no secret behind it. Armed on every target: the chart # requires the value when it holds the secret, and requires the store to name the key when # a store does. refuses "OpenTag switched on with no shared secret" \ --set config.opentag.enabled=true refuses "SCIM switched on with no bearer token" \ --set config.scim.enabled=true refuses "a Learning service token with no project to create the container in" \ --set-string config.learning.setup.serviceTokenSecret.name=intelligence-service-token # The rest are refusals the server makes at boot, mirrored so the value is named at install. refuses "an OpenTag URL with the pairing switched off" \ --set-string config.opentag.url=https://opentag.example.com refuses "a partial set of Twilio settings" \ --set config.sms.enabled=true --set-string secrets.twilioAuthToken=token \ --set-string config.sms.accountSid=AC0 --set-string config.sms.verifyServiceSid=VA0 refuses "a push project id that is not a UUID" \ --set-string config.push.projectId=not-a-uuid refuses "a delivery public URL that is not http(s)" \ --set-string config.deliveryPublicUrl=hooks.example.com # A warm pool nothing claims from. Only meaningful where the target asks for per-Bot # computers; on the others the mode is not sandbox and the refusal is not armed. if grep -qE '^ *mode: sandbox' charts/openbot/ci/${{ matrix.target }}-values.yaml; then refuses "a warm pool no Bot can be handed a computer from" \ --set computers.sandbox.warmPool.enabled=true else echo "skipped: the warm-pool refusal is only armed for computers.mode: sandbox" fi # And that a values key this chart did not used to have still renders when it is absent. # # `helm upgrade --reuse-values` takes the previous release's computed values rather than # merging the new chart's defaults, so a key added by the release being installed is missing on # every deployment that already exists. Unguarded that is a nil dereference that fails the # whole render, or an empty scalar Kubernetes reads as unset. Both shipped: one was found in # review, the other by a live upgrade after the first had been fixed one key over. - name: A new values key can be absent run: bun scripts/check-new-values-keys.ts charts/openbot/ci/${{ matrix.target }}-values.yaml test: name: tests runs-on: ubuntu-latest # The suite includes a real database integration test. Without a database it fails on every run, # including on main, which trains everyone to read a red CI as normal. pgvector rather than plain # postgres because the knowledge schema uses the extension. services: postgres: image: pgvector/pgvector:pg17 env: POSTGRES_DB: openbot_test POSTGRES_USER: openbot POSTGRES_PASSWORD: openbot ports: - 5432:5432 options: >- --health-cmd "pg_isready -U openbot -d openbot_test" --health-interval 5s --health-timeout 5s --health-retries 10 env: DATABASE_URL: postgres://openbot:openbot@localhost:5432/openbot_test TEST_DATABASE_URL: postgres://openbot:openbot@localhost:5432/openbot_test steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.3 - run: bun install --frozen-lockfile # The Bots are not root workspaces and each keeps its own lockfile. The startup tests spawn # the real entrypoint, so its dependencies have to be installed as well. - run: bun install --frozen-lockfile working-directory: openbot/agent-bot # Same for the LangGraph Bot: its history tests import @langchain/core, which lives in that # Bot's own tree and not in the root one. - run: bun install --frozen-lockfile working-directory: openbot/agent-langgraph # Same for the computer: its Web Bot Auth tests import `web-bot-auth`, which lives in # agent-computer's own tree and not in the root one. - run: bun install --frozen-lockfile working-directory: openbot/agent-computer # Same for the Mastra Bot: root test discovery imports its receiver tests, which load Mastra # from that Bot's own dependency tree. - run: bun install working-directory: openbot/agent-mastra # Same for the desktop app: root test discovery imports its React tests, whose JSX runtime is # pinned by the desktop lockfile rather than the root one. - run: bun install --frozen-lockfile working-directory: openbot/desktop # The same migrator a deployment runs, rather than `drizzle-kit migrate`: it is the one that # refuses a database holding a stamp newer than the migrations it is about to walk past # (server/src/db/migration-reach.ts), and a CI database that is ever reused rather than # recreated is one of the places that can happen. Not the db:migrate script, which loads # ../.env; that file does not exist in CI, and DATABASE_URL comes from the job env instead. - run: bun scripts/migrate.ts working-directory: openbot/server # A passing job must include the expected test floor. Import-time failures can otherwise skip # files before their tests are registered. - run: bun run test:ci startup: name: startup (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [macos-latest, windows-latest] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.3 - run: bun install --frozen-lockfile - run: bun test app/tests/build-cache.test.ts python-harness: name: python harness regressions runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.3 - name: CrewAI provider-boundary regression run: | set -euo pipefail python -m venv .venv-crewai . .venv-crewai/bin/activate python -m pip install --requirement agent-crewai/requirements.txt --requirement agent-crewai/requirements-test.txt python -m pytest agent-crewai/tests -q - name: Claude client-tool continuation regressions run: | set -euo pipefail python -m venv .venv-claude-sdk . .venv-claude-sdk/bin/activate python -m pip install --requirement agent-claude-sdk/requirements.txt --requirement agent-claude-sdk/requirements-test.txt python -m pytest agent-claude-sdk/tests -q - name: LangGraph AG-UI provider-boundary regressions run: | set -euo pipefail python -m venv .venv-langgraph-agui . .venv-langgraph-agui/bin/activate python -m pip install --requirement agent-langgraph-agui/requirements.txt --requirement agent-langgraph-agui/requirements-test.txt python -m pytest agent-langgraph-agui/tests -q - name: LlamaIndex model-choice regression run: | set -euo pipefail python -m venv .venv-llamaindex . .venv-llamaindex/bin/activate python -m pip install --requirement agent-llamaindex/requirements.txt --requirement agent-llamaindex/requirements-test.txt python -m pytest agent-llamaindex/tests -q - name: Agno model-choice regression run: | set -euo pipefail python -m venv .venv-agno . .venv-agno/bin/activate python -m pip install --requirement agent-agno/requirements.txt --requirement agent-agno/requirements-test.txt python -m pytest agent-agno/tests -q - name: Strands model-choice regression run: | set -euo pipefail python -m venv .venv-strands . .venv-strands/bin/activate python -m pip install --requirement agent-strands/requirements.txt --requirement agent-strands/requirements-test.txt python -m pytest agent-strands/tests -q - name: Pydantic AI model-choice regression run: | set -euo pipefail python -m venv .venv-pydantic-ai . .venv-pydantic-ai/bin/activate python -m pip install --requirement agent-pydantic-ai/requirements.txt --requirement agent-pydantic-ai/requirements-test.txt python -m pytest agent-pydantic-ai/tests -q - name: Google ADK tool-call regressions run: | set -euo pipefail python -m venv .venv-adk . .venv-adk/bin/activate python -m pip install --requirement agent-adk/requirements.txt --requirement agent-adk/requirements-test.txt python -m pytest agent-adk/tests -q - name: Antigravity provider pin and token gate run: | set -euo pipefail python -m venv .venv-antigravity . .venv-antigravity/bin/activate python -m pip install --requirement agent-antigravity/requirements.txt --requirement agent-antigravity/requirements-test.txt python -m pytest agent-antigravity/tests -q - name: Langroid model-choice and tool-call regressions run: | set -euo pipefail python -m venv .venv-langroid . .venv-langroid/bin/activate python -m pip install --requirement agent-langroid/requirements.txt --requirement agent-langroid/requirements-test.txt python -m pytest agent-langroid/tests -q - name: AG2 model-choice regression run: | set -euo pipefail python -m venv .venv-ag2 . .venv-ag2/bin/activate python -m pip install --requirement agent-ag2/requirements.txt --requirement agent-ag2/requirements-test.txt python -m pytest agent-ag2/tests -q - name: Microsoft Agent Framework model-choice regression run: | set -euo pipefail python -m venv .venv-microsoft . .venv-microsoft/bin/activate python -m pip install --requirement agent-microsoft/requirements.txt --requirement agent-microsoft/requirements-test.txt python -m pytest agent-microsoft/tests -q - run: bun install --frozen-lockfile - run: bun test tests/compose.test.ts - run: docker compose --env-file /dev/null --profile harness config --format json >/dev/null env: PICKED_HARNESS_IMAGE: openbot-agent-langgraph-agui:test build: name: build runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.3 - run: bun install --frozen-lockfile - run: bun run build # Migration files and the schema they were generated from, checked against each other. A snapshot # that has drifted from the schema produces a migration nobody wrote, applied to somebody's # database on their next deploy. Neither command needs a running database, only the config. migrations: name: migrations runs-on: ubuntu-latest env: # drizzle.config.ts refuses to load without it. Nothing here connects. DATABASE_URL: postgres://openbot:openbot@localhost:5432/openbot steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.3 - run: bun install --frozen-lockfile # Collisions and gaps between the migration files themselves. - run: bunx drizzle-kit check --config=drizzle.config.ts working-directory: openbot/server # And the other direction: a schema change with no migration written for it. `generate` writes a # file when it finds one, so the tree being dirty afterwards is the failure. - name: Schema has no unwritten migration working-directory: openbot/server run: | set -euo pipefail bunx drizzle-kit generate --config=drizzle.config.ts --name=ci_drift_probe if [ -n "$(git status --porcelain drizzle)" ]; then echo "::error::The schema has changed without a migration. Run drizzle-kit generate and commit it." git status --porcelain drizzle exit 1 fi # The image is the artefact people deploy, and almost nothing about whether it works is visible to # the checks above. A dangling symlink, a supervised service that exits, a missing binary: all of # them typecheck, lint and test perfectly. image: name: image runs-on: ubuntu-latest # The only slow job: it builds the container and boots it, ~14 minutes. The four checks above are # seconds and stay on every push, because they catch most things cheaply. This one is the cost, so # on a pull request it runs only when an admin adds the `full-ci` label — the point at which the PR # is actually a merge candidate. On main and through the release workflow_call it always runs, so # nothing reaches a release without it. `verify` treats a skipped job as passing, so an unlabelled # PR is green on the cheap checks alone. if: >- github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ci') steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Make room for the image build and Docker import run: | set -euo pipefail # BuildKit retains its layers while load: true unpacks another copy into Docker. # This job uses neither the runner's Android SDK nor its .NET installation. df -h / sudo rm -rf /usr/local/lib/android /usr/share/dotnet df -h / - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 # Loaded rather than pushed: this runs on pull requests, including from forks, and it proves # the image builds without granting anything the ability to publish one. - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: openbot load: true tags: openbot:ci cache-from: type=gha cache-to: type=gha,mode=max # Building it says the layers resolve. Running it says the supervision tree comes up and stays # up, which is a different claim and the one that has broken before. - name: The image boots and serves run: | set -euo pipefail # `OPENBOT_SINGLE_USER=true` because this boots a deployment with no identity provider, and # the image sets NODE_ENV=production, where that combination refuses to start rather than # serve an open deployment. Saying so is the point: the flag is how somebody declares they # meant it. This was `OPENBOT_DEV_NO_AUTH=1` before, which the code never accepted at all, # since it compares against the exact string "true"; it did nothing and nothing noticed. # # Placeholders, not secrets. `loadConfig` refuses to start without Intelligence and a # licence configured, but it only checks that they are present and well-formed; nothing is # contacted at start-up and /api/capabilities reads config alone. So this proves the image # boots and serves without needing a licence, which is the part CI cannot have: a licence # is bound to the machine it was issued for. Whether Intelligence actually answers is what # the smoke journey checks, on a real deployment. docker run -d --name openbot-ci -p 3001:3001 \ -e EMBEDDED_POSTGRES=on \ -e KEY_ENCRYPTION_KEY="$(openssl rand -base64 32)" \ -e TRUSTED_ORIGINS=http://localhost:3001 \ -e OPENBOT_SINGLE_USER=true \ -e INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai \ -e INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai \ -e INTELLIGENCE_API_KEY=ci-not-a-real-key \ openbot:ci for attempt in $(seq 1 150); do if curl -fsS http://localhost:3001/api/capabilities >/dev/null 2>&1; then echo "answered after ${attempt}s" exit 0 fi if [ -z "$(docker ps -q -f name=openbot-ci)" ]; then echo "::error::The container exited before it answered." docker logs openbot-ci exit 1 fi sleep 1 done echo "::error::No answer on /api/capabilities within 150s." docker logs openbot-ci exit 1 - name: Supervision tree is stable, not respawning run: | set -euo pipefail # A supervised service that exits is restarted forever. That looks healthy from outside for # as long as something else is answering, so the log is where it shows. sleep 15 if docker logs openbot-ci 2>&1 | grep -Eic 'restarting|respawn' | grep -qv '^0$'; then echo "::error::A supervised service is restarting." docker logs openbot-ci 2>&1 | grep -Ei 'restarting|respawn' | head -20 exit 1 fi test -n "$(docker ps -q -f name=openbot-ci)" || { echo "::error::The container is no longer running after 15s." docker logs openbot-ci exit 1 } - name: A Bot's shell cannot reach the deployment secrets run: | set -uo pipefail if docker exec -u pwuser openbot-ci env -i PATH=/usr/bin:/bin sh -c ' for e in /proc/[0-9]*/environ; do tr "\0" "\n" < "$e" 2>/dev/null; done ' | grep -q "^KEY_ENCRYPTION_KEY="; then echo "::error::A pwuser shell reads KEY_ENCRYPTION_KEY from a sibling process /proc//environ." exit 1 fi if docker exec -u pwuser openbot-ci env -i PATH=/usr/bin:/bin sh -c \ 'cat /run/s6/container_environment/KEY_ENCRYPTION_KEY 2>/dev/null' | grep -q .; then echo "::error::A pwuser shell reads KEY_ENCRYPTION_KEY from /run/s6/container_environment." exit 1 fi echo "pwuser cannot reach KEY_ENCRYPTION_KEY" - if: always() run: docker rm -f openbot-ci >/dev/null 2>&1 || true # The five images the release publishes alongside the one above. Nothing else here builds them: # they are built by `docker compose up --build`, which only the smoke journey runs and which # cannot run in CI. So a broken `supervisor/Dockerfile` used to surface during a release, after # the `openbot` image had already been pushed, which is the one failure this repository calls out # as leaving a published image with nothing pointing at it. # # Built, not pushed, and one architecture: this answers whether the Dockerfile resolves. Whether # it resolves on arm64 too is answered at release time, on an arm64 runner. # # No registry cache, so a release builds these a second time. `type=gha` needs ACTIONS_* in the # environment, which `build-push-action` injects and a plain `run:` step does not have, and # reaching for another action to export them would buy minutes in a job that runs rarely at the # price of a dependency in the one workflow whose inputs are a security boundary. component-dockerfiles: name: component dockerfiles runs-on: ubuntu-latest # The same cost gate as `image` above, and for the same reason: `agent-computer` pulls # Playwright's base and `server` builds the app, so this is minutes rather than seconds. On main # and through the release's workflow_call it always runs. if: >- github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ci') steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 # A Dockerfile the release does not publish is one a machine would build from source, so the # list and the tree have to agree. Checked here rather than trusted, because the failure is # silent: the release succeeds and a laptop somewhere compiles the app. - name: Every buildable service is published run: | set -euo pipefail found="$(find . -mindepth 2 -maxdepth 2 -name Dockerfile -not -path './node_modules/*' \ | sed -e 's|^\./||' -e 's|/Dockerfile$||' | sort)" published="$(jq -r '.[]' .github/published-images.json | sort)" if [ "$found" != "$published" ]; then echo "::error::.github/published-images.json does not match the Dockerfiles in the tree." diff <(echo "$published") <(echo "$found") || true exit 1 fi - name: They build run: | set -euo pipefail while read -r image; do echo "::group::$image" docker buildx build --file "$image/Dockerfile" --platform linux/amd64 . echo "::endgroup::" done < <(jq -r '.[]' .github/published-images.json) # Building proves the Dockerfile resolves, not that the image starts. A runtime import of a file # the Dockerfile never copied (../../shared/file-download, from #698) built cleanly and crashed # on start. The computer image has no dependencies to start, so it is booted and asked for health. - name: The computer image starts run: | set -euo pipefail docker buildx build --file agent-computer/Dockerfile --platform linux/amd64 --load \ --tag openbot-agent-computer:ci . docker run -d --name computer-ci -e COMPUTER_TOKEN=ci-token-0123456789abcdef \ -p 127.0.0.1:4100:4100 openbot-agent-computer:ci for _ in $(seq 1 60); do if curl -fsS http://127.0.0.1:4100/health >/dev/null; then echo "agent-computer answered /health" exit 0 fi if [ "$(docker inspect -f '{{.State.Running}}' computer-ci)" != "true" ]; then break; fi sleep 1 done echo "::error::agent-computer did not answer /health" docker logs computer-ci || true exit 1 # One check for branch protection to require. A new job above is covered by this without anybody # remembering to add it to a list, and a job that was skipped for the wrong reason is not a pass. verify: name: verify runs-on: ubuntu-latest if: always() needs: [static, deployables, mobile, computer-browser, chart, test, startup, python-harness, build, migrations, image, component-dockerfiles] # Nothing is checked out here, so the workflow's default `openbot` folder does not exist. defaults: run: working-directory: ${{ github.workspace }} steps: - name: Require every check env: RESULTS: ${{ join(needs.*.result, ' ') }} run: | set -euo pipefail echo "$RESULTS" for result in $RESULTS; do case "$result" in success|skipped) ;; *) echo "::error::A required check reported $result"; exit 1 ;; esac done