ci(reskinnable-demo): gate the demo's four gates and its agent's lockfile

Nothing in CI built or type-checked this app. Leaving the root pnpm
workspace — which was right, it contains the canary line — also removed it
from every repo-wide sweep, because Nx discovers projects THROUGH the
workspace and there is no `workspaceLayout` in `nx.json`:

    nx run-many -t build         static_compat.yml    does not see it
    nx run-many -t check-types   static_quality.yml   does not see it
    nx run-many -t test          test_unit.yml        does not see it

Both static workflows also carry `paths-ignore: ["examples/**"]`, so this
is a new workflow rather than an edit to either. It follows
`test_unit-showcase.yml`, which exists for the same reason applied to a
different directory.

Two jobs.

`gates` runs lint, typecheck, unit (2460 tests) and build, cheapest first,
inside the app because no root task reaches it. Measured locally at ~2
minutes of gates; the 20m budget is install headroom.

The build gate is the one that matters most and the one a developer can
least run: `next build` corrupts a concurrently running dev server's
PostCSS/Turbopack cache — measured, `globals.css` transforms to garbage
and every route 500s, and a dev-server restart does not clear it because
the corruption is on disk. CI is the only safe home for it. It needs no
env: the route constructs one agent per skin at module load and none of
them requires a key (banking's is an `HttpAgent` whose URL is never called
during a build).

`agent-resolve` syncs `agent/uv.lock` with `--frozen` and then asserts the
subagent surface is actually present. That is not hypothetical: until this
branch pinned it, `ag-ui-langgraph>=0.0.43` resolved the RELEASE, which
accepts no `emit_subagent_events` and exports no subagent symbols — the
demo's headline feature, failing silently because the flag is set as an
attribute on an object nobody reads. The assertion is on the CAPABILITY
rather than the version string, since a version assertion goes stale the
moment the pin moves and the surface is what must stay true.

One uncertainty is made into a gate rather than left to trust: this app
pins pnpm@10.10.0 while the root pins pnpm@10.33.4, and the older resolver
is load-bearing here — it still reads `pnpm.overrides` from package.json,
the only place `@ag-ui/core`, `@ag-ui/encoder` and `@ag-ui/proto` are
pinned to the canary (`@ag-ui/client` is a direct dependency; those three
are not). `pnpm/action-setup` is pointed at the app's package.json, and
because an unexpected action input is a WARNING in Actions rather than an
error, a step then compares `pnpm --version` against the pin and fails
loudly if they differ.

