chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# kaios 2.5 excluded deliberately — Gecko 48 era; excluded for CSS autoprefixer consumers.
# JS compat targets (es2022 ESM/CJS, es2018 UMD; es2020 for react-core UMD) are hardcoded in tsdown configs and compat-check scripts.
defaults
not dead
not op_mini all
not kaios 2.5
+38
View File
@@ -0,0 +1,38 @@
{
"name": "copilotkit-plugins",
"owner": {
"name": "CopilotKit",
"email": "support@copilotkit.ai"
},
"metadata": {
"description": "AI agent skills for CopilotKit — setup, develop, integrate, debug, upgrade, and contribute",
"version": "1.62.3"
},
"plugins": [
{
"name": "copilotkit",
"source": "./",
"description": "AI agent skills for CopilotKit — setup, develop, integrate, debug, upgrade, and contribute to CopilotKit projects",
"version": "1.62.3",
"author": {
"name": "CopilotKit",
"url": "https://copilotkit.ai"
},
"homepage": "https://docs.copilotkit.ai",
"repository": "https://github.com/CopilotKit/CopilotKit",
"license": "MIT",
"keywords": [
"copilotkit",
"ai",
"agents",
"react",
"next.js",
"langgraph",
"crewai",
"ag-ui",
"mcp"
],
"category": "ai-frameworks"
}
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "copilotkit",
"description": "AI agent skills for CopilotKit — setup, develop, integrate, debug, upgrade, and contribute to CopilotKit projects",
"version": "1.62.3",
"author": {
"name": "CopilotKit",
"url": "https://copilotkit.ai"
},
"homepage": "https://docs.copilotkit.ai",
"repository": "https://github.com/CopilotKit/CopilotKit",
"license": "MIT",
"keywords": [
"copilotkit",
"ai",
"agents",
"react",
"next.js",
"langgraph",
"crewai",
"pydantic-ai",
"mastra",
"ag-ui",
"mcp",
"copilot",
"chatbot"
],
"mcpServers": "./.mcp.json"
}
+113
View File
@@ -0,0 +1,113 @@
# Architecture & Packages
## Three-Layer Architecture
```
Frontend (React/Angular/Vanilla) → Runtime (Express/Hono server) → Agent (LangGraph/CrewAI/BuiltIn/Custom)
```
All layers communicate via the **AG-UI protocol** — an event-based standard streamed over SSE.
## Package Structure
All packages live flat under `packages/` using the `@copilotkit/` scope. There is no v1/v2 split — the codebase is consolidated.
## Packages
- **shared**: Common utilities, types, and constants used across all other packages.
- **core**: The `CopilotKitCore` orchestrator — the central brain on the frontend. Manages the agent registry, tool registry, context store, and event subscriptions. All framework packages (React, Angular, Vanilla) wrap this.
- **react-core**: The public `<CopilotKit>` provider and hooks. Wraps core for React.
- **react-ui**: Chat UI components — `CopilotChat`, `CopilotPopup`, `CopilotSidebar`, `CopilotPanel`.
- **react-textarea**: The `CopilotTextarea` component for AI-assisted text editing.
- **angular**: Angular DI tokens, services, and signal-based state. Same concepts as React but using Angular patterns (`inject()`, signals, `AgentStore`).
- **runtime**: The server-side `CopilotRuntime` class that receives HTTP requests and delegates to agents. Provides Express and Hono adapters. Contains the `AgentRunner` abstraction for managing thread/conversation state. Also includes GraphQL server and LLM adapters.
- **runtime-client-gql**: urql-based GraphQL client for frontend-to-runtime communication.
- **agent**: The `BuiltInAgent` — a default agent implementation powered by the Vercel AI SDK. Used when developers don't bring their own agent framework.
- **voice**: Voice input and transcription support.
- **web-inspector**: A debug console (Lit web component) for inspecting agent communication in development.
- **sqlite-runner**: An `AgentRunner` implementation that persists thread state to SQLite instead of memory.
- **sdk-js**: Helpers for LangGraph/LangChain agent integration.
## Request Lifecycle
1. **Init**: Frontend creates `CopilotKitCore` → fetches agent info from runtime → creates a `ProxiedAgent` instance per remote agent.
2. **User sends message**: Message is added to the agent, then `runAgent()` is called.
3. **HTTP request**: A POST is sent to the runtime with a `RunAgentInput` payload containing messages, registered tools, context, threadId, and state.
4. **Runtime processing**: Request middleware runs → agent is resolved and cloned → `AgentRunner` executes the agent.
5. **SSE stream back**: Agent emits AG-UI events streamed to the frontend: run lifecycle events, text message chunks (streaming), and optional tool call events.
6. **Frontend tool execution**: When the agent calls a frontend tool, Core looks up the handler in its registry, executes it locally in the browser, and sends the result back to the agent which continues processing.
7. **UI update**: Core updates its message store and notifies subscribers → React/Angular re-renders.
## Core Concepts
### AG-UI Protocol
All agent↔UI communication is event-based. Events follow a structured lifecycle: `RUN_STARTED``STEP_STARTED` → message/tool events → `STEP_FINISHED``RUN_FINISHED`. Events are streamed over SSE and validated with Zod schemas. The `EventType` enum in `@ag-ui/core` defines all event types.
### ProxiedAgent
The frontend representation of a remote agent. Implements the `AbstractAgent` interface but translates calls into HTTP requests to the runtime, streaming SSE events back. Created automatically when the runtime reports available agents.
### AgentRunner
An abstract class on the runtime side responsible for managing thread state (conversation history, agent state). The default `InMemoryAgentRunner` is ephemeral; `SQLiteAgentRunner` provides persistence. Custom runners can be built for any storage backend.
### Tool Registration
Tools can be **frontend tools** (handler runs in the browser, registered via `useFrontendTool`) or **backend tools** (handler runs on the server, defined in the agent config). Tools can be scoped to a specific agent via `agentId`, or available to all agents by omitting it.
### Context
Application data sent alongside messages to give agents awareness of the current UI state. Registered via `useAgentContext(description, data)` where data is any JSON-serializable value. Automatically included in every agent run.
### Multi-Agent
Multiple agents can be registered in a single `CopilotRuntime`. Each agent gets its own endpoint, message thread, state, and optionally scoped tools. The frontend selects which agent to interact with via `useAgent({ agentId })`.
### Middleware
`CopilotRuntime` supports `beforeRequestMiddleware` and `afterRequestMiddleware` for cross-cutting concerns like authentication, logging, and request/response transformation.
## Debug Mode
CopilotKit includes a built-in debug mode for both the runtime and client that provides detailed logging of the AG-UI event pipeline.
### Enabling Debug Mode
**Runtime (server-side):**
```ts
const runtime = new CopilotRuntime({
debug: true, // Full debug output with Pino structured logging
});
```
**Client (React):**
```tsx
<CopilotKit debug={true} runtimeUrl="...">
{children}
</CopilotKit>
```
### Granular Configuration
Both accept a config object for fine-grained control:
```ts
debug: {
events: true, // Log every event emitted/received (default: true)
lifecycle: true, // Log request/run lifecycle (default: true)
verbose: false, // Log full payloads vs summaries (default: false in object form, true in boolean form)
}
```
### What Gets Logged
**Runtime:** Agent run started, SSE stream opened/completed/errored, every AG-UI event emitted (with Pino structured logger).
**Client:** The debug configuration is forwarded to the AG-UI transport layer (`transformChunks`). CopilotKit itself does not currently emit client-side `console.debug` calls — the flag configures the underlying AG-UI event pipeline for transport-level debug output.
### Architecture
The `DebugConfig` type and `resolveDebugConfig()` normalizer live in `@copilotkit/shared`. The runtime and client toggles are independent — enabling one does not affect the other.
+103
View File
@@ -0,0 +1,103 @@
# Documentation — where to author it
There are **two** documentation domains. Putting a change in the wrong place means it
silently never reaches the live site. Read this before editing any docs.
## 1. CopilotKit product docs → `showcase/shell-docs/`
All CopilotKit documentation is authored in **`showcase/shell-docs/src/content/`**, which
builds and serves **docs.copilotkit.ai**:
| Content type | Location |
| ------------------------------ | ------------------------------------------------------ |
| Guide / how-to / concept pages | `showcase/shell-docs/src/content/docs/` |
| API reference | `showcase/shell-docs/src/content/reference/` |
| Reusable snippets (shared MDX) | `showcase/shell-docs/src/content/snippets/` |
| Framework overview pages | `showcase/shell-docs/src/content/framework-overviews/` |
When you add a **guide page** under `showcase/shell-docs/src/content/docs/`, also update
that section's `meta.json` so it appears in navigation.
### Hybrid docs architecture
Framework docs are in a hybrid state while showcase coverage is being completed. The
framework's `docs_mode` controls how shell-docs resolves routes, sidebars, snippets, and
search content. The source of truth for showcase integrations is
`showcase/integrations/<slug>/manifest.yaml`; shell-docs reads the generated registry via
`showcase/shell-docs/src/lib/registry.ts`.
Use these human-facing terms when discussing modes:
| User-facing term | Code value | Meaning |
| ---------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Showcase-driven | `docs_mode: generated` | Framework overview, supported features, demos, source snippets, and search data come from showcase registry/generated data plus shared/root MDX. |
| Authored | `docs_mode: authored` | The framework has its own MDX tree under `showcase/shell-docs/src/content/docs/integrations/<docsFolder>/` and its own `meta.json` sidebar. |
| Hidden | `docs_mode: hidden` | The framework is excluded from docs routes and framework switchers until it is ready to be shown. |
Content resolution differs by mode:
- **Authored frameworks** load the framework-owned MDX tree first, then fall back to
shared/root pages for intentionally shared content.
- **Showcase-driven frameworks (`docs_mode: generated`)** load shared/root MDX first and use
sparse framework overrides only where a generated framework needs framework-specific copy.
- **Hidden frameworks (`docs_mode: hidden`)** should not receive user-facing docs edits until
the framework is ready to become authored or showcase-driven.
Framework slugs do not always match docs folder names. Check `getDocsFolder()` in
`showcase/shell-docs/src/lib/registry.ts` before creating or moving framework-owned pages.
### How humans and agents should work
Before editing framework docs, check the framework's `docs_mode`.
- For **showcase-driven frameworks (`docs_mode: generated`)**, update the showcase inputs:
integration manifests, demos, feature coverage, source regions, generated registry inputs,
shared/root MDX, and sparse framework overrides. Do not edit generated files under
`showcase/shell-docs/src/data/frameworks/` by hand.
- For **authored frameworks (`docs_mode: authored`)**, update the framework MDX tree under
`showcase/shell-docs/src/content/docs/integrations/<docsFolder>/` and its `meta.json`.
- For **reference docs**, edit `showcase/shell-docs/src/content/reference/`. The v2
reference navigation is generated from frontmatter, not `meta.json`.
- For **snippets**, edit `showcase/shell-docs/src/content/snippets/`; snippets feed both
shared/root pages and framework-specific pages.
- To **flip a framework to showcase-driven docs**, complete showcase coverage first, then
change `docs_mode`, regenerate shell-docs data, and verify routes, sidebar entries, search
results, snippets, and framework switching.
**API reference is different.** The v2 reference (`reference/{components,hooks,sdk}/`) has
**no `meta.json`** — navigation is generated automatically by walking the tree and reading
each page's `title`/`description` frontmatter (see
`showcase/shell-docs/src/lib/reference-items.ts`). To add a reference page, drop an `.mdx`
file with frontmatter into the right subdirectory; it appears in nav on its own. Only the
legacy `reference/v1/` tree uses `meta.json`. For the full new-hook checklist see
[Hook Development](hooks.md).
**The top-level `docs/` path is only a symlink to `showcase/shell-docs/`.**
It exists for `cd docs` muscle memory, not as a separate docs app. The old
`docs/content/docs/` tree and retired Next app no longer publish anything. Historical
content remains recoverable from the archive refs: `archive/docs-save-do-not-prune` and
`archive/docs-retired-2026-06-17`.
## 2. AG-UI protocol docs → upstream `ag-ui-protocol/ag-ui`
The AG-UI protocol docs (the `showcase/shell-docs/src/content/ag-ui/` tree) are **not**
authored in this repo. Their canonical source is the upstream repo
**[`ag-ui-protocol/ag-ui`](https://github.com/ag-ui-protocol/ag-ui)** under its `docs/`
directory, which publishes to **docs.ag-ui.com**.
The `content/ag-ui/` copy here is a **downstream mirror** rendered on the CopilotKit docs
host. To change AG-UI protocol docs, make the change upstream in `ag-ui-protocol/ag-ui`;
it then needs to be synced back into the CopilotKit copy.
> **Do not author AG-UI content changes directly in `content/ag-ui/`** — they would diverge
> from upstream and never reach docs.ag-ui.com.
**Caveat (known, out of scope here):** there is currently no automated sync for the
`content/ag-ui/` mirror, so it can drift from upstream. Upstream is canonical — don't try to
change that process as part of unrelated work.
## Quick decision
- Changing a CopilotKit guide, reference, snippet, or framework page? → `showcase/shell-docs/src/content/`
- Changing AG-UI protocol docs? → upstream `ag-ui-protocol/ag-ui`, then sync
- Tempted to recreate `docs/content/docs/`? → stop, it's retired; use `showcase/shell-docs/`
+87
View File
@@ -0,0 +1,87 @@
# Git & PRs
## Always Branch from Up-to-Date `origin/main`
Before creating a branch (or worktree), fetch and base it on the remote tip, not whatever your local `main` happens to be:
```sh
git fetch origin && git switch -c <branch-name> origin/main
# worktree equivalent:
git fetch origin && git worktree add -b <branch-name> ../<dir> origin/main
```
Never branch off local `main` without fetching — it may be stale (e.g. "behind origin/main by N commits"), which bases your PR on an old commit and invites avoidable merge conflicts. If you discover an existing branch is stale, rebase it: `git fetch origin && git rebase origin/main`.
## Worktree Workflow
Always use a git worktree for non-trivial work. This keeps the main working tree clean and lets you work in isolation.
1. **Start a worktree** at the beginning of a task. This creates a new branch and a separate working directory.
2. **Do all work** inside the worktree — commits, builds, tests.
3. **Push the worktree branch** to the remote with `-u` to set up tracking.
4. **Open a draft PR immediately** — see "Open a Draft PR Up Front" below.
5. **Clean up** the worktree after the PR is merged.
## Open a Draft PR Up Front
The moment a new branch has at least one commit, open a **draft PR** against `main`. Don't wait until the work is "ready." Reasons:
- The work becomes visible to teammates the second it exists. Unmerged-and-unpushed branches are invisible work.
- Reviewers can leave comments early; CI starts running; conflicts surface fast.
- A draft PR is the cheapest possible coordination signal — no commitment, no review burden, just "this exists."
**The flow:**
1. After the first meaningful commit on a branch:
```bash
git push -u origin <branch-name>
gh pr create --base main --draft \
--title "<short title>" \
--body "<short summary of what's being built + current status>"
```
2. Keep committing + pushing as you go. The PR auto-updates.
3. When the work is ready for review, **flip the PR from draft to ready**:
```bash
gh pr ready <pr-number>
```
This is the explicit "please review" signal. Until you flip it, the PR is in-progress.
The rule: **PR exists before work continues. Ready-flag flips only when the developer says so.**
## Commit Early and Often, in Logical Chunks
**Commit your work as you go, not in one big dump at the end.** Each commit is a logical, self-contained unit. This rule is non-negotiable — letting a worktree accumulate hundreds of untracked files is how work gets lost and PRs become impossible to review.
**Rules:**
- Commit after each meaningful unit of work — a self-contained feature, a refactor, a bugfix, a docs sweep. Roughly one commit per logical idea.
- Tests for the code introduced in a commit belong in **that same commit**, not a separate one.
- Group related changes — if you rename a symbol, update its callers in the same commit.
- Don't bundle unrelated changes. A bugfix and a doc rewrite are two commits.
- Push after every commit. Unpushed commits are invisible to collaborators and at risk of being lost.
**Commit message style:**
- Plain English. No conventional-commit prefixes (`feat:`, `fix:`, `chore:`) unless the repo already uses them.
- Lead with what changed and why. Skip mechanical descriptions.
- Good: `add bridge restart recovery via Slack message metadata`
- Good: `drop default tool-call status posts; opt-in via showToolStatus`
- Bad: `update files`, `WIP`, `more changes`
**Detect drift and correct it.** If you notice a worktree accumulating uncommitted work for more than a single logical step, stop and commit before moving on. If you find yourself with a backlog of untracked files at the end of a session, split them into logical chunks and commit each — don't combine them just because they happened together.
## Creating a PR
When the work is ready:
1. Stage and commit your changes in the worktree.
2. Push the branch: `git push -u origin <branch-name>`
3. Create the PR: `gh pr create --base main`
4. Use a clear title (under 70 chars) and a body summarizing what changed and why.
## Commit Conventions
- Write concise commit messages focused on the "why", not the "what".
- Stage specific files — avoid `git add -A` or `git add .` to prevent accidentally including unrelated changes.
- Never amend commits unless explicitly asked. Always create new commits.
- Never force-push unless explicitly asked.
+12
View File
@@ -0,0 +1,12 @@
# Hook Development
When creating a new hook, always complete **all** of the following:
1. **Implementation**: Create the hook in `@copilotkit/react-core`. If backward compatibility shims are needed, add them in the package's `v1/` directory.
2. **JSDoc**: Add JSDoc on top of the hook implementation, including usage examples.
3. **Tests**: Write extensive tests covering behavior, edge cases, and lifecycle (mount/unmount/re-render).
4. **API reference**: Add a reference page at `showcase/shell-docs/src/content/reference/hooks/<hookName>.mdx` with `title` and `description` frontmatter. The v2 reference navigation is generated automatically by walking the `reference/` tree and reading frontmatter (see `showcase/shell-docs/src/lib/reference-items.ts`) — there is **no `meta.json`** to edit for v2 reference; the file and its frontmatter are the metadata. (Only the legacy `reference/v1/` tree uses `meta.json`.)
5. **Conceptual docs (if needed)**: If the hook needs usage/how-to documentation beyond the API reference, add a guide page under `showcase/shell-docs/src/content/docs/` and update that section's `meta.json` so it appears in navigation.
Never recreate the retired `docs/content/docs/` tree. The top-level `docs/` path is only
a symlink to shell-docs. See [Documentation](documentation.md).
+30
View File
@@ -0,0 +1,30 @@
# Workflow & Process
## When to Plan vs. Fix Autonomously
- **Bug fixes** (small/medium, < 5 files): Fix autonomously. No plan mode, no check-in. Just fix it, run tests, done.
- **Large bugs** (5+ files or architectural impact): Enter plan mode first.
- **New features and refactors**: Always enter plan mode. Get user sign-off on the approach before implementing.
## Planning
- Enter plan mode for non-trivial features and refactors.
- Write the plan to `tasks/todo.md` with checkable items.
- If something goes sideways during implementation, stop and re-plan — don't push through a broken approach.
## Verification
- Never mark a task complete without proving it works.
- Run tests and check for regressions.
- Diff behavior between main and your changes when relevant.
## Bug Fixing
- When given a bug report: find the root cause, fix it, verify. No hand-holding needed.
- Point at logs, errors, failing tests — then resolve them.
- Go fix failing CI tests without being told how.
## Self-Improvement
- After any correction from the user: update `tasks/lessons.md` with the pattern and a rule to prevent it.
- Review lessons at session start.
+103
View File
@@ -0,0 +1,103 @@
---
description:
globs:
alwaysApply: false
---
# CopilotKit Agent Development Guide
## Agent Types
### Python Agents
Python agents are typically in folders named `agent-py/` or `agent/` and use the [sdk-python/](mdc:sdk-python/) package.
#### Core Python SDK Components
- **[copilotkit/agent.py](mdc:sdk-python/copilotkit/agent.py)** - Main agent class
- **[copilotkit/action.py](mdc:sdk-python/copilotkit/action.py)** - Action definitions
- **[copilotkit/crewai/](mdc:sdk-python/copilotkit/crewai/)** - CrewAI integration
- **[copilotkit/integrations/](mdc:sdk-python/copilotkit/integrations/)** - Third-party integrations
#### Python Agent Examples
- **[coagents-starter/agent-py/](mdc:examples/coagents-starter/agent-py/)** - Basic Python agent setup
- **[coagents-ai-researcher/agent/](mdc:examples/coagents-ai-researcher/agent/)** - Research agent with web scraping
- **[coagents-travel/agent/](mdc:examples/coagents-travel/agent/)** - Travel planning agent
- **[langgraph-tutorial-quickstart/agent-py/](mdc:examples/langgraph-tutorial-quickstart/agent-py/)** - LangGraph integration
### JavaScript Agents
JavaScript agents are in folders named `agent-js/` and use the [sdk-js/](mdc:packages/sdk-js/) package.
#### JavaScript Agent Examples
- **[coagents-starter/agent-js/](mdc:examples/coagents-starter/agent-js/)** - Basic JavaScript agent setup
- **[coagents-qa-native/agent-js/](mdc:examples/coagents-qa-native/agent-js/)** - Native JavaScript QA agent
- **[coagents-routing/agent-js/](mdc:examples/coagents-routing/agent-js/)** - Routing agent
## Agent Patterns
### Single Agent Pattern
Simple agents that handle specific tasks:
- File structure: `agent/main.py` or `agent/index.js`
- Direct communication with frontend
- Good for focused use cases
### Multi-Agent (Coagents) Pattern
Complex systems with multiple cooperating agents:
- Multiple agent files or classes
- Shared state management
- Agent routing and coordination
- Examples: [coagents-routing/](mdc:examples/coagents-routing/), [coagents-shared-state/](mdc:examples/coagents-shared-state/)
### CrewAI Integration Pattern
Using CrewAI for agent orchestration:
- **Flows**: [coagents-starter-crewai-flows/](mdc:examples/coagents-starter-crewai-flows/)
### LangGraph Integration Pattern
Using LangGraph for agent state management:
- **[langgraph-tutorial-quickstart/](mdc:examples/langgraph-tutorial-quickstart/)** - Basic LangGraph setup
- **[langgraph-tutorial-customer-support/](mdc:examples/langgraph-tutorial-customer-support/)** - Customer support bot
- Uses [langgraph.json](mdc:sdk-python/langgraph.json) for configuration
## Agent Setup
### Python Agent Setup
1. Use [requirements-template.txt](mdc:examples/shared/requirements-template.txt) as a starting point
2. Configure [pyproject-template.toml](mdc:examples/shared/pyproject-template.toml)
3. Import from `copilotkit` package
4. Define actions and agent logic
### JavaScript Agent Setup
1. Install `@copilotkit/sdk-js` package
2. Configure TypeScript with proper types
3. Define actions and agent endpoints
4. Set up Express.js server or similar
## Common Agent Features
### Actions
- Define functions that agents can call
- Handle user interactions
- Modify application state
- Examples in action.py files across examples
### State Management
- Shared state between agents
- Persistent state across conversations
- Example: [coagents-shared-state/](mdc:examples/coagents-shared-state/)
### User Input Handling
- Wait for user input during agent execution
- Interactive workflows
- Example: [coagents-wait-user-input/](mdc:examples/coagents-wait-user-input/)
### Integration Patterns
- Vector databases (Pinecone, MongoDB Atlas)
- LLM providers (OpenAI, Anthropic)
- External APIs and services
## Development Best Practices
1. Start with a simple agent template
2. Define clear actions and interfaces
3. Handle errors gracefully
4. Test with the corresponding UI example
5. Use shared utilities from [examples/shared/](mdc:examples/shared/)
6. Follow the established patterns in existing examples
+45
View File
@@ -0,0 +1,45 @@
---
description:
globs:
alwaysApply: false
---
# CopilotKit Architecture Overview
## Core Library Structure
The main CopilotKit library is located in [CopilotKit/](mdc:./) with the following key packages:
### React Packages
- **[react-core/](mdc:packages/react-core/)** - Core React components and hooks for CopilotKit
- **[react-textarea/](mdc:packages/react-textarea/)** - Textarea component with AI integration
- **[react-ui/](mdc:packages/react-ui/)** - UI components for copilot interfaces
### Runtime & SDK
- **[runtime/](mdc:packages/runtime/)** - Core runtime for executing copilot actions
- **[runtime-client-gql/](mdc:packages/runtime-client-gql/)** - GraphQL client for runtime communication
- **[sdk-js/](mdc:packages/sdk-js/)** - JavaScript SDK for backend integration
- **[shared/](mdc:packages/shared/)** - Shared utilities and types
### Python SDK
- **[sdk-python/](mdc:sdk-python/)** - Python SDK with integrations for:
- CrewAI agents in [crewai/](mdc:sdk-python/copilotkit/crewai/)
- LangGraph integrations
- OpenAI integrations in [openai/](mdc:sdk-python/copilotkit/openai/)
## Key Concepts
- **Copilots**: AI assistants that can read and write application state
- **Actions**: Functions that copilots can call to interact with your application
- **Agents**: Backend AI agents that process complex tasks
- **Coagents**: Multi-agent systems for complex workflows
## Examples Structure
All examples are in [examples/](mdc:examples/) and typically follow this pattern:
- `agent/` or `agent-py/` - Backend agent implementation
- `ui/` - Frontend Next.js application
- `README.md` - Setup and usage instructions
## Development Workflow
- Examples use the local packages via workspace references
- [package.json](mdc:package.json) contains workspace configuration
- Scripts in [scripts/](mdc:scripts/) for development, testing, and releases
+65
View File
@@ -0,0 +1,65 @@
---
description:
globs:
alwaysApply: false
---
# CopilotKit Development Workflow
## Workspace Setup
CopilotKit uses a monorepo structure with:
- **[package.json](mdc:package.json)** - Main workspace configuration
- **[packages/](mdc:packages)** - Core library packages
- **[examples/](mdc:examples)** - Example applications
## Development Scripts
Located in [scripts/](mdc:scripts):
### Development Scripts
- **[develop/](mdc:scripts/develop)** - Development environment setup
- **[qa/](mdc:scripts/qa)** - Quality assurance and testing scripts
- **[release/](mdc:scripts/release)** - Release automation scripts
### Documentation Scripts
- **[docs/](mdc:scripts/docs)** - Documentation generation and management
## Package Management
- Uses pnpm workspaces for package management
- Local packages are linked automatically within the workspace
- Examples reference local packages via workspace protocol
## Testing
- **[e2e/](mdc:examples/e2e)** - End-to-end testing setup with Playwright
- **[playwright.config.ts](mdc:examples/e2e/playwright.config.ts)** - Playwright configuration
- Test results in [test-results/](mdc:examples/e2e/test-results)
## Documentation Site
- **[docs/](mdc:docs)** - Documentation website built with Next.js
- **[content/docs/](mdc:docs/content/docs)** - Markdown documentation files
- **[components/react/](mdc:docs/components/react)** - React component examples
- **[snippets/](mdc:docs/snippets)** - Code snippets for documentation
## Configuration Files
- **[CopilotKit.code-workspace](mdc:CopilotKit.code-workspace)** - VS Code workspace settings
- **[utilities/](mdc:utilities)** - Shared utilities:
- [tailwind-config/](mdc:utilities/tailwind-config) - Tailwind CSS configuration
- [tsconfig/](mdc:utilities/tsconfig) - TypeScript configurations
## Infrastructure
- **[infra/](mdc:infra)** - AWS CDK infrastructure code
- **[helmfile/](mdc:examples/helmfile)** - Kubernetes deployment configuration
- **[Dockerfile.*](mdc:examples/Dockerfile.agent-js)** - Docker configurations for different components
## Component Registry
- **[registry/](mdc:registry)** - Component registry for reusable components
- **[registry/registry/](mdc:registry/registry)** - Registry content:
- [chat/](mdc:registry/registry/chat) - Chat components
- [quickstarts/](mdc:registry/registry/quickstarts) - Quick start templates
## Development Best Practices
1. Work within the monorepo structure
2. Use the provided scripts for common tasks
3. Test changes with relevant examples
4. Follow the established patterns in existing code
5. Update documentation when adding new features
+58
View File
@@ -0,0 +1,58 @@
---
description:
globs:
alwaysApply: false
---
# CopilotKit Examples and Demos Guide
## Example Categories
### Coagents Examples
Multi-agent systems with complex workflows:
- **[coagents-starter/](mdc:examples/coagents-starter)** - Basic coagents setup with both JS and Python agents
- **[coagents-ai-researcher/](mdc:examples/coagents-ai-researcher)** - AI research assistant with web scraping
- **[coagents-travel/](mdc:examples/coagents-travel)** - Travel planning assistant
- **[coagents-qa/](mdc:examples/coagents-qa)** - Question-answering system
- **[coagents-research-canvas/](mdc:examples/coagents-research-canvas)** - Research canvas with collaborative editing
### CrewAI Integration Examples
- **[coagents-starter-crewai-flows/](mdc:examples/coagents-starter-crewai-flows)** - CrewAI flows integration
### Frontend-Focused Examples
- **[copilot-chat-with-your-data/](mdc:examples/copilot-chat-with-your-data)** - Chat interface with custom data
- **[copilot-form-filling/](mdc:examples/copilot-form-filling)** - AI-powered form filling
- **[copilot-fully-custom/](mdc:examples/copilot-fully-custom)** - Fully customized copilot implementation
- **[copilot-state-machine/](mdc:examples/copilot-state-machine)** - State machine integration
### Integration Examples
- **[copilot-openai-mongodb-atlas-vector-search/](mdc:examples/copilot-openai-mongodb-atlas-vector-search)** - OpenAI + MongoDB Atlas vector search
- **[copilot-anthropic-pinecone/](mdc:examples/copilot-anthropic-pinecone)** - Anthropic + Pinecone integration
### Framework Examples
- **[ag2/](mdc:examples/ag2)** - AG2 framework integration
- **[mastra/](mdc:examples/mastra)** - Mastra framework integration
- **[llamaindex/](mdc:examples/llamaindex)** - LlamaIndex integration
## Common Example Structure
Most examples follow this pattern:
```
example-name/
├── agent/ (or agent-py/) # Backend agent implementation
├── ui/ # Frontend Next.js application
├── README.md # Setup instructions
└── package.json # Dependencies
```
## Running Examples
1. Navigate to the example directory
2. Install dependencies: `npm install` or `pip install -r requirements.txt`
3. Follow the README.md instructions for setup
4. Run the frontend: `npm run dev`
5. Run the agent: `npm run agent` or `python agent.py`
## Demo Projects
Community demos are stored in:
- **[demos_2024/](mdc:community/demos_2024)** - 2024 community demos
- **[demos_2025/](mdc:community/demos_2025)** - 2025 community demos
+152
View File
@@ -0,0 +1,152 @@
---
description:
globs:
alwaysApply: false
---
# CopilotKit Frontend Development Guide
## Core React Packages
### React Components
- **[react-core/](mdc:packages/react-core/)** - Core React components and hooks
- `CopilotProvider` - Main provider component
- `useCopilotChat` - Chat functionality hook
- `useCopilotAction` - Action definition hook
- `useCopilotReadable` - State reading hook
### UI Components
- **[react-ui/](mdc:packages/react-ui/)** - Pre-built UI components
- `CopilotChat` - Chat interface component
- `CopilotPopup` - Popup chat component
- `CopilotSidebar` - Sidebar chat component
### Specialized Components
- **[react-textarea/](mdc:packages/react-textarea/)** - AI-enhanced textarea
- `CopilotTextarea` - Smart textarea with AI suggestions
- Auto-completion and suggestions
## Frontend Example Categories
### Basic Integration Examples
- **[copilot-chat-with-your-data/](mdc:examples/copilot-chat-with-your-data/)** - Chat with custom data
- **[copilot-form-filling/](mdc:examples/copilot-form-filling/)** - AI-powered form filling
- **[copilot-fully-custom/](mdc:examples/copilot-fully-custom/)** - Custom copilot implementation
### Advanced UI Examples
- **[copilot-state-machine/](mdc:examples/copilot-state-machine/)** - State machine integration
- **[demo-viewer/](mdc:examples/demo-viewer/)** - Demo viewer application
- **[saas-dynamic-dashboards/frontend/](mdc:examples/saas-dynamic-dashboards/frontend/)** - Dynamic dashboard UI
### Coagents UI Examples
Most coagents examples have a `ui/` folder with Next.js applications:
- **[coagents-starter/ui/](mdc:examples/coagents-starter/ui/)** - Basic coagents UI
- **[coagents-travel/ui/](mdc:examples/coagents-travel/ui/)** - Travel planning UI
- **[coagents-research-canvas/ui/](mdc:examples/coagents-research-canvas/ui/)** - Research canvas UI
## Component Registry
- **[registry/](mdc:registry/)** - Reusable component registry
- **[registry/registry/chat/](mdc:registry/registry/chat/)** - Chat components
- **[registry/registry/layout/](mdc:registry/registry/layout/)** - Layout components
- **[registry/registry/quickstarts/](mdc:registry/registry/quickstarts/)** - Quick start templates
## Common Frontend Patterns
### Provider Setup
```typescript
import { CopilotProvider } from '@copilotkit/react-core'
function App() {
return (
<CopilotProvider runtimeUrl="/api/copilotkit">
{/* Your app components */}
</CopilotProvider>
)
}
```
### Chat Integration
```typescript
import { CopilotChat } from '@copilotkit/react-ui'
function ChatInterface() {
return (
<CopilotChat
labels={{
title: "Your AI Assistant",
initial: "Hello! How can I help you today?"
}}
/>
)
}
```
### Actions and State
```typescript
import { useCopilotAction, useCopilotReadable } from '@copilotkit/react-core'
import { useState } from 'react'
function MyComponent() {
const [state, setState] = useState({})
// …
}
useCopilotReadable({
name: "current_state",
description: "Current application state",
value: state
})
useCopilotAction({
name: "update_state",
description: "Update application state",
parameters: [/* ... */],
handler: (args) => {
setState(args.newState)
}
})
}
```
## Documentation Components
- **[docs/components/react/](mdc:docs/components/react/)** - React component documentation
- **[docs/components/ui/](mdc:docs/components/ui/)** - UI component library
- **[docs/snippets/](mdc:docs/snippets/)** - Code snippets and examples
## Integration with Agents
### Runtime Configuration
- **[runtime/](mdc:packages/runtime/)** - Core runtime for agent communication
- **[runtime-client-gql/](mdc:packages/runtime-client-gql/)** - GraphQL client
### Agent Communication
- Frontend components communicate with agents via the runtime
- Actions defined in frontend are executed by backend agents
- State is synchronized between frontend and agents
## Development Best Practices
### Component Development
1. Use TypeScript for better type safety
2. Follow the established component patterns
3. Implement proper error handling
4. Use the provided hooks and utilities
### UI/UX Guidelines
1. Maintain consistent styling with Tailwind CSS
2. Use the component registry for reusable components
3. Implement responsive design
4. Provide clear user feedback
### Testing and Quality
1. Test components with different agent configurations
2. Use the E2E testing setup in [examples/e2e/](mdc:examples/e2e/)
3. Follow accessibility best practices
4. Validate agent integration flows
### Styling and Theming
- Uses Tailwind CSS for styling
- Shared configuration in [utilities/tailwind-config/](mdc:utilities/tailwind-config/)
- Custom components in [components.json](mdc:docs/components.json)
- UI components follow modern design patterns
+76
View File
@@ -0,0 +1,76 @@
---
description:
globs:
alwaysApply: false
---
# CopilotKit Quick Reference
## Need to...?
### Build a Simple Copilot
- Start with [copilot-chat-with-your-data/](mdc:examples/copilot-chat-with-your-data)
- Use [react-core/](mdc:packages/react-core) for basic integration
- Add [react-ui/](mdc:packages/react-ui) for pre-built components
### Create Multi-Agent Systems
- Check [coagents-starter/](mdc:examples/coagents-starter) for basic setup
- Use [coagents-routing/](mdc:examples/coagents-routing) for routing patterns
- See [coagents-shared-state/](mdc:examples/coagents-shared-state) for state management
### Integrate with CrewAI
- **Flows**: [coagents-starter-crewai-flows/](mdc:examples/coagents-starter-crewai-flows)
### Develop Python Agents
- Use [sdk-python/](mdc:sdk-python) package
- Check [copilotkit/agent.py](mdc:sdk-python/copilotkit/agent.py) for main agent class
- See [coagents-starter/agent-py/](mdc:examples/coagents-starter/agent-py) for example
### Develop JavaScript Agents
- Use [sdk-js/](mdc:packages/sdk-js) package
- Check [coagents-starter/agent-js/](mdc:examples/coagents-starter/agent-js) for example
### Customize UI Components
- Browse [registry/](mdc:registry) for reusable components
- Check [docs/components/react/](mdc:docs/components/react) for documentation
- Use [react-ui/](mdc:packages/react-ui) for pre-built components
### Set Up Development Environment
- Review [package.json](mdc:package.json) for workspace setup
- Use scripts in [scripts/](mdc:scripts)
- Check [utilities/](mdc:utilities) for shared configurations
### Run Tests
- Use [examples/e2e/](mdc:examples/e2e) for end-to-end testing
- Check [playwright.config.ts](mdc:examples/e2e/playwright.config.ts) for configuration
### Build Documentation
- Check [docs/](mdc:docs) for documentation site
- Use [docs/snippets/](mdc:docs/snippets) for code examples
- See [docs/content/docs/](mdc:docs/content/docs) for markdown files
## Key Files to Know
- **[package.json](mdc:package.json)** - Main workspace configuration
- **[sdk-python/pyproject.toml](mdc:sdk-python/pyproject.toml)** - Python SDK configuration
- **[CopilotKit.code-workspace](mdc:CopilotKit.code-workspace)** - VS Code workspace settings
- **[examples/shared/](mdc:examples/shared)** - Shared utilities and templates
## Common Patterns
1. **Frontend**: Next.js app with CopilotProvider and CopilotChat
2. **Backend**: Python or Node.js agent with actions and state management
3. **Integration**: Runtime connects frontend to backend agents
4. **Testing**: Playwright E2E tests for full integration validation
## Getting Started Checklist
1. Clone the repository
2. Navigate to the project root directory
3. Run `npm install` to set up workspace
4. Choose an example from [examples/](mdc:examples)
5. Follow the example's README.md
6. Start developing your custom copilot or agent
## Need Help?
- Check [CONTRIBUTING.md](mdc:CONTRIBUTING.md) for contribution guidelines
- Browse [community/](mdc:community) for demos and examples
- Look at [docs/](mdc:docs) for comprehensive documentation
+100
View File
@@ -0,0 +1,100 @@
---
description: When working with suggesitons, always load this up.
globs:
alwaysApply: false
---
# Suggestions development guide
## Summary
CopilotKit comes with suggestions behavior that allows an LLM generate (or the developer to statically program) suggestions that appear in the UI. When clicked, these suggestions will add a message. The logic for suggestions is currently divided between Headless UI or our Prebuilt components.
The suggestions system includes intelligent streaming validation, debouncing, and robust error handling to ensure reliable performance and prevent infinite retry loops.
### Prebuilt components (CopilotChat)
The `CopilotChat` component has a `suggestions` prop that controls suggestion behavior:
- **"auto"** (default) - suggestions are generated automatically at the start of a chat and after every turn
- **"manual"** - suggestions will only be shown via `setSuggestions` or `generateSuggestions` from the `useCopilotChat` hook
- **SuggestionItem[]** - an array of static suggestion items to be used as suggestions always
The system automatically handles debouncing and cooldowns to prevent excessive API calls. It also waits for `useCopilotChatSuggestions` hooks to register their configuration before generating initial suggestions.
### Headless UI (useCopilotChat)
The `useCopilotChat` hook provides programmatic control over suggestions:
- `suggestions` - current suggestion array
- `setSuggestions` - manually set suggestions
- `generateSuggestions` - trigger AI generation
- `resetSuggestions` - clear all suggestions
- `isLoadingSuggestions` - loading state
Users can configure suggestions via `useCopilotChatSuggestions` hook which registers configuration that the auto-generation system uses.
## Core files
- **@react-ui**
- [Suggestions.tsx](mdc:packages/react-ui/src/components/chat/Suggestions.tsx) - How suggestions are rendered
- [Messages.tsx](mdc:packages/react-ui/src/components/chat/Messages.tsx) - Includes relevant code for what renders [Suggestions.tsx](mdc:packages/react-ui/src/components/chat/Suggestions.tsx)
- [Chat.tsx](mdc:packages/react-ui/src/components/chat/Chat.tsx) - Includes relevant logic for our prebuilt components loading suggestions
- [use-copilot-chat-suggestions.tsx](mdc:packages/react-ui/src/hooks/use-copilot-chat-suggestions.tsx) - How users specify the configuration for their suggestions
- [suggestions.css](mdc:packages/react-ui/src/css/suggestions.css) - Styling for suggestions
- **@react-core**
- [copilot-context.tsx](mdc:packages/react-core/src/context/copilot-context.tsx) - Where the actual suggestions are stored, the "provider" or "context"
- [use-copilot-chat.ts](mdc:packages/react-core/src/hooks/use-copilot-chat.ts) - Hook that controls and contains logic for suggestions, often referred to as "headless UI"
- [suggestions.ts](mdc:packages/react-core/src/utils/suggestions.ts) - Core suggestion generation logic with streaming validation and error handling
- [suggestions-constants.ts](mdc:packages/react-core/src/utils/suggestions-constants.ts) - Retry configuration constants
## How Suggestions Work
### Architecture Separation
- **CopilotChat Component**: Handles automatic suggestion behavior based on `suggestions` prop
- **useCopilotChat Hook**: Provides programmatic functions for manual control
- **useCopilotChatSuggestions Hook**: Registers configuration for auto-generation
### Timing and Race Conditions
The system handles race conditions between component mounting and configuration registration:
- Auto-suggestion logic waits for `chatSuggestionConfiguration` to be populated
- Effect dependencies include configuration to trigger when it becomes available
- No timeouts needed - React's effect system handles the timing naturally
### Streaming & Validation
During suggestion generation, the AI builds suggestions incrementally (e.g., `{}` → `{title: ''}` → `{title: 'Plan a trip'}`). The system handles this partial data gracefully without console spam, allowing partial suggestions during streaming but ensuring only complete, valid suggestions are shown to users.
### Performance & Reliability
- **Global Debouncing**: Only one suggestion generation can run at a time across the entire app
- **Error Handling**: Network/API errors (missing API keys, rate limits) are categorized and don't cause infinite retries
- **Abort Handling**: Clean cancellation of in-flight requests when new ones are initiated
- **Deduplication**: Automatic removal of duplicate suggestions based on message content
- **Fallback Messages**: If a suggestion doesn't have a message, the title is used as a fallback
## Development Guidelines
### Best Practices
1. **Don't modify suggestions state directly** - Always use the provided hooks and functions
2. **Test with missing API keys** - Ensure your app doesn't infinite loop on network errors
3. **Monitor console for errors** - Check for network or configuration issues
4. **Handle empty states** - Some configurations may not generate suggestions
5. **Use the right approach for your use case**:
- Use `suggestions="auto"` on CopilotChat for most cases
- Use `suggestions="manual"` when you want full programmatic control
- Use static arrays for fixed suggestions
### Common Issues & Solutions
- **No initial suggestions**: Check if `useCopilotChatSuggestions` is being called in your component
- **Race conditions**: The new architecture automatically handles timing between configuration and generation
- **Infinite re-renders**: Check useEffect dependencies, ensure they're properly memoized
- **Console spam**: Usually indicates streaming validation issues - check for partial data handling
- **Duplicate suggestions**: The system automatically deduplicates, but check your configurations
### Debugging
- Check console for error messages about network issues or invalid configurations
- Verify `useCopilotChatSuggestions` is registering configuration
- Check network tab for repeated API calls (should be minimal due to global debouncing)
- Verify abort controllers are cleaning up properly
## Testing Checklist
- [ ] Suggestions load on empty chat (when configuration is present)
- [ ] Suggestions clear when sending message
- [ ] No infinite API calls on network errors
- [ ] Clean console output (no spam)
- [ ] Proper abort handling when component unmounts
- [ ] Configuration registration timing works correctly
- [ ] Manual mode provides full programmatic control
+6
View File
@@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
When you utilize a rule, always reconcile your work against the current rule you utilized. If they no longer match, please update the rule to reflect it and explicitly call out that you're doing so.
+27
View File
@@ -0,0 +1,27 @@
.github
.husky
.turbo
community
assets
.vscode
Dockerfile
**/.venv
**/.env
**/.env.local
**/.env.*.local
**/.vscode
infra
docs
**/.turbo
**/.husky
**/.next
**/coverage
**/dist
**/build
**/__pycache__
**/.pytest_cache
**/.git
**/.DS_Store
**/node_modules
**/.langgraph_api
**/*.log
+13
View File
@@ -0,0 +1,13 @@
*.gif filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.pdf filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
*.webm filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
# Shell scripts must retain LF line endings. Windows contributors with
# autocrlf=true would otherwise silently ship CRLF entrypoint.sh files
# that bash in the Docker runtime rejects.
*.sh text eol=lf
+7
View File
@@ -0,0 +1,7 @@
* @tylerslaton @jpr5 @ranst91 @marthakelly @mme
# CI supply-chain hardening — allowlist changes require core dev review
.github/config-allowlist.txt @tylerslaton @jpr5 @ranst91 @marthakelly @mme
# Showcases — demo team owns these alongside core dev
examples/showcases/ @CopilotKit/demo @tylerslaton @jpr5 @ranst91 @marthakelly @mme
+81
View File
@@ -0,0 +1,81 @@
name: 🐛 Bug Report
description: Found a bug? Report it here!
title: "🐛 Bug: "
labels: ["bug"]
assignees: []
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill out this bug report. Please choose a short and descriptive title for your issue.
- type: textarea
id: reproduction
attributes:
label: ♻️ Reproduction Steps
description: Explain how to reproduce the bug
value: |
1.
2.
3.
...
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: ✅ Expected Behavior
description: |
Tell us what you expected to happen.
If relevant, please include screenshots as well.
placeholder: |
e.g. "When clicking the Copilot chat button, I expected X..."
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: ❌ Actual Behavior
description: |
Tell us what actually happened.
If relevant, please include screenshots as well.
placeholder: |
e.g. "An error appeared in the console, and nothing happened."
validations:
required: true
- type: textarea
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
- type: textarea
id: version
attributes:
label: 𝌚 CopilotKit Version
description: |
Please run the following command in your project and paste the output below:
```bash
npm ls | grep "@copilotkit"
```
placeholder: |
Run `npm ls | grep "@copilotkit"` and paste the output here.
render: shell
validations:
required: true
- type: textarea
id: logs
attributes:
label: 📄 Logs (Optional)
description: |
Please provide any logs or error messages that may be useful.
- For Copilot Runtime, please provide any errors in the terminal.
- For CopilotKit on the browser, please provide any errors that appear in the browser's developer tools.
placeholder: |
This is optional.
render: shell
validations:
required: false
@@ -0,0 +1,55 @@
name: 🚀 Feature Request
description: Have an idea for enhancements or features? Let us know!
title: "🚀 Feature Request: "
labels: ["feature request"]
assignees: []
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to suggest a feature!
> **Before you file:** Search existing issues to avoid duplicates.
- type: checkboxes
id: preflight
attributes:
label: Pre-flight Checklist
options:
- label: I have searched existing issues, and this hasn't been requested yet.
required: true
- type: textarea
id: problem
attributes:
label: Problem or Motivation
description: What problem does this feature solve? Why is it needed?
placeholder: "I'm always frustrated when..."
validations:
required: true
- type: textarea
id: description
attributes:
label: 🎤 Tell us your idea
description: |
We want to make it as easy as possible for you to suggest enhancements or features. Please use the textareas below to describe your idea. Some high-level guidelines:
- What is your idea?
- Do you have any use case(s) in mind?
- Who would find this useful?
placeholder: |
Type your idea here...
validations:
required: true
- type: checkboxes
id: contribution
attributes:
label: Would you like to work on this?
description: We welcome contributions! Let us know if youd like to help implement this feature.
options:
- label: Yes, Id love to work on it!
- label: Im open to collaborating but need guidance.
- label: No, Im just sharing the idea.
validations:
required: false
@@ -0,0 +1,21 @@
name: 📚 Documentation Issue
description: Let us know how we can improve the documentation.
title: "📚 Documentation: "
labels: ["documentation"]
assignees: []
body:
- type: markdown
attributes:
value: |
We appreciate you taking the time to help us make our documentation better!
- type: textarea
id: description
attributes:
label: 💬 Let us know how we can improve our documentation.
description: |
If you are referring to an existing page in the documentation, please provide a link.
placeholder: |
Type your idea here...
validations:
required: true
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Read the Documentation
url: https://docs.copilot.ai
about: Read the documentation to get started.
- name: Join our Discord
url: https://discord.com/invite/6dffbvGU3D
about: Join the Discord server to ask questions and get help.
+1
View File
@@ -0,0 +1 @@
Please describe your issue in as much detail as possible
+37
View File
@@ -0,0 +1,37 @@
<!--
Thank you for sending the PR! We appreciate you spending the time to work on these changes.
Help us understand your motivation by explaining why you decided to make this change.
**Please PLEASE reach out to us first before starting any significant work on new or existing features.**
By the time you've gotten here, you're looking at creating a pull request so hopefully we're not too late.
We love community contributions! That said, we want to make sure we're all on the same page before you start.
Investing a lot of time and effort just to find out it doesn't align with the upstream project feels awful, and we don't want that to happen.
It also helps to make sure the work you're planning isn't already in progress.
As described in our contributing guide, please file an issue first: https://github.com/ag-ui-protocol/ag-ui/issues
Or, reach out to us on Discord: https://discord.com/invite/6dffbvGU3D
You can learn more about contributing to copilotkit here: https://github.com/copilotkit/copilotkit/blob/master/CONTRIBUTING.md
Happy contributing!
-->
## What does this PR do?
(Describe the changes introduced in this PR)
## Related PRs and Issues
- (Direct link to related PR or issue, if relevant)
## Checklist
- [ ] I have read the [Contribution Guide](https://github.com/copilotkit/copilotkit/blob/master/CONTRIBUTING.md)
- [ ] If the PR changes or adds functionality, I have updated the relevant documentation
- [ ] "Allow edits by maintainers" is checked (lets us help iterate on your PR directly — faster turnaround for everyone)
+121
View File
@@ -0,0 +1,121 @@
examples/canvas/gemini/next.config.mjs
examples/canvas/langgraph-python/next.config.ts
examples/canvas/llamaindex-composio/next.config.ts
examples/canvas/llamaindex/next.config.ts
examples/canvas/mastra-pm/next.config.ts
examples/canvas/mastra/next.config.ts
examples/canvas/pydantic-ai/next.config.ts
examples/shadcn/next.config.ts
examples/integrations/a2a-a2ui/next.config.js
examples/integrations/a2a-middleware/next.config.ts
examples/integrations/adk/next.config.ts
examples/integrations/agent-spec/next.config.ts
examples/integrations/agentcore/frontend/src/vite-env.d.ts
examples/integrations/agentcore/frontend/vite.config.ts
examples/integrations/agno/next.config.ts
examples/integrations/claude-sdk-python/next.config.ts
examples/integrations/claude-sdk-typescript/next.config.ts
examples/integrations/crewai-crews/next.config.ts
examples/integrations/crewai-flows/next.config.ts
examples/integrations/langgraph-fastapi/next.config.ts
examples/integrations/langgraph-js/next.config.ts
examples/integrations/langgraph-python/next.config.ts
examples/integrations/llamaindex/next.config.ts
examples/integrations/mastra/next.config.ts
examples/integrations/mcp-apps/next.config.ts
examples/integrations/mcp-apps/threejs-server/vite.config.ts
examples/integrations/ms-agent-framework-dotnet/next.config.ts
examples/integrations/ms-agent-framework-python/next.config.ts
examples/integrations/pydantic-ai/next.config.ts
examples/integrations/strands-python/next.config.ts
examples/showcases/a2a-travel/next.config.ts
examples/showcases/a2ui-pdf-analyst/next.config.ts
examples/showcases/adk-dashboard/next.config.ts
examples/showcases/arcade-tools/next.config.ts
examples/showcases/banking/next.config.mjs
examples/showcases/chatkit-studio/apps/playground/next.config.ts
examples/showcases/chatkit-studio/apps/studio/next.config.ts
examples/showcases/chatkit-studio/apps/world/next.config.ts
examples/showcases/daytona-runcode/next.config.ts
examples/showcases/deep-agents-finance-erp/next.config.ts
examples/showcases/deep-agents-job-search/next.config.ts
examples/showcases/deep-agents/next.config.ts
examples/showcases/enterprise-brex/next.config.mjs
examples/showcases/generative-ui-playground/next.config.ts
examples/showcases/langgraph-js-support-agents/apps/web/next.config.ts
examples/showcases/mcp-apps/mcp-server/apps/vite.config.ts
examples/showcases/mcp-apps/next.config.ts
examples/showcases/mcp-demo/next.config.ts
examples/showcases/microsoft-kanban/next.config.ts
examples/showcases/multi-agent-canvas/frontend/next.config.ts
examples/showcases/multi-page/vite.config.ts
examples/showcases/open-mcp-client/apps/threejs-server/vite.config.ts
examples/showcases/open-mcp-client/apps/web/next.config.ts
examples/showcases/oracle-agent-memory/frontend/next.config.ts
examples/showcases/orca/frontend/next.config.mjs
examples/showcases/presentation/next.config.mjs
examples/showcases/pydantic-ai-todos/next.config.ts
examples/showcases/research-canvas/final/frontend/next.config.ts
examples/showcases/research-canvas/frontend/next.config.ts
examples/showcases/research-canvas/start/frontend/next.config.ts
examples/showcases/scene-creator/next.config.ts
examples/showcases/spreadsheet/next.config.mjs
examples/showcases/strands-crm/frontend/next.config.ts
examples/showcases/strands-file-analyzer/next.config.ts
examples/showcases/todo/next.config.mjs
examples/v1/_legacy/copilot-anthropic-pinecone/next.config.mjs
examples/v1/_legacy/copilot-openai-mongodb-atlas-vector-search/next.config.mjs
examples/v1/_legacy/saas-dynamic-dashboards/frontend/next.config.mjs
examples/v1/chat-with-your-data/next.config.ts
examples/v1/form-filling/next.config.ts
examples/v1/next-openai/next.config.js
examples/v1/next-pages-router/next.config.mjs
examples/v1/research-canvas/next.config.mjs
examples/v1/state-machine/next.config.mjs
examples/v1/travel/next.config.mjs
examples/v2/interrupts-langgraph/apps/web/next.config.ts
examples/v2/next-pages-router/next.config.mjs
examples/v2/react-router/vite.config.ts
examples/v2/react/demo/next.config.ts
packages/a2ui-renderer/tsdown.config.ts
packages/agentcore-runner/tsdown.config.ts
packages/core/tsdown.config.ts
packages/demo-agents/tsdown.config.ts
packages/react-core/tsdown.config.ts
packages/react-native/tsdown.config.ts
packages/react-textarea/tsdown.config.ts
packages/react-ui/tsdown.config.ts
packages/runtime-client-gql/tsdown.config.ts
packages/runtime/tsdown.config.ts
packages/sdk-js/tsdown.config.ts
packages/shared/tsdown.config.ts
packages/sqlite-runner/tsdown.config.ts
packages/voice/tsdown.config.ts
packages/vue/vite.config.ts
packages/web-components/tsdown.config.ts
packages/web-inspector/dev/vite.config.ts
packages/web-inspector/tsdown.config.ts
showcase/integrations/ag2/next.config.ts
showcase/integrations/agno/next.config.ts
showcase/integrations/built-in-agent/next.config.ts
showcase/integrations/claude-sdk-python/next.config.ts
showcase/integrations/claude-sdk-typescript/next.config.ts
showcase/integrations/crewai-crews/next.config.ts
showcase/integrations/google-adk/next.config.ts
showcase/integrations/langgraph-fastapi/next.config.ts
showcase/integrations/langgraph-python/next.config.ts
showcase/integrations/langgraph-typescript/next.config.ts
showcase/integrations/langroid/next.config.ts
showcase/integrations/llamaindex/next.config.ts
showcase/integrations/mastra/next.config.ts
showcase/integrations/ms-agent-dotnet/next.config.ts
showcase/integrations/ms-agent-harness-dotnet/next.config.ts
showcase/integrations/ms-agent-python/next.config.ts
showcase/integrations/pydantic-ai/next.config.ts
showcase/integrations/spring-ai/next.config.ts
showcase/integrations/strands/next.config.ts
showcase/integrations/strands-typescript/next.config.ts
showcase/shell-dashboard/next.config.ts
showcase/shell-docs/next.config.ts
showcase/shell-dojo/next.config.ts
showcase/shell/next.config.ts
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
ALLOWLIST=".github/config-allowlist.txt"
if [ ! -f "$ALLOWLIST" ]; then
echo "ERROR: Allowlist file $ALLOWLIST not found" >&2
exit 1
fi
PATTERNS=(
-name 'vite.config.*'
-o -name 'vite_*.mjs' -o -name 'vite_*.ts' -o -name 'vite_*.js'
-o -name 'vite-*.mjs' -o -name 'vite-*.ts' -o -name 'vite-*.js'
-o -name 'tsdown.config.*'
-o -name 'tsup.config.*'
-o -name 'rollup.config.*'
-o -name 'webpack.config.*'
-o -name 'esbuild.config.*'
-o -name 'next.config.*'
)
FOUND=$(find . \
-path './node_modules' -prune -o \
-path './.claude' -prune -o \
-path './.next' -prune -o \
-path '*/node_modules' -prune -o \
-path '*/dist' -prune -o \
-path '*/.next' -prune -o \
\( "${PATTERNS[@]}" \) -print |
sed 's|^\./||' |
sort)
UNEXPECTED=""
while IFS= read -r file; do
[ -z "$file" ] && continue
if ! grep -qxF "$file" "$ALLOWLIST"; then
UNEXPECTED="${UNEXPECTED}${file}"$'\n'
fi
done <<< "$FOUND"
if [ -n "$UNEXPECTED" ]; then
echo "::error::Unexpected build config files detected (not in allowlist):"
echo "$UNEXPECTED" | while IFS= read -r f; do
[ -n "$f" ] && echo " - $f"
done
echo ""
echo "If these are legitimate, add them to $ALLOWLIST and get CODEOWNERS approval."
exit 1
fi
echo "All build config files are on the allowlist."
@@ -0,0 +1,93 @@
name: Auto-merge showcase PRs
on:
pull_request:
branches: [main]
paths:
- "examples/showcases/**"
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
runs-on: ubuntu-latest
steps:
- name: Check author is on Demo team
id: check-team
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
# Authorize on the PR AUTHOR (immutable for the lifetime of the PR),
# never `context.actor` — `actor` is whoever triggered the most
# recent event, so a team member synchronizing or reopening an
# outsider's PR would otherwise green-light auto-merge of code
# they didn't author.
script: |
const prAuthor = context.payload.pull_request.user.login;
try {
await github.rest.teams.getMembershipForUserInOrg({
org: 'CopilotKit',
team_slug: 'demo',
username: prAuthor,
});
core.setOutput('is_demo', 'true');
} catch {
core.info(`${prAuthor} is not a member of CopilotKit/demo`);
core.setOutput('is_demo', 'false');
}
- name: Check PR only touches showcases
if: steps.check-team.outputs.is_demo == 'true'
id: check-files
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
const allShowcases = files.every(f =>
f.filename.startsWith('examples/showcases/')
);
if (!allShowcases) {
const outside = files
.filter(f => !f.filename.startsWith('examples/showcases/'))
.map(f => f.filename);
core.info(`Files outside showcases: ${outside.join(', ')}`);
}
core.setOutput('only_showcases', allShowcases.toString());
- name: Approve PR
if: steps.check-team.outputs.is_demo == 'true' && steps.check-files.outputs.only_showcases == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
await github.rest.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
event: 'APPROVE',
body: 'Auto-approved: showcase-only changes from CopilotKit/demo team member.',
});
- name: Enable auto-merge
if: steps.check-team.outputs.is_demo == 'true' && steps.check-files.outputs.only_showcases == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
await github.graphql(`
mutation($prId: ID!) {
enablePullRequestAutoMerge(input: {
pullRequestId: $prId,
mergeMethod: MERGE
}) {
clientMutationId
}
}
`, { prId: context.payload.pull_request.node_id });
+335
View File
@@ -0,0 +1,335 @@
name: canary / publish
# Discoverable, one-click canary publisher. Surfaces in the Actions tab so any
# maintainer can publish a prerelease of the branch they're working on without
# learning the manual `canary/*` branch + dispatch dance.
#
# IMPORTANT: this workflow does NOT publish to npm itself. It ORCHESTRATES
# publish-release.yml, which holds the SINGLE npm OIDC trusted-publisher binding
# (see that file's header). Adding a second npm-publishing entry point would
# break OIDC for every @copilotkit/* package. The npm trusted publishers for
# the @copilotkit packages are bound to publish-release.yml + the `npm`
# environment.
#
# Why a separate orchestrator instead of a flag inside publish-release.yml:
# The `npm` GitHub Environment's deployment-branch policy is evaluated against
# the ref a run is TRIGGERED on — NOT against branches created mid-run. So
# publish-release.yml can only publish a canary when its run's ref already
# matches the policy (`canary/*`). Creating a branch inside a run triggered on
# `feature/*` does not change that run's ref, so it would still be rejected.
# This workflow therefore runs on any non-main branch, mirrors it to a
# short-lived `canary/<slug>` ref, dispatches publish-release.yml ON that ref
# (clearing the env gate), waits for it, then deletes the ref.
#
# Token: the branch create/delete and the cross-workflow dispatch use the
# devops-bot GitHub App token, NOT the default GITHUB_TOKEN. Events authenticated
# with GITHUB_TOKEN do not start new workflow runs (recursion prevention), so the
# delegated publish-release run would silently never fire.
on:
workflow_dispatch:
inputs:
scope:
description: "Package scope to publish a canary for. Regenerated from release.config.json — do NOT hand-edit (the release-scope-dropdown-sync CI guard enforces parity)."
required: true
type: choice
options:
- monorepo
- angular
- channels
- channels-discord
- channels-intelligence
- channels-slack
- channels-teams
- channels-telegram
- channels-whatsapp
suffix:
description: "Prerelease suffix (e.g. 'fix-user-issue'); blank = unix timestamp. Allowed: [a-zA-Z0-9._-]+. Reuse a suffix only if the base version moved, else the publish collides."
required: false
type: string
dry_run:
# NOTE: this orchestrator exposes `dry_run` (underscore, matching ag-ui's
# convention), but publish-release.yml's input is `dry-run` (hyphen).
# The dispatch step below maps `dry_run` → `-f dry-run=` accordingly.
description: "Dry run: build + detect but do NOT publish to npm. Useful for previewing what would ship."
required: false
default: false
type: boolean
concurrency:
# Serialize repeated dispatches on the same source branch FOR THE SAME SCOPE.
# Cross-branch ref races are independently prevented by making the canary ref
# unique per run (slug + github.run_id, see the slug step below).
# The scope is folded into the key so canaries for different scopes (e.g.
# `monorepo` vs `angular`) on the same branch run in independent lanes instead
# of queuing behind each other (cancel-in-progress: false).
group: canary-publish-${{ inputs.scope }}-${{ github.ref }}
cancel-in-progress: false
permissions:
# The job's own GITHUB_TOKEN does nothing privileged — every write goes through
# the App token minted below.
contents: read
jobs:
canary:
runs-on: ubuntu-latest
# The delegated publish-release run can take up to ~40 min worst case
# (build ~20 + publish ~20); this orchestrator's ceiling must exceed it,
# otherwise a timeout-kill triggers ref cleanup mid-publish (yanking the
# canary ref out from under a still-running publish). publish-release.yml
# also carries a GLOBAL `concurrency: group: publish-release,
# cancel-in-progress: false`, so our delegated run can sit QUEUED behind
# an unrelated release for a long time before it even starts; the ceiling
# must budget that queue time on top of the ~40-min worst case.
timeout-minutes: 90
steps:
- name: Guard ref
# Canary publishes are for non-main BRANCHES only. Block main (use the
# stable release flow) and block non-branch refs such as tags (a tag
# dispatch would otherwise canary-publish from the tagged commit).
if: github.ref == 'refs/heads/main' || !startsWith(github.ref, 'refs/heads/')
# Pass github.ref via env (matches "Validate suffix" discipline) so the
# ref name is never interpolated into the shell — tag/branch names may
# contain shell metacharacters and direct ${{ }} interpolation here
# would be an expression-injection sink.
env:
REF: ${{ github.ref }}
run: |
echo "::error::Canary publishes are for non-main branches only (got '$REF'). To release from main, use the 'release / create-pr' workflow (stable-release.yml) → merge the release PR, or 'release / publish' with mode=stable for retries."
exit 1
- name: Validate suffix
if: inputs.suffix != ''
env:
SUFFIX: ${{ inputs.suffix }}
run: |
set -euo pipefail
# Validate BEFORE any side effect (token mint, ref creation) so a bad
# suffix can't leave an orphaned canary ref behind. Bash regex matches
# the WHOLE string (grep matches per line and would accept a multi-line
# value whose first line is valid).
if ! [[ "$SUFFIX" =~ ^[a-zA-Z0-9._-]+$ ]]; then
echo "::error::Invalid suffix '$SUFFIX'. Allowed: [a-zA-Z0-9._-]+ (blank = unix timestamp)."
exit 1
fi
- name: Mint devops-bot token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: 1108748
private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }}
# Scoped permissions (v3+): contents=write for create/delete of the
# canary ref; actions=write to dispatch publish-release.yml and watch
# the delegated run. No other scopes are needed.
permission-contents: write
permission-actions: write
- name: Compute canary branch name
id: slug
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
# Byte-deterministic (LC_ALL=C) transform collapsing the source ref to a
# single path segment under canary/ so it matches the `canary/*`
# deployment-branch policy. tr -s collapses same-char runs (so no `..`),
# and the sed fully strips any leading/trailing `.`/`-` runs.
export LC_ALL=C
SLUG=$(printf '%s' "$REF_NAME" | tr '/' '-' | tr -c 'a-zA-Z0-9._-' '-' | tr -s '.-')
SLUG=$(printf '%s' "$SLUG" | sed -E 's/^[.-]+//; s/[.-]+$//')
if [ -z "$SLUG" ]; then
echo "::error::Could not derive a canary slug from ref '$REF_NAME'"
exit 1
fi
# Append run id AND attempt so every dispatch — including a re-run of
# this same orchestration — owns a UNIQUE canary ref. This prevents two
# dispatches whose source branches slugify to the same value (or a
# re-run reusing the run id) from racing one shared ref, and keeps run
# discovery below unambiguous (exactly one publish run per ref).
REF_SUFFIX="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "branch=canary/${SLUG}-${REF_SUFFIX}" >> "$GITHUB_OUTPUT"
echo "Canary branch: canary/${SLUG}-${REF_SUFFIX}"
- name: Create or update canary ref
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BRANCH: ${{ steps.slug.outputs.branch }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
# Point canary/<slug> at the dispatched ref's HEAD. The ref is unique
# per run AND attempt (slug + run_id + run_attempt — re-runs increment
# the attempt), so the "already exists" arm is defense-in-depth and
# should be unreachable in practice. Force-update only on that specific
# error; any OTHER failure (auth, rate limit, 5xx) must surface, not be
# silently retried.
ERR=$(mktemp)
if gh api --silent -X POST "repos/${GITHUB_REPOSITORY}/git/refs" \
-f ref="refs/heads/${BRANCH}" -f sha="$SHA" 2>"$ERR"; then
echo "Created ${BRANCH} at ${SHA}"
elif grep -qi "already exists" "$ERR"; then
echo "Ref ${BRANCH} already exists; force-updating to ${SHA}"
gh api --silent -X PATCH "repos/${GITHUB_REPOSITORY}/git/refs/heads/${BRANCH}" \
-f sha="$SHA" -F force=true
else
echo "::error::Failed to create canary ref ${BRANCH}:"
cat "$ERR" >&2
exit 1
fi
- name: Dispatch publish-release.yml on the canary ref and wait
id: dispatch
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BRANCH: ${{ steps.slug.outputs.branch }}
SCOPE: ${{ inputs.scope }}
SUFFIX: ${{ inputs.suffix }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
# (suffix already validated in the "Validate suffix" step above, before
# the canary ref was created)
#
# Input name mapping: this orchestrator's `dry_run` (underscore) maps
# to publish-release.yml's `dry-run` (hyphen). Keep both as-is — the
# underscore matches ag-ui's convention; the hyphen matches cpk's
# existing publish-release.yml signature.
gh workflow run publish-release.yml \
--repo "$GITHUB_REPOSITORY" \
--ref "$BRANCH" \
-f mode=prerelease \
-f scope="$SCOPE" \
-f suffix="$SUFFIX" \
-f dry-run="$DRY_RUN"
# The dispatch went through. If anything from here on fails BEFORE a run
# is located, the cleanup step keeps the ref (a dispatched-but-unindexed
# run may still need it). If the dispatch itself had failed, no run can
# exist and the ref is safe to delete.
echo "dispatched=true" >> "$GITHUB_OUTPUT"
# Residual race: a cancellation landing in the instant between
# `gh workflow run` succeeding above and this output write would route
# cleanup down the "no run exists" path and delete the ref under a
# queued run. Accepted risk — the delegated canary run fails visibly
# at checkout and can simply be re-dispatched.
# The canary ref is unique to this run+attempt, so there is exactly ONE
# publish-release dispatch on it — no timestamp watermark needed (which
# also sidesteps runner/server clock-skew). Poll until it indexes
# (30 x 6s = 3 min tolerance for Actions indexing lag). --limit is
# defensive headroom; the branch/workflow/event filters are applied
# server-side so the matching run is never crowded out.
RUN_ID=""
ERR=$(mktemp)
for i in $(seq 1 30); do
sleep 6
RUN_ID=$(gh run list \
--repo "$GITHUB_REPOSITORY" \
--workflow=publish-release.yml \
--branch "$BRANCH" \
--event workflow_dispatch \
--limit 100 \
--json databaseId \
--jq 'sort_by(.databaseId) | last | .databaseId // empty' 2>"$ERR") || {
RUN_ID=""
echo "::warning::gh run list failed on attempt ${i}; will retry:" >&2
cat "$ERR" >&2
}
if [ -n "$RUN_ID" ]; then
break
fi
done
if [ -z "$RUN_ID" ]; then
echo "::error::Dispatched publish-release run never appeared on ${BRANCH}. Leaving the ref in place for debugging; delete it manually once resolved."
exit 1
fi
# Mark located BEFORE the watch so cleanup runs even if the publish
# fails — but is skipped entirely if we never tracked a run (so we
# never delete a ref a still-pending run may need).
echo "located=true" >> "$GITHUB_OUTPUT"
echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT"
RUN_URL=$(gh run view "$RUN_ID" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
echo "Delegated publish run: ${RUN_URL}"
{
echo "## Canary publish"
echo ""
echo "- **Scope:** \`${SCOPE}\`"
echo "- **Source branch:** \`${GITHUB_REF_NAME}\`"
echo "- **Delegated run:** ${RUN_URL}"
} >> "$GITHUB_STEP_SUMMARY"
# --exit-status propagates the publish run's failure to this job.
gh run watch "$RUN_ID" --repo "$GITHUB_REPOSITORY" --exit-status
# --exit-status catches failures, but a non-failure non-success conclusion
# (e.g. skipped) exits 0 with nothing published. Require success explicitly.
CONCLUSION=$(gh run view "$RUN_ID" --repo "$GITHUB_REPOSITORY" --json conclusion --jq .conclusion)
if [ "$CONCLUSION" != "success" ]; then
echo "::error::Delegated publish run concluded '$CONCLUSION' (expected 'success')."
exit 1
fi
- name: Mint cleanup token
id: cleanup-token
# The job ceiling (90 min) exceeds the 1-hour App-token TTL, so the
# token minted at job start can be expired by cleanup time. Mint a
# fresh one. Same condition as the delete step below.
if: always() && steps.slug.outputs.branch != '' && (steps.dispatch.outputs.located == 'true' || steps.dispatch.outputs.dispatched != 'true')
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: 1108748
private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }}
permission-contents: write
permission-actions: read
- name: Delete canary ref
# Three cases:
# (a) Run was located (located=true) → delete the ref; the delegated
# publish run has finished (success or fail) and no longer needs it.
# Additionally gated below by a live status check on RUN_ID: on
# cancellation/timeout (always() runs cleanup too) a still-queued
# or running delegated run must keep its branch, since
# checkout-by-SHA can fail once the ref is gone.
# (b) Dispatch succeeded (dispatched=true) but the run was never located
# within the poll window → KEEP the ref; a pending publish run may
# still pick it up and would silently break if the ref vanished.
# (c) The dispatch command itself failed or this step never ran
# (dispatched != 'true') → no publish run can exist, so the ref is
# safe to delete (and almost certainly was never created).
# The `steps.slug.outputs.branch != ''` guard skips cleanup entirely when
# the slug step never produced a branch name (a guard/suffix failure that
# bailed before any ref was created). A DELETE on an empty path would
# 404-gracefully anyway, but skipping avoids the spurious API call.
if: always() && steps.slug.outputs.branch != '' && (steps.dispatch.outputs.located == 'true' || steps.dispatch.outputs.dispatched != 'true')
env:
GH_TOKEN: ${{ steps.cleanup-token.outputs.token }}
BRANCH: ${{ steps.slug.outputs.branch }}
RUN_ID: ${{ steps.dispatch.outputs.run_id }}
run: |
# Best-effort cleanup: never fail the job on a delete hiccup, but do
# surface a real error instead of masking every failure as "gone".
set -uo pipefail
# If we tracked a delegated run, only delete the ref once that run has
# completed. always() brings us here on cancellation/timeout too — a
# still-queued run would fail checkout if its branch (and thus the
# commit's reachability) disappears from under it.
if [ -n "${RUN_ID}" ]; then
STATUS=$(gh run view "$RUN_ID" --repo "$GITHUB_REPOSITORY" --json status --jq .status 2>/dev/null || echo unknown)
if [ "$STATUS" != "completed" ]; then
echo "::warning::Delegated run $RUN_ID status is '$STATUS' (not completed); keeping ${BRANCH} — delete it manually once the run finishes."
exit 0
fi
fi
ERR=$(mktemp)
if gh api --silent -X DELETE "repos/${GITHUB_REPOSITORY}/git/refs/heads/${BRANCH}" 2>"$ERR"; then
echo "Deleted ${BRANCH}"
elif grep -qiE "not found|does not exist" "$ERR"; then
echo "Branch ${BRANCH} already gone"
else
echo "::warning::Failed to delete canary ref ${BRANCH} (manual cleanup may be needed):"
cat "$ERR" >&2
fi
+34
View File
@@ -0,0 +1,34 @@
name: cleanup / pr-caches
on:
pull_request:
types:
- closed
permissions:
contents: read
jobs:
pr-caches:
runs-on: ubuntu-latest
permissions:
contents: read
# Needed to delete repository actions caches via `gh cache delete`
actions: write
steps:
- name: Cleanup
run: |
echo "Fetching list of cache key"
cacheKeysForPR=$(gh cache list --ref $BRANCH --limit 100 --json id --jq '.[].id')
## Setting this to not fail the workflow while deleting cache keys.
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
do
gh cache delete $cacheKey
done
echo "Done"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge
@@ -0,0 +1,206 @@
# Daily audit for GHCR container packages that are unlinked from a source
# repository (`repository: null` on the GHCR API).
#
# WHY THIS EXISTS
# ---------------
# When a GHCR container package is not linked to a repository, workflow
# builds in `CopilotKit/CopilotKit` get `403 Forbidden` on push to GHCR —
# the workflow's `GITHUB_TOKEN` only has package-write permissions when
# the package is linked to the actor's repo. We hit this twice in quick
# succession (`showcase-harness` caught manually after a failed deploy, and
# `showcase-pocketbase` caught by a preemptive scan). There is NO GitHub
# API to programmatically link a package to a repo — it is UI-only — so
# the only way to prevent future surprises is detect drift early via a
# scheduled audit + Slack alert.
#
# ALLOWLIST (VERIFIED_ACCESS)
# ---------------------------
# Some packages show `repository: null` on the API but have working
# Actions access because the "Manage Actions access" setting was
# configured manually in the GitHub UI. There is NO API to detect this
# setting, so we maintain an explicit allowlist of package names that
# have been verified to have Actions write access. These are excluded
# from the unlinked-package alert to avoid false positives. See the
# VERIFIED_ACCESS array in the audit step below.
#
# This workflow is the operationalization of the lesson captured in
# `feedback_ghcr_new_package_403.md`.
#
# REQUIRED SECRETS
# ----------------
# - ORG_READ_PACKAGES_PAT: a fine-grained PAT with `read:packages` scope,
# org-scoped to `CopilotKit`. The default `secrets.GITHUB_TOKEN` does
# NOT have `read:packages` for the org and cannot list org packages.
# - SLACK_WEBHOOK_GHCR_DRIFT: a CopilotKit-internal Slack incoming-webhook
# URL. Posts to whichever channel the webhook is bound to (intended:
# an internal alerts channel).
#
# If `ORG_READ_PACKAGES_PAT` is unset the workflow fails loudly — drift
# detection silently disabled is worse than no workflow at all.
# If `SLACK_WEBHOOK_GHCR_DRIFT` is unset the workflow logs a warning
# (the audit still runs) so a missing webhook does not mask drift.
#
# EXIT CODES
# ----------
# This workflow exits 0 in all non-error cases (including when drift is
# present). The Slack message IS the alert; failing the workflow on
# drift would create noisy red CI checks on a schedule.
name: GHCR unlinked-package audit
on:
schedule:
# Daily at 14:00 UTC (07:00 PT / 10:00 ET) — low-traffic window,
# well before the US workday's deploy activity.
- cron: "0 14 * * *"
workflow_dispatch: {}
permissions:
contents: read
jobs:
audit:
name: Audit org container packages for unlinked repos
# Hoist the Slack webhook into an env var so step-level `if:`
# expressions can reference it — `secrets.*` is not a valid
# named-value inside `if:` and causes a workflow startup failure.
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_GHCR_DRIFT }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Verify ORG_READ_PACKAGES_PAT is set
env:
PAT: ${{ secrets.ORG_READ_PACKAGES_PAT }}
run: |
if [ -z "$PAT" ]; then
echo "::error::ORG_READ_PACKAGES_PAT is not set. This workflow requires a fine-grained PAT with read:packages scope, org-scoped to CopilotKit. See the workflow header comment in .github/workflows/ghcr_unlinked_packages.yml for setup."
exit 1
fi
echo "ORG_READ_PACKAGES_PAT present."
- name: List org container packages and identify unlinked
id: audit
env:
GH_TOKEN: ${{ secrets.ORG_READ_PACKAGES_PAT }}
run: |
set -euo pipefail
# Page through all container packages in the CopilotKit org.
# `--paginate` follows Link headers; per_page=100 minimizes
# request count. `gh api` returns one JSON array per page;
# `--slurp` is unnecessary because gh concatenates pages into
# a single stream when called with `--paginate` on an array
# endpoint.
all_packages_json="$(gh api \
--paginate \
-H "Accept: application/vnd.github+json" \
"/orgs/CopilotKit/packages?package_type=container&per_page=100")"
total="$(echo "$all_packages_json" | jq 'length')"
echo "Total container packages in CopilotKit org: $total"
# Filter for packages where repository is null. Emit a compact
# JSON array of {name, visibility} objects for downstream use.
unlinked_json="$(echo "$all_packages_json" \
| jq -c '[.[] | select(.repository == null) | {name: .name, visibility: .visibility}]')"
# Packages with `repository: null` that have verified Actions access
# configured via the GitHub UI ("Manage Actions access" → CopilotKit →
# Write). These are not truly drifted — pushes work fine — but there
# is no API to detect this, so we maintain an explicit allowlist.
# To add a package here: verify in the package settings UI that
# "Manage Actions access" lists CopilotKit with Write role, then
# add the exact package name to this array.
VERIFIED_ACCESS=("showcase-pocketbase")
# Remove allowlisted packages from the unlinked set.
unlinked_json_before_filter="$unlinked_json"
if [ ${#VERIFIED_ACCESS[@]} -gt 0 ]; then
allowlist_filter=$(printf '"%s",' "${VERIFIED_ACCESS[@]}")
allowlist_filter="[${allowlist_filter%,}]"
unlinked_json="$(echo "$unlinked_json" \
| jq -c --argjson allow "$allowlist_filter" \
'[.[] | select(.name as $n | $allow | index($n) | not)]')"
fi
skipped=$(($(echo "$unlinked_json_before_filter" | jq 'length') - $(echo "$unlinked_json" | jq 'length')))
if [ "$skipped" -gt 0 ]; then
echo "Skipped $skipped package(s) with verified Actions access (allowlist)."
fi
unlinked_count="$(echo "$unlinked_json" | jq 'length')"
echo "Unlinked container packages: $unlinked_count"
# Emit outputs for the next step. Use the multiline-output
# delimiter form for the JSON array so jq output with embedded
# special chars survives intact.
{
echo "unlinked_count=$unlinked_count"
echo "unlinked_json<<EOF"
echo "$unlinked_json"
echo "EOF"
} >> "$GITHUB_OUTPUT"
if [ "$unlinked_count" = "0" ]; then
echo "::notice::No GHCR drift — all CopilotKit org container packages are linked to a repository."
else
echo "::warning::Detected $unlinked_count unlinked container package(s):"
echo "$unlinked_json" | jq -r '.[] | " - \(.name) (\(.visibility))"'
fi
- name: Build Slack payload
id: payload
if: steps.audit.outputs.unlinked_count != '0'
env:
UNLINKED_JSON: ${{ steps.audit.outputs.unlinked_json }}
UNLINKED_COUNT: ${{ steps.audit.outputs.unlinked_count }}
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# Build a single `text` field with mrkdwn — keeps the payload
# compatible with both incoming-webhooks and channel webhooks.
# Each unlinked package gets a deep link to its Actions-access
# settings page, where the UI fix lives.
lines="$(echo "$UNLINKED_JSON" | jq -r '.[] | "• <https://github.com/orgs/CopilotKit/packages/container/\(.name)/settings|\(.name)> (\(.visibility))"')"
# Build message via printf — avoids heredoc indentation
# gotchas (closing delimiter must be column-0, which confuses
# YAML linters on `run: |` blocks). mrkdwn renders *bold*,
# _italic_, and <url|label> links.
nl=$'\n'
message=":warning: *GHCR drift detected* — ${UNLINKED_COUNT} container package(s) in the \`CopilotKit\` org are unlinked from a source repository.${nl}${nl}"
message="${message}${lines}${nl}${nl}"
message="${message}*UI fix (per package):* open the settings link above → *Manage Actions access* → *Add Repository* → \`CopilotKit/CopilotKit\` → *Write*.${nl}${nl}"
message="${message}_This drift breaks future workflow builds with \`403 Forbidden\` on push to GHCR. <${RUN_URL}|View audit run>_"
# Emit as a multiline output so the next step can consume it
# without re-quoting through a shell.
{
echo "text<<EOF"
echo "$message"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Notify Slack (drift detected)
if: steps.audit.outputs.unlinked_count != '0' && env.SLACK_WEBHOOK != ''
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_WEBHOOK_GHCR_DRIFT }}
webhook-type: incoming-webhook
# Wrap the dynamic message via toJSON so quotes/newlines/
# backslashes inside package names or visibility values are
# safely JSON-encoded instead of breaking the payload.
payload: |
{ "text": ${{ toJSON(steps.payload.outputs.text) }} }
- name: Log (no Slack — webhook unset)
if: steps.audit.outputs.unlinked_count != '0' && env.SLACK_WEBHOOK == ''
run: |
echo "::warning::Drift detected but SLACK_WEBHOOK_GHCR_DRIFT is not set; no Slack notification sent. See run logs above for the unlinked package list."
- name: Log (no drift)
if: steps.audit.outputs.unlinked_count == '0'
run: |
echo "No drift — exiting 0."
+50
View File
@@ -0,0 +1,50 @@
name: "Integrations: Parity Check"
on:
pull_request:
paths:
- "examples/integrations/**"
- ".github/workflows/integrations_parity.yml"
push:
branches: [main]
paths:
- "examples/integrations/**"
- ".github/workflows/integrations_parity.yml"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
parity-check:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
- name: Install pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
run_install: false
- name: Install root dev dependencies (tsx)
run: pnpm install --frozen-lockfile --ignore-scripts --filter=.
- name: Verify integration-demo parity
run: |
echo "Checking examples/integrations/* against north-star..."
echo "If this fails, run: pnpm parity:sync --target=<instance>"
echo "and resolve agent-surface drift manually (see _parity/README.md)."
pnpm parity:check
@@ -0,0 +1,92 @@
name: Lint Release Workflows
# Runs actionlint + shellcheck against the release / create-pr, release /
# publish, and canary / publish pipelines and the scripts they call. Keeps
# these critical, retry-sensitive files from silently regressing on shell or
# action-syntax bugs.
#
# Scope is intentionally narrow: only the release workflows and
# scripts/release/*. Expanding later is cheap; starting narrow avoids
# drowning unrelated changes in pre-existing lint noise.
#
# Workflow mapping (cpk):
# release / create-pr -> .github/workflows/stable-release.yml
# release / publish -> .github/workflows/publish-release.yml
# canary / publish -> .github/workflows/canary.yml
on:
push:
branches: [main]
paths:
- ".github/workflows/stable-release.yml"
- ".github/workflows/publish-release.yml"
- ".github/workflows/canary.yml"
- ".github/workflows/lint-release-workflows.yml"
- "scripts/release/**"
- "release.config.json"
- "nx.json"
pull_request:
paths:
- ".github/workflows/stable-release.yml"
- ".github/workflows/publish-release.yml"
- ".github/workflows/canary.yml"
- ".github/workflows/lint-release-workflows.yml"
- "scripts/release/**"
- "release.config.json"
- "nx.json"
permissions:
contents: read
jobs:
actionlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Run actionlint on release workflows
uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1.72.0
with:
reporter: github-check
level: error
fail_level: error
actionlint_flags: >-
.github/workflows/stable-release.yml
.github/workflows/publish-release.yml
.github/workflows/canary.yml
.github/workflows/lint-release-workflows.yml
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Install shellcheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: Run shellcheck on release scripts
run: |
set -euo pipefail
shopt -s nullglob globstar
files=(scripts/release/**/*.sh)
if [ ${#files[@]} -eq 0 ]; then
echo "No shell scripts under scripts/release/"
exit 0
fi
shellcheck --severity=warning "${files[@]}"
release-scope-dropdown-sync:
# Verifies the workflow_dispatch `scope` choice dropdowns in
# stable-release.yml, publish-release.yml, and canary.yml match
# release.config.json's `.scopes` keys. These option lists are
# hand-maintained and drift from the config (newly-enrolled packages
# weren't canary-selectable; stale scopes lingered), so this guard fails
# CI whenever they diverge again.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Verify release scope dropdowns match release.config.json
run: bash scripts/release/verify-release-scope-dropdowns.sh
+59
View File
@@ -0,0 +1,59 @@
name: plugin-skills-check
on:
push:
branches: [main]
paths:
- "packages/*/skills/**"
- "skills/runtime/**"
- "skills/react-core/**"
- "skills/a2ui-renderer/**"
- "scripts/sync-plugin-skills.ts"
- "scripts/__tests__/sync-plugin-skills.test.ts"
- ".claude-plugin/**"
- ".github/workflows/plugin-skills-check.yml"
# The plugin version pins to packages/runtime/package.json, so a release
# bump there must re-trigger the drift check or the pin silently rots.
- "packages/runtime/package.json"
pull_request:
paths:
- "packages/*/skills/**"
- "skills/runtime/**"
- "skills/react-core/**"
- "skills/a2ui-renderer/**"
- "scripts/sync-plugin-skills.ts"
- "scripts/__tests__/sync-plugin-skills.test.ts"
- ".claude-plugin/**"
- ".github/workflows/plugin-skills-check.yml"
- "packages/runtime/package.json"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Run sync script unit tests
run: pnpm exec vitest run scripts/__tests__/sync-plugin-skills.test.ts
- name: Check plugin skill mirror is in sync
run: pnpm check:plugin-skills
+65
View File
@@ -0,0 +1,65 @@
name: 🚀 pkg-pr-new
on:
push:
paths:
- "packages/**"
pull_request:
paths:
- "packages/**"
permissions:
contents: read
concurrency:
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NX_VERBOSE_LOGGING: true
NX_CI_EXECUTION_ID: ${{ github.head_ref }}-${{ github.sha }}-${{ github.run_attempt }}
NX_CI_EXECUTION_ENV: "Publish Commit"
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
# NOTE: deliberately NOT in the `npm` environment. pkg-pr-new publishes to
# pkg.pr.new (not the npm registry) and uses no environment-scoped secrets,
# and this job runs on every push/PR touching packages/** — the npm
# environment's deployment-branch policy (main, canary/*,
# release/publish/*) would block it.
permissions:
contents: read
# pkg-pr-new posts snapshot comments on the PR using the workflow token
pull-requests: write
steps:
# persist-credentials required: pkg-pr-new uses repo token to post snapshot comments on the PR
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- run: corepack enable
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: "package.json"
# setup-node built-in cache is fork-safe (fork PRs can't write to base repo cache)
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Configure Nx Cloud environment
run: |
echo "NX_CI_EXECUTION_ID=${{ github.run_id }}-${{ github.run_attempt }}-pkg-pr-new" >> $GITHUB_ENV
echo "NX_CLOUD_NO_TIMEOUTS=true" >> $GITHUB_ENV
echo "NX_CLOUD_DISTRIBUTED_EXECUTION=false" >> $GITHUB_ENV
echo "NX_NO_CLOUD=true" >> $GITHUB_ENV
- name: Build
run: pnpm run build
- run: npx pkg-pr-new publish --pnpm --packageManager pnpm "./packages/*"
+873
View File
@@ -0,0 +1,873 @@
# release / publish
#
# Single npm OIDC entry point for both stable releases and prerelease canaries.
# npm trusted publisher records for the monorepo packages plus independently
# scoped @copilotkit packages are registered against THIS workflow file.
# Matching happens on the OIDC token's `workflow_ref` claim, which is always
# publish-release.yml when this workflow is the entry point.
#
# Triggers:
# - pull_request: closed on a release/publish/<scope>/v<X.Y.Z> branch → stable
# release of <scope> at version <X.Y.Z> (the normal flow).
# - workflow_dispatch with mode=stable → manual retrigger of a failed stable
# release. Republishes from the latest commit on main. Only use this when
# the normal flow failed BEFORE npm publish succeeded.
# - workflow_dispatch with mode=prerelease → canary publish. Bumps versions
# in the build job to <X.Y.Z>-canary.<suffix>, publishes with --tag canary,
# skips tag push + GH Release + Notion notification.
name: release / publish
# This workflow handles two independent release lanes:
#
# 1. npm (TypeScript) — fires on merged release/publish/* PRs or manual dispatch.
# Build → publish via nx release + OIDC trusted publishers.
#
# 2. PyPI (Python SDK) — fires on any merged PR that bumps sdk-python/pyproject.toml.
# Detects version change vs PyPI registry, builds with poetry, publishes with uv.
# Ported from ag-ui's publish-release.yml Python lane.
on:
pull_request:
types: [closed]
branches: [main]
workflow_dispatch:
inputs:
scope:
description: "What to release"
required: true
type: choice
options:
- monorepo
- angular
- channels
- channels-discord
- channels-intelligence
- channels-slack
- channels-teams
- channels-telegram
- channels-whatsapp
mode:
description: "Release mode: stable (full release with tag + GH Release) or prerelease (canary, no tag/release)"
required: false
default: stable
type: choice
options:
- stable
- prerelease
suffix:
description: "Canary suffix (only used when mode=prerelease). Falls back to timestamp if empty. Allowed: [a-zA-Z0-9._-]+"
required: false
type: string
default: ""
dry-run:
description: "Dry run (skip publish step)"
required: false
default: false
type: boolean
python_publish:
description: "Run the Python publish lane regardless of which files changed. Still no-ops if sdk-python's version already matches PyPI."
required: false
default: false
type: boolean
permissions:
contents: read
concurrency:
# Scope the lock to the package being released so a `monorepo` publish and an
# `angular` publish run in independent lanes instead of queuing behind each
# other. On the manual path `inputs.scope` carries the target; on the merged
# release-PR path inputs are empty, but the PR branch is
# `release/publish/<scope>/v<version>`, so `github.head_ref` already encodes
# the scope. Same-scope runs still serialize (cancel-in-progress: false),
# which is what protects the tag push / npm publish.
group: publish-release-${{ inputs.scope || github.head_ref || github.ref }}
cancel-in-progress: false
env:
NX_VERBOSE_LOGGING: true
jobs:
build:
# Run on a merged release PR (normal flow), a stable manual dispatch from
# main (retry escape hatch), or a prerelease manual dispatch from any
# selected branch. Canary publishes are intentionally branch-scoped so
# maintainers can push a button on feature work without merging first.
if: >
(github.event_name == 'workflow_dispatch' &&
(inputs.mode == 'prerelease' || github.ref == 'refs/heads/main')) ||
(github.event.pull_request.merged == true &&
startsWith(github.event.pull_request.head.ref, 'release/publish/'))
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Determine scope and mode
id: meta
env:
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
INPUT_SCOPE: ${{ inputs.scope }}
INPUT_MODE: ${{ inputs.mode }}
run: |
set -euo pipefail
if [ -n "$INPUT_SCOPE" ]; then
SCOPE="$INPUT_SCOPE"
else
# Branch format: release/publish/<scope>/v<version>
SCOPE="${PR_HEAD_REF#release/publish/}"
SCOPE="${SCOPE%%/v*}"
fi
MODE="${INPUT_MODE:-stable}"
echo "scope=$SCOPE" >> "$GITHUB_OUTPUT"
echo "mode=$MODE" >> "$GITHUB_OUTPUT"
echo "Detected scope: $SCOPE, mode: $MODE"
# No token/credential persistence: the publish job sets up its own
# `git config insteadOf` with secrets.GITHUB_TOKEN before pushing tags,
# so this checkout doesn't need write access. Critically, the
# subsequent `Upload workspace` step packs the entire checkout
# (including .git/config) into an artifact — persisting credentials
# here would leak a workflow-scoped token to anyone with actions:read.
- name: Checkout Repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
persist-credentials: false
- name: Setup pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
- name: Install Dependencies
run: pnpm install --frozen-lockfile
# Validate user-supplied suffix against npm-safe charset before passing
# to bump-prerelease.ts. Empty suffix → omit the --suffix flag entirely
# so the script applies its timestamp fallback (passing an empty string
# would produce a version like "X.Y.Z-canary." with a trailing dot).
- name: Bump prerelease versions
if: ${{ steps.meta.outputs.mode == 'prerelease' }}
env:
INPUT_SCOPE: ${{ inputs.scope }}
INPUT_SUFFIX: ${{ inputs.suffix }}
run: |
set -euo pipefail
if [ -n "$INPUT_SUFFIX" ]; then
if ! [[ "$INPUT_SUFFIX" =~ ^[a-zA-Z0-9._-]+$ ]]; then
echo "::error::Invalid suffix '$INPUT_SUFFIX'. Allowed: [a-zA-Z0-9._-]+"
exit 1
fi
pnpm tsx scripts/release/bump-prerelease.ts --scope "$INPUT_SCOPE" --suffix "$INPUT_SUFFIX"
else
pnpm tsx scripts/release/bump-prerelease.ts --scope "$INPUT_SCOPE"
fi
- name: Build packages
run: pnpm run build
# Strip caches and pack the workspace into a single tarball before
# upload. upload-artifact's path filters are post-walk: it still
# descends into every node_modules and stats every file (~6.4M for
# this monorepo with pnpm's .pnpm/ symlink farm) before applying
# negations, which is the actual bottleneck. Removing the dirs and
# uploading one file collapses that to a single fast step.
- name: Pack workspace
run: |
find . -type d \( -name node_modules -o -name .nx -o -name .turbo -o -name .next \) -prune -exec rm -rf {} +
tar -czf /tmp/workspace.tgz .
- name: Upload workspace
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: workspace
path: /tmp/workspace.tgz
retention-days: 1
outputs:
scope: ${{ steps.meta.outputs.scope }}
mode: ${{ steps.meta.outputs.mode }}
publish:
needs: build
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 20
# npm trusted publishing is bound to this environment. Its deployment
# branch policy must allow prerelease workflow_dispatch refs; stable
# releases remain main-only via the build job guard.
environment: npm
permissions:
contents: write
id-token: write
steps:
- name: Determine scope and mode
id: meta
env:
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
INPUT_SCOPE: ${{ inputs.scope }}
INPUT_MODE: ${{ inputs.mode }}
run: |
set -euo pipefail
if [ -n "$INPUT_SCOPE" ]; then
SCOPE="$INPUT_SCOPE"
else
# Branch format: release/publish/<scope>/v<version>
SCOPE="${PR_HEAD_REF#release/publish/}"
SCOPE="${SCOPE%%/v*}"
fi
if [ -z "$SCOPE" ]; then
echo "::error::Failed to resolve scope (input=$INPUT_SCOPE, ref=$PR_HEAD_REF)"
exit 1
fi
MODE="${INPUT_MODE:-stable}"
echo "scope=$SCOPE" >> "$GITHUB_OUTPUT"
echo "mode=$MODE" >> "$GITHUB_OUTPUT"
- name: Download workspace
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: workspace
- name: Unpack workspace
run: |
tar -xzf workspace.tgz
rm workspace.tgz
- name: Configure git credentials
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config --local url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/"
- name: Setup pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
registry-url: https://registry.npmjs.org
# Restore node_modules — the build job excludes them from the
# uploaded workspace artifact (see "Upload workspace" above). Uses
# pnpm-lock.yaml from the artifact, so this is a deterministic
# restore of exactly what the build job ran with.
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Dry-run notice
if: ${{ inputs.dry-run == true }}
run: |
{
echo "## Dry Run"
echo ""
echo "DRY RUN — skipping publish step. Scope: ${{ steps.meta.outputs.scope }}, mode: ${{ steps.meta.outputs.mode }}."
} >> "$GITHUB_STEP_SUMMARY"
- name: Publish to npm
id: publish
if: ${{ inputs.dry-run != true }}
env:
NODE_AUTH_TOKEN: ""
NOTION_API_KEY: ${{ steps.meta.outputs.mode == 'stable' && secrets.NOTION_API_KEY || '' }}
PUBLISH_SCRIPT: ${{ steps.meta.outputs.mode == 'prerelease' && 'prerelease.ts' || 'publish-release.ts' }}
SCOPE: ${{ steps.meta.outputs.scope }}
run: pnpm tsx "scripts/release/$PUBLISH_SCRIPT" --scope "$SCOPE"
- name: Verify publish step emitted version
if: ${{ success() && inputs.dry-run != true }}
env:
MODE: ${{ steps.meta.outputs.mode }}
VERSION: ${{ steps.publish.outputs.version }}
run: |
set -euo pipefail
if [ -z "$VERSION" ]; then
if [ "$MODE" = "prerelease" ]; then
echo "::error::prerelease.ts did not emit 'version' output to GITHUB_OUTPUT. The Prerelease summary would render a blank Version field; aborting."
else
echo "::error::publish-release.ts did not emit 'version' output to GITHUB_OUTPUT. Tag/release creation would produce malformed artifacts; aborting."
fi
exit 1
fi
echo "VERSION=$VERSION confirmed (mode=$MODE)"
- name: Configure git user
if: ${{ success() && inputs.dry-run != true && steps.meta.outputs.mode != 'prerelease' }}
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
- name: Check for pre-existing tags
if: ${{ success() && inputs.dry-run != true && steps.meta.outputs.mode != 'prerelease' }}
env:
SCOPE: ${{ steps.meta.outputs.scope }}
VERSION: ${{ steps.publish.outputs.version }}
run: |
if [ "$SCOPE" == "monorepo" ]; then
TAG="v${VERSION}"
else
TAG="${SCOPE}/v${VERSION}"
fi
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "ERROR: Tag $TAG already exists" >&2
exit 1
fi
- name: Create and push git tag
if: ${{ success() && inputs.dry-run != true && steps.meta.outputs.mode != 'prerelease' }}
env:
SCOPE: ${{ steps.meta.outputs.scope }}
VERSION: ${{ steps.publish.outputs.version }}
run: |
if [ "$SCOPE" == "monorepo" ]; then
TAG="v${VERSION}"
else
TAG="${SCOPE}/v${VERSION}"
fi
git tag -a "$TAG" -m "Release ${SCOPE} ${VERSION}"
git push origin "$TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
id: tag
- name: Create GitHub Release
if: ${{ success() && inputs.dry-run != true && steps.meta.outputs.mode != 'prerelease' }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
RELEASE_SCOPE: ${{ steps.meta.outputs.scope }}
RELEASE_VERSION: ${{ steps.publish.outputs.version }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require("fs");
const { owner, repo } = context.repo;
const tag = process.env.RELEASE_TAG;
const scope = process.env.RELEASE_SCOPE;
const version = process.env.RELEASE_VERSION;
const name = scope === "monorepo" ? `v${version}` : `${scope}/v${version}`;
let body = "";
try {
body = fs.readFileSync("./release-notes.md", "utf8");
} catch {
body = `Release ${name}`;
}
try {
const existing = await github.rest.repos.getReleaseByTag({ owner, repo, tag });
await github.rest.repos.updateRelease({
owner, repo,
release_id: existing.data.id,
tag_name: tag, name, body,
draft: false, prerelease: false,
});
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.repos.createRelease({
owner, repo,
tag_name: tag, name, body,
draft: false, prerelease: false,
});
}
- name: Release summary (stable)
if: ${{ success() && inputs.dry-run != true && steps.meta.outputs.mode != 'prerelease' }}
run: |
{
echo "## Release Published"
echo ""
echo "**Scope:** ${{ steps.meta.outputs.scope }}"
echo "**Mode:** ${{ steps.meta.outputs.mode }}"
echo "**Version:** ${{ steps.publish.outputs.version }}"
echo "**Tag:** ${{ steps.tag.outputs.tag }}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Prerelease summary
if: ${{ success() && inputs.dry-run != true && steps.meta.outputs.mode == 'prerelease' }}
run: |
{
echo "## Prerelease Published"
echo ""
echo "**Scope:** ${{ steps.meta.outputs.scope }}"
echo "**Version:** ${{ steps.publish.outputs.version }}"
echo "**Tag:** (prerelease — no tag created)"
} >> "$GITHUB_STEP_SUMMARY"
- name: Dry-run summary
if: ${{ success() && inputs.dry-run == true }}
run: |
{
echo "## Dry Run Completed"
echo ""
echo "**Scope:** ${{ steps.meta.outputs.scope }}"
echo "**Mode:** ${{ steps.meta.outputs.mode }}"
echo "- Publish step was skipped; no npm publish, no git tag, no GitHub Release."
} >> "$GITHUB_STEP_SUMMARY"
# Populated only on a stable, non-dry-run success (the publish/tag steps are
# gated on mode != prerelease && dry-run != true). On prerelease, dry-run, or
# failure these are empty — the notify job gates on that emptiness.
outputs:
version: ${{ steps.publish.outputs.version }}
tag: ${{ steps.tag.outputs.tag }}
# ===========================================================================
# Python SDK publish lane
#
# Fires independently of the npm lane. Detects whether sdk-python/pyproject.toml
# has a version newer than what's on PyPI, builds with poetry, publishes with uv.
#
# SECURITY: Same build/publish separation as the npm lane — PYPI_API_TOKEN is
# only available in the publish-python job, never where poetry install runs.
# ===========================================================================
build-python:
# Fires when:
# 1. A PR merging to main touched sdk-python/pyproject.toml (version bump), OR
# 2. Manual dispatch with python_publish=true
if: >
(github.event_name == 'workflow_dispatch' && inputs.python_publish == true) ||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
outputs:
should_publish: ${{ steps.detect.outputs.should_publish }}
version: ${{ steps.detect.outputs.version }}
name: ${{ steps.detect.outputs.name }}
# Earliest Python-release intent signal: emitted by the `changed` step
# BEFORE any failure-prone step (setup-python, detect, build). The notify
# job gates the PyPI FAILURE alert on this (not should_publish, which is
# emitted only at the END of detect) so a build-python failure at/before
# detect on a genuine release still pages instead of being silently
# swallowed.
pyproject_changed: ${{ steps.changed.outputs.pyproject_changed }}
steps:
- name: Checkout merged main
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
ref: main
persist-credentials: false
# For PRs, skip early if this PR didn't touch pyproject.toml. Manual
# dispatch always continues (the user explicitly asked for it).
- name: Check if pyproject.toml changed in this PR
if: github.event_name == 'pull_request'
id: changed
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
set -euo pipefail
if [ -z "$PR_BASE_SHA" ]; then
echo "::error::PR_BASE_SHA is empty — cannot determine PR base for diff. Refusing to silently skip Python publish."
exit 1
fi
if [ -z "$PR_HEAD_SHA" ]; then
echo "::error::PR_HEAD_SHA (merge_commit_sha) is empty — GitHub may not have computed the merge commit yet. Refusing to silently skip Python publish; rerun the workflow."
exit 1
fi
# Capture diff FIRST so a git failure trips set -e and fails loudly,
# rather than producing an empty pipe that grep silently routes to
# "not changed" — that path masked real version bumps before.
CHANGED="$(git diff --name-only "$PR_BASE_SHA" "$PR_HEAD_SHA")"
# grep -q exits 1 on legitimate no-match; guard with `if` so set -e
# doesn't kill the step on that expected case.
if printf '%s\n' "$CHANGED" | grep -q '^sdk-python/pyproject.toml$'; then
echo "pyproject_changed=true" >> "$GITHUB_OUTPUT"
else
echo "pyproject_changed=false" >> "$GITHUB_OUTPUT"
echo "sdk-python/pyproject.toml not changed in this PR — skipping Python publish"
fi
- name: Set up Python
if: github.event_name == 'workflow_dispatch' || steps.changed.outputs.pyproject_changed == 'true'
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.12"
- name: Detect version change
if: github.event_name == 'workflow_dispatch' || steps.changed.outputs.pyproject_changed == 'true'
id: detect
run: ./scripts/release/detect-py-version-changes.sh
- name: Install Poetry
if: steps.detect.outputs.should_publish == 'true'
uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2
with:
version: latest
virtualenvs-create: true
virtualenvs-in-project: true
- name: Build Python package
if: steps.detect.outputs.should_publish == 'true'
working-directory: sdk-python
run: poetry build
- name: Upload Python build artifacts
if: steps.detect.outputs.should_publish == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: py-build-artifacts
path: sdk-python/dist/
retention-days: 1
- name: Nothing to publish
if: steps.detect.outputs.should_publish != 'true'
run: |
{
echo "## Python SDK"
echo ""
echo "No version change detected — nothing to publish."
} >> "$GITHUB_STEP_SUMMARY"
# WARNING: PyPI trusted-publisher binding pins to:
# repository: CopilotKit/CopilotKit
# workflow_file: publish-release.yml
# environment: pypi
# Renaming this file, changing this job's `environment:` value, or moving the
# publish step into another workflow breaks PyPI publishing with HTTP 422
# until the Trusted Publisher record on pypi.org is updated to match.
publish-python:
needs: build-python
if: ${{ !cancelled() && needs.build-python.result == 'success' && needs.build-python.outputs.should_publish == 'true' && inputs.dry-run != true }}
runs-on: ubuntu-latest
timeout-minutes: 10
environment: pypi
permissions:
contents: write
id-token: write
steps:
- name: Checkout merged main
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
ref: main
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: ">=0.8.0"
- name: Download Python build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: py-build-artifacts
path: sdk-python/dist
- name: Publish to PyPI (OIDC trusted publishing)
run: |
set -euo pipefail
shopt -s nullglob
files=(sdk-python/dist/*)
if [ ${#files[@]} -eq 0 ]; then
echo "::error::no build artifacts in sdk-python/dist — nothing to publish"
exit 1
fi
uv publish --trusted-publishing always "${files[@]}"
- name: Verify version is live on PyPI
env:
NAME: ${{ needs.build-python.outputs.name }}
VERSION: ${{ needs.build-python.outputs.version }}
run: |
set -euo pipefail
for i in $(seq 1 18); do
if curl -fsS "https://pypi.org/pypi/${NAME}/${VERSION}/json" >/dev/null 2>&1; then
echo "Confirmed ${NAME}==${VERSION} on PyPI"; exit 0
fi
echo "Attempt ${i}: ${NAME}==${VERSION} not visible yet; retrying in 10s..."
sleep 10
done
echo "::error::${NAME}==${VERSION} did not appear on PyPI within 180s"
echo "Last curl response:"
curl -sS "https://pypi.org/pypi/${NAME}/${VERSION}/json" 2>&1 | tail -n 5 || true
exit 1
- name: Configure git
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git config --local url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/"
- name: Create and push git tag
id: tag
env:
PY_VERSION: ${{ needs.build-python.outputs.version }}
PY_NAME: ${{ needs.build-python.outputs.name }}
run: |
set -euo pipefail
TAG="python-sdk/v${PY_VERSION}"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists — skipping"
else
git tag -a "$TAG" -m "Release ${PY_NAME} ${PY_VERSION}"
git push origin "$TAG"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
RELEASE_VERSION: ${{ needs.build-python.outputs.version }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { owner, repo } = context.repo;
const tag = process.env.RELEASE_TAG;
const version = process.env.RELEASE_VERSION;
const name = `python-sdk/v${version}`;
const body = `Python SDK release: copilotkit ${version}\n\nhttps://pypi.org/project/copilotkit/${version}/`;
try {
const existing = await github.rest.repos.getReleaseByTag({ owner, repo, tag });
await github.rest.repos.updateRelease({
owner, repo,
release_id: existing.data.id,
tag_name: tag, name, body,
draft: false, prerelease: false,
});
} catch (error) {
if (error.status !== 404) throw error;
await github.rest.repos.createRelease({
owner, repo,
tag_name: tag, name, body,
draft: false, prerelease: false,
});
}
- name: Release summary
env:
PY_VERSION: ${{ needs.build-python.outputs.version }}
run: |
{
echo "## Python SDK Published"
echo ""
echo "- \`copilotkit@${PY_VERSION}\`"
echo "- https://pypi.org/project/copilotkit/${PY_VERSION}/"
} >> "$GITHUB_STEP_SUMMARY"
# ===========================================================================
# Slack #engr notification
#
# A single concise post to #engr when a release publishes (or fails).
# Runs after both lanes regardless of their outcome (`if: always()`). The
# load-bearing truth table lives in the unit-tested pure builder at
# scripts/release/lib/build-release-notification.ts; this job only feeds it the
# needs.* signals and posts what it returns. Suppressed entirely for canaries
# (mode=prerelease) and dry-runs — the builder returns should_post=false there.
# Webhook empty-guard mirrors showcase_validate.yml so an unset
# SLACK_WEBHOOK_ENGR secret does not break the shell or red this step.
# ===========================================================================
notify:
needs: [build, publish, build-python, publish-python]
# always() so we still report on a failed lane, but guard on a real release
# context: a workflow_dispatch (manual release) OR a *merged* PR. A
# closed-unmerged PR is not a release attempt and must not notify.
if: >
always() &&
(github.event_name == 'workflow_dispatch' ||
github.event.pull_request.merged == true)
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_ENGR }}
steps:
# Determine release intent IN THIS JOB, from the github.event payload +
# the PR changed-files API — NOT from needs.build*/needs.build-python
# outputs. The build jobs emit their intent signals (should_publish,
# pyproject_changed) AFTER failure-prone steps (SHA guards, setup-python,
# the PyPI version-compare), so a build job that dies before emitting them
# on a REAL release would leave the intent empty → no alert. Computing
# intent here, independent of whether the build jobs ran at all, closes
# that silent-swallow class. The builder gates the npm/PyPI FAILURE arms on
# these signals.
#
# This step runs FIRST — before Checkout/Setup/Install — on purpose:
# computing intent before any infra step means steps.intent.outputs.* are
# always populated even if a later infra step (the dependency install,
# checkout, or setup) fails. The failure() self-alert below gates its
# best-effort Slack post on these outputs, so running intent first
# guarantees that gate can still fire when the notify job dies during
# install — exactly the silent-swallow the self-watchdog exists to prevent.
# It needs only `gh api` (preinstalled), the github.event context, and
# GITHUB_TOKEN (in its own env), so it has no dependency on checkout/deps.
- name: Determine release intent
id: intent
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# npm intent: a manual dispatch, or a merged release/publish/* PR.
# Computed purely from event-context expressions (no API needed).
NPM_INTENDED="${{ (github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/publish/'))) && 'true' || 'false' }}"
echo "npm_intended=$NPM_INTENDED" >> "$GITHUB_OUTPUT"
# Python intent: a python_publish dispatch, OR a merged PR that changed
# sdk-python/pyproject.toml (per the GitHub PR changed-files API —
# robust to an uncomputed local merge_commit_sha). Default false.
PY_INTENDED="false"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
if [ "${{ inputs.python_publish }}" = "true" ]; then PY_INTENDED="true"; fi
elif [ "${{ github.event.pull_request.merged }}" = "true" ]; then
# Query the merged PR's changed files. Fail TOWARD paging: if the API
# call fails on a merged PR, default PY_INTENDED=true (never toward
# silence) and emit a ::warning::.
if FILES="$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate --jq '.[].filename' 2>/dev/null)"; then
if printf '%s\n' "$FILES" | grep -qx 'sdk-python/pyproject.toml'; then PY_INTENDED="true"; fi
else
echo "::warning::Could not list changed files for PR #${{ github.event.pull_request.number }} via the GitHub API — defaulting py_intended=true (fail toward paging, never toward silence)."
PY_INTENDED="true"
fi
fi
echo "py_intended=$PY_INTENDED" >> "$GITHUB_OUTPUT"
echo "npm_intended=$NPM_INTENDED py_intended=$PY_INTENDED"
- name: Checkout Repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
# Restore node_modules so `pnpm tsx` (and the release-config import the
# notifier does) resolves. The build/publish jobs install before running
# tsx for the same reason; without this the notify step fails and NO
# release notification can ever post.
- name: Install Dependencies
run: pnpm install --frozen-lockfile
# Compute the scope-correct npm URL: the monorepo packages live under the
# @copilotkit org page, while single-package scopes link to their package.
- name: Resolve npm URL for scope
id: npmurl
env:
SCOPE: ${{ needs.build.outputs.scope }}
run: |
set -euo pipefail
case "$SCOPE" in
angular)
NPM_URL="https://www.npmjs.com/package/@copilotkit/angular"
;;
*)
NPM_URL="https://www.npmjs.com/org/copilotkit"
;;
esac
echo "npm_url=$NPM_URL" >> "$GITHUB_OUTPUT"
- name: Build notification message
id: build
env:
MODE: ${{ needs.build.outputs.mode }}
NPM_RESULT: ${{ needs.publish.result }}
NPM_VER: ${{ needs.publish.outputs.version }}
BUILD_RESULT: ${{ needs.build.result }}
# Event-derived release intent computed in the `intent` step above
# (independent of the build jobs). The builder gates the npm/PyPI
# FAILURE arms on these so a build-job failure on a genuine release
# always pages, even if the build jobs emitted no usable outputs.
NPM_INTENDED: ${{ steps.intent.outputs.npm_intended }}
PY_INTENDED: ${{ steps.intent.outputs.py_intended }}
# should_publish still legitimately gates the PyPI SUCCESS arm (a real
# success means detect ran and emitted it).
PY_PUB: ${{ needs.build-python.outputs.should_publish }}
PY_RESULT: ${{ needs.publish-python.result }}
PY_BUILD_RESULT: ${{ needs.build-python.result }}
PY_VER: ${{ needs.build-python.outputs.version }}
SCOPE: ${{ needs.build.outputs.scope }}
DRY_RUN: ${{ inputs.dry-run }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Empty when no tag was created (non-stable / dry-run). The builder's
# empty-releaseUrl guard that consumes this is retained as
# DEFENSE-IN-DEPTH: the empty-releaseUrl-on-SUCCESS state is NOT
# currently reachable — the tag step is `if: success()`, so a tag-step
# failure flips the publish JOB to `failure` and routes to the failure
# arm rather than rendering an empty link. The guard protects against a
# FUTURE change making the tag step continue-on-error (publish success
# + empty tag output), which would otherwise render a broken empty
# "<|Release notes>" / "/releases/tag/" link — do NOT remove it.
RELEASE_URL: ${{ needs.publish.outputs.tag && format('{0}/{1}/releases/tag/{2}', github.server_url, github.repository, needs.publish.outputs.tag) || '' }}
NPM_URL: ${{ steps.npmurl.outputs.npm_url }}
# Empty-version guard, mirroring RELEASE_URL above: with no version
# the per-version PyPI URL would be a broken ".../copilotkit//" link,
# so fall back to the project root page.
PY_URL: ${{ needs.build-python.outputs.version && format('https://pypi.org/project/copilotkit/{0}/', needs.build-python.outputs.version) || 'https://pypi.org/project/copilotkit/' }}
run: pnpm tsx scripts/release/build-release-notification.ts
- name: Post to #engr
if: ${{ steps.build.outputs.should_post == 'true' && env.SLACK_WEBHOOK != '' && inputs.dry-run != true }}
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_WEBHOOK_ENGR }}
webhook-type: incoming-webhook
payload: |
{
"text": ${{ toJSON(steps.build.outputs.message) }}
}
- name: Log (no Slack — webhook unset)
if: ${{ steps.build.outputs.should_post == 'true' && env.SLACK_WEBHOOK == '' }}
run: |
echo "::warning::A release notification was ready to post but SLACK_WEBHOOK_ENGR is not set; no Slack notification sent."
# Self-watchdog: if any earlier step in THIS job failed (e.g. the
# dependency install or the builder crashed), the notifier itself is the
# thing that broke — so a real release alert could be silently swallowed.
# Emit a ::error:: and, when the webhook is configured, a minimal
# best-effort Slack post so the failure isn't completely invisible.
- name: Notifier failed — self-alert
if: ${{ failure() }}
run: |
echo "::error::The release notify job failed before it could post — a release alert may have been swallowed. Check this run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
- name: Notifier failed — best-effort Slack
# Guard on dry-run: the self-watchdog Slack post must honor the same
# silence invariant as the real notification — a dry-run is silent
# EVERYWHERE, so a notify-job failure during one must not post a false
# red page.
#
# ALSO guard on real-release-context via the robust, build-job-INDEPENDENT
# intent computed in the `intent` step: the notify job runs on EVERY
# merged PR (always()), so a routine non-release merge whose notify job
# hits a transient install/builder flake would otherwise self-page even
# though no release was attempted. Only self-alert when a release was
# actually in flight (npm_intended OR py_intended). This replaces the old
# npm-biased `mode != 'prerelease'` + should_publish/pyproject_changed
# heuristic — which both relied on the build jobs' outputs (the very
# signals that may be empty if a build job died) AND would have suppressed
# a python_publish self-alert under a prerelease-mode dispatch. The intent
# gate is correct and robust. (The ::error:: echo step above stays
# unconditional — only the Slack POST needs these guards.)
if: ${{ failure() && env.SLACK_WEBHOOK != '' && inputs.dry-run != true && (steps.intent.outputs.npm_intended == 'true' || steps.intent.outputs.py_intended == 'true') }}
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_WEBHOOK_ENGR }}
webhook-type: incoming-webhook
payload: |
{
"text": ${{ toJSON(format('🔴 *CopilotKit release notifier failed* — a release alert may have been swallowed · <{0}/{1}/actions/runs/{2}|View run>', github.server_url, github.repository, github.run_id)) }}
}
@@ -0,0 +1,106 @@
name: "Security: Fork PR Alert"
on:
pull_request:
types: [opened, synchronize, closed, reopened]
permissions:
pull-requests: write
contents: read
jobs:
fork-pr-monitor:
if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
steps:
- name: Check for suspicious patterns
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const pr = context.payload.pull_request;
const alerts = [];
// 1. Check for [skip ci] in commit messages from fork PRs
if (context.payload.action === 'opened' || context.payload.action === 'synchronize') {
const commits = await github.rest.pulls.listCommits({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
per_page: 100
});
const skipCiCommits = commits.data.filter(c =>
/\[skip ci\]|\[ci skip\]|\[no ci\]/i.test(c.commit.message)
);
if (skipCiCommits.length > 0) {
alerts.push(`⚠️ **[skip ci] detected in fork PR** — ${skipCiCommits.length} commit(s) contain CI skip directives. Commits: ${skipCiCommits.map(c => c.sha.substring(0, 7)).join(', ')}`);
}
}
// 2. Check for force-push that reduces changed files to 0 (evidence cleanup)
if (context.payload.action === 'synchronize') {
const prDetails = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number
});
if (prDetails.data.changed_files === 0) {
alerts.push(`🚨 **Zero-file fork PR after force-push** — PR was force-pushed to show 0 changed files. This matches the TanStack attack cleanup pattern.`);
}
}
// 3. Check for rapid open-then-close (PR used only to trigger CI)
if (context.payload.action === 'closed' && !pr.merged) {
const created = new Date(pr.created_at);
const closed = new Date(pr.closed_at);
const minutesOpen = (closed - created) / (1000 * 60);
if (minutesOpen < 30) {
alerts.push(`🚨 **Fork PR closed rapidly** — opened and closed within ${Math.round(minutesOpen)} minutes without merging. May indicate a CI-trigger-only attack.`);
}
}
// 4. Check for large bundled files (>5000 lines) added by the PR
if (context.payload.action === 'opened' || context.payload.action === 'synchronize') {
const files = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
per_page: 100
});
const largeNewFiles = files.data.filter(f =>
f.status === 'added' && f.additions > 5000
);
if (largeNewFiles.length > 0) {
alerts.push(`⚠️ **Large files added by fork PR** — ${largeNewFiles.map(f => '`' + f.filename + '` (' + f.additions + ' lines)').join(', ')}. Bundled payloads are a common supply-chain attack vector.`);
}
}
// Report alerts
if (alerts.length > 0) {
const body = [
'## 🔒 Supply Chain Security Alert',
'',
'This fork PR triggered the following security alerts:',
'',
alerts.join('\n\n'),
'',
'---',
'_Automated by supply-chain security monitor. See [TanStack incident](https://socket.dev/blog/tanstack-npm-packages-compromised-mini-shai-hulud-supply-chain-attack) for context._'
].join('\n');
// Post as PR comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: body
});
// Also set the action as failed annotation
core.warning(alerts.join(' | '));
}
+60
View File
@@ -0,0 +1,60 @@
name: security / zizmor
# zizmor runs static analysis over every workflow under .github/workflows
# looking for the well-known classes of GitHub Actions footguns: template
# injection from untrusted input, dangerous triggers like
# `pull_request_target`, unpinned `uses:` refs, excessive token scopes,
# secret exfil via job outputs, and a long tail of others.
#
# Findings at `low` confidence and above fail the job, so this acts as a
# blocking PR check. To deliberately allow a finding, suppress it in
# `.github/zizmor.yml` with a justification — never silently ignore.
on:
push:
branches: [main]
paths:
- ".github/workflows/**"
- ".github/actions/**"
- ".github/zizmor.yml"
pull_request:
paths:
- ".github/workflows/**"
- ".github/actions/**"
- ".github/zizmor.yml"
schedule:
# Catch findings introduced by newly-published advisories even when no
# workflow file changed this week.
- cron: "0 9 * * 1"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
zizmor:
name: Static analysis (zizmor)
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
# SARIF upload requires `security-events: write`, but we currently
# rely on the action's own annotations. Keep contents:read only.
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Run zizmor
# `min-severity: low` blocks PRs on the broadest set of findings
# without flagging hypothetical-only `informational` notes. Drop
# to `medium` if low-severity churn becomes a problem.
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
with:
min-severity: low
advanced-security: false
config: .github/zizmor.yml
+998
View File
@@ -0,0 +1,998 @@
name: "Showcase: Build & Push"
# Decoupled from the old "Build & Deploy" workflow. This workflow builds
# Docker images, pushes them to GHCR, and triggers Railway to redeploy.
# The separate "Showcase: Verify Deploy" workflow (showcase_deploy.yml)
# handles health verification.
#
# Critical design property: NO concurrency group with cancel-in-progress.
# Every push to main runs to completion so that rapid-fire PR merges never
# cancel in-flight builds. This was the #1 operational pain point with the
# old combined workflow.
on:
push:
branches: [main]
paths:
- "showcase/**"
- "examples/integrations/**"
- ".github/workflows/showcase_build.yml"
- ".github/workflows/showcase_build_check.yml"
workflow_dispatch:
inputs:
service:
description: "Service to build"
required: false
default: "all"
type: choice
options:
- all
- shell
- langgraph-python
- mastra
- crewai-crews
- pydantic-ai
- google-adk
- ag2
- agno
- llamaindex
- langgraph-fastapi
- langgraph-typescript
- langroid
- spring-ai
- strands
- strands-typescript
- ms-agent-python
- claude-sdk-typescript
- ms-agent-dotnet
- ms-agent-harness-dotnet
- claude-sdk-python
- built-in-agent
- shell-dojo
- shell-dashboard
- shell-docs
- showcase-harness
- showcase-aimock
- showcase-pocketbase
- webhooks
# Per-starter image builds (model B, §b stage 1). These map to the
# build-starters job below, NOT the showcase `build` job. "all"
# builds the full showcase fleet AND all 12 starters; a specific
# starter slug narrows to that one starter via STARTER_DISPATCH.
- starter-langgraph-python
- starter-mastra
- starter-langgraph-js
- starter-crewai-crews
- starter-pydantic-ai
- starter-adk
- starter-agno
- starter-llamaindex
- starter-langgraph-fastapi
- starter-strands-python
- starter-ms-agent-framework-python
- starter-ms-agent-framework-dotnet
# No top-level concurrency group. Every build run completes. This is the
# whole point of the decoupling: rapid pushes to main no longer cancel
# in-flight builds.
# Top-level env intentionally empty: env IDs are resolved inside
# showcase/scripts/redeploy-env.ts from the showcase/scripts/railway-envs.ts
# SSOT (and its emitted railway-envs.generated.json), not by this workflow.
permissions:
contents: read
jobs:
detect-changes:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
matrix: ${{ steps.build-matrix.outputs.matrix }}
has_changes: ${{ steps.build-matrix.outputs.has_changes }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Detect changed paths
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: filter
with:
# All filter values use list form for consistency. `shell`
# genuinely needs multiple paths; the others could collapse to
# single-line strings, but mixing styles (list vs. string)
# in the same filters block is easy to misread during review.
filters: |
workflow_config:
- '.github/workflows/showcase_build.yml'
- '.github/workflows/showcase_build_check.yml'
shell:
- 'showcase/shell/**'
- 'showcase/shared/**'
- 'showcase/scripts/**'
- 'showcase/integrations/*/manifest.yaml'
langgraph_python:
- 'showcase/integrations/langgraph-python/**'
mastra:
- 'showcase/integrations/mastra/**'
crewai_crews:
- 'showcase/integrations/crewai-crews/**'
pydantic_ai:
- 'showcase/integrations/pydantic-ai/**'
google_adk:
- 'showcase/integrations/google-adk/**'
ag2:
- 'showcase/integrations/ag2/**'
agno:
- 'showcase/integrations/agno/**'
llamaindex:
- 'showcase/integrations/llamaindex/**'
langgraph_fastapi:
- 'showcase/integrations/langgraph-fastapi/**'
langgraph_typescript:
- 'showcase/integrations/langgraph-typescript/**'
langroid:
- 'showcase/integrations/langroid/**'
spring_ai:
- 'showcase/integrations/spring-ai/**'
strands:
- 'showcase/integrations/strands/**'
strands_typescript:
- 'showcase/integrations/strands-typescript/**'
ms_agent_python:
- 'showcase/integrations/ms-agent-python/**'
claude_sdk_typescript:
- 'showcase/integrations/claude-sdk-typescript/**'
ms_agent_dotnet:
- 'showcase/integrations/ms-agent-dotnet/**'
ms_agent_harness_dotnet:
- 'showcase/integrations/ms-agent-harness-dotnet/**'
claude_sdk_python:
- 'showcase/integrations/claude-sdk-python/**'
built_in_agent:
- 'showcase/integrations/built-in-agent/**'
shell_dojo:
- 'showcase/shell-dojo/**'
- 'showcase/shared/**'
- 'showcase/scripts/**'
- 'showcase/integrations/*/manifest.yaml'
shell_dashboard:
- 'showcase/shell-dashboard/**'
- 'showcase/shared/**'
- 'showcase/scripts/**'
- 'showcase/integrations/*/manifest.yaml'
shell_docs:
- 'showcase/shell-docs/**'
- 'showcase/shared/**'
- 'showcase/scripts/**'
- 'showcase/integrations/*/manifest.yaml'
- 'showcase/integrations/*/docs-links.json'
- 'showcase/integrations/*/docs/setup/**'
- 'showcase/integrations/*/src/**'
showcase_harness:
- 'showcase/harness/**'
- 'showcase/shared/**'
- 'showcase/scripts/**'
- 'showcase/integrations/*/manifest.yaml'
showcase_aimock:
- 'showcase/aimock/**'
pocketbase:
# PB image is self-contained: PB binary + pb_migrations +
# pb_hooks + Dockerfile. No shared-module copy, so the slot
# is gated purely to its own subtree — it does NOT rebuild
# on every showcase push, only when migrations/hooks/Dockerfile
# change.
- 'showcase/pocketbase/**'
webhooks:
# Sentinel pattern that cannot match any in-tree path.
# The webhooks GHCR image is built by the showcase-eval-webhook
# repo's own release workflow, so we never want a push-driven
# build run to include it. workflow_dispatch can still target
# webhooks explicitly via the service input — that path skips
# paths-filter entirely.
- 'showcase/__no_match_webhooks_built_out_of_band__'
- name: Build service matrix
id: build-matrix
env:
DISPATCH_SERVICE: ${{ github.event.inputs.service }}
GITHUB_SHA_ENV: ${{ github.sha }}
GITHUB_REF_NAME_ENV: ${{ github.ref_name }}
FILTER_CHANGES: ${{ steps.filter.outputs.changes }}
run: |
# Full service config as JSON
# Fields: dispatch_name, filter_key, context, image, railway_id, timeout, lfs, build_args, build_args_sha, build_args_branch, dockerfile, health_path, skip_build
# skip_build (optional, boolean): when true, the Docker build step
# is skipped for this slot (image is built out-of-band by another
# workflow/repo). Currently used by `webhooks` (built by the
# showcase-eval-webhook repo's own release workflow).
# health_path: historical field retained in the matrix for human
# reference. The actual verify probe is driven by per-service
# drivers in verify-deploy.ts (showcase/scripts/verify-deploy.ts),
# NOT by this field — it is informational only at this layer.
ALL_SERVICES='[
{"dispatch_name":"shell","filter_key":"shell","context":".","image":"showcase-shell","railway_id":"40eea0da-6071-4ea8-bdb9-39afb19225ec","timeout":10,"lfs":true,"build_args_sha":"__GH_SHA__","build_args_branch":"__GH_REF_NAME__","dockerfile":"showcase/shell/Dockerfile","health_path":"/"},
{"dispatch_name":"langgraph-python","filter_key":"langgraph_python","context":"showcase/integrations/langgraph-python","image":"showcase-langgraph-python","railway_id":"90d03214-4569-41b0-b4c1-6438a8a7b203","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"mastra","filter_key":"mastra","context":"showcase/integrations/mastra","image":"showcase-mastra","railway_id":"d7979eb7-2405-4aab-ad21-438f4a1b08af","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"crewai-crews","filter_key":"crewai_crews","context":"showcase/integrations/crewai-crews","image":"showcase-crewai-crews","railway_id":"0e9c284d-8d87-4fcf-9f82-6b704d7e4bd4","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"pydantic-ai","filter_key":"pydantic_ai","context":"showcase/integrations/pydantic-ai","image":"showcase-pydantic-ai","railway_id":"0a106173-2282-4887-a994-0ca276a99d69","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"google-adk","filter_key":"google_adk","context":"showcase/integrations/google-adk","image":"showcase-google-adk","railway_id":"87f60507-5a3d-4b8a-9e23-2b1de85d939c","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"ag2","filter_key":"ag2","context":"showcase/integrations/ag2","image":"showcase-ag2","railway_id":"4a37481b-f264-4eb7-a9cd-0a9ebb9ac05c","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"agno","filter_key":"agno","context":"showcase/integrations/agno","image":"showcase-agno","railway_id":"32cab80b-e329-45bd-9c73-c4e1ddc94305","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"llamaindex","filter_key":"llamaindex","context":"showcase/integrations/llamaindex","image":"showcase-llamaindex","railway_id":"285386e8-492d-4cb8-b632-0a7d4607378f","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"langgraph-fastapi","filter_key":"langgraph_fastapi","context":"showcase/integrations/langgraph-fastapi","image":"showcase-langgraph-fastapi","railway_id":"06cccb5c-59f4-46b5-8adc-7113e77011a4","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"langgraph-typescript","filter_key":"langgraph_typescript","context":"showcase/integrations/langgraph-typescript","image":"showcase-langgraph-typescript","railway_id":"66246d3b-a18e-46f0-be51-5f3ff7a36e5a","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"langroid","filter_key":"langroid","context":"showcase/integrations/langroid","image":"showcase-langroid","railway_id":"6dd9cb0a-66cc-46f1-972e-7cd74756157d","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"spring-ai","filter_key":"spring_ai","context":"showcase/integrations/spring-ai","image":"showcase-spring-ai","railway_id":"eed5d041-91be-4282-b414-beea00843401","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"strands","filter_key":"strands","context":"showcase/integrations/strands","image":"showcase-strands","railway_id":"92e1cfad-ad53-403f-ab2b-5ab380832232","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"strands-typescript","filter_key":"strands_typescript","context":"showcase/integrations/strands-typescript","image":"showcase-strands-typescript","railway_id":"d6f47c8c-a0a1-4dbe-991c-50f8463fd68d","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"ms-agent-python","filter_key":"ms_agent_python","context":"showcase/integrations/ms-agent-python","image":"showcase-ms-agent-python","railway_id":"655db75a-af8d-427d-a4f9-441570ae5003","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"claude-sdk-typescript","filter_key":"claude_sdk_typescript","context":"showcase/integrations/claude-sdk-typescript","image":"showcase-claude-sdk-typescript","railway_id":"18a98727-5700-44aa-b497-b60795dbbd6a","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"ms-agent-dotnet","filter_key":"ms_agent_dotnet","context":"showcase/integrations/ms-agent-dotnet","image":"showcase-ms-agent-dotnet","railway_id":"beeb2dd6-87a4-4599-aa07-0578f7bd6519","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"ms-agent-harness-dotnet","filter_key":"ms_agent_harness_dotnet","context":"showcase/integrations/ms-agent-harness-dotnet","image":"showcase-ms-agent-harness-dotnet","railway_id":"6343d7f9-6c3f-4c8d-9a6e-79f03d2f1e37","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"claude-sdk-python","filter_key":"claude_sdk_python","context":"showcase/integrations/claude-sdk-python","image":"showcase-claude-sdk-python","railway_id":"b122ab65-9854-4cb2-a68e-b50ff13f7481","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"built-in-agent","filter_key":"built_in_agent","context":"showcase/integrations/built-in-agent","image":"showcase-built-in-agent","railway_id":"f4f8371a-bc46-45b2-b6d4-9c9af608bdbf","timeout":15,"lfs":false,"build_args":"","dockerfile":"","health_path":"/api/health"},
{"dispatch_name":"shell-dojo","filter_key":"shell_dojo","context":".","image":"showcase-shell-dojo","railway_id":"7ad1ece7-2228-49cd-8a78-bddf30322907","timeout":10,"lfs":false,"build_args":"","dockerfile":"showcase/shell-dojo/Dockerfile","health_path":"/"},
{"dispatch_name":"shell-dashboard","filter_key":"shell_dashboard","context":".","image":"showcase-shell-dashboard","railway_id":"4d5dfd74-be61-40b2-8564-b53b7dd4c15b","timeout":10,"lfs":true,"build_args_sha":"__GH_SHA__","build_args_branch":"__GH_REF_NAME__","dockerfile":"showcase/shell-dashboard/Dockerfile","health_path":"/"},
{"dispatch_name":"shell-docs","filter_key":"shell_docs","context":".","image":"showcase-shell-docs","railway_id":"7badfb8d-4228-414c-9145-b4026803714f","timeout":10,"lfs":true,"build_args_sha":"__GH_SHA__","build_args_branch":"__GH_REF_NAME__","dockerfile":"showcase/shell-docs/Dockerfile","health_path":"/"},
{"dispatch_name":"showcase-harness","filter_key":"showcase_harness","context":".","image":"showcase-harness","railway_id":"3a14bfed-0537-4d71-897b-7c593dca161d","timeout":20,"lfs":false,"build_args":"","dockerfile":"showcase/harness/Dockerfile","health_path":"/health"},
{"dispatch_name":"showcase-aimock","filter_key":"showcase_aimock","context":"showcase/aimock","image":"showcase-aimock","railway_id":"0fa0435d-8a66-46f0-84fd-e4250b580013","timeout":5,"lfs":false,"build_args":"","dockerfile":"showcase/aimock/Dockerfile","health_path":"/health"},
{"dispatch_name":"showcase-pocketbase","filter_key":"pocketbase","context":"showcase/pocketbase","image":"showcase-pocketbase","railway_id":"ba11e854-d695-4738-9a45-2b0776788824","timeout":10,"lfs":false,"build_args":"","dockerfile":"showcase/pocketbase/Dockerfile","health_path":"/api/health"},
{"dispatch_name":"webhooks","filter_key":"webhooks","context":".","image":"showcase-eval-webhook","railway_id":"ba6acc13-7585-41fe-a5ee-585b34a58fcd","timeout":5,"lfs":false,"build_args":"","dockerfile":"","health_path":"/health","skip_build":true}
]'
DISPATCH="$DISPATCH_SERVICE"
CHANGES="${FILTER_CHANGES:-[]}"
# Filter services based on three dispatch modes:
# dispatch == "all": manual "deploy all" — include every service unconditionally.
# This re-pulls :latest for every matrix slot (~38 services); intentional for
# drift-rebuild runs and full-fleet restarts. Do NOT try to short-circuit
# unchanged services here — operators invoke "all" precisely when they want
# the fleet re-deployed regardless of git state (cache poisoning, base-image CVE).
# (paths-filter is unreliable on workflow_dispatch because there is no 'before' SHA,
# so we must NOT consult $changes here — doing so silently produces an empty matrix).
# dispatch == <specific service>: narrow to that service only (skips paths-filter so
# drift-rebuild and manual single-service dispatches work regardless of $changes).
# dispatch == "": push event — include services whose filter_key appears in paths-filter CHANGES.
MATRIX=$(echo "$ALL_SERVICES" | jq -c --arg dispatch "$DISPATCH" --argjson changes "$CHANGES" --arg sha "$GITHUB_SHA_ENV" --arg ref "$GITHUB_REF_NAME_ENV" '
[.[] |
if .build_args_sha == "__GH_SHA__" then .build_args_sha = $sha else . end |
if .build_args_branch == "__GH_REF_NAME__" then .build_args_branch = $ref else . end |
(.filter_key as $fk | select(
$dispatch == "all" or
($dispatch != "" and $dispatch != "all" and $dispatch == .dispatch_name) or
($dispatch == "" and (($changes | index("workflow_config") != null) or ($changes | index($fk) != null)))
))]
')
# Fail loudly on typo'd workflow_dispatch inputs. If a user types a
# service name that doesn't exist in ALL_SERVICES, the jq filter
# silently produces [] and the run shows green with zero work
# done — a common "did my dispatch deploy?" footgun. The `all` and
# empty (push-event) modes legitimately produce [] when there are
# no changes and must still succeed.
#
# The `starter-*` namespace is OWNED by the detect-starter-changes
# job (which scopes its OWN fail-loud to `starter-*`). A
# `service=starter-<slug>` dispatch legitimately yields [] here
# (no showcase service is named `starter-*`), so it must SKIP — an
# empty showcase matrix, NOT a fail-loud exit 1. Mirrors how the
# starter job scopes its fail-loud to the `starter-*` namespace.
case "$DISPATCH" in
starter-*) ;; # starter namespace → empty showcase matrix + skip
"" | "all") ;; # push / full-fleet → [] is legitimate
*)
if [ "$MATRIX" = "[]" ]; then
echo "::error::workflow_dispatch service='$DISPATCH' did not match any entry in ALL_SERVICES — check the dispatch_name spelling"
exit 1
fi
;;
esac
echo "matrix=$MATRIX" >> $GITHUB_OUTPUT
if [ "$MATRIX" = "[]" ]; then
echo "has_changes=false" >> $GITHUB_OUTPUT
else
echo "has_changes=true" >> $GITHUB_OUTPUT
fi
check-lockfile:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (enforced via corepack).
# Earlier revisions hard-pinned `version: 10.13.1` which silently
# drifted from package.json whenever the repo bumped pnpm —
# resulting in lockfile-vs-engine mismatches that only surfaced on
# the slow `--frozen-lockfile` path.
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
- run: pnpm install --frozen-lockfile --ignore-scripts
verify-image-refs:
needs: [detect-changes]
if: needs.detect-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
timeout-minutes: 3
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
- name: Verify Railway image refs
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
run: npx tsx showcase/scripts/verify-railway-image-refs.ts
build:
needs: [detect-changes, check-lockfile, verify-image-refs]
if: needs.detect-changes.outputs.has_changes == 'true'
runs-on: depot-ubuntu-24.04-4
timeout-minutes: ${{ fromJSON(matrix.service.timeout) }}
permissions:
id-token: write
contents: read
packages: write
strategy:
fail-fast: false
matrix:
service: ${{ fromJSON(needs.detect-changes.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# Always pull LFS. Nearly every integration ships LFS-tracked demo
# assets under public/ (public/demo-files/*.png|*.pdf,
# public/demo-audio/*.wav per root .gitattributes). With lfs:false
# these check out as 130-byte LFS pointer stubs and get COPYed into
# the image as text — the deployed image then serves the pointer
# with HTTP 200 and the frontend's magic-bytes guard rejects it,
# breaking the multimodal test pill. The per-slot matrix.service.lfs
# flag was true only for shell/shell-dashboard/shell-docs, leaving
# every framework integration shipping pointer stubs. Uniform
# lfs:true is the least-error-prone fix: a new integration that adds
# demo assets is covered automatically, with no matrix flag to
# forget.
lfs: true
persist-credentials: false
- name: Setup Depot
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1
- name: Login to GHCR
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Prepare build args
id: build-args
env:
BUILD_ARGS_SHA: ${{ matrix.service.build_args_sha }}
BUILD_ARGS_BRANCH: ${{ matrix.service.build_args_branch }}
run: |
# set -euo pipefail: without `-e`, a transient `$GITHUB_OUTPUT`
# write failure (disk pressure, ENOSPC) could silently produce
# empty build-args and we'd ship an image without COMMIT_SHA /
# BRANCH baked in — invisible drift between build label and
# what's actually running.
set -euo pipefail
ARGS=""
if [ -n "$BUILD_ARGS_SHA" ]; then
ARGS="COMMIT_SHA=${BUILD_ARGS_SHA}"
ARGS="${ARGS}"$'\n'"BRANCH=${BUILD_ARGS_BRANCH}"
fi
# Use delimiter to safely pass multiline value
echo "args<<BUILDARGS_EOF" >> $GITHUB_OUTPUT
echo "$ARGS" >> $GITHUB_OUTPUT
echo "BUILDARGS_EOF" >> $GITHUB_OUTPUT
- name: Copy shared modules into build context
run: |
set -euo pipefail
CONTEXT="${{ matrix.service.context }}"
# Idempotent copy: if a stale `shared_python`/`shared_typescript`
# already exists (previous failed run on the same runner, or a
# checkout artifact), remove it first. `cp -r src dst` into an
# existing directory nests source-inside-destination, which
# would silently produce a broken build context.
if [ -d "showcase/shared/python" ] && [ -d "$CONTEXT" ]; then
rm -rf "$CONTEXT/shared_python"
cp -r showcase/shared/python "$CONTEXT/shared_python"
fi
if [ -d "showcase/shared/typescript/tools" ] && [ -d "$CONTEXT" ]; then
rm -rf "$CONTEXT/shared_typescript"
mkdir -p "$CONTEXT/shared_typescript"
cp -r showcase/shared/typescript/tools "$CONTEXT/shared_typescript/tools"
fi
# Dereference tools/, shared-tools/, and _shared/ symlinks for the
# Docker context. Integration directories use symlinks pointing to
# ../../shared/python/tools etc., and _shared -> ../_shared for the
# CVDIAG bootstrap modules. Docker cannot follow symlinks outside
# the build context (buildkit fails the checksum with "too many
# symlinks: /_shared"), so we replace each symlink with a real copy
# of its target. Mirrors stage_shared() in
# showcase/scripts/cli/_common.sh (the local bin/showcase path).
for link_name in tools shared-tools _shared; do
link_path="$CONTEXT/$link_name"
if [ -L "$link_path" ]; then
target="$(readlink -f "$link_path")"
if [ -d "$target" ]; then
rm "$link_path"
cp -r "$target" "$link_path"
fi
fi
done
- name: Build and push
if: ${{ matrix.service.skip_build != true }}
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
with:
project: m2kw2wmmcp
context: ${{ matrix.service.context }}
file: ${{ matrix.service.dockerfile != '' && matrix.service.dockerfile || format('{0}/Dockerfile', matrix.service.context) }}
# Pin amd64: Railway and GHCR serve x86 hosts. An arm64-only
# image crashes on pull with "does not have a linux/amd64
# variant available".
platforms: linux/amd64
push: true
tags: |
ghcr.io/copilotkit/${{ matrix.service.image }}:latest
ghcr.io/copilotkit/${{ matrix.service.image }}:${{ github.sha }}
build-args: ${{ steps.build-args.outputs.args }}
- name: Write per-slot build result
if: always()
env:
SERVICE: ${{ matrix.service.dispatch_name }}
BUILD_STATUS: ${{ job.status }}
run: |
# job.status is one of: success, failure, cancelled. Normalize
# cancelled→skipped to match the BuildOutcome contract in
# showcase/scripts/lib/build-outputs.ts. We deliberately do NOT
# write to $GITHUB_OUTPUT — matrix-slot outputs are not
# aggregable across slots in GitHub Actions, so we publish the
# per-slot result as an artifact instead. The downstream
# aggregator job downloads every `build-result-*` artifact.
case "$BUILD_STATUS" in
success) STATUS=success ;;
failure) STATUS=failure ;;
*) STATUS=skipped ;;
esac
mkdir -p "$RUNNER_TEMP/build-result"
printf '{"service":"%s","status":"%s"}\n' "$SERVICE" "$STATUS" \
> "$RUNNER_TEMP/build-result/result.json"
- name: Upload per-slot build-result artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
# Canonical per-slot name (see buildResultArtifactName in
# showcase/scripts/lib/build-outputs.ts). The aggregator
# downloads every artifact matching `build-result-*`.
name: build-result-${{ matrix.service.dispatch_name }}
path: ${{ runner.temp }}/build-result/result.json
if-no-files-found: error
retention-days: 7
# ────────────────────────────────────────────────────────────────────
# Per-starter image publish (model B, §b stage 1 / Phase 1).
#
# Fully decoupled from the showcase `build`→`aggregate`→`redeploy` chain
# above: starters are self-contained npm projects (no monorepo-source
# build, no shared-module copy) and build from their own root Dockerfile
# at examples/integrations/<slug>/Dockerfile (the single-image deployable:
# Next.js frontend + agent, EXPOSE 3000, CMD entrypoint.sh — distinct from
# the docker/Dockerfile.app + docker/Dockerfile.agent split stack used by
# docker-compose.test.yml). Published to ghcr.io/copilotkit/starter-<slug>
# (the `starter-` prefix is disjoint from `showcase-*`; S2's harness
# discovery filters on namePrefix "starter-"). Railway deploy is the
# gated S5 — NOT done here.
#
# The 12 starter slugs are the matrix source of truth (the smoke matrix in
# test_smoke-starter.yml and STARTER_TO_COLUMN in
# showcase/harness/src/probes/helpers/starter-mapping.ts). The dashboard
# column remap lives in the harness (§a), so this layer uses raw starter
# slugs.
detect-starter-changes:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
matrix: ${{ steps.starter-matrix.outputs.matrix }}
has_changes: ${{ steps.starter-matrix.outputs.has_changes }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Detect changed starter paths
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: filter
with:
filters: |
workflow_config:
- '.github/workflows/showcase_build.yml'
langgraph_python:
- 'examples/integrations/langgraph-python/**'
mastra:
- 'examples/integrations/mastra/**'
langgraph_js:
- 'examples/integrations/langgraph-js/**'
crewai_crews:
- 'examples/integrations/crewai-crews/**'
pydantic_ai:
- 'examples/integrations/pydantic-ai/**'
adk:
- 'examples/integrations/adk/**'
agno:
- 'examples/integrations/agno/**'
llamaindex:
- 'examples/integrations/llamaindex/**'
langgraph_fastapi:
- 'examples/integrations/langgraph-fastapi/**'
strands_python:
- 'examples/integrations/strands-python/**'
ms_agent_framework_python:
- 'examples/integrations/ms-agent-framework-python/**'
ms_agent_framework_dotnet:
- 'examples/integrations/ms-agent-framework-dotnet/**'
- name: Build starter matrix
id: starter-matrix
env:
DISPATCH_SERVICE: ${{ github.event.inputs.service }}
FILTER_CHANGES: ${{ steps.filter.outputs.changes }}
run: |
set -euo pipefail
# One slot per starter. `slug` is the examples/integrations/<slug>
# directory name; `image` is the published GHCR repo (starter-<slug>);
# `filter_key` matches the paths-filter key above.
ALL_STARTERS='[
{"slug":"langgraph-python","image":"starter-langgraph-python","filter_key":"langgraph_python"},
{"slug":"mastra","image":"starter-mastra","filter_key":"mastra"},
{"slug":"langgraph-js","image":"starter-langgraph-js","filter_key":"langgraph_js"},
{"slug":"crewai-crews","image":"starter-crewai-crews","filter_key":"crewai_crews"},
{"slug":"pydantic-ai","image":"starter-pydantic-ai","filter_key":"pydantic_ai"},
{"slug":"adk","image":"starter-adk","filter_key":"adk"},
{"slug":"agno","image":"starter-agno","filter_key":"agno"},
{"slug":"llamaindex","image":"starter-llamaindex","filter_key":"llamaindex"},
{"slug":"langgraph-fastapi","image":"starter-langgraph-fastapi","filter_key":"langgraph_fastapi"},
{"slug":"strands-python","image":"starter-strands-python","filter_key":"strands_python"},
{"slug":"ms-agent-framework-python","image":"starter-ms-agent-framework-python","filter_key":"ms_agent_framework_python"},
{"slug":"ms-agent-framework-dotnet","image":"starter-ms-agent-framework-dotnet","filter_key":"ms_agent_framework_dotnet"}
]'
# Dispatch modes (mirror the showcase detect-changes job):
# "all" → every starter (full-fleet rebuild).
# "starter-<slug>" → that one starter (strip "starter-" prefix to match .image).
# "<showcase service slug>" → no starters (this is a showcase-only dispatch).
# "" (push event) → starters whose filter_key appears in CHANGES
# (or workflow_config touched → rebuild all).
DISPATCH="${DISPATCH_SERVICE:-}"
CHANGES="${FILTER_CHANGES:-[]}"
# `.image` is exactly "starter-<slug>", which is also the
# workflow_dispatch choice value, so a specific-starter dispatch
# matches `$dispatch == .image` directly.
MATRIX=$(echo "$ALL_STARTERS" | jq -c --arg dispatch "$DISPATCH" --argjson changes "$CHANGES" '
[.[] |
(.filter_key as $fk | select(
$dispatch == "all" or
($dispatch != "" and $dispatch != "all" and $dispatch == .image) or
($dispatch == "" and (($changes | index("workflow_config") != null) or ($changes | index($fk) != null)))
))]
')
# Fail loudly on a typo'd starter dispatch (mirrors showcase job).
# Only applies to the starter-* dispatch namespace; a showcase
# service slug or "all" legitimately yields [] here.
case "$DISPATCH" in
starter-*)
if [ "$MATRIX" = "[]" ]; then
echo "::error::workflow_dispatch service='$DISPATCH' did not match any starter — check the slug"
exit 1
fi
;;
esac
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
if [ "$MATRIX" = "[]" ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
build-starters:
needs: [detect-starter-changes]
if: needs.detect-starter-changes.outputs.has_changes == 'true'
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 20
permissions:
id-token: write
contents: read
packages: write
strategy:
fail-fast: false
matrix:
starter: ${{ fromJSON(needs.detect-starter-changes.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Depot
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1
- name: Login to GHCR
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push starter image
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
with:
project: m2kw2wmmcp
context: examples/integrations/${{ matrix.starter.slug }}
file: examples/integrations/${{ matrix.starter.slug }}/Dockerfile
# Pin amd64: Railway and GHCR serve x86 hosts. Depot defaults to
# the runner's native arch, so an arm64-only image would crash on
# pull with "does not have a linux/amd64 variant available".
platforms: linux/amd64
push: true
tags: |
ghcr.io/copilotkit/${{ matrix.starter.image }}:latest
ghcr.io/copilotkit/${{ matrix.starter.image }}:${{ github.sha }}
aggregate-build-results:
name: Aggregate build results
needs: [detect-changes, build]
if: ${{ !cancelled() && needs.detect-changes.outputs.has_changes == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 3
permissions:
contents: read
outputs:
results: ${{ steps.collect.outputs.results }}
any_success: ${{ steps.collect.outputs.any_success }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
cache: pnpm
- name: Install
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Download all per-slot build-result artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
# `pattern` matches every per-slot artifact emitted by the
# build matrix. `merge-multiple: false` keeps each artifact
# in its own subdirectory so we can iterate them deterministically.
pattern: build-result-*
path: ${{ runner.temp }}/build-results-in
merge-multiple: false
- name: Collect per-service build outcomes
id: collect
env:
INPUT_DIR: ${{ runner.temp }}/build-results-in
OUTPUT_DIR: ${{ runner.temp }}/build-results-out
run: |
set -euo pipefail
mkdir -p "$OUTPUT_DIR"
# Each per-slot artifact extracts to
# $INPUT_DIR/build-result-<dispatch_name>/result.json
# The aggregator script (showcase/scripts/aggregate-build-results.ts)
# reads them, merges via the shared helper (mergeBuildResultFiles),
# writes $OUTPUT_DIR/results.json, and appends `results` +
# `any_success` to $GITHUB_OUTPUT. The contract (service +
# status enum) is enforced in one place (build-outputs.ts).
npx tsx showcase/scripts/aggregate-build-results.ts
- name: Upload aggregated build-results artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: build-results
path: ${{ runner.temp }}/build-results-out/results.json
if-no-files-found: error
retention-days: 7
redeploy-staging:
name: Trigger Railway staging redeploy
needs: [detect-changes, build, aggregate-build-results]
# Run if at least one service was in the matrix AND the build job
# was not outright cancelled or skipped AND at least one slot
# succeeded (per aggregate-build-results.outputs.any_success). The
# any_success guard is the explicit "do not redeploy when nothing
# was pushed" check — without it, an all-failed build run would
# still kick a redeploy that just re-pulls the stale :latest and
# silently looks healthy. The skipped/cancelled checks on the build
# job still cover the "verify-image-refs gate blocked the build job
# entirely" path. Partial build failures (fail-fast: false) still
# surface as needs.build.result == 'failure' with any_success ==
# 'true', so redeploying what did get pushed is preserved.
if: >-
${{ !cancelled()
&& needs.detect-changes.outputs.has_changes == 'true'
&& needs.build.result != 'skipped'
&& needs.build.result != 'cancelled'
&& needs.aggregate-build-results.outputs.any_success == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
- name: Compute changed-service list from build matrix
id: changed
env:
MATRIX_JSON: ${{ needs.detect-changes.outputs.matrix }}
BUILD_RESULTS_JSON: ${{ needs.aggregate-build-results.outputs.results }}
run: |
# We feed the redeploy script with the INTERSECTION of:
# (a) the scheduled build matrix (detect-changes.outputs.matrix —
# JSON array of objects with `dispatch_name`), and
# (b) the SUCCESS set from the aggregator
# (aggregate-build-results.outputs.results — JSON array of
# `{service: <dispatch_name>, status: success|failure|skipped}`;
# the `service` field is the dispatch_name; shape defined in
# showcase/scripts/lib/build-outputs.ts).
# Without this intersection a service whose Docker build FAILED
# would still be in the redeploy CSV, Railway would re-pull its
# stale `:latest`, and the verify workflow would report it as a
# fresh, healthy deploy — a false green. Skipped slots are also
# excluded (only `status == "success"` qualifies).
set -euo pipefail
matrix_names="$(echo "$MATRIX_JSON" | jq -r '[.[] | .dispatch_name]')"
success_names="$(echo "$BUILD_RESULTS_JSON" | jq -r '[.[] | select(.status == "success") | .service]')"
csv="$(jq -rn --argjson m "$matrix_names" --argjson s "$success_names" \
'($m | map(select(. as $n | $s | index($n)))) | join(",")')"
if [ -z "$csv" ]; then
echo "No services in matrix ∩ success-set — skipping redeploy."
echo "services=" >> "$GITHUB_OUTPUT"
else
echo "services=$csv" >> "$GITHUB_OUTPUT"
fi
echo "Computed services CSV (matrix ∩ build-success): $csv"
- name: Redeploy changed services in staging
if: steps.changed.outputs.services != ''
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
SERVICES_CSV: ${{ steps.changed.outputs.services }}
# Bridge the per-service redeploy summary to showcase_deploy.yml's
# `enforce-redeploy-gate` (consumed via the `redeploy-summary`
# artifact, extracted to `.redeploy/summary.json`). redeploy-env.ts
# writes this path atomically (.tmp → rename) but does NOT create
# parent dirs, so the step below mkdir's `.redeploy` first.
REDEPLOY_SUMMARY_JSON: .redeploy/summary.json
run: |
# Staging is non-blocking by design: the script always exits 0
# and writes per-service failures into $GITHUB_STEP_SUMMARY. The
# verify-deploy workflow is the real release gate.
mkdir -p .redeploy
npx tsx showcase/scripts/redeploy-env.ts staging --services "$SERVICES_CSV"
- name: Upload redeploy summary
# Upload is MANDATORY whenever a redeploy was attempted (services
# != ''). Both failure modes red the build — no false-green path:
#
# (A) HARD crash inside redeploy-env.ts BEFORE summary.json is
# written. redeploy-env.ts writes the summary atomically
# (.tmp → rename) AFTER the per-service loop completes, so
# a crash leaves no file. The redeploy step itself exits
# non-zero on that crash and fails the redeploy-staging
# job; this upload step is then skipped entirely by
# step-failure propagation. Build → red.
#
# (B) redeploy step exits 0 but summary.json is absent (e.g. a
# logic bug skipped the write). `if-no-files-found: error`
# reds this step → reds the redeploy-staging job → reds the
# build. The deploy workflow's resolve-matrix.if
# (workflow_run.conclusion == 'success') then blocks the
# deploy run from starting at all.
#
# Do NOT add hashFiles() guards here: that would silently skip
# the upload on (B), the deploy workflow would see "artifact
# absent" via check-redeploy-summary, treat it as "nothing
# redeployed", skip the gate, and ship a false-green.
# The legitimate "services == '' → nothing redeployed → no upload"
# path is preserved by the services != '' guard.
#
# If a future change ever switches this step to `if: always()`,
# `if-no-files-found: error` STILL reds path (A): the redeploy
# step's non-zero exit on a HARD crash is independent of upload
# gating, and `if-no-files-found: error` on `always()` then trips
# because summary.json was never written. So neither relaxation
# alone opens a false-green window.
#
# However, swapping the guard to `if: always()` would ALSO red the
# legitimate `services == ''` (nothing-to-redeploy) path — no
# summary.json is written there either, so `if-no-files-found:
# error` would trip on every push that didn't redeploy anything.
# Net effect: trades the (already-closed) false-green risk for a
# false-red on every non-buildable push. Don't do it.
if: steps.changed.outputs.services != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
# Artifact name MUST stay `redeploy-summary`: showcase_deploy.yml's
# `resolve-matrix` job downloads it by this exact name and reads
# `.redeploy/summary.json` inside.
name: redeploy-summary
path: .redeploy/summary.json
if-no-files-found: error
retention-days: 7
notify-all-builds-failed:
name: Notify all builds failed (staging unchanged)
needs: [detect-changes, build, aggregate-build-results]
# Explicit "everything failed; nothing redeployed" signal — distinct
# from the `notify:` job below which fires on any build-job failure
# (some slots may still have succeeded in that case). Both jobs can
# fire; that's intentional and matches the Slack alert SOP.
if: >-
${{ !cancelled()
&& needs.detect-changes.outputs.has_changes == 'true'
&& needs.build.result == 'failure'
&& needs.aggregate-build-results.outputs.any_success == 'false' }}
# The `needs.build.result == 'failure'` clause guards against the case
# where the build job itself was SKIPPED (e.g. verify-image-refs failed
# upstream, so the matrix never executed). Without it, the aggregator
# would still report `any_success=false` and we'd Slack-spam "all
# builds failed" even though builds never ran — a misleading alert.
runs-on: ubuntu-latest
timeout-minutes: 3
permissions:
contents: read
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
steps:
- name: Mark workflow red (no service succeeded)
run: |
echo "::error::All builds failed for this run; staging redeploy skipped; :latest unchanged."
exit 1
- name: Slack #oss-alerts
if: always() && env.SLACK_WEBHOOK != ''
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
webhook-type: incoming-webhook
# NOTE: `\n` is NOT an escape sequence in GitHub Actions expression
# string literals — `format()` would emit the two literal characters
# backslash+n, which `toJSON` then encodes as `\\n`, so Slack renders
# a literal "\n". Inject real newlines via `fromJSON('"\n"')` ({4}) so
# `toJSON` encodes them as a single `\n` that Slack honors.
payload: |
{ "text": ${{ toJSON(format(':x: *Showcase: all builds failed*{4}staging unchanged (no redeploy){4}Commit: `{0}` by {1}{4}<https://github.com/{2}/actions/runs/{3}|View run>', github.sha, github.actor, github.repository, github.run_id, fromJSON('"\n"'))) }} }
notify:
name: Notify on failure
# Cover the whole detect→build→aggregate→redeploy pipeline. `needs: [build]`
# alone meant a failure in aggregate-build-results or redeploy-staging
# produced ZERO Slack signal (verify just never ran). Likewise, a red
# in detect-changes, check-lockfile, or verify-image-refs would skip
# `build` (skipped != failure) so the original `needs: [build]` form
# also missed those pre-build red paths. Adding the early-stage jobs
# to `needs` extends the alert surface to the full workflow, matching
# the workflow-level notify pattern in showcase_promote.yml.
# `if: failure()` already skips when none of the needs failed — so
# this still no-ops for the "no changes → build skipped" path, since
# skipped != failure. If `notify-all-builds-failed` also fires
# (genuine all-failed case), both alerts firing for the same event is
# acceptable per the alert SOP.
needs:
[
detect-changes,
check-lockfile,
verify-image-refs,
build,
aggregate-build-results,
redeploy-staging,
]
if: failure()
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
timeout-minutes: 5
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
steps:
- name: Slack alert
if: env.SLACK_WEBHOOK != ''
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
webhook-type: incoming-webhook
# NOTE: `\n` is NOT an escape sequence in GitHub Actions expression
# string literals — `format()` would emit the two literal characters
# backslash+n, which `toJSON` then encodes as `\\n`, so Slack renders
# a literal "\n". Inject real newlines via `fromJSON('"\n"')` ({4}) so
# `toJSON` encodes them as a single `\n` that Slack honors.
payload: |
{ "text": ${{ toJSON(format(':x: *Showcase Build Failed*{4}Commit: `{0}` by {1}{4}<https://github.com/{2}/actions/runs/{3}|View run>', github.sha, github.actor, github.repository, github.run_id, fromJSON('"\n"'))) }} }
- name: Comment on PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.sha,
});
const merged = prs.find(pr => pr.merged_at);
if (!merged) {
console.log('No merged PR found for this commit — skipping comment');
return;
}
const marker = '<!-- showcase-build-failure -->';
const body = [
marker,
`### :x: Showcase Build Failed`,
``,
`The Docker build triggered by this PR's merge failed.`,
``,
`**Run:** ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
`**Commit:** \`${context.sha.slice(0, 8)}\``,
``,
`@${merged.user?.login ?? 'unknown'} — please check the build logs and fix the issue.`,
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: merged.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: merged.number,
body,
});
}
+262
View File
@@ -0,0 +1,262 @@
name: "Showcase: Build Check (PR)"
# Pre-merge Docker build check. Runs the same Depot Docker builds as the
# post-merge showcase_build.yml pipeline but with push: false, so
# Dockerfile-specific failures (missing deps that exist in the monorepo
# but not in the isolated Docker context) are caught before merge.
on:
pull_request:
paths:
- "showcase/**"
- ".github/workflows/showcase_build.yml"
- ".github/workflows/showcase_build_check.yml"
concurrency:
group: showcase-build-check-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
jobs:
detect-changes:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
matrix: ${{ steps.build-matrix.outputs.matrix }}
has_changes: ${{ steps.build-matrix.outputs.has_changes }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Detect changed paths
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: filter
with:
filters: |
workflow_config:
- '.github/workflows/showcase_build.yml'
- '.github/workflows/showcase_build_check.yml'
shell:
- 'showcase/shell/**'
- 'showcase/shared/**'
- 'showcase/scripts/**'
- 'showcase/integrations/*/manifest.yaml'
langgraph_python:
- 'showcase/integrations/langgraph-python/**'
mastra:
- 'showcase/integrations/mastra/**'
crewai_crews:
- 'showcase/integrations/crewai-crews/**'
pydantic_ai:
- 'showcase/integrations/pydantic-ai/**'
google_adk:
- 'showcase/integrations/google-adk/**'
ag2:
- 'showcase/integrations/ag2/**'
agno:
- 'showcase/integrations/agno/**'
llamaindex:
- 'showcase/integrations/llamaindex/**'
langgraph_fastapi:
- 'showcase/integrations/langgraph-fastapi/**'
langgraph_typescript:
- 'showcase/integrations/langgraph-typescript/**'
langroid:
- 'showcase/integrations/langroid/**'
spring_ai:
- 'showcase/integrations/spring-ai/**'
strands:
- 'showcase/integrations/strands/**'
strands_typescript:
- 'showcase/integrations/strands-typescript/**'
ms_agent_python:
- 'showcase/integrations/ms-agent-python/**'
claude_sdk_typescript:
- 'showcase/integrations/claude-sdk-typescript/**'
ms_agent_dotnet:
- 'showcase/integrations/ms-agent-dotnet/**'
ms_agent_harness_dotnet:
- 'showcase/integrations/ms-agent-harness-dotnet/**'
claude_sdk_python:
- 'showcase/integrations/claude-sdk-python/**'
built_in_agent:
- 'showcase/integrations/built-in-agent/**'
shell_dojo:
- 'showcase/shell-dojo/**'
- 'showcase/shared/**'
- 'showcase/scripts/**'
- 'showcase/integrations/*/manifest.yaml'
shell_dashboard:
- 'showcase/shell-dashboard/**'
- 'showcase/shared/**'
- 'showcase/scripts/**'
- 'showcase/integrations/*/manifest.yaml'
shell_docs:
- 'showcase/shell-docs/**'
- 'showcase/shared/**'
- 'showcase/scripts/**'
- 'showcase/integrations/*/manifest.yaml'
- 'showcase/integrations/*/docs-links.json'
- 'showcase/integrations/*/docs/setup/**'
- 'showcase/integrations/*/src/**'
showcase_harness:
- 'showcase/harness/**'
- 'showcase/shared/**'
- 'showcase/scripts/**'
- 'showcase/integrations/*/manifest.yaml'
showcase_aimock:
- 'showcase/aimock/**'
- name: Build service matrix
id: build-matrix
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.head_ref }}
FILTER_CHANGES: ${{ steps.filter.outputs.changes }}
run: |
# Mirror of the ALL_SERVICES definition from showcase_build.yml.
# Keep in sync — the matrix here must match the production build
# workflow so that every service buildable post-merge is also
# checked pre-merge.
#
# Fields carried over: dispatch_name, filter_key, context, image,
# timeout, lfs, build_args_*, dockerfile.
# Fields omitted (not needed for build-only): railway_id, health_path.
ALL_SERVICES='[
{"dispatch_name":"shell","filter_key":"shell","context":".","image":"showcase-shell","timeout":10,"lfs":true,"build_args_sha":"__GH_SHA__","build_args_branch":"__GH_REF_NAME__","dockerfile":"showcase/shell/Dockerfile"},
{"dispatch_name":"langgraph-python","filter_key":"langgraph_python","context":"showcase/integrations/langgraph-python","image":"showcase-langgraph-python","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"mastra","filter_key":"mastra","context":"showcase/integrations/mastra","image":"showcase-mastra","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"crewai-crews","filter_key":"crewai_crews","context":"showcase/integrations/crewai-crews","image":"showcase-crewai-crews","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"pydantic-ai","filter_key":"pydantic_ai","context":"showcase/integrations/pydantic-ai","image":"showcase-pydantic-ai","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"google-adk","filter_key":"google_adk","context":"showcase/integrations/google-adk","image":"showcase-google-adk","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"ag2","filter_key":"ag2","context":"showcase/integrations/ag2","image":"showcase-ag2","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"agno","filter_key":"agno","context":"showcase/integrations/agno","image":"showcase-agno","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"llamaindex","filter_key":"llamaindex","context":"showcase/integrations/llamaindex","image":"showcase-llamaindex","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"langgraph-fastapi","filter_key":"langgraph_fastapi","context":"showcase/integrations/langgraph-fastapi","image":"showcase-langgraph-fastapi","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"langgraph-typescript","filter_key":"langgraph_typescript","context":"showcase/integrations/langgraph-typescript","image":"showcase-langgraph-typescript","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"langroid","filter_key":"langroid","context":"showcase/integrations/langroid","image":"showcase-langroid","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"spring-ai","filter_key":"spring_ai","context":"showcase/integrations/spring-ai","image":"showcase-spring-ai","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"strands","filter_key":"strands","context":"showcase/integrations/strands","image":"showcase-strands","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"strands-typescript","filter_key":"strands_typescript","context":"showcase/integrations/strands-typescript","image":"showcase-strands-typescript","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"ms-agent-python","filter_key":"ms_agent_python","context":"showcase/integrations/ms-agent-python","image":"showcase-ms-agent-python","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"claude-sdk-typescript","filter_key":"claude_sdk_typescript","context":"showcase/integrations/claude-sdk-typescript","image":"showcase-claude-sdk-typescript","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"ms-agent-dotnet","filter_key":"ms_agent_dotnet","context":"showcase/integrations/ms-agent-dotnet","image":"showcase-ms-agent-dotnet","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"ms-agent-harness-dotnet","filter_key":"ms_agent_harness_dotnet","context":"showcase/integrations/ms-agent-harness-dotnet","image":"showcase-ms-agent-harness-dotnet","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"claude-sdk-python","filter_key":"claude_sdk_python","context":"showcase/integrations/claude-sdk-python","image":"showcase-claude-sdk-python","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"built-in-agent","filter_key":"built_in_agent","context":"showcase/integrations/built-in-agent","image":"showcase-built-in-agent","timeout":15,"lfs":false,"build_args":"","dockerfile":""},
{"dispatch_name":"shell-dojo","filter_key":"shell_dojo","context":".","image":"showcase-shell-dojo","timeout":10,"lfs":false,"build_args":"","dockerfile":"showcase/shell-dojo/Dockerfile"},
{"dispatch_name":"shell-dashboard","filter_key":"shell_dashboard","context":".","image":"showcase-shell-dashboard","timeout":10,"lfs":true,"build_args_sha":"__GH_SHA__","build_args_branch":"__GH_REF_NAME__","dockerfile":"showcase/shell-dashboard/Dockerfile"},
{"dispatch_name":"shell-docs","filter_key":"shell_docs","context":".","image":"showcase-shell-docs","timeout":10,"lfs":true,"build_args_sha":"__GH_SHA__","build_args_branch":"__GH_REF_NAME__","dockerfile":"showcase/shell-docs/Dockerfile"},
{"dispatch_name":"showcase-harness","filter_key":"showcase_harness","context":".","image":"showcase-harness","timeout":20,"lfs":false,"build_args":"","dockerfile":"showcase/harness/Dockerfile"},
{"dispatch_name":"showcase-aimock","filter_key":"showcase_aimock","context":"showcase/aimock","image":"showcase-aimock","timeout":5,"lfs":false,"build_args":"","dockerfile":"showcase/aimock/Dockerfile"}
]'
CHANGES="${FILTER_CHANGES:-[]}"
# PR event only — filter to changed services. Use jq --arg for
# untrusted PR metadata so branch names are encoded as JSON data,
# not interpolated into shell source.
MATRIX=$(echo "$ALL_SERVICES" | jq -c --argjson changes "$CHANGES" --arg sha "$PR_HEAD_SHA" --arg ref "$PR_HEAD_REF" '
[.[] |
if .build_args_sha == "__GH_SHA__" then .build_args_sha = $sha else . end |
if .build_args_branch == "__GH_REF_NAME__" then .build_args_branch = $ref else . end |
(.filter_key as $fk | select(
($changes | index("workflow_config") != null) or
($changes | index($fk) != null)
))]
')
echo "matrix=$MATRIX" >> $GITHUB_OUTPUT
if [ "$MATRIX" = "[]" ]; then
echo "has_changes=false" >> $GITHUB_OUTPUT
else
echo "has_changes=true" >> $GITHUB_OUTPUT
fi
build-check:
needs: [detect-changes]
if: needs.detect-changes.outputs.has_changes == 'true'
runs-on: depot-ubuntu-24.04-4
timeout-minutes: ${{ fromJSON(matrix.service.timeout) }}
permissions:
id-token: write
contents: read
strategy:
fail-fast: false
matrix:
service: ${{ fromJSON(needs.detect-changes.outputs.matrix) }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
lfs: ${{ matrix.service.lfs }}
persist-credentials: false
- name: Setup Depot
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1
- name: Prepare build args
id: build-args
env:
BUILD_ARGS_SHA: ${{ matrix.service.build_args_sha }}
BUILD_ARGS_BRANCH: ${{ matrix.service.build_args_branch }}
run: |
ARGS=""
if [ -n "$BUILD_ARGS_SHA" ]; then
ARGS="COMMIT_SHA=${BUILD_ARGS_SHA}"
ARGS="${ARGS}"$'\n'"BRANCH=${BUILD_ARGS_BRANCH}"
fi
# Use delimiter to safely pass multiline value
echo "args<<BUILDARGS_EOF" >> $GITHUB_OUTPUT
echo "$ARGS" >> $GITHUB_OUTPUT
echo "BUILDARGS_EOF" >> $GITHUB_OUTPUT
- name: Copy shared modules into build context
run: |
set -euo pipefail
CONTEXT="${{ matrix.service.context }}"
if [ -d "showcase/shared/python" ] && [ -d "$CONTEXT" ]; then
rm -rf "$CONTEXT/shared_python"
cp -r showcase/shared/python "$CONTEXT/shared_python"
fi
if [ -d "showcase/shared/typescript/tools" ] && [ -d "$CONTEXT" ]; then
rm -rf "$CONTEXT/shared_typescript"
mkdir -p "$CONTEXT/shared_typescript"
cp -r showcase/shared/typescript/tools "$CONTEXT/shared_typescript/tools"
fi
# Dereference tools/, shared-tools/, and _shared/ symlinks for the
# Docker context. Integration directories use symlinks pointing
# outside the build context (tools -> ../../shared/python/tools,
# _shared -> ../_shared for the CVDIAG bootstrap modules). Docker
# build contexts cannot follow a symlink whose target escapes the
# context root — buildkit fails the checksum with "too many
# symlinks: /_shared" — so each symlink is replaced with a real
# copy of its target. Mirrors stage_shared() in
# showcase/scripts/cli/_common.sh (the local bin/showcase path).
for link_name in tools shared-tools _shared; do
link_path="$CONTEXT/$link_name"
if [ -L "$link_path" ]; then
target="$(readlink -f "$link_path")"
if [ -d "$target" ]; then
rm "$link_path"
cp -r "$target" "$link_path"
fi
fi
done
- name: Build Docker image (no push)
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
with:
project: m2kw2wmmcp
context: ${{ matrix.service.context }}
file: ${{ matrix.service.dockerfile != '' && matrix.service.dockerfile || format('{0}/Dockerfile', matrix.service.context) }}
platforms: linux/amd64
push: false
build-args: ${{ steps.build-args.outputs.args }}
@@ -0,0 +1,280 @@
name: "Showcase: Capture Previews"
on:
workflow_run:
workflows: ["Showcase: Build & Push"]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
slug:
description: "Integration slug to capture (leave empty for all)"
type: string
default: ""
demo:
description: "Demo ID to capture (leave empty for all)"
type: string
default: ""
push:
branches: [main]
paths:
- "showcase/integrations/*/src/app/demos/**"
- "showcase/integrations/*/manifest.yaml"
concurrency:
group: showcase-capture-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
jobs:
capture:
name: Capture Preview MP4s
# Hoist the Slack webhook into an env var so step-level `if:`
# expressions can reference it — `secrets.*` is not a valid
# named-value inside `if:` and causes a workflow startup failure.
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
runs-on: ubuntu-latest
# Captures demo previews as MP4 and uploads them to a GitHub release.
# Loop prevention: capture commits registry.json with the message below,
# which could re-trigger via push or the completed-deploy workflow_run.
# Skip when the triggering commit came from this workflow itself.
if: >-
(github.event_name != 'workflow_run' ||
!startsWith(github.event.workflow_run.head_commit.message, 'Update preview URLs in registry')) &&
(github.event_name != 'push' ||
!startsWith(github.event.head_commit.message, 'Update preview URLs in registry'))
timeout-minutes: 30
steps:
- name: Mint devops-bot token
# PROTECT_OUR_MAIN requires a PR for pushes to main; the default
# GITHUB_TOKEN fails with GH013 on the `Commit registry updates`
# step below. devops-bot (app-id 1108748) is a configured bypass
# actor on that ruleset — mint its installation token here and use
# it for checkout + push. Mirrors the pattern in
# CopilotKit/internal-skills's sync-versions.yml.
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: "1108748"
private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }}
permission-contents: write
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
lfs: true
ref: ${{ github.event.workflow_run.head_branch || github.ref }}
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false
- name: Ensure preview release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view showcase-previews --repo ${{ github.repository }} >/dev/null 2>&1 || \
gh release create showcase-previews --repo ${{ github.repository }} \
--title "Showcase Preview Videos" \
--notes "Auto-generated demo preview videos for the showcase platform. Updated by CI." \
--latest=false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install ffmpeg
run: sudo apt-get update && sudo apt-get install -y ffmpeg
- name: Install Playwright
run: npx playwright install chromium --with-deps
- name: Install script dependencies
working-directory: showcase/scripts
run: pnpm install --ignore-scripts || true
- name: Detect changed packages
id: detect
if: github.event_name == 'push'
run: |
# Find which package slugs had demo changes
CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'showcase/integrations/*/src/app/demos/' 'showcase/integrations/*/manifest.yaml' | \
sed -n 's|showcase/integrations/\([^/]*\)/.*|\1|p' | sort -u | tr '\n' ',' | sed 's/,$//')
echo "slugs=$CHANGED" >> $GITHUB_OUTPUT
echo "Changed packages: $CHANGED"
- name: Generate registry
run: |
cd showcase/scripts
npm ci --silent
npx tsx generate-registry.ts
- name: Capture previews
env:
INPUT_SLUG: ${{ inputs.slug }}
INPUT_DEMO: ${{ inputs.demo }}
EVENT_NAME: ${{ github.event_name }}
DETECTED_SLUGS: ${{ steps.detect.outputs.slugs }}
run: |
# Build the args as an array (not a space-joined string) so a
# slug or demo value containing whitespace or shell metacharacters
# stays a single argument rather than being re-tokenized by the shell.
ARGS=()
# Use input slug/demo if provided (manual dispatch)
if [ -n "$INPUT_SLUG" ]; then
ARGS+=(--slug "$INPUT_SLUG")
fi
if [ -n "$INPUT_DEMO" ]; then
ARGS+=(--demo "$INPUT_DEMO")
fi
# Use detected slugs for push events (capture only changed)
if [ "$EVENT_NAME" = "push" ] && [ -n "$DETECTED_SLUGS" ]; then
# Capture each changed slug
IFS=',' read -ra SLUGS <<< "$DETECTED_SLUGS"
for slug in "${SLUGS[@]}"; do
npx tsx showcase/scripts/capture-previews.ts --slug "$slug" || true
done
else
npx tsx showcase/scripts/capture-previews.ts "${ARGS[@]}" || true
fi
- name: Configure git for push
run: |
git config user.name "devops-bot[bot]"
git config user.email "1108748+devops-bot[bot]@users.noreply.github.com"
git config --local url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
env:
TOKEN: ${{ steps.app-token.outputs.token }}
- name: Commit registry updates
run: |
cd showcase/shell/src/data
if git diff --quiet registry.json; then
echo "No registry changes"
exit 0
fi
git add -f registry.json
git commit -m "Update preview URLs in registry"
# Rebase-and-retry loop: the capture job can take ~30 minutes,
# during which other commits routinely land on main. Without
# this loop, `git push` loses the race and fails with "fetch
# first" (observed in runs 24799601181 and 24809331709 on
# 2026-04-22). Rebase our single registry commit onto the
# latest main and retry; bounded attempts so a persistent
# failure still surfaces rather than looping forever.
attempts=0
max_attempts=5
until git push; do
attempts=$((attempts + 1))
if [ "$attempts" -ge "$max_attempts" ]; then
echo "::error::push failed after $max_attempts rebase attempts"
exit 1
fi
echo "push rejected — rebasing onto latest main (attempt $attempts/$max_attempts)"
git pull --rebase origin "$(git rev-parse --abbrev-ref HEAD)"
done
# Slack failure alert. This workflow only runs on main-branch pushes,
# workflow_run completions, and manual dispatch — all of which
# constitute "production" events where silent failures (e.g. the
# GH013 PROTECT_OUR_MAIN regression that motivated PR #4159) must
# surface in #oss-alerts. Extracts failed step name + first
# meaningful error line so the payload is triage-ready rather than
# forcing a click-through, per the oss-alerts detail policy.
#
# Must NEVER fail the job (runs on failure() already; a crash here
# would compound the original failure and could block the notify
# step). All extraction uses best-effort fallbacks so a malformed
# jobs response or truncated log still yields sane defaults.
- name: Extract failure details for Slack
id: extract
if: failure() && env.SLACK_WEBHOOK != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
run: |
set +e # best-effort: never block the notify step below
# --- Find the currently-running job and its first failed step ---
# The jobs API returns every job in the run. Match by job name
# first (mirrors `jobs.capture.name`); fall back to first job
# with a failed step if name match misses (e.g. future rename).
jobs_json=$(gh api "/repos/${GH_REPO}/actions/runs/${RUN_ID}/jobs" --paginate 2>/dev/null)
job_id=$(printf '%s' "$jobs_json" | jq -r '
.jobs // []
| map(select(.name == "Capture Preview MP4s"))
| (.[0].id // empty)
' 2>/dev/null)
if [ -z "$job_id" ]; then
job_id=$(printf '%s' "$jobs_json" | jq -r '
.jobs // []
| map(select(.steps // [] | map(.conclusion) | index("failure")))
| (.[0].id // empty)
' 2>/dev/null)
fi
failed_step=$(printf '%s' "$jobs_json" | jq -r --arg id "$job_id" '
.jobs // []
| map(select((.id|tostring) == $id))
| (.[0].steps // [])
| map(select(.conclusion == "failure"))
| (.[0].name // "unknown step")
' 2>/dev/null)
[ -z "$failed_step" ] && failed_step="unknown step"
# --- Pull log and extract first meaningful error line ------------
# `gh run view --log-failed` output is TSV: job\tstep\ttimestamp +
# content. Strip the three leading columns, strip ANSI escape
# codes + BOM, skip runner/group/env header noise, grab first
# line matching a recognised error marker. Truncate to ~300
# chars so the Slack payload stays under the 800-char budget.
error_excerpt="see workflow run for details"
if [ -n "$job_id" ]; then
log_excerpt=$(gh run view "$RUN_ID" --repo "$GH_REPO" --log-failed --job="$job_id" 2>/dev/null \
| awk -F'\t' 'NF>=3 { sub(/^[\xEF\xBB\xBF]?[0-9T:.\-Z ]+/, "", $3); print $3 }' \
| sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' \
| grep -vE '^(##\[|shell: |env: |Run |[[:space:]]*$)' \
| grep -m1 -E '^\[(FAIL|ERROR)\]|^Error:|^error:|^::error' \
| head -c 300)
if [ -n "$log_excerpt" ]; then
error_excerpt="$log_excerpt"
fi
fi
# --- Emit to $GITHUB_ENV using heredoc delimiter -----------------
# Heredoc delimiter protects against values with `=` or newlines
# breaking the KEY=VALUE format.
{
echo "failed_step<<EOF_FAILED_STEP_b3f2"
printf '%s\n' "$failed_step"
echo "EOF_FAILED_STEP_b3f2"
echo "error_excerpt<<EOF_ERROR_EXCERPT_b3f2"
printf '%s\n' "$error_excerpt"
echo "EOF_ERROR_EXCERPT_b3f2"
} >> "$GITHUB_ENV"
exit 0 # belt-and-suspenders: never propagate a failure
- name: Notify Slack (failure)
if: failure() && env.SLACK_WEBHOOK != ''
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
webhook-type: incoming-webhook
# Defensive: wrap dynamic values via toJSON(format(...)) so that
# if github.repository or extracted failed_step / error_excerpt
# contain characters that would break the JSON payload (quotes,
# backslashes, newlines), the value is safely JSON-encoded.
# Matches the pattern used in showcase_validate.yml.
payload: |
{ "text": ${{ toJSON(format(':x: *Showcase: Capture Previews*: failed — {0}: {1} | <https://github.com/{2}/actions/runs/{3}|View run>', env.failed_step, env.error_excerpt, github.repository, github.run_id)) }} }
- name: Log (no Slack — webhook unset)
if: failure() && env.SLACK_WEBHOOK == ''
run: |
echo "::warning::showcase_capture-previews failed but SLACK_WEBHOOK_OSS_ALERTS is not set; no Slack notification sent."
+370
View File
@@ -0,0 +1,370 @@
name: "Showcase: Verify Deploy"
# Triggered after "Showcase: Build & Push" completes. Verifies that the
# STAGING redeploy from the build workflow actually produced healthy
# services. Push-to-main redeploys staging only. This workflow is the
# staging gate; verify-deploy.ts is the parameterized probe driven off
# showcase/scripts/railway-envs.ts (the SSOT).
on:
workflow_run:
workflows: ["Showcase: Build & Push"]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
service:
description: "Service to verify (SSOT key or dispatch_name; 'all' = everything probe-eligible)"
required: false
default: "all"
type: string
concurrency:
group: showcase-verify-deploy
cancel-in-progress: true
permissions:
contents: read
jobs:
resolve-matrix:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
actions: read
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
outputs:
services_csv: ${{ steps.matrix.outputs.services_csv }}
has_services: ${{ steps.matrix.outputs.has_services }}
build_run_id: ${{ github.event.workflow_run.id }}
build_run_url: ${{ github.event.workflow_run.html_url }}
redeploy_red: ${{ steps.redeploy-gate.outputs.redeploy_red }}
ok_services: ${{ steps.redeploy-gate.outputs.ok_services }}
failed_services: ${{ steps.redeploy-gate.outputs.failed_services }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
- name: Check whether build uploaded a redeploy-summary artifact
id: check-redeploy-summary
# `actions/download-artifact@v4` with a `name:` HARD-FAILS when the
# named artifact does not exist. The artifact legitimately does
# NOT exist whenever the upstream build ran but redeployed nothing
# — e.g. a push touching `showcase/**` that fires the build's
# `paths:` filter, but `detect-changes` finds no buildable service
# changed, so `redeploy-staging` is skipped and never uploads.
# The build still concludes `success`, so this workflow fires on
# `workflow_run` and `resolve-matrix` runs. Without this pre-check
# the unguarded download would fail the job and (via
# `enforce-redeploy-gate` tripping on `result == 'failure'`) flip
# the whole deploy workflow RED — a false-red on a routine
# showcase-docs/script-only change. Listing artifacts via the API
# only requires `actions: read`, which `resolve-matrix` already has.
if: github.event_name == 'workflow_run'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
// Fail loud on API error: github-script propagates unhandled
// rejections, which fails this step and (via the
// resolve-matrix.result == 'failure' clause on
// enforce-redeploy-gate) reds the workflow. Do NOT wrap this
// in try/catch — silently defaulting summary_present=false on
// a 5xx/403 would open the gate (skip verify) on what is
// actually a transient API failure, hiding a broken pipeline.
//
// Use the `name` query-param on listWorkflowRunArtifacts to
// ask the API to return only the redeploy-summary artifact.
// This makes the lookup robust to the build run uploading
// many artifacts (per-slot build-result-* + build-results +
// redeploy-summary — already ~28 today, well within per_page
// 100, but a future expansion past 100 would otherwise risk a
// false "absent" if redeploy-summary fell off the first page).
// The endpoint accepts `name` for an exact-match filter; we
// still paginate defensively in case the API returns multiple
// rows (e.g. an artifact with the same name re-uploaded).
const runId = context.payload.workflow_run.id;
const iterator = github.paginate.iterator(
github.rest.actions.listWorkflowRunArtifacts,
{
owner: context.repo.owner,
repo: context.repo.repo,
run_id: runId,
name: "redeploy-summary",
per_page: 100,
},
);
let present = false;
for await (const page of iterator) {
if ((page.data || []).some((a) => a.name === "redeploy-summary")) {
present = true;
break;
}
}
core.setOutput("summary_present", present ? "true" : "false");
core.info(`redeploy-summary present for run ${runId}: ${present}`);
- name: Download redeploy summary from build workflow
# Three cases now handled distinctly:
# (a) workflow_dispatch — no `workflow_run` payload exists; the
# download is skipped and the bash `[ ! -f "$SUMMARY" ]`
# branch below treats it as "nothing to gate" (correct: a
# manual dispatch is not gated by a build's per-service set).
# (b) workflow_run + artifact absent — the upstream build
# redeployed nothing (e.g. no service had buildable
# changes); the precheck reports `summary_present=false`,
# the download is skipped, and the bash branch no-ops the
# gate. The deploy workflow should NOT red here — there is
# nothing to gate.
# (c) workflow_run + artifact PRESENT — we always attempt the
# download. We intentionally do NOT set
# `continue-on-error: true`: if the artifact exists but the
# download genuinely fails (network/permission), silently
# opening the gate would let verify probe the FULL service
# set against stale `:latest` and mask a broken redeploy as
# a green deploy. Fail loud instead.
if: >-
github.event_name == 'workflow_run' &&
steps.check-redeploy-summary.outputs.summary_present == 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: redeploy-summary
path: .redeploy
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Gate on staging redeploy errors
id: redeploy-gate
run: |
set -euo pipefail
SUMMARY=".redeploy/summary.json"
if [ ! -f "$SUMMARY" ]; then
echo "No redeploy summary found (workflow_dispatch path or build did not upload). Skipping gate."
{
echo "redeploy_red=false"
echo "ok_services="
echo "failed_services="
} >> "$GITHUB_OUTPUT"
exit 0
fi
# Shape guard: redeploy-env.ts writes per-entry `status` of
# exactly "ok" or "error". If the schema ever drifts (e.g.
# `status`→`state`, `ok`→`success`) every `select(.status==...)`
# silently yields empty → redeploy_red=false AND ok_services=""
# → resolve-verify-matrix skips verify on a real unverified
# redeploy = green CI on a broken release. Refuse the ambiguity:
# if the file has entries but ANY entry is missing a valid
# ok|error status (partial drift — some rows on the legacy schema,
# some on the new one), fail loud here so enforce-redeploy-gate
# reds the workflow (resolve-matrix.result == 'failure' fans into
# the gate). The previous TOTAL>0 && WITH_STATUS==0 check was
# all-or-nothing and silently dropped the drifted rows on a mixed
# summary.
TOTAL=$(jq 'length' "$SUMMARY")
WITH_STATUS=$(jq '[.[] | select(.status == "ok" or .status == "error")] | length' "$SUMMARY")
if [ "$TOTAL" -gt 0 ] && [ "$WITH_STATUS" -lt "$TOTAL" ]; then
echo "::error::summary.json shape drift: $WITH_STATUS of $TOTAL entries have status ok|error"
exit 1
fi
# Per spec §3: the workflow MUST turn red on any staging
# status:"error", while verify still runs against the success-set.
ERRORS=$(jq -c '[.[] | select(.status == "error")]' "$SUMMARY")
ERROR_COUNT=$(echo "$ERRORS" | jq 'length')
OK=$(jq -r '[.[] | select(.status == "ok") | .service] | join(",")' "$SUMMARY")
FAILED=$(jq -r '[.[] | select(.status == "error") | .service] | join(",")' "$SUMMARY")
echo "ok_services=$OK" >> "$GITHUB_OUTPUT"
echo "failed_services=$FAILED" >> "$GITHUB_OUTPUT"
if [ "$ERROR_COUNT" -gt 0 ]; then
echo "::error::Staging redeploy reported $ERROR_COUNT per-service error(s):"
echo "$ERRORS" | jq -r '.[] | " - \(.service): \(.error)"'
echo "redeploy_red=true" >> "$GITHUB_OUTPUT"
else
echo "redeploy_red=false" >> "$GITHUB_OUTPUT"
fi
- name: Build verify matrix from SSOT
id: matrix
env:
DISPATCH_SERVICE: ${{ github.event.inputs.service }}
OK_FROM_REDEPLOY: ${{ steps.redeploy-gate.outputs.ok_services }}
EVENT_NAME: ${{ github.event_name }}
SUMMARY_PRESENT: ${{ steps.check-redeploy-summary.outputs.summary_present }}
# The decision-table that picks the verify matrix lives in
# showcase/scripts/resolve-verify-matrix.ts (pure function +
# unit tests). Summary of cases:
# - workflow_dispatch + 'all'/empty → full probe-eligible set.
# - workflow_dispatch + specific svc → just that service
# (unknown name → error exit).
# - workflow_run + summary_present=false → has_services=false
# (build redeployed nothing).
# - workflow_run + summary_present=true + ok empty
# → has_services=false (success-set empty; verify is skipped).
# In practice redeploy-env.ts only emits status ok|error,
# so this branch implies redeploy_red=true and
# enforce-redeploy-gate reds the workflow independently —
# skipping verify here is correct (no ok services left to
# probe; the gate has already turned the workflow red).
# - workflow_run + summary_present=true + ok non-empty
# → intersect ok_services (SSOT key OR dispatchName aliases)
# with probe.staging-eligible SSOT services. has_services
# reflects CSV emptiness. When the intersection collapses
# to empty, verify is skipped; if there were per-service
# errors, enforce-redeploy-gate reds the workflow — otherwise
# the run is correctly green (every redeploy succeeded, just
# none probe-eligible).
run: npx tsx showcase/scripts/resolve-verify-matrix.ts
verify:
needs: [resolve-matrix]
if: needs.resolve-matrix.outputs.has_services == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
environment: railway
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
- name: Install
working-directory: showcase/scripts
run: npm ci
- name: Run verify-deploy --env staging
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
SERVICES_CSV: ${{ needs.resolve-matrix.outputs.services_csv }}
run: |
if [ -z "$RAILWAY_TOKEN" ]; then
echo "::error::RAILWAY_TOKEN is not set"
exit 1
fi
npx tsx showcase/scripts/verify-deploy.ts --env staging --services "$SERVICES_CSV"
enforce-redeploy-gate:
# Spec §3: workflow turns red on any per-service redeploy error, even
# if verify against the success-set passes. This job is independent of
# verify so the user sees both signals (what was redeployed badly,
# what was redeployed and is unhealthy) rather than one masking the other.
needs: [resolve-matrix]
# Trip the gate on EITHER a per-service redeploy error OR a complete
# resolve-matrix failure. A resolve-matrix failure leaves the
# `redeploy_red` output empty (jobs that fail mid-step don't publish
# outputs reliably), which would otherwise let an upstream crash slip
# past as "not red" — a silent bypass of the gate.
if: always() && (needs.resolve-matrix.outputs.redeploy_red == 'true' || needs.resolve-matrix.result == 'failure')
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
contents: read
steps:
- name: Fail workflow on staging redeploy errors
run: |
echo "::error::One or more staging services reported status:error in the redeploy summary."
echo "See the resolve-matrix job's 'Gate on staging redeploy errors' step for details."
exit 1
notify-harness:
needs: [resolve-matrix, verify, enforce-redeploy-gate]
if: always() && needs.resolve-matrix.outputs.has_services == 'true'
permissions:
contents: read
actions: read
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- name: Compute deploy-result payload
id: payload
env:
VERIFY_RESULT: ${{ needs.verify.result }}
OK_SERVICES: ${{ needs.resolve-matrix.outputs.ok_services }}
FAILED_SERVICES: ${{ needs.resolve-matrix.outputs.failed_services }}
BUILD_RUN_ID: ${{ needs.resolve-matrix.outputs.build_run_id }}
BUILD_RUN_URL: ${{ needs.resolve-matrix.outputs.build_run_url }}
RUN_ID: ${{ github.run_id }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# Build the payload to match the harness ingest schema at
# showcase/harness/src/http/webhooks/deploy.ts (`.strict()`):
# {runId, runUrl, buildRunId?, buildRunUrl?, services[], succeeded[], failed[], cancelled}
# No `state` key — the schema rejects unknown fields. State is
# derived downstream from (failed.length, cancelled, succeeded).
# - succeeded = ok_services from the redeploy gate
# - failed = failed_services from the redeploy gate
# - services = union (full attempted set, per deploy.ts:307)
# - cancelled = verify job conclusion == "cancelled"
# jq -R/split handles empty CSV → [] safely.
run: |
set -euo pipefail
# `echo` (not printf '%s') so empty CSV → newline → jq -R reads
# an empty record → split produces [""] → filter to []. Without
# the trailing newline jq sees no records and --argjson barfs
# on the empty string.
SUCCEEDED=$(echo "${OK_SERVICES:-}" | jq -R 'split(",") | map(select(length>0))')
FAILED=$(echo "${FAILED_SERVICES:-}" | jq -R 'split(",") | map(select(length>0))')
if [ "$VERIFY_RESULT" = "cancelled" ]; then
CANCELLED="true"
else
CANCELLED="false"
fi
# buildRunId/buildRunUrl are .url()-validated in the harness
# Zod schema, so we must omit them entirely when empty rather
# than emit "" (which fails .url()). Same for runUrl: only
# emit it if non-empty (it's optional in the schema). The base
# object always carries runId + succeeded/failed/services/cancelled;
# we conditionally splice in the optional keys.
PAYLOAD=$(jq -cn \
--arg runId "$RUN_ID" --arg runUrl "${RUN_URL:-}" \
--arg buildRunId "${BUILD_RUN_ID:-}" --arg buildRunUrl "${BUILD_RUN_URL:-}" \
--argjson succeeded "$SUCCEEDED" \
--argjson failed "$FAILED" \
--argjson cancelled "$CANCELLED" \
'
{
runId: $runId,
services: ($succeeded + $failed | unique),
succeeded: $succeeded,
failed: $failed,
cancelled: $cancelled
}
+ (if $runUrl == "" then {} else {runUrl: $runUrl} end)
+ (if $buildRunId == "" then {} else {buildRunId: $buildRunId} end)
+ (if $buildRunUrl == "" then {} else {buildRunUrl: $buildRunUrl} end)
')
{
echo "payload<<EOF_PAYLOAD"
echo "$PAYLOAD"
echo "EOF_PAYLOAD"
} >> "$GITHUB_OUTPUT"
- name: POST deploy result to showcase-harness
env:
SHOWCASE_HARNESS_URL: ${{ secrets.SHOWCASE_HARNESS_URL }}
SHARED_SECRET: ${{ secrets.SHOWCASE_HARNESS_SHARED_SECRET }}
PAYLOAD: ${{ steps.payload.outputs.payload }}
run: |
set -euo pipefail
if [ -z "${SHOWCASE_HARNESS_URL:-}" ] || [ -z "${SHARED_SECRET:-}" ]; then
echo "::warning::SHOWCASE_HARNESS_URL or SHOWCASE_HARNESS_SHARED_SECRET not set; skipping webhook"
exit 0
fi
TS=$(date +%s)
BODY_SHA=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hex | awk '{print $2}')
CANONICAL="POST|/webhooks/deploy|${TS}|${BODY_SHA}"
SIG=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$SHARED_SECRET" -hex | awk '{print $2}')
curl -sS --fail-with-body \
-X POST "${SHOWCASE_HARNESS_URL%/}/webhooks/deploy" \
-H 'content-type: application/json' \
-H "X-Ops-Timestamp: ${TS}" \
-H "X-Ops-Signature: sha256=${SIG}" \
--data-raw "$PAYLOAD"
@@ -0,0 +1,37 @@
name: "showcase / eval-webhook / build"
on:
push:
branches: [main]
paths:
- "showcase/eval-webhook/**"
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Log in to GHCR
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: showcase/eval-webhook
push: true
tags: |
ghcr.io/copilotkit/showcase-eval-webhook:latest
ghcr.io/copilotkit/showcase-eval-webhook:${{ github.sha }}
+621
View File
@@ -0,0 +1,621 @@
name: "showcase / eval"
# SECURITY — residual trust model (read before editing):
#
# This workflow executes `showcase/bin/showcase eval` against PR-HEAD code.
# Hardening layers mirror test_e2e-showcase-on-demand.yml:
# - `getCollaboratorPermissionLevel` gate limits the trigger to users with
# write (or higher) access — third-party commenters cannot spawn runs.
# - workflow-level `permissions: contents: read` means the eval job's
# GITHUB_TOKEN cannot mutate the repo; the `post-result` job gets write
# perms scoped to just the final PR comment.
# - `persist-credentials: false` on `actions/checkout` prevents the token
# from leaking to PR-HEAD build hooks.
# - `env:`-based pattern for UNTRUSTED values (comment body) prevents shell
# injection.
# - Slug whitelist (`^[a-z0-9-]+$`) prevents path traversal.
#
# Known TOCTOU — comment-trigger vs resolved HEAD SHA:
# Same gap as test_e2e-showcase-on-demand.yml. The `pulls.get` call resolves
# whatever HEAD is current at job start, not at comment time. The permission
# gate + code-review social contract are the mitigations.
on:
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number:
description: "PR number to evaluate"
required: true
type: string
check_run_id:
description: "Check Run ID to update with results"
required: false
type: string
level:
description: "Eval depth level"
required: false
default: "d5"
type: string
concurrency:
group: showcase-eval-${{ github.event.inputs.pr_number || github.event.issue.number || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
gate:
if: >
github.event.issue.pull_request
&& startsWith(github.event.comment.body, '/eval')
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
pr_sha: ${{ steps.pr-ref.outputs.sha }}
pr_number: ${{ steps.pr-ref.outputs.pr_number }}
level: ${{ steps.parse.outputs.level }}
scope_flag: ${{ steps.parse.outputs.scope_flag }}
scope_display: ${{ steps.parse.outputs.scope_display }}
permissions:
contents: read
pull-requests: write
issues: write
steps:
- name: Check commenter has write access
id: auth
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login,
});
const level = perm.permission;
if (!['admin', 'write'].includes(level)) {
core.setFailed(`User ${context.payload.comment.user.login} has '${level}' access — write access required to trigger /eval.`);
return;
}
core.info(`User ${context.payload.comment.user.login} has '${level}' access — authorized.`);
- name: Parse /eval command
id: parse
env:
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
set -euo pipefail
# Extract the first line of the comment to parse the command.
FIRST_LINE=$(printf '%s' "$COMMENT_BODY" | head -n1)
# Parse: /eval → d5 affected
# /eval d5 → d5 affected
# /eval d5 all → d5 all
# /eval d5 mastra,agno → d5 specific slugs
ARGS=$(printf '%s' "$FIRST_LINE" | sed 's|^/eval[[:space:]]*||')
# Default level
LEVEL="d5"
SCOPE=""
SCOPE_FLAG=""
SCOPE_DISPLAY=""
if [ -z "$ARGS" ]; then
# Bare /eval — d5 affected
SCOPE_FLAG="--scope affected"
SCOPE_DISPLAY="affected integrations"
else
# First token is the level (only d5 supported for now)
LEVEL_TOKEN=$(printf '%s' "$ARGS" | awk '{print $1}')
REST=$(printf '%s' "$ARGS" | sed "s|^${LEVEL_TOKEN}[[:space:]]*||")
# Validate level
case "$LEVEL_TOKEN" in
d5) LEVEL="d5" ;;
*)
echo "::error::Unknown eval level '$LEVEL_TOKEN'. Supported: d5"
exit 1
;;
esac
if [ -z "$REST" ]; then
# /eval d5 — affected
SCOPE_FLAG="--scope affected"
SCOPE_DISPLAY="affected integrations"
elif [ "$REST" = "all" ]; then
# /eval d5 all
SCOPE_FLAG="--scope all"
SCOPE_DISPLAY="all integrations"
else
# /eval d5 mastra,agno → specific slugs
# Validate each slug against ^[a-z0-9-]+$ to prevent injection
IFS=',' read -ra SLUGS <<< "$REST"
for s in "${SLUGS[@]}"; do
s=$(printf '%s' "$s" | xargs) # trim whitespace
case "$s" in
''|*[!a-z0-9-]*)
echo "::error::Invalid slug '$s' — must match ^[a-z0-9-]+$"
exit 1
;;
esac
done
# Reassemble validated slugs into a clean comma-separated string
# (trims whitespace the user may have typed, e.g. "mastra, agno")
CLEAN_REST=$(printf '%s' "$REST" | tr -d ' ')
SCOPE_FLAG="--slug $CLEAN_REST"
SCOPE_DISPLAY="$CLEAN_REST"
fi
fi
echo "level=$LEVEL" >> "$GITHUB_OUTPUT"
echo "scope_flag=$SCOPE_FLAG" >> "$GITHUB_OUTPUT"
echo "scope_display=$SCOPE_DISPLAY" >> "$GITHUB_OUTPUT"
- name: Resolve PR HEAD ref
id: pr-ref
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
if (pr.state !== 'open') {
core.setFailed(`PR #${pr.number} is ${pr.state} (not open). Refusing to run eval on a non-open PR.`);
return;
}
core.setOutput('sha', pr.head.sha);
core.setOutput('pr_number', pr.number);
- name: React with rocket emoji
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'rocket',
});
- name: Post running status comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
LEVEL: ${{ steps.parse.outputs.level }}
SCOPE_DISPLAY: ${{ steps.parse.outputs.scope_display }}
with:
script: |
const level = process.env.LEVEL;
const scope = process.env.SCOPE_DISPLAY;
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: [
`<!-- showcase-eval-status -->`,
`### Showcase Eval`,
``,
`| | |`,
`|---|---|`,
`| **Status** | Running... |`,
`| **Level** | \`${level}\` |`,
`| **Scope** | ${scope} |`,
`| **Run** | [View workflow](${runUrl}) |`,
].join('\n'),
});
dispatch-gate:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
contents: read
pull-requests: read
outputs:
pr_sha: ${{ steps.resolve.outputs.sha }}
pr_number: ${{ github.event.inputs.pr_number }}
level: ${{ github.event.inputs.level || 'd5' }}
scope_flag: "--scope affected"
scope_display: "affected"
check_run_id: ${{ github.event.inputs.check_run_id }}
steps:
- name: Resolve PR HEAD SHA
id: resolve
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: Number(process.env.PR_NUMBER),
});
if (pr.data.state !== 'open') {
core.setFailed(`PR #${process.env.PR_NUMBER} is not open`);
return;
}
core.setOutput('sha', pr.data.head.sha);
env:
PR_NUMBER: ${{ github.event.inputs.pr_number }}
eval:
needs: [gate, dispatch-gate]
if: always() && (needs.gate.result == 'success' || needs.dispatch-gate.result == 'success')
runs-on: depot-ubuntu-24.04-16
timeout-minutes: 45
permissions:
contents: read
env:
PR_SHA: ${{ needs.gate.outputs.pr_sha || needs.dispatch-gate.outputs.pr_sha }}
PR_NUMBER: ${{ needs.gate.outputs.pr_number || needs.dispatch-gate.outputs.pr_number }}
EVAL_LEVEL: ${{ needs.gate.outputs.level || needs.dispatch-gate.outputs.level || 'd5' }}
EVAL_SCOPE_FLAG: ${{ needs.gate.outputs.scope_flag || needs.dispatch-gate.outputs.scope_flag }}
CHECK_RUN_ID: ${{ needs.dispatch-gate.outputs.check_run_id || '' }}
outputs:
result_json: ${{ steps.run-eval.outputs.result_json }}
exit_code: ${{ steps.run-eval.outputs.exit_code }}
stderr_excerpt: ${{ steps.run-eval.outputs.stderr_excerpt }}
steps:
- name: Checkout PR HEAD
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ env.PR_SHA }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install dependencies
run: pnpm install --ignore-scripts
- name: Install Playwright chromium
run: npx playwright install chromium --with-deps
- name: Run showcase eval
id: run-eval
run: |
set -o pipefail
# Build the command. EVAL_SCOPE_FLAG may contain spaces (e.g. "--slug mastra,agno")
# so we intentionally leave it unquoted for word splitting.
# shellcheck disable=SC2086
CMD="showcase/bin/showcase eval --${EVAL_LEVEL} ${EVAL_SCOPE_FLAG} --parallel 8 --json --baseline compare --timeout 60000 --ci"
echo "::group::Running: $CMD"
EXIT_CODE=0
# Capture both stdout (JSON results) and stderr separately.
# Tee stderr to a file for excerpt extraction on failure.
$CMD > eval-results.json 2> eval-stderr.log || EXIT_CODE=$?
echo "::endgroup::"
echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
if [ -f eval-results.json ] && [ -s eval-results.json ]; then
# GitHub outputs have a 1MB limit; truncate if needed
RESULT_SIZE=$(wc -c < eval-results.json)
if [ "$RESULT_SIZE" -gt 900000 ]; then
echo "::warning::eval-results.json exceeds 900KB ($RESULT_SIZE bytes), truncating for output"
head -c 900000 eval-results.json > eval-results-truncated.json
echo "result_json<<GHEOF" >> "$GITHUB_OUTPUT"
cat eval-results-truncated.json >> "$GITHUB_OUTPUT"
echo "GHEOF" >> "$GITHUB_OUTPUT"
else
echo "result_json<<GHEOF" >> "$GITHUB_OUTPUT"
cat eval-results.json >> "$GITHUB_OUTPUT"
echo "GHEOF" >> "$GITHUB_OUTPUT"
fi
else
echo 'result_json={}' >> "$GITHUB_OUTPUT"
fi
# Capture last 50 lines of stderr for failure reporting
if [ -f eval-stderr.log ] && [ -s eval-stderr.log ]; then
echo "stderr_excerpt<<GHEOF" >> "$GITHUB_OUTPUT"
tail -n 50 eval-stderr.log >> "$GITHUB_OUTPUT"
echo "GHEOF" >> "$GITHUB_OUTPUT"
else
echo "stderr_excerpt=" >> "$GITHUB_OUTPUT"
fi
# Propagate the exit code so the job status reflects eval outcome
exit $EXIT_CODE
- name: Upload eval artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: showcase-eval-results
path: |
eval-results.json
eval-stderr.log
retention-days: 14
if-no-files-found: ignore
post-result:
needs: [gate, dispatch-gate, eval]
if: always() && (needs.gate.result == 'success' || needs.dispatch-gate.result == 'success')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
pull-requests: write
issues: write
checks: write
steps:
- name: Post eval results to PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
EVAL_STATUS: ${{ needs.eval.result }}
RESULT_JSON: ${{ needs.eval.outputs.result_json }}
STDERR_EXCERPT: ${{ needs.eval.outputs.stderr_excerpt }}
EXIT_CODE: ${{ needs.eval.outputs.exit_code }}
LEVEL: ${{ needs.gate.outputs.level || needs.dispatch-gate.outputs.level || 'd5' }}
SCOPE_DISPLAY: ${{ needs.gate.outputs.scope_display || needs.dispatch-gate.outputs.scope_display || 'affected' }}
PR_NUMBER: ${{ needs.gate.outputs.pr_number || needs.dispatch-gate.outputs.pr_number }}
with:
script: |
const evalStatus = process.env.EVAL_STATUS;
const resultJson = process.env.RESULT_JSON || '{}';
const stderrExcerpt = process.env.STDERR_EXCERPT || '';
const exitCode = process.env.EXIT_CODE || 'unknown';
const level = process.env.LEVEL;
const scope = process.env.SCOPE_DISPLAY;
const prNumber = parseInt(process.env.PR_NUMBER, 10);
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
let body = '';
if (evalStatus === 'success') {
// Parse JSON results and build markdown table
let results;
try {
results = JSON.parse(resultJson);
} catch (e) {
// JSON parse failed — report raw
body = [
`<!-- showcase-eval-result -->`,
`### Showcase Eval Results`,
``,
`| | |`,
`|---|---|`,
`| **Verdict** | :warning: PARSE ERROR |`,
`| **Level** | \`${level}\` |`,
`| **Scope** | ${scope} |`,
`| **Run** | [View workflow](${runUrl}) |`,
``,
`Could not parse eval JSON output:`,
'```',
e.message,
'```',
``,
`<details><summary>Raw output</summary>`,
``,
'```json',
resultJson.substring(0, 50000),
'```',
``,
`</details>`,
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
return;
}
// Build results table from the JSON.
// Expected shape: { summary: { total, pass, fail, skip, duration_ms },
// results: { slug: { testName: { status, duration_ms, error? } } } }
const summary = results.summary || {};
const resultsMap = results.results || {};
const total = summary.total || 0;
const passed = summary.pass || 0;
const failed = summary.fail || 0;
const skipped = summary.skip || 0;
const verdict = failed === 0
? ':white_check_mark: **SAFE TO MERGE**'
: `:x: **FAILURES DETECTED** (${failed}/${total} failed)`;
// Build per-integration results table from nested object
let tableRows = '';
const rows = [];
for (const [slug, tests] of Object.entries(resultsMap)) {
for (const [testName, r] of Object.entries(tests)) {
const icon = r.status === 'pass' ? ':white_check_mark:'
: r.status === 'fail' ? ':x:'
: r.status === 'skip' ? ':fast_forward:'
: r.status === 'error' ? ':boom:'
: r.status === 'build_failed' ? ':hammer:'
: r.status === 'unhealthy' ? ':warning:'
: ':question:';
const duration = r.duration_ms ? `${(r.duration_ms / 1000).toFixed(1)}s` : '-';
const detail = r.error ? r.error.substring(0, 120) : '-';
rows.push(`| ${icon} | \`${slug}\` | ${testName} | ${r.status || 'unknown'} | ${duration} | ${detail} |`);
}
}
if (rows.length > 0) {
tableRows = rows.join('\n');
}
body = [
`<!-- showcase-eval-result -->`,
`### Showcase Eval Results`,
``,
`| | |`,
`|---|---|`,
`| **Verdict** | ${verdict} |`,
`| **Level** | \`${level}\` |`,
`| **Scope** | ${scope} |`,
`| **Total** | ${total} |`,
`| **Passed** | ${passed} |`,
`| **Failed** | ${failed} |`,
`| **Skipped** | ${skipped} |`,
`| **Run** | [View workflow](${runUrl}) |`,
``,
].join('\n');
if (tableRows) {
body += [
`#### Per-Integration Results`,
``,
`| | Integration | Test | Status | Duration | Details |`,
`|---|---|---|---|---|---|`,
tableRows,
``,
].join('\n');
}
// Collapsible full JSON
body += [
`<details><summary>Full JSON details</summary>`,
``,
'```json',
JSON.stringify(results, null, 2).substring(0, 60000),
'```',
``,
`</details>`,
].join('\n');
} else {
// Eval failed — post error with stderr excerpt
body = [
`<!-- showcase-eval-result -->`,
`### Showcase Eval Results`,
``,
`| | |`,
`|---|---|`,
`| **Verdict** | :x: **EVAL FAILED** (exit code: ${exitCode}) |`,
`| **Level** | \`${level}\` |`,
`| **Scope** | ${scope} |`,
`| **Run** | [View workflow](${runUrl}) |`,
``,
].join('\n');
if (stderrExcerpt) {
body += [
`<details><summary>Error output (last 50 lines)</summary>`,
``,
'```',
stderrExcerpt.substring(0, 30000),
'```',
``,
`</details>`,
``,
].join('\n');
}
// If we got partial JSON, include it
if (resultJson && resultJson !== '{}') {
body += [
`<details><summary>Partial JSON output</summary>`,
``,
'```json',
resultJson.substring(0, 30000),
'```',
``,
`</details>`,
].join('\n');
}
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
- name: Generate devops-bot token
id: bot-token
if: needs.dispatch-gate.outputs.check_run_id != ''
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: 1108748
private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }}
permission-checks: write
- name: Update Check Run with results
if: needs.dispatch-gate.outputs.check_run_id != ''
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ steps.bot-token.outputs.token }}
script: |
const checkRunId = Number(process.env.CHECK_RUN_ID);
const resultJson = process.env.RESULT_JSON || '{}';
const evalStatus = '${{ needs.eval.result }}';
let conclusion = 'failure';
let title = 'Showcase Eval — error';
let summary = 'The evaluation encountered an error.';
try {
const results = JSON.parse(resultJson);
const s = results.summary || {};
if (evalStatus === 'success' && s.fail === 0) {
conclusion = 'success';
title = `${s.pass}/${s.total} passed (${(s.duration_ms / 1000).toFixed(1)}s)`;
} else if (s.total === 0) {
conclusion = 'neutral';
title = 'No showcase integrations affected';
} else {
conclusion = 'failure';
title = `${s.fail} failed, ${s.pass} passed`;
}
const lines = ['## Eval Results\n'];
lines.push('| Integration | Status |');
lines.push('|-------------|--------|');
if (results.results) {
for (const [slug, tests] of Object.entries(results.results)) {
const statuses = Object.values(tests);
const pass = statuses.filter(t => t.status === 'pass').length;
const total = statuses.length;
const icon = pass === total ? '✅' : '❌';
lines.push(`| ${slug} | ${icon} ${pass}/${total} |`);
}
}
lines.push(`\n**Total:** ${s.pass} passed, ${s.fail} failed, ${s.skip} skipped (${(s.duration_ms / 1000).toFixed(1)}s)`);
summary = lines.join('\n');
} catch (e) {
summary = `Parse error: ${e.message}`;
}
await github.rest.checks.update({
owner: context.repo.owner,
repo: context.repo.repo,
check_run_id: checkRunId,
status: 'completed',
conclusion,
output: { title, summary },
actions: [{
label: 'Re-run Eval',
description: 'Run D5 evaluation',
identifier: 'run-eval',
}],
});
env:
CHECK_RUN_ID: ${{ needs.dispatch-gate.outputs.check_run_id }}
RESULT_JSON: ${{ needs.eval.outputs.result_json }}
+108
View File
@@ -0,0 +1,108 @@
name: "Showcase: Eval Check"
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
jobs:
create-check:
if: false
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
contents: read
pull-requests: write
issues: write
steps:
- name: Generate devops-bot token
id: bot-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: 1108748
private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }}
permission-checks: write
permission-issues: write
permission-pull-requests: write
- name: Create Check Run
id: check-run
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ steps.bot-token.outputs.token }}
script: |
const result = await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: "Showcase Eval",
head_sha: context.payload.pull_request.head.sha,
status: "completed",
conclusion: "neutral",
external_id: String(context.payload.pull_request.number),
output: {
title: "Showcase Eval — click to run",
summary: "Evaluates this PR against the showcase integration matrix.\n\n_For targeted runs, comment `/eval d5 slug1,slug2` on the PR._",
},
actions: [
{
label: "Run Showcase Eval",
description: "Run D5 evaluation",
identifier: "run-eval",
},
],
});
core.setOutput('check_run_id', result.data.id);
- name: Post or update bot comment with trigger link
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ steps.bot-token.outputs.token }}
script: |
const crypto = require('crypto');
const pr = String(context.payload.pull_request.number);
const checkRunId = String(${{ steps.check-run.outputs.check_run_id }});
const secret = process.env.WEBHOOK_SECRET;
const sig = crypto
.createHmac('sha256', secret)
.update(`${pr}:${checkRunId}`)
.digest('hex');
const baseUrl = 'https://hooks.showcase.copilotkit.ai';
const triggerUrl = `${baseUrl}/trigger/eval?pr=${pr}&check_run_id=${checkRunId}&sig=${sig}`;
const marker = '<!-- showcase-eval-button -->';
const body = `${marker}\n\n` +
`### 🔬 Showcase Eval\n\n` +
`Evaluate this PR against the showcase integration matrix.\n\n` +
`[**▶ Run Evaluation**](${triggerUrl})\n\n` +
`_For targeted runs: \`/eval d5 slug1,slug2\`_`;
// Find existing bot comment
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
per_page: 100,
});
const existing = comments.data.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.payload.pull_request.number,
body,
});
}
env:
WEBHOOK_SECRET: ${{ secrets.EVAL_WEBHOOK_SECRET }}
+57
View File
@@ -0,0 +1,57 @@
# INTERIM — superseded by showcase-harness smoke cadence (see Notion).
#
# Goal: curl /api/health on each showcase service every 5 min so the agent's
# in-memory state (JIT, module cache, connection pools) stays warm. Railway Pro
# tier doesn't sleep containers, but warm-state decays over idle.
#
# This workflow is intentionally minimal — no Slack, no metrics, no state.
# This is strictly keep-alive; alerting lives with the validation/deploy
# workflows.
name: "Showcase: Keep-Alive"
on:
schedule:
- cron: "*/5 * * * *"
workflow_dispatch: {}
permissions:
contents: read
jobs:
ping:
permissions:
contents: read
name: Ping ${{ matrix.slug }}
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 2
continue-on-error: true
strategy:
fail-fast: false
matrix:
slug:
- ag2
- agno
- claude-sdk-python
- claude-sdk-typescript
- crewai-crews
- google-adk
- langgraph-fastapi
- langgraph-python
- langgraph-typescript
- langroid
- llamaindex
- mastra
- ms-agent-dotnet
- ms-agent-harness-dotnet
- ms-agent-python
- pydantic-ai
- spring-ai
- strands
steps:
- name: Ping /api/health
run: |
curl -fsS --max-time 15 \
"https://showcase-${{ matrix.slug }}-production.up.railway.app/api/health" \
> /dev/null
+173
View File
@@ -0,0 +1,173 @@
name: "Showcase: lint-prod (digest pinning)"
# Runs `bin/railway lint-prod` on every PR that touches showcase/.
#
# Currently ADVISORY: the `--exit-zero` flag makes the step exit 0 even when
# findings exist, so this workflow will not block PRs while we soak. Findings
# still print to the step log so we can monitor drift. Once we have confidence
# the findings are clean, remove `--exit-zero` to flip this to enforcing.
#
# Long-term contract: production must always be reproducible from a snapshot
# (every service pinned to `ghcr.io/...@sha256:...`).
#
# Visibility surfaces:
# - $GITHUB_STEP_SUMMARY: structured markdown table rendered at the top of
# the workflow run page (every run, push or pull_request).
# - Sticky PR comment: a single comment per PR, found+updated via an HTML
# marker (<!-- lint-prod-sticky-comment -->). Only posted on
# pull_request events; push events on main only write the step summary.
on:
pull_request:
paths:
- "showcase/**"
- ".github/workflows/showcase_lint_prod.yml"
workflow_dispatch:
concurrency:
group: showcase-lint-prod-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
lint-prod:
name: Lint production pinning (advisory)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
with:
ruby-version: "3.2"
- name: Lint production pinning (advisory)
id: lint
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
run: |
set -uo pipefail
if [ -z "${RAILWAY_TOKEN:-}" ]; then
echo "::warning::RAILWAY_TOKEN secret not configured; skipping lint-prod."
echo "skipped=true" >> "$GITHUB_OUTPUT"
echo "findings=0" >> "$GITHUB_OUTPUT"
echo "errored=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skipped=false" >> "$GITHUB_OUTPUT"
# Advisory mode: --exit-zero so findings don't block the PR while we soak.
# Flip to enforcing by removing --exit-zero once findings are clean.
# Also emit machine-readable JSON for the visibility renderer.
#
# We capture stderr + exit code so a snapshot/GraphQL error doesn't
# abort the workflow before we can render a "audit unavailable" block.
# The intent of advisory mode is "never block a PR on this check".
rc=0
ruby showcase/bin/railway lint-prod --exit-zero --format json \
> lint-prod.json 2> lint-prod.err || rc=$?
# Mirror to the job log for humans (best-effort; ignore errors).
ruby showcase/bin/railway lint-prod --exit-zero || true
if [ "${rc}" -ne 0 ] || [ ! -s lint-prod.json ]; then
echo "::warning::lint-prod failed (rc=${rc}); rendering audit-unavailable block."
echo "--- lint-prod stderr ---"
cat lint-prod.err || true
echo "errored=true" >> "$GITHUB_OUTPUT"
echo "findings=0" >> "$GITHUB_OUTPUT"
# Synthesize an empty payload so the renderer has something to chew on.
ruby -rjson -rtime -e '
err = File.exist?("lint-prod.err") ? File.read("lint-prod.err").strip : ""
puts JSON.generate({"services" => [], "findings" => 0,
"timestamp" => Time.now.utc.iso8601,
"error" => err})
' > lint-prod.json
else
echo "errored=false" >> "$GITHUB_OUTPUT"
findings=$(ruby -rjson -e 'puts JSON.parse(File.read("lint-prod.json"))["findings"]')
echo "findings=${findings}" >> "$GITHUB_OUTPUT"
fi
- name: Render audit summary
if: always() && steps.lint.outputs.skipped != 'true'
id: render
run: |
set -uo pipefail
# Render in America/Los_Angeles so the timestamp respects DST.
export TZ="America/Los_Angeles"
ruby <<'RUBY' > audit.md
require "json"
require "time"
data = JSON.parse(File.read("lint-prod.json"))
services = data["services"] || []
findings = (data["findings"] || 0).to_i
ts_utc = Time.parse(data["timestamp"] || Time.now.utc.iso8601)
ts_pt = ts_utc.getlocal
total = services.size
err = data["error"].to_s
out = +""
out << "## Production Digest-Pinning Audit\n\n"
if !err.empty?
# Audit failed to run (e.g. snapshot/GraphQL error). Render a fallback
# block instead of leaving the surface blank.
out << "Audit could not run this round.\n\n"
out << "<details><summary>Error</summary>\n\n```\n#{err}\n```\n\n</details>\n\n"
elsif findings.zero?
out << "All #{total} services digest-pinned.\n\n"
else
out << "#{findings} of #{total} service(s) not digest-pinned.\n\n"
out << "| Service | Source.image | Status |\n"
out << "|---|---|---|\n"
services.each do |s|
next if s["status"] == "pinned"
src = s["source"].to_s.empty? ? "_(unset)_" : "`#{s['source']}`"
out << "| #{s['name']} | #{src} | mutable-tag |\n"
end
out << "\n"
end
out << "_Run #{ts_pt.strftime('%Y-%m-%d %H:%M:%S %Z')} — #{findings} finding(s)._\n"
puts out
RUBY
# Write to step summary (top of run page).
cat audit.md >> "$GITHUB_STEP_SUMMARY"
# Save body (with sticky marker) for the comment step.
{
echo "<!-- lint-prod-sticky-comment -->"
cat audit.md
} > comment.md
- name: Post/update sticky PR comment
if: always() && github.event_name == 'pull_request' && steps.lint.outputs.skipped != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
set -uo pipefail
MARKER="<!-- lint-prod-sticky-comment -->"
# Find existing sticky comment (returns id only).
existing_id=$(gh api \
"/repos/${REPO}/issues/${PR_NUMBER}/comments?per_page=100" \
--jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
| head -1)
# Build a JSON body file so we can PATCH/POST a multiline body safely.
ruby -rjson -e 'puts JSON.generate({body: File.read("comment.md")})' \
> comment.json
if [ -n "${existing_id:-}" ]; then
echo "Updating existing sticky comment id=${existing_id}"
gh api -X PATCH \
"/repos/${REPO}/issues/comments/${existing_id}" \
--input comment.json > /dev/null
else
echo "Creating new sticky comment"
gh pr comment "${PR_NUMBER}" --repo "${REPO}" --body-file comment.md
fi
- name: Run bin/railway tests
run: |
ruby showcase/bin/spec/all_tests.rb
+634
View File
@@ -0,0 +1,634 @@
name: "Showcase: Promote (staging → prod)"
# Promotes the staging-tested digest of one or more services to prod.
# Workflow_dispatch only. Humans trigger. No automatic prod promotes.
#
# Order:
# 0. resolve-targets → expand the workflow_dispatch `service`
# input (SSOT key, dispatch_name, or
# 'all') into the canonical services_csv
# consumed by every downstream job.
# 1. verify-staging-precondition → live-probe staging for the target service(s).
# Refuse on red (matches bin/railway
# --require-staging-green default).
# 2. promote → bin/railway promote <service>; runs the
# spec §7 hardening (P1..P6).
# 3. verify-prod → verify-deploy.ts --env prod for the
# target service(s). Feature-level probes,
# not naked 200. Then the prod equivalence
# re-sweep gate (§6.2) and the pinned-ness
# gate (lint-prod, §8.2 — every prod service
# must be on an immutable @sha256: digest).
# 4. notify → Slack #oss-alerts on any red. Never #engr.
# success → #team-showcase.
on:
workflow_dispatch:
inputs:
service:
description: "Service to promote (dispatch_name or SSOT key). 'all' = whole fleet. Leave the placeholder to abort."
required: true
type: choice
# >>> BEGIN GENERATED service options (showcase/scripts/sync-promote-service-options.ts) — DO NOT EDIT
default: __select_a_service__
options:
- __select_a_service__
- all
- ag2
- agno
- built-in-agent
- claude-sdk-python
- claude-sdk-typescript
- crewai-crews
- google-adk
- langgraph-fastapi
- langgraph-python
- langgraph-typescript
- langroid
- llamaindex
- mastra
- ms-agent-dotnet
- ms-agent-harness-dotnet
- ms-agent-python
- pydantic-ai
- shell
- shell-dashboard
- shell-docs
- shell-dojo
- showcase-aimock
- showcase-harness
- showcase-pocketbase
- spring-ai
- starter-adk
- starter-agno
- starter-crewai-crews
- starter-langgraph-fastapi
- starter-langgraph-js
- starter-langgraph-python
- starter-llamaindex
- starter-mastra
- starter-ms-agent-framework-dotnet
- starter-ms-agent-framework-python
- starter-pydantic-ai
- starter-strands-python
- strands
- strands-typescript
- webhooks
# <<< END GENERATED service options
digest:
description: "Optional digest override (default: snapshot from staging)"
required: false
type: string
# Serialize ALL promotes on a single input-agnostic group so concurrent runs
# (e.g. `all` + a single-service promote) can't pin the same Railway service
# at once; `cancel-in-progress: false` ensures an in-flight prod promote is
# never cancelled by a newer run queued behind it.
concurrency:
group: showcase-promote
cancel-in-progress: false
permissions:
contents: read
jobs:
resolve-targets:
runs-on: ubuntu-latest
timeout-minutes: 3
permissions:
contents: read
outputs:
# Leaf-set CSV — the BACKWARD-COMPAT promote target the promote job still
# drives off (Phase 1 behavior is unchanged).
services_csv: ${{ steps.resolve.outputs.services_csv }}
# Tier-ordered promote closure (requested transitive runtimeDeps
# Tier-1 verification, §4.2). Phase 1 SURFACES it (operators see the
# closure in the step summary) but the actual promote still uses
# services_csv; U4 enforces tier ordering / dependent-gating off these.
closure_csv: ${{ steps.resolve.outputs.closure_csv }}
# Machine-readable `tier:name` form of the closure for U4 to consume.
closure_plan: ${{ steps.resolve.outputs.closure_plan }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
- name: Generate SSOT artifact
working-directory: showcase/scripts
# The emitted JSON is EPHEMERAL here: resolve-promote-targets.sh reads it
# with jq to resolve which service(s) to promote and it is NEVER
# committed, so oxfmt-canonical formatting is irrelevant. This job's
# `npm ci` runs in showcase/scripts only and does NOT install the
# repo-root oxfmt binary the committed-artifact path shells out to, so
# leave it canonical-free via EMIT_SKIP_OXFMT (else spawnSync ENOENT
# fails the step and blocks EVERY promote). The committed-artifact path
# (static_quality.yml) keeps oxfmt REQUIRED + fail-loud.
env:
EMIT_SKIP_OXFMT: "1"
run: |
npm ci
npx tsx emit-railway-envs-json.ts
- name: Resolve target service set
id: resolve
# Resolution + the tiered-closure derivation live in the bats-tested
# showcase/scripts/resolve-promote-targets.sh so they can't drift from
# their test (see __tests__/resolve-promote-targets.bats), mirroring how
# promote-fleet.sh / verify-prod-display.sh were extracted from this same
# workflow. The script preserves the existing guards (--digest+all
# reject, empty-`all` fail-loud, unknown/ambiguous/not-prod-eligible) and
# emits services_csv (leaf, backward-compat) + closure_csv/closure_plan
# (tiered closure) into $GITHUB_OUTPUT, plus the closure plan + skips into
# $GITHUB_STEP_SUMMARY.
env:
INPUT: ${{ inputs.service }}
DIGEST: ${{ inputs.digest }}
GENERATED: showcase/scripts/railway-envs.generated.json
run: showcase/scripts/resolve-promote-targets.sh
verify-staging-precondition:
needs: [resolve-targets]
runs-on: ubuntu-latest
timeout-minutes: 15
environment: railway
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
- working-directory: showcase/scripts
run: npm ci
- name: Live-probe staging for promote precondition
working-directory: showcase/scripts
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
SERVICES_CSV: ${{ needs.resolve-targets.outputs.services_csv }}
run: |
if [ -z "$RAILWAY_TOKEN" ]; then
echo "::error::RAILWAY_TOKEN is not set"
exit 1
fi
# Spec §7.2 P3: the live staging probe at promote time is
# authoritative. CI verify history is a leading indicator only.
# Run from showcase/scripts (where `npm ci` installed tsx) so npx
# uses the local install instead of network-fetching it.
#
# --skip-ineligible: SERVICES_CSV is the PROMOTE target set, which
# for `service=all` legitimately includes services that are
# promotable but NOT staging-probe-eligible (the starter-* fleet
# carries probe.staging=false in the SSOT). Those are an expected
# state here, not a fault — verify-deploy SKIPS them with a clear
# status line and probes only the eligible services, rather than
# hard-crashing the whole precondition on the first starter-*. This
# flag does NOT relax green for eligible services, nor does it
# tolerate an unknown (non-SSOT) name — a typo is still a hard error.
npx tsx verify-deploy.ts --env staging --services "$SERVICES_CSV" --skip-ineligible
promote:
needs: [resolve-targets, verify-staging-precondition]
# Do NOT hard-depend on verify-staging-precondition's RESULT. That job
# probes the FULL requested set all-or-nothing, so a single staging-red
# service (e.g. a chronically-broken integration in `all`) fails it and
# — under the default `if: success()` — would SKIP promote entirely,
# re-blocking the whole fleet. bin/railway enforces staging-green
# PER-SERVICE (spec §7 P2 = latest staging deploy must be SUCCESS, P3 =
# live staging probe, default-on), so a red service is refused on its own
# and lands in promote-fleet.sh's failed set (reds the run) without
# taking the greens down with it. verify-staging-precondition therefore
# stays as an advisory early signal surfaced in the notify payload, not a
# hard blocker. `!cancelled()` keeps a human-cancelled run from promoting;
# mirrors the verify-prod gate idiom below.
if: ${{ !cancelled() && needs.resolve-targets.result == 'success' }}
runs-on: ubuntu-latest
# promote-fleet.sh now fans out promotes WITHIN a tier (PROMOTE_FANOUT, cap
# 5) instead of running the whole fleet serially. Even so, the largest tier
# (~30 integration leaves) at cap 5 with bin/railway's ~300s/service
# verify_serving_digest! is ~6 batches * ~5 min ≈ 30 min, so 20 min cancelled
# a `service=all` promote mid-fleet. 35 min leaves headroom above the
# worst-case tier without masking a genuinely stuck promote.
timeout-minutes: 35
environment: railway
permissions:
contents: read
packages: read
outputs:
# CSV of the services that ACTUALLY promoted (best-effort: a partial
# failure still exposes the succeeded subset). verify-prod scopes its
# prod verification to exactly this set so a single failed service can't
# red the verification of the services that did promote.
succeeded_csv: ${{ steps.promote.outputs.succeeded_csv }}
# Aggregated staging-drift payload: non-empty when any promoted service's
# staging RUNNING digest differed from the current GHCR :latest (i.e.
# staging was NOT serving :latest). Folded into the Slack notify payload
# so operators see "what shipped to prod is not current :latest".
staging_drift: ${{ steps.promote.outputs.staging_drift }}
# Base64-encoded results JSON (schema_version=1) carrying BOTH the
# succeeded[] and failed[] sets, consumed by the three-variant Slack
# renderer (showcase_promote_notify.yml). promote-fleet.sh is the SSOT
# for the result set; the notify job enriches this blob with run-context
# (run_id, trigger, operator, elapsed, pre_staging) before dispatch.
# Empty when the promote step did not run (the notify job synthesizes a
# total-failure blob in that case).
results_b64: ${{ steps.promote.outputs.results_b64 }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
with:
ruby-version: "3.3"
bundler-cache: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
- working-directory: showcase/scripts
# Same EPHEMERAL regeneration as resolve-targets: bin/railway reads this
# JSON to derive EXPECTED_DOMAINS and it is never committed, so skip the
# oxfmt-canonical pass (repo-root oxfmt is not installed by this job's
# showcase/scripts-scoped `npm ci`; an ENOENT here would abort promote).
env:
EMIT_SKIP_OXFMT: "1"
run: |
npm ci
npx tsx emit-railway-envs-json.ts
- name: bin/railway promote
id: promote
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Tier-ordered closure plan (U4): promote-fleet.sh prefers CLOSURE_PLAN
# over SERVICES_CSV, promoting BY TIER (0->1->2) with dependent-tier
# gating AND within-tier parallel fan-out (PROMOTE_FANOUT). A serial
# `service=all` fleet overran the job timeout; tiered fan-out cuts the
# wall-clock so the whole fleet completes inside timeout-minutes.
CLOSURE_PLAN: ${{ needs.resolve-targets.outputs.closure_plan }}
# Retained as the backward-compat fallback: promote-fleet.sh uses this
# ONLY when CLOSURE_PLAN is empty (e.g. an older resolve step).
SERVICES_CSV: ${{ needs.resolve-targets.outputs.services_csv }}
DIGEST: ${{ inputs.digest }}
run: |
set -euo pipefail
if [ -z "$RAILWAY_TOKEN" ]; then
echo "::error::RAILWAY_TOKEN is not set"
exit 1
fi
# promote-fleet.sh runs each service in turn BEST-EFFORT: a single
# red service (e.g. a chronically-broken integration in the `all`
# set) must not abort promotion of the rest of the fleet. The script
# attempts every service, aggregates the succeeded/failed sets, emits
# a step summary, and exits non-zero iff ANY service failed (so the
# notify job still fires) — but only AFTER attempting all of them.
# bin/railway itself handles spec §7 preconditions (P1..P6);
# --require-staging-green is default-on and the prior job already
# established staging is green (defense in depth). We deliberately do
# NOT pass --confirm-divergence: WARN-divergence refusals are a real
# signal that must fail the run.
RAILWAY_BIN="showcase/bin/railway" \
showcase/scripts/promote-fleet.sh
verify-prod:
needs: [resolve-targets, promote]
# Run whenever promote actually RAN — success OR partial failure — so the
# services that DID promote still get prod verification. `!cancelled()`
# excludes a human-cancelled run; `needs.promote.result != 'skipped'`
# excludes the case where promote never ran (e.g. an upstream abort/skip).
# Under the default `if: success()` this job was SKIPPED on any partial
# promote failure, leaving the promoted services with zero prod
# verification — that is the bug this gate fixes.
if: ${{ !cancelled() && needs.promote.result != 'skipped' }}
runs-on: ubuntu-latest
timeout-minutes: 20
environment: railway
permissions:
contents: read
outputs:
# Distinguishes a job that actually PROBED prod (`success`) from one that
# SKIPPED probing because nothing promoted (`skipped`). The GitHub job
# `result` is `success` in BOTH cases (the skip path exits 0), so the
# notify job must read THIS output — not `needs.verify-prod.result` — to
# avoid reporting a misleading `verify-prod=success` when prod was never
# touched. A real probe failure / contract violation exits non-zero, so
# the job `result` becomes `failure` and this output is never written
# (notify falls back to the job result for that case).
status: ${{ steps.verify.outputs.status }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
- working-directory: showcase/scripts
run: npm ci
- name: Run verify-deploy --env prod
id: verify
working-directory: showcase/scripts
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
# Scope verification to the services that ACTUALLY promoted (from the
# promote job's best-effort succeeded set), NOT the full requested set
# (resolve-targets) — verifying a service that failed to promote would
# guarantee a red verify and mask the health of the ones that did
# promote.
SERVICES_CSV: ${{ needs.promote.outputs.succeeded_csv }}
# The promote job's result, so the empty-CSV branch can tell a genuine
# all-failed run (empty succeeded set is expected) apart from a
# contract violation (promote reported success yet emitted no CSV).
PROMOTE_RESULT: ${{ needs.promote.result }}
run: |
set -euo pipefail
if [ -z "$RAILWAY_TOKEN" ]; then
echo "::error::RAILWAY_TOKEN is not set"
exit 1
fi
# If NOTHING promoted (every requested service failed, so the
# succeeded set is empty), there is nothing to verify — skip with a
# clear log line rather than calling verify-deploy.ts with an empty
# --services (which would either error or vacuously pass). The promote
# job already exited non-zero in that case, so the overall run is red
# via the notify state machine regardless.
#
# BUT: an empty succeeded set is only legitimate when promote did NOT
# succeed. If promote reported success and STILL emitted no CSV, the
# "promote already failed" assumption that justifies the vacuous skip
# is violated — fail loud instead of silently exiting 0.
if [ -z "$SERVICES_CSV" ]; then
if [ "$PROMOTE_RESULT" = "success" ]; then
echo "::error::promote reported success but succeeded_csv is empty — contract violation"
exit 1
fi
echo "::notice::succeeded_csv is empty (no services promoted, or promote did not run); skipping prod verification. The run is red via the promote job result if anything failed."
# Record that prod was SKIPPED, not verified. The job still exits 0
# (its `result` is `success`), so the notify job reads this `status`
# output to render `verify-prod=skipped` instead of a misleading
# `verify-prod=success`.
echo "status=skipped" >> "$GITHUB_OUTPUT"
exit 0
fi
# Run from showcase/scripts (where `npm ci` installed tsx) so npx uses
# the local install instead of network-fetching it. A non-zero exit
# here fails the step (job `result` = failure) and `status` is never
# written, so notify falls back to the job result.
npx tsx verify-deploy.ts --env prod --services "$SERVICES_CSV"
# Prod was actually probed and passed.
echo "status=success" >> "$GITHUB_OUTPUT"
# ── Prod equivalence gate (UNIT U10, spec §6.2) ──────────────────────
# After the closure pins (promote job) AND the per-service prod probe
# (above), re-sweep the promoted integration closure on the PROD control
# plane (prod harness scheduler + prod harness-workers, or scheduler
# INLINE fallback when workers are unprovisioned — §4.4) and run U9's
# equivalence gate over the FRESH prod rows vs current staging. Promote
# success flips to the equivalence definition (fail only on
# staging-green / prod-not-green, gray/stale excluded, one-directional).
#
# GATED ON CONFIGURATION: the equivalence gate needs prod + staging
# PocketBase creds and the prod Railway environment id. Those are an
# out-of-PR operational prerequisite (§8.1) — until the secrets are wired
# the step ANNOTATES "not configured" and is a no-op, so this job's
# existing per-service prod verification (above) remains the gate. Once
# configured, a gate FAILURE fails the step (and the run).
# Install the pnpm workspace ONLY when the equivalence gate is configured
# (the gate step's enqueue dynamically imports the harness producer graph,
# which lives in the pnpm workspace — `npm ci` in showcase/scripts alone
# does not resolve it). `--ignore-scripts` skips postinstall/Playwright
# browser downloads: the host only ENQUEUES + POLLS prod PocketBase; the
# prod harness-workers own the browser. Gated on the same config check as
# the gate step so the no-op (unconfigured) path stays cheap.
- name: Set up pnpm (equivalence gate only)
if: ${{ steps.verify.outputs.status == 'success' && vars.SHOWCASE_PROD_POCKETBASE_URL != '' && vars.SHOWCASE_STAGING_POCKETBASE_URL != '' && vars.SHOWCASE_RAILWAY_ENV_ID_PROD != '' }}
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: pnpm install (equivalence gate only)
if: ${{ steps.verify.outputs.status == 'success' && vars.SHOWCASE_PROD_POCKETBASE_URL != '' && vars.SHOWCASE_STAGING_POCKETBASE_URL != '' && vars.SHOWCASE_RAILWAY_ENV_ID_PROD != '' }}
run: pnpm install --ignore-scripts
- name: Prod equivalence re-sweep gate
if: ${{ steps.verify.outputs.status == 'success' }}
working-directory: showcase/scripts
env:
# The promoted closure subset that ACTUALLY pinned (best-effort) —
# the set to re-sweep + compare. Same scope as the prod probe above.
PROMOTED_CLOSURE_CSV: ${{ needs.promote.outputs.succeeded_csv }}
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
RAILWAY_PROJECT_ID: ${{ vars.SHOWCASE_RAILWAY_PROJECT_ID }}
RAILWAY_ENVIRONMENT_ID_PROD: ${{ vars.SHOWCASE_RAILWAY_ENV_ID_PROD }}
PROD_POCKETBASE_URL: ${{ vars.SHOWCASE_PROD_POCKETBASE_URL }}
PROD_POCKETBASE_SUPERUSER_EMAIL: ${{ secrets.SHOWCASE_PROD_POCKETBASE_SUPERUSER_EMAIL }}
PROD_POCKETBASE_SUPERUSER_PASSWORD: ${{ secrets.SHOWCASE_PROD_POCKETBASE_SUPERUSER_PASSWORD }}
STAGING_POCKETBASE_URL: ${{ vars.SHOWCASE_STAGING_POCKETBASE_URL }}
STAGING_POCKETBASE_SUPERUSER_EMAIL: ${{ secrets.SHOWCASE_STAGING_POCKETBASE_SUPERUSER_EMAIL }}
STAGING_POCKETBASE_SUPERUSER_PASSWORD: ${{ secrets.SHOWCASE_STAGING_POCKETBASE_SUPERUSER_PASSWORD }}
# §4.4 annotation: flip to "false" once prod harness-workers are
# provisioned-but-degraded is the default-true full-throughput path.
PROD_HARNESS_WORKERS_PROVISIONED: ${{ vars.SHOWCASE_PROD_HARNESS_WORKERS_PROVISIONED }}
run: |
set -euo pipefail
# OUT-OF-PR PREREQ GATE: the equivalence gate is inert until the prod
# + staging PocketBase + prod Railway env-id config is wired (§8.1).
# Absence is NOT a failure — the per-service prod probe above already
# gated the run; this step just annotates that the equivalence gate
# was skipped for lack of configuration.
if [ -z "${PROD_POCKETBASE_URL:-}" ] || [ -z "${STAGING_POCKETBASE_URL:-}" ] || [ -z "${RAILWAY_ENVIRONMENT_ID_PROD:-}" ]; then
echo "::notice::Prod equivalence gate not configured (prod/staging PocketBase + prod Railway env-id absent) — skipping the re-sweep gate. This is the §8.1 out-of-PR prerequisite; the per-service prod probe remains the gate."
exit 0
fi
if [ -z "${PROMOTED_CLOSURE_CSV:-}" ]; then
echo "::notice::no promoted closure to re-sweep — equivalence gate vacuously passes."
exit 0
fi
# A gate FAILURE (a genuine staging-green / prod-not-green regression)
# or a re-sweep timeout (REFUSE) exits non-zero → fails this step and
# the run, exactly like the per-service probe above. `pnpm exec` (not
# `npx`) so the dynamically-imported harness producer graph resolves
# through the workspace node_modules the install step above created.
pnpm exec tsx verify-prod-resweep.ts
# ── Prod pinned-ness gate (UNIT U12, spec §8.2) ──────────────────────
# COMPLEMENTS the equivalence gate above — it asserts PINNED-NESS, not
# equivalence. After the promote pins the closure and prod is verified
# healthy + equivalent, assert that EVERY prod service is on an immutable
# `@sha256:` digest, not a mutable `:latest` tag. A born-on-:latest prod
# service (deploy-to-railway.ts provisions an unpinned source.image, spec
# R-E) can be healthy AND equivalent while still floating on a mutable tag
# — a latent rollback/repro hazard the other gates do not catch. The gate
# logic lives in the bats-tested showcase/scripts/lint-prod-gate.sh (a thin
# `bin/railway lint-prod` wrapper) so it can't drift from its test (see
# __tests__/lint-prod-gate.bats). Runs whenever prod was actually probed
# (same `status == 'success'` guard as the equivalence gate); a non-zero
# exit (an unpinned prod service, or a hard lint-prod error) fails the step
# and the run. No `--exit-zero`: an unpinned prod service must red the run.
- name: Prod pinned-ness gate (lint-prod)
if: ${{ steps.verify.outputs.status == 'success' }}
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
run: |
set -euo pipefail
if [ -z "$RAILWAY_TOKEN" ]; then
echo "::error::RAILWAY_TOKEN is not set"
exit 1
fi
showcase/scripts/lint-prod-gate.sh
notify:
# Single Slack message per run via the three-variant aggregated renderer
# (.github/workflows/showcase_promote_notify.yml): success ✅ / partial ⚠️
# (per-service Failed bullets, cross-posts #oss-alerts) / total ❌. This job
# REPLACES the old inline two-state notify (success/failure-only, which
# dumped the full requested CSV and mislabeled a partial promote as a blanket
# "Failed [all 39]"). It builds the results JSON (from the promote job's
# results_b64, enriched with this run's context) and DISPATCHES the renderer.
#
# Best-effort promote invariant (do NOT reintroduce a PRE gate):
# resolve-targets = HARD precondition (must succeed).
# verify-staging-precondition = ADVISORY only — surfaced in the Slack
# payload (`pre_staging`), never gates
# promote or run success.
# promote = best-effort (exits non-zero iff a
# service failed).
# verify-prod = verifies the succeeded subset.
needs: [resolve-targets, verify-staging-precondition, promote, verify-prod]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
# The job's own GITHUB_TOKEN cannot start NEW workflow runs (Actions'
# recursion-prevention drops workflow_dispatch events authenticated with
# GITHUB_TOKEN), so the renderer dispatch goes through the devops-bot App
# token minted below — mirroring canary.yml's cross-workflow dispatch.
contents: read
# actions:read lets the payload step query this run's own metadata
# (created_at) to compute real wall-clock elapsed for the Slack message.
actions: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Build notify payload
id: payload
env:
INPUT: ${{ inputs.service }}
# promote-fleet's results JSON (schema_version=1, succeeded[]+failed[]).
# Empty when the promote step did not run (upstream abort/skip/cancel).
RESULTS_B64: ${{ needs.promote.outputs.results_b64 }}
RESOLVE: ${{ needs.resolve-targets.result }}
PRE: ${{ needs.verify-staging-precondition.result }}
PROMOTE: ${{ needs.promote.result }}
PROD: ${{ needs.verify-prod.result }}
# Operator identity for the renderer's `operator_mention`. The renderer
# prefers a Slack users.lookupByEmail on operator_email; we have no
# reliable email for github.actor, so pass the actor as the git-name
# fallback (the renderer degrades to it when the email lookup is empty).
ACTOR: ${{ github.actor }}
# gh api (run metadata lookup for real wall-clock elapsed) needs a token.
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# SKIP cases that must NOT post a Slack message (parity with the old
# neutral-state arms): a deliberate no-pick abort, or a human cancel.
if [ "$INPUT" = "__select_a_service__" ]; then
echo "::notice::no service selected (deliberate no-op abort); skipping Slack notify."
echo "dispatch=0" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$RESOLVE" = "cancelled" ] || [ "$PRE" = "cancelled" ] || [ "$PROMOTE" = "cancelled" ] || [ "$PROD" = "cancelled" ]; then
echo "::notice::run cancelled by a human; skipping Slack notify (no red page)."
echo "dispatch=0" >> "$GITHUB_OUTPUT"
exit 0
fi
# Map the ADVISORY verify-staging-precondition result onto the
# renderer's pre_staging glyph vocabulary (green/amber/red/skipped).
case "$PRE" in
success) PRE_STAGING="green" ;;
failure) PRE_STAGING="amber" ;; # advisory red → amber (not a gate)
*) PRE_STAGING="skipped" ;;
esac
# 6-char lowercase hex run_id — the renderer's HARD CONTRACT
# (^[0-9a-f]{6}$; see its run-name directive). Derive it from this
# run's own id so it is stable + greppable, never random.
RUN_ID=$(printf '%06x' "$(( GITHUB_RUN_ID % 16777216 ))")
# Base results JSON: prefer promote-fleet's emitted blob (the SSOT for
# succeeded[]/failed[]). When promote never ran (no results_b64), the
# run aborted upstream — synthesize a total-failure blob so the
# renderer posts the ❌ variant instead of nothing.
if [ -n "${RESULTS_B64:-}" ]; then
if ! printf '%s' "$RESULTS_B64" | base64 -d > /tmp/results-base.json 2>/dev/null; then
echo "::error::failed to decode promote results_b64"
exit 1
fi
else
jq -nc '{schema_version:1, abort_reason:"fleet-preflight", succeeded:[], failed:[]}' > /tmp/results-base.json
fi
# Real wall-clock elapsed: this notify job runs last (needs: [...],
# if: always()), so (now - run.created_at) is the run's end-to-end
# duration. Query this run's own metadata via gh api (actions:read).
# Fall back to 0 only if the lookup fails or yields a non-sane value,
# in which case the renderer omits the "in {elapsed}" phrasing.
ELAPSED=0
created_at=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--jq '.created_at' 2>/dev/null || echo "")
if [ -n "$created_at" ]; then
start_epoch=$(date -u -d "$created_at" +%s 2>/dev/null || echo "")
if [ -n "$start_epoch" ]; then
now_epoch=$(date -u +%s)
delta=$(( now_epoch - start_epoch ))
# Guard against clock skew / parse glitches producing a negative.
if [ "$delta" -ge 0 ]; then
ELAPSED="$delta"
fi
fi
fi
# Enrich the base blob with this run's context (run_id, trigger,
# operator, elapsed, pre_staging). trigger=workflow (the renderer maps
# it to the `showcase_promote.yml` label). elapsed_seconds is the real
# wall-clock computed above; the renderer formats it as "Nm SSs".
ENRICHED_B64=$(jq -c \
--arg run_id "$RUN_ID" \
--arg trigger "workflow" \
--arg actor "$ACTOR" \
--arg pre "$PRE_STAGING" \
--argjson elapsed "$ELAPSED" \
'. + {run_id:$run_id, trigger:$trigger, operator_git_name:$actor, elapsed_seconds:$elapsed, pre_staging:$pre}' \
/tmp/results-base.json | base64 | tr -d '\n')
{
echo "dispatch=1"
echo "run_id=$RUN_ID"
echo "results_b64=$ENRICHED_B64"
} >> "$GITHUB_OUTPUT"
- name: Mint devops-bot token
id: app-token
if: steps.payload.outputs.dispatch == '1'
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: 1108748
private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }}
# actions=write is the ONLY scope needed: dispatch the sibling renderer
# workflow. The default GITHUB_TOKEN cannot start new workflow runs.
permission-actions: write
- name: Dispatch showcase_promote_notify.yml
if: steps.payload.outputs.dispatch == '1'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
RESULTS_B64: ${{ steps.payload.outputs.results_b64 }}
RUN_ID: ${{ steps.payload.outputs.run_id }}
run: |
set -euo pipefail
# Dispatch the three-variant renderer. It owns ALL Slack posting
# (#team-showcase init + thread, #oss-alerts cross-post on
# partial/total). Pass trigger=workflow + the 6-hex run_id (which is
# also the renderer's run-name for the CLI's gh-run-list polling
# contract). Dispatched on the default branch (no --ref) so the
# renderer's reviewed main-branch definition runs.
gh workflow run showcase_promote_notify.yml \
--repo "$GITHUB_REPOSITORY" \
-f results="$RESULTS_B64" \
-f trigger=workflow \
-f run_id="$RUN_ID"
echo "dispatched showcase_promote_notify.yml (run_id=$RUN_ID)"
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env bash
# showcase_promote_notify.dry-run.sh
#
# Mirrors the decode + render logic in `showcase_promote_notify.yml` so
# the workflow can be exercised without invoking Slack.
#
# Usage:
# showcase_promote_notify.dry-run.sh <base64-results-json>
# showcase_promote_notify.dry-run.sh --file <path-to-json>
#
# Output: prints, for each Slack call the workflow WOULD make, a block of
# the form:
# --- chat.postMessage ---
# channel: <channel>
# text: |
# <text>
#
# Exits 0 on success, 1 on decode/argv errors, 2 on schema_version mismatch
# (notify workflow aborts gracefully — we treat that as a non-fatal but
# distinct exit so callers can assert on it).
# ---------- alert post-and-verify predicate (shared with the workflow) ----------
# Slack returns HTTP 200 with `{"ok":false,"error":"..."}` on LOGICAL failures
# (channel_not_found, not_in_channel, ...). A failure-ALERT that is silently
# dropped pages nobody, so the live workflow MUST surface it. This predicate is
# the testable core of that surfacing logic: it inspects a captured Slack
# response and, when the post did NOT succeed, emits a GitHub `::warning::`
# (matching the workflow's existing `::warning::`/`>&2` idiom) and returns 1.
#
# Sourcing this script (e.g. from bats) defines this function without running
# the dry-run body — see the EXECUTION GUARD just below the function.
# $1 = label for the warning (e.g. "thread reply", "#oss-alerts cross-post")
# $2 = captured Slack API response body (JSON, or "{}" on transport failure)
slack_alert_posted_ok() {
local label="$1"
local resp="$2"
local ok
ok=$(printf '%s' "$resp" | jq -r '.ok // false' 2>/dev/null || echo false)
if [ "$ok" != "true" ]; then
local err
err=$(printf '%s' "$resp" | jq -r '.error // "unknown"' 2>/dev/null || echo unknown)
echo "::warning::Slack ${label} did NOT post (ok=${ok} error=${err}); failure alert may have been dropped" >&2
return 1
fi
return 0
}
# EXECUTION GUARD: define functions only when sourced. `return` outside a
# function is legal only in a sourced script (it errors when executed), so the
# subshell `(return 0 2>/dev/null)` succeeds iff we are being sourced — in which
# case we `return 0` here and skip the dry-run body below. When executed
# directly the subshell fails and execution falls through to `set -euo`.
(return 0 2>/dev/null) && return 0
set -euo pipefail
if [ "${1:-}" = "" ]; then
echo "usage: $0 <base64-results-json> | --file <path>" >&2
exit 1
fi
if [ "$1" = "--file" ]; then
if [ -z "${2:-}" ]; then
echo "usage: $0 --file <path>" >&2
exit 1
fi
if [ ! -f "$2" ]; then
echo "error: file not found: $2" >&2
exit 1
fi
cp "$2" /tmp/dry-run-results.json
else
if ! printf '%s' "$1" | base64 -d > /tmp/dry-run-results.json 2>/tmp/dry-run-results.err; then
echo "error: base64 decode failed: $(cat /tmp/dry-run-results.err)" >&2
exit 1
fi
fi
if ! jq -e . /tmp/dry-run-results.json >/dev/null 2>&1; then
echo "error: decoded payload is not valid JSON" >&2
exit 1
fi
R=/tmp/dry-run-results.json
schema_version=$(jq -r '.schema_version // empty' "$R")
if [ "$schema_version" != "1" ]; then
echo "warn: schema_version mismatch — expected 1, got '${schema_version}'; would abort Slack post"
exit 2
fi
run_id=$(jq -r '.run_id' "$R")
# Enforce the run_id contract — required for the CLI's gh run list polling.
# See HARD CONTRACT comment + run-name directive in showcase_promote_notify.yml.
# A malformed run_id is a dispatcher-contract violation, not a recoverable
# runtime condition, so hard-fail (exit 1) rather than warn-and-abort.
if ! printf '%s' "$run_id" | grep -Eq '^[0-9a-f]{6}$'; then
echo "error: run_id '$run_id' does not match ^[0-9a-f]{6}$ (breaks CLI polling contract; see run-name)" >&2
exit 1
fi
trigger=$(jq -r '.trigger' "$R")
operator_email=$(jq -r '.operator_email // ""' "$R")
operator_git_name=$(jq -r '.operator_git_name // ""' "$R")
# Coerce to an integer up front: elapsed_seconds may arrive as a float (e.g.
# 5.2) OR as a JSON STRING (e.g. "5.2"). `floor` on a string raises jq error 5
# ("number required") which, under `set -euo pipefail`, aborts the whole render
# step so NO Slack message posts. `tonumber?` parses numeric strings and
# swallows non-numeric input (-> 0); `floor` then yields the integer Bash
# `[ -gt ]`/`$(( ))` need.
elapsed=$(jq -r '(.elapsed_seconds // 0) | tonumber? // 0 | floor' "$R")
pre_staging=$(jq -r '.pre_staging // "skipped"' "$R")
abort_reason=$(jq -r '.abort_reason // ""' "$R")
succeeded_count=$(jq -r '.succeeded | length' "$R")
jq '.failed | sort_by(.service)' "$R" > /tmp/dry-run-failed-sorted.json
jq '[.[] | select(.category != "truncation-suffix")]' /tmp/dry-run-failed-sorted.json > /tmp/dry-run-failed-render.json
truncation_more=$(jq -r '[.[] | select(.category == "truncation-suffix") | .service] | .[0] // ""' /tmp/dry-run-failed-sorted.json)
# Counts:
# total_count = succeeded_count + failed_real_count
# succeeded_count = raw .succeeded length
# failed_real_count = .failed length minus truncation-suffix sentinels
# (rendered to operators on all display lines)
failed_real_count=$(jq 'length' /tmp/dry-run-failed-render.json)
total_count=$((succeeded_count + failed_real_count))
# Comma-separated list of the SUCCEEDED service names, for the ✅ success
# thread reply AND the ⚠️ partial reply's `Promoted:` line. The runtime blob
# emits .succeeded[] as {service} objects (see promote-fleet.sh); tolerate bare
# strings too (hand-written fixtures use them).
succeeded_csv=$(jq -r '[.succeeded[] | if type == "object" then .service else . end] | join(", ")' "$R")
# Names of every ATTEMPTED service (succeeded + real failures), for the init
# post. For `service=all` this is the drifted subset resolve-targets selected;
# for a scoped/single-service dispatch it is exactly what was requested. Either
# way it is the set we ATTEMPTED — we do not claim the rest was already current.
# Sorted for a stable, legible list; the truncation-suffix sentinel is excluded
# via /tmp/dry-run-failed-render.json.
attempted_csv=$(jq -rs '
(.[0] | [.succeeded[] | if type == "object" then .service else . end])
+ (.[1] | [.[].service])
| sort | join(", ")
' "$R" /tmp/dry-run-failed-render.json)
# GitHub Actions run URL — used by the success message's inline "View run" link.
# In CI GITHUB_REPOSITORY/GITHUB_RUN_ID are set; in a bare dry-run they may not
# be, so fall back to a stable placeholder so the rendered shape still matches.
gha_url="https://github.com/${GITHUB_REPOSITORY:-CopilotKit/CopilotKit}/actions/runs/${GITHUB_RUN_ID:-<run_id>}"
# elapsed is the real wall-clock seconds the dispatcher measured
# (showcase_promote.yml computes now - run.created_at). When it is a positive
# value we render " in Nm SSs"; when it is 0 (dispatcher could not measure it,
# or a hand-dispatch passed nothing) we OMIT the phrase entirely rather than
# print a meaningless "in 0m 00s".
fmt_elapsed() {
local total="$1"
local m=$((total / 60))
local s=$((total % 60))
printf '%dm %02ds' "$m" "$s"
}
if [ "$elapsed" -gt 0 ] 2>/dev/null; then
elapsed_phrase=" in $(fmt_elapsed "$elapsed")"
else
elapsed_phrase=""
fi
# operator mention: dry-run simulates a successful Slack lookup; falls back to git name then "unknown" if no email present.
if [ -n "$operator_email" ]; then
operator_mention="<lookupByEmail:${operator_email}>"
elif [ -n "$operator_git_name" ]; then
operator_mention="$operator_git_name"
else
operator_mention="unknown"
fi
case "$pre_staging" in
green) pre_staging_line="pre_staging: ✓ green" ;;
amber) pre_staging_line="pre_staging: ⚠ amber" ;;
red) pre_staging_line="pre_staging: ✗ red" ;;
skipped) pre_staging_line="pre_staging: — skipped" ;;
*) pre_staging_line="pre_staging: ${pre_staging}" ;;
esac
if [ "$trigger" = "cli" ]; then
# shellcheck disable=SC2016
trigger_label='`bin/railway --notify`'
else
# shellcheck disable=SC2016
trigger_label='`showcase_promote.yml`'
fi
# Name the services being promoted this run. We name only what was ATTEMPTED
# — accurate whether the dispatch was `service=all` (the drifted subset) or a
# single service. We do NOT claim the rest of the fleet was "already current":
# for a scoped/single-service dispatch that is false (it conflates "attempted"
# with "drifted"). Fall back to a bare count when the attempted set is empty
# (e.g. a fleet-preflight abort that touched zero services).
if [ -n "$attempted_csv" ]; then
init_headline="🚂 *Promoting showcase → prod* (${total_count}): ${attempted_csv}"
else
init_headline="🚂 *Promoting showcase → prod* (${total_count})"
fi
init_text="${init_headline}
operator ${operator_mention} · trigger ${trigger_label} · run \`${run_id}\`
${pre_staging_line}"
fail_bullets=$(jq -r '.[] | "• `\(.service)` — exit \(.exit) (\(.category))"' /tmp/dry-run-failed-render.json)
if [ -n "$truncation_more" ]; then
if [ -n "$fail_bullets" ]; then
fail_bullets="${fail_bullets}
${truncation_more}"
else
fail_bullets="${truncation_more}"
fi
fi
# Branching uses failed_real_count (excludes truncation sentinel) so a
# sentinel-only failed[] does not get mis-classified as partial and
# spuriously cross-posted to #oss-alerts.
#
# An abort_reason combined with zero successes is ALWAYS a total abort,
# regardless of failed_real_count — fleet-preflight refusals abort the
# whole run BEFORE any service is attempted (succeeded=[], failed=[] or
# sentinel-only). Without this guard, the failed_real_count==0 branch
# would fire first and mis-announce the run as a clean success.
if [ -n "$abort_reason" ] && [ "$succeeded_count" -eq 0 ]; then
outcome="total"
case "$abort_reason" in
fleet-preflight) reason_line="*Reason:* fleet-wide preflight refused" ;;
per-service) reason_line="*Reason:* all services individually refused" ;;
*) reason_line="*Reason:* aborted" ;;
esac
if [ "$failed_real_count" -eq 0 ]; then
# Fleet-preflight abort with zero services touched: no bullets to
# render, so omit the *Failed:* heading entirely.
thread_text="❌ *Aborted${elapsed_phrase}* — 0 ✓ · 0 ✗
${pre_staging_line}
${reason_line}"
else
thread_text="❌ *Aborted${elapsed_phrase}* — 0 ✓ · ${failed_real_count}
${pre_staging_line}
${reason_line}
*Failed:*
${fail_bullets}"
fi
elif [ "$failed_real_count" -eq 0 ]; then
outcome="success"
thread_text="✅ *Showcase Promoted to Prod* — ${succeeded_count} ✓ · <${gha_url}|View run>
Services: ${succeeded_csv}"
elif [ "$succeeded_count" -gt 0 ] && [ "$failed_real_count" -gt 0 ]; then
outcome="partial"
thread_text="⚠️ *Done${elapsed_phrase}* — ${succeeded_count} ✓ · ${failed_real_count}
*Promoted:* ${succeeded_csv}
*Failed:*
${fail_bullets}"
else
# succeeded_count == 0 && failed_real_count > 0 — per-service refusals
# without an abort_reason set (defensive fallback).
outcome="total"
case "$abort_reason" in
fleet-preflight) reason_line="*Reason:* fleet-wide preflight refused" ;;
per-service) reason_line="*Reason:* all services individually refused" ;;
*) reason_line="*Reason:* aborted" ;;
esac
thread_text="❌ *Aborted${elapsed_phrase}* — 0 ✓ · ${failed_real_count}
${pre_staging_line}
${reason_line}
*Failed:*
${fail_bullets}"
fi
emit() {
echo "--- chat.postMessage ---"
echo "channel: $1"
echo "text: |"
printf '%s\n' "$2" | sed 's/^/ /'
echo
}
emit "#team-showcase" "$init_text"
emit "#team-showcase (thread_ts=<init_ts>)" "$thread_text"
# Mirror the workflow's post-and-verify exit semantics so the dry-run exercises
# the SAME fail-loud/warn-only distinction the live .yml does (see the matching
# slack_alert_posted_ok calls there). No real Slack call happens here, so we
# feed each predicate a simulated response: a successful post by default (the
# dry-run convention — see the operator-mention block above), overridable via
# DRY_RUN_THREAD_RESP / DRY_RUN_OSS_RESP so a test can inject a 200/ok:false
# drop and assert on the exit code.
sim_ok='{"ok":true,"ts":"<sim>"}'
# Thread reply: informational, in the promote channel — warn-only (|| true),
# mirroring the .yml. A dropped summary post must not red the job.
slack_alert_posted_ok "thread reply" "${DRY_RUN_THREAD_RESP:-$sim_ok}" || true
if [ "$outcome" != "success" ]; then
case "$outcome" in
partial) oss_text="⚠️ showcase promote: ${succeeded_count} ✓ · ${failed_real_count} ✗ — thread: <permalink>" ;;
total) oss_text="❌ showcase promote aborted: 0 ✓ · ${failed_real_count} ✗ — thread: <permalink>" ;;
esac
emit "#oss-alerts" "$oss_text"
# Page-the-humans alert: FAIL LOUD, mirroring the .yml. No `|| true` — a
# 200/ok:false drop here means nobody is told the promote failed, so the
# predicate's non-zero return must abort (set -e) and red the run.
slack_alert_posted_ok "#oss-alerts cross-post" "${DRY_RUN_OSS_RESP:-$sim_ok}"
fi
echo "outcome=${outcome} run_id=${run_id}"
@@ -0,0 +1,433 @@
name: "Showcase: Promote Notify"
# HARD CONTRACT (spec N2): the CLI polls `gh run list` for a run whose
# display_title matches `promote-<run_id>`. Without this `run-name`
# directive, that polling lookup will always time out.
run-name: promote-${{ inputs.run_id }}
on:
workflow_dispatch:
inputs:
results:
description: "Base64-encoded results JSON (schema_version=1)"
required: true
type: string
trigger:
description: "Dispatch source"
required: true
type: choice
options:
- cli
- workflow
run_id:
description: "6-char lowercase hex run id"
required: true
type: string
permissions:
contents: read
jobs:
notify:
name: Post aggregated Slack notification
runs-on: ubuntu-latest
timeout-minutes: 5
env:
RESULTS_B64: ${{ inputs.results }}
TRIGGER: ${{ inputs.trigger }}
RUN_ID: ${{ inputs.run_id }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
TEAM_SHOWCASE_CHANNEL: "#team-showcase"
OSS_ALERTS_CHANNEL: "#oss-alerts"
steps:
- name: Decode and validate results
id: decode
run: |
set -euo pipefail
if ! command -v jq >/dev/null 2>&1; then
echo "::error::jq is required"
exit 1
fi
# Decode the base64 results blob into a JSON file. The blob may
# contain newlines from `base64` line-wrapping; -d handles that.
if ! printf '%s' "$RESULTS_B64" | base64 -d > /tmp/results.json 2>/tmp/results.err; then
echo "::error::base64 decode failed: $(cat /tmp/results.err)"
exit 1
fi
if ! jq -e . /tmp/results.json >/dev/null 2>&1; then
echo "::error::decoded payload is not valid JSON"
exit 1
fi
schema_version=$(jq -r '.schema_version // empty' /tmp/results.json)
if [ "$schema_version" != "1" ]; then
echo "::warning::schema_version mismatch — expected 1, got '${schema_version}'; aborting Slack post gracefully"
echo "abort=1" >> "$GITHUB_OUTPUT"
exit 0
fi
# Enforce the run_id contract — required for the CLI's gh run list polling.
# See HARD CONTRACT comment + run-name directive at top of file. A malformed
# run_id is a dispatcher-contract violation, not a recoverable runtime
# condition, so hard-fail (exit 1) rather than warn-and-abort.
if ! printf '%s' "$RUN_ID" | grep -Eq '^[0-9a-f]{6}$'; then
echo "::error::run_id '$RUN_ID' does not match ^[0-9a-f]{6}$ (breaks CLI polling contract; see run-name)"
exit 1
fi
echo "abort=0" >> "$GITHUB_OUTPUT"
- name: Render Slack messages and post
if: steps.decode.outputs.abort == '0'
env:
# Re-export for the script step
RESULTS_PATH: /tmp/results.json
run: |
set -euo pipefail
if [ -z "${SLACK_BOT_TOKEN:-}" ]; then
echo "::error::SLACK_BOT_TOKEN is not set"
exit 1
fi
# ---------- helpers ----------
slack_api() {
# $1 = method, $2 = JSON body
local method="$1"
local body="$2"
local resp http
resp=$(mktemp)
http=$(curl -sS -o "$resp" -w '%{http_code}' \
-X POST "https://slack.com/api/${method}" \
-H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \
-H "Content-type: application/json; charset=utf-8" \
--data "$body" || echo "000")
if [ "$http" != "200" ]; then
echo "::warning::Slack ${method} non-2xx http=${http} body=$(head -c 500 "$resp")" >&2
echo "{}"
rm -f "$resp"
return 0
fi
cat "$resp"
rm -f "$resp"
}
# Slack returns HTTP 200 with `{"ok":false,"error":"..."}` on LOGICAL
# failures (channel_not_found, not_in_channel, ...). slack_api only
# surfaces non-2xx HTTP, so a failure-ALERT that posts 200/ok:false
# would otherwise be silently dropped — pages nobody. This predicate
# checks the captured response and emits a `::warning::` (mirroring the
# slack_api non-2xx idiom above) when the post did NOT succeed.
# $1 = label, $2 = captured Slack response body
slack_alert_posted_ok() {
local label="$1"
local resp="$2"
local ok
ok=$(printf '%s' "$resp" | jq -r '.ok // false' 2>/dev/null || echo false)
if [ "$ok" != "true" ]; then
local err
err=$(printf '%s' "$resp" | jq -r '.error // "unknown"' 2>/dev/null || echo unknown)
echo "::warning::Slack ${label} did NOT post (ok=${ok} error=${err}); failure alert may have been dropped" >&2
return 1
fi
return 0
}
# ---------- read payload ----------
R="$RESULTS_PATH"
run_id=$(jq -r '.run_id' "$R")
# Validate the BLOB's run_id, not just the RUN_ID input. The decode
# step (separate step, no shared shell vars) checks the input; the
# CLI/hand-dispatch path renders THIS value into the run-name and the
# Slack messages, so it must satisfy the same ^[0-9a-f]{6}$ contract.
# Mirrors showcase_promote_notify.dry-run.sh. Hard-fail (exit 1) — a
# malformed run_id is a dispatcher-contract violation, not recoverable.
if ! printf '%s' "$run_id" | grep -Eq '^[0-9a-f]{6}$'; then
echo "::error::run_id '$run_id' does not match ^[0-9a-f]{6}$ (breaks CLI polling contract; see run-name)"
exit 1
fi
trigger=$(jq -r '.trigger' "$R")
operator_email=$(jq -r '.operator_email // ""' "$R")
operator_git_name=$(jq -r '.operator_git_name // ""' "$R")
# Coerce to an integer up front: elapsed_seconds may arrive as a float
# (e.g. 5.2) OR as a JSON STRING (e.g. "5.2"). `floor` on a string
# raises jq error 5 ("number required") which, under `set -euo
# pipefail`, aborts the whole render step so NO Slack message posts.
# `tonumber?` parses numeric strings and swallows non-numeric input
# (-> 0); `floor` then yields the integer Bash `[ -gt ]`/`$(( ))` need.
elapsed=$(jq -r '(.elapsed_seconds // 0) | tonumber? // 0 | floor' "$R")
pre_staging=$(jq -r '.pre_staging // "skipped"' "$R")
abort_reason=$(jq -r '.abort_reason // ""' "$R")
succeeded_count=$(jq -r '.succeeded | length' "$R")
# Comma-separated list of the SUCCEEDED service names, for the ✅
# success thread reply AND the ⚠️ partial reply's `Promoted:` line.
# The runtime blob emits .succeeded[] as {service} objects (see
# promote-fleet.sh); tolerate bare strings too.
succeeded_csv=$(jq -r '[.succeeded[] | if type == "object" then .service else . end] | join(", ")' "$R")
# GitHub Actions run URL — used by the success message's inline
# "View run" link AND by the cross-post branch's permalink fallback.
# Computed once here so both render the SAME url.
gha_url="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
# Sort failed alphabetically by service and split off the
# truncation-suffix sentinel (if any) so it renders as a
# trailing "+ K more" line instead of a bullet.
jq '.failed | sort_by(.service)' "$R" > /tmp/failed-sorted.json
jq '[.[] | select(.category != "truncation-suffix")]' /tmp/failed-sorted.json > /tmp/failed-render.json
truncation_more=$(jq -r '[.[] | select(.category == "truncation-suffix") | .service] | .[0] // ""' /tmp/failed-sorted.json)
# Counts:
# total_count = succeeded_count + failed_real_count
# succeeded_count = raw .succeeded length
# failed_real_count = .failed length minus truncation-suffix sentinels
# (rendered to operators on all display lines)
failed_real_count=$(jq 'length' /tmp/failed-render.json)
# Service total = succeeded + real failures (truncation entries
# are not real services).
total_count=$((succeeded_count + failed_real_count))
# Names of every ATTEMPTED service (succeeded + real failures), for
# the init post. For `service=all` this is the drifted subset
# resolve-targets selected; for a scoped/single-service dispatch it is
# exactly what was requested. Either way it is the set we ATTEMPTED —
# we do not claim the rest was already current. Sorted for a stable,
# legible list; the truncation-suffix sentinel (not a real service) is
# excluded via /tmp/failed-render.json.
attempted_csv=$(jq -rs '
(.[0] | [.succeeded[] | if type == "object" then .service else . end])
+ (.[1] | [.[].service])
| sort | join(", ")
' "$R" /tmp/failed-render.json)
# ---------- format elapsed seconds ----------
# elapsed is the real wall-clock seconds the dispatcher measured
# (showcase_promote.yml computes now - run.created_at). When it is a
# positive value we render " in Nm SSs"; when it is 0 (dispatcher
# could not measure it, or a hand-dispatch passed nothing) we OMIT the
# phrase entirely rather than print a meaningless "in 0m 00s".
fmt_elapsed() {
local total="$1"
local m=$((total / 60))
local s=$((total % 60))
printf '%dm %02ds' "$m" "$s"
}
if [ "$elapsed" -gt 0 ] 2>/dev/null; then
elapsed_phrase=" in $(fmt_elapsed "$elapsed")"
else
elapsed_phrase=""
fi
# ---------- resolve operator mention ----------
operator_mention=""
if [ -n "$operator_email" ]; then
# users.lookupByEmail requires GET (Slack Web API).
lookup_resp=$(curl -sS \
-G "https://slack.com/api/users.lookupByEmail" \
-H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \
--data-urlencode "email=${operator_email}" || echo '{}')
ok=$(echo "$lookup_resp" | jq -r '.ok // false')
if [ "$ok" = "true" ]; then
uid=$(echo "$lookup_resp" | jq -r '.user.id // ""')
if [ -n "$uid" ]; then
operator_mention="<@${uid}>"
fi
fi
fi
if [ -z "$operator_mention" ]; then
if [ -n "$operator_git_name" ]; then
operator_mention="$operator_git_name"
elif [ -n "$operator_email" ]; then
operator_mention="$operator_email"
else
operator_mention="unknown"
fi
fi
# ---------- pre_staging glyph ----------
case "$pre_staging" in
green) pre_staging_line="pre_staging: ✓ green" ;;
amber) pre_staging_line="pre_staging: ⚠ amber" ;;
red) pre_staging_line="pre_staging: ✗ red" ;;
skipped) pre_staging_line="pre_staging: — skipped" ;;
*) pre_staging_line="pre_staging: ${pre_staging}" ;;
esac
# ---------- trigger label ----------
if [ "$trigger" = "cli" ]; then
trigger_label='`bin/railway --notify`'
else
trigger_label='`showcase_promote.yml`'
fi
# ---------- initiation post ----------
# Name the services being promoted this run. We name only what was
# ATTEMPTED — accurate whether the dispatch was `service=all` (the
# drifted subset) or a single service. We do NOT claim the rest of
# the fleet was "already current": for a scoped/single-service
# dispatch that is false (it conflates "attempted" with "drifted").
# Fall back to a bare count when the attempted set is empty (e.g. a
# fleet-preflight abort that touched zero services).
if [ -n "$attempted_csv" ]; then
init_headline="🚂 *Promoting showcase → prod* (${total_count}): ${attempted_csv}"
else
init_headline="🚂 *Promoting showcase → prod* (${total_count})"
fi
init_text="${init_headline}
operator ${operator_mention} · trigger ${trigger_label} · run \`${run_id}\`
${pre_staging_line}"
# Strip leading whitespace introduced by the heredoc-style indent above.
init_text=$(printf '%s\n' "$init_text" | sed 's/^[[:space:]]\{10\}//')
init_body=$(jq -nc \
--arg channel "$TEAM_SHOWCASE_CHANNEL" \
--arg text "$init_text" \
'{channel:$channel, text:$text}')
init_resp=$(slack_api chat.postMessage "$init_body")
init_ok=$(echo "$init_resp" | jq -r '.ok // false')
init_ts=$(echo "$init_resp" | jq -r '.ts // ""')
init_channel_id=$(echo "$init_resp" | jq -r '.channel // ""')
if [ "$init_ok" != "true" ] || [ -z "$init_ts" ]; then
echo "::warning::initiation post failed; continuing without threading"
fi
# ---------- permalink (for #oss-alerts cross-post) ----------
permalink=""
if [ -n "$init_ts" ] && [ -n "$init_channel_id" ]; then
perm_resp=$(curl -sS \
-G "https://slack.com/api/chat.getPermalink" \
-H "Authorization: Bearer ${SLACK_BOT_TOKEN}" \
--data-urlencode "channel=${init_channel_id}" \
--data-urlencode "message_ts=${init_ts}" || echo '{}')
if [ "$(echo "$perm_resp" | jq -r '.ok // false')" = "true" ]; then
permalink=$(echo "$perm_resp" | jq -r '.permalink // ""')
fi
fi
# ---------- build failure bullets ----------
fail_bullets=$(jq -r '.[] | "• `\(.service)` — exit \(.exit) (\(.category))"' /tmp/failed-render.json)
if [ -n "$truncation_more" ]; then
if [ -n "$fail_bullets" ]; then
fail_bullets="${fail_bullets}
${truncation_more}"
else
fail_bullets="${truncation_more}"
fi
fi
# ---------- determine outcome & build thread reply ----------
# Branching uses failed_real_count (excludes truncation sentinel)
# so a sentinel-only failed[] does not get mis-classified as
# partial and spuriously cross-posted to #oss-alerts.
#
# An abort_reason combined with zero successes is ALWAYS a total
# abort, regardless of failed_real_count — fleet-preflight
# refusals abort the whole run BEFORE any service is attempted
# (succeeded=[], failed=[] or sentinel-only). Without this guard,
# the failed_real_count==0 branch would fire first and
# mis-announce the run as a clean success.
if [ -n "$abort_reason" ] && [ "$succeeded_count" -eq 0 ]; then
outcome="total"
case "$abort_reason" in
fleet-preflight) reason_line="*Reason:* fleet-wide preflight refused" ;;
per-service) reason_line="*Reason:* all services individually refused" ;;
*) reason_line="*Reason:* aborted" ;;
esac
if [ "$failed_real_count" -eq 0 ]; then
# Fleet-preflight abort with zero services touched: no
# bullets to render, so omit the *Failed:* heading entirely.
thread_text="❌ *Aborted${elapsed_phrase}* — 0 ✓ · 0 ✗
${pre_staging_line}
${reason_line}"
else
thread_text="❌ *Aborted${elapsed_phrase}* — 0 ✓ · ${failed_real_count} ✗
${pre_staging_line}
${reason_line}
*Failed:*
${fail_bullets}"
fi
elif [ "$failed_real_count" -eq 0 ]; then
outcome="success"
thread_text="✅ *Showcase Promoted to Prod* — ${succeeded_count} ✓ · <${gha_url}|View run>
Services: ${succeeded_csv}"
elif [ "$succeeded_count" -gt 0 ] && [ "$failed_real_count" -gt 0 ]; then
outcome="partial"
thread_text="⚠️ *Done${elapsed_phrase}* — ${succeeded_count} ✓ · ${failed_real_count} ✗
*Promoted:* ${succeeded_csv}
*Failed:*
${fail_bullets}"
else
# succeeded_count == 0 && failed_real_count > 0 — per-service
# refusals without an abort_reason set (defensive fallback).
outcome="total"
case "$abort_reason" in
fleet-preflight) reason_line="*Reason:* fleet-wide preflight refused" ;;
per-service) reason_line="*Reason:* all services individually refused" ;;
*) reason_line="*Reason:* aborted" ;;
esac
thread_text="❌ *Aborted${elapsed_phrase}* — 0 ✓ · ${failed_real_count} ✗
${pre_staging_line}
${reason_line}
*Failed:*
${fail_bullets}"
fi
# Strip the 10-space indent the heredoc-style strings carry.
thread_text=$(printf '%s\n' "$thread_text" | sed 's/^[[:space:]]\{10\}//')
# ---------- post thread reply ----------
if [ -n "$init_ts" ]; then
thread_body=$(jq -nc \
--arg channel "$TEAM_SHOWCASE_CHANNEL" \
--arg text "$thread_text" \
--arg ts "$init_ts" \
'{channel:$channel, text:$text, thread_ts:$ts}')
thread_resp=$(slack_api chat.postMessage "$thread_body")
else
# Initiation failed; post the thread text as a top-level
# message so the operator still gets the summary.
fallback_body=$(jq -nc \
--arg channel "$TEAM_SHOWCASE_CHANNEL" \
--arg text "$thread_text" \
'{channel:$channel, text:$text}')
thread_resp=$(slack_api chat.postMessage "$fallback_body")
fi
# Surface a dropped summary post (200/ok:false). `|| true` keeps the
# ::warning:: visible without aborting the cross-post below.
slack_alert_posted_ok "thread reply" "$thread_resp" || true
# ---------- cross-post to #oss-alerts on partial/total failure ----------
if [ "$outcome" != "success" ]; then
# Build the trailing link suffix once so both branches share it
# without relying on a trailing-space suffix-strip.
if [ -n "$permalink" ]; then
link_suffix="thread: ${permalink}"
else
# No Slack permalink available; link to the GitHub Actions run
# instead. gha_url is defined earlier in this render step (where
# the payload is read) so the success message and this fallback
# share the same URL.
link_suffix="thread permalink unavailable; see ${gha_url}"
fi
case "$outcome" in
partial) oss_text="⚠️ showcase promote: ${succeeded_count} ✓ · ${failed_real_count} ✗ — ${link_suffix}" ;;
total) oss_text="❌ showcase promote aborted: 0 ✓ · ${failed_real_count} ✗ — ${link_suffix}" ;;
esac
oss_body=$(jq -nc \
--arg channel "$OSS_ALERTS_CHANNEL" \
--arg text "$oss_text" \
'{channel:$channel, text:$text}')
oss_resp=$(slack_api chat.postMessage "$oss_body")
# This is the page-the-humans alert. A 200/ok:false drop here means
# nobody is told the promote failed, so a silently-green renderer job
# would hide the dropped page. FAIL LOUD: the ::warning:: alone is
# easy to miss, so let the predicate's non-zero return abort this
# step (set -e) → the job goes red and the drop is visible. Unlike
# the thread reply (informational, in the promote channel), this
# alert MUST not be swallowed — so there is no `|| true` here.
slack_alert_posted_ok "#oss-alerts cross-post" "$oss_resp"
fi
echo "notify completed: outcome=${outcome} run_id=${run_id}"
+90
View File
@@ -0,0 +1,90 @@
name: "Showcase: Reconcile prod vs staging (on-demand drift check)"
# On-demand prod-vs-staging reconciliation. The showcase deploy model: staging
# tracks a mutable `:latest` tag and is continuously rebuilt; prod is pinned to
# an immutable `@sha256:` digest and advances only on an explicit promote. So
# prod can sit BEHIND a green staging — which is OFTEN INTENTIONAL (changes are
# batched and promoted deliberately), so a recurring drift alert would be noise.
#
# This workflow is therefore MANUAL ONLY (workflow_dispatch). There is no
# schedule and no unsolicited Slack alert. A maintainer runs it from the Actions
# tab to answer "is prod caught up with staging right now?"; the per-service
# reconcile table is rendered into the GH step summary, and the run exits
# nonzero when a column is stale so the drift is visibly flagged on the run.
#
# Read-only: runs `bin/railway reconcile-prod`, which performs NO promotes /
# mutations.
on:
workflow_dispatch:
concurrency:
group: showcase-reconcile-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
reconcile:
name: Reconcile prod vs staging
runs-on: ubuntu-latest
timeout-minutes: 10
environment: railway
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Set up Ruby
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
with:
ruby-version: "3.2"
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
- name: Regenerate SSOT JSON
# bin/railway reads showcase/scripts/railway-envs.generated.json to
# derive the prod-eligible (probe.prod) set; it is never committed, so
# regenerate it here. Skip the oxfmt-canonical pass (repo-root oxfmt is
# not installed by this scripts-scoped `npm ci`), mirroring the promote
# workflow's resolve/promote jobs.
working-directory: showcase/scripts
env:
EMIT_SKIP_OXFMT: "1"
run: |
npm ci
npx tsx emit-railway-envs-json.ts
- name: Reconcile gate
id: gate
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Path the gate writes machine-readable JSON to (uploaded as an
# artifact below). Cheap to keep — handy for a maintainer inspecting a
# manual run, but no Slack/alert consumer.
RECONCILE_JSON: ${{ github.workspace }}/reconcile.json
run: |
set -uo pipefail
if [ -z "${RAILWAY_TOKEN:-}" ]; then
echo "::error::RAILWAY_TOKEN secret not configured; cannot run the reconcile gate."
exit 1
fi
# Run the gate. reconcile-prod-gate.sh surfaces the per-service table
# into the GH step summary and exits 1 on a stale service / 2 on a hard
# error — either reds the run so a manual run visibly flags drift.
RAILWAY_BIN="showcase/bin/railway" \
showcase/scripts/reconcile-prod-gate.sh
- name: Upload reconcile JSON
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: reconcile-json
path: reconcile.json
if-no-files-found: ignore
retention-days: 7
@@ -0,0 +1,57 @@
name: "Showcase: Runtime-Route Wiring (PR)"
# Pre-merge guard for the OSS-451 failure class: a demo page whose CopilotKit
# `runtimeUrl` points at an `/api/copilotkit-<demo>` route that does not exist,
# so the page 404s on load (runtime_info_fetch_failed) and never mounts.
#
# The existing Showcase Build Check (showcase_build_check.yml) is a Docker
# *build* — it compiles a page that references a non-existent route just fine,
# because `runtimeUrl` is an opaque string with no build-time link to the
# route's existence. That blind spot is exactly how OSS-451 shipped. This
# static check runs alongside the build check and asserts that every SHIPPED
# demo (a demo listed in its integration's manifest `features`) wires its
# `runtimeUrl` to a route that actually exists.
#
# Fast, no Docker, no secrets. Should be added to the branch-protection
# required checks so it blocks merge like the build check does.
on:
pull_request:
paths:
- "showcase/integrations/**"
- "showcase/scripts/validate-runtime-routes.ts"
- "showcase/scripts/validate-runtime-routes.baseline.json"
- "showcase/scripts/package.json"
- ".github/workflows/showcase_validate-wiring.yml"
concurrency:
group: showcase-validate-wiring-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
jobs:
validate-runtime-routes:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
- name: Install validator deps
working-directory: showcase/scripts
run: npm ci --no-audit --no-fund --ignore-scripts
- name: Validate runtime-route wiring (OSS-451 guard)
working-directory: showcase/scripts
run: npm run validate-routes
+770
View File
@@ -0,0 +1,770 @@
name: "Showcase: Validate"
on:
pull_request:
paths:
- "showcase/**"
- "examples/integrations/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- ".github/workflows/showcase_validate.yml"
- ".github/workflows/showcase_deploy.yml"
push:
branches: [main]
paths:
- "showcase/**"
- "examples/integrations/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- ".github/workflows/showcase_validate.yml"
- ".github/workflows/showcase_deploy.yml"
# Least-privilege by default. Individual jobs/steps can widen when needed.
permissions:
contents: read
# Split concurrency per event so main-branch push runs are never canceled
# mid-execution (we need Slack failure alerts to fire reliably). PR runs
# still cancel in progress to keep PR CI responsive.
concurrency:
group: showcase-validate-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
validate:
name: Validate Showcase
# Hoist the Slack webhook into an env var so step-level `if:`
# expressions can reference it — `secrets.*` is not a valid
# named-value inside `if:` and causes a workflow startup failure
# on push events.
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
# Depot (Startup plan, unlimited minutes) for persistent pnpm/npm
# cache across runs — cold ubuntu-latest runs were ~18-20m; Depot
# typically reduces to ~5-8m. 25m timeout retained as headroom.
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 25
outputs:
inline_slack_notifier_reached: ${{ steps.inline_slack_marker.outputs.reached }}
permissions:
actions: read
contents: read
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
id-token: write
defaults:
run:
# Pin shell so `set -euo pipefail` + `mapfile` behave the same
# across any future runner image changes (default on ubuntu is
# already bash, but we lock it explicitly).
shell: bash
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
# Cache npm for the showcase/shell `npm ci` step below (shell is
# NOT a pnpm workspace member; it ships its own package-lock.json).
cache: "npm"
cache-dependency-path: showcase/shell/package-lock.json
- name: Setup pnpm
# Pinned to a specific minor rather than floating @v4 so that a
# silent upstream major/minor change can't alter install semantics
# on a random CI run. Bump deliberately when refreshing the toolchain.
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Verify lockfile is up to date
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Enforce e2e spec count (baseline per package)
run: |
set -euo pipefail
shopt -s nullglob
# Single source of truth: showcase/scripts/fail-baseline.json
# `baselineDemoCount` is read here AND by validate-parity.ts so the
# per-package e2e-spec-count floor cannot drift between CI and the
# validator. If parsing fails we distinguish JSON syntax errors
# from schema failures (missing/non-integer/negative field).
set +e
MIN=$(node -e "
let v;
try {
v = require('./showcase/scripts/fail-baseline.json');
} catch (e) {
console.error('fail-baseline.json: JSON syntax error: ' + e.message);
process.exit(2);
}
const n = v.baselineDemoCount;
if (typeof n !== 'number' || !Number.isInteger(n) || n < 0) {
console.error('fail-baseline.json: schema failure: baselineDemoCount must be a non-negative integer');
process.exit(3);
}
console.log(n);
")
rc=$?
set -e
if [ "$rc" -ne 0 ]; then
# Preserve node's distinct rc (2=JSON syntax, 3=schema) in the
# annotation so the CI log pinpoints the cause without re-running.
echo "::error::Failed to read baselineDemoCount from showcase/scripts/fail-baseline.json (node exit=$rc; 2=JSON syntax, 3=schema)"
exit "$rc"
fi
failed=0
found=0
for pkg_dir in showcase/integrations/*/; do
[ -d "$pkg_dir" ] || continue
pkg=$(basename "$pkg_dir")
# Skip manifest-only packages (no src/ directory) — these are
# virtual/meta integrations (e.g. built-in-agent) that carry no
# source code or demos and therefore have no e2e specs to enforce.
if [ ! -d "${pkg_dir}src" ]; then
echo "skip: $pkg (manifest-only, no src/)"
continue
fi
found=$((found + 1))
e2e_dir="${pkg_dir}tests/e2e/"
if [ ! -d "$e2e_dir" ]; then
echo "::error file=$pkg_dir::Package '$pkg' is missing tests/e2e/ directory (required for baseline e2e coverage)"
failed=1
continue
fi
# Capture `find` output into a variable first so we can check
# its exit status directly. Bash process substitution (used with
# `mapfile < <(cmd)`) does NOT propagate the producer's exit
# status to the parent shell — `mapfile` only reports its own
# usage errors — so a failing `find` (EACCES on a subdir, ELOOP,
# transient I/O) would have been silently treated as "zero
# specs" and surfaced as the misleading "minimum required"
# error instead of the real root cause. Command substitution
# propagates `find`'s status via `$?` on the assignment, which
# we check immediately. A zero-spec result is a legitimate
# success from `find` and is handled by the `$count -lt $MIN`
# check below, not treated as a find failure.
# Aggregate find failures with the rest of the per-package
# failure modes (missing tests/e2e/, below-MIN count) so one bad
# package doesn't short-circuit reporting for the others. A
# single CI run should surface every problematic package at
# once; `exit "$failed"` at the end of the loop reports the
# aggregate.
if ! find_out=$(find "$e2e_dir" -maxdepth 1 -type f -name '*.spec.ts'); then
echo "::error file=$e2e_dir::find failed while enumerating specs for '$pkg'"
failed=1
continue
fi
specs=()
# Only populate the array if `find` produced output; `mapfile
# <<< ""` would otherwise create a single empty element and
# inflate the count by one.
if [ -n "$find_out" ]; then
mapfile -t specs <<< "$find_out"
fi
count=${#specs[@]}
if [ "$count" -lt "$MIN" ]; then
echo "::error file=$e2e_dir::Package '$pkg' has $count e2e spec(s); minimum required is $MIN"
failed=1
else
echo "ok: $pkg has $count spec(s)"
fi
done
if [ "$found" -eq 0 ]; then
echo "::error::No showcase/integrations/*/ directories found — baseline check cannot run"
exit 1
fi
exit "$failed"
- name: Run validate-parity (MUST checks gating)
working-directory: showcase/scripts
# MUST failures (missing manifest, missing src/app/demos dir) exit 1 and
# fail the PR. SHOULD deviations print warnings and exit 0. See
# showcase/scripts/validate-parity.ts for the full policy.
#
# `pnpm exec` resolves tsx from the pnpm-lock.yaml-pinned workspace
# install; `npx tsx` could fetch a drifting version on a registry
# cache miss.
run: pnpm exec tsx validate-parity.ts
- name: Run validate-fixture-tool-surface (aimock drift)
working-directory: showcase/scripts
# Cross-references every aimock fixture's returned tool-call names
# against the tool surface of each demo whose suggestion prompt
# contains the fixture's match substring. Catches the class of
# drift that caused the 2026-04-22 regression where generic
# substring matches (e.g. "pie chart") cross-fired across demos
# with different tool surfaces, leaving the UI blank in prod.
# See showcase/scripts/validate-fixture-tool-surface.ts and the
# postmortem linked from there.
run: pnpm exec tsx validate-fixture-tool-surface.ts
- name: Run validate-pins (ratchet)
working-directory: showcase/scripts
# Ratchet gate on pin drift. Baseline (count + SHA-256 hash of sorted
# unique FAIL lines) lives in `showcase/scripts/fail-baseline.json`;
# see that file for the full ratchet semantics and adjustment
# procedure. Weekly backlog visibility is provided by
# `.github/workflows/showcase_drift-report.yml`. Driving the drift to
# zero (and flipping this advisory ratchet to fully enforcing) is
# future work.
run: |
set -euo pipefail
# --- Load + validate baseline -----------------------------------
# `node -e` prints either a validated value or an error marker
# we match below. We deliberately do NOT let require() throw
# out of the subshell; we format a clean CI error instead.
#
# We distinguish three failure modes with distinct exit codes so
# the CI log pinpoints the cause without requiring a re-run:
# exit 2 => JSON syntax error (require() threw)
# exit 3 => schema failure (missing/wrong-typed required field)
# exit 4 => unexpected/unknown top-level field (typo guard)
#
# The unexpected-field check rejects silent typos like
# `validatepinsfailcount` or an accidentally-added `comment`
# field (distinct from the allowed leading underscore
# `_comment`) that would otherwise leave required fields
# undefined and be caught only via the schema branch with a
# more confusing message.
set +e
baseline_json=$(node -e "
const ALLOWED = ['_comment', 'validatePinsFailCount', 'validatePinsFailHash', 'baselineDemoCount'];
let v;
try {
v = require('./fail-baseline.json');
} catch (e) {
console.error('fail-baseline.json: JSON syntax error: ' + e.message);
process.exit(2);
}
const unexpected = Object.keys(v).filter(k => !ALLOWED.includes(k));
if (unexpected.length > 0) {
console.error('fail-baseline.json: unexpected field(s): ' + unexpected.join(', ') + '. Allowed fields: ' + ALLOWED.join(', '));
process.exit(4);
}
const c = v.validatePinsFailCount;
const h = v.validatePinsFailHash;
if (typeof c !== 'number' || !Number.isInteger(c) || c < 0) {
console.error('fail-baseline.json: schema failure: validatePinsFailCount must be a non-negative integer');
process.exit(3);
}
if (typeof h !== 'string' || !/^[0-9a-f]{64}$/.test(h)) {
console.error('fail-baseline.json: schema failure: validatePinsFailHash must be a 64-char lowercase hex SHA-256');
process.exit(3);
}
console.log(JSON.stringify({ count: c, hash: h }));
")
rc=$?
set -e
if [ "$rc" -ne 0 ]; then
# Preserve node's distinct rc (2=JSON syntax, 3=schema, 4=unexpected field)
# in the annotation so the CI log pinpoints the cause.
echo "::error::fail-baseline.json failed validation (node exit=$rc; 2=JSON syntax, 3=schema, 4=unexpected field)"
exit "$rc"
fi
baseline=$(node -e "console.log(JSON.parse(process.argv[1]).count)" "$baseline_json")
baseline_hash=$(node -e "console.log(JSON.parse(process.argv[1]).hash)" "$baseline_json")
# --- Run validator; separate internal crash from pin-drift exit -
# validate-pins exits 0 when FAIL=0, 1 when FAIL>0. Anything else
# (2+, uncaught throw, node crash, SIGSEGV) is an internal failure
# we must surface distinctly from a legitimate drift report.
#
# We deliberately keep stdout and stderr in separate variables.
# validate-pins.ts emits progress/summary on stdout and `[FAIL]`
# lines on stderr; mingling them with `2>&1` allowed progress
# chatter (or future stdout additions) to corrupt the hash input.
# The hash is computed strictly from stderr.
set +e
stderr_file=$(mktemp)
stdout=$(pnpm exec tsx validate-pins.ts 2>"$stderr_file")
rc=$?
stderr=$(cat "$stderr_file")
rm -f "$stderr_file"
set -e
# Replay both streams to the job log so humans can debug.
printf '%s\n' "$stdout"
printf '%s\n' "$stderr" >&2
if [ "$rc" -ne 0 ] && [ "$rc" -ne 1 ]; then
# Preserve validate-pins.ts's distinct exit code (2=EXIT_INTERNAL,
# 3=EXIT_UNREADABLE, 4+=future) so downstream consumers can
# distinguish "validator crashed" from "pin drift found" (which
# would be rc=1). Collapsing to `exit 1` would make an internal
# crash indistinguishable from legitimate drift in the PR check
# signal.
echo "::error::validate-pins.ts exited with unexpected code $rc (expected 0 or 1). This indicates an internal failure, not pin drift."
exit "$rc"
fi
# --- Parse Summary line (actual FAIL count) ---------------------
# Summary line is on stdout. If the validator output format
# changed (missing Summary, non-numeric FAIL), fail loudly
# instead of silently treating it as zero.
#
# Scope grep's no-match tolerance to grep alone by wrapping just
# the grep stage in a `{ ... || true; }` group. A trailing
# `|| true` on the whole pipeline would defeat `pipefail` and
# swallow producer/head failures too; we only want to tolerate
# grep finding no match (which `[ -z "$summary_line" ]` below
# already reports with a precise error).
summary_line=$(printf '%s\n' "$stdout" | { grep -E '^[[:space:]]*Summary:' || true; } | head -n 1)
if [ -z "$summary_line" ]; then
echo "::error::Could not find validate-pins 'Summary:' line in output"
exit 1
fi
# Word-boundary anchored to avoid matching e.g. `NEWFAIL=` or
# `TOTALFAIL=` if such tokens are ever added to the Summary line.
actual=$(printf '%s\n' "$summary_line" | grep -oE '\bFAIL=[0-9]+\b' | head -n 1 | cut -d= -f2)
if [ -z "${actual:-}" ] || ! [[ "$actual" =~ ^[0-9]+$ ]]; then
echo "::error::Could not parse FAIL=<int> from Summary line: $summary_line"
exit 1
fi
# --- Compute tuple hash of current FAIL set ---------------------
# Hash the sorted, deduplicated `[FAIL] ...` lines (stderr only).
# This catches the "count equal but set drifted" case: one FAIL
# healed while another regressed.
#
# Scope grep's no-match tolerance to grep alone by wrapping just
# the grep stage in a `{ ... || true; }` group. A trailing
# `|| true` on the whole pipeline would defeat `pipefail` and
# swallow sort/shasum/cut failures too; clean runs with zero
# `[FAIL]` lines must not be an error, so we tolerate grep's
# no-match here and only here.
actual_hash=$(printf '%s\n' "$stderr" | { grep -E '^\[FAIL\]' || true; } | LC_ALL=C sort -u | shasum -a 256 | cut -d' ' -f1)
echo "validate-pins FAIL: actual=$actual baseline=$baseline"
echo "validate-pins HASH: actual=$actual_hash baseline=$baseline_hash"
if [ "$actual" -gt "$baseline" ]; then
echo "::error::Pin drift increased: $actual FAIL(s) vs baseline $baseline. Fix the new drift or, with explicit sign-off, update showcase/scripts/fail-baseline.json (bump validatePinsFailCount to $actual and validatePinsFailHash to $actual_hash)."
exit 1
fi
if [ "$actual" -lt "$baseline" ]; then
echo "::error::Pin drift decreased: $actual FAIL(s) vs baseline $baseline. Ratchet down the baseline in showcase/scripts/fail-baseline.json (set validatePinsFailCount=$actual, validatePinsFailHash=$actual_hash)."
exit 1
fi
if [ "$actual_hash" != "$baseline_hash" ]; then
echo "::error::Pin drift SET changed (count equal at $actual, hash differs). One FAIL healed while another regressed — net zero on the counter but the failing tuples are not the same set. Update showcase/scripts/fail-baseline.json (validatePinsFailHash=$actual_hash) if this is intentional, or fix the new drift."
echo "--- FAIL lines (current) ---"
printf '%s\n' "$stderr" | grep -E '^\[FAIL\]' | LC_ALL=C sort -u
exit 1
fi
echo "Pin drift unchanged at baseline ($baseline, hash $baseline_hash)."
- name: Run build pipeline tests
working-directory: showcase/scripts
# Use pnpm to resolve the workspace-installed vitest (pinned via
# pnpm-lock.yaml) rather than `npx`, which could fetch a different
# version on a registry cache miss.
run: pnpm exec vitest run
- name: CVDIAG emit perf-regression gate
working-directory: showcase/harness
# Perf gate for the CVDIAG `CvdiagEmitter` hot path (plan unit L2-D).
# Pure instrumentation must stay cheap on the boundary it observes:
# spec §7 sets a 500µs/event prod budget; this gate holds emit at 50%
# of that for headroom — per-event median ≤100µs, p99 ≤250µs — and the
# bench's teardown throws (failing this step, and the job) on a >20%
# regression past either threshold. vitest `bench` is experimental but
# stable for this single-task run; the throw-on-breach is what gates,
# not the (advisory) hz/p99 table. Resolve vitest via pnpm so the
# pnpm-lock.yaml-pinned version is used (no registry-cache-miss drift).
run: pnpm exec vitest bench src/cvdiag/emit-perf.bench.ts --run
- name: Validate manifests & generate registry
working-directory: showcase/scripts
run: pnpm exec tsx generate-registry.ts
# ADVISORY ONLY — never fail the build. The promote dropdown is
# self-healed by the lefthook pre-commit hook; this step only warns if a
# commit somehow lands with a drifted showcase_promote.yml `service`
# dropdown (e.g. hook skipped). The trailing `|| true` keeps a non-zero
# `--check` exit from reddening validate.
- name: Advisory — promote dropdown drift check
working-directory: showcase/scripts
run: |
# ADVISORY ONLY: capture the exit code without letting a non-zero
# `--check` redden the build. `|| true` alone would discard rc and
# collapse every failure mode into the misleading "stale, re-run"
# warning. sync-promote-service-options.ts exits:
# 1 => drift (dropdown out of date; re-running the generator fixes it)
# 2 => read error
# 3 => missing/duplicate/malformed marker block (corruption)
# Only rc=1 is actually self-heals-by-rerun; rc>=2 needs a human, and
# the suggested re-run would itself fail — so report it distinctly.
set +e
pnpm exec tsx sync-promote-service-options.ts --check
rc=$?
set -e
if [ "$rc" -eq 1 ]; then
echo "::warning::showcase_promote.yml service dropdown is stale. Run: npx tsx showcase/scripts/sync-promote-service-options.ts and commit the result."
elif [ "$rc" -ge 2 ]; then
echo "::warning::sync-promote-service-options.ts failed (exit $rc) — marker block missing/duplicated or read error; investigate before trusting the dropdown."
fi
# Always succeed: this step must never fail the build (the lefthook
# pre-commit hook self-heals; CI only warns).
exit 0
- name: Bundle demo content
working-directory: showcase/scripts
run: pnpm exec tsx bundle-demo-content.ts
- name: Install showcase shell dependencies
working-directory: showcase/shell
# `showcase/shell` is NOT a pnpm workspace member (see pnpm-workspace.yaml)
# and ships its own `package-lock.json`. Use `npm ci` to get a
# reproducible install; `npm install` would re-resolve ranges.
# npm cache is configured at the setup-node step above via
# `cache-dependency-path: showcase/shell/package-lock.json`.
run: npm ci --ignore-scripts
- name: Build showcase shell
working-directory: showcase/shell
run: npm run build
# NOTE: Slack failure alert only fires on `push` (i.e. main-branch
# merges) by design. PR failures already surface in the PR checks UI
# and the PR author's inbox, and we don't want PR-author noise
# pinging the OSS alerts channel. Tradeoff: a broken PR that sneaks
# past review won't alert Slack until after merge.
#
# Extract the failed step name and first meaningful error line so the
# Slack payload is actionable at a glance rather than forcing a
# click-through to the workflow run. Bare "X failed" alerts bury the
# signal; red alerts must carry triage-ready detail per the oss-alerts
# policy. Writes `failed_step` and `error_excerpt` to $GITHUB_ENV for
# consumption by the notify step below.
#
# This step must NEVER fail the job (it runs on failure() already; a
# crash here would compound the original failure with extraction
# noise and could block the notify step). All extraction uses `|| true`
# fallbacks so a malformed jobs response or truncated log still yields
# sane defaults ("unknown" / "see workflow run for details").
- name: Extract failure details for Slack
id: extract
if: failure() && github.event_name == 'push' && env.SLACK_WEBHOOK != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
run: |
set +e # best-effort: never block the notify step below
# --- Find the currently-running job and its first failed step ---
# The jobs API returns every job in the run. We identify *this*
# job by name (matches `jobs.validate.name`) rather than
# job.status=='in_progress', because at this point the step we're
# running hasn't flipped the job state yet in the API. Fall back
# to the first job with a failed step if the name match misses
# (e.g. future rename drift).
jobs_json=$(gh api "/repos/${GH_REPO}/actions/runs/${RUN_ID}/jobs" --paginate 2>/dev/null)
job_id=$(printf '%s' "$jobs_json" | jq -r '
.jobs // []
| map(select(.name == "Validate Showcase"))
| (.[0].id // empty)
' 2>/dev/null)
if [ -z "$job_id" ]; then
job_id=$(printf '%s' "$jobs_json" | jq -r '
.jobs // []
| map(select(.steps // [] | map(.conclusion) | index("failure")))
| (.[0].id // empty)
' 2>/dev/null)
fi
failed_step=$(printf '%s' "$jobs_json" | jq -r --arg id "$job_id" '
.jobs // []
| map(select((.id|tostring) == $id))
| (.[0].steps // [])
| map(select(.conclusion == "failure"))
| (.[0].name // "unknown step")
' 2>/dev/null)
[ -z "$failed_step" ] && failed_step="unknown step"
# --- Pull log and extract first meaningful error line ------------
# `gh run view --log-failed` output is TSV: job\tstep\ttimestamp + content.
# Strip the three leading columns to get the raw step output, strip
# ANSI escape codes, strip any stray BOM, skip runner/group/env
# header noise, then grab the first line matching a recognised
# error marker. Truncate to ~300 chars so the Slack payload stays
# well under the 800-char budget even with escaping overhead.
error_excerpt="see workflow run for details"
if [ -n "$job_id" ]; then
log_excerpt=$(gh run view "$RUN_ID" --repo "$GH_REPO" --log-failed --job="$job_id" 2>/dev/null \
| awk -F'\t' 'NF>=3 { sub(/^[\xEF\xBB\xBF]?[0-9T:.\-Z ]+/, "", $3); print $3 }' \
| sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' \
| grep -vE '^(##\[|shell: |env: |Run |[[:space:]]*$)' \
| grep -m1 -E '^\[(FAIL|ERROR)\]|^Error:|^error:|^::error' \
| head -c 300)
if [ -n "$log_excerpt" ]; then
error_excerpt="$log_excerpt"
fi
fi
# --- Emit to $GITHUB_ENV using heredoc delimiter -----------------
# Heredoc delimiter protects against values that contain `=` or
# newlines breaking the KEY=VALUE format. The delimiter is a
# long random-ish string unlikely to appear in any log line.
{
echo "failed_step<<EOF_FAILED_STEP_b3f2"
printf '%s\n' "$failed_step"
echo "EOF_FAILED_STEP_b3f2"
echo "error_excerpt<<EOF_ERROR_EXCERPT_b3f2"
printf '%s\n' "$error_excerpt"
echo "EOF_ERROR_EXCERPT_b3f2"
} >> "$GITHUB_ENV"
exit 0 # belt-and-suspenders: never propagate a failure
- name: Mark inline Slack notifier reached
id: inline_slack_marker
if: failure() && github.event_name == 'push' && env.SLACK_WEBHOOK != ''
run: echo "reached=true" >> "$GITHUB_OUTPUT"
- name: Notify Slack (failure)
if: failure() && github.event_name == 'push' && env.SLACK_WEBHOOK != ''
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
webhook-type: incoming-webhook
# Defensive: wrap dynamic values via toJSON(format(...)) so that
# if github.repository or the extracted failed_step / error_excerpt
# contain characters that would break the JSON payload (quotes,
# backslashes, newlines), the value is safely JSON-encoded instead
# of injected as raw text. Matches the pattern used in
# showcase_drift-report.yml. github.run_id is numeric so safe on
# its own, but we wrap it for consistency and defense-in-depth.
# env.failed_step and env.error_excerpt are populated by the
# preceding "Extract failure details" step (with safe fallbacks if
# extraction fails).
payload: |
{ "text": ${{ toJSON(format(':x: *Showcase validate*: failed — {0}: {1} | <https://github.com/{2}/actions/runs/{3}|View run>', env.failed_step, env.error_excerpt, github.repository, github.run_id)) }} }
- name: Log (no Slack — webhook unset)
if: failure() && github.event_name == 'push' && env.SLACK_WEBHOOK == ''
run: |
echo "::warning::showcase_validate failed on push but SLACK_WEBHOOK_OSS_ALERTS is not set; no Slack notification sent."
shell-script-tests:
name: Shell script tests (bats + shellcheck)
# Separate job (mirrors python-unit-tests) so the showcase shell-script
# regression suite runs independently of the JS/TS validate job. Runs on
# ubuntu-latest where shellcheck is preinstalled; bats is apt-installed.
# These tests gate the promote-fleet.sh best-effort loop + succeeded_csv
# export that the promote → verify-prod handoff depends on.
runs-on: ubuntu-latest
# The bats suite runs ~4.5min and keeps growing; at the old 5min job cap it
# raced the deadline and intermittently got cancelled mid-suite (all steps
# passing) rather than reported. Give headroom so a green suite reports green.
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Install bats
run: |
# GitHub's ubuntu-latest runner image preconfigures third-party apt
# repos (Microsoft / azure-cli) for preinstalled tooling this job does
# not use. When one of those repos serves invalid release metadata,
# `apt-get update` exits non-zero and `bash -e` aborts the step —
# even though bats comes from Ubuntu's own `universe` repo, which is
# unaffected. This job only needs Ubuntu packages, so drop those unused
# third-party repos before updating.
sudo rm -f /etc/apt/sources.list.d/*microsoft* /etc/apt/sources.list.d/*azure-cli*
sudo apt-get update
sudo apt-get install -y bats
- name: Shellcheck promote workflow scripts
# shellcheck is preinstalled on ubuntu-latest.
run: shellcheck showcase/scripts/promote-fleet.sh showcase/scripts/verify-prod-display.sh showcase/scripts/reconcile-prod-gate.sh
- name: Run bats suite
run: bats showcase/scripts/__tests__/
python-unit-tests:
name: Python unit tests (${{ matrix.python-version }})
# Separate job so pre-existing `validate-parity` failures don't mask new
# Python unit-test regressions. pytest runs independently of JS/TS checks.
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
strategy:
# Fail-fast disabled so a 3.10-only regression (e.g. typing_extensions
# fallback path breaking) doesn't cancel the 3.12 run and leave us
# guessing which version is the actual problem.
fail-fast: false
matrix:
# 3.10 covers the typing_extensions `NotRequired` fallback path used
# by aimock_toggle.py (stdlib `NotRequired` only landed in 3.11).
# 3.12 is the production/runner default. Pinning both guarantees we
# catch a regression in either branch the first time it lands.
python-version: ["3.10", "3.12"]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
cache-dependency-path: |
showcase/integrations/*/requirements.txt
- name: Install minimal test deps
# Always need pytest + typing_extensions. pytest-asyncio is required
# by langroid's test_agui_adapter.py (16 tests use
# `@pytest.mark.asyncio`); without it, pytest reports
# "async def functions are not natively supported" and skips them.
# pytest-mock is installed pre-emptively as it's commonly used by
# showcase package tests and is cheap to install.
# Per-package `requirements.txt` is installed inside the run loop
# below so tests that import runtime deps (openai, google.genai,
# httpx, opentelemetry, etc.) don't fail at collection time with
# ModuleNotFoundError. Conftest-based stub finders can't help
# because test_*.py imports the target deps BEFORE conftest runs.
run: python -m pip install --quiet pytest pytest-asyncio pytest-mock typing_extensions
- name: Run showcase package Python unit tests
# Keep scope narrow: only showcase/integrations/*/tests/python/ directories
# (not e2e, not langgraph which has its own runtime). Each package has
# its own conftest.py that wires up import paths; we cd into the pkg
# dir so those apply.
#
# Before running pytest in a package we install that package's own
# `requirements.txt` (if present) so runtime-dep imports in test modules
# resolve. Keeps CI parity with real runtime and avoids the fragile
# stub-finder dance conftest.py would need to do otherwise.
run: |
set -euo pipefail
failed=0
found=0
# Current interpreter major.minor (e.g. "3.10", "3.12"). Used
# below to skip packages whose runtime deps are incompatible
# with the matrix Python on this job.
py_mm=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
for pkg_dir in showcase/integrations/*/; do
tests_dir="${pkg_dir}tests/python"
[ -d "$tests_dir" ] || continue
found=$((found + 1))
pkg=$(basename "$pkg_dir")
# --- Per-package Python-version gates -------------------------
# Skip packages whose `requirements.txt` pins a dep whose
# `requires-python` excludes this interpreter. Surgical skip
# (not matrix exclusion) so the rest of the packages continue
# to exercise the 3.10 typing_extensions fallback path.
#
# claude-sdk-python: ag-ui-claude-sdk declares `requires-python >=3.11`;
# the package Dockerfile and production runner use Python 3.12.
# strands: ag_ui_strands==0.1.0 declares `requires-python >=3.12,<3.14`,
# so `pip install` fails on 3.10 before pytest even runs.
# langroid: tests import `typing.Self` (3.11+); on 3.10 the import fails
# at collection time. typing_extensions.Self would fix it but the tests
# are tightly coupled to the modern typing module.
# Revisit when ag_ui_strands relaxes its floor or when 3.10 is dropped.
if [ "$py_mm" = "3.10" ] && { [ "$pkg" = "claude-sdk-python" ] || [ "$pkg" = "strands" ] || [ "$pkg" = "langroid" ]; }; then
echo "--- pytest: $pkg --- SKIPPED on Python $py_mm (requires >=3.11/3.12)"
continue
fi
echo "--- pytest: $pkg ---"
if [ -f "${pkg_dir}requirements.txt" ]; then
echo "Installing ${pkg_dir}requirements.txt"
python -m pip install --quiet -r "${pkg_dir}requirements.txt" || {
echo "::error::pip install failed for $pkg"
failed=1
continue
}
fi
# Export PYTHONPATH so `from tools import ...` in agent modules
# resolves via the `tools` symlink at the integration root.
# Also include src/ so `from agents.X import ...` works even if
# a conftest.py omits the sys.path setup. Mirrors the local dev
# convention (`PYTHONPATH=. python ...` in package.json scripts).
(cd "$pkg_dir" && PYTHONPATH=".:src:${PYTHONPATH:-}" python -m pytest tests/python/ -v) || failed=1
done
if [ "$found" -eq 0 ]; then
echo "::warning::No showcase/integrations/*/tests/python/ directories found"
fi
exit "$failed"
notify:
# Slack #oss-alerts on any red. Never #engr (engr is sacred — release alerts only).
# Mirrors the workflow-level notify pattern in showcase_promote.yml so
# red runs surface uniformly across the showcase pipeline. The validate
# job has its own inline (and richer) push-only notifier that extracts
# the failing step + error excerpt; this job is the workflow-level
# safety net that also covers the python-unit-tests matrix job and the
# shell-script-tests job — which otherwise had no Slack signal at all.
# Webhook empty-guard mirrors promote.yml so an unset
# SLACK_WEBHOOK_OSS_ALERTS secret does not break the shell or red the
# workflow on this step.
needs: [validate, python-unit-tests, shell-script-tests]
if: always() && github.event_name == 'push' && !(needs.validate.result == 'failure' && needs.validate.outputs.inline_slack_notifier_reached == 'true' && needs.python-unit-tests.result == 'success' && needs.shell-script-tests.result == 'success')
runs-on: ubuntu-latest
timeout-minutes: 3
permissions:
contents: read
actions: read
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
steps:
- name: Compute state
id: state
env:
VALIDATE: ${{ needs.validate.result }}
PYTEST: ${{ needs.python-unit-tests.result }}
SHELL: ${{ needs.shell-script-tests.result }}
run: |
set -euo pipefail
if [ "$VALIDATE" = "success" ] && [ "$PYTEST" = "success" ] && [ "$SHELL" = "success" ]; then
STATE="success"; ICON=":white_check_mark:"
else
STATE="failure"; ICON=":x:"
fi
{
echo "state=$STATE"
echo "icon=$ICON"
} >> "$GITHUB_OUTPUT"
- name: Post to #oss-alerts
if: steps.state.outputs.state == 'failure' && env.SLACK_WEBHOOK != ''
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
webhook-type: incoming-webhook
# Newlines are injected via fromJSON('"\n"') (a real LF char) as {8},
# NOT a literal '\n' in the template: GitHub Actions expression string
# literals do not interpret backslash escapes, so a literal '\n' would
# survive toJSON as the two chars \\n and Slack would render it
# verbatim as "\n" instead of a line break.
payload: |
{
"text": ${{ toJSON(format(
'{0} *showcase_validate failed on {1}*{8}validate={2} python-unit-tests={3} shell-script-tests={4}{8}<{5}/{6}/actions/runs/{7}|View run>',
steps.state.outputs.icon,
github.ref,
needs.validate.result,
needs.python-unit-tests.result,
needs.shell-script-tests.result,
github.server_url,
github.repository,
github.run_id,
fromJSON('"\n"')
)) }}
}
- name: Log (no Slack — webhook unset)
if: steps.state.outputs.state == 'failure' && env.SLACK_WEBHOOK == ''
env:
REF: ${{ github.ref }}
run: |
echo "::warning::showcase_validate failed on $REF but SLACK_WEBHOOK_OSS_ALERTS is not set; no Slack notification sent."
+403
View File
@@ -0,0 +1,403 @@
name: social / copy-generator
on:
pull_request:
types: [opened]
issue_comment:
types: [edited]
workflow_dispatch:
inputs:
pr_number:
description: "PR number to generate social copies for"
required: true
type: number
permissions:
contents: read
jobs:
post-comment:
if: >-
(github.event_name == 'workflow_dispatch') ||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
steps:
- name: Post initial comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.pull_request.number }}
with:
script: |
const prNumber = Number(process.env.PR_NUMBER);
// For workflow_dispatch, verify the PR isn't from a fork
if (context.eventName === 'workflow_dispatch') {
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.data.head.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
console.log('PR is from a fork, skipping');
return;
}
}
// Check if there's already a social-copy-generator comment on this PR
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
per_page: 100,
});
const existing = comments.data.find(c => c.body.includes('<!-- social-copy-generator -->'));
if (existing) {
console.log(`Comment already exists (id: ${existing.id}), skipping`);
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: [
'<!-- social-copy-generator -->',
'### 📣 Social Copy Generator',
'',
'Generate social media copies (Twitter/X, LinkedIn, Blog Post) for this PR using Claude.',
'',
'- [ ] **Generate social media copies**',
].join('\n'),
});
generate:
if: >-
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '<!-- social-copy-generator -->') &&
contains(github.event.comment.body, '- [x]')
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
concurrency:
group: social-copy-${{ github.event.issue.number }}
cancel-in-progress: true
steps:
- name: Verify checkbox transition and permissions
id: verify
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
// Check that the sender has write access (not an external collaborator or random user)
const sender = context.payload.sender.login;
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: sender,
});
const allowed = ['admin', 'write'];
if (!allowed.includes(permissionLevel.permission)) {
core.setOutput('should_run', 'false');
console.log(`User ${sender} has '${permissionLevel.permission}' permission, skipping`);
return;
}
} catch (e) {
core.setOutput('should_run', 'false');
console.log(`Could not verify permissions for ${sender}, skipping`);
return;
}
const oldBody = context.payload.changes?.body?.from || '';
const newBody = context.payload.comment.body;
const wasUnchecked = oldBody.includes('- [ ]');
const isNowChecked = newBody.includes('- [x]');
if (!wasUnchecked || !isNowChecked) {
core.setOutput('should_run', 'false');
console.log('Not a checkbox transition, skipping');
return;
}
core.setOutput('should_run', 'true');
core.setOutput('comment_id', context.payload.comment.id);
- name: Update comment to generating state
if: steps.verify.outputs.should_run == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
COMMENT_ID: ${{ steps.verify.outputs.comment_id }}
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: Number(process.env.COMMENT_ID),
body: [
'<!-- social-copy-generator -->',
'### 📣 Social Copy Generator',
'',
`⏳ **Analyzing PR changes with Claude...** This may take a minute. [View progress](${runUrl})`,
].join('\n'),
});
- name: Get PR details
if: steps.verify.outputs.should_run == 'true'
id: pr
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
core.setOutput('head_ref', pr.data.head.ref);
core.setOutput('head_sha', pr.data.head.sha);
core.setOutput('base_ref', pr.data.base.ref);
core.setOutput('title', pr.data.title);
core.setOutput('body', pr.data.body || '(no description)');
core.setOutput('number', String(context.issue.number));
- name: Checkout repo
if: steps.verify.outputs.should_run == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ steps.pr.outputs.head_sha }}
fetch-depth: 0
persist-credentials: false
- name: Remove repository Claude settings
if: steps.verify.outputs.should_run == 'true'
run: rm -f .claude/settings.json .claude/settings.local.json .mcp.json
- name: Get PR diff
if: steps.verify.outputs.should_run == 'true'
id: diff
env:
BASE_REF: ${{ steps.pr.outputs.base_ref }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
GH_TOKEN: ${{ github.token }}
run: |
# Write diff files inside the repo so Claude's sandbox can access them
mkdir -p .claude-tmp
# Try git merge-base first (works for open PRs and merged PRs whose base is fetchable)
MERGE_BASE=$(git merge-base "origin/$BASE_REF" HEAD 2>/dev/null) || true
if [ -n "$MERGE_BASE" ]; then
git diff --stat "$MERGE_BASE"..HEAD > .claude-tmp/diff-stat.txt
git diff "$MERGE_BASE"..HEAD > .claude-tmp/diff-full.txt
else
# Fallback: use GitHub API diff (works for merged PRs where branch was deleted)
echo "Using GitHub API for diff (merge-base not found)"
gh pr diff "$PR_NUMBER" --patch > .claude-tmp/diff-full.txt
gh pr diff "$PR_NUMBER" | head -200 > .claude-tmp/diff-stat.txt
fi
- name: Setup pnpm
if: steps.verify.outputs.should_run == 'true'
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node.js
if: steps.verify.outputs.should_run == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- name: Install Claude Code
if: steps.verify.outputs.should_run == 'true'
# @anthropic-ai/claude-code is pinned as a root devDependency and installed
# from the frozen lockfile — no ad-hoc `npm install -g`. `--ignore-scripts`
# keeps install-time scripts from running against the PR-head checkout; the
# `cli-wrapper.cjs` entrypoint (used below) resolves the native binary from
# the installed optionalDependency without needing the postinstall step.
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Write prompt file
if: steps.verify.outputs.should_run == 'true'
env:
PR_TITLE: ${{ steps.pr.outputs.title }}
PR_BRANCH: ${{ steps.pr.outputs.head_ref }}
PR_BODY: ${{ steps.pr.outputs.body }}
run: |
cat << 'PROMPT_EOF' > .claude-tmp/prompt.txt
You are a developer advocate and social media copywriter for CopilotKit — an open-source AI agent framework that lets developers add AI copilots to their products with React hooks, UI components, and agent infrastructure. CopilotKit connects frontend (React/Angular), runtime (Express/Hono), and AI agents (LangGraph, CrewAI, custom) via the AG-UI protocol.
A PR has been opened. Your job is to analyze the changes and generate social media copy that the team can use to announce this work.
## PR Information
PROMPT_EOF
echo "- **Title:** $PR_TITLE" >> .claude-tmp/prompt.txt
echo "- **Branch:** $PR_BRANCH" >> .claude-tmp/prompt.txt
echo "- **Description:**" >> .claude-tmp/prompt.txt
echo "$PR_BODY" >> .claude-tmp/prompt.txt
cat << 'PROMPT_EOF' >> .claude-tmp/prompt.txt
## Instructions
1. Read the diff stat file at `.claude-tmp/diff-stat.txt` to understand the scope of changes.
2. Read the full diff at `.claude-tmp/diff-full.txt` to understand the details.
3. If needed, read relevant source files to understand context.
4. Generate the following three pieces of content:
### Output Format
Produce your output in EXACTLY this format (including the markdown headers).
Do NOT include any preamble, analysis summary, or commentary before or after the three sections.
Start directly with the first header:
### 🐦 Twitter/X Post
(A concise, engaging tweet under 280 characters. No hashtags. Focus on the user benefit.)
### 💼 LinkedIn Post
(A professional post of 3-5 short paragraphs. Lead with the value proposition. Include a call to action. Add relevant hashtags. No length limit.)
### 📝 Blog Post Draft
(A blog-style announcement. Include a title, explain what changed, why it matters, and how to get started. No length limit — be as thorough as needed.)
## Guidelines
- Focus on user-facing impact, not implementation details
- Be enthusiastic but authentic — avoid hype
- Mention CopilotKit by name and link to https://github.com/CopilotKit/CopilotKit
- If the changes are internal/minor, still generate copy but note it's a maintenance/improvement update
PROMPT_EOF
- name: Run Claude Code
if: steps.verify.outputs.should_run == 'true'
id: claude
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
PROMPT=$(cat .claude-tmp/prompt.txt)
# Invoke the workspace-installed Claude Code via its cli-wrapper entrypoint.
# We installed with --ignore-scripts (PR-head safety), so the postinstall
# that copies the native binary over the `claude` bin stub did not run;
# cli-wrapper.cjs is the package's documented fallback that resolves and
# spawns the native binary from the installed optionalDependency.
node node_modules/@anthropic-ai/claude-code/cli-wrapper.cjs -p "$PROMPT" \
--output-format json \
--max-turns 10 \
--allowedTools "Read,Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(cat:*)" \
--disallowedTools "TodoWrite,Edit,MultiEdit,Write,NotebookEdit,WebFetch,WebSearch,Task" \
> .claude-tmp/claude-output.json
- name: Extract result to markdown
if: steps.verify.outputs.should_run == 'true' && always() && steps.claude.outcome != 'skipped'
id: extract
run: |
node << 'EXTRACT_SCRIPT' > .claude-tmp/pr-social-copy.md
const fs = require('fs');
const file = '.claude-tmp/claude-output.json';
if (!fs.existsSync(file)) {
process.exit(0);
}
const raw = fs.readFileSync(file, 'utf8').trim();
let result = '';
// With --output-format json, output is a single JSON object with a "result" field
try {
const obj = JSON.parse(raw);
result = obj.result || '';
} catch {
// Fallback: try JSONL format (one JSON object per line)
for (const line of raw.split('\n')) {
try {
const obj = JSON.parse(line);
if (obj.type === 'result' && obj.result) {
result = obj.result;
}
if (!result && obj.type === 'assistant' && obj.message?.content) {
for (const block of obj.message.content) {
if (block.type === 'text' && block.text && block.text.includes('### ')) {
result = block.text;
}
}
}
} catch {}
}
}
// Strip any preamble before the first ### header
const idx = result.indexOf('### ');
const clean = idx >= 0 ? result.slice(idx) : result;
process.stdout.write(clean);
EXTRACT_SCRIPT
- name: Upload social copy artifact
if: steps.verify.outputs.should_run == 'true' && always() && steps.extract.outcome == 'success'
id: artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: social-copy-pr${{ github.event.issue.number }}
path: .claude-tmp/pr-social-copy.md
retention-days: 90
- name: Update comment with results
if: steps.verify.outputs.should_run == 'true' && always() && steps.extract.outcome == 'success'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
COMMENT_ID: ${{ steps.verify.outputs.comment_id }}
ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}
with:
script: |
const fs = require('fs');
const output = fs.readFileSync('.claude-tmp/pr-social-copy.md', 'utf8').trim() || '_Claude did not produce output. Please try again._';
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const artifactUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/artifacts/${process.env.ARTIFACT_ID}`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: Number(process.env.COMMENT_ID),
body: [
'<!-- social-copy-generator -->',
'### 📣 Social Copy Generator',
'',
output,
'',
'---',
`_Generated at ${timestamp} from commit \`${process.env.HEAD_SHA}\` | [Workflow run](${runUrl}) | [Download markdown](${artifactUrl})_`,
'',
'- [ ] **Regenerate social media copies**',
].join('\n'),
});
- name: Update comment on failure
if: steps.verify.outputs.should_run == 'true' && always() && steps.extract.outcome != 'success'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
COMMENT_ID: ${{ steps.verify.outputs.comment_id }}
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: Number(process.env.COMMENT_ID),
body: [
'<!-- social-copy-generator -->',
'### 📣 Social Copy Generator',
'',
`❌ **Generation failed.** [View workflow logs](${runUrl}) for details.`,
'',
'- [ ] **Regenerate social media copies**',
].join('\n'),
});
+200
View File
@@ -0,0 +1,200 @@
name: release / create-pr
on:
workflow_dispatch:
inputs:
scope:
description: "What to release"
required: true
type: choice
options:
- monorepo
- angular
- channels
- channels-discord
- channels-intelligence
- channels-slack
- channels-teams
- channels-telegram
- channels-whatsapp
bump:
description: "Version bump level"
required: true
type: choice
options:
- patch
- minor
- major
dry_run:
description: "Dry run (preview without creating PR)"
required: false
default: false
type: boolean
concurrency:
# Scope the lock to the package being released so that, e.g., a `monorepo`
# create-pr run and an `angular` create-pr run proceed in independent lanes
# instead of queuing behind each other. Same-scope runs still serialize
# (cancel-in-progress: false), which is what protects the version bump.
group: release-pr-${{ inputs.scope }}
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
env:
NX_VERBOSE_LOGGING: true
jobs:
create-release-pr:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: npm
steps:
- name: Check for existing release PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { owner, repo } = context.repo;
const { data: prs } = await github.rest.pulls.list({
owner,
repo,
state: "open",
head_prefix: `${owner}:release/publish/`,
});
const releasePRs = prs.filter(pr => pr.head.ref.startsWith("release/publish/"));
if (releasePRs.length > 0) {
const existing = releasePRs.map(pr => ` - #${pr.number}: ${pr.title} (${pr.html_url})`).join("\n");
core.setFailed(
`An open release PR already exists. Close or merge it before creating a new one:\n${existing}`
);
}
- name: Checkout Repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
persist-credentials: false
- name: Setup pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20.x
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Prepare release
id: prepare
run: |
if [ "${{ inputs.dry_run }}" == "true" ]; then
pnpm tsx scripts/release/prepare-release.ts --bump ${{ inputs.bump }} --scope ${{ inputs.scope }} --dry-run
else
pnpm tsx scripts/release/prepare-release.ts --bump ${{ inputs.bump }} --scope ${{ inputs.scope }}
fi
- name: Sync plugin skills
if: inputs.dry_run != true
run: pnpm sync:plugin-skills
- name: Generate AI release notes
if: inputs.dry_run != true
id: ai_notes
run: pnpm tsx scripts/release/generate-ai-release-notes.ts "${{ steps.prepare.outputs.version }}"
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}
NOTION_RELEASE_NOTES_PAGE: ${{ secrets.NOTION_RELEASE_NOTES_PAGE }}
- name: Mint devops-bot token
if: inputs.dry_run != true
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: 1108748
private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
permission-issues: write
- name: Create release PR
if: inputs.dry_run != true
id: create_pr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
env:
# The PR branch is validated by CI after creation; do not run local
# developer pre-commit hooks inside the automation commit.
LEFTHOOK: "0"
with:
token: ${{ steps.app-token.outputs.token }}
branch: release/publish/${{ inputs.scope }}/v${{ steps.prepare.outputs.version }}
delete-branch: true
commit-message: "chore: release ${{ inputs.scope }} v${{ steps.prepare.outputs.version }}"
title: "chore: release ${{ inputs.scope }} v${{ steps.prepare.outputs.version }}"
body: |
## Release ${{ inputs.scope }} v${{ steps.prepare.outputs.version }}
**Scope:** `${{ inputs.scope }}` | **Bump:** `${{ inputs.bump }}`
---
### How this release process works
1. **This PR was created automatically** by the "release / create-pr" workflow.
It bumped the `${{ inputs.scope }}` packages to `${{ steps.prepare.outputs.version }}`
and generated AI-enhanced release notes.
2. **CI runs on this PR** — the full test suite (unit tests, lint, type checks, build)
must pass before merging. This is the review gate.
3. **Review the release notes** in `release-notes.md` in this PR.
If a Notion draft was created, you can edit the release notes there before merging.
4. **When this PR is merged**, the `release / publish` workflow automatically:
- Builds all packages
- Publishes the `${{ inputs.scope }}` packages to npm at version `${{ steps.prepare.outputs.version }}`
- Creates git tag `${{ inputs.scope }}/v${{ steps.prepare.outputs.version }}`
- Creates a GitHub Release with the final release notes
### Before merging
- [ ] CI is green (tests, lint, types, build)
- [ ] Version bumps look correct
- [ ] Release notes are accurate (edit in Notion if a draft was created)
---
> **Do not merge until CI is fully green.** The full test suite runs automatically on this PR.
labels: release
- name: Comment Notion link on PR
if: inputs.dry_run != true && steps.ai_notes.outputs.notion_url
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
NOTION_URL: ${{ steps.ai_notes.outputs.notion_url }}
PR_NUMBER: ${{ steps.create_pr.outputs.pull-request-number }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const { owner, repo } = context.repo;
const prNumber = parseInt(process.env.PR_NUMBER, 10);
const notionUrl = process.env.NOTION_URL;
if (prNumber && notionUrl) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: `📝 **Release notes draft:** ${notionUrl}\n\nYou can edit the release notes in Notion before merging. The final content will be used for the GitHub Release.`,
});
}
+104
View File
@@ -0,0 +1,104 @@
name: static / bundle-size
on:
pull_request:
paths-ignore:
- "README.md"
- "examples/**"
- "showcase/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
NODE_OPTIONS: "--max-old-space-size=4096"
NX_VERBOSE_LOGGING: true
jobs:
# Posts a per-PR comment with per-file gzip diffs for the 9 in-scope packages.
# Runs preactjs/compressed-size-action, which builds the PR head and the base
# branch, scans the `pattern` glob in each, and diffs the gzip sizes; the
# comment updates in place on subsequent pushes. No hard-fail (Phase 1) — see
# dev-docs/bundle-size.md for the rollout plan.
#
# Fork limitation: pull_request runs from a fork get a read-only GITHUB_TOKEN,
# so the action cannot post the comment and prints the report to the logs
# instead. The measurement still runs. Accepted for Phase 1 (informational, no
# hard-fail); see dev-docs/bundle-size.md for the relay pattern if the comment
# ever becomes required.
bundle-size:
runs-on: ubuntu-latest
timeout-minutes: 30
# pull-requests: write is scoped to this job only — it's the one that posts
# the size-diff comment. The import-size job below stays read-only.
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
fetch-depth: 0
- name: Setup pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Use Node.js 20
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20.x
# setup-node built-in cache is fork-safe (fork PRs can't write to base repo cache)
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Measure compressed bundle sizes
uses: preactjs/compressed-size-action@f322c295dde06a1cb7ccaef105732dd8a726d1d9 # 2.10.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# Use the root `build` script (present on both this branch and the base
# branch) so compressed-size-action can build both sides for comparison.
# The `pattern` below restricts measurement to the 9 in-scope packages.
build-script: build
pattern: "packages/{core,shared,react-core,react-ui,react-textarea,runtime-client-gql,web-inspector,voice,a2ui-renderer}/dist/**/*.{mjs,js,cjs}"
# Measures what an app importing { CopilotChat } from
# @copilotkit/react-core/v2 bundles, by driving esbuild over a synthetic entry
# and summing the gzipped output. Writes the total to the GitHub job summary
# for per-PR visibility. It is a relative regression signal across PRs, not a
# production Vite/Next figure — see dev-docs/bundle-size.md.
copilotchat-import-size:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Use Node.js 20
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20.x
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build react-core
run: npx nx run @copilotkit/react-core:build
- name: Measure CopilotChat bundle regression signal
run: pnpm --filter @copilotkit/react-core size:headline
+104
View File
@@ -0,0 +1,104 @@
name: static / check binaries
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
check-config-files:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Check build config allowlist
run: bash .github/scripts/check-config-allowlist.sh
check-binaries:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
persist-credentials: false
- name: Check for binary and build artifacts
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
VIOLATIONS=0
# Get list of added/modified files in the PR
CHANGED_FILES=$(git diff --name-only "origin/${BASE_REF}...HEAD")
if [ -z "$CHANGED_FILES" ]; then
echo "No changed files detected."
exit 0
fi
# Check for binary file extensions
BINARY_FILES=$(echo "$CHANGED_FILES" | grep -iE '\.(exe|dll|so|dylib|o|obj|a|lib|wasm)$' || true)
if [ -n "$BINARY_FILES" ]; then
echo "::error::Binary files detected in PR:"
echo "$BINARY_FILES"
VIOLATIONS=1
fi
# Check for build directories
BUILD_FILES=$(echo "$CHANGED_FILES" | grep -E '/build/' || true)
if [ -n "$BUILD_FILES" ]; then
echo "::error::Files in build directories detected in PR:"
echo "$BUILD_FILES"
VIOLATIONS=1
fi
# Check for dSYM directories
DSYM_FILES=$(echo "$CHANGED_FILES" | grep -E '\.dSYM/' || true)
if [ -n "$DSYM_FILES" ]; then
echo "::error::dSYM debug symbol directories detected in PR:"
echo "$DSYM_FILES"
VIOLATIONS=1
fi
# Check for large files (>1MB) among changed files
# Exclude known large files: lockfiles, assets, bundled actions
LARGE_FILES=""
while IFS= read -r file; do
if [ -f "$file" ]; then
case "$file" in
pnpm-lock.yaml|*/pnpm-lock.yaml|*/package-lock.json|*/poetry.lock) continue ;;
assets/*|examples/*/preview.gif|examples/*/assets/*) continue ;;
.github/actions/*/dist/*) continue ;;
showcase/shell/src/data/*|showcase/shell-docs/src/data/*|showcase/shell-dojo/src/data/demo-content.json) continue ;;
esac
SIZE=$(wc -c < "$file" | tr -d ' ')
if [ "$SIZE" -gt 1048576 ]; then
LARGE_FILES="${LARGE_FILES}${file} ($(( SIZE / 1024 )) KB)\n"
fi
fi
done <<< "$CHANGED_FILES"
if [ -n "$LARGE_FILES" ]; then
echo "::error::Files over 1 MB detected in PR:"
echo -e "$LARGE_FILES"
VIOLATIONS=1
fi
if [ "$VIOLATIONS" -eq 1 ]; then
echo ""
echo "This PR contains binary artifacts, build outputs, or oversized files."
echo "Please remove them and update your .gitignore if needed."
exit 1
fi
echo "No binary artifacts or oversized files detected."
+57
View File
@@ -0,0 +1,57 @@
name: static / compat
on:
push:
branches: [main]
paths-ignore:
- "README.md"
- "examples/**"
- "showcase/**"
pull_request:
paths-ignore:
- "README.md"
- "examples/**"
- "showcase/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
compat-check:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Use Node.js 20
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20.x
# setup-node built-in cache is fork-safe (fork PRs can't write to base repo cache)
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build packages
run: >
npx nx run-many -t build
--projects=@copilotkit/core,@copilotkit/shared,@copilotkit/react-core,@copilotkit/react-ui,@copilotkit/react-textarea,@copilotkit/runtime-client-gql,@copilotkit/web-inspector,@copilotkit/voice,@copilotkit/a2ui-renderer
- name: Run compat-check
run: >
npx nx run-many -t compat-check
--projects=@copilotkit/core,@copilotkit/shared,@copilotkit/react-core,@copilotkit/react-ui,@copilotkit/react-textarea,@copilotkit/runtime-client-gql,@copilotkit/web-inspector,@copilotkit/voice,@copilotkit/a2ui-renderer
+55
View File
@@ -0,0 +1,55 @@
name: static / danger
on:
pull_request:
paths:
- "sdk-python/copilotkit/langgraph_agent.py"
- "packages/sdk-js/src/langgraph.ts"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NX_VERBOSE_LOGGING: true
NX_CI_EXECUTION_ID: ${{ github.head_ref }}-${{ github.sha }}-${{ github.run_attempt }}
NX_CI_EXECUTION_ENV: "Danger"
permissions:
contents: read
jobs:
danger:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
# Danger posts review comments on the PR via GITHUB_TOKEN
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Use Node.js 20
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20.x
# setup-node built-in cache is fork-safe (fork PRs can't write to base repo cache)
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run Danger
run: pnpm exec danger ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+419
View File
@@ -0,0 +1,419 @@
name: static / quality
on:
push:
branches: [main]
paths-ignore:
- "README.md"
- "examples/**"
pull_request:
branches: [main]
paths-ignore:
- "README.md"
- "examples/**"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_OPTIONS: "--max-old-space-size=4096"
NX_VERBOSE_LOGGING: true
NX_CI_EXECUTION_ID: ${{ github.head_ref }}-${{ github.sha }}-${{ github.run_attempt }}
NX_CI_EXECUTION_ENV: "Static Quality"
permissions:
contents: read
jobs:
format:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }}
# Check the head branch out from the head repo, not the base repo.
# For fork PRs the head branch only exists on the fork, so defaulting
# to the base repo makes checkout fail with "a branch or tag with the
# name '<branch>' could not be found". Same-repo PRs resolve to the
# base repo unchanged, so the auto-format push-back below still works.
repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
token: ${{ secrets.GITHUB_TOKEN }}
# Full history so we can diff HEAD against the current base branch
# tip to scope the formatter to PR-changed files.
fetch-depth: 0
- name: Setup pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20.x
cache: pnpm
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install oxfmt
# oxfmt is pinned as a root devDependency and installed from the frozen
# lockfile — no ad-hoc `npm install -g`. `--ignore-scripts` keeps
# install-time scripts from running against the PR-head checkout this job
# uses. Put node_modules/.bin on PATH so the bare `oxfmt` calls below
# (invoked via xargs) resolve the pinned binary.
run: |
pnpm install --frozen-lockfile --ignore-scripts
echo "$(pwd)/node_modules/.bin" >> "$GITHUB_PATH"
- name: Install ruff
# Pin ruff so a compromised or breaking release can't land on the next
# PR run with the persisted-credentials write token in this job. The
# official ruff-action installs the pinned version (via uv) and puts
# `ruff` on PATH for the format steps below; `args: --version` makes the
# action install-only (it defaults to running `ruff check` otherwise).
# Bump the version manually when needed (ruff isn't tracked by Dependabot).
uses: astral-sh/ruff-action@278981a28ce3188b1e39527901f38254bf3aac89 # v4.1.0
with:
version: "0.15.13"
args: "--version"
- name: Collect PR-changed files for formatting
if: github.event_name == 'pull_request'
id: changed
env:
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
base_ref="${PR_BASE_REF}"
# Fetch the current tip of the base branch so the merge-base tracks
# main as it advances (using the PR's stored base.sha would pull in
# every file main has touched since the PR opened).
git fetch --no-tags origin "$base_ref"
# Scope to files changed between the current base-branch merge-base
# and HEAD so advances on main don't drag unrelated files into the
# PR. Restrict to oxfmt-supported extensions so oxfmt never errors
# on an unknown target. Canonical list lives upstream in oxfmt
# (https://github.com/oxc-project/oxc-formatter) — update here when
# oxfmt adds a new format.
#
# Exclude lockfiles: they match *.json / *.yaml but oxfmt rejects
# them internally (size threshold or filename heuristic), which
# caused lockfile-only PRs to fail with "Expected at least one
# target file". Lockfiles are auto-generated by npm/pnpm and should
# never be hand-formatted regardless.
git diff --name-only --diff-filter=ACMR "origin/$base_ref"...HEAD -- \
'*.js' '*.jsx' '*.ts' '*.tsx' '*.mjs' '*.cjs' \
'*.json' '*.jsonc' '*.json5' \
'*.md' \
'*.css' '*.yml' '*.yaml' '*.html' '*.vue' '*.py' \
':!**/package-lock.json' ':!**/pnpm-lock.yaml' ':!**/yarn.lock' \
> .pr-format-files.txt
: > .pr-format-files.existing.txt
while IFS= read -r f; do
[ -n "$f" ] && [ -f "$f" ] && printf '%s\n' "$f" >> .pr-format-files.existing.txt
done < .pr-format-files.txt
# Drop tracked-but-gitignored paths (e.g. fixtures under a
# `recorded/` rule). Without this, oxfmt would rewrite them and
# the auto-commit step's `git add` would refuse the ignored
# path, killing the whole step and leaving the PR unfixed.
if [ -s .pr-format-files.existing.txt ]; then
git ls-files -i -c --exclude-standard > .pr-format-files.ignored.txt
grep -vxFf .pr-format-files.ignored.txt .pr-format-files.existing.txt > .pr-format-files.scoped.txt || true
mv .pr-format-files.scoped.txt .pr-format-files.existing.txt
fi
count=$(wc -l < .pr-format-files.existing.txt | tr -d ' ')
echo "count=$count" >> "$GITHUB_OUTPUT"
echo "PR-changed format candidates: $count"
cat .pr-format-files.existing.txt
- name: Run formatter (fix on PR)
run: |
if [ "${{ steps.changed.outputs.count }}" = "0" ]; then
echo "No formattable files changed in this PR — skipping."
exit 0
fi
# oxfmt: auto-fix JS/TS/JSON/MD/CSS/YAML/HTML/Vue
if ! xargs -a .pr-format-files.existing.txt oxfmt --no-error-on-unmatched-pattern --write; then
echo "::warning::oxfmt exited with error — auto-fix may be incomplete"
fi
# ruff: auto-fix Python
py_files=$(grep -E '\.py$' .pr-format-files.existing.txt || true)
if [ -n "$py_files" ]; then
echo "$py_files" | xargs ruff format || echo "::warning::ruff format exited with error"
fi
# Trigger the auto-commit only when one of the SCOPED PR files
# actually changed. A whole-tree `git diff` here also trips on
# unrelated working-tree drift (e.g. an LFS smudge on a tracked
# `*.png filter=lfs` file), which would set format_fixed=true while
# the scoped `git add` below stages nothing — making `git commit`
# fail with "nothing to commit". Diffing only the scoped files keeps
# the trigger aligned with what the commit step can actually stage.
# shellcheck disable=SC2046 # intentional split: each path is a
# separate `git diff` pathspec arg; the `-s` guard rules out the
# empty-arg (whole-tree) case, and PR paths never contain spaces.
if [ -s .pr-format-files.existing.txt ] && \
! git diff --quiet -- $(cat .pr-format-files.existing.txt); then
echo "format_fixed=true" >> "$GITHUB_ENV"
fi
# Check mode: verify everything is formatted
xargs -a .pr-format-files.existing.txt oxfmt --no-error-on-unmatched-pattern --check
if [ -n "$py_files" ]; then
echo "$py_files" | xargs ruff format --check
fi
- name: Configure git for push
if: >-
env.format_fixed == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --local url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit formatting fixes
if: >-
env.format_fixed == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name
run: |
if [ -z "$(git diff --name-only)" ]; then
echo "No formatting changes to commit"
exit 0
fi
# Stage only the files the formatter was scoped to operate on.
# Piping `git diff --name-only` into `git add` is unsafe: if a
# tracked-but-gitignored path shows up in the diff, `git add`
# aborts the whole step and the auto-fix push never lands —
# leaving formatting violations on the PR branch and (post-
# merge) on main.
xargs -a .pr-format-files.existing.txt git add --
# Guard against an empty staged set: if the scoped `git add` staged
# nothing (e.g. the whole-tree drift that set format_fixed=true lives
# entirely outside the scoped files), `git commit` would exit 1 and
# fail the job. Treat an empty index as a no-op instead.
if git diff --cached --quiet; then
echo "No scoped formatting changes to commit"
exit 0
fi
git commit -m "style: auto-fix formatting"
git push
oxlint:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Use Node.js 20
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20.x
# setup-node built-in cache is fork-safe (fork PRs can't write to base repo cache)
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run oxlint check
run: pnpm run lint
package-quality:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Use Node.js 20
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20.x
# setup-node built-in cache is fork-safe (fork PRs can't write to base repo cache)
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Configure Nx Cloud environment
run: |
{
echo "NX_CI_EXECUTION_ID=${{ github.run_id }}-${{ github.run_attempt }}-quality-packages"
echo "NX_CLOUD_NO_TIMEOUTS=true"
echo "NX_CLOUD_DISTRIBUTED_EXECUTION=false"
echo "NX_NO_CLOUD=true"
} >> "$GITHUB_ENV"
- name: Run publint and attw
run: pnpm run check:packages
check-types:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
env:
# tsc on @copilotkit/runtime needs ~10 GB: the AI SDK v6 tool()
# generics explode against zod 3 schemas (~40M type instantiations,
# ~5 min check time). Bounding the worst inline schemas helps but the
# cost is systemic to the ai x zod type interaction, so this job gets
# a 12 GB heap instead of the workflow-level 4 GB default.
NODE_OPTIONS: "--max-old-space-size=12288"
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Use Node.js 20
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20.x
# setup-node built-in cache is fork-safe (fork PRs can't write to base repo cache)
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Configure Nx Cloud environment
run: |
{
echo "NX_CI_EXECUTION_ID=${{ github.run_id }}-${{ github.run_attempt }}-quality-check-types"
echo "NX_CLOUD_NO_TIMEOUTS=true"
echo "NX_CLOUD_DISTRIBUTED_EXECUTION=false"
echo "NX_NO_CLOUD=true"
} >> "$GITHUB_ENV"
- name: Generate GraphQL codegen files
run: npx nx run @copilotkit/runtime-client-gql:graphql-codegen
- name: Run check-types
# Invoke nx directly: `pnpm run check-types -- --parallel=1` makes
# nx forward --parallel=1 to each package's tsc command instead of
# consuming it. --parallel=1 keeps tsc within the runner's 16 GB
# RAM: the @copilotkit/runtime check alone peaks near 10 GB.
run: npx nx run-many -t check-types --parallel=1
commitlint:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
persist-credentials: false
- name: Setup pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Use Node.js 20
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20.x
# setup-node built-in cache is fork-safe (fork PRs can't write to base repo cache)
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Validate current commit (last commit) with commitlint
if: github.event_name == 'push'
run: |
# Skip merge commits. GitHub's "Create a merge commit" option takes
# the message from the PR body, which can contain markdown lists
# that parse as additional (empty) commit subjects and fail
# subject-empty / type-empty — see commit 5ed233f01.
parents=$(git rev-list --parents -n 1 HEAD | awk '{print NF - 1}')
if [ "$parents" -gt 1 ]; then
echo "HEAD is a merge commit ($parents parents) — skipping commitlint."
exit 0
fi
npx commitlint --last --verbose
- name: Validate PR commits with commitlint
id: commitlint
if: github.event_name == 'pull_request'
continue-on-error: true
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: npx commitlint --from "${PR_BASE_SHA}" --to "${PR_HEAD_SHA}" --verbose 2>&1 | tee /tmp/commitlint-output.txt
- name: Post fix suggestion on failure
if: github.event_name == 'pull_request' && steps.commitlint.outcome == 'failure'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const fs = require('fs');
const output = fs.readFileSync('/tmp/commitlint-output.txt', 'utf8');
const body = `### ❌ Commitlint failed\n\nCommit messages must follow [Conventional Commits](https://www.conventionalcommits.org/).\n\n**Valid prefixes:** \`feat:\`, \`fix:\`, \`docs:\`, \`style:\`, \`refactor:\`, \`test:\`, \`chore:\`, \`ci:\`, \`perf:\`, \`build:\`\n\n**Example:** \`feat: add user authentication\`\n\n<details><summary>Full output</summary>\n\n\`\`\`\n${output}\n\`\`\`\n</details>\n\nTo fix, amend your commit messages:\n\`\`\`bash\ngit rebase -i HEAD~N # N = number of commits to fix\n# Change 'pick' to 'reword' for bad commits\n\`\`\``;
// Find existing comment to update
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('Commitlint failed'));
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: Fail if commitlint failed
if: github.event_name == 'pull_request' && steps.commitlint.outcome == 'failure'
run: exit 1
+340
View File
@@ -0,0 +1,340 @@
name: test / e2e / dojo
on:
push:
branches: [main]
paths:
- "packages/**"
- "sdk-python/**"
- ".github/workflows/test_e2e-dojo.yml"
pull_request:
branches: [main]
paths:
- "packages/**"
- "sdk-python/**"
- ".github/workflows/test_e2e-dojo.yml"
workflow_dispatch:
inputs:
branch:
description: "Branch to run the workflow on"
required: true
default: "main"
type: string
env:
NX_VERBOSE_LOGGING: true
NX_CI_EXECUTION_ID: ${{ github.head_ref }}-${{ github.sha }}-${{ github.run_attempt }}
NX_CI_EXECUTION_ENV: "E2E Dojo"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
detect-changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
ts-changed: ${{ steps.changes.outputs.ts }}
python-changed: ${{ steps.changes.outputs.python }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
filters: |
ts:
- 'packages/**'
- '.github/workflows/test_e2e-dojo.yml'
python:
- 'sdk-python/**'
dojo:
needs: detect-changes
name: ${{ matrix.suite }}
# 4-vCPU runner (was depot-ubuntu-24.04 = 2 vCPU) to speed the build, the
# dojo prep, and Playwright worker parallelism on the long-pole suites.
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 30
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- suite: a2a-middleware
test_path: tests/a2aMiddlewareTests
services: ["dojo", "a2a-middleware"]
wait_on: http://localhost:9999,tcp:localhost:8011,tcp:localhost:8012,tcp:localhost:8013,tcp:localhost:8014
needs_python: true
- suite: adk-middleware
test_path: tests/adkMiddlewareTests
services: ["dojo", "adk-middleware"]
wait_on: http://localhost:9999,tcp:localhost:8010
needs_python: true
- suite: agno
test_path: tests/agnoTests
services: ["dojo", "agno"]
wait_on: http://localhost:9999,tcp:localhost:8002
needs_python: true
- suite: crew-ai
test_path: tests/crewAITests
services: ["dojo", "crew-ai"]
wait_on: http://localhost:9999,tcp:localhost:8003
needs_python: true
- suite: langgraph-python
test_path: tests/langgraphPythonTests
services: ["dojo", "langgraph-platform-python"]
wait_on: http://localhost:9999,tcp:localhost:8005
needs_python: true
- suite: langgraph-typescript
test_path: tests/langgraphTypescriptTests
services: ["dojo", "langgraph-platform-typescript"]
wait_on: http://localhost:9999,tcp:localhost:8006
needs_python: false
- suite: langgraph-fastapi
test_path: tests/langgraphFastAPITests
services: ["dojo", "langgraph-fastapi"]
wait_on: http://localhost:9999,tcp:localhost:8004
needs_python: true
- suite: llama-index
test_path: tests/llamaIndexTests
services: ["dojo", "llama-index"]
wait_on: http://localhost:9999,tcp:localhost:8007
needs_python: true
- suite: mastra
test_path: tests/mastraTests
services: ["dojo", "mastra"]
wait_on: http://localhost:9999,tcp:localhost:8008
needs_python: false
- suite: mastra-agent-local
test_path: tests/mastraAgentLocalTests
services: ["dojo"]
wait_on: http://localhost:9999
needs_python: false
- suite: middleware-starter
test_path: tests/middlewareStarterTests
services: ["dojo"]
wait_on: http://localhost:9999
needs_python: false
- suite: pydantic-ai
test_path: tests/pydanticAITests
services: ["dojo", "pydantic-ai"]
wait_on: http://localhost:9999,tcp:localhost:8009
needs_python: true
- suite: server-starter
test_path: tests/serverStarterTests
services: ["dojo", "server-starter"]
wait_on: http://localhost:9999,tcp:localhost:8000
needs_python: true
- suite: server-starter-all
test_path: tests/serverStarterAllFeaturesTests
services: ["dojo", "server-starter-all"]
wait_on: http://localhost:9999,tcp:localhost:8001
needs_python: true
- suite: aws-strands
test_path: tests/awsStrandsTests
services: ["dojo", "aws-strands"]
wait_on: http://localhost:9999,tcp:localhost:8017
needs_python: true
steps:
- name: Check relevance
id: should-run
run: |
if [[ "${{ needs.detect-changes.outputs.ts-changed }}" == "true" ]] || \
[[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "skip=false" >> $GITHUB_OUTPUT
elif [[ "${{ needs.detect-changes.outputs.python-changed }}" == "true" ]] && \
[[ "${{ matrix.needs_python }}" == "true" ]]; then
echo "skip=false" >> $GITHUB_OUTPUT
else
echo "skip=true" >> $GITHUB_OUTPUT
echo "⏭️ Skipping ${{ matrix.suite }} — no relevant changes"
fi
- name: Detect fork PR
id: fork-check
env:
EVENT_NAME: ${{ github.event_name }}
PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
REPO_FULL: ${{ github.repository }}
run: |
if [[ "$EVENT_NAME" == "pull_request" && \
"$PR_HEAD_REPO" != "$REPO_FULL" ]]; then
echo "prefix=fork-" >> "$GITHUB_OUTPUT"
else
echo "prefix=" >> "$GITHUB_OUTPUT"
fi
- name: Checkout CPK
if: steps.should-run.outputs.skip != 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
lfs: true
path: CopilotKit
ref: ${{ github.event.inputs.branch || github.ref }}
persist-credentials: false
- name: Checkout AGUI
if: steps.should-run.outputs.skip != 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
repository: ag-ui-protocol/ag-ui
path: ag-ui
ref: main
persist-credentials: false
- name: Set up Node.js
if: steps.should-run.outputs.skip != 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
- name: Install pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
if: steps.should-run.outputs.skip != 'true'
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
package_json_file: CopilotKit/package.json
# Now that pnpm is available, cache its store to speed installs
- name: Resolve pnpm store path
if: steps.should-run.outputs.skip != 'true'
id: pnpm-store
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
if: steps.should-run.outputs.skip != 'true'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ steps.fork-check.outputs.prefix }}${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ steps.fork-check.outputs.prefix }}${{ runner.os }}-pnpm-store-
# Cache Python tool caches and virtualenvs; restore only to avoid long saves
- name: Cache Python dependencies (restore-only)
if: steps.should-run.outputs.skip != 'true' && matrix.needs_python
id: cache-python
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cache/pip
~/.cache/pypoetry
~/.cache/uv
**/.venv
key: ${{ steps.fork-check.outputs.prefix }}${{ runner.os }}-pydeps-${{ hashFiles('**/poetry.lock', '**/pyproject.toml') }}
restore-keys: |
${{ steps.fork-check.outputs.prefix }}${{ runner.os }}-pydeps-
- name: Install Poetry
if: steps.should-run.outputs.skip != 'true' && matrix.needs_python
uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2
with:
version: latest
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install uv
if: steps.should-run.outputs.skip != 'true' && matrix.needs_python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Install cpk dependencies
if: steps.should-run.outputs.skip != 'true'
working-directory: CopilotKit
run: pnpm install --frozen-lockfile
- name: Configure Nx Cloud environment
if: steps.should-run.outputs.skip != 'true'
run: |
echo "NX_CI_EXECUTION_ID=${{ github.run_id }}-${{ github.run_attempt }}-e2e-dojo-${{ matrix.suite }}" >> $GITHUB_ENV
echo "NX_CLOUD_NO_TIMEOUTS=true" >> $GITHUB_ENV
echo "NX_CLOUD_DISTRIBUTED_EXECUTION=false" >> $GITHUB_ENV
echo "NX_NO_CLOUD=true" >> $GITHUB_ENV
- name: Build cpk packages
if: steps.should-run.outputs.skip != 'true'
working-directory: CopilotKit
env:
NODE_OPTIONS: --max-old-space-size=8192
# Match the 4-vCPU runner so the monorepo build uses the extra cores.
NX_PARALLEL: 4
run: pnpm build
- name: Install ag-ui dependencies
if: steps.should-run.outputs.skip != 'true'
working-directory: ag-ui
run: pnpm install --frozen-lockfile
- name: Prepare dojo for e2e
working-directory: ag-ui/apps/dojo
env:
NODE_OPTIONS: --max-old-space-size=8192
if: steps.should-run.outputs.skip != 'true' && join(matrix.services, ',') != ''
run: node ./scripts/prep-dojo-everything.js --only ${{ join(matrix.services, ',') }}
- name: Link cpk into ag-ui
if: steps.should-run.outputs.skip != 'true'
working-directory: CopilotKit
run: node ../ag-ui/apps/dojo/scripts/link-cpk.js ${{ github.workspace }}/CopilotKit/packages
- name: Install e2e dependencies
if: steps.should-run.outputs.skip != 'true'
working-directory: ag-ui/apps/dojo/e2e
run: |
pnpm install
- name: write langgraph env files
working-directory: ag-ui/integrations/langgraph
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
if: steps.should-run.outputs.skip != 'true' && (contains(join(matrix.services, ','), 'langgraph-fastapi') || contains(join(matrix.services, ','), 'langgraph-platform-python') || contains(join(matrix.services, ','), 'langgraph-platform-typescript'))
run: |
echo "OPENAI_API_KEY=${OPENAI_API_KEY}" > python/examples/.env
echo "LANGSMITH_API_KEY=${LANGSMITH_API_KEY}" >> python/examples/.env
echo "OPENAI_API_KEY=${OPENAI_API_KEY}" > typescript/examples/.env
echo "LANGSMITH_API_KEY=${LANGSMITH_API_KEY}" >> typescript/examples/.env
- name: Run dojo+agents
uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635 # v1
env:
NODE_OPTIONS: --max-old-space-size=8192
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_GEMINI_API_KEY }}
if: steps.should-run.outputs.skip != 'true' && join(matrix.services, ',') != '' && contains(join(matrix.services, ','), 'dojo')
with:
run: |
node ../scripts/run-dojo-everything.js --only ${{ join(matrix.services, ',') }}
working-directory: ag-ui/apps/dojo/e2e
wait-on: ${{ matrix.wait_on }}
wait-for: 300000
- name: Run tests ${{ matrix.suite }}
if: steps.should-run.outputs.skip != 'true'
working-directory: ag-ui/apps/dojo/e2e
env:
NODE_OPTIONS: --max-old-space-size=8192
BASE_URL: http://localhost:9999
PLAYWRIGHT_SUITE: ${{ matrix.suite }}
run: |
pnpm test -- ${{ matrix.test_path }}
- name: Upload traces ${{ matrix.suite }}
if: always() && steps.should-run.outputs.skip != 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.suite }}-playwright-traces
path: |
ag-ui/apps/dojo/e2e/test-results/${{ matrix.suite }}/**/*
ag-ui/apps/dojo/e2e/playwright-report/**/*
retention-days: 7
+139
View File
@@ -0,0 +1,139 @@
name: test / e2e / legacy-v1
on:
push:
branches: [main]
paths:
- "examples/**"
- ".github/workflows/test_e2e-legacy-v1.yml"
pull_request:
branches: [main]
paths:
- "examples/**"
- ".github/workflows/test_e2e-legacy-v1.yml"
workflow_dispatch:
inputs:
branch:
description: "Branch to run the workflow on"
required: true
default: "main"
type: string
env:
NX_VERBOSE_LOGGING: true
NX_CI_EXECUTION_ID: ${{ github.head_ref }}-${{ github.sha }}-${{ github.run_attempt }}
NX_CI_EXECUTION_ENV: "E2E Examples"
# Least-privilege by default. Individual jobs/steps can widen when needed.
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
examples:
name: ${{ matrix.example }}
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 20
permissions:
contents: read
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
id-token: write
strategy:
fail-fast: false
matrix:
example:
- form-filling
- travel
- research-canvas
- chat-with-your-data
- state-machine
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
lfs: true
ref: ${{ github.event.inputs.branch || github.ref }}
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
- name: Install pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Detect fork PR
id: fork-check
env:
EVENT_NAME: ${{ github.event_name }}
PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
REPO_FULL: ${{ github.repository }}
run: |
if [[ "$EVENT_NAME" == "pull_request" && \
"$PR_HEAD_REPO" != "$REPO_FULL" ]]; then
echo "prefix=fork-" >> "$GITHUB_OUTPUT"
else
echo "prefix=" >> "$GITHUB_OUTPUT"
fi
- name: Resolve pnpm store path
id: pnpm-store
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ steps.fork-check.outputs.prefix }}${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ steps.fork-check.outputs.prefix }}${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Configure Nx Cloud environment
run: |
echo "NX_CI_EXECUTION_ID=${{ github.run_id }}-${{ github.run_attempt }}-e2e-examples-${{ matrix.example }}" >> $GITHUB_ENV
echo "NX_CLOUD_NO_TIMEOUTS=true" >> $GITHUB_ENV
echo "NX_CLOUD_DISTRIBUTED_EXECUTION=false" >> $GITHUB_ENV
echo "NX_NO_CLOUD=true" >> $GITHUB_ENV
- name: Build CopilotKit
id: build-cpk
run: pnpm build
- name: Install e2e dependencies
working-directory: examples/e2e
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
working-directory: examples/e2e
run: pnpm exec playwright install --with-deps chromium
- name: Run e2e tests
working-directory: examples/e2e
env:
EXAMPLE: ${{ matrix.example }}
CI: "true"
OPENAI_API_KEY: test
NEXT_PUBLIC_CPK_PUBLIC_API_KEY: ""
NEXT_PUBLIC_COPILOT_PUBLIC_API_KEY: ""
LANGSMITH_API_KEY: ""
run: pnpm test
- name: Upload Playwright artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: examples-e2e-${{ matrix.example }}
path: |
examples/e2e/test-results/**/*
examples/e2e/playwright-report/**/*
retention-days: 7
@@ -0,0 +1,555 @@
name: "test / e2e / showcase / on-demand"
# SECURITY — residual trust model (read before editing):
#
# This workflow EXISTS to execute PR-HEAD code (Playwright tests, Next.js dev
# server, Python agent, pip install of PR-controlled requirements.txt). Several
# hardening layers reduce blast radius:
# - `author_association` gate limits the `issue_comment` trigger to OWNER /
# MEMBER / COLLABORATOR (third-party commenters cannot spawn runs).
# - workflow-level `permissions: contents: read` means the heavy test job's
# GITHUB_TOKEN cannot mutate the repo; the `post-result` job gets write
# perms scoped to just the final PR comment.
# - `persist-credentials: false` on `actions/checkout` prevents the token
# from being left behind in `.git/config` where PR-HEAD build hooks might
# read it.
# - `pnpm install --ignore-scripts` / `npm install --ignore-scripts` block
# install-time hooks in PR-controlled JS manifests from executing on the
# runner. The Python install uses `pip install --prefer-binary` (prefers
# wheels, falls back to sdist on transitive deps that lack a wheel for
# linux-x86_64/py3.12). We used to use `--only-binary :all:` for a hard
# block against source-build hooks, but CrewAI's transitive graph
# (tiktoken / chromadb / litellm cadence releases) regularly ships a
# sdist-only revision that makes every CI run fail-loud with "Could not
# find a version that satisfies the requirement". `--prefer-binary` trades
# that hard guarantee for reliability — the `author_association` gate
# above still limits WHO can trigger this workflow, so the residual risk
# is bounded to a trusted commenter. See also the "Start Python agent"
# step for the in-context trade-off rationale.
# - A strict slug whitelist (`^[a-z0-9-]+$` + existing-dir check) and the
# `env:`-based pattern for UNTRUSTED values (comment body, dispatch slug)
# prevent shell injection / path traversal.
#
# What this is NOT: a security boundary against a malicious trusted commenter.
# The last line of defense is the SOCIAL CONTRACT that a trusted commenter
# reviews the PR diff BEFORE typing `/test-aimock` — if a compromised / rogue
# OWNER/MEMBER/COLLABORATOR comments on an attacker's PR, they get a full
# runner exec with the job's token. That is an accepted residual risk for the
# developer-velocity benefit of PR-triggered E2E runs. Do not loosen the
# `author_association` gate without revisiting the threat model above.
#
# Known TOCTOU — comment-trigger vs resolved HEAD SHA:
# "Resolve PR HEAD ref" below calls `pulls.get` at job start. There is a
# window between the trusted commenter typing `/test-aimock` (reviewed diff
# D1) and the workflow actually calling `pulls.get` (resolves whatever HEAD
# is current — possibly D2 after a force-push). A PR author who force-pushes
# malicious content AFTER the trusted comment but BEFORE the resolve call
# gets their code executed. GitHub Actions does NOT natively support pinning
# the SHA at comment time (no `comment.commit_sha` equivalent), so this gap
# is architectural. The `author_association` gate + code-review social
# contract are the mitigations; the residual TOCTOU risk is accepted. If
# GitHub ever ships a comment-time SHA field, pin to it and drop this note.
on:
issue_comment:
types: [created]
workflow_dispatch:
inputs:
slug:
description: "Package slug to test (Python integration with aimock support)"
required: true
# Only Python integrations that exercise the AIMOCK_URL path
# end-to-end belong here. Restricting the enum prevents accidental
# dispatch of a TS-only (mastra) or Java (spring-ai) slug that would
# skip the Python agent startup step and then fail with a misleading
# Playwright timeout. When a new Python slug is validated, append it.
#
# No `default:` is set — the operator must pick a slug explicitly. A
# hidden default would silently bind manual dispatches to whichever
# slug happens to be first in the enum, which contradicts the
# "no silent fallback" guarantee the comment-path extractor enforces.
type: choice
options:
- crewai-crews
- langgraph-python
# Default to read-only at the job level. The only step that needs write access
# is "Post result to PR" at the end — we grant it write perms inline there.
# Keeping the workflow-level perms read-only means every intermediate step
# (including `pip install` on attacker-controlled requirements.txt) runs with
# a token that cannot mutate the repo.
permissions:
contents: read
jobs:
aimock-e2e:
# Only run on PR comments matching `/test-aimock ` (trailing space REQUIRED)
# from trusted authors, or manual dispatch. The trailing space tightens
# the match so unrelated text like `/test-aimocker` or `don't /test-aimock-like-this`
# does NOT trigger the workflow. The author_association gate additionally
# prevents arbitrary third-party commenters from triggering runs with
# attacker-controlled comment bodies (which the 'Determine slug' step then
# parses — see env-based shell interpolation below). A bare `/test-aimock`
# alone (no trailing space) is rejected by design; commenters must pick a
# slug explicitly — no silent fallback to crewai-crews (see "Determine slug"
# step below).
# `startsWith` (not `contains`) is the Actions-level gate: it requires
# `/test-aimock ` to be the FIRST token of the comment, so embedded mentions
# (in code blocks, quoted replies, or mid-sentence prose) cannot spin up a
# runner. The shell extractor in the "Determine slug" step uses the same
# leading anchor (`^/test-aimock[[:space:]]+…`) as defense-in-depth; both
# layers agree on "first token only" so a future edit that loosens either
# layer alone cannot bypass validation. Commenters who
# want to add narration around the command should put the command on its
# own line at the top of the comment.
if: >
github.event_name == 'workflow_dispatch' ||
(github.event.issue.pull_request
&& startsWith(github.event.comment.body, '/test-aimock ')
&& contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
# Pinned to ubuntu-latest deliberately: the 'Determine slug' step uses
# POSIX-only `grep -oE` + `sed` (no `grep -oP` / PCRE) so a future BSD
# grep would still work, but ubuntu-latest keeps the install/setup matrix
# consistent with every other showcase workflow.
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# For issue_comment events, we need to resolve the PR HEAD SHA ourselves
# because the event payload doesn't include pull_request.head.sha
- name: Resolve PR HEAD ref
id: pr-ref
if: github.event_name == 'issue_comment'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
// Refuse to run against a closed / merged PR. A trusted commenter
// typing `/test-aimock` on a stale closed PR would otherwise
// re-exec the old HEAD — either wasting CI or (if the PR was
// closed BECAUSE it was bad) re-running known-bad code. Fail loud.
if (pr.state !== 'open') {
core.setFailed(`PR #${pr.number} is ${pr.state} (not open). Refusing to run E2E on a non-open PR.`);
return;
}
core.setOutput('ref', pr.head.sha);
core.setOutput('pr_number', pr.number);
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ steps.pr-ref.outputs.ref || github.sha }}
# Do NOT leave the workflow's GITHUB_TOKEN in `.git/config` after
# checkout. PR-HEAD code (pip build hooks, Next.js dev scripts,
# Playwright fixtures) runs on this runner; a credential left in the
# working tree could be read by that code and exfiltrated. The job's
# `permissions: contents: read` limits blast radius, but defense-in-
# depth cheap — disable credential persistence.
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22.x
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Determine slug
id: slug
# SECURITY: comment body and dispatch slug are UNTRUSTED. Pass via env
# (NOT via `${{ ... }}` expression interpolation) so shell never parses
# attacker-controlled text. Then validate against a strict whitelist
# before anything downstream uses $SLUG as a path / package name — so
# `../../../etc/shadow` or similar cannot reach `cd`/`pip install`.
env:
EVENT_NAME: ${{ github.event_name }}
COMMENT_BODY: ${{ github.event.comment.body }}
DISPATCH_SLUG: ${{ github.event.inputs.slug }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
SLUG="$DISPATCH_SLUG"
else
# POSIX-safe extraction (no `grep -oP` / PCRE `\K`): match
# `/test-aimock` ONLY at the start of the comment body, followed
# by whitespace + a slug. The leading anchor (^) matches exactly
# what the job-level `if:` gate enforces via
# `startsWith(github.event.comment.body, '/test-aimock ')` — both
# layers agree that the command must be the FIRST token of the
# body, so an edit that loosens either layer cannot accidentally
# desynchronize from the other. This blocks
# `/test-aimocker` or mid-line mentions from matching.
# Works on both GNU grep (ubuntu-latest) and BSD grep.
SLUG=$(printf '%s' "$COMMENT_BODY" \
| grep -oE '^/test-aimock[[:space:]]+[^[:space:]]+' \
| head -n1 \
| sed 's|^/test-aimock[[:space:]]*||' \
|| true)
# No default slug fallback. A bare `/test-aimock` (no slug) or a
# match that only skimmed our boundary (e.g. `/test-aimocker x`)
# FAILS the workflow rather than silently running against
# crewai-crews. A hidden default is a footgun: a trusted commenter
# typing `don't /test-aimock-like-this` would otherwise spawn a
# full CI run against the wrong package.
if [ -z "$SLUG" ]; then
echo "::error::No slug provided. Usage: '/test-aimock <slug>' (e.g. '/test-aimock crewai-crews')"
exit 1
fi
fi
# Strict slug whitelist: lowercase alphanumerics + hyphens only. This
# blocks path traversal (`../`), absolute paths, command substitution,
# and anything else that could escape `showcase/integrations/$SLUG`.
case "$SLUG" in
''|*[!a-z0-9-]*)
echo "::error::Invalid slug '$SLUG' — must match ^[a-z0-9-]+$"
exit 1
;;
esac
# Belt-and-suspenders: the slug must correspond to an existing package
# directory. Rejects typos and anything that bypasses the regex.
if [ ! -d "showcase/integrations/$SLUG" ]; then
echo "::error::Slug '$SLUG' does not map to showcase/integrations/$SLUG"
exit 1
fi
echo "slug=$SLUG" >> "$GITHUB_OUTPUT"
# NOTE on `${{ steps.slug.outputs.slug }}` vs `env:` pattern:
# Downstream steps interpolate `steps.slug.outputs.slug` directly into
# the shell script body. This is SAFE here because the "Determine slug"
# step above whitelists the value against `^[a-z0-9-]+$` AND rejects any
# slug that doesn't map to an existing package directory — so the value
# that reaches these interpolations is always a trusted, validated
# identifier. We still use the `env:`-based defensive default for
# downstream script bodies that handle anything else UNTRUSTED (see the
# `actions/github-script` step at the bottom of the workflow).
- name: Detect package type
id: pkg-type
run: |
SLUG="${{ steps.slug.outputs.slug }}"
PKG_DIR="showcase/integrations/$SLUG"
if [ -f "$PKG_DIR/requirements.txt" ] || [ -f "$PKG_DIR/pyproject.toml" ]; then
echo "has_python=true" >> "$GITHUB_OUTPUT"
else
echo "has_python=false" >> "$GITHUB_OUTPUT"
fi
# Detect agent server type: langgraph (langgraph_cli dev on :8123)
# vs uvicorn (agent_server:app on :8000). The two use different
# start commands, ports, and health endpoints.
if [ -f "$PKG_DIR/langgraph.json" ]; then
echo "agent_type=langgraph" >> "$GITHUB_OUTPUT"
echo "agent_port=8123" >> "$GITHUB_OUTPUT"
echo "agent_health_path=/ok" >> "$GITHUB_OUTPUT"
else
echo "agent_type=uvicorn" >> "$GITHUB_OUTPUT"
echo "agent_port=8000" >> "$GITHUB_OUTPUT"
echo "agent_health_path=/health" >> "$GITHUB_OUTPUT"
fi
# aimock_toggle.py ships in crewai-crews and wires AIMOCK_URL
# end-to-end via configure_aimock(). Packages without it (e.g.
# langgraph-python) can still use aimock — the workflow injects
# OPENAI_BASE_URL directly on the agent process. Log the status
# but do not block; the toggle is a nice-to-have, not a gate.
if [ -f "$PKG_DIR/src/aimock_toggle.py" ]; then
echo "ships_toggle=true" >> "$GITHUB_OUTPUT"
echo "::notice::Slug '$SLUG' ships aimock_toggle.py — aimock redirect handled by configure_aimock()"
else
echo "ships_toggle=false" >> "$GITHUB_OUTPUT"
echo "::notice::Slug '$SLUG' does not ship aimock_toggle.py — aimock redirect will be injected via OPENAI_BASE_URL env var"
fi
# Still require Python — this workflow cannot exercise TS-only or
# Java slugs (no Python agent to start).
if [ ! -f "$PKG_DIR/requirements.txt" ] && [ ! -f "$PKG_DIR/pyproject.toml" ]; then
echo "::error::Slug '$SLUG' has no requirements.txt or pyproject.toml — this workflow only exercises Python-backed packages."
exit 1
fi
- name: Install aimock
run: |
# aimock is pinned as a workspace dependency (@copilotkit/showcase-scripts)
# and installed from the frozen lockfile — no ad-hoc `npm install -g`.
# A frozen install guarantees the exact pinned version resolves (the old
# caret floor could drift to a bad publish); this keeps the CI signal
# reproducible AND satisfies zizmor's adhoc-packages audit.
#
# `--ignore-scripts`: a trusted commenter can run this workflow on a PR
# whose package.json is untrusted content, so we never execute install-time
# scripts. aimock's `llmock` bin runs fine without them.
#
# `--filter` scopes the install to just the aimock owner package so we
# don't pay for the full monorepo install here (the per-slug package deps
# are installed later in "Install package dependencies").
pnpm --filter @copilotkit/showcase-scripts install --frozen-lockfile --ignore-scripts
- name: Start aimock
run: |
# Invoke the workspace-installed `llmock` bin directly from the repo root.
# `llmock` is aimock's fixtures-based CLI (the package also ships an
# `aimock` bin, which is the newer config-only CLI that does NOT accept
# --fixtures). Running from the repo root keeps the root-relative
# --fixtures paths correct (a `pnpm --filter exec` would run inside
# showcase/scripts and break them).
AIMOCK_BIN="./showcase/scripts/node_modules/.bin/llmock"
if [ ! -x "$AIMOCK_BIN" ]; then
echo "::error::aimock binary not found at $AIMOCK_BIN after workspace install"
exit 1
fi
# Fixture layout matches docker-compose.local.yml: feature-parity.json
# was split into per-framework shared/d4/d5-recorded/d6 directories
# (directory-based loading, one --fixtures per directory).
"$AIMOCK_BIN" --port 4010 --host 127.0.0.1 \
--fixtures showcase/aimock/shared \
--fixtures showcase/aimock/d4 \
--fixtures showcase/aimock/d5-recorded \
--fixtures showcase/aimock/d6 \
--validate-on-load &
AIMOCK_PID=$!
echo "AIMOCK_PID=$AIMOCK_PID" >> "$GITHUB_ENV"
# Wait for aimock to be ready. Capture the PID + `kill -0` inside
# the loop so an aimock that crashes on startup (bad fixture path,
# port in use, binary import error) fails fast instead of burning
# the full 20s polling a dead process.
#
# Probe `/__aimock/health` — aimock's actual readiness endpoint.
# Root `/` returns HTTP 404 (aimock serves `/__aimock/*` and `/v1/*`
# only), and `curl -sf` treats 404 as failure, so probing `/` would
# loop until the budget expired and then hard-fail every run.
#
# `--max-time 2 --connect-timeout 1` caps each probe so a hung
# socket cannot blow the loop's 20-iteration budget.
for i in $(seq 1 20); do
if ! kill -0 "$AIMOCK_PID" 2>/dev/null; then
echo "::error::aimock process (PID $AIMOCK_PID) exited before becoming ready — check the preceding aimock stdout/stderr."
exit 1
fi
curl -sf --max-time 2 --connect-timeout 1 http://localhost:4010/__aimock/health > /dev/null 2>&1 && break
sleep 1
done
curl -sf --max-time 2 --connect-timeout 1 http://localhost:4010/__aimock/health || { echo "aimock failed to start"; exit 1; }
- name: Setup Python agent
if: steps.pkg-type.outputs.has_python == 'true'
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.12"
# Cache pip to avoid reinstalling CrewAI's heavy transitive dep
# tree on every PR run. Key scopes to the selected slug so each
# package gets its own cache bucket keyed on its requirements.txt.
cache: "pip"
cache-dependency-path: showcase/integrations/${{ steps.slug.outputs.slug }}/requirements.txt
- name: Start Python agent
if: steps.pkg-type.outputs.has_python == 'true'
run: |
SLUG="${{ steps.slug.outputs.slug }}"
AGENT_TYPE="${{ steps.pkg-type.outputs.agent_type }}"
AGENT_PORT="${{ steps.pkg-type.outputs.agent_port }}"
AGENT_HEALTH="${{ steps.pkg-type.outputs.agent_health_path }}"
cd "showcase/integrations/$SLUG"
# SECURITY / RELIABILITY trade-off: `pip install` runs setup.py /
# PEP 517 build hooks from PR-controlled packages. Unlike npm / pnpm
# there is no `--ignore-scripts` flag for pip; the closest equivalent
# is `--only-binary :all:` (wheel-only, blocks source-build hooks).
#
# We previously used `--only-binary :all:` but CrewAI's dependency
# graph (tiktoken / chromadb / litellm etc.) regularly ships a
# sdist-only revision of a transitive dep. That made every CI run
# fail with "Could not find a version that satisfies the requirement"
# — not a security win but a CI outage. `--prefer-binary` keeps the
# wheel-first preference (most installs remain hook-free) and only
# falls back to sdist when a wheel isn't published for
# linux-x86_64/py3.12. The `author_association` gate at the job
# level still restricts WHO can trigger this workflow, so the
# residual source-build-hook risk is bounded to a trusted commenter.
pip install --prefer-binary -r requirements.txt
if [ "$AGENT_TYPE" = "langgraph" ]; then
# langgraph-python: start via langgraph_cli dev on port 8123.
# Uses langgraph.json for graph configuration. The /ok endpoint
# is the readiness probe. Inject OPENAI_BASE_URL + dummy key
# directly since langgraph-python does not ship aimock_toggle.py.
if [ ! -f "langgraph.json" ]; then
echo "::error::Slug '$SLUG' detected as langgraph but langgraph.json is missing."
exit 1
fi
OPENAI_BASE_URL=http://localhost:4010/v1 \
OPENAI_API_KEY=sk-aimock-dev-ci-only \
python -u -m langgraph_cli dev \
--config langgraph.json \
--host 127.0.0.1 \
--port "$AGENT_PORT" \
--no-browser &
else
# uvicorn-based agent (crewai-crews): start via agent_server:app
# on port 8000. Packages that ship aimock_toggle.py wire
# OPENAI_BASE_URL internally — set AIMOCK_URL only so the toggle
# itself is exercised end-to-end.
if [ ! -f "src/agent_server.py" ]; then
echo "::error::Slug '$SLUG' is missing src/agent_server.py — uvicorn agent type requires the FastAPI entrypoint."
exit 1
fi
export PYTHONPATH="$PWD/src:${PYTHONPATH:-}"
AIMOCK_URL=http://localhost:4010/v1 \
python -m uvicorn "agent_server:app" --host 127.0.0.1 --port "$AGENT_PORT" &
fi
# Wait for agent to be ready. Cold imports (litellm + crew graph
# or langgraph compile) can exceed 60s on a cold runner, so give
# it 90s (45 iterations x 2s). Mirrors the aimock start pattern:
# loop + hard-fail so a cryptic Playwright timeout doesn't mask a
# bind/startup failure.
for i in $(seq 1 45); do
curl -sf --max-time 2 --connect-timeout 1 "http://localhost:${AGENT_PORT}${AGENT_HEALTH}" > /dev/null 2>&1 && break
sleep 2
done
curl -sf --max-time 2 --connect-timeout 1 "http://localhost:${AGENT_PORT}${AGENT_HEALTH}" > /dev/null 2>&1 \
|| { echo "Python agent failed to start on :${AGENT_PORT}"; exit 1; }
- name: Install package dependencies
run: |
SLUG="${{ steps.slug.outputs.slug }}"
cd "showcase/integrations/$SLUG"
# `--ignore-scripts`: a trusted commenter can run `/test-aimock` on
# a PR whose package.json is untrusted content. Without this flag
# an attacker's postinstall script would execute on the runner with
# the workflow's token. The E2E path (Playwright + Next.js dev) does
# not require install-time scripts to succeed.
pnpm install --ignore-scripts
- name: Start dev server
run: |
SLUG="${{ steps.slug.outputs.slug }}"
AGENT_TYPE="${{ steps.pkg-type.outputs.agent_type }}"
AGENT_PORT="${{ steps.pkg-type.outputs.agent_port }}"
cd "showcase/integrations/$SLUG"
# Invoke `next dev` directly instead of `pnpm dev` — the package's
# `pnpm dev` script spawns a SECOND agent process via concurrently,
# but the previous "Start Python agent" step already bound the agent
# port. A second bind would fail with EADDRINUSE. Running Next
# directly also keeps the aimock env flow clean.
#
# `OPENAI_BASE_URL` + `OPENAI_API_KEY` on Next are DEFENSIVE ONLY.
# Next proxies chat traffic to the Python agent via the CopilotKit
# runtime — it does not call OpenAI directly. Setting these prevents
# accidental real-API fallback if a future route adds a direct call.
#
# Agent URL wiring differs by agent type:
# - uvicorn (crewai-crews): AGENT_URL=http://localhost:8000
# - langgraph: LANGGRAPH_DEPLOYMENT_URL=http://localhost:8123
# (langgraph-python's Next routes read this env var, defaulting
# to localhost:8123 if unset — but we set it explicitly for clarity)
# Export the correct agent URL env var for Next.js to read.
if [ "$AGENT_TYPE" = "langgraph" ]; then
export LANGGRAPH_DEPLOYMENT_URL="http://localhost:${AGENT_PORT}"
else
export AGENT_URL="http://localhost:${AGENT_PORT}"
fi
export OPENAI_BASE_URL=http://localhost:4010/v1
export OPENAI_API_KEY=sk-aimock-dev-ci-only
npx next dev --turbopack &
# Wait for dev server. `--max-time 2 --connect-timeout 1` caps each
# probe so a hung socket can't blow the loop budget.
for i in $(seq 1 30); do
curl -sf --max-time 2 --connect-timeout 1 http://localhost:3000 > /dev/null 2>&1 && break
sleep 2
done
curl -sf --max-time 2 --connect-timeout 1 http://localhost:3000 || { echo "Dev server failed to start"; exit 1; }
- name: Install Playwright
run: |
cd "showcase/integrations/${{ steps.slug.outputs.slug }}"
npx playwright install chromium --with-deps
- name: Re-probe aimock liveness
# aimock was readiness-checked once right after startup, but several
# steps (Python agent start, pnpm install, Next dev startup, Playwright
# install) may have run for multiple minutes since. If aimock died
# during any of that time, Playwright would silently run against real
# OpenAI because OPENAI_BASE_URL=http://localhost:4010/v1 still points
# at the (now dead) port — curl would refuse the connection, litellm
# would fall through to the default OpenAI endpoint, and the test
# would pass/fail on REAL traffic with REAL costs. Fail loud before
# Playwright runs.
run: |
if ! curl -sf --max-time 2 --connect-timeout 1 http://localhost:4010/__aimock/health > /dev/null 2>&1; then
echo "::error::aimock is no longer responding on :4010. Refusing to run Playwright against a dead aimock (would silently hit real OpenAI)."
exit 1
fi
- name: Run Playwright tests
run: |
SLUG="${{ steps.slug.outputs.slug }}"
cd "showcase/integrations/$SLUG"
BASE_URL=http://localhost:3000 npx playwright test --reporter=list
env:
CI: "true"
# Dead env — Next.js is already running from the "Start dev server"
# step above (which set these inline on that process). Env set here
# would only affect the `npx playwright test` process, which does not
# read OPENAI_BASE_URL / OPENAI_API_KEY. Leaving unset to avoid the
# false impression that these values flow to the running Next server.
- name: Re-check aimock liveness after Playwright
if: always()
# Defense-in-depth: aimock might have OOM'd DURING the Playwright run.
# If that happened, the test either silently used stale fixtures (no-op
# after aimock died if responses were cached) or fell through to real
# OpenAI. Fail the job loudly so a dead aimock cannot masquerade as a
# green run. Keeps the 4010-is-still-alive invariant symmetric with the
# pre-Playwright re-probe above.
run: |
if [ -n "${AIMOCK_PID:-}" ] && ! kill -0 "$AIMOCK_PID" 2>/dev/null; then
echo "::error::aimock process (PID $AIMOCK_PID) died during the Playwright run. Playwright results are untrusted — it may have hit real OpenAI or returned stale fixtures."
exit 1
fi
- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-${{ steps.slug.outputs.slug }}
path: showcase/integrations/${{ steps.slug.outputs.slug }}/playwright-report/
retention-days: 7
if-no-files-found: ignore
outputs:
slug: ${{ steps.slug.outputs.slug }}
# Post the final status as a PR comment. Separated into its own job so
# the write perms (pull-requests + issues) are scoped to JUST this job —
# the heavy test job above runs with `contents: read` only, so a compromised
# transitive dep in `pip install` on a PR-controlled requirements.txt
# cannot mutate PRs / issues with the workflow's token.
post-result:
needs: aimock-e2e
if: github.event_name == 'issue_comment' && always() && needs.aimock-e2e.result != 'skipped'
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
pull-requests: write
issues: write
steps:
- name: Post result to PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
# Pass dynamic values through env (NOT `${{ ... }}` interpolation into
# the script body). Even though the slug is whitelisted upstream, the
# env-var pattern is the defensive default: any future additions that
# aren't pre-validated cannot accidentally reach script text.
env:
SLUG: ${{ needs.aimock-e2e.outputs.slug }}
JOB_STATUS: ${{ needs.aimock-e2e.result }}
with:
script: |
const slug = process.env.SLUG || '(unknown)';
const jobStatus = process.env.JOB_STATUS;
const status = jobStatus === 'success' ? '✅' : '❌';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `${status} **Aimock E2E Tests** (\`${slug}\`): ${jobStatus}\n\n[View run](${runUrl})`
});
@@ -0,0 +1,86 @@
name: test / integration / docs
on:
pull_request:
branches: [main]
paths:
- "showcase/shell-docs/src/content/**"
- "showcase/shell-docs/model-allowlist.json"
- "scripts/validate-doc-model-names.ts"
- "scripts/doc-tests/**"
- ".github/workflows/test_integration-docs.yml"
push:
branches: [main]
paths:
- "showcase/shell-docs/src/content/**"
- "showcase/shell-docs/model-allowlist.json"
- "scripts/validate-doc-model-names.ts"
- "scripts/doc-tests/**"
- ".github/workflows/test_integration-docs.yml"
# Least-privilege by default. Individual jobs/steps can widen when needed.
permissions:
contents: read
jobs:
validate-model-names:
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 15
permissions:
contents: read
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
id-token: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm tsx scripts/validate-doc-model-names.ts
doc-tests:
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 15
needs: validate-model-names
permissions:
contents: read
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
id-token: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: pnpm
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.12"
- run: pnpm install --frozen-lockfile
- name: Start aimock
run: |
# aimock is pinned as a workspace dependency (@copilotkit/showcase-scripts)
# and installed from the frozen lockfile above — no ad-hoc `npm install -g`.
# The `llmock` bin is aimock's fixtures-based CLI (the package also ships an
# `aimock` bin, which is the newer config-only CLI that does NOT accept
# --fixtures). Invoke the workspace-installed bin directly from the repo
# root so the root-relative --fixtures path resolves correctly (a
# `pnpm --filter exec` would run inside showcase/scripts and break the path).
nohup ./showcase/scripts/node_modules/.bin/llmock --fixtures scripts/doc-tests/fixtures --validate-on-load > /tmp/aimock.log 2>&1 &
for i in $(seq 1 60); do
if curl -sf http://localhost:4010/health; then
echo "aimock ready"
exit 0
fi
sleep 1
done
echo "aimock failed to start. Logs:"
cat /tmp/aimock.log
exit 1
- run: pnpm tsx scripts/doc-tests/extract.ts
- run: pnpm tsx scripts/doc-tests/run.ts
@@ -0,0 +1,118 @@
name: test / integration / runtime
on:
push:
branches: [main]
paths:
- "packages/runtime/**"
- ".github/workflows/test_integration-runtime.yml"
pull_request:
branches: [main]
paths:
- "packages/runtime/**"
- ".github/workflows/test_integration-runtime.yml"
workflow_dispatch:
inputs:
branch:
description: "Branch to run the workflow on"
required: true
default: "main"
type: string
# Least-privilege by default. Individual jobs/steps can widen when needed.
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
node:
name: "runtime / node"
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 15
permissions:
contents: read
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
id-token: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event.inputs.branch || github.ref }}
persist-credentials: false
- name: Setup pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Use Node.js 22.x
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.x"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Configure Nx Cloud environment
run: |
echo "NX_CI_EXECUTION_ID=${{ github.run_id }}-${{ github.run_attempt }}-integration-node" >> $GITHUB_ENV
echo "NX_CLOUD_NO_TIMEOUTS=true" >> $GITHUB_ENV
echo "NX_CLOUD_DISTRIBUTED_EXECUTION=false" >> $GITHUB_ENV
echo "NX_NO_CLOUD=true" >> $GITHUB_ENV
- name: Build runtime
run: pnpm nx run @copilotkit/runtime:build
- name: Run Node.js server integration tests
working-directory: packages/runtime
run: pnpm exec vitest run src/v2/runtime/__tests__/integration/node-servers.integration.test.ts
bun:
name: "runtime / bun"
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 15
permissions:
contents: read
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
id-token: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event.inputs.branch || github.ref }}
persist-credentials: false
- name: Setup pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Use Node.js 22.x
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.x"
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Configure Nx Cloud environment
run: |
echo "NX_CI_EXECUTION_ID=${{ github.run_id }}-${{ github.run_attempt }}-integration-bun" >> $GITHUB_ENV
echo "NX_CLOUD_NO_TIMEOUTS=true" >> $GITHUB_ENV
echo "NX_CLOUD_DISTRIBUTED_EXECUTION=false" >> $GITHUB_ENV
echo "NX_NO_CLOUD=true" >> $GITHUB_ENV
- name: Build runtime
run: pnpm nx run @copilotkit/runtime:build
- name: Run Bun server integration tests
working-directory: packages/runtime
run: bun test src/v2/runtime/__tests__/integration/bun/bun-servers.integration.test.ts
+195
View File
@@ -0,0 +1,195 @@
name: test / smoke / starter
# PR build-sanity gate (model B, §e / Phase 5) PLUS the post-merge 6h
# floating-dependency breakage detector. The eventual live dashboard signal
# for starter health comes from the harness HTTP-probing the deployed
# (sleepable) Railway starter services — but those S5 Railway services do
# NOT exist yet, so this `schedule` cron remains the ONLY post-merge
# detector that catches a starter broken by a floating transitive dependency
# or an upstream CopilotKit release. We KEEP the 6h cron (and the harness
# alert path, alerts/alert-engine.ts → #oss-alerts, carries the
# scheduled-failure Slack signal too) until S5 starter-service probing is
# confirmed live, at which point the cron can be retired in favour of the
# harness signal. The fast pre-merge build-sanity gate is the
# `pull_request` trigger on examples/integrations/**: it builds + smoke-
# tests each starter offline against aimock on every starter change. The
# post-merge GHCR `:latest` publish that feeds the Railway services lives in
# showcase_build.yml's build-starters job (push:main, §b stage 1), so it is
# intentionally NOT duplicated here.
on:
schedule:
# Every 6h — post-merge floating-dependency / upstream-release breakage
# detector. Stays until S5 harness probing of live Railway starter
# services is confirmed, then retire in favour of the harness signal.
- cron: "0 */6 * * *"
workflow_run:
workflows: ["publish / release"]
types: [completed]
pull_request:
paths:
- "examples/integrations/**"
- ".github/workflows/test_smoke-starter.yml"
workflow_dispatch: {}
permissions:
contents: read
packages: read
jobs:
smoke-starter:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
strategy:
matrix:
starter:
- langgraph-python
- mastra
- langgraph-js
- crewai-crews
- pydantic-ai
- adk
- agno
- llamaindex
- langgraph-fastapi
- strands-python
- ms-agent-framework-python
- ms-agent-framework-dotnet
fail-fast: false
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 1
lfs: false
persist-credentials: false
- name: Run starter smoke tests
working-directory: examples/integrations/${{ matrix.starter }}
env:
STARTER: ${{ matrix.starter }}
# Starters whose SDK can't be intercepted by aimock (currently
# google-adk — google-genai ignores endpoint overrides) need a
# real provider key. docker-compose.test.yml for those starters
# reads this via `${GOOGLE_API_KEY:-test-key-for-aimock}` so
# unaffected starters still run offline against aimock.
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
run: |
docker compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from tests
- name: Capture failure cause
if: failure()
id: failure-cause
working-directory: examples/integrations/${{ matrix.starter }}
env:
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_USER_LOGIN: ${{ github.event.pull_request.user.login }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
{
echo "summary<<CAUSE_EOF"
# Capture error from container logs (containers still running at this point)
build_err=$(docker compose -f docker-compose.test.yml logs app 2>&1 | grep -E "ERR_|Error:|error:|Build error|failed to" | head -3)
agent_err=$(docker compose -f docker-compose.test.yml logs agent 2>&1 | grep -E "Error:|Traceback|ModuleNotFoundError|ImportError" | head -3)
test_err=$(docker compose -f docker-compose.test.yml logs tests 2>&1 | grep -E "✘|FAIL|Error:|expect\(" | head -5)
if [ -n "$build_err" ]; then
echo "Build failure:"
echo "$build_err"
elif [ -n "$agent_err" ]; then
echo "Agent crash:"
echo "$agent_err"
elif [ -n "$test_err" ]; then
echo "Test failure:"
echo "$test_err"
else
echo "Unknown failure — check run logs"
fi
echo ""
# Identify recent changes that may have caused the failure
if [ "$EVENT_NAME" = "pull_request" ]; then
echo "Triggered by PR #${PR_NUMBER} (${PR_USER_LOGIN}): ${PR_TITLE}"
echo "Head: ${PR_HEAD_SHA}"
elif [ "$EVENT_NAME" = "schedule" ] || [ "$EVENT_NAME" = "workflow_run" ]; then
# Use GitHub API instead of git log (avoids needing deep clone)
echo "Recent commits touching this starter (last 12h):"
gh api "repos/${{ github.repository }}/commits?path=examples/integrations/${{ matrix.starter }}&since=$(date -u -d '12 hours ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-12H +%Y-%m-%dT%H:%M:%SZ)&per_page=5" \
--jq '.[] | "\(.sha[0:7]) \(.commit.message | split("\n")[0])"' 2>/dev/null || true
starter_commits=$(gh api "repos/${{ github.repository }}/commits?path=examples/integrations/${{ matrix.starter }}&since=$(date -u -d '12 hours ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-12H +%Y-%m-%dT%H:%M:%SZ)&per_page=1" --jq 'length' 2>/dev/null || echo "0")
if [ "$starter_commits" = "0" ]; then
echo "No starter code changes — likely a floating dependency update or upstream CopilotKit release"
echo "Recent releases:"
gh api "repos/${{ github.repository }}/releases?per_page=3" \
--jq '.[] | "\(.tag_name) (\(.published_at[0:10]))"' 2>/dev/null | head -3 || true
fi
fi
echo "CAUSE_EOF"
} >> "$GITHUB_OUTPUT"
- name: Tear down
if: always()
working-directory: examples/integrations/${{ matrix.starter }}
run: docker compose -f docker-compose.test.yml down -v
- name: Upload test artifacts on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: smoke-starter-${{ matrix.starter }}
path: showcase/tests/test-results/
retention-days: 7
# Sanitize the failure summary and stash it in $GITHUB_ENV so the
# inline Slack payload can reference it via `env.summary`. We don't
# write to a payload file because slackapi/slack-github-action@v2.1.0
# rejects payload files that don't end in `.json`/`.yaml`/`.yml`
# (mktemp produces extensionless files), and `jq --rawfile` can also
# crash under `set -e` on edge-case inputs, leaving an empty/missing
# payload and silently suppressing the alert. Inline `payload:` with
# `toJSON(format(...))` sidesteps both failure modes — quotes,
# backslashes, newlines in the summary are safely JSON-encoded by
# toJSON, and there's no intermediate file to mishandle. This is the
# same pattern used in test_smoke-starter-deployed.yml (PR #4068) and
# showcase_validate.yml.
- name: Prepare Slack alert fields
if: failure() && env.SLACK_WEBHOOK != '' && (github.event_name == 'schedule' || github.event_name == 'workflow_run')
env:
SUMMARY_RAW: ${{ steps.failure-cause.outputs.summary }}
run: |
# Strip ANSI sequences (SGR, OSC, and G0/G1 charset designators), then truncate
# to 200 bytes and drop any trailing partial UTF-8 bytes so we don't emit mojibake.
SUMMARY=$(printf '%s' "$SUMMARY_RAW" | head -3 \
| sed -E 's/\x1b\[[0-9;?]*[A-Za-z]//g; s/\x1b\][^\x07]*\x07//g; s/\x1b[()][A-Za-z0-9]//g' \
| head -c 200 | iconv -f UTF-8 -t UTF-8//IGNORE)
if [ -z "$SUMMARY" ]; then
SUMMARY="(no failure detail captured — see job log)"
fi
# Emit via heredoc so embedded `=`, quotes, or newlines don't
# break KEY=VALUE parsing in $GITHUB_ENV.
{
echo "summary<<EOF_SUMMARY_7a4c"
printf '%s\n' "$SUMMARY"
echo "EOF_SUMMARY_7a4c"
} >> "$GITHUB_ENV"
- name: Alert Slack on failure
if: failure() && env.SLACK_WEBHOOK != '' && (github.event_name == 'schedule' || github.event_name == 'workflow_run')
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
webhook-type: incoming-webhook
# NOTE: `\n` is NOT an escape sequence in GitHub Actions expression
# string literals — `format()` would emit the two literal characters
# backslash+n, which `toJSON` then encodes as `\\n`, so Slack renders
# a literal "\n" instead of a line break. Inject real newlines via
# `fromJSON('"\n"')` ({6}) so `toJSON` encodes them as a single `\n`
# that Slack honors. The code-fence delimiters sit on their own lines
# so the summary renders as a proper code block.
payload: |
{ "text": ${{ toJSON(format(':x: `[ci:{2}]` *Starter smoke test failing: {0}*{6}<{1}/{2}/actions/runs/{3}|View run> · <{1}/{2}/actions/runs/{3}/job/{4}|View job>{6}```{6}{5}{6}```', matrix.starter, github.server_url, github.repository, github.run_id, github.job, env.summary, fromJSON('"\n"'))) }} }
@@ -0,0 +1,58 @@
name: test / unit / python-sdk
on:
push:
branches: [main]
paths:
- "sdk-python/**"
- ".github/workflows/test_unit-python-sdk.yml"
pull_request:
branches: [main]
paths:
- "sdk-python/**"
- ".github/workflows/test_unit-python-sdk.yml"
# Least-privilege by default. Individual jobs/steps can widen when needed.
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 10
permissions:
contents: read
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
id-token: write
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ matrix.python-version }}
- name: Install Poetry
uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2
with:
version: latest
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install dependencies
working-directory: sdk-python
run: poetry lock && poetry install --with dev
- name: Run tests
working-directory: sdk-python
run: poetry run python -m pytest tests/ -v
+127
View File
@@ -0,0 +1,127 @@
name: test / unit / spring-ai
# Unit tests for the spring-ai showcase integration.
#
# The Dockerfile uses `-DskipTests` and intentionally omits `src/test/`
# (production image stays lean), so wire-shape contracts authored in
# `src/test/java` would otherwise never execute in CI. This workflow gives
# them a home: gated on the same `spring_ai` paths-filter as the rest of the
# showcase pipeline, runs `mvn -pl spring-ai test` after building the AG-UI
# community artifacts from source (same install dance the Dockerfile does).
#
# The critical contract under test is
# RunErrorEventWireShapeTest — a @SpringBootTest that asserts the application
# ObjectMapper actually applies JacksonConfig's customizer (catches the
# AG-UI AgUiAutoConfiguration bare-ObjectMapper bean-race that previously
# made the wire-shape rename inert in production).
on:
pull_request:
paths:
- "showcase/integrations/spring-ai/**"
- ".github/workflows/test_unit-spring-ai.yml"
push:
branches: [main]
paths:
- "showcase/integrations/spring-ai/**"
- ".github/workflows/test_unit-spring-ai.yml"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
spring-ai-unit:
name: "spring-ai unit"
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Set up JDK 21 (matches Dockerfile)
uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
distribution: temurin
java-version: "21"
# NOTE: do NOT use `cache: maven` here — setup-java's implicit cache
# keys on hashFiles('**/pom.xml') only, which is BLIND to AG_UI_SHA
# bumps. The explicit actions/cache step below keys on AG_UI_SHA so
# the cache invalidates whenever the pinned SHA changes.
- name: Load AG_UI_SHA from .ag-ui-sha
# Single source of truth for the AG-UI commit shared with the
# Dockerfile (`showcase/integrations/spring-ai/.ag-ui-sha`). Keeps CI
# and the production image building against the same snapshot.
run: |
set -euo pipefail
AG_UI_SHA="$(tr -d '[:space:]' < showcase/integrations/spring-ai/.ag-ui-sha)"
if [ -z "${AG_UI_SHA}" ]; then
echo "::error::.ag-ui-sha is empty"; exit 1
fi
echo "AG_UI_SHA=${AG_UI_SHA}" >> "${GITHUB_ENV}"
- name: Cache Maven repository (keyed on AG_UI_SHA)
# Explicit cache keyed on AG_UI_SHA so SHA bumps invalidate the cache
# — otherwise CI would silently reuse stale AG-UI community artifacts
# installed under a prior SHA (R3-A1).
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-agui-${{ env.AG_UI_SHA }}-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-agui-${{ env.AG_UI_SHA }}-
${{ runner.os }}-maven-
- name: Install AG-UI Java SDK from source
# The spring-ai showcase depends on AG-UI community Maven artifacts
# (com.ag-ui.community:spring-ai / java-server / spring) that aren't
# on Maven Central yet. The Dockerfile installs them by cloning the
# upstream repo and running `mvn install`; do the same here so the
# showcase's own `mvn test` can resolve them from the local repo.
env:
GIT_LFS_SKIP_SMUDGE: "1"
run: |
set -euo pipefail
# Belt-and-suspenders: evict any stale AG-UI artifacts that a
# restore-keys cache hit might have repopulated. The cache key
# above pins to AG_UI_SHA, but a `restore-keys` fallback can
# still surface older AG-UI jars on a fresh SHA. Force a clean
# install from this SHA.
rm -rf \
~/.m2/repository/com/ag-ui \
~/.m2/repository/io/github/ag-ui-protocol
rm -rf /tmp/ag-ui
# `git clone` only fetches the default branch by default — if
# AG_UI_SHA points at a commit on a PR branch or older history
# not reachable from HEAD, the subsequent `checkout` fails with
# a cryptic "reference is not a tree". Explicitly fetch the SHA
# first so checkout is always against a known-local commit
# (R7-A2). `--depth 1` keeps the cache lean.
git clone --no-checkout https://github.com/ag-ui-protocol/ag-ui.git /tmp/ag-ui
git -C /tmp/ag-ui fetch --depth 1 origin "${AG_UI_SHA}"
git -C /tmp/ag-ui checkout "${AG_UI_SHA}"
cd /tmp/ag-ui/sdks/community/java
mvn install \
-pl servers/spring,integrations/spring-ai -am \
-DskipTests -Dgpg.skip=true \
-Dmaven.javadoc.skip=true -Djavadoc.skip=true \
-Dmaven.source.skip=true -Dcheckstyle.skip=true \
-Dmaven.site.skip=true -Dreporting.skip=true \
-Dassembly.skipAssembly=true \
-B
- name: Run unit tests
working-directory: showcase/integrations/spring-ai
# Belt-and-suspenders: @TestPropertySource in the suite supplies a
# placeholder key, but some Spring auto-configuration paths read the
# env var directly on context init. A literal non-secret string keeps
# CI deterministic without leaking real credentials.
env:
OPENAI_API_KEY: test-key-not-used
run: mvn -B test
+181
View File
@@ -0,0 +1,181 @@
name: test / unit
on:
push:
branches: [main]
paths-ignore:
- "README.md"
- "examples/**"
- "showcase/**"
- "sdk-python/**"
pull_request:
branches: [main]
paths-ignore:
- "README.md"
- "examples/**"
- "showcase/**"
- "sdk-python/**"
workflow_dispatch:
inputs:
branch:
description: "Branch to run the workflow on"
required: true
default: "main"
type: string
env:
NODE_OPTIONS: "--max-old-space-size=4096"
NX_VERBOSE_LOGGING: true
NX_CI_EXECUTION_ID: ${{ github.head_ref }}-${{ github.sha }}-${{ github.run_attempt }}
NX_CI_EXECUTION_ENV: "Unit Tests"
# Least-privilege by default. Individual jobs/steps can widen when needed.
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
permissions:
contents: read
id-token: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
unit:
name: "unit"
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
node-version: [20.x, 22.x, 24.x]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event.inputs.branch || github.ref }}
persist-credentials: false
# Full history so `nx affected` can diff HEAD against the PR base /
# the previous push, instead of rebuilding+retesting every package
# on every run. A shallow clone has no merge-base to diff against.
fetch-depth: 0
- name: Setup pnpm
# Omit `version:` so pnpm/action-setup inherits from the repo's
# `packageManager` field in package.json (via corepack).
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node-version }}
# Do NOT use cache: "pnpm" here — its key omits the Node.js version,
# so a better-sqlite3 binary compiled for one Node ABI (e.g. ABI 137
# from Node 24) would be served to a job running a different ABI
# (Node 20 = ABI 115, Node 22 = ABI 127), causing "Module did not
# self-register". We handle pnpm caching manually below with the
# node-version in the key.
# Fork-safety note: actions/cache is equally fork-safe — GitHub
# prevents fork PRs from writing to the base repo's cache at the platform level.
- name: Get pnpm store directory
id: pnpm-cache
run: echo "store-path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
- name: Cache pnpm store (scoped to Node.js version)
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.pnpm-cache.outputs.store-path }}
key: ${{ runner.os }}-pnpm-store-${{ matrix.node-version }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-${{ matrix.node-version }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Configure Nx Cloud environment
run: |
echo "NX_CI_EXECUTION_ID=${{ github.run_id }}-${{ github.run_attempt }}-unit-v1-${{ matrix.node-version }}" >> $GITHUB_ENV
echo "NX_CLOUD_NO_TIMEOUTS=true" >> $GITHUB_ENV
echo "NX_CLOUD_DISTRIBUTED_EXECUTION=false" >> $GITHUB_ENV
echo "NX_NO_CLOUD=true" >> $GITHUB_ENV
echo "NX_TUI=false" >> $GITHUB_ENV
- name: Determine affected range
# Pass GitHub context through env (not inline ${{ }} in the script) to
# avoid template-injection — base_ref is attacker-influenceable.
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
BEFORE_SHA: ${{ github.event.before }}
run: |
if [ "$EVENT_NAME" = "pull_request" ]; then
# Diff against the merge-base with the (current tip of the) base
# branch so advances on main don't drag unrelated packages in.
git fetch --no-tags origin "$BASE_REF"
BASE=$(git merge-base FETCH_HEAD HEAD)
elif [ "$EVENT_NAME" = "push" ]; then
# BEFORE_SHA (github.event.before) is the previous tip of this branch.
BASE="$BEFORE_SHA"
if [ -z "$BASE" ] \
|| [ "$BASE" = "0000000000000000000000000000000000000000" ] \
|| ! git cat-file -e "${BASE}^{commit}" 2>/dev/null; then
# First push / force-push / unknown parent → previous commit.
BASE=$(git rev-parse HEAD~1 2>/dev/null || git rev-parse HEAD)
fi
fi
echo "NX_BASE=${BASE}" >> "$GITHUB_ENV"
echo "NX_HEAD=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
echo "Affected range: ${BASE:-<full>}...$(git rev-parse HEAD)"
- name: Generate GraphQL codegen files
run: npx nx run @copilotkit/runtime-client-gql:graphql-codegen
- name: Select test projects
id: select
# PR/push → only packages affected since the base. workflow_dispatch
# (manual / nightly-style full run) → every package with tests.
# `--projects` scopes to packages/** in `nx show projects` (it does NOT
# in the `nx affected` run form, which also pulls in downstream
# examples/storybook — hence the show-projects → run-many split).
env:
EVENT_NAME: ${{ github.event_name }}
# The workflow sets NX_VERBOSE_LOGGING=true, which makes `nx show
# projects` print "[isolated-plugin] spawned worker…" to stdout and
# corrupt the --json payload we parse below. Force it off here.
NX_VERBOSE_LOGGING: "false"
run: |
# Editing this workflow can't surface as an "affected" nx package, so
# `nx affected` would select nothing and the build/test path would go
# unexercised on the very PR that changes it. Force a full run when
# this file itself changed in the range, same as a manual dispatch.
FULL=false
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
FULL=true
elif git diff --name-only "$NX_BASE" "$NX_HEAD" \
| grep -qx '.github/workflows/test_unit.yml'; then
FULL=true
echo "test_unit.yml changed in range → running ALL packages."
fi
if [ "$FULL" = "true" ]; then
PROJECTS=$(npx nx show projects --projects='packages/**' --exclude=@copilotkit/demo-agents -t test --json)
else
PROJECTS=$(npx nx show projects --affected --base="$NX_BASE" --head="$NX_HEAD" --projects='packages/**' --exclude=@copilotkit/demo-agents -t test --json)
fi
LIST=$(printf '%s' "$PROJECTS" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>process.stdout.write(JSON.parse(d).join(',')))")
echo "projects=$LIST" >> "$GITHUB_OUTPUT"
if [ -n "$LIST" ]; then echo "has=true" >> "$GITHUB_OUTPUT"; else echo "has=false" >> "$GITHUB_OUTPUT"; fi
echo "Selected projects: ${LIST:-<none>}"
- name: Build and test affected packages
if: steps.select.outputs.has == 'true'
# run-many builds each selected package's upstream deps via `^build`,
# so unchanged dependencies are still compiled when something needs them.
env:
PROJECTS: ${{ steps.select.outputs.projects }}
run: npx nx run-many -t build,test --projects="$PROJECTS" --exclude=@copilotkit/demo-agents
- name: No affected packages
if: steps.select.outputs.has != 'true'
run: echo "No package code affected since the base — skipping build & test."
- name: Run release script tests
run: npx vitest run --config scripts/release/vitest.config.mts
+43
View File
@@ -0,0 +1,43 @@
name: Update PR branch
on:
pull_request_target:
types: [labeled]
permissions:
contents: write
pull-requests: write
jobs:
update-branch:
if: github.event.label.name == 'qa:update-branch'
runs-on: ubuntu-latest
steps:
- name: Update PR branch with base
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const { owner, repo } = context.repo;
const pull_number = context.payload.pull_request.number;
try {
await github.rest.pulls.updateBranch({ owner, repo, pull_number });
core.info(`Updated branch for PR #${pull_number}`);
} catch (error) {
if (error.status === 422) {
core.info(`Branch already up to date or cannot be updated: ${error.message}`);
} else {
core.setFailed(`Failed to update branch: ${error.message}`);
}
} finally {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: pull_number,
name: 'qa:update-branch',
});
} catch (error) {
core.warning(`Could not remove label: ${error.message}`);
}
}
+79
View File
@@ -0,0 +1,79 @@
# zizmor configuration — static analysis for GitHub Actions workflows.
#
# Docs: https://docs.zizmor.sh/configuration/
#
# This file lives at `.github/zizmor.yml` because zizmor auto-discovers
# it there. The companion workflow at `.github/workflows/security_zizmor.yml`
# runs zizmor in CI and fails the build on findings at the configured level.
#
# When a finding genuinely cannot be remediated (e.g. the action behavior
# is correct but tripped a check), add it under `rules:` with a clear
# justification comment — never blanket-suppress.
rules:
# `template-injection` flags ${{ }} expansions that put untrusted input
# (issue titles, PR titles, branch names, comment bodies) directly into
# `run:` scripts. The fix is always to route through `env:` and reference
# the env var inside the shell. We treat any new occurrence as a blocker.
template-injection:
ignore: []
# `dangerous-triggers` flags `pull_request_target` and similar triggers
# that run with secrets against fork-controlled refs. Anything new must
# be reviewed by a maintainer.
#
# Suppressions below were each reviewed by a maintainer; new additions
# must come with a comment explaining why the workflow is safe.
dangerous-triggers:
ignore:
# update-branch.yml: pull_request_target gated on `types: [labeled]`
# with a maintainer-applied "update-branch" label. The workflow uses
# the base-repo checkout (not the PR head), so no fork-controlled
# code runs with the elevated token. This is the documented safe
# pattern for label-gated automation.
- update-branch.yml
# showcase_deploy.yml / showcase_capture-previews.yml: workflow_run
# triggered by the in-repo "Showcase: Build & Push" workflow. Only
# main-branch builds fire workflow_run, and the downstream workflows
# never check out PR-head code — they checkout the same trusted
# main-branch ref or use the head_branch of the triggering run
# (which is also main per the upstream filter).
- showcase_deploy.yml
- showcase_capture-previews.yml
# test_smoke-starter.yml: workflow_run triggered by the in-repo
# "publish / release" workflow. Listens to release completions; no
# fork-controlled refs reach this workflow.
- test_smoke-starter.yml
# `unpinned-uses` is satisfied because every `uses:` is SHA-pinned and
# Renovate keeps them current (see renovate.json → local>CopilotKit/renovate).
# `excessive-permissions` flags jobs that grant token scopes they don't
# need. We surface but don't auto-suppress — fixing requires per-job
# `permissions:` blocks that match the actual API calls in the job.
excessive-permissions:
ignore: []
# `artipacked` flags `actions/checkout` calls that leave the default
# GITHUB_TOKEN persisted in the local git config. Every other checkout
# in the repo sets `persist-credentials: false`. The suppressions below
# are the workflows that legitimately need the persisted token to push
# back to the repo (release tagging, docs sync, PR comment from
# pkg-pr-new). Each call site carries a `persist-credentials required:`
# comment immediately above it.
#
# static_quality.yml was removed: its format job now uses
# persist-credentials: false and injects credentials via insteadOf
# only before the push step.
artipacked:
ignore:
# Release workflows that push tags / branches via GITHUB_TOKEN.
# publish-release.yml is intentionally NOT suppressed: its build job
# now uses persist-credentials: false (the artifact it uploads would
# otherwise leak a workflow-scoped token through .git/config) and the
# publish job downloads the artifact and sets up its own git auth via
# `git config insteadOf`.
- prerelease.yml
# pkg-pr-new posts snapshot comments on the PR using the repo token
# — credentials must remain persisted for the action to authenticate.
- publish-commit.yml
+86
View File
@@ -0,0 +1,86 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.vscode
.chalk
.claude/*.local.json
.claude/worktrees
# misc
.DS_Store
*.pem
__snapshots__
@generated
node_modules
.pnpm-store
.idea/
cdk_outputs.json
.nx
test-run-comment.md
superpowers/
*.egg-info/
.installed.cfg
*.egg
**/.langgraph_api
*.sqlite
*.sqlite-shm
*.sqlite-wal
__pycache__/
*.env
.doctest-output/
*.next
.venv
.vercel
# Host-specific deploy config — lives in your platform's dashboard, not the repo.
# (e.g. a local `railway up` upload filter generated when deploying from a worktree)
.railwayignore
docs/next-env.d.ts
showcase/shell-docs/next-env.d.ts
# Showcase: staged shared contexts for Docker builds (see showcase/scripts/dev-local.sh)
showcase/integrations/*/shared_python/
showcase/integrations/*/shared_typescript/
# External repos (cloned for development)
ext-apps/
dist
.nx
.angular
build
debug-storybook.log
storybook-static
coverage
output/
packages/angular/src/styles/generated.css
.turbo
.langgraph_api
lefthook-local.yml
.husky/
.langgraph_api
# Private agent instructions (not shared with the team)
private-agents.md
# Release artifacts
release-notes.md
release-notes-notion.json
# Binary artifacts
*.dSYM/
*.exe
*.dll
*.so
*.dylib
*.o
*.obj
*.a
*.lib
*.wasm
tasks/
# Transient session artifacts
.playwright-mcp/
.claude/scheduled_tasks.lock
+18
View File
@@ -0,0 +1,18 @@
# .kodiak.toml
version = 1
[merge]
automerge_label = "automerge"
require_automerge_label = false
method = "squash"
delete_branch_on_merge = true
optimistic_updates = true
prioritize_ready_to_merge = true
notify_on_conflict = false
[merge.message]
title = "pull_request_title"
body = "pull_request_body"
include_pr_number = true
body_type = "markdown"
strip_html_comments = true
+13
View File
@@ -0,0 +1,13 @@
{
"mcpServers": {
"nx-mcp": {
"type": "stdio",
"command": "npx",
"args": ["nx", "mcp"]
},
"copilotkit-docs": {
"type": "http",
"url": "https://mcp.copilotkit.ai/mcp"
}
}
}
+13
View File
@@ -0,0 +1,13 @@
minimum-release-age=1440
minimum-release-age-exclude[]=@ag-ui/core
minimum-release-age-exclude[]=@ag-ui/client
minimum-release-age-exclude[]=@ag-ui/encoder
minimum-release-age-exclude[]=@ag-ui/proto
minimum-release-age-exclude[]=@ag-ui/langgraph
minimum-release-age-exclude[]=@ag-ui/a2ui-middleware
minimum-release-age-exclude[]=@ag-ui/a2ui-toolkit
minimum-release-age-exclude[]=@copilotkit/channels
minimum-release-age-exclude[]=@copilotkit/channels-slack
minimum-release-age-exclude[]=@copilotkit/channels-ui
minimum-release-age-exclude[]=@copilotkit/license-verifier
block-exotic-subdeps=true
+1
View File
@@ -0,0 +1 @@
22
+18
View File
@@ -0,0 +1,18 @@
{
"printWidth": 80,
"proseWrap": "preserve",
"ignorePatterns": [
"docs/**",
"showcase/shell/src/content/**",
"showcase/shell-docs/src/content/**",
"packages/runtime/src/lib/runtime/mcp-tools-utils.ts",
".github/actions/changesets-action/src/run.ts",
"examples/v1/_legacy/copilot-anthropic-pinecone/src/app/api/copilotkit/route.ts",
"examples/v1/_legacy/copilot-openai-mongodb-atlas-vector-search/src/app/api/copilotkit/route.ts",
"packages/web-inspector/src/styles/generated.css",
"showcase/aimock/shared/**",
"showcase/aimock/d4/**",
"showcase/aimock/d6/**",
"**/package.json"
]
}
+110
View File
@@ -0,0 +1,110 @@
{
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxlint-json-schema/refs/heads/main/.oxlintrc.json",
"plugins": ["typescript", "unicorn", "oxc", "react", "nextjs", "import"],
"jsPlugins": ["./packages/react-ui/oxlint-rules/copilotkit-plugin.mjs"],
"categories": {
"correctness": "warn",
"suspicious": "warn"
},
"rules": {
"react/react-in-jsx-scope": "off",
"import/no-unassigned-import": "off",
"unicorn/no-array-sort": "off",
"copilotkit/require-cpk-prefix": "off",
"no-restricted-imports": "off",
"typescript/consistent-type-imports": [
"warn",
{ "prefer": "type-imports", "fixStyle": "separate-type-imports" }
],
"typescript/no-import-type-side-effects": "warn",
"import/consistent-type-specifier-style": ["warn", "prefer-top-level"],
"typescript/no-unnecessary-type-assertion": "error",
"react/self-closing-comp": "warn",
"unicorn/prefer-optional-catch-binding": "warn",
"eslint/no-useless-computed-key": "warn",
"unicorn/prefer-string-slice": "warn",
"unicorn/prefer-array-flat-map": "warn"
},
"overrides": [
{
"files": ["packages/**/*.{ts,tsx}"],
"rules": {
"copilotkit/no-single-arg-zod-record": "error"
}
},
{
"files": ["packages/**/*.{ts,tsx}", "examples/**/*.{ts,tsx}"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@a2ui/lit",
"message": "Do not use value imports from '@a2ui/lit' — it registers custom elements as a side effect, which breaks React Strict Mode. Use `import type` for types, or import from '@a2ui/lit/0.8' for value access (core only, no UI side effects).",
"allowTypeImports": true
}
]
}
]
}
},
{
"files": ["packages/react-ui/src/**/*.{ts,tsx}"],
"rules": {
"copilotkit/require-cpk-prefix": "warn"
}
},
{
"files": ["**/__tests__/**", "**/*.test.*", "**/*.spec.*"],
"rules": {
"copilotkit/require-cpk-prefix": "off"
}
},
{
"files": [
"packages/angular/**/*.ts",
"packages/runtime/src/graphql/**/*.ts"
],
"rules": {
"typescript/consistent-type-imports": "off",
"typescript/no-import-type-side-effects": "off",
"import/consistent-type-specifier-style": "off"
}
},
{
"files": [
"showcase/shell-dashboard/src/**/*.{ts,tsx}",
"showcase/shell-docs/src/**/*.{ts,tsx}",
"showcase/shell/src/**/*.{ts,tsx}",
"showcase/shell-dojo/src/**/*.{ts,tsx}"
],
"rules": {
"copilotkit/no-public-env-shell-read": "error"
}
},
{
"files": [
"showcase/shell-docs/src/content/**",
"showcase/**/lib/runtime-config*.{ts,tsx}",
"showcase/**/*.test.{ts,tsx}",
"showcase/**/*.spec.{ts,tsx}"
],
"rules": {
"copilotkit/no-public-env-shell-read": "off"
}
}
],
"ignorePatterns": [
"dist/**",
"node_modules/**",
".next/**",
".nuxt/**",
"coverage/**",
"storybook-static/**",
"**/@generated/**",
"docs/**"
]
}
+39
View File
@@ -0,0 +1,39 @@
const path = require("path");
const fs = require("fs");
/**
* When A2UI_LOCAL=1, rewrites @a2ui/* dependencies to link against
* locally-built packages from the sibling A2UI repo.
*
* Usage:
* A2UI_LOCAL=1 pnpm install # link to local A2UI packages
* pnpm install # install from npm (default)
*
* Expects:
* /some/path/CopilotKit/ (this repo)
* /some/path/A2UI/ (sibling A2UI repo)
*/
const A2UI_ROOT = path.resolve(__dirname, "..", "A2UI");
const A2UI_PACKAGES = {
"@a2ui/web_core": "renderers/web_core",
"@a2ui/react": "renderers/react",
};
function readPackage(pkg) {
if (!process.env.A2UI_LOCAL) return pkg;
for (const [dep, relPath] of Object.entries(A2UI_PACKAGES)) {
if (pkg.dependencies && pkg.dependencies[dep]) {
const localPath = path.join(A2UI_ROOT, relPath);
if (fs.existsSync(localPath)) {
pkg.dependencies[dep] = `link:${localPath}`;
}
}
}
return pkg;
}
module.exports = { hooks: { readPackage } };
+4
View File
@@ -0,0 +1,4 @@
{
"setup": ["pnpm install"],
"teardown": []
}
+70
View File
@@ -0,0 +1,70 @@
<!-- nx configuration start-->
<!-- Leave the start & end comments to automatically receive updates. -->
# General Guidelines for working with Nx
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
- You have access to the Nx MCP server and its tools, use them to help the user
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
- For Nx plugin best practices, check `node_modules/@nx/<plugin>/PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable.
<!-- nx configuration end-->
# CopilotKit
AI agent framework with three layers: **Frontend** (React/Angular/Vanilla) → **Runtime** (Express/Hono) → **Agent** (LangGraph/CrewAI/BuiltIn/Custom), communicating via the AG-UI protocol (event-based SSE).
## Essentials
- **Nx monorepo** — always run tasks through `nx` (`nx run`, `nx run-many`, `nx affected`), never the underlying tooling directly.
- **Flat package structure** — All packages live under `packages/` with the `@copilotkit/` scope. Some packages have `v1/` and `v2/` internal directories for backward compatibility, but they're a single published package.
- **Simplicity** — prefer the simplest correct solution. For non-trivial changes, consider if there's a cleaner approach before committing.
- **Worktrees** — always work in a git worktree for isolation. See [Git & PRs](.claude/docs/git.md) for the full workflow.
- **Commit as you go** — every meaningful unit of work gets its own commit, pushed immediately. Don't let untracked files accumulate across a session. Tests belong in the commit that introduces the code being tested. Full rules in [Git & PRs](.claude/docs/git.md#commit-early-and-often-in-logical-chunks).
- **Draft PR up front** — the moment a new branch has one commit, push it and open a **draft PR**. Don't wait until "ready" — unmerged-and-unpushed work is invisible. Flip the PR from draft to ready (`gh pr ready <pr#>`) only when the developer says so. See [Git & PRs](.claude/docs/git.md#open-a-draft-pr-up-front).
- **Documentation lives in shell-docs** — author CopilotKit docs in `showcase/shell-docs/src/content/`. The top-level `docs/` path is only a symlink to `showcase/shell-docs/`; never recreate the old `docs/content/docs/` tree for live documentation. AG-UI protocol docs are authored upstream in `ag-ui-protocol/ag-ui`, not directly in this repo. See [Documentation](.claude/docs/documentation.md).
## Private Agent Instructions
Individual developers may optionally create a `private-agents.md` file at the repo root. This file is gitignored and not shared with the team -- it contains personal agent instructions, workflow overrides, or context that applies only to that developer's work. If `private-agents.md` exists, read it and follow its instructions (they take precedence over the defaults in this file where they conflict).
## Internal Skills
The team maintains shared AI agent skills at [CopilotKit/internal-skills](https://github.com/CopilotKit/internal-skills). If installed as a Claude Code plugin, these skills are available automatically. Key skills relevant to this repo:
- **copilotkit-ui-theme** — CopilotCloud visual design system (colors, typography, glass effects, blur circles). Use when building any UI that should look like an official CopilotKit product.
- **copilotkit-branding** — Brand rules, logos, and visual identity guidelines.
- **copilotkit-dev-workflow** — Internal dev workflow conventions for this monorepo.
- **cr-loop** — Automated code review and fix loop.
If you need a skill and don't have the plugin installed, clone the repo and read the relevant `skills/<name>/SKILL.md` directly.
## Documentation Editing
Before editing anything that looks like product docs, read [Documentation](.claude/docs/documentation.md) and the local README for the docs area you are touching. The live docs source is **`showcase/shell-docs/`**; top-level `docs/` is only a symlink there.
- **CopilotKit product docs** live under `showcase/shell-docs/src/content/`:
- Guides, how-tos, and concepts: `showcase/shell-docs/src/content/docs/`
- API reference: `showcase/shell-docs/src/content/reference/`
- Shared MDX snippets: `showcase/shell-docs/src/content/snippets/`
- Framework overview pages: `showcase/shell-docs/src/content/framework-overviews/`
- When adding or moving a guide page under `showcase/shell-docs/src/content/docs/`, update that section's `meta.json` so the page appears in navigation.
- The v2 API reference under `showcase/shell-docs/src/content/reference/{components,hooks,sdk}/` does **not** use `meta.json`; navigation is generated from page frontmatter. Only `reference/v1/` uses `meta.json`.
- For framework docs, check the framework's `docs_mode` in `showcase/integrations/<slug>/manifest.yaml` and confirm the docs folder with `getDocsFolder()` in `showcase/shell-docs/src/lib/registry.ts`.
- For showcase-driven frameworks (`docs_mode: generated`), update the showcase source of truth: manifests, demos, feature coverage, source regions, registry inputs, shared/root MDX, and sparse framework overrides. Do not hand-edit generated files under `showcase/shell-docs/src/data/frameworks/`.
- For authored frameworks (`docs_mode: authored`), edit `showcase/shell-docs/src/content/docs/integrations/<docsFolder>/` and its `meta.json`.
- For snippets, edit `showcase/shell-docs/src/content/snippets/`; snippets can feed root docs, authored framework pages, and showcase-driven framework pages.
- **AG-UI protocol docs** are canonical upstream in `ag-ui-protocol/ag-ui`. The `showcase/shell-docs/src/content/ag-ui/` tree is a downstream mirror; change AG-UI upstream first, then sync the mirror back.
- **Do not recreate `docs/content/docs/`**. Top-level `docs/` is only a symlink to shell-docs. The retired Next app no longer publishes to `docs.copilotkit.ai`. Historical content is available from the archive branch/tag, not from `main`.
- To run shell-docs locally, follow `showcase/shell-docs/README.md` and use the shell-docs npm commands.
## Reference (read when relevant to your task)
- [Architecture & Packages](.claude/docs/architecture.md) — V2/V1 package roles, request lifecycle, core concepts (AG-UI, ProxiedAgent, AgentRunner, tools, context, multi-agent)
- [Hook Development](.claude/docs/hooks.md) — checklist for creating new hooks (docs, tests, JSDoc)
- [Workflow & Process](.claude/docs/workflow.md) — when to plan, when to fix autonomously, verification, self-improvement loop, this should be your default mindset when working on any task
- [Git & PRs](.claude/docs/git.md) — worktree workflow, branching, creating PRs
- [Documentation](.claude/docs/documentation.md) — where to author docs (CopilotKit → shell-docs; AG-UI → upstream); `docs/` is retired
+34
View File
@@ -0,0 +1,34 @@
<!-- nx configuration start-->
<!-- Leave the start & end comments to automatically receive updates. -->
# General Guidelines for working with Nx
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
- You have access to the Nx MCP server and its tools, use them to help the user
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
- For Nx plugin best practices, check `node_modules/@nx/<plugin>/PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable.
<!-- nx configuration end-->
# CopilotKit
AI agent framework with three layers: **Frontend** (React/Angular/Vanilla) → **Runtime** (Express/Hono) → **Agent** (LangGraph/CrewAI/BuiltIn/Custom), communicating via the AG-UI protocol (event-based SSE).
## Essentials
- **Nx monorepo** — always run tasks through `nx` (`nx run`, `nx run-many`, `nx affected`), never the underlying tooling directly.
- **Flat package structure** — all packages live directly under `packages/` (no `v1/` or `v2/` subdirectories). Every package uses the `@copilotkit/` scope.
- **Simplicity** — prefer the simplest correct solution. For non-trivial changes, consider if there's a cleaner approach before committing.
- **Worktrees** — always work in a git worktree for isolation. See [Git & PRs](.claude/docs/git.md) for the full workflow.
- **Documentation lives in shell-docs** — author all CopilotKit docs in `showcase/shell-docs/src/content/`. The top-level `docs/` path is only a symlink to `showcase/shell-docs/`; never recreate the old `docs/content/docs/` tree. AG-UI protocol docs are authored upstream in `ag-ui-protocol/ag-ui`, not here. See [Documentation](.claude/docs/documentation.md).
## Reference (read when relevant to your task)
- [Architecture & Packages](.claude/docs/architecture.md) — package roles, request lifecycle, core concepts (AG-UI, ProxiedAgent, AgentRunner, tools, context, multi-agent)
- [Hook Development](.claude/docs/hooks.md) — checklist for creating new hooks (docs, tests, JSDoc)
- [Workflow & Process](.claude/docs/workflow.md) — when to plan, when to fix autonomously, verification, self-improvement loop, this should be your default mindset when working on any task
- [Git & PRs](.claude/docs/git.md) — worktree workflow, branching, creating PRs
- [Documentation](.claude/docs/documentation.md) — where to author docs (CopilotKit → shell-docs; AG-UI → upstream); `docs/` is retired
+116
View File
@@ -0,0 +1,116 @@
# Code of Conduct for CopilotKit
## Table of Contents
1. [Statement of Purpose](#statement-of-purpose)
2. [Community Values](#community-values)
3. [Expected Behavior](#expected-behavior)
4. [Unacceptable Behavior](#unacceptable-behavior)
5. [Procedures for Reporting and Resolving Issues](#procedures-for-reporting-and-resolving-issues)
6. [Consequences of Unacceptable Behavior](#consequences-of-unacceptable-behavior)
7. [Scope](#scope)
8. [Acknowledgments](#acknowledgments)
9. [Get Involved](#get-involved)
---
## 1. Statement of Purpose
CopilotKit is dedicated to creating an open-source framework that allows for the seamless integration of powerful AI copilots into various applications. Our Code of Conduct aims to foster a collaborative, respectful, and innovative community that empowers contributors and users alike. By adhering to this code, we can ensure that our community thrives and remains a welcoming space for everyone.
---
## 2. Community Values
At CopilotKit, we prioritize the following values:
| **Value** | **Description** |
| ----------------- | ---------------------------------------------------------------------------------------------------------- |
| **Inclusivity** | We embrace diverse backgrounds and perspectives, enhancing our collective work. |
| **Collaboration** | We believe in teamwork and encourage open communication and mutual support. |
| **Respect** | We treat everyone with kindness and respect, fostering an environment where contributions are valued. |
| **Innovation** | We encourage creative thinking and experimentation, supporting initiatives that drive our project forward. |
---
## 3. Expected Behavior
As a participant in the CopilotKit community, you are expected to:
- **Be Respectful and Inclusive**: Treat all individuals with respect and kindness, regardless of their background, identity, or experience level.
- **Communicate Constructively**: Engage in discussions that are constructive, focusing on ideas and solutions rather than personal opinions.
- **Welcome Collaboration**: Actively seek out diverse viewpoints and be open to feedback that can improve our projects.
- **Support Others**: Offer help to fellow contributors, especially newcomers, and encourage a culture of mentorship.
- **Practice Empathy**: Consider the feelings and experiences of others, striving to create an understanding environment.
---
## 4. Unacceptable Behavior
The following behaviors are considered unacceptable within our community:
| **Behavior Type** | **Description** |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Harassment** | Any form of harassment, including offensive comments, intimidation, or unwanted attention. |
| **Discrimination** | Actions or comments that discriminate based on race, ethnicity, gender, sexual orientation, disability, age, or any other characteristic. |
| **Personal Attacks** | Engaging in personal insults or derogatory remarks directed at individuals or groups. |
| **Disruption** | Disrupting discussions, meetings, or community spaces, hindering constructive dialogue. |
| **Invasive Actions** | Unwanted physical contact or any other form of unwanted attention. |
---
## 5. Procedures for Reporting and Resolving Issues
If you witness or experience unacceptable behavior, we encourage you to take the following steps:
1. **Identify the Incident**: Clearly identify the nature of the unacceptable behavior and gather any evidence, such as screenshots or messages.
2. **Report the Incident**: Reach out to a project maintainer or designated community leader through direct message or email. Include detailed information about the incident, such as:
- Names of those involved (if known)
- Dates and times of the incident
- Context surrounding the behavior
- Any relevant evidence
3. **Documentation**: Keep a record of your report and any responses received to ensure a transparent resolution process.
4. **Confidentiality**: All reports will be treated confidentially. Personal information will only be shared with those involved in the resolution process.
5. **Follow-Up**: You can request updates on the status of your report and any actions taken.
---
## 6. Consequences of Unacceptable Behavior
Consequences for violating the Code of Conduct will be determined based on the severity and frequency of the behavior. Potential consequences include:
| **Consequences** | **Description** |
| ------------------------ | ------------------------------------------------------------------------------------------- |
| **Verbal Warning** | A private discussion addressing the behavior. |
| **Written Warning** | A formal notice outlining the behavior and expected changes. |
| **Temporary Suspension** | A temporary ban from contributing to the project or participating in community discussions. |
| **Permanent Removal** | A permanent ban for severe violations or repeated offenses. |
---
## 7. Scope
This Code of Conduct applies to all participants in the CopilotKit community, including contributors, maintainers, and users, both online and offline. It encompasses interactions in the following areas:
- GitHub discussions and issues
- Community meetings and events
- Social media platforms
- Any other official channels related to CopilotKit
---
## 8. Acknowledgments
This Code of Conduct draws inspiration from various open-source communities dedicated to inclusivity and respect. We appreciate their efforts in creating positive environments and strive to uphold similar standards in our community. Your feedback is always welcome to improve our Code of Conduct and practices.
---
## 9. Get Involved
We invite you to contribute to CopilotKit! Whether through code, documentation, or community engagement, your participation is invaluable. For more information on how to contribute, please check our [contribution guide](https://docs.copilotkit.ai/contributing/docs-contributions#how-to-contribute).
Thank you for being part of the CopilotKit community! Together, we can build a safe, respectful, and innovative space for all.
+191
View File
@@ -0,0 +1,191 @@
# Contributing to CopilotKit
⭐ Thank you for your interest in contributing!!
Heres how you can contribute to this repository
## How can I contribute?
**Please PLEASE reach out to us first before starting any significant work on new or existing features.**
We love community contributions! That said, we want to make sure we're all on the same page before you start.
Investing a lot of time and effort just to find out it doesn't align with the upstream project feels awful, and we don't want that to happen.
It also helps to make sure the work you're planning isn't already in progress.
As described below, please file an issue first: https://github.com/ag-ui-protocol/ag-ui/issues
Or, reach out to us on Discord: https://discord.com/invite/6dffbvGU3D
Ready to contribute but seeking guidance, we have several avenues to assist you. Explore the upcoming segment for clarity on the kind of contributions we appreciate and how to jump in. Reach out to us directly on [Discord](https://discord.gg/6dffbvGU3D) for immediate assistance! Alternatively, you're welcome to raise an issue and one of our dedicated maintainers will promptly steer you in the right direction!
## Found a bug?
If you find a bug in the source code, you can help us by [submitting an issue](https://github.com/CopilotKit/CopilotKit/issues/new?assignees=&labels=bug&projects=&template=bug_report.yaml) to our GitHub Repository. Even better, you can submit a Pull Request with a fix.
## Missing a feature?
So, you've got an awesome feature in mind? Throw it over to us by [creating an issue](https://github.com/CopilotKit/CopilotKit/issues/new?assignees=&labels=feature-request&projects=&template=feature_request.yaml) on our GitHub Repo.
If you don't feel ready to make a code contribution yet, no problem! You can also check out the [documentation issues](https://github.com/CopilotKit/CopilotKit/issues?q=is%3Aopen+is%3Aissue+label%3Adocumentation).
## Contributing to documentation
There are two documentation domains — make sure your change goes to the right place, or it won't reach the live site:
- **CopilotKit docs** (docs.copilotkit.ai) are authored in **`showcase/shell-docs/src/content/`** (`docs/`, `reference/`, `snippets/`, `framework-overviews/`). When adding a page, update the relevant `meta.json` so it appears in navigation. Top-level `docs/` is only a symlink to `showcase/shell-docs/`; do not recreate the old `docs/content/docs/` tree.
- **AG-UI protocol docs** (docs.ag-ui.com) are authored upstream in [`ag-ui-protocol/ag-ui`](https://github.com/ag-ui-protocol/ag-ui), not in this repo. The `showcase/shell-docs/src/content/ag-ui/` copy is a downstream mirror.
# How do I make a code contribution?
## Good first issues
Are you new to open source contribution? Wondering how contributions work in our project? Here's a quick rundown.
Find an issue that you're interested in addressing, or a feature that you'd like to add.
You can use [this view](https://github.com/CopilotKit/CopilotKit/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) which helps new contributors find easy gateways into our project.
## Step 1: Make a fork
Fork the CopilotKit repository to your GitHub organization. This means that you'll have a copy of the repository under _your-GitHub-username/repository-name_.
![Group 3](https://github.com/user-attachments/assets/7c2b8d15-87cf-4cc7-be86-5fadaebfad0b)
## Step 2: Clone the repository to your local machine
```
git clone https://github.com/<your-GitHub-username>/CopilotKit
```
![Group 4](https://github.com/user-attachments/assets/e3e78b2b-eead-463b-858b-8d40e4cb18e9)
## Step 3: Prepare the development environment
### 1)Install Prerequisites
- Node.js 20.x or later
- pnpm v9.x installed globally (npm i -g pnpm@^9)
> **Windows users:** Enable **Developer Mode** (Settings > System > For developers > Developer Mode → On) to allow symlink creation. This is required for Next.js standalone builds and pnpm to work correctly.
### 2)Install Dependencies
To install the dependencies using pnpm
Go inside project folder and run :
```jsx
pnpm install
```
### 3)Build Packages
To make sure everything works, lets build all packages once:
```jsx
cd CopilotKit
pnpm build
```
## Step 4: Create a branch
Create a new branch for your changes.
In order to keep branch names uniform and easy-to-understand, please use the following conventions for branch naming.
Generally speaking, it is a good idea to add a group/type prefix to a branch.
Here is a list of good examples:
- for docs change : docs/<ISSUE_NUMBER>-<CUSTOM_NAME>
- for new features : feat/<ISSUE_NUMBER>-<CUSTOM_NAME>
- for bug fixes : fix/<ISSUE_NUMBER>-<CUSTOM_NAME>
```jsx
git checkout -b <new-branch-name-here>
```
## Step 5: Make your changes
Now that everything is set up and works as expected, you can get started developing or update the code with your bug fix or new feature.
```jsx
# To start all packages in development mode
pnpm dev
# Start a specific package in development mode
nx run @copilotkit/package-name:dev
```
## Step 6: Add the changes that are ready to be committed
Stage the changes that are ready to be committed:
```jsx
git add .
```
## Step 7: Commit the changes (Git)
Commit the changes with a short message. (See below for more details on how we structure our commit messages)
```jsx
git commit -m "<type>(<package>): <subject>"
```
## Step 8: Push the changes to the remote repository
Push the changes to the remote repository using:
```jsx
git push origin <branch-name-here>
```
## Step 9: Create Pull Request
In GitHub, do the following to submit a pull request to the upstream repository:
1. Give the pull request a title and a short description of the changes made. Include also the issue or bug number associated with your change. Explain the changes that you made, any issues you think exist with the pull request you made, and any questions you have for the maintainer.
Remember, it's okay if your pull request is not perfect (no pull request ever is). The reviewer will be able to help you fix any problems and improve it!
2. Wait for the pull request to be reviewed by a maintainer.
3. Make changes to the pull request if the reviewing maintainer recommends them.
Celebrate your success after your pull request is merged :-)
## Git Commit Messages
We structure our commit messages like this:
```
<type>(<package>): <subject>
```
Example
```
fix(server): missing entity on init
```
### Types:
- **feat**: A new feature
- **fix**: A bug fix
- **docs**: Changes to the documentation
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc.)
- **refactor**: A code change that neither fixes a bug nor adds a feature
- **perf**: A code change that improves performance
- **test**: Adding missing or correcting existing tests
- **chore**: Changes to the build process or auxiliary tools and libraries such as documentation generation
## Code of conduct
Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
[Code of Conduct](https://github.com/CopilotKit/CopilotKit/blob/main/CODE_OF_CONDUCT.md)
Our Code of Conduct means that you are responsible for treating everyone on the project with respect and courtesy.
## Need Help?
- **Questions**: Use our [Discord support channel](https://discord.com/invite/6dffbvGU3D) for any questions you have.
- **Resources**: Visit [CopilotKit documentation](https://docs.copilotkit.ai/) for more helpful documentatation info.
⭐ Happy coding, and we look forward to your contributions!
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright (c) Atai Barkai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+256
View File
@@ -0,0 +1,256 @@
<div align=center>
<img width="120" height="120" alt="FavIcon" src="https://github.com/user-attachments/assets/779de607-2b8d-4751-872b-1243e97c7d18" />
# CopilotKit
<div align=center>
[Docs](https://docs.copilotkit.ai/?ref=github_readme) ·
[Examples](https://www.copilotkit.ai/examples) ·
[Enterprise Intelligence Platform](https://go.copilotkit.ai/enterprise-intelligence-platform) ·
[Discord](https://discord.gg/6dffbvGU3D?ref=github_readme)
</div>
Build **agent-native applications** — on any framework, on any surface.
Generative UI, shared state, and human-in-the-loop workflows for React, Angular, Vue, React Native — and beyond the browser.
</div>
[![CopilotKit](https://github.com/user-attachments/assets/aeb56c28-c766-44a5-810c-5d999bb6a32a)](https://go.copilotkit.ai/copilotkit-docs)
<div align="center" style="display:flex;justify-content:start;gap:16px;height:20px;margin: 0;">
<a href="https://www.npmjs.com/package/@copilotkit/react-core" target="_blank">
<img src="https://img.shields.io/npm/v/%40copilotkit%2Freact-core?logo=npm&logoColor=%23FFFFFF&label=Version&color=%236963ff" alt="NPM">
</a>
<a href="https://github.com/copilotkit/copilotkit/blob/main/LICENSE" target="_blank">
<img src="assets/license-badge.svg" alt="License: MIT" height="20">
</a>
<a href="https://discord.gg/6dffbvGU3D" target="_blank">
<img src="https://img.shields.io/discord/1122926057641742418?logo=discord&logoColor=%23FFFFFF&label=Discord&color=%236963ff" alt="Discord">
</a>
</div>
<br/>
<div>
<a href="https://www.producthunt.com/posts/copilotkit" target="_blank">
</a>
<div />
<div align="center">
<a href="https://trendshift.io/repositories/5730" target="_blank"><img src="https://trendshift.io/api/badge/repositories/5730" alt="CopilotKit%2FCopilotKit | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
<a href="https://www.producthunt.com/posts/copilotkit" target="_blank">
<img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=428778&theme=light&period=daily">
</a>
</div>
---
## What is CopilotKit
CopilotKit is a best-in-class SDK for building full-stack agentic applications, Generative UI, and chat applications.
What started as a React library is now a **multi-platform agentic framework**: the same agent can power your web app, your mobile app, and your team's Slack workspace.
We are the company behind the **[AG-UI Protocol](https://github.com/ag-ui-protocol/ag-ui)** - adopted by Google, LangChain, AWS, Microsoft, Mastra, PydanticAI, and more!
## Quick Start
Up and running in under five minutes. All you need is an LLM key (OpenAI, Anthropic, Gemini, etc.).
```bash
npx copilotkit@latest create
```
## Agent Skills
CopilotKit ships [agent skills](https://docs.copilotkit.ai) that teach your coding agent (Claude Code, Codex, Cursor, Gemini, and others) how to set up, build with, integrate, debug, and upgrade CopilotKit.
Install them into any project directory:
```bash
npx copilotkit@latest skills install
```
Run it again any time to refresh to the latest skills.
## Bring Your App to Life
https://github.com/user-attachments/assets/72b7b4f3-b6e7-460c-a932-5746fe3c8db3
<div align="center"> Add AI to your app in 1 minute</div>
**Features:**
- **Chat UI** A fully customizable chat interface that supports message streaming, tool calls, and agent responses.
- **Backend Tool Rendering** Enables agents to call backend tools that return UI components rendered directly in the client.
- **Generative UI** Allows agents to generate and update UI components dynamically at runtime based on user intent and agent state.
- **Shared State** A synchronized state layer that both agents and UI components can read from and write to in real time.
- **Human-in-the-Loop** Lets agents pause execution to request user input, confirmation, or edits before continuing.
- **Self-Learning** _(early access)_ Agents that continuously improve from user feedback via in-context reinforcement learning (CLHF).
## 🧩 Works With Your Stack
One agent backend. Every frontend.
| Platform | Status | Get Started |
| ------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------- |
| ⚛️ React / Next.js | ✅ GA | [Quickstart](https://docs.copilotkit.ai/built-in-agent/quickstart) |
| 🅰️ Angular | ✅ Supported | [Source Code - Quickstart coming soon](https://github.com/CopilotKit/CopilotKit/tree/main/packages/angular) |
| 💚 Vue | ✅ Supported | [Source Code - Quickstart coming soon](https://github.com/CopilotKit/CopilotKit/tree/main/packages/vue) |
| 📱 React Native | ✅ Supported | [Quickstart](https://docs.copilotkit.ai/react-native) |
| 💬 Slack / MS Teams / Discord / Google Chat | 🟡 Beta | [Request early access](https://go.copilotkit.ai/beyond-the-web-form) |
Your agent logic stays the same — AG-UI handles the wire protocol, CopilotKit handles the UI layer for each framework.
## 💬 Beyond the Browser: Slack & Microsoft Teams (Discord, Google Chat coming soon...)
Your agents can run and generate Generative UI beyond the web app (**[Learn more](https://www.copilotkit.ai/integrations)**).
CopilotKit now lets you deploy the **same agent** to the places your users already work:
- **Slack** Agents as first-class Slack apps: threads, tool calls, and human-in-the-loop approvals right in the channel.
- **Microsoft Teams** Bring agentic workflows to the enterprise, where your org already lives.
🔒 **Early access:** We're onboarding teams now.
👉 **[Request early access →](https://go.copilotkit.ai/beyond-the-web-form)**
## 🧠 Self-Learning Agents
Improve your product by learning over time.
With **Continuous Learning from Human Feedback (CLHF)**, part of the [CopilotKit Intelligence Platform](https://www.copilotkit.ai/copilotkit-intelligence), agents improve with every interaction:
- **In-context reinforcement learning** Agents automatically improve from user interactions, no model fine-tuning required.
- **Automatic prompt augmentation** Agent behavior adapts based on recent interactions and outcomes.
- **Per-user adaptation** Agents learn individual preferences and get better for each user over time.
- **Threads & persistence** Full interaction history — generative UI, human-in-the-loop, shared state — captured across sessions.
Available via CopilotKit Cloud or self-hosted.
🔒 **Early access:** We're onboarding teams now.
👉 **[Request early access →](https://go.copilotkit.ai/beyond-the-web-form)**
https://github.com/user-attachments/assets/7372b27b-8def-40fb-a11d-1f6585f556ad
What this gives you:
- **CopilotKit installed** Core packages are fully set up in your app
- **Provider configured** Context, state, and hooks ready to use
- **Agent <> UI connected** Agents can stream actions and render UI immediately
- **Deployment-ready** Your app is ready to deploy
[Complete getting started guide →](https://docs.copilotkit.ai/langgraph/quickstart)
## How it works:
CopilotKit connects your UI, agents, and tools into a single interaction loop.
![CopilotKit Diagram — Motion x2 6 sec version](https://github.com/user-attachments/assets/6f175d86-bd22-4c26-a13a-6013654ed542)
This enables:
- Agents that ask users for input
- Tools that render UI
- Stateful workflows across steps and sessions
- One agent, deployed across web, mobile, and chat platforms
## ⭐️ useAgent Hook
The `useAgent` hook sits directly on AG-UI, giving you full programmatic control over the agent connection.
```ts
// Programmatically access and control your agents
const { agent } = useAgent({ agentId: "my_agent" });
// Render and update your agent's state
return <div>
<h1>{agent.state.city}</h1>
<button onClick={() => agent.setState({ city: "NYC" })}>
Set City
</button>
</div>
```
Check out the [useAgent docs](https://go.copilotkit.ai/useagent-docs) to learn more.
https://github.com/user-attachments/assets/67928406-8abc-49a1-a851-98018b52174f
## Generative UI
Generative UI is a core CopilotKit pattern that allows agents to dynamically render UI as part of their workflow.
https://github.com/user-attachments/assets/3cfacac0-4ffd-457a-96f9-d7951e4ab7b6
### Compare the Three Types
<img width="708" height="311" alt="image" src="https://github.com/user-attachments/assets/962f49c2-31ea-43c5-b2a3-7cdde114705a" />
#### Explore:
- [Static (AG-UI Protocol)](https://docs.copilotkit.ai/ag-ui-protocol)
- [Declarative (A2UI)](https://docs.copilotkit.ai/generative-ui/specs/a2ui#using-a2ui-with-copilotkit)
- [Open-Ended (MCP Apps & Open JSON)](https://docs.copilotkit.ai/generative-ui/specs/mcp-apps)
[Generative UI educational repo →](https://github.com/CopilotKit/CopilotKit/tree/main/examples/showcases/generative-ui)
## 🖥️ AG-UI: The AgentUser Interaction Protocol
Connect agent workflows to user-facing apps, with deep partnerships and 1st-party integrations across the agentic stack—including LangChain, CrewAI, Mastra, PydanticAI, and more.
[![AG-UI](https://github.com/user-attachments/assets/a625237a-cfc1-45fc-8d0c-637316b81291)](https://go.copilotkit.ai/ag-ui)
---
```
npx create-ag-ui-app my-agent-app
```
<a href="https://github.com/ag-ui-protocol/ag-ui" target="_blank">
Learn more in the AG-UI README →
</a>
## 🤝 Community
- [What's New](https://docs.copilotkit.ai/whats-new)
<h3>Have questions or need help?</h3>
<a href="https://discord.gg/6dffbvGU3D?ref=github_readme" target="_blank">
Join our Discord →
</a> </br>
<a href="https://docs.copilotkit.ai/?ref=github_readme" target="_blank">
Read the Docs →
</a> </br>
<a href="https://dashboard.operations.copilotkit.ai?ref=github_readme" target="_blank">
Try the Enterprise Intelligence Platform →
</a>
<h3>Stay up to date with our latest releases!</h3>
<a href="https://www.linkedin.com/company/copilotkit/" target="_blank">
Follow us on LinkedIn →
</a> </br>
<a href="https://x.com/copilotkit" target="_blank">
Follow us on X →
</a>
## 🙋🏽‍♂️ Contributing
Thanks for your interest in contributing to CopilotKit! 💜
We value all contributions, whether it's through code, documentation, creating demo apps, or just spreading the word.
Here are a few useful resources to help you get started:
- For code contributions, [CONTRIBUTING.md](./CONTRIBUTING.md).
- For documentation-related contributions, [check out the documentation contributions guide](https://docs.copilotkit.ai/contributing/docs-contributions?ref=github_readme).
- Want to contribute but not sure how? [Join our Discord](https://discord.gg/6dffbvGU3D) and we'll help you out!
## 📄 License
This repository's source code is available under the [MIT License](https://github.com/CopilotKit/CopilotKit/blob/main/LICENSE).
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`CopilotKit/CopilotKit`
- 原始仓库:https://github.com/CopilotKit/CopilotKit
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+42
View File
@@ -0,0 +1,42 @@
# Security Policy
At **CopilotKit**, we are continuously working to improve not only the product but also the open-source repository. To achieve this, we encourage you to take some time to responsibly disclose any issues you may encounter.
## Reporting a Vulnerability
We hope this product meets your expectations. However, if you notice anything that seems off, please feel free to report the issue by following the steps below:
1. **Contact Information**:
- Email: [security@copilotkit.ai](mailto:security@copilotkit.ai)
2. **Required Information**:
- A detailed description of the vulnerability
- Steps to reproduce the issue
- Potential impact or risk
- Any possible mitigations or workarounds
3. **Preferred Method of Disclosure**:
<br>
Since our community operates in a public domain, please **do not** discuss the details of the vulnerability publicly. When escalating the issue, simply mention that you are trying to reach someone from the security team.
## Response Process
- **Acknowledgment**: Within 48 hours of receiving your report, we will acknowledge your submission.
- **Investigation**: We will investigate the issue within 5 business days.
- **Resolution**: We aim to release a fix or mitigation within 30 days of confirming the vulnerability.
### Note
**If you do not receive an acknowledgment of your email within 48 hours, and you havent heard from our security team after 5 days, please directly message someone from the CopilotKit team in our [Discord community](https://discord.gg/6dffbvGU3D).**
## Security Best Practices
While we strive to keep our project secure, here are a few best practices for users of our software:
- Always keep your installation up to date with the latest security patches.
- Avoid using outdated or unsupported versions of the project.
- Regularly audit your dependencies and review their security advisories.
---
Thank you for helping us maintain the security and integrity of this project.
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7960381a6f906533ea0a3d0f6b5882341d024dd49a06fb18b5ae348b17410928
size 13135672
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fee71e08b4d2d05da072766383de1ce969c5137e75ad21824f4f609df99a6b9e
size 17684593
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e64bcbd50b9b8685342cc012c1f79659d7c9966714b4fcb6b0c625e5f9f52265
size 26159308
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c351040b050fafc8b6d11030d7e9505fff055d3f6fa42238f717cc25fb0abcd2
size 17436012
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="82" height="20" role="img" aria-label="License: MIT"><title>License: MIT</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="82" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="51" height="20" fill="#555"/><rect x="51" width="31" height="20" fill="#6963ff"/><rect width="82" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="265" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="410">License</text><text x="265" y="140" transform="scale(.1)" fill="#fff" textLength="410">License</text><text aria-hidden="true" x="655" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="210">MIT</text><text x="655" y="140" transform="scale(.1)" fill="#fff" textLength="210">MIT</text></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:efdc87f029a8f166cebf0aa88dae6cb133f10ad6f580fc6c8b4be5531ceaaaf3
size 173304
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a32cfd1092966672084663a90c1f6ebb0d2d13f62d915e652dbcb5d521ed7bd
size 267116
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:70bd2b5a0a86be25d49c914e6a69e93a9cb725dc626702945d05f137ed50879e
size 45717200
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a9bf75be94981f314162f6055cdb8c793857cf1c2242b17a05d4460752107c23
size 12567423
+128
View File
@@ -0,0 +1,128 @@
# @copilotkit/react-core
## 1.8.2
### Patch Changes
- @copilotkit/runtime-client-gql@1.8.2
- @copilotkit/shared@1.8.2
# @copilotkit/react-ui
## 1.8.2
### Patch Changes
- @copilotkit/runtime-client-gql@1.8.2
- @copilotkit/react-core@1.8.2
- @copilotkit/shared@1.8.2
# @copilotkit/sdk-js
## 1.8.2
### Patch Changes
- @copilotkit/shared@1.8.2
# @copilotkit/react-textarea
## 1.8.2
### Patch Changes
- @copilotkit/runtime-client-gql@1.8.2
- @copilotkit/react-core@1.8.2
- @copilotkit/shared@1.8.2
# @copilotkit/runtime
## 1.8.2
### Patch Changes
- 4eedf97: - fix: handle failure to fetch langgraph schema
- @copilotkit/shared@1.8.2
# @copilotkit/runtime-client-gql
## 1.8.2
### Patch Changes
- @copilotkit/shared@1.8.2
# @copilotkit/shared
## 1.8.2
# @copilotkit/react-core
## 1.8.2
### Patch Changes
- @copilotkit/runtime-client-gql@1.8.2
- @copilotkit/shared@1.8.2
# @copilotkit/react-ui
## 1.8.2
### Patch Changes
- @copilotkit/runtime-client-gql@1.8.2
- @copilotkit/react-core@1.8.2
- @copilotkit/shared@1.8.2
# @copilotkit/sdk-js
## 1.8.2
### Patch Changes
- @copilotkit/shared@1.8.2
# @copilotkit/react-textarea
## 1.8.2
### Patch Changes
- @copilotkit/runtime-client-gql@1.8.2
- @copilotkit/react-core@1.8.2
- @copilotkit/shared@1.8.2
# @copilotkit/runtime
## 1.8.2
### Patch Changes
- 4eedf97: - fix: handle failure to fetch langgraph schema
- @copilotkit/shared@1.8.2
# @copilotkit/runtime-client-gql
## 1.8.2
### Patch Changes
- @copilotkit/shared@1.8.2
# @copilotkit/shared
## 1.8.2
@@ -0,0 +1,263 @@
import { describe, it, expect } from "vitest";
import jscodeshift from "jscodeshift";
import transform from "../migrate-attachments";
const j = jscodeshift.withParser("tsx");
function run(source: string): string {
const result = transform(
{ source, path: "test.tsx" },
{ jscodeshift: j, j, stats: () => {}, report: () => {} },
);
return result ?? source;
}
describe("migrate-attachments codemod", () => {
// -----------------------------------------------------------------------
// Props transformation
// -----------------------------------------------------------------------
describe("JSX props", () => {
it("transforms imageUploadsEnabled to attachments", () => {
const input = `
import { CopilotChat } from "@copilotkit/react-ui";
<CopilotChat imageUploadsEnabled={true} />;
`;
const output = run(input);
expect(output).toContain("attachments={{");
expect(output).toContain("enabled: true");
expect(output).not.toContain("imageUploadsEnabled");
});
it("transforms inputFileAccept to attachments.accept", () => {
const input = `
import { CopilotChat } from "@copilotkit/react-ui";
<CopilotChat inputFileAccept="image/*" />;
`;
const output = run(input);
expect(output).toContain('accept: "image/*"');
expect(output).not.toContain("inputFileAccept");
});
it("merges both props into a single attachments object", () => {
const input = `
import { CopilotChat } from "@copilotkit/react-ui";
<CopilotChat imageUploadsEnabled={true} inputFileAccept="image/*,.pdf" />;
`;
const output = run(input);
expect(output).toContain("enabled: true");
expect(output).toContain('accept: "image/*,.pdf"');
expect(output).not.toContain("imageUploadsEnabled");
expect(output).not.toContain("inputFileAccept");
});
it("works on CopilotSidebar", () => {
const input = `
import { CopilotSidebar } from "@copilotkit/react-ui";
<CopilotSidebar imageUploadsEnabled={true} />;
`;
const output = run(input);
expect(output).toContain("attachments={{");
expect(output).not.toContain("imageUploadsEnabled");
});
it("works on CopilotPopup", () => {
const input = `
import { CopilotPopup } from "@copilotkit/react-ui";
<CopilotPopup imageUploadsEnabled={true} />;
`;
const output = run(input);
expect(output).toContain("attachments={{");
expect(output).not.toContain("imageUploadsEnabled");
});
it("preserves other props", () => {
const input = `
import { CopilotChat } from "@copilotkit/react-ui";
<CopilotChat className="my-chat" imageUploadsEnabled={true} labels={{ placeholder: "Ask..." }} />;
`;
const output = run(input);
expect(output).toContain('className="my-chat"');
expect(output).toContain("labels=");
expect(output).toContain("attachments={{");
});
it("preserves imageUploadsEnabled={false}", () => {
const input = `
import { CopilotChat } from "@copilotkit/react-ui";
<CopilotChat imageUploadsEnabled={false} />;
`;
const output = run(input);
expect(output).toContain("enabled: false");
expect(output).not.toContain("enabled: true");
});
it("preserves dynamic imageUploadsEnabled expression", () => {
const input = `
import { CopilotChat } from "@copilotkit/react-ui";
<CopilotChat imageUploadsEnabled={isEnabled} />;
`;
const output = run(input);
expect(output).toContain("enabled: isEnabled");
});
it("handles shorthand imageUploadsEnabled (no value)", () => {
const input = `
import { CopilotChat } from "@copilotkit/react-ui";
<CopilotChat imageUploadsEnabled />;
`;
const output = run(input);
expect(output).toContain("enabled: true");
});
it("skips if attachments prop already exists", () => {
const input = `
import { CopilotChat } from "@copilotkit/react-ui";
<CopilotChat imageUploadsEnabled={true} attachments={{ enabled: true }} />;
`;
const output = run(input);
// Should not double-add attachments — leaves as-is
expect(output).toContain("imageUploadsEnabled");
});
it("preserves dynamic inputFileAccept expression", () => {
const input = `
import { CopilotChat } from "@copilotkit/react-ui";
<CopilotChat imageUploadsEnabled={true} inputFileAccept={acceptTypes} />;
`;
const output = run(input);
expect(output).toContain("accept: acceptTypes");
expect(output).toContain("enabled: true");
expect(output).not.toContain("inputFileAccept");
});
it("ignores non-CopilotKit components", () => {
const input = `
<SomeOtherChat imageUploadsEnabled={true} />;
`;
const output = run(input);
expect(output).toContain("imageUploadsEnabled");
});
});
// -----------------------------------------------------------------------
// Import renames
// -----------------------------------------------------------------------
describe("import renames", () => {
it("renames ImageUploadQueue to AttachmentQueue", () => {
const input = `
import { ImageUploadQueue } from "@copilotkit/react-ui";
<ImageUploadQueue images={imgs} />;
`;
const output = run(input);
expect(output).toContain("AttachmentQueue");
expect(output).not.toContain("ImageUploadQueue");
});
it("renames ImageUpload type to Attachment", () => {
const input = `
import type { ImageUpload } from "@copilotkit/react-ui";
const x: ImageUpload = { contentType: "", bytes: "" };
`;
const output = run(input);
expect(output).toContain("Attachment");
expect(output).not.toContain("ImageUpload");
});
it("handles aliased imports (keeps alias, renames imported)", () => {
const input = `
import { ImageUploadQueue as MyQueue } from "@copilotkit/react-ui";
<MyQueue images={imgs} />;
`;
const output = run(input);
// Imported name changes, but local alias stays
expect(output).toContain("AttachmentQueue as MyQueue");
expect(output).toContain("<MyQueue");
});
it("ignores imports from other packages", () => {
const input = `
import { ImageUploadQueue } from "some-other-package";
`;
const output = run(input);
expect(output).toContain("ImageUploadQueue");
expect(output).not.toContain("AttachmentQueue");
});
it("does not rename local variables that shadow the import name", () => {
const input = `
import type { ImageUpload } from "@copilotkit/react-ui";
const x: ImageUpload = {} as any;
const ImageUpload = "unrelated local variable";
console.log(ImageUpload);
`;
const output = run(input);
// Import and type reference should be renamed
expect(output).toContain("import type { Attachment }");
expect(output).toContain("const x: Attachment");
// Local variable declaration and its reference should NOT be renamed
expect(output).toContain(
'const ImageUpload = "unrelated local variable"',
);
expect(output).toContain("console.log(ImageUpload)");
});
it("does not rename object property keys or member expressions", () => {
const input = `
import type { ImageUpload } from "@copilotkit/react-ui";
const x: ImageUpload = {} as any;
const config = { ImageUpload: true };
const val = obj.ImageUpload;
`;
const output = run(input);
// Import and type reference should be renamed
expect(output).toContain("import type { Attachment }");
expect(output).toContain("const x: Attachment");
// Object key and member access should NOT be renamed
expect(output).toContain("{ ImageUpload: true }");
expect(output).toContain("obj.ImageUpload");
});
});
// -----------------------------------------------------------------------
// Idempotency and no-op
// -----------------------------------------------------------------------
describe("idempotency", () => {
it("running twice produces the same result", () => {
const input = `
import { CopilotChat, ImageUploadQueue } from "@copilotkit/react-ui";
<CopilotChat imageUploadsEnabled={true} inputFileAccept="image/*" />;
<ImageUploadQueue images={imgs} />;
`;
const first = run(input);
const second = run(first);
expect(second).toBe(first);
});
});
describe("no-op on unrelated code", () => {
it("does not modify files without deprecated APIs", () => {
const input = `
import { useState } from "react";
function App() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
`;
const output = run(input);
expect(output).toBe(input);
});
it("does not modify already-migrated code", () => {
const input = `
import { CopilotChat, AttachmentQueue } from "@copilotkit/react-ui";
import type { Attachment } from "@copilotkit/react-ui";
<CopilotChat attachments={{ enabled: true, accept: "image/*" }} />;
`;
const output = run(input);
expect(output).toBe(input);
});
});
});
+275
View File
@@ -0,0 +1,275 @@
/**
* Codemod: migrate-attachments
*
* Migrates from the deprecated image-upload API to the new attachments API.
*
* Transformations:
* 1. JSX props on CopilotChat / CopilotSidebar / CopilotPopup:
* - imageUploadsEnabled={true} → attachments={{ enabled: true }}
* - inputFileAccept="..." → merged into attachments={{ accept: "..." }}
* - Both props present → attachments={{ enabled: true, accept: "..." }}
*
* 2. Named imports from "@copilotkit/react-ui":
* - ImageUploadQueue → AttachmentQueue
* - ImageUpload (type) → Attachment (type)
*
* Usage:
* npx jscodeshift -t ./codemods/migrate-attachments.ts --extensions=tsx,ts ./src
*/
import type {
API,
FileInfo,
JSXElement,
JSXAttribute,
JSXExpressionContainer,
ImportSpecifier,
} from "jscodeshift";
const COPILOTKIT_PACKAGE = "@copilotkit/react-ui";
const TARGET_COMPONENTS = new Set([
"CopilotChat",
"CopilotSidebar",
"CopilotPopup",
]);
const IMPORT_RENAMES: Record<string, string> = {
ImageUploadQueue: "AttachmentQueue",
ImageUpload: "Attachment",
};
export default function transform(file: FileInfo, api: API) {
const j = api.jscodeshift;
const root = j(file.source);
let changed = false;
// -----------------------------------------------------------------------
// 1. Rename imports from @copilotkit/react-ui
// -----------------------------------------------------------------------
root
.find(j.ImportDeclaration, { source: { value: COPILOTKIT_PACKAGE } })
.forEach((path) => {
const specifiers = path.node.specifiers;
if (!specifiers) return;
for (const spec of specifiers) {
if (spec.type !== "ImportSpecifier") continue;
const imported = (spec as ImportSpecifier).imported;
if (imported.type !== "Identifier") continue;
const newName = IMPORT_RENAMES[imported.name];
if (!newName) continue;
const localName = spec.local?.name ?? imported.name;
const isAliased = localName !== imported.name;
// Rename the imported identifier
imported.name = newName;
// If the local name matched the old imported name (not aliased),
// update references in the file to use the new name.
//
// To avoid corrupting unrelated code, we check if any local
// declaration (variable, function, class) shadows the imported
// name. If so, we only rename type-position references (which
// unambiguously refer to the type import) and leave value-position
// references alone since they may refer to the local binding.
if (!isAliased) {
const hasShadow =
root.find(j.VariableDeclarator, {
id: { type: "Identifier", name: localName },
}).length > 0 ||
root.find(j.FunctionDeclaration, {
id: { type: "Identifier", name: localName },
}).length > 0 ||
root.find(j.ClassDeclaration, {
id: { type: "Identifier", name: localName },
}).length > 0;
root.find(j.Identifier, { name: localName }).forEach((idPath) => {
// Skip the import specifier itself — already renamed above
if (idPath.parent.node === spec) return;
const parent = idPath.parent.node;
// Skip declaration positions — these define new bindings
if (
parent.type === "VariableDeclarator" &&
parent.id === idPath.node
)
return;
if (
parent.type === "FunctionDeclaration" &&
parent.id === idPath.node
)
return;
if (parent.type === "ClassDeclaration" && parent.id === idPath.node)
return;
if (
parent.type === "TSTypeAliasDeclaration" &&
parent.id === idPath.node
)
return;
if (
parent.type === "TSInterfaceDeclaration" &&
parent.id === idPath.node
)
return;
// Skip non-computed object property keys and member expression properties
if (
(parent.type === "Property" ||
parent.type === "ObjectProperty") &&
parent.key === idPath.node &&
!parent.computed
)
return;
if (
parent.type === "MemberExpression" &&
parent.property === idPath.node &&
!parent.computed
)
return;
// Skip import specifiers from other packages
if (
parent.type === "ImportSpecifier" &&
idPath.parent.parent?.node !== path.node
)
return;
// If a local declaration shadows this name, only rename
// unambiguous type-position references (e.g. type annotations)
if (hasShadow) {
const isTypePosition =
parent.type === "TSTypeReference" ||
parent.type === "TSTypeAnnotation" ||
parent.type === "TSTypeQuery";
if (!isTypePosition) return;
}
idPath.node.name = newName;
});
// Only rename JSX identifiers if there's no shadow
if (!hasShadow) {
root
.find(j.JSXIdentifier, { name: localName })
.forEach((idPath) => {
idPath.node.name = newName;
});
}
if (spec.local) {
spec.local.name = newName;
}
}
changed = true;
}
});
// -----------------------------------------------------------------------
// 2. Transform JSX props on CopilotChat / CopilotSidebar / CopilotPopup
// -----------------------------------------------------------------------
root.find(j.JSXOpeningElement).forEach((path) => {
const nameNode = path.node.name;
if (nameNode.type !== "JSXIdentifier") return;
if (!TARGET_COMPONENTS.has(nameNode.name)) return;
const attrs = path.node.attributes;
if (!attrs) return;
// Find the deprecated props
let imageUploadsAttr: JSXAttribute | null = null;
let inputFileAcceptAttr: JSXAttribute | null = null;
let existingAttachmentsAttr: JSXAttribute | null = null;
for (const attr of attrs) {
if (
attr.type !== "JSXAttribute" ||
!attr.name ||
attr.name.type !== "JSXIdentifier"
)
continue;
if (attr.name.name === "imageUploadsEnabled") imageUploadsAttr = attr;
if (attr.name.name === "inputFileAccept") inputFileAcceptAttr = attr;
if (attr.name.name === "attachments") existingAttachmentsAttr = attr;
}
// Skip if neither deprecated prop is present
if (!imageUploadsAttr && !inputFileAcceptAttr) return;
// Skip if attachments prop already exists (already migrated or manual)
if (existingAttachmentsAttr) return;
// Build the attachments object properties
const properties = [];
if (imageUploadsAttr) {
let enabledExpr;
const val = imageUploadsAttr.value;
if (!val) {
// Shorthand: <CopilotChat imageUploadsEnabled /> means true
enabledExpr = j.booleanLiteral(true);
} else if (
val.type === "JSXExpressionContainer" &&
val.expression.type === "BooleanLiteral"
) {
enabledExpr = j.booleanLiteral(val.expression.value);
} else if (val.type === "JSXExpressionContainer") {
// Dynamic expression — preserve as-is
enabledExpr = val.expression;
} else {
enabledExpr = j.booleanLiteral(true);
}
properties.push(j.objectProperty(j.identifier("enabled"), enabledExpr));
}
if (inputFileAcceptAttr) {
const val = inputFileAcceptAttr.value;
if (val) {
if (val.type === "StringLiteral") {
properties.push(
j.objectProperty(
j.identifier("accept"),
j.stringLiteral(val.value),
),
);
} else if (
val.type === "JSXExpressionContainer" &&
val.expression.type === "StringLiteral"
) {
properties.push(
j.objectProperty(
j.identifier("accept"),
j.stringLiteral(val.expression.value),
),
);
} else if (val.type === "JSXExpressionContainer") {
// Dynamic expression — preserve as-is
properties.push(
j.objectProperty(j.identifier("accept"), val.expression),
);
}
}
}
if (properties.length === 0) return;
// Create: attachments={{ enabled: true, accept: "..." }}
const attachmentsAttr = j.jsxAttribute(
j.jsxIdentifier("attachments"),
j.jsxExpressionContainer(j.objectExpression(properties)),
);
// Remove old props, add new one
path.node.attributes = attrs.filter(
(attr) => attr !== imageUploadsAttr && attr !== inputFileAcceptAttr,
);
path.node.attributes.push(attachmentsAttr);
changed = true;
});
return changed ? root.toSource() : undefined;
}

Some files were not shown because too many files have changed in this diff Show More