No `continue-on-error` and no `|| true` anywhere in the file.
This commit is contained in:
Maxim
2026-08-20 19:32:26 +02:00
parent cf9de905ac
commit a36bef985d
+199
View File
@@ -0,0 +1,199 @@
name: test / reskinnable-demo
# The four gates for examples/showcases/reskinnable-demo, plus a resolve check
# for its Python agent.
#
# WHY THIS WORKFLOW EXISTS
# ------------------------
# This app left the root pnpm workspace so its `@ag-ui/*` / `@copilotkit/*`
# canary line could not leak into the rest of the monorepo. Nx discovers
# projects THROUGH the pnpm workspace (there is no `workspaceLayout` in
# `nx.json`), so leaving it also removed the app from the repo-wide sweeps:
#
# sweep workflow sees this app?
# ------------------------------------- ------------------- --------------
# nx run-many -t build static_compat.yml NO
# nx run-many -t check-types static_quality.yml NO
# nx run-many -t test --projects=... test_unit.yml NO
#
# Both static workflows additionally carry `paths-ignore: ["examples/**"]`, so
# a new workflow is the right shape rather than an edit to either. Verified on
# the commit this branched from: `nx show project` cannot find the standalone
# showcases, and no workflow file named this app.
#
# Net effect until this landed: nothing in CI built or type-checked the
# reference sales demo. A change anywhere in `packages/*` could break it, or a
# change inside it could break itself, and the check list stayed green — the
# four gates were "whoever remembers to run them locally".
#
# WHY THE BUILD GATE MATTERS MOST, and cannot be a local habit:
# `next build` writes into the app's `distDir` and rewrites `next-env.d.ts`.
# Run alongside a running `pnpm dev` it corrupts the dev server's PostCSS /
# Turbopack cache — measured: `globals.css` transforms to garbage and every
# route 500s, and a dev-server restart does NOT clear it because the corruption
# is on disk. So the one gate most likely to catch real breakage is the one a
# developer is least able to run while working. CI is the only safe home for it.
#
# NO `continue-on-error` AND NO `|| true` ANYWHERE IN THIS FILE, BY DESIGN.
# A gate that cannot fail is not a gate. Keep it that way.
on:
pull_request:
paths:
- "examples/showcases/reskinnable-demo/**"
- ".github/workflows/test_reskinnable-demo.yml"
push:
branches: [main]
paths:
- "examples/showcases/reskinnable-demo/**"
- ".github/workflows/test_reskinnable-demo.yml"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_OPTIONS: "--max-old-space-size=4096"
jobs:
gates:
name: "four gates (lint, typecheck, unit, build)"
runs-on: ubuntu-latest
# ~2 minutes of gates locally (lint 18s, typecheck 12s, 2460 unit tests 43s,
# build 54s). The rest of the budget is install headroom on a cold store.
timeout-minutes: 20
defaults:
run:
# Every step runs inside the app. It is NOT a workspace member, so
# there is no root-level task that reaches it.
working-directory: examples/showcases/reskinnable-demo
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Setup pnpm
# Omit `version:` so pnpm/action-setup inherits from `packageManager`
# via corepack. NOTE: the root pins pnpm@10.33.4 and THIS APP pins
# pnpm@10.10.0 — two different resolvers in one repo, and the older one
# is load-bearing here: 10.10.0 still reads `pnpm.overrides` from
# package.json, which is the only place `@ag-ui/core`, `@ag-ui/encoder`
# and `@ag-ui/proto` are pinned to the canary (`@ag-ui/client` is a
# direct dependency, those three are not). Because every step below
# runs inside the app directory, corepack resolves the app's pin.
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
package_json_file: examples/showcases/reskinnable-demo/package.json
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: "pnpm"
# THIS APP'S lockfile, not the root one. They are different files
# since the app left the workspace, and keying on the root lockfile
# would restore a cache that has none of these canary versions in it.
cache-dependency-path: "examples/showcases/reskinnable-demo/pnpm-lock.yaml"
- name: Assert the app's own pnpm is in use
# Turns the one genuine uncertainty in this job into a gate instead of a
# silent wrong answer. The two `packageManager` pins mean the resolver
# that runs here decides whether the three override-only `@ag-ui/*`
# canary pins are honoured, and an unexpected action input is a WARNING
# in Actions, not an error — so a typo above would quietly hand this job
# the root's 10.33.4 and the check list would stay green.
run: |
set -euo pipefail
expected="$(node -p "require('./package.json').packageManager.split('@')[1].split('+')[0]")"
actual="$(pnpm --version)"
echo "expected pnpm ${expected}, got ${actual}"
[ "${expected}" = "${actual}" ] || {
echo "::error::pnpm ${actual} is not this app's pinned ${expected}; the @ag-ui/* override pins may not be honoured"
exit 1
}
- name: Install dependencies
# `--frozen-lockfile` is the point of the job as much as the gates are:
# it fails if the committed lockfile no longer satisfies package.json,
# which is how the canary pins stop silently drifting.
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Typecheck
# The ONLY full type-check in this tree. `pnpm build` type-checks just
# the app's module graph, so it never visits the test files — and
# Vitest transpiles without checking types at all.
run: pnpm typecheck
- name: Unit tests
run: pnpm test:unit
- name: Build
# Default `distDir` on purpose. `next.config.mjs` honours
# `NEXT_DIST_DIR`, but that is a LOCAL workaround for building beside a
# running dev server; in CI it only adds a tsconfig include entry.
#
# No env needed — measured. The route builds one agent per skin at
# module load, and none of them requires OPENAI_API_KEY to construct
# (banking's is an HttpAgent pointed at a URL that is never called
# during a build).
run: pnpm build
agent-resolve:
name: "agent deps resolve + subagent surface"
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: examples/showcases/reskinnable-demo/agent
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- name: Setup uv
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
- name: Sync from the committed lockfile
# `--frozen` fails rather than re-resolving, so a `pyproject.toml` edit
# without a matching `uv lock` is a red check instead of a silent drift.
run: uv sync --frozen
- name: Assert the subagent surface is present
# THE GATE THIS JOB EXISTS FOR, and it is not hypothetical: before the
# pins landed, `ag-ui-langgraph>=0.0.43` resolved the RELEASE, which
# accepts no `emit_subagent_events` and exports no subagent symbols.
# That is the demo's headline feature, and it failed SILENTLY — the flag
# is set as an attribute (copilotkit's subclass takes four kwargs), so
# the assignment succeeds against an object nobody reads and the service
# starts clean.
#
# Asserting the CAPABILITY rather than the version string on purpose: a
# version assertion goes stale the moment the pin moves, and the thing
# that must stay true is the surface, not the number.
run: |
uv run python -c '
import inspect, sys
from ag_ui_langgraph import LangGraphAgent
import ag_ui.core as core
accepts = "emit_subagent_events" in inspect.getsource(LangGraphAgent.__init__)
symbols = [n for n in dir(core) if "ubagent" in n]
print("emit_subagent_events accepted:", accepts)
print("subagent symbols:", symbols)
if not accepts or not symbols:
sys.exit("subagent surface missing — the banking harness console would be silently dead")
'
- name: Import the agent module
# Cheap smoke: catches a syntax error or a dropped dependency in
# `agent/` that no other gate in this repo would ever see, since the
# app is TypeScript and this service is Python.
run: uv run python -c "import agent, main; print('agent + main import clean')"