Merge main: remove langgraph_agent.py (deprecated), resolve lock conflict
- Keep deletion of sdk-python/copilotkit/langgraph_agent.py (deprecated LangGraphAgent removed in this PR; main's unrelated bug fixes are superseded by our removal) - Resolve poetry.lock conflict by taking main's ag_ui_langgraph 0.0.33 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.0.4/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"privatePackages": {
|
||||
"tag": false,
|
||||
"version": false
|
||||
},
|
||||
"access": "public",
|
||||
"baseBranch": "origin/main",
|
||||
"fixed": [
|
||||
[
|
||||
"@copilotkit/a2ui-renderer",
|
||||
"@copilotkit/core",
|
||||
"@copilotkit/react-core",
|
||||
"@copilotkit/react-textarea",
|
||||
"@copilotkit/react-ui",
|
||||
"@copilotkit/runtime",
|
||||
"@copilotkit/runtime-client-gql",
|
||||
"@copilotkit/sdk-js",
|
||||
"@copilotkit/shared",
|
||||
"@copilotkit/sqlite-runner",
|
||||
"@copilotkit/voice",
|
||||
"@copilotkit/web-inspector"
|
||||
]
|
||||
],
|
||||
"ignore": [
|
||||
"@copilotkit-examples/*",
|
||||
"@copilotkit-storybook/*",
|
||||
"@copilotkit/angular-demo",
|
||||
"@copilotkit/angular-demo-server",
|
||||
"@copilotkit/demo-agents",
|
||||
"@copilotkit/docs",
|
||||
"@copilotkit/example-runtime-*",
|
||||
"@copilotkit/next-pages-router",
|
||||
"@copilotkit/node-express",
|
||||
"@apps/node",
|
||||
"agent-langraph-interrupt",
|
||||
"demo",
|
||||
"langgraph-js-starter",
|
||||
"next-openai",
|
||||
"next-pages-router",
|
||||
"node-express",
|
||||
"node-http",
|
||||
"react-router-example",
|
||||
"ui-protocols-demo",
|
||||
"web-langraph-interrupt"
|
||||
],
|
||||
"linked": [],
|
||||
"updateInternalDependencies": "patch"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@copilotkit/a2ui-renderer": patch
|
||||
---
|
||||
|
||||
Bumping the a2ui-middleware underneath the a2ui renderer
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"@copilotkit/shared": minor
|
||||
"@copilotkit/runtime": minor
|
||||
"@copilotkit/react-core": minor
|
||||
"@copilotkit/core": minor
|
||||
---
|
||||
|
||||
feat: add debug mode to runtime and client
|
||||
|
||||
Add `debug` option to `CopilotRuntime` constructor and `<CopilotKit>` provider for detailed event pipeline logging. Server-side uses Pino structured logger; client-side passes config through to AG-UI transport. Accepts `true` for default output or a granular config: `{ events, lifecycle, verbose }`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@copilotkit/runtime": patch
|
||||
---
|
||||
|
||||
fix: preserve jsonSchema structure in MCP tool parameter extraction
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@copilotkit/react-core": patch
|
||||
---
|
||||
|
||||
fix: pass toolCallId to useRenderTool render components
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@copilotkit/react-core": patch
|
||||
---
|
||||
|
||||
fix: preserve assistant text when multiple tool calls fire in one turn (CPK-7154)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@copilotkit/react-ui": patch
|
||||
---
|
||||
|
||||
fix: pass urlTransform prop through to ReactMarkdown
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"mode": "pre",
|
||||
"tag": "next",
|
||||
"initialVersions": {
|
||||
"ui-protocols-demo": "0.1.0",
|
||||
"@copilotkit-examples/chat-with-your-data": "0.1.0",
|
||||
"@copilotkit-examples/form-filling": "0.1.0",
|
||||
"next-openai": "1.4.10-next.0",
|
||||
"next-pages-router": "1.4.10-next.0",
|
||||
"node-express": "1.4.10-next.0",
|
||||
"node-http": "1.4.10-next.0",
|
||||
"@copilotkit-examples/research-canvas": "0.1.0",
|
||||
"@copilotkit-examples/state-machine": "0.1.0",
|
||||
"@copilotkit-examples/travel": "0.1.0",
|
||||
"@copilotkit/angular-demo": "0.0.0",
|
||||
"@copilotkit/angular-demo-server": "0.0.0",
|
||||
"@copilotkit-storybook/angular": "0.0.6-next.1",
|
||||
"@copilotkit/docs": "0.1.8",
|
||||
"langgraph-js-starter": "0.1.4",
|
||||
"agent-langraph-interrupt": "0.0.1",
|
||||
"web-langraph-interrupt": "0.1.0",
|
||||
"@copilotkit/next-pages-router": "1.55.0-next.8",
|
||||
"@apps/node": "0.0.1",
|
||||
"@copilotkit/node-express": "1.55.0-next.8",
|
||||
"react-router-example": "0.1.0",
|
||||
"demo": "0.1.4-next.0",
|
||||
"@copilotkit-storybook/react": "0.0.0",
|
||||
"@copilotkit/example-runtime-cf-workers": "0.0.1",
|
||||
"@copilotkit/example-runtime-deno": "0.0.1",
|
||||
"@copilotkit/example-runtime-elysia": "0.0.1",
|
||||
"@copilotkit/example-runtime-express": "0.0.1",
|
||||
"@copilotkit/example-runtime-hono": "0.0.1",
|
||||
"@copilotkit/example-runtime-node": "0.0.1",
|
||||
"@copilotkit/a2ui-renderer": "1.55.1",
|
||||
"@copilotkitnext/angular": "1.54.2",
|
||||
"copilotkit": "1.0.1",
|
||||
"@copilotkit/core": "1.55.1",
|
||||
"@copilotkit/demo-agents": "1.55.0-next.8",
|
||||
"@copilotkit/react-core": "1.55.1",
|
||||
"@copilotkit/react-textarea": "1.55.1",
|
||||
"@copilotkit/react-ui": "1.55.1",
|
||||
"@copilotkit/runtime": "1.55.1",
|
||||
"@copilotkit/runtime-client-gql": "1.55.1",
|
||||
"@copilotkit/sdk-js": "1.55.1",
|
||||
"@copilotkit/shared": "1.55.1",
|
||||
"@copilotkit/sqlite-runner": "1.55.1",
|
||||
"tailwind-config": "1.4.12",
|
||||
"tsconfig": "1.4.12",
|
||||
"@copilotkit/typescript-config": "1.55.0-next.8",
|
||||
"@copilotkit/voice": "1.55.1",
|
||||
"@copilotkit/web-inspector": "1.55.1"
|
||||
},
|
||||
"changesets": ["cozy-phones-reply"]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@copilotkit/runtime": patch
|
||||
---
|
||||
|
||||
feat(runtime): add factory mode to BuiltInAgent for backend-agnostic LLM integration
|
||||
@@ -67,3 +67,47 @@ Multiple agents can be registered in a single `CopilotRuntime`. Each agent gets
|
||||
### 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.
|
||||
|
||||
@@ -6,3 +6,28 @@
|
||||
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
*.webm filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Shell scripts must retain LF line endings. Windows contributors regenerating
|
||||
# showcase starters on a Windows checkout (or with autocrlf=true) would
|
||||
# otherwise silently ship CRLF ``entrypoint.sh`` files that bash in the
|
||||
# Docker runtime rejects (``bad interpreter: No such file or directory``).
|
||||
*.sh text eol=lf
|
||||
|
||||
# Generated showcase starters — do not edit manually
|
||||
# Regenerate with: cd showcase/scripts && npx tsx generate-starters.ts
|
||||
showcase/starters/ag2/** linguist-generated=true
|
||||
showcase/starters/agno/** linguist-generated=true
|
||||
showcase/starters/claude-sdk-python/** linguist-generated=true
|
||||
showcase/starters/claude-sdk-typescript/** linguist-generated=true
|
||||
showcase/starters/crewai-crews/** linguist-generated=true
|
||||
showcase/starters/google-adk/** linguist-generated=true
|
||||
showcase/starters/langgraph-fastapi/** linguist-generated=true
|
||||
showcase/starters/langgraph-python/** linguist-generated=true
|
||||
showcase/starters/langgraph-typescript/** linguist-generated=true
|
||||
showcase/starters/langroid/** linguist-generated=true
|
||||
showcase/starters/llamaindex/** linguist-generated=true
|
||||
showcase/starters/mastra/** linguist-generated=true
|
||||
showcase/starters/ms-agent-dotnet/** linguist-generated=true
|
||||
showcase/starters/ms-agent-python/** linguist-generated=true
|
||||
showcase/starters/pydantic-ai/** linguist-generated=true
|
||||
showcase/starters/spring-ai/** linguist-generated=true
|
||||
showcase/starters/strands/** linguist-generated=true
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
* @arielweinberger @mme @ataibarkai @ranst91 @tylerslaton @suhasdeshpande
|
||||
* @tylerslaton @jpr5 @ranst91 @marthakelly @mme
|
||||
|
||||
# Showcases — demo team owns these alongside core dev
|
||||
examples/showcases/ @CopilotKit/demo @arielweinberger @mme @ataibarkai @ranst91 @tylerslaton @suhasdeshpande
|
||||
examples/showcases/ @CopilotKit/demo @tylerslaton @jpr5 @ranst91 @marthakelly @mme
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
["@babel/preset-env", { "targets": { "node": 10 } }],
|
||||
"@babel/preset-typescript"
|
||||
]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@1.6.2/schema.json",
|
||||
"changelog": [
|
||||
"@changesets/changelog-github",
|
||||
{ "repo": "changesets/action" }
|
||||
],
|
||||
"commit": false,
|
||||
"linked": [],
|
||||
"access": "restricted",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
name: Node CI
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
timeout-minutes: 20
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install Dependencies
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
- name: Test
|
||||
run: yarn test
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Version or Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
changesets:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install Dependencies
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
|
||||
- name: Create Release Pull Request or Publish
|
||||
id: changesets
|
||||
uses: ./
|
||||
with:
|
||||
version: yarn bump
|
||||
publish: yarn release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
.parcel-cache
|
||||
.cache
|
||||
*.log
|
||||
@@ -1,118 +0,0 @@
|
||||
# @changesets/action
|
||||
|
||||
## 1.4.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#415](https://github.com/changesets/action/pull/415) [`57ab80c`](https://github.com/changesets/action/commit/57ab80c61104c270bebc125910ae32da3a5aca46) Thanks [@benmccann](https://github.com/benmccann)! - Improve error message when attempting to publish without publish script defined
|
||||
|
||||
## 1.4.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#393](https://github.com/changesets/action/pull/393) [`48ab0d2`](https://github.com/changesets/action/commit/48ab0d2f2e77ae169182d022591ef5c18c931ff2) Thanks [@s0](https://github.com/s0)! - Ensure the PR remains open when updated
|
||||
|
||||
- [#393](https://github.com/changesets/action/pull/393) [`48ab0d2`](https://github.com/changesets/action/commit/48ab0d2f2e77ae169182d022591ef5c18c931ff2) Thanks [@s0](https://github.com/s0)! - Switch to cheaper API for querying existing PRs
|
||||
|
||||
## 1.4.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#255](https://github.com/changesets/action/pull/255) [`f2660aa`](https://github.com/changesets/action/commit/f2660aa7e78365f53dbeb4cfa774c1499ec6483a) Thanks [@ernestognw](https://github.com/ernestognw)! - Allow customize PR `branch` field
|
||||
|
||||
## 1.4.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#350](https://github.com/changesets/action/pull/350) [`9385be9`](https://github.com/changesets/action/commit/9385be9e757839189ea5ee63ec4e3caa8a6ca71b) Thanks [@m-shaka](https://github.com/m-shaka)! - Bump the used node.js from 16 to 20
|
||||
|
||||
## 1.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#282](https://github.com/changesets/action/pull/282) [`eb19e25`](https://github.com/changesets/action/commit/eb19e25e7797cf33dc2de4caa071e85a8057a0f0) Thanks [@mark-omarov](https://github.com/mark-omarov)! - Updated a few dependencies to patch the security vulnerabilities that were reported for their older versions.
|
||||
|
||||
## 1.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#291](https://github.com/changesets/action/pull/291) [`db8a109`](https://github.com/changesets/action/commit/db8a1099bc0ba1dd6f46a5b9df4212e4f69e78c9) Thanks [@varl](https://github.com/varl)! - Wire up [`@octokit/plugin-throttling`](https://github.com/octokit/plugin-throttling.js) with all GitHub Octokit instances
|
||||
|
||||
## 1.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#289](https://github.com/changesets/action/pull/289) [`8b28186`](https://github.com/changesets/action/commit/8b2818674de86a7fc69aebb9ed6b486ee32eb96e) Thanks [@varl](https://github.com/varl)! - Use logging provided by `@actions/core`
|
||||
|
||||
## 1.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#286](https://github.com/changesets/action/pull/286) [`225a1e8`](https://github.com/changesets/action/commit/225a1e8cbcabb7b585174ba0ad806549db40d4cd) Thanks [@varl](https://github.com/varl)! - This patch implements the [`@octokit/plugin-throttling`](https://github.com/octokit/plugin-throttling.js) plugin and [wires
|
||||
it up with the internal GitHub Octokit instance](https://github.com/actions/toolkit/tree/457303960f03375db6f033e214b9f90d79c3fe5c/packages/github#extending-the-octokit-instance).
|
||||
|
||||
This plugin is recommended by [the Octokit docs](://octokit.github.io/rest.js/v19#throttling) as it implements all the GitHub [best practices for integrators](https://docs.github.com/en/rest/guides/best-practices-for-integrators?apiVersion=2022-11-28).
|
||||
|
||||
This should help with `changesets/action` gitting spurious secondary rate limits and failing CI jobs, for which the only known workaround is to simply re-run the job.
|
||||
|
||||
## 1.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#123](https://github.com/changesets/action/pull/123) [`b78f480`](https://github.com/changesets/action/commit/b78f48099899f0a853c5d9cd3feb21a5440babbd) Thanks [@Andarist](https://github.com/Andarist)! - Updated `@actions/*` dependencies to avoid using deprecated features of the runner.
|
||||
|
||||
## 1.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#216](https://github.com/changesets/action/pull/216) [`398d7ed`](https://github.com/changesets/action/commit/398d7ed) Thanks [@quinnjn](https://github.com/quinnjn)! - Execute action with node16 instead of node12.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#228](https://github.com/changesets/action/pull/228) [`bff53cc`](https://github.com/changesets/action/commit/bff53cc50c1ebb33f8f558f9de2e0eb9a99230c6) Thanks [@iansan5653](https://github.com/iansan5653)! - Add `is:pull-request` to search query when looking for existing PR. This fixes an issue with user-owned PATs.
|
||||
|
||||
* [#206](https://github.com/changesets/action/pull/206) [`8c3f5f5`](https://github.com/changesets/action/commit/8c3f5f5637a95a2327e78d5dabcf357978aedcbb) Thanks [@glasser](https://github.com/glasser)! - Skip creating a PR when all existing changesets are empty.
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#167](https://github.com/changesets/action/pull/167) [`993a0a0`](https://github.com/changesets/action/commit/993a0a090df78cee07481d3886dcd8b29deb9567) Thanks [@dmregister](https://github.com/dmregister)! - Added `pullRequestNumber` to the action's outputs
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#157](https://github.com/changesets/action/pull/157) [`521c27b`](https://github.com/changesets/action/commit/521c27bf86ec53547d6a350d208fbbbc9d576fbc) Thanks [@emmenko](https://github.com/emmenko)! - Automatically adjust GitHub PR message if it exceeds a size limit of 60k characters by omitting some of the changelog information.
|
||||
|
||||
## 1.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#161](https://github.com/changesets/action/pull/161) [`52c9ce7`](https://github.com/changesets/action/commit/52c9ce75d9d8a14ea2d75e4157b0c15b7a4ac313) Thanks [@bicknellr](https://github.com/bicknellr)! - Change directory to `cwd` before running git user setup. This fixes an issue when the action starts its execution not in a git repository.
|
||||
|
||||
## 1.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#144](https://github.com/changesets/action/pull/144) [`898d125`](https://github.com/changesets/action/commit/898d125cee6ba00c6a11b6cadca512752c6c910c) Thanks [@Andarist](https://github.com/Andarist)! - Updated all Changesets dependencies. This should fix parsing issues for completely empty summaries that has been fixed in `@changesets/parse@0.3.11`.
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#130](https://github.com/changesets/action/pull/130) [`5c0997b`](https://github.com/changesets/action/commit/5c0997b25e175ecf5e1723ba07210bbcea5d92fb) Thanks [@akphi](https://github.com/akphi)! - Added `createGithubReleases` input option (defaults to `true`) to control whether to create Github releases during publish or not.
|
||||
|
||||
* [#134](https://github.com/changesets/action/pull/134) [`1ed9bc2`](https://github.com/changesets/action/commit/1ed9bc24b7a56462c183eb815c8f4bdf0e2e5785) Thanks [@dmregister](https://github.com/dmregister)! - Added `cwd` input option that can be used in projects that are not in the root directory.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#128](https://github.com/changesets/action/pull/128) [`1937303`](https://github.com/changesets/action/commit/19373036c4bad4b0183344b6f2623a3b0e42da6c) Thanks [@dhruvdutt](https://github.com/dhruvdutt)! - Setup the git user in the local config instead of the global one.
|
||||
|
||||
* [#131](https://github.com/changesets/action/pull/131) [`d3db9ec`](https://github.com/changesets/action/commit/d3db9eceaf41d42c56d5370d504c86851627188f) Thanks [@jacklesliewise](https://github.com/jacklesliewise)! - Added `setupGitUser` option to enable or disable setting up a default git user
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- [#118](https://github.com/changesets/action/pull/118) [`05c863d`](https://github.com/changesets/action/commit/05c863d3f980125585016a593b5cb45b27d19c2c) Thanks [@Andarist](https://github.com/Andarist)! - From now on this action will be released using the Changesets-based workflow (using itself). Thanks to that we'll have a good release history. The users will be able to find specific versions of the action and will be able to track changes over time. It also improves the security as the build artifact will always get built in the CI environment, using a frozen lockfile.
|
||||
@@ -1,217 +0,0 @@
|
||||
# Changesets Release Action
|
||||
|
||||
This action for [Changesets](https://github.com/atlassian/changesets) creates a pull request with all of the package versions updated and changelogs updated and when there are new changesets on [your configured `baseBranch`](https://github.com/changesets/changesets/blob/main/docs/config-file-options.md#basebranch-git-branch-name), the PR will be updated. When you're ready, you can merge the pull request and you can either publish the packages to npm manually or setup the action to do it for you.
|
||||
|
||||
## Usage
|
||||
|
||||
### Inputs
|
||||
|
||||
- publish - The command to use to build and publish packages
|
||||
- version - The command to update version, edit CHANGELOG, read and delete changesets. Default to `changeset version` if not provided
|
||||
- commit - The commit message to use. Default to `Version Packages`
|
||||
- title - The pull request title. Default to `Version Packages`
|
||||
- setupGitUser - Sets up the git user for commits as `"github-actions[bot]"`. Default to `true`
|
||||
- createGithubReleases - A boolean value to indicate whether to create Github releases after `publish` or not. Default to `true`
|
||||
- cwd - Changes node's `process.cwd()` if the project is not located on the root. Default to `process.cwd()`
|
||||
|
||||
### Outputs
|
||||
|
||||
- published - A boolean value to indicate whether a publishing has happened or not
|
||||
- publishedPackages - A JSON array to present the published packages. The format is `[{"name": "@xx/xx", "version": "1.2.0"}, {"name": "@xx/xy", "version": "0.8.9"}]`
|
||||
|
||||
### Example workflow:
|
||||
|
||||
#### Without Publishing
|
||||
|
||||
Create a file at `.github/workflows/release.yml` with the following content.
|
||||
|
||||
```yml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js 20
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install Dependencies
|
||||
run: yarn
|
||||
|
||||
- name: Create Release Pull Request
|
||||
uses: changesets/action@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
#### With Publishing
|
||||
|
||||
Before you can setup this action with publishing, you'll need to have an [npm token](https://docs.npmjs.com/creating-and-viewing-authentication-tokens) that can publish the packages in the repo you're setting up the action for and doesn't have 2FA on publish enabled ([2FA on auth can be enabled](https://docs.npmjs.com/about-two-factor-authentication)). You'll also need to [add it as a secret on your GitHub repo](https://help.github.com/en/articles/virtual-environments-for-github-actions#creating-and-using-secrets-encrypted-variables) with the name `NPM_TOKEN`. Once you've done that, you can create a file at `.github/workflows/release.yml` with the following content.
|
||||
|
||||
```yml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install Dependencies
|
||||
run: yarn
|
||||
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
# This expects you to have a script called release which does a build for your packages and calls changeset publish
|
||||
publish: yarn release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Send a Slack notification if a publish happens
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
# You can do something when a publish happens.
|
||||
run: my-slack-bot send-notification --message "A new version of ${GITHUB_REPOSITORY} was published!"
|
||||
```
|
||||
|
||||
By default the GitHub Action creates a `.npmrc` file with the following content:
|
||||
|
||||
```
|
||||
//registry.npmjs.org/:_authToken=${process.env.NPM_TOKEN}
|
||||
```
|
||||
|
||||
However, if a `.npmrc` file is found, the GitHub Action does not recreate the file. This is useful if you need to configure the `.npmrc` file on your own.
|
||||
For example, you can add a step before running the Changesets GitHub Action:
|
||||
|
||||
```yml
|
||||
- name: Creating .npmrc
|
||||
run: |
|
||||
cat << EOF > "$HOME/.npmrc"
|
||||
//registry.npmjs.org/:_authToken=$NPM_TOKEN
|
||||
EOF
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
```
|
||||
|
||||
#### Custom Publishing
|
||||
|
||||
If you want to hook into when publishing should occur but have your own publishing functionality, you can utilize the `hasChangesets` output.
|
||||
|
||||
Note that you might need to account for things already being published in your script because a commit without any new changesets can always land on your base branch after a successful publish. In such a case you need to figure out on your own how to skip over the actual publishing logic or handle errors gracefully as most package registries won't allow you to publish over already published version.
|
||||
|
||||
```yml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install Dependencies
|
||||
run: yarn
|
||||
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish
|
||||
if: steps.changesets.outputs.hasChangesets == 'false'
|
||||
# You can do something when a publish should happen.
|
||||
run: yarn publish
|
||||
```
|
||||
|
||||
#### With version script
|
||||
|
||||
If you need to add additional logic to the version command, you can do so by using a version script.
|
||||
|
||||
If the version script is present, this action will run that script instead of `changeset version`, so please make sure that your script calls `changeset version` at some point. All the changes made by the script will be included in the PR.
|
||||
|
||||
```yml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install Dependencies
|
||||
run: yarn
|
||||
|
||||
- name: Create Release Pull Request
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
# this expects you to have a npm script called version that runs some logic and then calls `changeset version`.
|
||||
version: yarn version
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
#### With Yarn 2 / Plug'n'Play
|
||||
|
||||
If you are using [Yarn Plug'n'Play](https://yarnpkg.com/features/pnp), you should use a custom `version` command so that the action can resolve the `changeset` CLI:
|
||||
|
||||
```yaml
|
||||
- uses: changesets/action@v1
|
||||
with:
|
||||
version: yarn changeset version
|
||||
...
|
||||
```
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@1.3.0/schema.json",
|
||||
"ignore": ["ignored-package-pkg-a"]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "ignored-package",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
]
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "ignored-package-pkg-a",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"ignored-package-pkg-b": "1.0.0"
|
||||
}
|
||||
}
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"name": "ignored-package-pkg-b",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@1.3.0/schema.json"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "simple-project",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
]
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "simple-project-pkg-a",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"simple-project-pkg-b": "1.0.0"
|
||||
}
|
||||
}
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"name": "simple-project-pkg-b",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Changesets
|
||||
description: A GitHub action to automate releases with Changesets
|
||||
runs:
|
||||
using: "node20"
|
||||
main: "dist/index.js"
|
||||
inputs:
|
||||
publish:
|
||||
description: "The command to use to build and publish packages"
|
||||
required: false
|
||||
version:
|
||||
description: "The command to update version, edit CHANGELOG, read and delete changesets. Default to `changeset version` if not provided"
|
||||
required: false
|
||||
cwd:
|
||||
description: Sets the cwd for the node process. Default to `process.cwd()`
|
||||
required: false
|
||||
commit:
|
||||
description: |
|
||||
The commit message. Default to `Version Packages`
|
||||
required: false
|
||||
title:
|
||||
description: The pull request title. Default to `Version Packages`
|
||||
required: false
|
||||
setupGitUser:
|
||||
description: Sets up the git user for commits as `"github-actions[bot]"`. Default to `true`
|
||||
required: false
|
||||
default: true
|
||||
createGithubReleases:
|
||||
description: "A boolean value to indicate whether to create Github releases after `publish` or not"
|
||||
required: false
|
||||
default: true
|
||||
branch:
|
||||
description: Sets the branch in which the action will run. Default to `github.ref_name` if not provided
|
||||
required: false
|
||||
outputs:
|
||||
published:
|
||||
description: A boolean value to indicate whether a publishing is happened or not
|
||||
publishedPackages:
|
||||
description: >
|
||||
A JSON array to present the published packages. The format is `[{"name": "@xx/xx", "version": "1.2.0"}, {"name": "@xx/xy", "version": "0.8.9"}]`
|
||||
hasChangesets:
|
||||
description: A boolean about whether there were changesets. Useful if you want to create your own publishing functionality.
|
||||
pullRequestNumber:
|
||||
description: The pull request number that was created or updated
|
||||
branding:
|
||||
icon: "package"
|
||||
color: "blue"
|
||||
File diff suppressed because one or more lines are too long
-10911
File diff suppressed because it is too large
Load Diff
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"name": "@changesets/action",
|
||||
"version": "1.4.9",
|
||||
"license": "MIT",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "ncc build src/index.ts -o dist --transpile-only --minify",
|
||||
"test": "jest",
|
||||
"test:watch": "yarn test --watch",
|
||||
"changeset": "changeset",
|
||||
"bump": "node ./scripts/bump.js",
|
||||
"release": "node ./scripts/release.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.0",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/github": "^5.1.1",
|
||||
"@changesets/pre": "^1.0.9",
|
||||
"@changesets/read": "^0.5.3",
|
||||
"@manypkg/get-packages": "^1.1.3",
|
||||
"@octokit/plugin-throttling": "^5.2.1",
|
||||
"fs-extra": "^8.1.0",
|
||||
"mdast-util-to-string": "^1.0.6",
|
||||
"remark-parse": "^7.0.1",
|
||||
"remark-stringify": "^7.0.3",
|
||||
"resolve-from": "^5.0.0",
|
||||
"semver": "^7.5.3",
|
||||
"unified": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.13.10",
|
||||
"@babel/preset-env": "^7.13.10",
|
||||
"@babel/preset-typescript": "^7.13.0",
|
||||
"@changesets/changelog-github": "^0.4.2",
|
||||
"@changesets/cli": "^2.20.0",
|
||||
"@changesets/write": "^0.1.6",
|
||||
"@types/fs-extra": "^8.0.0",
|
||||
"@types/jest": "^29.5.1",
|
||||
"@types/node": "^20.11.17",
|
||||
"@types/semver": "^7.5.0",
|
||||
"@vercel/ncc": "^0.36.1",
|
||||
"babel-jest": "^29.5.0",
|
||||
"fixturez": "^1.1.0",
|
||||
"husky": "^3.0.3",
|
||||
"jest": "^29.5.0",
|
||||
"prettier": "^2.0.5",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"**/@octokit/core": "4.2.0",
|
||||
"trim": "^0.0.3",
|
||||
"y18n": "^4.0.1"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {}
|
||||
},
|
||||
"prettier": {},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { exec } = require("@actions/exec");
|
||||
|
||||
process.chdir(path.join(__dirname, ".."));
|
||||
|
||||
(async () => {
|
||||
await exec("changeset", ["version"]);
|
||||
|
||||
const releaseLine = `v${require("../package.json").version.split(".")[0]}`;
|
||||
|
||||
const readmePath = path.join(__dirname, "..", "README.md");
|
||||
const content = fs.readFileSync(readmePath, "utf8");
|
||||
const updatedContent = content.replace(
|
||||
/changesets\/action@[^\s]+/g,
|
||||
`changesets/action@${releaseLine}`,
|
||||
);
|
||||
fs.writeFileSync(readmePath, updatedContent);
|
||||
})();
|
||||
@@ -1,41 +0,0 @@
|
||||
const path = require("path");
|
||||
const { exec, getExecOutput } = require("@actions/exec");
|
||||
|
||||
const { version } = require("../package.json");
|
||||
const tag = `v${version}`;
|
||||
const releaseLine = `v${version.split(".")[0]}`;
|
||||
|
||||
process.chdir(path.join(__dirname, ".."));
|
||||
|
||||
(async () => {
|
||||
const { exitCode, stderr } = await getExecOutput(
|
||||
`git`,
|
||||
["ls-remote", "--exit-code", "origin", "--tags", `refs/tags/${tag}`],
|
||||
{
|
||||
ignoreReturnCode: true,
|
||||
},
|
||||
);
|
||||
if (exitCode === 0) {
|
||||
console.log(
|
||||
`Action is not being published because version ${tag} is already published`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (exitCode !== 2) {
|
||||
throw new Error(`git ls-remote exited with ${exitCode}:\n${stderr}`);
|
||||
}
|
||||
|
||||
await exec("git", ["checkout", "--detach"]);
|
||||
await exec("git", ["add", "--force", "dist"]);
|
||||
await exec("git", ["commit", "-m", tag]);
|
||||
|
||||
await exec("changeset", ["tag"]);
|
||||
|
||||
await exec("git", [
|
||||
"push",
|
||||
"--force",
|
||||
"--follow-tags",
|
||||
"origin",
|
||||
`HEAD:refs/heads/${releaseLine}`,
|
||||
]);
|
||||
})();
|
||||
@@ -1,59 +0,0 @@
|
||||
import { exec, getExecOutput } from "@actions/exec";
|
||||
|
||||
export const setupUser = async () => {
|
||||
await exec("git", ["config", "user.name", `"github-actions[bot]"`]);
|
||||
await exec("git", [
|
||||
"config",
|
||||
"user.email",
|
||||
`"github-actions[bot]@users.noreply.github.com"`,
|
||||
]);
|
||||
};
|
||||
|
||||
export const pullBranch = async (branch: string) => {
|
||||
await exec("git", ["pull", "origin", branch]);
|
||||
};
|
||||
|
||||
export const push = async (
|
||||
branch: string,
|
||||
{ force }: { force?: boolean } = {},
|
||||
) => {
|
||||
await exec(
|
||||
"git",
|
||||
["push", "origin", `HEAD:${branch}`, force && "--force"].filter<string>(
|
||||
Boolean as any,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const pushTags = async () => {
|
||||
await exec("git", ["push", "origin", "--tags"]);
|
||||
};
|
||||
|
||||
export const switchToMaybeExistingBranch = async (branch: string) => {
|
||||
let { stderr } = await getExecOutput("git", ["checkout", branch], {
|
||||
ignoreReturnCode: true,
|
||||
});
|
||||
let isCreatingBranch = !stderr
|
||||
.toString()
|
||||
.includes(`Switched to a new branch '${branch}'`);
|
||||
if (isCreatingBranch) {
|
||||
await exec("git", ["checkout", "-b", branch]);
|
||||
}
|
||||
};
|
||||
|
||||
export const reset = async (
|
||||
pathSpec: string,
|
||||
mode: "hard" | "soft" | "mixed" = "hard",
|
||||
) => {
|
||||
await exec("git", ["reset", `--${mode}`, pathSpec]);
|
||||
};
|
||||
|
||||
export const commitAll = async (message: string) => {
|
||||
await exec("git", ["add", "."]);
|
||||
await exec("git", ["commit", "-m", message]);
|
||||
};
|
||||
|
||||
export const checkIfClean = async (): Promise<boolean> => {
|
||||
const { stdout } = await getExecOutput("git", ["status", "--porcelain"]);
|
||||
return !stdout.length;
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
import * as core from "@actions/core";
|
||||
import fs from "fs-extra";
|
||||
import * as gitUtils from "./gitUtils";
|
||||
import { runPublish, runVersion } from "./run";
|
||||
import readChangesetState from "./readChangesetState";
|
||||
|
||||
const getOptionalInput = (name: string) => core.getInput(name) || undefined;
|
||||
|
||||
(async () => {
|
||||
let githubToken = process.env.GITHUB_TOKEN;
|
||||
|
||||
if (!githubToken) {
|
||||
core.setFailed("Please add the GITHUB_TOKEN to the changesets action");
|
||||
return;
|
||||
}
|
||||
|
||||
const inputCwd = core.getInput("cwd");
|
||||
if (inputCwd) {
|
||||
core.info("changing directory to the one given as the input");
|
||||
process.chdir(inputCwd);
|
||||
}
|
||||
|
||||
let setupGitUser = core.getBooleanInput("setupGitUser");
|
||||
|
||||
if (setupGitUser) {
|
||||
core.info("setting git user");
|
||||
await gitUtils.setupUser();
|
||||
}
|
||||
|
||||
core.info("setting GitHub credentials");
|
||||
await fs.writeFile(
|
||||
`${process.env.HOME}/.netrc`,
|
||||
`machine github.com\nlogin github-actions[bot]\npassword ${githubToken}`,
|
||||
);
|
||||
|
||||
let { changesets } = await readChangesetState();
|
||||
|
||||
let publishScript = core.getInput("publish");
|
||||
let hasChangesets = changesets.length !== 0;
|
||||
const hasNonEmptyChangesets = changesets.some(
|
||||
(changeset) => changeset.releases.length > 0,
|
||||
);
|
||||
let hasPublishScript = !!publishScript;
|
||||
|
||||
core.setOutput("published", "false");
|
||||
core.setOutput("publishedPackages", "[]");
|
||||
core.setOutput("hasChangesets", String(hasChangesets));
|
||||
|
||||
switch (true) {
|
||||
case !hasChangesets && !hasPublishScript:
|
||||
core.info(
|
||||
"No changesets present or were removed by merging release PR. Not publishing because no publish script found.",
|
||||
);
|
||||
return;
|
||||
case !hasChangesets && hasPublishScript: {
|
||||
core.info(
|
||||
"No changesets found. Attempting to publish any unpublished packages to npm",
|
||||
);
|
||||
|
||||
let userNpmrcPath = `${process.env.HOME}/.npmrc`;
|
||||
if (fs.existsSync(userNpmrcPath)) {
|
||||
core.info("Found existing user .npmrc file");
|
||||
const userNpmrcContent = await fs.readFile(userNpmrcPath, "utf8");
|
||||
const authLine = userNpmrcContent.split("\n").find((line) => {
|
||||
// check based on https://github.com/npm/cli/blob/8f8f71e4dd5ee66b3b17888faad5a7bf6c657eed/test/lib/adduser.js#L103-L105
|
||||
return /^\s*\/\/registry\.npmjs\.org\/:[_-]authToken=/i.test(line);
|
||||
});
|
||||
if (authLine) {
|
||||
core.info(
|
||||
"Found existing auth token for the npm registry in the user .npmrc file",
|
||||
);
|
||||
} else {
|
||||
core.info(
|
||||
"Didn't find existing auth token for the npm registry in the user .npmrc file, creating one",
|
||||
);
|
||||
fs.appendFileSync(
|
||||
userNpmrcPath,
|
||||
`\n//registry.npmjs.org/:_authToken=${process.env.NPM_TOKEN}\n`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
core.info("No user .npmrc file found, creating one");
|
||||
fs.writeFileSync(
|
||||
userNpmrcPath,
|
||||
`//registry.npmjs.org/:_authToken=${process.env.NPM_TOKEN}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await runPublish({
|
||||
script: publishScript,
|
||||
githubToken,
|
||||
createGithubReleases: core.getBooleanInput("createGithubReleases"),
|
||||
});
|
||||
|
||||
if (result.published) {
|
||||
core.setOutput("published", "true");
|
||||
core.setOutput(
|
||||
"publishedPackages",
|
||||
JSON.stringify(result.publishedPackages),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case hasChangesets && !hasNonEmptyChangesets:
|
||||
core.info("All changesets are empty; not creating PR");
|
||||
return;
|
||||
case hasChangesets:
|
||||
const { pullRequestNumber } = await runVersion({
|
||||
script: getOptionalInput("version"),
|
||||
githubToken,
|
||||
prTitle: getOptionalInput("title"),
|
||||
commitMessage: getOptionalInput("commit"),
|
||||
hasPublishScript,
|
||||
branch: getOptionalInput("branch"),
|
||||
});
|
||||
|
||||
if (pullRequestNumber !== -1) {
|
||||
core.setOutput("pullRequestNumber", String(pullRequestNumber));
|
||||
} else {
|
||||
core.setOutput("isNextAutomerge", String(true));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
})().catch((err) => {
|
||||
core.error(err);
|
||||
core.setFailed(err.message);
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { PreState, NewChangeset } from "@changesets/types";
|
||||
import { readPreState } from "@changesets/pre";
|
||||
import readChangesets from "@changesets/read";
|
||||
|
||||
export type ChangesetState = {
|
||||
preState: PreState | undefined;
|
||||
changesets: NewChangeset[];
|
||||
};
|
||||
|
||||
export default async function readChangesetState(
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<ChangesetState> {
|
||||
let preState = await readPreState(cwd);
|
||||
let changesets = await readChangesets(cwd);
|
||||
|
||||
if (preState !== undefined && preState.mode === "pre") {
|
||||
let changesetsToFilter = new Set(preState.changesets);
|
||||
|
||||
return {
|
||||
preState,
|
||||
changesets: changesets.filter((x) => !changesetsToFilter.has(x.id)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
preState: undefined,
|
||||
changesets,
|
||||
};
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
import fixturez from "fixturez";
|
||||
import * as github from "@actions/github";
|
||||
import * as githubUtils from "@actions/github/lib/utils";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import writeChangeset from "@changesets/write";
|
||||
import { Changeset } from "@changesets/types";
|
||||
import { runVersion } from "./run";
|
||||
|
||||
jest.mock("@actions/github", () => ({
|
||||
context: {
|
||||
repo: {
|
||||
owner: "changesets",
|
||||
repo: "action",
|
||||
},
|
||||
ref: "refs/heads/some-branch",
|
||||
sha: "xeac7",
|
||||
},
|
||||
}));
|
||||
jest.mock("@actions/github/lib/utils", () => ({
|
||||
GitHub: {
|
||||
plugin: () => {
|
||||
// function necessary to be used as constructor
|
||||
return function () {
|
||||
return {
|
||||
rest: mockedGithubMethods,
|
||||
};
|
||||
};
|
||||
},
|
||||
},
|
||||
getOctokitOptions: jest.fn(),
|
||||
}));
|
||||
jest.mock("./gitUtils");
|
||||
|
||||
let mockedGithubMethods = {
|
||||
pulls: {
|
||||
create: jest.fn(),
|
||||
list: jest.fn(),
|
||||
},
|
||||
repos: {
|
||||
createRelease: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
let f = fixturez(__dirname);
|
||||
|
||||
const linkNodeModules = async (cwd: string) => {
|
||||
await fs.symlink(
|
||||
path.join(__dirname, "..", "node_modules"),
|
||||
path.join(cwd, "node_modules"),
|
||||
);
|
||||
};
|
||||
const writeChangesets = (changesets: Changeset[], cwd: string) => {
|
||||
return Promise.all(changesets.map((commit) => writeChangeset(commit, cwd)));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("version", () => {
|
||||
it("creates simple PR", async () => {
|
||||
let cwd = f.copy("simple-project");
|
||||
linkNodeModules(cwd);
|
||||
|
||||
mockedGithubMethods.pulls.list.mockImplementationOnce(() => ({ data: [] }));
|
||||
|
||||
mockedGithubMethods.pulls.create.mockImplementationOnce(() => ({
|
||||
data: { number: 123 },
|
||||
}));
|
||||
|
||||
await writeChangesets(
|
||||
[
|
||||
{
|
||||
releases: [
|
||||
{
|
||||
name: "simple-project-pkg-a",
|
||||
type: "minor",
|
||||
},
|
||||
{
|
||||
name: "simple-project-pkg-b",
|
||||
type: "minor",
|
||||
},
|
||||
],
|
||||
summary: "Awesome feature",
|
||||
},
|
||||
],
|
||||
cwd,
|
||||
);
|
||||
|
||||
await runVersion({
|
||||
githubToken: "@@GITHUB_TOKEN",
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(mockedGithubMethods.pulls.create.mock.calls[0]).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("only includes bumped packages in the PR body", async () => {
|
||||
let cwd = f.copy("simple-project");
|
||||
linkNodeModules(cwd);
|
||||
|
||||
mockedGithubMethods.pulls.list.mockImplementationOnce(() => ({ data: [] }));
|
||||
|
||||
mockedGithubMethods.pulls.create.mockImplementationOnce(() => ({
|
||||
data: { number: 123 },
|
||||
}));
|
||||
|
||||
await writeChangesets(
|
||||
[
|
||||
{
|
||||
releases: [
|
||||
{
|
||||
name: "simple-project-pkg-a",
|
||||
type: "minor",
|
||||
},
|
||||
],
|
||||
summary: "Awesome feature",
|
||||
},
|
||||
],
|
||||
cwd,
|
||||
);
|
||||
|
||||
await runVersion({
|
||||
githubToken: "@@GITHUB_TOKEN",
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(mockedGithubMethods.pulls.create.mock.calls[0]).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("doesn't include ignored package that got a dependency update in the PR body", async () => {
|
||||
let cwd = f.copy("ignored-package");
|
||||
linkNodeModules(cwd);
|
||||
|
||||
mockedGithubMethods.pulls.list.mockImplementationOnce(() => ({ data: [] }));
|
||||
|
||||
mockedGithubMethods.pulls.create.mockImplementationOnce(() => ({
|
||||
data: { number: 123 },
|
||||
}));
|
||||
|
||||
await writeChangesets(
|
||||
[
|
||||
{
|
||||
releases: [
|
||||
{
|
||||
name: "ignored-package-pkg-b",
|
||||
type: "minor",
|
||||
},
|
||||
],
|
||||
summary: "Awesome feature",
|
||||
},
|
||||
],
|
||||
cwd,
|
||||
);
|
||||
|
||||
await runVersion({
|
||||
githubToken: "@@GITHUB_TOKEN",
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(mockedGithubMethods.pulls.create.mock.calls[0]).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("does not include changelog entries if full message exceeds size limit", async () => {
|
||||
let cwd = f.copy("simple-project");
|
||||
linkNodeModules(cwd);
|
||||
|
||||
mockedGithubMethods.pulls.list.mockImplementationOnce(() => ({ data: [] }));
|
||||
|
||||
mockedGithubMethods.pulls.create.mockImplementationOnce(() => ({
|
||||
data: { number: 123 },
|
||||
}));
|
||||
|
||||
await writeChangesets(
|
||||
[
|
||||
{
|
||||
releases: [
|
||||
{
|
||||
name: "simple-project-pkg-a",
|
||||
type: "minor",
|
||||
},
|
||||
],
|
||||
summary: `# Non manus superum
|
||||
|
||||
## Nec cornibus aequa numinis multo onerosior adde
|
||||
|
||||
Lorem markdownum undas consumpserat malas, nec est lupus; memorant gentisque ab
|
||||
limine auctore. Eatque et promptu deficit, quam videtur aequa est **faciat**,
|
||||
locus. Potentia deus habebat pia quam qui coniuge frater, tibi habent fertque
|
||||
viribus. E et cognoscere arcus, lacus aut sic pro crimina fuit tum **auxilium**
|
||||
dictis, qua, in.
|
||||
|
||||
In modo. Nomen illa membra.
|
||||
|
||||
> Corpora gratissima parens montibus tum coeperat qua remulus caelum Helenamque?
|
||||
> Non poenae modulatur Amathunta in concita superi, procerum pariter rapto cornu
|
||||
> munera. Perrhaebum parvo manus contingere, morari, spes per totiens ut
|
||||
> dividite proculcat facit, visa.
|
||||
|
||||
Adspicit sequitur diffamatamque superi Phoebo qua quin lammina utque: per? Exit
|
||||
decus aut hac inpia, seducta mirantia extremo. Vidi pedes vetus. Saturnius
|
||||
fluminis divesque vulnere aquis parce lapsis rabie si visa fulmineis.
|
||||
`,
|
||||
},
|
||||
],
|
||||
cwd,
|
||||
);
|
||||
|
||||
await runVersion({
|
||||
githubToken: "@@GITHUB_TOKEN",
|
||||
cwd,
|
||||
prBodyMaxCharacters: 1000,
|
||||
});
|
||||
|
||||
expect(mockedGithubMethods.pulls.create.mock.calls[0]).toMatchSnapshot();
|
||||
expect(mockedGithubMethods.pulls.create.mock.calls[0][0].body).toMatch(
|
||||
/The changelog information of each package has been omitted from this message/,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not include any release information if a message with simplified release info exceeds size limit", async () => {
|
||||
let cwd = f.copy("simple-project");
|
||||
linkNodeModules(cwd);
|
||||
|
||||
mockedGithubMethods.pulls.list.mockImplementationOnce(() => ({ data: [] }));
|
||||
|
||||
mockedGithubMethods.pulls.create.mockImplementationOnce(() => ({
|
||||
data: { number: 123 },
|
||||
}));
|
||||
|
||||
await writeChangesets(
|
||||
[
|
||||
{
|
||||
releases: [
|
||||
{
|
||||
name: "simple-project-pkg-a",
|
||||
type: "minor",
|
||||
},
|
||||
],
|
||||
summary: `# Non manus superum
|
||||
|
||||
## Nec cornibus aequa numinis multo onerosior adde
|
||||
|
||||
Lorem markdownum undas consumpserat malas, nec est lupus; memorant gentisque ab
|
||||
limine auctore. Eatque et promptu deficit, quam videtur aequa est **faciat**,
|
||||
locus. Potentia deus habebat pia quam qui coniuge frater, tibi habent fertque
|
||||
viribus. E et cognoscere arcus, lacus aut sic pro crimina fuit tum **auxilium**
|
||||
dictis, qua, in.
|
||||
|
||||
In modo. Nomen illa membra.
|
||||
|
||||
> Corpora gratissima parens montibus tum coeperat qua remulus caelum Helenamque?
|
||||
> Non poenae modulatur Amathunta in concita superi, procerum pariter rapto cornu
|
||||
> munera. Perrhaebum parvo manus contingere, morari, spes per totiens ut
|
||||
> dividite proculcat facit, visa.
|
||||
|
||||
Adspicit sequitur diffamatamque superi Phoebo qua quin lammina utque: per? Exit
|
||||
decus aut hac inpia, seducta mirantia extremo. Vidi pedes vetus. Saturnius
|
||||
fluminis divesque vulnere aquis parce lapsis rabie si visa fulmineis.
|
||||
`,
|
||||
},
|
||||
],
|
||||
cwd,
|
||||
);
|
||||
|
||||
await runVersion({
|
||||
githubToken: "@@GITHUB_TOKEN",
|
||||
cwd,
|
||||
prBodyMaxCharacters: 500,
|
||||
});
|
||||
|
||||
expect(mockedGithubMethods.pulls.create.mock.calls[0]).toMatchSnapshot();
|
||||
expect(mockedGithubMethods.pulls.create.mock.calls[0][0].body).toMatch(
|
||||
/All release information have been omitted from this message, as the content exceeds the size limit/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,463 +0,0 @@
|
||||
import { exec, getExecOutput } from "@actions/exec";
|
||||
import { GitHub, getOctokitOptions } from "@actions/github/lib/utils";
|
||||
import * as github from "@actions/github";
|
||||
import * as core from "@actions/core";
|
||||
import fs from "fs-extra";
|
||||
import { getPackages, Package } from "@manypkg/get-packages";
|
||||
import path from "path";
|
||||
import * as semver from "semver";
|
||||
import { PreState } from "@changesets/types";
|
||||
import {
|
||||
getChangelogEntry,
|
||||
getChangedPackages,
|
||||
sortTheThings,
|
||||
getVersionsByDirectory,
|
||||
} from "./utils";
|
||||
import * as gitUtils from "./gitUtils";
|
||||
import readChangesetState from "./readChangesetState";
|
||||
import resolveFrom from "resolve-from";
|
||||
import { throttling } from "@octokit/plugin-throttling";
|
||||
|
||||
// GitHub Issues/PRs messages have a max size limit on the
|
||||
// message body payload.
|
||||
// `body is too long (maximum is 65536 characters)`.
|
||||
// To avoid that, we ensure to cap the message to 60k chars.
|
||||
const MAX_CHARACTERS_PER_MESSAGE = 60000;
|
||||
|
||||
const setupOctokit = (githubToken: string) => {
|
||||
return new (GitHub.plugin(throttling))(
|
||||
getOctokitOptions(githubToken, {
|
||||
throttle: {
|
||||
onRateLimit: (retryAfter, options: any, octokit, retryCount) => {
|
||||
core.warning(
|
||||
`Request quota exhausted for request ${options.method} ${options.url}`,
|
||||
);
|
||||
|
||||
if (retryCount <= 2) {
|
||||
core.info(`Retrying after ${retryAfter} seconds!`);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
onSecondaryRateLimit: (
|
||||
retryAfter,
|
||||
options: any,
|
||||
octokit,
|
||||
retryCount,
|
||||
) => {
|
||||
core.warning(
|
||||
`SecondaryRateLimit detected for request ${options.method} ${options.url}`,
|
||||
);
|
||||
|
||||
if (retryCount <= 2) {
|
||||
core.info(`Retrying after ${retryAfter} seconds!`);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const createRelease = async (
|
||||
octokit: ReturnType<typeof setupOctokit>,
|
||||
{ pkg, tagName }: { pkg: Package; tagName: string },
|
||||
) => {
|
||||
try {
|
||||
let changelogFileName = path.join(pkg.dir, "CHANGELOG.md");
|
||||
|
||||
let changelog = await fs.readFile(changelogFileName, "utf8");
|
||||
|
||||
let changelogEntry = getChangelogEntry(changelog, pkg.packageJson.version);
|
||||
if (!changelogEntry) {
|
||||
// we can find a changelog but not the entry for this version
|
||||
// if this is true, something has probably gone wrong
|
||||
throw new Error(
|
||||
`Could not find changelog entry for ${pkg.packageJson.name}@${pkg.packageJson.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
await octokit.rest.repos.createRelease({
|
||||
name: tagName,
|
||||
tag_name: tagName,
|
||||
body: changelogEntry.content,
|
||||
prerelease: pkg.packageJson.version.includes("-"),
|
||||
...github.context.repo,
|
||||
});
|
||||
} catch (err) {
|
||||
// if we can't find a changelog, the user has probably disabled changelogs
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"code" in err &&
|
||||
err.code !== "ENOENT"
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
type PublishOptions = {
|
||||
script: string;
|
||||
githubToken: string;
|
||||
createGithubReleases: boolean;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
type PublishedPackage = { name: string; version: string };
|
||||
|
||||
type PublishResult =
|
||||
| {
|
||||
published: true;
|
||||
publishedPackages: PublishedPackage[];
|
||||
}
|
||||
| {
|
||||
published: false;
|
||||
};
|
||||
|
||||
export async function runPublish({
|
||||
script,
|
||||
githubToken,
|
||||
createGithubReleases,
|
||||
cwd = process.cwd(),
|
||||
}: PublishOptions): Promise<PublishResult> {
|
||||
const octokit = setupOctokit(githubToken);
|
||||
|
||||
let [publishCommand, ...publishArgs] = script.split(/\s+/);
|
||||
|
||||
let changesetPublishOutput = await getExecOutput(
|
||||
publishCommand,
|
||||
publishArgs,
|
||||
{ cwd },
|
||||
);
|
||||
|
||||
await gitUtils.pushTags();
|
||||
|
||||
let { packages, tool } = await getPackages(cwd);
|
||||
let releasedPackages: Package[] = [];
|
||||
|
||||
if (tool !== "root") {
|
||||
let newTagRegex = /New tag:\s+(@[^/]+\/[^@]+|[^/]+)@([^\s]+)/;
|
||||
let packagesByName = new Map(packages.map((x) => [x.packageJson.name, x]));
|
||||
|
||||
for (let line of changesetPublishOutput.stdout.split("\n")) {
|
||||
let match = line.match(newTagRegex);
|
||||
if (match === null) {
|
||||
continue;
|
||||
}
|
||||
let pkgName = match[1];
|
||||
let pkg = packagesByName.get(pkgName);
|
||||
if (pkg === undefined) {
|
||||
throw new Error(
|
||||
`Package "${pkgName}" not found.` +
|
||||
"This is probably a bug in the action, please open an issue",
|
||||
);
|
||||
}
|
||||
releasedPackages.push(pkg);
|
||||
}
|
||||
|
||||
if (createGithubReleases) {
|
||||
await Promise.all(
|
||||
releasedPackages.map((pkg) =>
|
||||
createRelease(octokit, {
|
||||
pkg,
|
||||
tagName: `${pkg.packageJson.name}@${pkg.packageJson.version}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (packages.length === 0) {
|
||||
throw new Error(
|
||||
`No package found.` +
|
||||
"This is probably a bug in the action, please open an issue",
|
||||
);
|
||||
}
|
||||
let pkg = packages[0];
|
||||
let newTagRegex = /New tag:/;
|
||||
|
||||
for (let line of changesetPublishOutput.stdout.split("\n")) {
|
||||
let match = line.match(newTagRegex);
|
||||
|
||||
if (match) {
|
||||
releasedPackages.push(pkg);
|
||||
if (createGithubReleases) {
|
||||
await createRelease(octokit, {
|
||||
pkg,
|
||||
tagName: `v${pkg.packageJson.version}`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (releasedPackages.length) {
|
||||
return {
|
||||
published: true,
|
||||
publishedPackages: releasedPackages.map((pkg) => ({
|
||||
name: pkg.packageJson.name,
|
||||
version: pkg.packageJson.version,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return { published: false };
|
||||
}
|
||||
|
||||
const requireChangesetsCliPkgJson = (cwd: string) => {
|
||||
try {
|
||||
return require(resolveFrom(cwd, "@changesets/cli/package.json"));
|
||||
} catch (err) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"code" in err &&
|
||||
err.code === "MODULE_NOT_FOUND"
|
||||
) {
|
||||
throw new Error(
|
||||
`Have you forgotten to install \`@changesets/cli\` in "${cwd}"?`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
type GetMessageOptions = {
|
||||
hasPublishScript: boolean;
|
||||
branch: string;
|
||||
changedPackagesInfo: {
|
||||
highestLevel: number;
|
||||
private: boolean;
|
||||
content: string;
|
||||
header: string;
|
||||
}[];
|
||||
prBodyMaxCharacters: number;
|
||||
preState?: PreState;
|
||||
};
|
||||
|
||||
export async function getVersionPrBody({
|
||||
hasPublishScript,
|
||||
preState,
|
||||
changedPackagesInfo,
|
||||
prBodyMaxCharacters,
|
||||
branch,
|
||||
}: GetMessageOptions) {
|
||||
let messageHeader = `This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and ${
|
||||
hasPublishScript
|
||||
? `the packages will be published to npm automatically`
|
||||
: `publish to npm yourself or [setup this action to publish automatically](https://github.com/changesets/action#with-publishing)`
|
||||
}. If you're not ready to do a release yet, that's fine, whenever you add more changesets to ${branch}, this PR will be updated.
|
||||
`;
|
||||
let messagePrestate = !!preState
|
||||
? `⚠️⚠️⚠️⚠️⚠️⚠️
|
||||
|
||||
\`${branch}\` is currently in **pre mode** so this branch has prereleases rather than normal releases. If you want to exit prereleases, run \`changeset pre exit\` on \`${branch}\`.
|
||||
|
||||
⚠️⚠️⚠️⚠️⚠️⚠️
|
||||
`
|
||||
: "";
|
||||
let messageReleasesHeading = `# Releases`;
|
||||
|
||||
let fullMessage = [
|
||||
messageHeader,
|
||||
messagePrestate,
|
||||
messageReleasesHeading,
|
||||
...changedPackagesInfo.map((info) => `${info.header}\n\n${info.content}`),
|
||||
].join("\n");
|
||||
|
||||
// Check that the message does not exceed the size limit.
|
||||
// If not, omit the changelog entries of each package.
|
||||
if (fullMessage.length > prBodyMaxCharacters) {
|
||||
fullMessage = [
|
||||
messageHeader,
|
||||
messagePrestate,
|
||||
messageReleasesHeading,
|
||||
`\n> The changelog information of each package has been omitted from this message, as the content exceeds the size limit.\n`,
|
||||
...changedPackagesInfo.map((info) => `${info.header}\n\n`),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// Check (again) that the message is within the size limit.
|
||||
// If not, omit all release content this time.
|
||||
if (fullMessage.length > prBodyMaxCharacters) {
|
||||
fullMessage = [
|
||||
messageHeader,
|
||||
messagePrestate,
|
||||
messageReleasesHeading,
|
||||
`\n> All release information have been omitted from this message, as the content exceeds the size limit.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return fullMessage;
|
||||
}
|
||||
|
||||
type VersionOptions = {
|
||||
script?: string;
|
||||
githubToken: string;
|
||||
cwd?: string;
|
||||
prTitle?: string;
|
||||
commitMessage?: string;
|
||||
hasPublishScript?: boolean;
|
||||
prBodyMaxCharacters?: number;
|
||||
branch?: string;
|
||||
};
|
||||
|
||||
type RunVersionResult = {
|
||||
pullRequestNumber: number;
|
||||
};
|
||||
|
||||
export async function runVersion({
|
||||
script,
|
||||
githubToken,
|
||||
cwd = process.cwd(),
|
||||
prTitle = "Version Packages",
|
||||
commitMessage = "Version Packages",
|
||||
hasPublishScript = false,
|
||||
prBodyMaxCharacters = MAX_CHARACTERS_PER_MESSAGE,
|
||||
branch,
|
||||
}: VersionOptions): Promise<RunVersionResult> {
|
||||
const octokit = setupOctokit(githubToken);
|
||||
|
||||
let repo = `${github.context.repo.owner}/${github.context.repo.repo}`;
|
||||
branch = branch ?? github.context.ref.replace("refs/heads/", "");
|
||||
let versionBranch = `changeset-release/${branch}`;
|
||||
|
||||
let { preState } = await readChangesetState(cwd);
|
||||
|
||||
await gitUtils.switchToMaybeExistingBranch(versionBranch);
|
||||
await gitUtils.reset(github.context.sha);
|
||||
|
||||
let versionsByDirectory = await getVersionsByDirectory(cwd);
|
||||
|
||||
if (script) {
|
||||
let [versionCommand, ...versionArgs] = script.split(/\s+/);
|
||||
await exec(versionCommand, versionArgs, { cwd });
|
||||
} else {
|
||||
let changesetsCliPkgJson = requireChangesetsCliPkgJson(cwd);
|
||||
let cmd = semver.lt(changesetsCliPkgJson.version, "2.0.0")
|
||||
? "bump"
|
||||
: "version";
|
||||
await exec("node", [resolveFrom(cwd, "@changesets/cli/bin.js"), cmd], {
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const existingPullRequestsPromise = octokit.rest.pulls.list({
|
||||
...github.context.repo,
|
||||
state: "open",
|
||||
head: `${github.context.repo.owner}:${versionBranch}`,
|
||||
base: branch,
|
||||
});
|
||||
let changedPackages = await getChangedPackages(cwd, versionsByDirectory);
|
||||
let changedPackagesInfoPromises = Promise.all(
|
||||
changedPackages.map(async (pkg) => {
|
||||
let changelogContents = await fs.readFile(
|
||||
path.join(pkg.dir, "CHANGELOG.md"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
let entry = getChangelogEntry(changelogContents, pkg.packageJson.version);
|
||||
return {
|
||||
highestLevel: entry.highestLevel,
|
||||
private: !!pkg.packageJson.private,
|
||||
content: entry.content,
|
||||
header: `## ${pkg.packageJson.name}@${pkg.packageJson.version}`,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const finalPrTitle = `${prTitle}${!!preState ? ` (${preState.tag})` : ""}`;
|
||||
const isNext = preState?.tag === "next";
|
||||
|
||||
// project with `commit: true` setting could have already committed files
|
||||
if (!(await gitUtils.checkIfClean())) {
|
||||
const finalCommitMessage = `${commitMessage}${
|
||||
!!preState ? ` (${preState.tag})` : ""
|
||||
}`;
|
||||
await gitUtils.commitAll(finalCommitMessage);
|
||||
}
|
||||
|
||||
await gitUtils.push(versionBranch, { force: true });
|
||||
|
||||
let existingPullRequests = await existingPullRequestsPromise;
|
||||
core.info(JSON.stringify(existingPullRequests.data, null, 2));
|
||||
|
||||
const changedPackagesInfo = (await changedPackagesInfoPromises)
|
||||
.filter((x) => x)
|
||||
.sort(sortTheThings);
|
||||
|
||||
let prBody = await getVersionPrBody({
|
||||
hasPublishScript,
|
||||
preState,
|
||||
branch,
|
||||
changedPackagesInfo,
|
||||
prBodyMaxCharacters,
|
||||
});
|
||||
|
||||
if (isNext) {
|
||||
core.info("creating pull request for next release");
|
||||
const { data: newPullRequest } = await octokit.rest.pulls.create({
|
||||
base: branch,
|
||||
head: versionBranch,
|
||||
title: finalPrTitle,
|
||||
body: prBody,
|
||||
...github.context.repo,
|
||||
});
|
||||
|
||||
// Add a special label
|
||||
await octokit.rest.issues.addLabels({
|
||||
...github.context.repo,
|
||||
issue_number: newPullRequest.number,
|
||||
labels: ["automated-version-pr"],
|
||||
});
|
||||
|
||||
// Run Release workflow
|
||||
await octokit.rest.actions.createWorkflowDispatch({
|
||||
...github.context.repo,
|
||||
workflow_id: "release.yml",
|
||||
ref: versionBranch,
|
||||
});
|
||||
|
||||
// Immediately merge it
|
||||
await octokit.rest.pulls.merge({
|
||||
...github.context.repo,
|
||||
pull_number: newPullRequest.number,
|
||||
merge_method: "squash",
|
||||
commit_title: finalPrTitle,
|
||||
});
|
||||
|
||||
return {
|
||||
pullRequestNumber: newPullRequest.number,
|
||||
};
|
||||
} else {
|
||||
if (existingPullRequests.data.length === 0) {
|
||||
core.info("creating pull request");
|
||||
const { data: newPullRequest } = await octokit.rest.pulls.create({
|
||||
base: branch,
|
||||
head: versionBranch,
|
||||
title: finalPrTitle,
|
||||
body: prBody,
|
||||
...github.context.repo,
|
||||
});
|
||||
|
||||
return {
|
||||
pullRequestNumber: newPullRequest.number,
|
||||
};
|
||||
} else {
|
||||
const [pullRequest] = existingPullRequests.data;
|
||||
|
||||
core.info(`updating found pull request #${pullRequest.number}`);
|
||||
await octokit.rest.pulls.update({
|
||||
pull_number: pullRequest.number,
|
||||
title: finalPrTitle,
|
||||
body: prBody,
|
||||
...github.context.repo,
|
||||
state: "open",
|
||||
});
|
||||
|
||||
return {
|
||||
pullRequestNumber: pullRequest.number,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { getChangelogEntry, BumpLevels, sortTheThings } from "./utils";
|
||||
|
||||
let changelog = `# @keystone-alpha/email
|
||||
|
||||
## 3.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [19fe6c1b](https://github.com/keystonejs/keystone-5/commit/19fe6c1b):
|
||||
|
||||
Move frontmatter in docs into comments
|
||||
|
||||
## 3.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- [2164a779](https://github.com/keystonejs/keystone-5/commit/2164a779):
|
||||
|
||||
- Replace jade with pug because Jade was renamed to Pug, and \`jade\` package is outdated
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [81dc0be5](https://github.com/keystonejs/keystone-5/commit/81dc0be5):
|
||||
|
||||
- Update dependencies
|
||||
|
||||
## 2.0.0
|
||||
|
||||
- [patch][b69fb9b7](https://github.com/keystonejs/keystone-5/commit/b69fb9b7):
|
||||
|
||||
- Update dev devependencies
|
||||
|
||||
- [major][f97e4ecf](https://github.com/keystonejs/keystone-5/commit/f97e4ecf):
|
||||
|
||||
- Export { emailSender } as the API, rather than a default export
|
||||
|
||||
## 1.0.2
|
||||
|
||||
- [patch][7417ea3a](https://github.com/keystonejs/keystone-5/commit/7417ea3a):
|
||||
|
||||
- Update patch-level dependencies
|
||||
|
||||
## 1.0.1
|
||||
|
||||
- [patch][1f0bc236](https://github.com/keystonejs/keystone-5/commit/1f0bc236):
|
||||
|
||||
- Update the package.json author field to "The Keystone Development Team"
|
||||
|
||||
## 1.0.0
|
||||
|
||||
- [major] 8b6734ae:
|
||||
|
||||
- This is the first release of keystone-alpha (previously voussoir).
|
||||
All packages in the \`@voussoir\` namespace are now available in the \`@keystone-alpha\` namespace, starting at version \`1.0.0\`.
|
||||
To upgrade your project you must update any \`@voussoir/<foo>\` dependencies in \`package.json\` to point to \`@keystone-alpha/<foo>: "^1.0.0"\` and update any \`require\`/\`import\` statements in your code.
|
||||
|
||||
# @voussoir/email
|
||||
|
||||
## 0.0.2
|
||||
|
||||
- [patch] 113e16d4:
|
||||
|
||||
- Remove unused dependencies
|
||||
|
||||
- [patch] 625c1a6d:
|
||||
|
||||
- Update mjml-dependency
|
||||
`;
|
||||
|
||||
test("it works", () => {
|
||||
let entry = getChangelogEntry(changelog, "3.0.0");
|
||||
expect(entry.content).toMatchSnapshot();
|
||||
expect(entry.highestLevel).toBe(BumpLevels.major);
|
||||
});
|
||||
|
||||
test("it works", () => {
|
||||
let entry = getChangelogEntry(changelog, "3.0.1");
|
||||
expect(entry.content).toMatchSnapshot();
|
||||
expect(entry.highestLevel).toBe(BumpLevels.patch);
|
||||
});
|
||||
|
||||
test("it sorts the things right", () => {
|
||||
let things = [
|
||||
{
|
||||
name: "a",
|
||||
highestLevel: BumpLevels.major,
|
||||
private: true,
|
||||
},
|
||||
{
|
||||
name: "b",
|
||||
highestLevel: BumpLevels.patch,
|
||||
private: false,
|
||||
},
|
||||
{
|
||||
name: "c",
|
||||
highestLevel: BumpLevels.major,
|
||||
private: false,
|
||||
},
|
||||
];
|
||||
expect(things.sort(sortTheThings)).toMatchSnapshot();
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import unified from "unified";
|
||||
import remarkParse from "remark-parse";
|
||||
import remarkStringify from "remark-stringify";
|
||||
// @ts-ignore
|
||||
import mdastToString from "mdast-util-to-string";
|
||||
import { getPackages, Package } from "@manypkg/get-packages";
|
||||
|
||||
export const BumpLevels = {
|
||||
dep: 0,
|
||||
patch: 1,
|
||||
minor: 2,
|
||||
major: 3,
|
||||
} as const;
|
||||
|
||||
export async function getVersionsByDirectory(cwd: string) {
|
||||
let { packages } = await getPackages(cwd);
|
||||
return new Map(packages.map((x) => [x.dir, x.packageJson.version]));
|
||||
}
|
||||
|
||||
export async function getChangedPackages(
|
||||
cwd: string,
|
||||
previousVersions: Map<string, string>,
|
||||
) {
|
||||
let { packages } = await getPackages(cwd);
|
||||
let changedPackages = new Set<Package>();
|
||||
|
||||
for (let pkg of packages) {
|
||||
const previousVersion = previousVersions.get(pkg.dir);
|
||||
if (previousVersion !== pkg.packageJson.version) {
|
||||
changedPackages.add(pkg);
|
||||
}
|
||||
}
|
||||
|
||||
return [...changedPackages];
|
||||
}
|
||||
|
||||
export function getChangelogEntry(changelog: string, version: string) {
|
||||
let ast = unified().use(remarkParse).parse(changelog);
|
||||
|
||||
let highestLevel: number = BumpLevels.dep;
|
||||
|
||||
let nodes = ast.children as Array<any>;
|
||||
let headingStartInfo:
|
||||
| {
|
||||
index: number;
|
||||
depth: number;
|
||||
}
|
||||
| undefined;
|
||||
let endIndex: number | undefined;
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
let node = nodes[i];
|
||||
if (node.type === "heading") {
|
||||
let stringified: string = mdastToString(node);
|
||||
let match = stringified.toLowerCase().match(/(major|minor|patch)/);
|
||||
if (match !== null) {
|
||||
let level = BumpLevels[match[0] as "major" | "minor" | "patch"];
|
||||
highestLevel = Math.max(level, highestLevel);
|
||||
}
|
||||
if (headingStartInfo === undefined && stringified === version) {
|
||||
headingStartInfo = {
|
||||
index: i,
|
||||
depth: node.depth,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
endIndex === undefined &&
|
||||
headingStartInfo !== undefined &&
|
||||
headingStartInfo.depth === node.depth
|
||||
) {
|
||||
endIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (headingStartInfo) {
|
||||
ast.children = (ast.children as any).slice(
|
||||
headingStartInfo.index + 1,
|
||||
endIndex,
|
||||
);
|
||||
}
|
||||
return {
|
||||
content: unified().use(remarkStringify).stringify(ast),
|
||||
highestLevel: highestLevel,
|
||||
};
|
||||
}
|
||||
|
||||
export function sortTheThings(
|
||||
a: { private: boolean; highestLevel: number },
|
||||
b: { private: boolean; highestLevel: number },
|
||||
) {
|
||||
if (a.private === b.private) {
|
||||
return b.highestLevel - a.highestLevel;
|
||||
}
|
||||
if (a.private) {
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Basic Options */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
"target": "esnext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
|
||||
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
|
||||
// "lib": [], /* Specify library files to be included in the compilation. */
|
||||
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||
// "declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||
// "sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
// "outDir": "./", /* Redirect output structure to the directory. */
|
||||
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
"noEmit": true /* Do not emit outputs. */,
|
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
"isolatedModules": true /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */,
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* Enable strict null checks. */
|
||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
|
||||
/* Additional Checks */
|
||||
// "noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
|
||||
/* Module Resolution Options */
|
||||
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
|
||||
/* Source Map Options */
|
||||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
|
||||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||
|
||||
/* Experimental Options */
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
type Opts = {
|
||||
glob?: string | Array<string>;
|
||||
root?: string;
|
||||
cleanup?: boolean;
|
||||
};
|
||||
|
||||
declare module "fixturez" {
|
||||
export default function (
|
||||
cwd: string,
|
||||
opts?: Opts,
|
||||
): {
|
||||
find: (a: string) => string;
|
||||
temp: () => string;
|
||||
copy: (a: string) => string;
|
||||
cleanup: () => any;
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,10 @@ env:
|
||||
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
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -25,10 +25,20 @@ env:
|
||||
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.
|
||||
# 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:
|
||||
examples:
|
||||
name: ${{ matrix.example }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: release / pre
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
scope:
|
||||
description: "What to release"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- monorepo
|
||||
- cli
|
||||
- angular
|
||||
suffix:
|
||||
description: "Version suffix (e.g. 'fix-user-issue'). Leave blank for timestamp."
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Dry run (don't actually publish)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NX_VERBOSE_LOGGING: true
|
||||
|
||||
jobs:
|
||||
prerelease:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: "10.13.1"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
registry-url: https://registry.npmjs.org
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Configure npm auth
|
||||
run: |
|
||||
npm config set "//registry.npmjs.org/:_authToken" "${NPM_TOKEN}"
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Publish prerelease
|
||||
run: |
|
||||
ARGS="--scope ${{ inputs.scope }}"
|
||||
if [ -n "${{ inputs.suffix }}" ]; then
|
||||
ARGS="$ARGS --suffix ${{ inputs.suffix }}"
|
||||
fi
|
||||
if [ "${{ inputs.dry_run }}" == "true" ]; then
|
||||
ARGS="$ARGS --dry-run"
|
||||
fi
|
||||
pnpm tsx scripts/release/prerelease.ts $ARGS
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -1,5 +1,11 @@
|
||||
name: 🚀 pkg-pr-new
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "packages/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "packages/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -17,7 +23,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -26,9 +32,11 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: "package.json"
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Configure Nx Cloud environment
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
name: release / publish
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: publish-release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NX_VERBOSE_LOGGING: true
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Only run when a release PR is merged (not just closed)
|
||||
if: >
|
||||
github.event.pull_request.merged == true &&
|
||||
startsWith(github.event.pull_request.head.ref, 'release/publish/')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Extract scope from branch
|
||||
id: meta
|
||||
run: |
|
||||
BRANCH="${{ github.event.pull_request.head.ref }}"
|
||||
# Branch format: release/publish/<scope>/v<version>
|
||||
SCOPE=$(echo "$BRANCH" | sed 's|release/publish/\([^/]*\)/v.*|\1|')
|
||||
echo "scope=$SCOPE" >> $GITHUB_OUTPUT
|
||||
echo "Detected scope: $SCOPE"
|
||||
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: "10.13.1"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
registry-url: https://registry.npmjs.org
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Configure npm auth
|
||||
run: |
|
||||
npm config set "//registry.npmjs.org/:_authToken" "${NPM_TOKEN}"
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Publish to npm
|
||||
id: publish
|
||||
run: pnpm tsx scripts/release/publish-release.ts --scope ${{ steps.meta.outputs.scope }}
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}
|
||||
|
||||
- name: Configure git user
|
||||
run: |
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
|
||||
- name: Check for pre-existing tags
|
||||
run: |
|
||||
SCOPE="${{ steps.meta.outputs.scope }}"
|
||||
VERSION="${{ steps.publish.outputs.version }}"
|
||||
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
|
||||
run: |
|
||||
SCOPE="${{ steps.meta.outputs.scope }}"
|
||||
VERSION="${{ steps.publish.outputs.version }}"
|
||||
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
|
||||
uses: actions/github-script@v7
|
||||
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
|
||||
run: |
|
||||
echo "## Release Published" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Scope:** ${{ steps.meta.outputs.scope }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ steps.publish.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Tag:** ${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -1,48 +0,0 @@
|
||||
name: publish / custom
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
env:
|
||||
NX_VERBOSE_LOGGING: true
|
||||
|
||||
jobs:
|
||||
custom-pre:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: "10.13.1"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
registry-url: https://registry.npmjs.org
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Configure Nx Cloud environment
|
||||
run: |
|
||||
echo "NX_CI_EXECUTION_ID=${{ github.run_id }}-${{ github.run_attempt }}-publish-custom" >> $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
|
||||
|
||||
- name: Publish snapshot
|
||||
run: ./scripts/release/publish-snapshot-release.sh
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -1,176 +0,0 @@
|
||||
name: publish / release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
NX_VERBOSE_LOGGING: true
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: "10.13.1"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
registry-url: https://registry.npmjs.org
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Configure npm auth
|
||||
run: |
|
||||
npm config set "//registry.npmjs.org/:_authToken" "${NPM_TOKEN}"
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Validate release package allowlist
|
||||
run: pnpm ci:check-release-plan
|
||||
|
||||
- name: Create Release PR or Publish
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
version: pnpm changeset version
|
||||
publish: pnpm ci:publish-release
|
||||
commit: "chore: version packages"
|
||||
title: "chore: version packages"
|
||||
createGithubReleases: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
- name: Get published version
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
id: release-version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./packages/react-core/package.json').version")
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
echo "is_stable=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_stable=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Configure git user for tagging
|
||||
if: steps.changesets.outputs.published == 'true' && steps.release-version.outputs.is_stable == 'true'
|
||||
run: |
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
|
||||
- name: Generate release changelog
|
||||
if: steps.changesets.outputs.published == 'true' && steps.release-version.outputs.is_stable == 'true'
|
||||
run: |
|
||||
node -p "require('./scripts/release/generate-changelog')('${{ steps.release-version.outputs.version }}')" > changelog.txt
|
||||
|
||||
- name: Ensure stable release tag
|
||||
if: steps.changesets.outputs.published == 'true' && steps.release-version.outputs.is_stable == 'true'
|
||||
id: release-tag
|
||||
run: |
|
||||
TAG="v${{ steps.release-version.outputs.version }}"
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
|
||||
echo "created=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
git tag -a "$TAG" -m "Release ${{ steps.release-version.outputs.version }}"
|
||||
git push origin "$TAG"
|
||||
echo "created=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create or update GitHub release
|
||||
if: steps.changesets.outputs.published == 'true' && steps.release-version.outputs.is_stable == 'true'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
RELEASE_TAG: v${{ steps.release-version.outputs.version }}
|
||||
RELEASE_NAME: v${{ steps.release-version.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 name = process.env.RELEASE_NAME;
|
||||
const body = fs.readFileSync("./changelog.txt", "utf8");
|
||||
|
||||
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: Re-enter next prerelease mode
|
||||
if: steps.changesets.outputs.published == 'true' && steps.release-version.outputs.is_stable == 'true'
|
||||
run: |
|
||||
if [[ -f .changeset/pre.json ]]; then
|
||||
echo "Changesets prerelease mode is already enabled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pnpm changeset pre enter next
|
||||
|
||||
- name: Create or update prerelease mode PR
|
||||
if: steps.changesets.outputs.published == 'true' && steps.release-version.outputs.is_stable == 'true'
|
||||
id: prerelease-pr
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: changeset-release/re-enter-next
|
||||
delete-branch: true
|
||||
add-paths: .changeset/pre.json
|
||||
commit-message: "chore: re-enter next prerelease mode"
|
||||
title: "chore: re-enter next prerelease mode"
|
||||
body: |
|
||||
This PR was created automatically after publishing a stable release.
|
||||
|
||||
It re-enters Changesets prerelease mode for `next` so future release PRs continue on the `next` channel.
|
||||
@@ -0,0 +1,538 @@
|
||||
name: "Showcase: Aimock E2E Tests"
|
||||
|
||||
# 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 (must ship aimock_toggle.py)"
|
||||
required: true
|
||||
# Only crewai-crews currently ships aimock_toggle.py and exercises
|
||||
# the AIMOCK_URL path end-to-end. Restricting the enum here 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 instead of a clear "no toggle shipped"
|
||||
# error. When a new Python slug adds aimock_toggle.py, append it here.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# Single-choice enum UX note: the GitHub Actions UI pre-selects the
|
||||
# only option when a `choice` has one entry. That IS the intended
|
||||
# experience here — with exactly one valid slug today, showing a
|
||||
# disabled dropdown matches what "the operator must pick a slug"
|
||||
# reduces to when the valid set has size one. Do not add a sentinel
|
||||
# option (e.g. "--choose--") to force a picker — sentinel values
|
||||
# would need separate validation and re-introduce the silent-fallback
|
||||
# class of bug the comment-path extractor was hardened against.
|
||||
type: choice
|
||||
options:
|
||||
- crewai-crews
|
||||
|
||||
# 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@v7
|
||||
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@v4
|
||||
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@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
- uses: pnpm/action-setup@v4.4.0
|
||||
with:
|
||||
version: "10.13.1"
|
||||
|
||||
- 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/packages/$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/packages/$SLUG" ]; then
|
||||
echo "::error::Slug '$SLUG' does not map to showcase/packages/$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/packages/$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
|
||||
# The workflow's downstream steps (dev-server + Playwright) assume
|
||||
# a Python agent listening on :8000. Dispatching a TS (mastra) or
|
||||
# Java (spring-ai) slug would skip the Python agent start step and
|
||||
# then fail with a misleading Playwright timeout. Short-circuit
|
||||
# with a clear error instead — the workflow_dispatch enum narrows
|
||||
# this at the UI layer, but a comment-trigger slug bypasses that.
|
||||
if [ -f "$PKG_DIR/src/aimock_toggle.py" ]; then
|
||||
echo "ships_toggle=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "::error::Slug '$SLUG' does not ship src/aimock_toggle.py — this workflow only exercises packages that wire AIMOCK_URL end-to-end. Add aimock_toggle.py (and requirements.txt) to the package first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Start aimock
|
||||
run: |
|
||||
# Pin aimock to a known-good floor (caret = safe patch/minor).
|
||||
# An unrestricted `@latest` means a bad aimock publish silently
|
||||
# poisons CI for everyone; pinning makes the upgrade explicit and
|
||||
# keeps this PR's CI signal reproducible.
|
||||
npm install -g "@copilotkit/aimock@^1.14.3" --ignore-scripts
|
||||
# Invoke the installed global binary directly rather than `npx`.
|
||||
# `npx @copilotkit/aimock@^1.14.3` re-resolves the spec against the
|
||||
# registry and MAY re-fetch a package even when an identical global
|
||||
# install exists — which would defeat `--ignore-scripts` on the
|
||||
# npm-install step above (npx's transient install does not inherit
|
||||
# that flag) and would also defeat the caret pin if a new patch
|
||||
# published between those two invocations.
|
||||
#
|
||||
# `npm prefix -g` returns the npm global prefix; its `bin/` subdir
|
||||
# holds globally-installed binaries. We deliberately avoid `npm bin
|
||||
# -g` here: the `bin` subcommand was removed in npm 9.0.0 and
|
||||
# `setup-node@v4` with node 22.x ships npm 10+, so `npm bin -g`
|
||||
# would exit with "Unknown command: 'bin'" and the existence check
|
||||
# below would fire every run. `npm prefix -g` has been stable since
|
||||
# npm 7 and returns the prefix on every supported version. The
|
||||
# `$(npm prefix -g)/bin` dir is also on PATH via setup-node, so we
|
||||
# could call `aimock` directly — keeping the absolute-path pattern
|
||||
# + existence check as defense-in-depth against a PATH-shadowing
|
||||
# binary sneaking in from a prior step on the runner.
|
||||
AIMOCK_BIN="$(npm prefix -g)/bin/aimock"
|
||||
if [ ! -x "$AIMOCK_BIN" ]; then
|
||||
echo "::error::aimock binary not found at $AIMOCK_BIN after global install"
|
||||
exit 1
|
||||
fi
|
||||
"$AIMOCK_BIN" --port 4010 --host 127.0.0.1 --fixtures showcase/aimock/feature-parity.json --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@v5
|
||||
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/packages/${{ steps.slug.outputs.slug }}/requirements.txt
|
||||
|
||||
- name: Start Python agent
|
||||
if: steps.pkg-type.outputs.has_python == 'true'
|
||||
# NOTE: this workflow tests the SOURCE PACKAGE directly
|
||||
# (showcase/packages/<slug>), whose dev script binds the agent on port
|
||||
# 8000. Generated STARTERS (showcase/starters/<slug>) instead bind on
|
||||
# port 8123 — that's the production scaffold users copy. If you're
|
||||
# debugging a scaffolded starter, the health checks here won't apply.
|
||||
run: |
|
||||
SLUG="${{ steps.slug.outputs.slug }}"
|
||||
cd "showcase/packages/$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.
|
||||
#
|
||||
# If a future requirements.txt needs an even stronger guarantee,
|
||||
# `--require-hashes` + a fully hash-pinned requirements.txt blocks
|
||||
# swap-in attacks while allowing the pinned source build to run.
|
||||
pip install --prefer-binary -r requirements.txt
|
||||
# All currently-dispatchable slugs (the `workflow_dispatch` enum +
|
||||
# the "ships_toggle" short-circuit in the step above) ship
|
||||
# src/agent_server.py, so the `agent_server:app` entrypoint is the
|
||||
# only path actually exercised by this workflow today. The
|
||||
# existence check below is retained as defense-in-depth: a future
|
||||
# refactor that removes agent_server.py from a supported slug
|
||||
# should fail loudly here rather than silently fall through to a
|
||||
# guessed module name and then die mid-Playwright.
|
||||
if [ ! -f "src/agent_server.py" ]; then
|
||||
echo "::error::Slug '$SLUG' is missing src/agent_server.py — this workflow requires the FastAPI entrypoint. Add the file to the package or widen this step."
|
||||
exit 1
|
||||
fi
|
||||
export PYTHONPATH="$PWD/src:${PYTHONPATH:-}"
|
||||
APP_MODULE="agent_server:app"
|
||||
# Set AIMOCK_URL ONLY (not OPENAI_BASE_URL). Packages that ship
|
||||
# aimock_toggle.py MUST prove the toggle itself wires OPENAI_BASE_URL
|
||||
# + LITELLM_API_BASE + dummy key. Pre-setting OPENAI_BASE_URL here
|
||||
# would make a green E2E indistinguishable from "toggle worked"
|
||||
# vs "OPENAI_BASE_URL was already set before the toggle ran" — so
|
||||
# we deliberately leave the rest for configure_aimock() to inject.
|
||||
# OPENAI_API_KEY is left unset so the toggle's dummy-key injection
|
||||
# path is exercised too.
|
||||
AIMOCK_URL=http://localhost:4010/v1 \
|
||||
python -m uvicorn "$APP_MODULE" --host 127.0.0.1 --port 8000 &
|
||||
# Wait for agent to be ready. CrewAI's cold import (litellm + the
|
||||
# full crew graph) can exceed 60s on a cold runner, so give it 90s
|
||||
# (45 iterations × 2s) before declaring failure. Mirrors the aimock
|
||||
# start pattern: loop + hard-fail so a cryptic Playwright timeout
|
||||
# doesn't mask a bind/startup failure here.
|
||||
#
|
||||
# Only probe `/health` — the root `/` of a FastAPI app is typically
|
||||
# a POST endpoint (AG-UI SSE stream) that fails `curl -sf`. Probing
|
||||
# `/` was copy-paste residue from a prior iteration and added no
|
||||
# signal (the check always failed, which made the fallback dead
|
||||
# code). If a future Python agent doesn't expose `/health`, add a
|
||||
# dedicated readiness endpoint instead of reintroducing `/`.
|
||||
for i in $(seq 1 45); do
|
||||
curl -sf --max-time 2 --connect-timeout 1 http://localhost:8000/health > /dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
curl -sf --max-time 2 --connect-timeout 1 http://localhost:8000/health > /dev/null 2>&1 \
|
||||
|| { echo "Python agent failed to start on :8000"; exit 1; }
|
||||
|
||||
- name: Install package dependencies
|
||||
run: |
|
||||
SLUG="${{ steps.slug.outputs.slug }}"
|
||||
cd "showcase/packages/$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 }}"
|
||||
cd "showcase/packages/$SLUG"
|
||||
# Invoke `next dev` directly instead of `pnpm dev` — the package's
|
||||
# `pnpm dev` script spawns a SECOND uvicorn on :8000 via concurrently,
|
||||
# but the previous "Start Python agent" step already bound :8000. A
|
||||
# second uvicorn bind there would fail with EADDRINUSE and silently
|
||||
# race the Playwright test against whichever agent happened to win
|
||||
# the port. Running Next directly also keeps the AIMOCK_URL env flow
|
||||
# clean (only the Python agent reads AIMOCK_URL).
|
||||
#
|
||||
# `OPENAI_BASE_URL` + `OPENAI_API_KEY` on Next here are DEFENSIVE ONLY.
|
||||
# In the CrewAI showcase, Next proxies chat traffic to the Python
|
||||
# agent via the CopilotKit runtime — it does not call OpenAI directly.
|
||||
# Setting these on Next still matters if a future route in this
|
||||
# showcase adds a direct OpenAI call (server action, tool call, etc.),
|
||||
# because the default `OPENAI_API_KEY` would fall back to real OpenAI
|
||||
# and the test would pass/fail on real API traffic. The values here
|
||||
# keep that class of leak impossible even if the showcase changes.
|
||||
OPENAI_BASE_URL=http://localhost:4010/v1 \
|
||||
OPENAI_API_KEY=test-key \
|
||||
AGENT_URL=http://localhost:8000 \
|
||||
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/packages/${{ 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/packages/$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@v4
|
||||
with:
|
||||
name: playwright-report-${{ steps.slug.outputs.slug }}
|
||||
path: showcase/packages/${{ 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@v7
|
||||
# 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})`
|
||||
});
|
||||
@@ -30,7 +30,16 @@ jobs:
|
||||
name: Capture Preview GIFs
|
||||
runs-on: ubuntu-latest
|
||||
# Disabled — preview GIFs removed from repo, awaiting external storage setup
|
||||
if: false
|
||||
# Loop prevention: when re-enabled, the conditions below prevent an infinite cycle:
|
||||
# capture commits registry.json → push matches showcase/** → deploy runs →
|
||||
# workflow_run triggers capture again. We break the loop by skipping when either
|
||||
# trigger path was caused by this workflow's own commit (identified by commit message).
|
||||
if: >-
|
||||
false &&
|
||||
(github.event_name != 'workflow_run' ||
|
||||
!startsWith(github.event.workflow_run.head_commit.message, 'Auto-capture preview GIFs')) &&
|
||||
(github.event_name != 'push' ||
|
||||
!startsWith(github.event.head_commit.message, 'Auto-capture preview GIFs'))
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
+666
-1081
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ concurrency:
|
||||
jobs:
|
||||
sync-docs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -54,85 +54,410 @@ jobs:
|
||||
echo "No changes to sync"
|
||||
echo "action=none" >> "$GITHUB_OUTPUT"
|
||||
elif [ $EXIT_CODE -eq 0 ]; then
|
||||
echo "Clean transforms only — will auto-push"
|
||||
echo "Clean transforms only"
|
||||
echo "action=auto_push" >> "$GITHUB_OUTPUT"
|
||||
elif [ $EXIT_CODE -eq 3 ]; then
|
||||
echo "Has review items — will auto-push clean transforms + open PR for review items"
|
||||
echo "Has review items + clean transforms"
|
||||
echo "action=push_and_pr" >> "$GITHUB_OUTPUT"
|
||||
# The sync script writes review-items.txt and emits
|
||||
# review_items_file=<abs-path> directly to GITHUB_OUTPUT.
|
||||
# Verify the file actually exists so downstream steps don't
|
||||
# silently read an empty path.
|
||||
if [ ! -f review-items.txt ]; then
|
||||
echo "::warning::review-items.txt not found despite exit code 3 — sync script may have a bug"
|
||||
fi
|
||||
else
|
||||
echo "Sync failed with exit code $EXIT_CODE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Auto-push clean transforms directly to the showcase branch
|
||||
- name: Auto-push clean transforms
|
||||
# Generate a token from copilotkit-devops-bot (bypass actor on branch protection)
|
||||
- name: Generate bot token
|
||||
id: bot-token
|
||||
if: steps.sync.outputs.action == 'auto_push' || steps.sync.outputs.action == 'push_and_pr'
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: 1108748
|
||||
private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }}
|
||||
|
||||
# Create PR. For action=auto_push (no review items) the PR is
|
||||
# auto-merged via the devops bot (bypasses branch protection). For
|
||||
# action=push_and_pr the sync script has already written best-effort
|
||||
# 3-way merged content to disk — this step commits it and opens a PR
|
||||
# tagged [NEEDS REVIEW]. Auto-merge is DISABLED for needs-review PRs
|
||||
# so a human reconciles any upstream-wins overrides.
|
||||
- name: Create PR for docs sync
|
||||
id: push
|
||||
if: steps.sync.outputs.action == 'auto_push' || steps.sync.outputs.action == 'push_and_pr'
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git add showcase/shell/src/content/ showcase/shell/.docs-sync-sha
|
||||
CHANGED=$(git diff --cached --name-only | wc -l | tr -d ' ')
|
||||
git commit -m "Auto-sync docs from main ($(date +%Y-%m-%d))" || echo "Nothing to commit"
|
||||
git push origin "$SHOWCASE_BRANCH"
|
||||
echo "files_changed=${CHANGED}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Notify Slack (auto-push)
|
||||
if: steps.sync.outputs.action == 'auto_push'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{ "text": ":arrows_counterclockwise: *Docs sync*: auto-pushed ${{ steps.push.outputs.files_changed || '?' }} file(s) to showcase\n<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" }
|
||||
|
||||
# Open PR only for files needing human review
|
||||
- name: Create PR for review items
|
||||
if: steps.sync.outputs.action == 'push_and_pr'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ steps.bot-token.outputs.token }}
|
||||
ACTION: ${{ steps.sync.outputs.action }}
|
||||
REVIEW_ITEMS_FILE: ${{ steps.sync.outputs.review_items_file }}
|
||||
run: |
|
||||
# Trap-based cleanup so temp files are removed even on signal kill.
|
||||
CLEANUP_FILES=()
|
||||
cleanup() {
|
||||
for f in "${CLEANUP_FILES[@]}"; do
|
||||
[ -n "$f" ] && [ -e "$f" ] && rm -f "$f" || true
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
git config user.name "copilotkit-devops-bot[bot]"
|
||||
git config user.email "copilotkit-devops-bot[bot]@users.noreply.github.com"
|
||||
|
||||
SHORT_SHA=$(git rev-parse --short origin/main)
|
||||
BRANCH="docs-sync-review/${SHORT_SHA}"
|
||||
if [ "$ACTION" = "push_and_pr" ]; then
|
||||
# Dedupe against open needs-review PRs. If one already exists,
|
||||
# skip PR creation and let the Slack alert point at the existing
|
||||
# one (simpler than re-pushing to its branch).
|
||||
#
|
||||
# NOTE: `gh pr list --search "head:..."` is NOT supported —
|
||||
# GitHub's PR search syntax has no `head:` qualifier, so that
|
||||
# query always returns empty. Use jq on the full list instead
|
||||
# to filter by headRefName prefix client-side.
|
||||
# Fail the step on gh API failure rather than swallowing it —
|
||||
# `|| echo ""` would silently treat a transient 5xx as "no
|
||||
# existing PR" and open a duplicate. Retry once with backoff
|
||||
# to absorb blips; if both attempts fail, exit non-zero.
|
||||
gh_pr_list_existing() {
|
||||
gh pr list \
|
||||
--state open \
|
||||
--base "$SHOWCASE_BRANCH" \
|
||||
--json number,url,headRefName \
|
||||
--jq '[.[] | select(.headRefName | startswith("docs-sync/needs-review/"))] | .[0].url'
|
||||
}
|
||||
if ! EXISTING_PR=$(gh_pr_list_existing 2>gh-err.txt); then
|
||||
echo "::warning::gh pr list failed on first attempt — retrying after 5s"
|
||||
cat gh-err.txt || true
|
||||
sleep 5
|
||||
if ! EXISTING_PR=$(gh_pr_list_existing 2>gh-err.txt); then
|
||||
echo "::error::gh pr list failed twice — aborting to avoid duplicate PR creation"
|
||||
cat gh-err.txt || true
|
||||
rm -f gh-err.txt
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
rm -f gh-err.txt
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
echo "Open needs-review PR already exists: $EXISTING_PR"
|
||||
echo "Skipping new PR — Slack alert will point at the existing one."
|
||||
echo "files_changed=0" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_url=${EXISTING_PR}" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_opened=false" >> "$GITHUB_OUTPUT"
|
||||
echo "needs_review=true" >> "$GITHUB_OUTPUT"
|
||||
echo "existing_pr=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BRANCH="docs-sync/needs-review/${SHORT_SHA}-$(date +%s)"
|
||||
COMMIT_SUBJECT="chore: docs sync from main — needs review ($(date +%Y-%m-%d))"
|
||||
else
|
||||
BRANCH="docs-sync/auto/${SHORT_SHA}-$(date +%s)"
|
||||
COMMIT_SUBJECT="chore: auto-sync docs from main ($(date +%Y-%m-%d))"
|
||||
fi
|
||||
|
||||
git checkout -b "$BRANCH"
|
||||
|
||||
# The review-items.txt was written by the sync script
|
||||
REVIEW_ITEMS=$(cat review-items.txt 2>/dev/null || echo "See sync output for details")
|
||||
# For push_and_pr: apply the conflict manifest FIRST (upstream-wins
|
||||
# content for conflicted files, written ONLY to the PR branch so
|
||||
# the main branch worktree stays as-is and future sync runs still
|
||||
# flag those files for review until this PR is merged).
|
||||
#
|
||||
# CRITICAL: This MUST run BEFORE `git add` below. `git add` stages
|
||||
# the current working tree, so any manifest files written after
|
||||
# that point would be left un-staged and never make it into the
|
||||
# commit.
|
||||
if [ "$ACTION" = "push_and_pr" ] && [ -f conflict-manifest.json ]; then
|
||||
CLEANUP_FILES+=("conflict-manifest.json")
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const manifest = JSON.parse(fs.readFileSync("conflict-manifest.json", "utf-8"));
|
||||
for (const entry of manifest) {
|
||||
const target = path.resolve(entry.showcasePath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, entry.content);
|
||||
console.log("Applied upstream-wins to PR branch: " + entry.showcasePath);
|
||||
}
|
||||
'
|
||||
fi
|
||||
|
||||
gh pr create \
|
||||
--title "Docs sync: review needed (${SHORT_SHA})" \
|
||||
--body "$(cat <<EOF
|
||||
## Docs Sync — Manual Review Needed
|
||||
# Stage AFTER manifest has been applied so conflicted files land
|
||||
# in the commit.
|
||||
git add showcase/shell/src/content/ showcase/shell/.docs-sync-sha
|
||||
CHANGED=$(git diff --cached --name-only | wc -l | tr -d ' ')
|
||||
|
||||
Clean transforms were auto-pushed to \`$SHOWCASE_BRANCH\`. These files need manual attention:
|
||||
if [ "$CHANGED" = "0" ]; then
|
||||
echo "Nothing to commit — no PR will be opened (deliberate)"
|
||||
echo "files_changed=0" >> "$GITHUB_OUTPUT"
|
||||
# Explicit signal: no PR opened, and this is the intended outcome
|
||||
# (not an error path). Downstream alerts key on this.
|
||||
echo "pr_opened=false" >> "$GITHUB_OUTPUT"
|
||||
# Set needs_review explicitly so downstream `needs_review != 'true'`
|
||||
# gates (notify-auto-sync) don't misfire on this deliberate
|
||||
# no-op. Value depends on why we got here: push_and_pr path =
|
||||
# review still pending; auto_push path = no review needed.
|
||||
if [ "$ACTION" = "push_and_pr" ]; then
|
||||
echo "needs_review=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "needs_review=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
\`\`\`
|
||||
${REVIEW_ITEMS}
|
||||
\`\`\`
|
||||
git commit --no-verify -m "$COMMIT_SUBJECT"
|
||||
|
||||
<details>
|
||||
<summary>Full sync output</summary>
|
||||
# Override git credential to use bot token (checkout configured GITHUB_TOKEN)
|
||||
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
|
||||
git push origin "$BRANCH"
|
||||
|
||||
\`\`\`
|
||||
$(cat sync-output.txt)
|
||||
\`\`\`
|
||||
if [ "$ACTION" = "push_and_pr" ]; then
|
||||
# Build the PR body from review-items.txt plus a clear callout.
|
||||
REVIEW_ITEMS_CONTENT="(no review-items file produced)"
|
||||
if [ -n "${REVIEW_ITEMS_FILE:-}" ] && [ -f "$REVIEW_ITEMS_FILE" ]; then
|
||||
REVIEW_ITEMS_CONTENT=$(cat "$REVIEW_ITEMS_FILE")
|
||||
fi
|
||||
PR_BODY_FILE=$(mktemp)
|
||||
CLEANUP_FILES+=("$PR_BODY_FILE")
|
||||
{
|
||||
printf '%s\n' ':warning: **Docs sync — MANUAL REVIEW REQUIRED**'
|
||||
printf '\n'
|
||||
printf '%s\n' 'This PR was auto-opened because the docs-sync script detected'
|
||||
printf '%s\n' 'showcase-local modifications overlapping with upstream changes.'
|
||||
printf '\n'
|
||||
printf '%s\n' 'The script attempted a best-effort 3-way merge:'
|
||||
printf '\n'
|
||||
printf '%s\n' '- Where `git merge-file` produced a clean merge, the merged content was written.'
|
||||
printf '%s\n' '- Where `git merge-file` produced conflict markers, **upstream content was written as-is** and showcase-local modifications were overridden. **Manual review required.**'
|
||||
printf '\n'
|
||||
printf '%s\n' '### Review items'
|
||||
printf '\n'
|
||||
printf '%s\n' '```'
|
||||
# printf '%s\n' avoids running command substitution / backticks
|
||||
# embedded in review-items content (cat "$REVIEW_ITEMS_FILE"
|
||||
# would also work; printf is equivalent here since we already
|
||||
# captured the content).
|
||||
printf '%s\n' "$REVIEW_ITEMS_CONTENT"
|
||||
printf '%s\n' '```'
|
||||
printf '\n'
|
||||
printf '%s\n' '### Source'
|
||||
printf '\n'
|
||||
printf '%s\n' "- Upstream ref: [\`${SHORT_SHA}\`](https://github.com/${{ github.repository }}/commit/${SHORT_SHA})"
|
||||
printf '%s\n' "- Workflow run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
printf '\n'
|
||||
printf '%s\n' '**Review before merging.** Auto-merge is intentionally disabled'
|
||||
printf '%s\n' 'for `needs-review` PRs — confirm the upstream-wins sections'
|
||||
printf '%s\n' 'preserve any intentional showcase-local divergence you want to'
|
||||
printf '%s\n' 'keep, then merge manually.'
|
||||
} > "$PR_BODY_FILE"
|
||||
|
||||
</details>
|
||||
PR_URL=$(gh pr create \
|
||||
--title "docs-sync(needs-review): sync from main (${SHORT_SHA}) [NEEDS REVIEW]" \
|
||||
--body-file "$PR_BODY_FILE" \
|
||||
--base "$SHOWCASE_BRANCH" \
|
||||
--head "$BRANCH")
|
||||
|
||||
---
|
||||
Generated by Showcase Docs Sync
|
||||
EOF
|
||||
)" \
|
||||
--base "$SHOWCASE_BRANCH" \
|
||||
--head "$BRANCH"
|
||||
echo "Created NEEDS-REVIEW PR: $PR_URL"
|
||||
echo "files_changed=${CHANGED}" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
|
||||
# Only set after URL captured — error paths leave this unset so
|
||||
# downstream alerts fall through to failure().
|
||||
echo "pr_opened=true" >> "$GITHUB_OUTPUT"
|
||||
echo "needs_review=true" >> "$GITHUB_OUTPUT"
|
||||
# Intentionally no `gh pr merge` — human must review & merge.
|
||||
else
|
||||
PR_URL=$(gh pr create \
|
||||
--title "chore: auto-sync docs from main (${SHORT_SHA})" \
|
||||
--body "Automated docs sync. Clean transforms / clean 3-way merges only." \
|
||||
--base "$SHOWCASE_BRANCH" \
|
||||
--head "$BRANCH")
|
||||
|
||||
- name: Notify Slack (review needed)
|
||||
if: steps.sync.outputs.action == 'push_and_pr'
|
||||
echo "Created PR: $PR_URL"
|
||||
echo "files_changed=${CHANGED}" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_opened=true" >> "$GITHUB_OUTPUT"
|
||||
echo "needs_review=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
gh pr merge "$PR_URL" --merge
|
||||
fi
|
||||
|
||||
# Build all Slack payloads via jq into tmpfiles. This guarantees any
|
||||
# review-item filename containing ", \, or control chars is safely
|
||||
# JSON-escaped (never string-interpolated into a JSON literal).
|
||||
- name: Build Slack payloads
|
||||
id: payloads
|
||||
if: always()
|
||||
env:
|
||||
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
PR_URL: ${{ steps.push.outputs.pr_url }}
|
||||
FILES_CHANGED: ${{ steps.push.outputs.files_changed }}
|
||||
REVIEW_ITEMS_FILE: ${{ steps.sync.outputs.review_items_file }}
|
||||
SYNC_OUTCOME: ${{ steps.sync.outcome }}
|
||||
BOT_TOKEN_OUTCOME: ${{ steps.bot-token.outcome }}
|
||||
PUSH_OUTCOME: ${{ steps.push.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p slack-payloads
|
||||
|
||||
# auto-sync success (PR merged)
|
||||
jq -n \
|
||||
--arg pr_url "${PR_URL:-}" \
|
||||
--arg files "${FILES_CHANGED:-?}" \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'{text: (":arrows_counterclockwise: *Docs sync*: auto-merged " + $files + " file(s)\n" + $pr_url + "\n<" + $run_url + "|View run>")}' \
|
||||
> slack-payloads/auto-sync.json
|
||||
|
||||
# merge failed (PR exists but gh pr merge failed)
|
||||
jq -n \
|
||||
--arg pr_url "${PR_URL:-}" \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'{text: (":warning: *Docs sync*: PR created but auto-merge FAILED — needs manual merge\n" + $pr_url + "\n<" + $run_url + "|View run>")}' \
|
||||
> slack-payloads/merge-failed.json
|
||||
|
||||
# review-needed payloads: read items from file, let jq handle escaping
|
||||
REVIEW_ITEMS=""
|
||||
if [ -n "${REVIEW_ITEMS_FILE:-}" ] && [ -f "$REVIEW_ITEMS_FILE" ]; then
|
||||
REVIEW_ITEMS=$(cat "$REVIEW_ITEMS_FILE")
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg items "$REVIEW_ITEMS" \
|
||||
--arg pr_url "${PR_URL:-}" \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'{text: (":warning: *Docs sync*: auto-opened *NEEDS REVIEW* PR (best-effort 3-way merge; upstream-wins where conflicts) — human must review + merge\n```" + $items + "```\nReview: " + $pr_url + "\n<" + $run_url + "|View run>")}' \
|
||||
> slack-payloads/review-with-pr.json
|
||||
|
||||
# Collision path: new review items flagged but an existing open
|
||||
# needs-review PR already covers the territory; we did NOT open a
|
||||
# new PR. Point reviewers at the existing one.
|
||||
jq -n \
|
||||
--arg items "$REVIEW_ITEMS" \
|
||||
--arg pr_url "${PR_URL:-}" \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'{text: (":warning: *Docs sync*: new review items detected, but an open *NEEDS REVIEW* PR already exists — skipped new PR creation to avoid collision. Please resolve the existing PR.\n```" + $items + "```\nExisting PR: " + $pr_url + "\n<" + $run_url + "|View run>")}' \
|
||||
> slack-payloads/review-existing-pr.json
|
||||
|
||||
# Fallback path: review items flagged but no PR opened (e.g. 3-way
|
||||
# merge produced bit-for-bit identical content to what's already on
|
||||
# disk so nothing to commit). Keep an alert so it's visible.
|
||||
jq -n \
|
||||
--arg items "$REVIEW_ITEMS" \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'{text: (":warning: *Docs sync*: review items flagged but produced no diff (no PR opened)\n```" + $items + "```\n<" + $run_url + "|View run>")}' \
|
||||
> slack-payloads/review-no-pr.json
|
||||
|
||||
# failure alert
|
||||
FAILED_STEP="unknown"
|
||||
if [ "${SYNC_OUTCOME:-}" = "failure" ]; then
|
||||
FAILED_STEP="sync-docs script"
|
||||
elif [ "${BOT_TOKEN_OUTCOME:-}" = "failure" ]; then
|
||||
FAILED_STEP="bot token generation (check DEVOPS_BOT_PRIVATE_KEY secret)"
|
||||
elif [ "${PUSH_OUTCOME:-}" = "failure" ]; then
|
||||
FAILED_STEP="push/PR creation"
|
||||
fi
|
||||
jq -n \
|
||||
--arg failed_step "$FAILED_STEP" \
|
||||
--arg run_url "$RUN_URL" \
|
||||
'{text: (":x: *Docs sync*: workflow failed\n*Failed step:* " + $failed_step + " | <" + $run_url + "|View run>")}' \
|
||||
> slack-payloads/failure.json
|
||||
|
||||
- name: Notify Slack (auto-sync)
|
||||
id: notify-auto-sync
|
||||
if: always() && steps.push.outcome == 'success' && steps.push.outputs.files_changed != '0' && steps.push.outputs.needs_review != 'true'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{ "text": ":warning: *Docs sync*: auto-pushed ${{ steps.push.outputs.files_changed || '?' }} file(s) + opened PR for files needing manual review\n<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" }
|
||||
payload-file-path: slack-payloads/auto-sync.json
|
||||
|
||||
- name: Notify Slack (merge failed)
|
||||
id: notify-merge-failed
|
||||
if: failure() && steps.push.outputs.pr_opened == 'true' && steps.push.outputs.needs_review != 'true'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: slack-payloads/merge-failed.json
|
||||
|
||||
# Review items = files the sync script flagged but did NOT write to disk
|
||||
# (local modifications, files deleted on main). A PR was opened for the
|
||||
# clean-transform portion — link it so reviewers can click through.
|
||||
# Gated on pr_opened == 'true' so it only fires when we actually have a
|
||||
# PR URL (not on any error path that leaves pr_url empty).
|
||||
- name: Notify Slack (review needed, with PR)
|
||||
id: notify-review-with-pr
|
||||
if: always() && steps.push.outputs.needs_review == 'true' && steps.push.outputs.pr_opened == 'true'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: slack-payloads/review-with-pr.json
|
||||
|
||||
# Review items but the clean-transform portion was empty so no PR was
|
||||
# opened. Gated on pr_opened == 'false' (deliberate no-PR path) — not
|
||||
# empty pr_url, which would also match error paths. Explicitly excludes
|
||||
# the existing-PR collision path, which gets its own step below.
|
||||
- name: Notify Slack (review needed, no PR)
|
||||
id: notify-review-no-pr
|
||||
if: always() && steps.sync.outputs.action == 'push_and_pr' && steps.push.outputs.pr_opened == 'false' && steps.push.outputs.existing_pr != 'true'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: slack-payloads/review-no-pr.json
|
||||
|
||||
# Collision path: new review items flagged but an existing open
|
||||
# needs-review PR already exists — point reviewers at it.
|
||||
- name: Notify Slack (review needed, existing PR)
|
||||
id: notify-review-existing-pr
|
||||
if: always() && steps.push.outputs.existing_pr == 'true'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: slack-payloads/review-existing-pr.json
|
||||
|
||||
- name: Notify Slack (failure)
|
||||
id: notify-failure
|
||||
if: failure() && steps.push.outputs.pr_opened != 'true'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: slack-payloads/failure.json
|
||||
|
||||
# Unconditional fallback: if any notify-* step above failed (webhook 5xx,
|
||||
# rate limit, malformed payload), fire a plain-text alert so we never
|
||||
# silently lose a review-needed or failure notification. Uses curl
|
||||
# directly so it doesn't share failure modes with the slackapi action.
|
||||
# Payload is inlined (not read from slack-payloads/) so this fallback
|
||||
# has no dependency on the Build Slack payloads step succeeding — if
|
||||
# that step broke (jq missing, mkdir failed, etc), every notify-* step
|
||||
# would fail AND the fallback could not read its file. RUN_URL is
|
||||
# constructed from GitHub-controlled env vars only (no user input), so
|
||||
# direct string interpolation into the JSON literal is safe — the
|
||||
# values cannot contain " or \.
|
||||
- name: Notify Slack (alert machinery failed)
|
||||
if: >-
|
||||
always() && (
|
||||
steps.notify-auto-sync.outcome == 'failure' ||
|
||||
steps.notify-merge-failed.outcome == 'failure' ||
|
||||
steps.notify-review-with-pr.outcome == 'failure' ||
|
||||
steps.notify-review-no-pr.outcome == 'failure' ||
|
||||
steps.notify-review-existing-pr.outcome == 'failure' ||
|
||||
steps.notify-failure.outcome == 'failure'
|
||||
)
|
||||
env:
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ -z "${SLACK_WEBHOOK:-}" ]; then
|
||||
echo "::warning::SLACK_WEBHOOK_OSS_ALERTS not set — cannot post fallback alert"
|
||||
exit 0
|
||||
fi
|
||||
RUN_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
curl -sS -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"text\": \":rotating_light: *Docs sync*: review/alert machinery failed — check Actions UI ${RUN_URL}\"}" \
|
||||
"$SLACK_WEBHOOK" || echo "::warning::fallback Slack post also failed"
|
||||
|
||||
@@ -24,71 +24,95 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
lfs: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: showcase/tests
|
||||
run: pnpm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright
|
||||
working-directory: showcase/tests
|
||||
run: npx playwright install chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run L1-L3 tests
|
||||
id: l1_l3
|
||||
working-directory: showcase/tests
|
||||
env:
|
||||
CI: true
|
||||
run: npx playwright test --grep "@health|@agent|@chat" --reporter=github
|
||||
run: |
|
||||
npx playwright test integration-smoke --grep "@health|@agent|@chat" --reporter=github 2>&1 | tee /tmp/e2e-output.log
|
||||
exit ${PIPESTATUS[0]}
|
||||
|
||||
- name: Run L4 tests (daily only)
|
||||
id: l4
|
||||
if: github.event.schedule == '0 0 * * *' || github.event_name == 'workflow_dispatch'
|
||||
working-directory: showcase/tests
|
||||
env:
|
||||
CI: true
|
||||
run: npx playwright test --grep "@tools" --reporter=github
|
||||
run: |
|
||||
npx playwright test integration-smoke --grep "@tools" --reporter=github 2>&1 | tee -a /tmp/e2e-output.log
|
||||
exit ${PIPESTATUS[0]}
|
||||
|
||||
- name: Extract failure summary
|
||||
id: failures
|
||||
if: failure()
|
||||
run: |
|
||||
# Extract failed test names and error messages from playwright output
|
||||
DETAILS=$(grep -E "^\s+\d+\)|Error:" /tmp/e2e-output.log 2>/dev/null | head -15 | sed 's/"/\\"/g' || echo "See CI logs for details")
|
||||
DETAILS="${DETAILS:0:1200}"
|
||||
|
||||
{
|
||||
echo "details<<EOFEOF"
|
||||
echo "$DETAILS"
|
||||
echo "EOFEOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Slack payload
|
||||
if: failure()
|
||||
id: slack-payload
|
||||
env:
|
||||
DETAILS_RAW: ${{ steps.failures.outputs.details }}
|
||||
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' "$DETAILS_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
|
||||
URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
JOB_URL="${URL}/job/${{ github.job }}"
|
||||
|
||||
SLACK_MSG=$(mktemp)
|
||||
SLACK_PAYLOAD=$(mktemp)
|
||||
|
||||
# Build the message with REAL newlines, then hand it to jq via --rawfile so escaping is handled correctly.
|
||||
{
|
||||
printf ':x: *Showcase E2E suite failed*\n'
|
||||
printf '<%s|View run> · <%s|View job>\n' "$URL" "$JOB_URL"
|
||||
printf '```\n%s\n```\n' "$SUMMARY"
|
||||
} > "$SLACK_MSG"
|
||||
jq -n --rawfile text "$SLACK_MSG" '{text: $text}' > "$SLACK_PAYLOAD"
|
||||
rm -f "$SLACK_MSG"
|
||||
echo "payload_path=${SLACK_PAYLOAD}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Post failure to Slack
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
if: failure()
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{ "text": ":x: *Showcase E2E suite failed*\n<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" }
|
||||
payload-file-path: ${{ steps.slack-payload.outputs.payload_path }}
|
||||
|
||||
- name: Create issue on failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const title = '[Drift] Showcase E2E suite failing';
|
||||
const body = `## E2E Drift Detection Alert\n\n**Run**: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}\n**Schedule**: ${context.payload.schedule || 'manual'}\n\nThe centralized E2E smoke suite is failing. Please investigate.`;
|
||||
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'showcase-drift',
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
const existing = issues.find(i => i.title === title);
|
||||
if (!existing) {
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title,
|
||||
body,
|
||||
labels: ['showcase-drift'],
|
||||
});
|
||||
}
|
||||
- name: Clean up Slack payload tmpfile
|
||||
if: always() && steps.slack-payload.outputs.payload_path
|
||||
run: rm -f "${{ steps.slack-payload.outputs.payload_path }}"
|
||||
|
||||
version-drift:
|
||||
name: Version Drift Report
|
||||
@@ -171,50 +195,20 @@ jobs:
|
||||
echo -e "$report" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create drift report issue
|
||||
- name: Notify Slack (version drift)
|
||||
if: steps.python_drift.outputs.report != '' || steps.npm_drift.outputs.report != ''
|
||||
uses: actions/github-script@v7
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
script: |
|
||||
const pythonDrift = `${{ steps.python_drift.outputs.report }}`.trim();
|
||||
const npmDrift = `${{ steps.npm_drift.outputs.report }}`.trim();
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{ "text": ":warning: *Version drift*: dependency updates available | <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" }
|
||||
|
||||
if (!pythonDrift && !npmDrift) return;
|
||||
|
||||
const title = `[Drift] Showcase packages have outdated pinned versions`;
|
||||
const body = `## Weekly Version Drift Report
|
||||
|
||||
Showcase packages have pinned versions that differ from the latest releases.
|
||||
Review each and update if the new version is compatible.
|
||||
|
||||
${pythonDrift ? `### Python Packages\n| Package | Dep | Pinned | Latest |\n|---------|-----|--------|--------|\n${pythonDrift}` : ''}
|
||||
|
||||
${npmDrift ? `### npm Packages\n| Package | Dep | Pinned | Latest |\n|---------|-----|--------|--------|\n${npmDrift}` : ''}
|
||||
|
||||
**Action**: For each outdated dep, check the Dojo example for the correct version.
|
||||
Update \`requirements.txt\` / \`package.json\`, rebuild, and verify demos still work.
|
||||
`;
|
||||
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'showcase-drift,version-drift',
|
||||
state: 'open',
|
||||
});
|
||||
|
||||
if (issues.length === 0) {
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title,
|
||||
body,
|
||||
labels: ['showcase-drift', 'version-drift'],
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issues[0].number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
- name: Notify Slack (version drift failure)
|
||||
if: failure()
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{ "text": ":x: *Version drift check*: failed | <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" }
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
name: "Showcase: Drift Report"
|
||||
|
||||
# Weekly report on pin-drift baseline (validate-pins FAIL count).
|
||||
# Complements showcase_validate.yml's ratchet gating by surfacing the
|
||||
# current backlog even when no PR is open. Goal: keep the drift count
|
||||
# visible so the drift-to-zero commitment stays in front of the team.
|
||||
# Drift-to-zero work is tracked in GitHub issue #4047.
|
||||
#
|
||||
# Reference: Notion: "Showcase Drift Baseline Runbook" (accessible to
|
||||
# copilotkit.ai team members only; not linked here to avoid embedding
|
||||
# private URLs in a public workflow).
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Monday 10:00 UTC — intentionally offset from showcase_drift-detection.yml's
|
||||
# Monday 09:00 UTC version-drift slot so the two reports do not pile up
|
||||
# in Slack simultaneously.
|
||||
- cron: "0 10 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
# id-token: write is required for Depot OIDC auth (runs-on: depot-ubuntu-*).
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
report:
|
||||
name: Weekly Pin-Drift Report
|
||||
# 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 }}
|
||||
# Depot (Startup plan, unlimited minutes) for pnpm cache across
|
||||
# scheduled runs — keeps the weekly ratchet cheap.
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# pnpm must be set up BEFORE setup-node so that `cache: 'pnpm'` can
|
||||
# detect the pnpm binary when wiring up the store cache.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4.4.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
# Cache the pnpm store so weekly cold-cache runs don't re-fetch
|
||||
# the full dependency set every Monday.
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Read baseline
|
||||
id: baseline
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Emit `count|hash` so we can ratchet on both; surface "SET DRIFTED"
|
||||
# when the count matches but the hash differs.
|
||||
set +e
|
||||
baseline_tuple=$(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 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(c + '|' + h);
|
||||
")
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
# Preserve node's distinct rc (2=JSON syntax, 3=schema) in the
|
||||
# annotation so the weekly log pinpoints the cause.
|
||||
echo "::error::Failed to read baseline from showcase/scripts/fail-baseline.json (node exit=$rc; 2=JSON syntax, 3=schema)"
|
||||
exit "$rc"
|
||||
fi
|
||||
baseline_count="${baseline_tuple%%|*}"
|
||||
baseline_hash="${baseline_tuple##*|}"
|
||||
echo "count=$baseline_count" >> "$GITHUB_OUTPUT"
|
||||
echo "hash=$baseline_hash" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run validate-pins
|
||||
id: validate
|
||||
working-directory: showcase/scripts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Temporarily disable -e around validate-pins so we can capture its
|
||||
# exit code ourselves (we expect 0 or 1 as legitimate outcomes and
|
||||
# need to distinguish 2+ as an internal crash). pipefail stays on:
|
||||
# the `{ grep || true; }` scoping below relies on pipefail being
|
||||
# enabled to actually deliver on its stated protection (scope no-
|
||||
# match tolerance to grep alone without swallowing producer/head
|
||||
# failures). -u stays on to catch accidental unset-var reads.
|
||||
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
|
||||
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 the weekly job signal
|
||||
# distinguishes "validator crashed" from "pin drift found"
|
||||
# (rc=1). Matches the same pattern in showcase_validate.yml.
|
||||
echo "::error::validate-pins.ts exited with unexpected code $rc"
|
||||
exit "$rc"
|
||||
fi
|
||||
# Scope grep's no-match tolerance to grep alone: a trailing
|
||||
# `|| true` on the whole pipeline would defeat `pipefail` (if it
|
||||
# were enabled) and swallow producer/head failures too. Wrapping
|
||||
# just grep in `{ ... || true; }` keeps other stages' errors
|
||||
# visible while still tolerating "Summary line absent" (which is
|
||||
# then reported explicitly by the `[ -z "$summary_line" ]` check).
|
||||
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"
|
||||
exit 1
|
||||
fi
|
||||
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"
|
||||
exit 1
|
||||
fi
|
||||
# Hash the sorted, deduplicated `[FAIL] ...` lines (stderr only),
|
||||
# matching the identical computation in showcase_validate.yml. 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 `{ ... || true; }`. Without this, a zero-FAIL
|
||||
# week (grep exit 1 = no match) either fails the pipeline (if
|
||||
# pipefail is enabled) or — more insidiously — silently yields
|
||||
# the empty-input SHA-256 (`e3b0c44...`), which then mismatches
|
||||
# the recorded baseline hash and spuriously fires a "SET DRIFTED"
|
||||
# alert on a clean week. A trailing `|| true` on the whole
|
||||
# pipeline would defeat `pipefail` and also swallow real
|
||||
# sort/shasum/cut failures; we only tolerate grep's no-match.
|
||||
actual_hash=$(printf '%s\n' "$stderr" | { grep -E '^\[FAIL\]' || true; } | LC_ALL=C sort -u | shasum -a 256 | cut -d' ' -f1)
|
||||
# Determine set-drift status for the weekly Slack payload so a
|
||||
# count-equal-but-set-changed week is visible, not silently clean.
|
||||
if [ "$actual" -eq "${{ steps.baseline.outputs.count }}" ] && [ "$actual_hash" != "${{ steps.baseline.outputs.hash }}" ]; then
|
||||
set_status="SET DRIFTED"
|
||||
else
|
||||
set_status="ok"
|
||||
fi
|
||||
echo "actual=$actual" >> "$GITHUB_OUTPUT"
|
||||
echo "actual_hash=$actual_hash" >> "$GITHUB_OUTPUT"
|
||||
echo "set_status=$set_status" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Notify Slack (weekly drift report)
|
||||
# Skip cleanly when the webhook secret is unset (forks / pre-provision)
|
||||
# rather than failing the job. Counts still appear in the job log.
|
||||
if: ${{ env.SLACK_WEBHOOK != '' }}
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
# Defensive: wrap dynamic values via toJSON(format(...)) so that
|
||||
# if hash/count/set_status ever contains characters that would
|
||||
# break the JSON payload (quotes, backslashes, newlines), the
|
||||
# value is safely JSON-encoded instead of injected as raw text.
|
||||
payload: |
|
||||
{ "text": ${{ toJSON(format(':chart_with_downwards_trend: *Showcase pin-drift (weekly)*: FAIL={0} (baseline {1}) [{2}] | <https://github.com/{3}/actions/runs/{4}|View run>', steps.validate.outputs.actual, steps.baseline.outputs.count, steps.validate.outputs.set_status, github.repository, github.run_id)) }} }
|
||||
|
||||
- name: Log result (no Slack)
|
||||
if: ${{ env.SLACK_WEBHOOK == '' }}
|
||||
run: |
|
||||
echo "::warning::SLACK_WEBHOOK_OSS_ALERTS not set; weekly drift report not sent to Slack."
|
||||
echo "Weekly pin-drift report: FAIL=${{ steps.validate.outputs.actual }} baseline=${{ steps.baseline.outputs.count }} set_status=${{ steps.validate.outputs.set_status }} actual_hash=${{ steps.validate.outputs.actual_hash }} baseline_hash=${{ steps.baseline.outputs.hash }}"
|
||||
|
||||
- name: Notify Slack (job failure)
|
||||
# Surface silent crashes (baseline read, validate-pins internal error,
|
||||
# Slack post failure) so the weekly report doesn't fail invisibly.
|
||||
if: ${{ failure() && env.SLACK_WEBHOOK != '' }}
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
# Defensive: wrap dynamic values via toJSON(format(...)) so that
|
||||
# if repository/run_id ever contains characters that would break
|
||||
# the JSON payload (quotes, backslashes, newlines), the value is
|
||||
# safely JSON-encoded instead of injected as raw text. Mirrors
|
||||
# the success payload above for consistency.
|
||||
payload: |
|
||||
{ "text": ${{ toJSON(format(':x: *Showcase drift report*: job failed | <https://github.com/{0}/actions/runs/{1}|View run>', github.repository, github.run_id)) }} }
|
||||
@@ -44,3 +44,12 @@ jobs:
|
||||
env:
|
||||
NOTION_API_KEY: ${{ secrets.SHOWCASE_NOTION_API_KEY }}
|
||||
run: npx tsx sync-qa-to-notion.ts
|
||||
|
||||
- name: Notify Slack (failure)
|
||||
if: failure()
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{ "text": ":x: *QA sync to Notion*: failed | <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" }
|
||||
|
||||
@@ -15,6 +15,17 @@ jobs:
|
||||
packages: read
|
||||
actions: write
|
||||
steps:
|
||||
- name: Checkout (starters dir only)
|
||||
# Sparse checkout of showcase/starters/ so the "Check image drift"
|
||||
# step can enumerate starter slugs from the filesystem. This keeps
|
||||
# the starter list a single source of truth (directory names under
|
||||
# showcase/starters/) instead of a literal list duplicated in this
|
||||
# workflow.
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: showcase/starters
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Restore state from cache
|
||||
id: cache-restore
|
||||
uses: actions/cache/restore@v4
|
||||
@@ -148,12 +159,6 @@ jobs:
|
||||
echo "has_alerts=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Save state to cache
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: smoke-state.json
|
||||
key: smoke-monitor-state-${{ github.run_id }}
|
||||
|
||||
- name: Build Slack payload
|
||||
if: steps.smoke.outputs.has_alerts == 'true'
|
||||
run: |
|
||||
@@ -189,27 +194,135 @@ jobs:
|
||||
|
||||
STALE=""
|
||||
STALE_LIST=""
|
||||
# Non-starter services stay literal — they don't live under
|
||||
# showcase/starters/ and each has bespoke provisioning elsewhere.
|
||||
SERVICES=(
|
||||
shell langgraph-python langgraph-typescript langgraph-fastapi
|
||||
mastra crewai-crews pydantic-ai google-adk ag2 agno llamaindex
|
||||
strands ms-agent-python ms-agent-dotnet claude-sdk-python
|
||||
claude-sdk-typescript langroid spring-ai aimock
|
||||
)
|
||||
MAIN_SHA=$(git ls-remote https://github.com/${{ github.repository }}.git main | cut -f1)
|
||||
echo "Main SHA: ${MAIN_SHA:0:8}"
|
||||
# Starter slugs are derived from showcase/starters/*/ so adding a
|
||||
# new starter directory automatically extends drift detection.
|
||||
# `template/` is scaffolding, not a deployed service — keep in sync
|
||||
# with showcase/scripts/validate-workflow-starters.ts EXCLUDED_DIRS.
|
||||
# nullglob guards against an empty showcase/starters/ tree expanding
|
||||
# the literal "showcase/starters/*/" pattern into the iteration,
|
||||
# which would corrupt SERVICES with a "starter-*" entry.
|
||||
shopt -s nullglob
|
||||
for dir in showcase/starters/*/; do
|
||||
slug=$(basename "$dir")
|
||||
[ "$slug" = "template" ] && continue
|
||||
SERVICES+=("starter-$slug")
|
||||
done
|
||||
shopt -u nullglob
|
||||
# Count appended starter-* entries independently of the non-starter
|
||||
# list size. Using a magic `-eq 19` sentinel tied us to a specific
|
||||
# non-starter count; any add/remove there would have silently
|
||||
# disabled this guard. `grep -c '^starter-'` stays correct under
|
||||
# arbitrary churn to the literal non-starter list.
|
||||
STARTER_COUNT=$(printf '%s\n' "${SERVICES[@]}" | grep -c '^starter-' || true)
|
||||
if [ "$STARTER_COUNT" -eq 0 ]; then
|
||||
# Sparse checkout failed to populate showcase/starters/, or every
|
||||
# directory under it was `template/` — fail loudly instead of
|
||||
# silently under-checking.
|
||||
echo "::error::No starter directories found under showcase/starters/ — sparse checkout failed?"
|
||||
exit 1
|
||||
fi
|
||||
# Compare against the last commits that touched showcase-related paths,
|
||||
# NOT main HEAD. Deploys only trigger on showcase/ and examples/integrations/
|
||||
# changes, so non-showcase commits shouldn't make images appear stale.
|
||||
SHOWCASE_SHA=$(gh api "repos/${{ github.repository }}/commits?sha=main&path=showcase&per_page=1" --jq '.[0].sha // empty') || {
|
||||
echo "::warning::Failed to fetch showcase/ commit SHA"
|
||||
SHOWCASE_SHA=""
|
||||
}
|
||||
EXAMPLES_SHA=$(gh api "repos/${{ github.repository }}/commits?sha=main&path=examples/integrations&per_page=1" --jq '.[0].sha // empty') || {
|
||||
echo "::warning::Failed to fetch examples/integrations/ commit SHA"
|
||||
EXAMPLES_SHA=""
|
||||
}
|
||||
echo "Last showcase/ SHA: ${SHOWCASE_SHA:0:8}, Last examples/integrations/ SHA: ${EXAMPLES_SHA:0:8}"
|
||||
|
||||
if [ -z "$SHOWCASE_SHA" ] && [ -z "$EXAMPLES_SHA" ]; then
|
||||
echo "::warning::Could not resolve any path-specific SHAs — skipping drift check"
|
||||
echo "has_stale=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
|
||||
for SVC in "${SERVICES[@]}"; do
|
||||
PKG="showcase-${SVC}"
|
||||
# Get tags for the latest version via GitHub Packages API
|
||||
TAGS=$(gh api "/orgs/copilotkit/packages/container/${PKG}/versions?per_page=1" \
|
||||
--jq '.[0].metadata.container.tags | join(" ")' 2>/dev/null) || true
|
||||
# Query GHCR for the latest package version. The previous
|
||||
# implementation used `gh api ... || true` + `[ -z "$TAGS" ]`
|
||||
# to skip, which silently conflated three distinct cases:
|
||||
# - new service, not yet published (legitimate, expected)
|
||||
# - GHCR 404 (legitimate — same as above)
|
||||
# - GHCR 401/403/5xx (transient API error — NOT legitimate;
|
||||
# should surface so a broken drift run doesn't look clean)
|
||||
# Split them: capture HTTP status from gh api -i, treat 200+empty
|
||||
# and 404 as "no versions yet" (quiet continue), and warn on any
|
||||
# other non-200 so the run log shows a ::warning:: without
|
||||
# failing the whole drift detector over one flaky service.
|
||||
# Capture stderr separately from stdout. Merging them with 2>&1
|
||||
# can splice gh error lines (auth failures, rate limits, network
|
||||
# errors) ahead of the HTTP header block, which then corrupts
|
||||
# HTTP_STATUS and API_BODY parsing — a drift run can look "clean"
|
||||
# while actually failing every call. Keep stderr in a temp file
|
||||
# and surface it only when we need to diagnose a non-zero RC.
|
||||
API_STDERR=$(mktemp)
|
||||
set +e
|
||||
API_RESPONSE=$(gh api -i "/orgs/copilotkit/packages/container/${PKG}/versions?per_page=1" 2>"$API_STDERR")
|
||||
API_RC=$?
|
||||
set -e
|
||||
# gh api -i emits HTTP headers on stdout followed by a blank
|
||||
# line then the body. Extract the status line (first line
|
||||
# beginning with HTTP/) even if redirects prepended extras.
|
||||
HTTP_STATUS=$(printf '%s\n' "$API_RESPONSE" | awk '/^HTTP\// { status=$2 } END { print status }')
|
||||
# Body is everything after the first blank line.
|
||||
API_BODY=$(printf '%s\n' "$API_RESPONSE" | awk 'blank { print; next } /^\r?$/ { blank=1 }')
|
||||
|
||||
if [ -z "$TAGS" ]; then
|
||||
continue # No package versions — new service, not yet built
|
||||
if [ "$API_RC" -ne 0 ] && [ -z "$HTTP_STATUS" ]; then
|
||||
# gh itself failed (network, missing binary) with no response
|
||||
# at all — surface as a warning and move on. A brief outage
|
||||
# shouldn't mark every service stale on the next clean run.
|
||||
GH_ERR=$(tr '\n' ' ' < "$API_STDERR" | sed 's/ */ /g' | sed 's/^ *//;s/ *$//')
|
||||
rm -f "$API_STDERR"
|
||||
echo "::warning::${SVC}: gh api call failed (rc=$API_RC): ${GH_ERR:-no stderr captured}, skipping drift check"
|
||||
continue
|
||||
fi
|
||||
rm -f "$API_STDERR"
|
||||
|
||||
if echo "$TAGS" | grep -q "$MAIN_SHA"; then
|
||||
continue # Latest version includes main SHA — up to date
|
||||
case "$HTTP_STATUS" in
|
||||
200)
|
||||
TAGS=$(printf '%s' "$API_BODY" | jq -r '.[0].metadata.container.tags | join(" ")' 2>/dev/null) || TAGS=""
|
||||
if [ -z "$TAGS" ]; then
|
||||
echo " ${SVC}: no package versions yet, skipping"
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
404)
|
||||
echo " ${SVC}: no GHCR package yet (404), skipping"
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
# 401/403 (auth drift), 5xx (transient), any other surprise.
|
||||
# Warn visibly but don't fail the whole drift run — one
|
||||
# flaky service shouldn't break the monitor.
|
||||
echo "::warning::${SVC}: GHCR returned HTTP ${HTTP_STATUS:-<no status>}, skipping"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
# Image is up to date if it matches EITHER the last showcase/ or
|
||||
# examples/integrations/ commit (deploy triggers on both paths).
|
||||
# Use `grep -w` (word boundary) instead of `grep` (substring)
|
||||
# so one SHA isn't accidentally matched as a prefix of another.
|
||||
UP_TO_DATE=false
|
||||
if [ -n "$SHOWCASE_SHA" ] && echo "$TAGS" | grep -qw "$SHOWCASE_SHA"; then
|
||||
UP_TO_DATE=true
|
||||
fi
|
||||
if [ -n "$EXAMPLES_SHA" ] && echo "$TAGS" | grep -qw "$EXAMPLES_SHA"; then
|
||||
UP_TO_DATE=true
|
||||
fi
|
||||
if [ "$UP_TO_DATE" = true ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo " ${SVC}: stale (tags: ${TAGS:0:60})"
|
||||
@@ -217,38 +330,134 @@ jobs:
|
||||
STALE_LIST="${STALE_LIST} ${SVC}"
|
||||
done
|
||||
|
||||
# Compare against previous drift state to avoid alerting repeatedly
|
||||
PREV_STALE=$(jq -r '.image_drift_services // ""' smoke-state.json 2>/dev/null) || true
|
||||
SORTED_STALE=$(echo "$STALE_LIST" | tr ' ' '\n' | sort | tr '\n' ' ' | xargs)
|
||||
|
||||
if [ -n "$STALE_LIST" ]; then
|
||||
echo "Image drift detected:${STALE_LIST}"
|
||||
echo "has_stale=true" >> "$GITHUB_OUTPUT"
|
||||
echo "stale_services=${STALE_LIST}" >> "$GITHUB_OUTPUT"
|
||||
printf "%b" "$STALE" > image-drift.txt
|
||||
|
||||
if [ "$SORTED_STALE" != "$PREV_STALE" ]; then
|
||||
echo "Stale set changed (was: '${PREV_STALE}', now: '${SORTED_STALE}') — alerting"
|
||||
echo "has_stale=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Same stale set as last run — suppressing alert"
|
||||
echo "has_stale=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Persist current stale set
|
||||
STATE=$(cat smoke-state.json)
|
||||
echo "$STATE" | jq --arg ds "$SORTED_STALE" '. + {"image_drift_services": $ds}' > smoke-state.json
|
||||
else
|
||||
echo "All images up to date"
|
||||
echo "has_stale=false" >> "$GITHUB_OUTPUT"
|
||||
# Clear drift state
|
||||
STATE=$(cat smoke-state.json)
|
||||
echo "$STATE" | jq 'del(.image_drift_services)' > smoke-state.json
|
||||
fi
|
||||
|
||||
fi # end SHA guard
|
||||
|
||||
- name: Trigger rebuild for stale services
|
||||
id: rebuild
|
||||
if: steps.image_drift.outputs.has_stale == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
FAILED=""
|
||||
FAILED_COUNT=0
|
||||
: > rebuild-failures.txt
|
||||
COUNT=0
|
||||
for SVC in ${{ steps.image_drift.outputs.stale_services }}; do
|
||||
echo "Triggering rebuild for ${SVC}..."
|
||||
gh workflow run showcase_deploy.yml -f service="${SVC}" || echo " Failed to trigger ${SVC}"
|
||||
RC=0
|
||||
ERR_OUTPUT=$(gh workflow run showcase_deploy.yml --repo "${{ github.repository }}" -f service="${SVC}" 2>&1) || RC=$?
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
COUNT=$((COUNT + 1))
|
||||
else
|
||||
echo "::warning::Failed to trigger rebuild for ${SVC}: ${ERR_OUTPUT}"
|
||||
# Collapse whitespace/newlines in the error so it fits on one Slack line
|
||||
REASON=$(echo "$ERR_OUTPUT" | tr '\n' ' ' | sed 's/ */ /g' | sed 's/^ *//;s/ *$//')
|
||||
if [ -z "$REASON" ]; then
|
||||
REASON="unknown error"
|
||||
fi
|
||||
FAILED="${FAILED} ${SVC}"
|
||||
FAILED_COUNT=$((FAILED_COUNT + 1))
|
||||
printf ":x: *%s* — %s\n" "$SVC" "$REASON" >> rebuild-failures.txt
|
||||
fi
|
||||
done
|
||||
echo "triggered_count=${COUNT}" >> "$GITHUB_OUTPUT"
|
||||
echo "failed_count=${FAILED_COUNT}" >> "$GITHUB_OUTPUT"
|
||||
if [ "$FAILED_COUNT" -gt 0 ]; then
|
||||
# Surface failures via annotation and fail the job so the run shows
|
||||
# RED in GitHub Actions UI / `gh run list`. Slack dedup is handled
|
||||
# by guarding the failure() notifier with
|
||||
# `steps.image_drift.outputs.has_stale != 'true'`, so this exit 1
|
||||
# does NOT cause double-posting — the detailed drift-alert step
|
||||
# covers the drift path.
|
||||
echo "::error::Failed to trigger rebuilds for:${FAILED}"
|
||||
echo "has_failures=true" >> "$GITHUB_OUTPUT"
|
||||
exit 1
|
||||
else
|
||||
echo "has_failures=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Alert image drift to Slack
|
||||
if: steps.image_drift.outputs.has_stale == 'true'
|
||||
if: always() && steps.image_drift.outputs.has_stale == 'true'
|
||||
run: |
|
||||
HEADER=":package: *Image drift detected — rebuilds triggered:*"
|
||||
BODY=$(cat image-drift.txt)
|
||||
printf "%s\n%s" "$HEADER" "$BODY" > drift-message.txt
|
||||
RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
COUNT="${{ steps.rebuild.outputs.triggered_count }}"
|
||||
FAILED_COUNT="${{ steps.rebuild.outputs.failed_count }}"
|
||||
if [ -z "$COUNT" ]; then
|
||||
# Fallback: rebuild step didn't run or didn't set the output; count from stale_services
|
||||
COUNT=$(echo "${{ steps.image_drift.outputs.stale_services }}" | wc -w | tr -d ' ')
|
||||
fi
|
||||
if [ -z "$FAILED_COUNT" ]; then
|
||||
FAILED_COUNT=0
|
||||
fi
|
||||
NOUN="rebuilds"
|
||||
if [ "$COUNT" = "1" ]; then NOUN="rebuild"; fi
|
||||
|
||||
if [ "${{ steps.rebuild.outputs.has_failures }}" = "true" ] && [ -s rebuild-failures.txt ]; then
|
||||
# Failure case: list only the services that failed to rebuild, with reasons.
|
||||
# COUNT reflects only successfully-triggered rebuilds; FAILED_COUNT is the rest.
|
||||
{
|
||||
printf ":package: *Image drift detected — %s %s triggered, %s failed:*\n" "$COUNT" "$NOUN" "$FAILED_COUNT"
|
||||
cat rebuild-failures.txt
|
||||
printf "<%s|Workflow run>\n" "$RUN_URL"
|
||||
} > drift-message.txt
|
||||
else
|
||||
# Success case: just summarize the count with a link to the run
|
||||
printf ":package: Image drift detected — %s %s triggered (<%s|run>)\n" "$COUNT" "$NOUN" "$RUN_URL" > drift-message.txt
|
||||
fi
|
||||
jq -n --rawfile text drift-message.txt '{"text": $text}' > drift-payload.json
|
||||
|
||||
- name: Post image drift to Slack
|
||||
if: steps.image_drift.outputs.has_stale == 'true'
|
||||
if: always() && steps.image_drift.outputs.has_stale == 'true'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: drift-payload.json
|
||||
|
||||
- name: Notify Slack (workflow failure)
|
||||
# Suppress when the drift-alert path is active — that step already
|
||||
# posts a detailed Slack message covering per-service rebuild results.
|
||||
# This generic notifier still fires for non-drift failures (e.g., the
|
||||
# smoke-check step itself fails before drift detection runs).
|
||||
if: failure() && steps.image_drift.outputs.has_stale != 'true'
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{ "text": ":x: *Smoke monitor*: workflow failed | <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" }
|
||||
|
||||
- name: Save state to cache
|
||||
if: always()
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: smoke-state.json
|
||||
key: smoke-monitor-state-${{ github.run_id }}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: "Showcase: Verify Starters Match Template"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "showcase/starters/**"
|
||||
- "showcase/packages/*/src/agents/**"
|
||||
- "showcase/packages/*/src/mastra/**"
|
||||
- "showcase/scripts/generate-starters.ts"
|
||||
- "showcase/shared/python/**"
|
||||
- "showcase/shared/typescript/**"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "showcase/starters/**"
|
||||
- "showcase/packages/*/src/agents/**"
|
||||
- "showcase/packages/*/src/mastra/**"
|
||||
- "showcase/scripts/generate-starters.ts"
|
||||
- "showcase/shared/python/**"
|
||||
- "showcase/shared/typescript/**"
|
||||
|
||||
jobs:
|
||||
drift-check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
- name: Verify generated starters match template output
|
||||
run: |
|
||||
echo "Regenerating starters from template and comparing to committed versions..."
|
||||
echo "If this fails, run: npx tsx showcase/scripts/generate-starters.ts"
|
||||
npx tsx showcase/scripts/generate-starters.ts --check
|
||||
@@ -4,20 +4,61 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "showcase/**"
|
||||
- "examples/integrations/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- ".github/workflows/showcase_validate.yml"
|
||||
# Also re-run when starter/workflow files change — the
|
||||
# validate-workflow-starters step below verifies parity between
|
||||
# showcase/starters/* and the SERVICES/options lists in these files.
|
||||
- ".github/workflows/showcase_deploy.yml"
|
||||
- ".github/workflows/showcase_smoke-monitor.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"
|
||||
- ".github/workflows/showcase_smoke-monitor.yml"
|
||||
|
||||
# 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
|
||||
|
||||
# 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 }}
|
||||
cancel-in-progress: true
|
||||
group: showcase-validate-${{ github.ref }}-${{ github.event_name }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Showcase
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# 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
|
||||
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
|
||||
@@ -26,30 +67,523 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
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
|
||||
uses: pnpm/action-setup@v4
|
||||
# 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@v4.4.0
|
||||
|
||||
- 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/packages/*/; do
|
||||
[ -d "$pkg_dir" ] || continue
|
||||
found=$((found + 1))
|
||||
pkg=$(basename "$pkg_dir")
|
||||
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/packages/*/ 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-workflow-starters (parity with workflows)
|
||||
working-directory: showcase/scripts
|
||||
# Gate: for every directory under showcase/starters/ (excluding
|
||||
# template/), verify `starter-<slug>` is registered in
|
||||
# showcase_deploy.yml (workflow_dispatch options AND the
|
||||
# ALL_SERVICES matrix) and in showcase_smoke-monitor.yml's
|
||||
# SERVICES array. Catches the silent-drift failure mode where a
|
||||
# new starter ships on disk but is invisible to deploy dispatch
|
||||
# or drift detection.
|
||||
run: pnpm exec tsx validate-workflow-starters.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`. Drift-to-zero work
|
||||
# is tracked in GitHub issue #4047.
|
||||
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
|
||||
run: npx vitest run
|
||||
# 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: Validate manifests & generate registry
|
||||
working-directory: showcase/scripts
|
||||
run: npx tsx generate-registry.ts
|
||||
run: pnpm exec tsx generate-registry.ts
|
||||
|
||||
- name: Bundle demo content
|
||||
working-directory: showcase/scripts
|
||||
run: npx tsx bundle-demo-content.ts
|
||||
run: pnpm exec tsx bundle-demo-content.ts
|
||||
|
||||
- name: Install showcase shell dependencies
|
||||
working-directory: showcase/shell
|
||||
run: npm install --ignore-scripts
|
||||
# `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: Notify Slack (failure)
|
||||
if: failure() && github.event_name == 'push' && env.SLACK_WEBHOOK != ''
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
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."
|
||||
|
||||
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
|
||||
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@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "pip"
|
||||
cache-dependency-path: |
|
||||
showcase/packages/*/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/packages/*/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/packages/*/; 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.
|
||||
#
|
||||
# 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" = "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
|
||||
(cd "$pkg_dir" && python -m pytest tests/python/ -v) || failed=1
|
||||
done
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "::warning::No showcase/packages/*/tests/python/ directories found"
|
||||
fi
|
||||
exit "$failed"
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
name: release / create-pr
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
scope:
|
||||
description: "What to release"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- monorepo
|
||||
- cli
|
||||
- angular
|
||||
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:
|
||||
group: release-pr
|
||||
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
|
||||
steps:
|
||||
- name: Check for existing release PR
|
||||
uses: actions/github-script@v7
|
||||
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@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: "10.13.1"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
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: 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: Create release PR
|
||||
if: inputs.dry_run != true
|
||||
id: create_pr
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_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@v7
|
||||
env:
|
||||
NOTION_URL: ${{ steps.ai_notes.outputs.notion_url }}
|
||||
PR_NUMBER: ${{ steps.create_pr.outputs.pull-request-number }}
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_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.`,
|
||||
});
|
||||
}
|
||||
@@ -16,7 +16,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
starter-smoke:
|
||||
@@ -113,39 +112,45 @@ jobs:
|
||||
path: showcase/tests/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Build Slack payload
|
||||
if: failure() && (github.event_name == 'schedule' || github.event_name == 'workflow_run')
|
||||
id: slack-payload
|
||||
env:
|
||||
SUMMARY_RAW: ${{ steps.failure-cause.outputs.summary }}
|
||||
STARTER: ${{ matrix.starter }}
|
||||
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
|
||||
URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
JOB_URL="${URL}/job/${{ github.job }}"
|
||||
|
||||
SLACK_MSG=$(mktemp)
|
||||
SLACK_PAYLOAD=$(mktemp)
|
||||
|
||||
# Build the message with REAL newlines, then hand it to jq via --rawfile so escaping is handled correctly.
|
||||
{
|
||||
printf ':x: *Starter smoke test failing: %s*\n' "$STARTER"
|
||||
printf '<%s|View run> · <%s|View job>\n' "$URL" "$JOB_URL"
|
||||
printf '```\n%s\n```\n' "$SUMMARY"
|
||||
} > "$SLACK_MSG"
|
||||
jq -n --rawfile text "$SLACK_MSG" '{text: $text}' > "$SLACK_PAYLOAD"
|
||||
rm -f "$SLACK_MSG"
|
||||
echo "payload_path=${SLACK_PAYLOAD}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Alert Slack on failure
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
if: failure() && (github.event_name == 'schedule' || github.event_name == 'workflow_run')
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{
|
||||
"text": ${{ toJSON(format('Starter smoke test failing: *{0}*\n```{1}```\nRun: {2}/{3}/actions/runs/{4}', matrix.starter, steps.failure-cause.outputs.summary, github.server_url, github.repository, github.run_id)) }}
|
||||
}
|
||||
payload-file-path: ${{ steps.slack-payload.outputs.payload_path }}
|
||||
|
||||
- name: Create GitHub issue on failure
|
||||
if: failure() && github.event_name == 'schedule'
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
FAILURE_SUMMARY: ${{ steps.failure-cause.outputs.summary }}
|
||||
with:
|
||||
script: |
|
||||
const title = `[Drift] Starter smoke test failing: ${{ matrix.starter }}`;
|
||||
const cause = (process.env.FAILURE_SUMMARY || 'Unknown').replace(/`/g, "'");
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: 'starter-drift',
|
||||
state: 'open',
|
||||
});
|
||||
const existing = issues.find(i => i.title === title);
|
||||
if (!existing) {
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title,
|
||||
labels: ['starter-drift'],
|
||||
body: `The scheduled starter smoke test for \`${{ matrix.starter }}\` is failing.\n\n**Cause:**\n\`\`\`\n${cause}\n\`\`\`\n\n**Run:** ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}\n\nThis likely means a floating dependency update broke the starter.`,
|
||||
});
|
||||
}
|
||||
- name: Clean up Slack payload tmpfile
|
||||
if: always() && steps.slack-payload.outputs.payload_path
|
||||
run: rm -f "${{ steps.slack-payload.outputs.payload_path }}"
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
name: Starter Deployed Smoke Tests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 6 hours — primary safety net for deployed starters
|
||||
- cron: "0 */6 * * *"
|
||||
workflow_run:
|
||||
workflows: ["Showcase: Build & Deploy"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
starter_slug:
|
||||
description: "Optional: test a single starter slug"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
starter-deployed-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
# 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 some
|
||||
# event types. Matches the pattern used in showcase_validate.yml.
|
||||
env:
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
if: >-
|
||||
github.event_name != 'workflow_run' ||
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
lfs: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
# Restore previous run's pass/fail status from cache so we can emit
|
||||
# a red→green transition alert on recovery. Mirrors the per-service
|
||||
# transition pattern in showcase_smoke-monitor.yml. The restore-keys
|
||||
# prefix gives us the most recently saved state regardless of which
|
||||
# run_id wrote it. Cache TTL is ~7 days; scheduled runs every 6h
|
||||
# plus workflow_run triggers refresh it well within that window.
|
||||
- name: Restore smoke state from cache
|
||||
id: cache-restore
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: starter-smoke-state.json
|
||||
key: starter-smoke-state-impossible-match
|
||||
restore-keys: |
|
||||
starter-smoke-state-
|
||||
|
||||
- name: Initialize state if missing
|
||||
run: |
|
||||
# First-ever run (or cache eviction): assume "ok" so we don't
|
||||
# emit a false recovery alert on the first green run after
|
||||
# deploying this workflow change.
|
||||
if [ ! -f starter-smoke-state.json ]; then
|
||||
echo '{"lastStatus":"ok","lastFailureAt":""}' > starter-smoke-state.json
|
||||
fi
|
||||
|
||||
- name: Install test dependencies
|
||||
run: npm ci
|
||||
working-directory: showcase/tests
|
||||
|
||||
- name: Install Playwright
|
||||
working-directory: showcase/tests
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Run starter deployed smoke tests
|
||||
id: playwright
|
||||
working-directory: showcase/tests
|
||||
# Use github reporter for Actions annotations plus json for programmatic parsing.
|
||||
run: npx playwright test e2e/integration-smoke.spec.ts --grep "@starter-health|@starter-agent|@starter-chat" --reporter=github,json
|
||||
env:
|
||||
STARTER_SLUG: ${{ inputs.starter_slug || '' }}
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME: test-results/results.json
|
||||
|
||||
# Extract the failed starters + first 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_count`, `starters`, `error_excerpt`, and
|
||||
# `extraction_error` to $GITHUB_ENV so the Slack notify step below
|
||||
# can reference them via `env.*` (which is valid inside
|
||||
# `toJSON(format(...))` expressions). We deliberately do NOT fail
|
||||
# this step on any error: all extraction branches fall back to
|
||||
# sentinel values so the notify step still fires with best-effort
|
||||
# content.
|
||||
- name: Extract failure details
|
||||
if: failure() && (github.event_name == 'schedule' || github.event_name == 'workflow_run') && env.SLACK_WEBHOOK != ''
|
||||
id: failures
|
||||
working-directory: showcase/tests
|
||||
run: |
|
||||
set +e # best-effort: never block the notify step below
|
||||
REPORT=test-results/results.json
|
||||
EXTRACTION_ERROR=""
|
||||
FAILED_COUNT=0
|
||||
STARTERS=""
|
||||
ERROR_EXCERPT="see workflow run for details"
|
||||
|
||||
if [ ! -f "$REPORT" ]; then
|
||||
# Report file never produced (pre-test stage: install,
|
||||
# playwright install, test discovery, etc.).
|
||||
EXTRACTION_ERROR="missing_report"
|
||||
ERROR_EXCERPT="no JSON report produced — pre-test stage failure (install/setup/discovery)"
|
||||
else
|
||||
# Walk the playwright JSON suite tree, pull failed tests with:
|
||||
# title, tags, first error line. Iterate all tests per spec
|
||||
# so multi-project configs don't drop failures.
|
||||
if ! jq -r '
|
||||
[ .. | objects | select(.tests? and .title?) ] as $specs
|
||||
| $specs
|
||||
| map(
|
||||
. as $spec
|
||||
| ($spec.tests // [])[] as $t
|
||||
| ($t.results // [])[-1] as $r
|
||||
| select($r.status == "failed" or $r.status == "timedOut")
|
||||
| {
|
||||
title: $spec.title,
|
||||
tags: ($spec.title | [scan("@[a-zA-Z0-9_-]+")]),
|
||||
slug: ((try ($spec.title | capture("\\[Starter\\] (?<s>[a-zA-Z0-9_-]+)").s) catch null) // ($spec.title | .[0:40]) // "unknown"),
|
||||
error: (
|
||||
($r.error.message // $r.errors[0].message // "no error message")
|
||||
| gsub("\u001b\\[[0-9;?]*[A-Za-z]"; "")
|
||||
| split("\n")[0]
|
||||
| .[0:240]
|
||||
)
|
||||
}
|
||||
)
|
||||
' "$REPORT" > /tmp/failures.json 2> /tmp/jq-err.log; then
|
||||
echo "::warning::jq failed to parse $REPORT"
|
||||
echo "jq stderr:"; cat /tmp/jq-err.log
|
||||
echo "[]" > /tmp/failures.json
|
||||
EXTRACTION_ERROR="jq_parse_failed"
|
||||
ERROR_EXCERPT="jq failed to parse Playwright report — check workflow log"
|
||||
else
|
||||
FAILED_COUNT=$(jq 'length' /tmp/failures.json 2>/dev/null || echo 0)
|
||||
STARTERS=$(jq -r '[.[].slug] | unique | map(select(. != "")) | join(", ")' /tmp/failures.json 2>/dev/null || echo "")
|
||||
if [ "$FAILED_COUNT" = "0" ]; then
|
||||
# Report exists but jq matched zero failures — job-level
|
||||
# error (e.g. non-zero exit without failed tests).
|
||||
EXTRACTION_ERROR="no_failures_in_report"
|
||||
ERROR_EXCERPT="no test failures in report — job-level error, check run for details"
|
||||
else
|
||||
# Build first-failure excerpt: slug + first error line,
|
||||
# capped at ~240 chars. Slack payload stays well under
|
||||
# the budget even with JSON escaping overhead.
|
||||
ERROR_EXCERPT=$(jq -r '
|
||||
.[0]
|
||||
| "\(.slug): \(.error)"
|
||||
' /tmp/failures.json 2>/dev/null | head -c 240)
|
||||
[ -z "$ERROR_EXCERPT" ] && ERROR_EXCERPT="see workflow run for details"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Emit to $GITHUB_ENV using heredoc delimiters so values
|
||||
# containing `=`, quotes, or newlines don't break KEY=VALUE.
|
||||
{
|
||||
echo "failed_count=${FAILED_COUNT}"
|
||||
echo "extraction_error=${EXTRACTION_ERROR}"
|
||||
echo "starters<<EOF_STARTERS_b3f2"
|
||||
printf '%s\n' "$STARTERS"
|
||||
echo "EOF_STARTERS_b3f2"
|
||||
echo "error_excerpt<<EOF_ERR_EXCERPT_b3f2"
|
||||
printf '%s\n' "$ERROR_EXCERPT"
|
||||
echo "EOF_ERR_EXCERPT_b3f2"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
exit 0 # belt-and-suspenders: never propagate a failure
|
||||
|
||||
# Use inline `payload:` with `toJSON(format(...))` so dynamic
|
||||
# values (starters list, error excerpt) are safely JSON-encoded —
|
||||
# quotes/backslashes/newlines can't break the payload. This
|
||||
# matches the pattern used in showcase_validate.yml (PR #4068) and
|
||||
# replaces the previous `payload-file-path` approach which failed
|
||||
# with `SlackError: Invalid input! Failed to parse file extension`
|
||||
# because slackapi/slack-github-action@v2.1.0 requires `.json` /
|
||||
# `.yaml` / `.yml` and `mktemp` produces extensionless files.
|
||||
- name: Alert Slack on failure
|
||||
if: failure() && (github.event_name == 'schedule' || github.event_name == 'workflow_run') && env.SLACK_WEBHOOK != ''
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload: |
|
||||
{ "text": ${{ toJSON(format(':rotating_light: *Starter Deployed Smoke Test Failed* — {0} failure(s) in [{1}]: {2} | <https://github.com/{3}/actions/runs/{4}|View run>', env.failed_count, env.starters, env.error_excerpt, github.repository, github.run_id)) }} }
|
||||
|
||||
- name: Log (no Slack — webhook unset)
|
||||
if: failure() && (github.event_name == 'schedule' || github.event_name == 'workflow_run') && env.SLACK_WEBHOOK == ''
|
||||
run: |
|
||||
echo "::warning::starter_deployed_smoke failed but SLACK_WEBHOOK_OSS_ALERTS is not set; no Slack notification sent."
|
||||
|
||||
# Compute outcome + state transition for the next run. Runs on
|
||||
# success and failure alike (but not when the job is cancelled —
|
||||
# that's indeterminate). Policy: post recovery message on
|
||||
# red→green transition only. green→green is silent.
|
||||
#
|
||||
# This step also decides whether to save state. We save iff the
|
||||
# run had a terminal outcome (passed or failed tests); cancelled
|
||||
# runs leave the prior state untouched so streaks aren't broken.
|
||||
- name: Compute transition + recovery payload
|
||||
id: transition
|
||||
if: always() && !cancelled() && (github.event_name == 'schedule' || github.event_name == 'workflow_run')
|
||||
run: |
|
||||
set +e
|
||||
NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
|
||||
if [ ! -f starter-smoke-state.json ]; then
|
||||
# Shouldn't happen — "Initialize state if missing" runs before
|
||||
# the tests. Belt-and-suspenders fallback so this step never
|
||||
# crashes and leaves state unwritten.
|
||||
echo '{"lastStatus":"ok","lastFailureAt":""}' > starter-smoke-state.json
|
||||
fi
|
||||
PREV_STATUS=$(jq -r '.lastStatus // "ok"' starter-smoke-state.json)
|
||||
PREV_FAILURE_AT=$(jq -r '.lastFailureAt // ""' starter-smoke-state.json)
|
||||
|
||||
# The Playwright step is the only thing that can fail this job.
|
||||
# We infer success vs failure from job.status, which is exposed
|
||||
# by Actions via the `job` context — but step-level `if:` can't
|
||||
# read it directly. Instead, we use the playwright step's
|
||||
# outcome which is reliably set by the preceding step.
|
||||
PLAYWRIGHT_OUTCOME='${{ steps.playwright.outcome }}'
|
||||
case "$PLAYWRIGHT_OUTCOME" in
|
||||
success)
|
||||
NEW_STATUS="ok"
|
||||
NEW_FAILURE_AT=""
|
||||
if [ "$PREV_STATUS" = "failure" ]; then
|
||||
# Red→green transition — emit recovery. Wording mirrors
|
||||
# the smoke-monitor per-service format.
|
||||
RECOVERY_MSG=":white_check_mark: *Starter Deployed Smoke Tests*: recovered (was down since ${PREV_FAILURE_AT})"
|
||||
jq -n --arg text "$RECOVERY_MSG | <$URL|View run>" '{text: $text}' > /tmp/starter-smoke-recovery.json
|
||||
echo "should_post_recovery=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "should_post_recovery=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
;;
|
||||
failure|*)
|
||||
# Any non-success outcome is treated as failure for state
|
||||
# tracking. The existing "Alert Slack on failure" step
|
||||
# handles the red alert — we only touch state here.
|
||||
NEW_STATUS="failure"
|
||||
NEW_FAILURE_AT="$NOW"
|
||||
echo "should_post_recovery=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
|
||||
jq -n \
|
||||
--arg status "$NEW_STATUS" \
|
||||
--arg failureAt "$NEW_FAILURE_AT" \
|
||||
'{lastStatus: $status, lastFailureAt: $failureAt}' \
|
||||
> starter-smoke-state.json
|
||||
|
||||
echo "update_state=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
|
||||
- name: Alert Slack on recovery
|
||||
if: >-
|
||||
always() && !cancelled() &&
|
||||
steps.transition.outputs.should_post_recovery == 'true' &&
|
||||
(github.event_name == 'schedule' || github.event_name == 'workflow_run') &&
|
||||
env.SLACK_WEBHOOK != ''
|
||||
uses: slackapi/slack-github-action@v2.1.0
|
||||
with:
|
||||
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
|
||||
webhook-type: incoming-webhook
|
||||
payload-file-path: /tmp/starter-smoke-recovery.json
|
||||
|
||||
- name: Save smoke state to cache
|
||||
if: always() && steps.transition.outputs.update_state == 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: starter-smoke-state.json
|
||||
key: starter-smoke-state-${{ github.run_id }}
|
||||
@@ -1,44 +0,0 @@
|
||||
name: static / commitlint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
NX_VERBOSE_LOGGING: true
|
||||
NX_CI_EXECUTION_ID: ${{ github.head_ref }}-${{ github.sha }}-${{ github.run_attempt }}
|
||||
NX_CI_EXECUTION_ENV: "Commitlint"
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: "10.13.1"
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
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: npx commitlint --last --verbose
|
||||
|
||||
- name: Validate PR commits with commitlint
|
||||
if: github.event_name == 'pull_request'
|
||||
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
|
||||
@@ -6,6 +6,10 @@ on:
|
||||
- "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 }}
|
||||
@@ -14,20 +18,21 @@ env:
|
||||
jobs:
|
||||
danger:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: "10.13.1"
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
node-version: 20.x
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
|
||||
|
||||
@@ -7,18 +7,16 @@ on:
|
||||
- "docs/**"
|
||||
- "README.md"
|
||||
- "examples/**"
|
||||
- ".github/workflows/demos_preview.yml"
|
||||
- ".github/workflows/release.yml"
|
||||
- "packages/**/package.json"
|
||||
- "packages/**/CHANGELOG.md"
|
||||
- ".changeset/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "README.md"
|
||||
- "examples/**"
|
||||
- ".changeset/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
@@ -30,10 +28,15 @@ jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -41,7 +44,7 @@ jobs:
|
||||
version: "10.13.1"
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: "pnpm"
|
||||
@@ -50,15 +53,40 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run format check
|
||||
run: pnpm run check-format
|
||||
- name: Run formatter (check on push, fix on PR)
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
if ! pnpm run format; then
|
||||
echo "::warning::Formatter exited with error — auto-fix may be incomplete"
|
||||
fi
|
||||
if [ -n "$(git diff --name-only)" ]; then
|
||||
echo "format_fixed=true" >> $GITHUB_ENV
|
||||
fi
|
||||
fi
|
||||
pnpm run check-format
|
||||
|
||||
- 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
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git diff --name-only -z | xargs -0 git add
|
||||
git commit -m "style: auto-fix formatting"
|
||||
git push
|
||||
|
||||
oxlint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -66,7 +94,7 @@ jobs:
|
||||
version: "10.13.1"
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: "pnpm"
|
||||
@@ -83,7 +111,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -91,7 +119,7 @@ jobs:
|
||||
version: "10.13.1"
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: "pnpm"
|
||||
@@ -109,3 +137,76 @@ jobs:
|
||||
|
||||
- name: Run publint and attw
|
||||
run: pnpm run check:packages
|
||||
|
||||
commitlint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: "10.13.1"
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
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: npx commitlint --last --verbose
|
||||
|
||||
- name: Validate PR commits with commitlint
|
||||
id: commitlint
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.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@v7
|
||||
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
|
||||
|
||||
@@ -6,9 +6,15 @@ on:
|
||||
branches: [main]
|
||||
paths: ["docs/**"]
|
||||
|
||||
# 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
|
||||
|
||||
jobs:
|
||||
validate-model-names:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -21,7 +27,7 @@ jobs:
|
||||
- run: pnpm tsx scripts/validate-doc-model-names.ts
|
||||
|
||||
doc-tests:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 15
|
||||
needs: validate-model-names
|
||||
steps:
|
||||
@@ -42,7 +48,7 @@ jobs:
|
||||
echo "aimock binary: $AIMOCK_BIN"
|
||||
ls -la "$AIMOCK_BIN" || echo "binary not found at expected path"
|
||||
which aimock || echo "aimock not on PATH"
|
||||
nohup node $(npm root -g)/@copilotkit/aimock/dist/cli.js --fixtures scripts/doc-tests/fixtures > /tmp/aimock.log 2>&1 &
|
||||
nohup node $(npm root -g)/@copilotkit/aimock/dist/cli.js --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"
|
||||
|
||||
@@ -19,10 +19,20 @@ on:
|
||||
default: "main"
|
||||
type: string
|
||||
|
||||
# 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:
|
||||
node:
|
||||
name: "runtime / node"
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -59,7 +69,7 @@ jobs:
|
||||
|
||||
bun:
|
||||
name: "runtime / bun"
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -12,9 +12,20 @@ on:
|
||||
- "sdk-python/**"
|
||||
- ".github/workflows/test_unit-python-sdk.yml"
|
||||
|
||||
# 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:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -7,11 +7,6 @@ on:
|
||||
- "docs/**"
|
||||
- "README.md"
|
||||
- "examples/**"
|
||||
- ".github/workflows/demos_preview.yml"
|
||||
- ".github/workflows/release.yml"
|
||||
- "packages/**/package.json"
|
||||
- "packages/**/CHANGELOG.md"
|
||||
- ".changeset/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
@@ -31,10 +26,20 @@ env:
|
||||
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: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -55,6 +60,8 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: "**/pnpm-lock.yaml"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -74,3 +81,6 @@ jobs:
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm run test
|
||||
|
||||
- name: Run release script tests
|
||||
run: npx vitest run --config scripts/release/vitest.config.mts
|
||||
|
||||
@@ -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@v7
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@ __pycache__/
|
||||
|
||||
docs/next-env.d.ts
|
||||
|
||||
# Showcase: staged shared contexts for Docker builds (see showcase/scripts/dev-local.sh)
|
||||
showcase/packages/*/shared_python/
|
||||
showcase/packages/*/shared_typescript/
|
||||
|
||||
# External repos (cloned for development)
|
||||
ext-apps/
|
||||
dist
|
||||
@@ -53,6 +57,10 @@ lefthook-local.yml
|
||||
# Private agent instructions (not shared with the team)
|
||||
private-agents.md
|
||||
|
||||
# Release artifacts
|
||||
release-notes.md
|
||||
release-notes-notion.json
|
||||
|
||||
# Binary artifacts
|
||||
*.dSYM/
|
||||
*.exe
|
||||
|
||||
+26
-1
@@ -11,7 +11,21 @@
|
||||
"import/no-unassigned-import": "off",
|
||||
"unicorn/no-array-sort": "off",
|
||||
"copilotkit/require-cpk-prefix": "off",
|
||||
"no-restricted-imports": "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": [
|
||||
{
|
||||
@@ -42,6 +56,17 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": [
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import cn from "classnames";
|
||||
import React, { useState, ReactNode, useEffect } from "react";
|
||||
import React, {
|
||||
ReactNode,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
// Local className-joining helper so this component has no external dep.
|
||||
// Mirrors the subset of `classnames` behavior used below (strings + falsy values).
|
||||
function cn(...values: Array<string | false | null | undefined>): string {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
type TailoredContentOptionProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -12,14 +24,13 @@ type TailoredContentOptionProps = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function TailoredContentOption({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
}: TailoredContentOptionProps) {
|
||||
// This is just a type definition component - it won't render anything
|
||||
return <div>{children}</div>;
|
||||
/**
|
||||
* Declarative child marker for `TailoredContent`. This component intentionally
|
||||
* renders nothing; the parent reads its props (including `children`) directly
|
||||
* and renders the selected option's content itself.
|
||||
*/
|
||||
export function TailoredContentOption(_props: TailoredContentOptionProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
type TailoredContentProps = {
|
||||
@@ -30,45 +41,127 @@ type TailoredContentProps = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function TailoredContent({
|
||||
type IconElement = React.ReactElement<{ className?: string }>;
|
||||
|
||||
function TailoredContentInner({
|
||||
children,
|
||||
className,
|
||||
defaultOptionIndex = 0,
|
||||
id,
|
||||
header,
|
||||
}: TailoredContentProps) {
|
||||
// All hooks must run unconditionally to satisfy the Rules of Hooks.
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const tabRefs = useRef<Array<HTMLDivElement | null>>([]);
|
||||
const warnedKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Get options from children
|
||||
const options = React.Children.toArray(children).filter((child) =>
|
||||
React.isValidElement(child),
|
||||
) as React.ReactElement<TailoredContentOptionProps>[];
|
||||
// Memoize derived arrays so downstream hook deps have stable identities.
|
||||
const options = useMemo(
|
||||
() =>
|
||||
React.Children.toArray(children).filter((child) =>
|
||||
React.isValidElement(child),
|
||||
) as React.ReactElement<TailoredContentOptionProps>[],
|
||||
[children],
|
||||
);
|
||||
const optionIds = useMemo(
|
||||
() => options.map((option) => option.props.id),
|
||||
[options],
|
||||
);
|
||||
|
||||
if (options.length === 0) {
|
||||
throw new Error(
|
||||
"TailoredContent must have at least one TailoredContentOption child",
|
||||
// Warn (dev-mode friendly) when duplicate option ids would cause ambiguous
|
||||
// URL <-> selection mapping. Runs only when ids change; warnedKeyRef guards
|
||||
// against duplicate warns for the same set (e.g. StrictMode double-invoke).
|
||||
useEffect(() => {
|
||||
const seen = new Set<string>();
|
||||
const duplicates: string[] = [];
|
||||
for (const oid of optionIds) {
|
||||
if (seen.has(oid) && !duplicates.includes(oid)) {
|
||||
duplicates.push(oid);
|
||||
}
|
||||
seen.add(oid);
|
||||
}
|
||||
if (duplicates.length === 0) return;
|
||||
const warnKey = duplicates.join(",");
|
||||
if (warnedKeyRef.current === warnKey) return;
|
||||
warnedKeyRef.current = warnKey;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`TailoredContent(id=${id}): duplicate option id(s) detected: ${duplicates
|
||||
.map((d) => `"${d}"`)
|
||||
.join(", ")}. Option ids must be unique.`,
|
||||
);
|
||||
}
|
||||
}, [optionIds, id]);
|
||||
|
||||
// Get the option IDs for URL handling
|
||||
const optionIds = options.map((option) => option.props.id);
|
||||
const updateSelection = useCallback(
|
||||
(index: number) => {
|
||||
if (index < 0 || index >= options.length) return;
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
newParams.set(id, optionIds[index]);
|
||||
// Update URL without reload; derived selectedIndex will follow.
|
||||
router.replace(`?${newParams.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, searchParams, id, optionIds, options.length],
|
||||
);
|
||||
|
||||
// Initialize selected index from URL or default
|
||||
const [selectedIndex, setSelectedIndex] = useState(() => {
|
||||
const urlParam = searchParams.get(id);
|
||||
const indexFromUrl = optionIds.indexOf(urlParam || "");
|
||||
return indexFromUrl >= 0 ? indexFromUrl : defaultOptionIndex;
|
||||
});
|
||||
// No hooks below this point — safe to short-circuit when there are no options.
|
||||
if (options.length === 0) return null;
|
||||
|
||||
// Update URL when selection changes
|
||||
const updateSelection = (index: number) => {
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
newParams.set(id, optionIds[index]);
|
||||
// Clamp defaultOptionIndex to the valid range.
|
||||
const clampedDefault = Math.min(
|
||||
Math.max(0, defaultOptionIndex),
|
||||
options.length - 1,
|
||||
);
|
||||
|
||||
// Update URL without reload
|
||||
router.replace(`?${newParams.toString()}`, { scroll: false });
|
||||
setSelectedIndex(index);
|
||||
// Derive selectedIndex from the URL on every render so state stays in sync
|
||||
// with navigation (back/forward, external updates to the search param).
|
||||
const urlParam = searchParams.get(id);
|
||||
const indexFromUrl = urlParam ? optionIds.indexOf(urlParam) : -1;
|
||||
const selectedIndex = indexFromUrl >= 0 ? indexFromUrl : clampedDefault;
|
||||
|
||||
const focusTab = (index: number) => {
|
||||
const el = tabRefs.current[index];
|
||||
if (el) el.focus();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>, index: number) => {
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
case " ":
|
||||
case "Spacebar":
|
||||
e.preventDefault();
|
||||
updateSelection(index);
|
||||
return;
|
||||
case "ArrowRight": {
|
||||
e.preventDefault();
|
||||
const next = (index + 1) % options.length;
|
||||
updateSelection(next);
|
||||
focusTab(next);
|
||||
return;
|
||||
}
|
||||
case "ArrowLeft": {
|
||||
e.preventDefault();
|
||||
const prev = (index - 1 + options.length) % options.length;
|
||||
updateSelection(prev);
|
||||
focusTab(prev);
|
||||
return;
|
||||
}
|
||||
case "Home": {
|
||||
e.preventDefault();
|
||||
updateSelection(0);
|
||||
focusTab(0);
|
||||
return;
|
||||
}
|
||||
case "End": {
|
||||
e.preventDefault();
|
||||
const last = options.length - 1;
|
||||
updateSelection(last);
|
||||
focusTab(last);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const itemCn =
|
||||
@@ -78,41 +171,86 @@ export function TailoredContent({
|
||||
const iconCn =
|
||||
"w-10 h-10 mb-4 top-0 transition-all opacity-20 group-[.selected]:text-indigo-500 group-[.selected]:opacity-60 dark:group-[.selected]:text-indigo-400 dark:group-[.selected]:opacity-60 dark:text-gray-400";
|
||||
|
||||
const tablistId = `tailored-content-tablist-${id}`;
|
||||
const tabId = (optId: string) => `tailored-content-tab-${id}-${optId}`;
|
||||
const panelId = (optId: string) => `tailored-content-panel-${id}-${optId}`;
|
||||
|
||||
const selectedOption = options[selectedIndex];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={cn("tailored-content-wrapper mt-4", className)}>
|
||||
{header}
|
||||
<div className="flex flex-col md:flex-row gap-3 my-2 w-full">
|
||||
{options.map((option, index) => (
|
||||
<div
|
||||
key={option.props.id}
|
||||
className={cn(itemCn, selectedIndex === index && selectedCn)}
|
||||
onClick={() => updateSelection(index)}
|
||||
role="tab"
|
||||
aria-selected={selectedIndex === index}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="my-0">
|
||||
{React.isValidElement(option.props.icon) ? (
|
||||
React.cloneElement(
|
||||
option.props.icon as React.ReactElement<any>,
|
||||
{
|
||||
className: cn(iconCn, selectedIndex === index, "my-0"),
|
||||
},
|
||||
)
|
||||
) : (
|
||||
<span className={cn(iconCn, "my-0")} />
|
||||
)}
|
||||
<div
|
||||
id={tablistId}
|
||||
role="tablist"
|
||||
aria-orientation="horizontal"
|
||||
className="flex flex-col md:flex-row gap-3 my-2 w-full"
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
return (
|
||||
<div
|
||||
key={option.props.id}
|
||||
ref={(el) => {
|
||||
tabRefs.current[index] = el;
|
||||
}}
|
||||
id={tabId(option.props.id)}
|
||||
className={cn(itemCn, isSelected && selectedCn)}
|
||||
onClick={() => updateSelection(index)}
|
||||
onKeyDown={(e) => onKeyDown(e, index)}
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
aria-controls={panelId(option.props.id)}
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
>
|
||||
<div className="my-0">
|
||||
{React.isValidElement(option.props.icon) ? (
|
||||
(() => {
|
||||
const icon = option.props.icon as IconElement;
|
||||
return React.cloneElement(icon, {
|
||||
className: cn(icon.props?.className, iconCn, "my-0"),
|
||||
});
|
||||
})()
|
||||
) : (
|
||||
<span className={cn(iconCn, "my-0")} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-lg">{option.props.title}</p>
|
||||
<p className="text-xs md:text-sm">{option.props.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-lg">{option.props.title}</p>
|
||||
<p className="text-xs md:text-sm">{option.props.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{options[selectedIndex]?.props.children}
|
||||
{selectedOption && (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={panelId(selectedOption.props.id)}
|
||||
aria-labelledby={tabId(selectedOption.props.id)}
|
||||
>
|
||||
{selectedOption.props.children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `TailoredContent` renders a set of tab-like options and the currently
|
||||
* selected option's content. The selection is persisted in the URL via
|
||||
* `?<id>=<optionId>` so links are shareable.
|
||||
*
|
||||
* Next.js App Router requires `useSearchParams()` to be wrapped in a
|
||||
* `<Suspense>` boundary. The exported component wraps the inner
|
||||
* implementation so consumers don't need to do that themselves.
|
||||
*/
|
||||
export function TailoredContent(props: TailoredContentProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<TailoredContentInner {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Debug Mode"
|
||||
description: "Enable debug mode to get detailed logging of the AG-UI event pipeline on both the server and client."
|
||||
icon: "lucide/Bug"
|
||||
---
|
||||
|
||||
import DebugMode from "@/snippets/shared/troubleshooting/debug-mode.mdx";
|
||||
|
||||
<DebugMode components={props.components} />
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"pages": [
|
||||
"error-debugging",
|
||||
"debug-mode",
|
||||
"observability-connectors",
|
||||
"common-issues"
|
||||
]
|
||||
|
||||
@@ -128,36 +128,84 @@ In addition, some state properties contain a lot of information. Syncing them ba
|
||||
```tsx title="ui/app/page.tsx"
|
||||
"use client";
|
||||
|
||||
import { useAgent } from "@copilotkit/react-core/v2";
|
||||
import { useState } from "react";
|
||||
import { useAgent, useCopilotKit } from "@copilotkit/react-core/v2";
|
||||
|
||||
// Only define the types for state you'll interact with
|
||||
// Define the agent state type, should match the actual state of your agent
|
||||
type AgentState = {
|
||||
question: string;
|
||||
answer: string;
|
||||
// Note: 'resources' is intentionally omitted - it's internal to the agent
|
||||
}
|
||||
|
||||
function YourMainContent() {
|
||||
/* Example usage in a pseudo React component */
|
||||
function YourMainContent() { // [!code highlight]
|
||||
const [inputQuestion, setInputQuestion] = useState("What's the capital of France?");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { agent } = useAgent({
|
||||
agentId: "my_agent",
|
||||
initialState: {
|
||||
question: "How's the weather in SF?",
|
||||
answer: "",
|
||||
}
|
||||
});
|
||||
const { copilotkit } = useCopilotKit();
|
||||
|
||||
const askQuestion = (newQuestion: string) => {
|
||||
agent.setState({ ...agent.state, question: newQuestion });
|
||||
const askQuestion = async (newQuestion: string) => {
|
||||
setIsLoading(true);
|
||||
|
||||
// Update the state with the new question
|
||||
agent.setState({ ...agent.state, question: newQuestion, answer: "" });
|
||||
|
||||
try {
|
||||
// Add a message and trigger the agent to run
|
||||
agent.addMessage({
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content: newQuestion,
|
||||
});
|
||||
await copilotkit.runAgent({ agent });
|
||||
} catch (error) {
|
||||
console.error("Error running agent:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
|
||||
<h1>Q&A Assistant</h1>
|
||||
<p><strong>Question:</strong> {agent.state?.question}</p>
|
||||
<p><strong>Answer:</strong> {agent.state?.answer || "Waiting for response..."}</p>
|
||||
<button onClick={() => askQuestion("What's the capital of France?")}>
|
||||
Ask New Question
|
||||
</button>
|
||||
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={inputQuestion}
|
||||
onChange={(e) => setInputQuestion(e.target.value)}
|
||||
placeholder="Enter your question..."
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
width: "300px",
|
||||
marginRight: "0.5rem",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ccc"
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => askQuestion(inputQuestion)}
|
||||
disabled={isLoading || !inputQuestion.trim()}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: "4px",
|
||||
border: "none",
|
||||
backgroundColor: isLoading ? "#ccc" : "#0070f3",
|
||||
color: "white",
|
||||
cursor: isLoading ? "not-allowed" : "pointer"
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Thinking..." : "Ask Question"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1.5rem" }}>
|
||||
<p><strong>Question:</strong> {agent.state?.question || "(none yet)"}</p>
|
||||
<p><strong>Answer:</strong> {agent.state?.answer || (isLoading ? "Thinking..." : "Waiting for question...")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ agent = ConversableAgent(
|
||||
name="assistant",
|
||||
system_message="You are a helpful assistant.",
|
||||
llm_config=LLMConfig({"model": "gpt-5.4-mini"}),
|
||||
human_input_mode="NEVER",
|
||||
)
|
||||
|
||||
stream = AGUIStream(agent)
|
||||
@@ -140,6 +141,7 @@ agent = ConversableAgent(
|
||||
name="assistant",
|
||||
system_message="You are a helpful assistant.",
|
||||
llm_config=LLMConfig({"model": "gpt-5.4-mini"}),
|
||||
human_input_mode="NEVER",
|
||||
)
|
||||
|
||||
stream = AGUIStream(agent)
|
||||
|
||||
@@ -100,6 +100,7 @@ Without frontend actions, agents are limited to just processing and returning da
|
||||
name="assistant",
|
||||
system_message="You are a helpful assistant.",
|
||||
llm_config=LLMConfig({"model": "gpt-5.4-mini"}),
|
||||
human_input_mode="NEVER",
|
||||
)
|
||||
|
||||
stream = AGUIStream(agent)
|
||||
|
||||
@@ -67,6 +67,7 @@ is a situation where a user and an agent are working together to solve a problem
|
||||
"Use `add_search` once per query, then call `run_searches`."
|
||||
),
|
||||
llm_config=LLMConfig({"model": "gpt-5.4-mini"}),
|
||||
human_input_mode="NEVER",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ Start your AG2 backend with a `/chat` endpoint and connect CopilotKit to that en
|
||||
name="assistant",
|
||||
system_message="You are a helpful assistant.",
|
||||
llm_config=LLMConfig({"model": "gpt-5.4-mini"}),
|
||||
human_input_mode="NEVER",
|
||||
)
|
||||
|
||||
@agent.register_for_llm(
|
||||
|
||||
@@ -116,6 +116,7 @@ Use frontend tools when you need your agent to interact with client-side primiti
|
||||
"After it returns, call `store_user_choice` with the selected value."
|
||||
),
|
||||
llm_config=LLMConfig({"model": "gpt-5.4-mini"}),
|
||||
human_input_mode="NEVER",
|
||||
)
|
||||
|
||||
@agent.register_for_llm(description="Store the latest user choice in shared state.")
|
||||
|
||||
@@ -118,6 +118,7 @@ This context can then be shared with your AG2 backend.
|
||||
"Call `get_colleagues` before suggesting recipients."
|
||||
),
|
||||
llm_config=LLMConfig({"model": "gpt-5.4-mini"}),
|
||||
human_input_mode="NEVER",
|
||||
)
|
||||
|
||||
@agent.register_for_llm(description="Return the current user's colleagues from CopilotKit readables.")
|
||||
@@ -204,6 +205,7 @@ This context can then be shared with your AG2 backend.
|
||||
name="assistant",
|
||||
system_message="You are a helpful assistant.",
|
||||
llm_config=LLMConfig({"model": "gpt-5.4-mini"}),
|
||||
human_input_mode="NEVER",
|
||||
)
|
||||
|
||||
@agent.register_for_llm(description="Read colleagues from CopilotKit readable context.")
|
||||
|
||||
@@ -65,6 +65,7 @@ state updates, you can reflect these updates natively in your application.
|
||||
"Always respond in the current language."
|
||||
),
|
||||
llm_config=LLMConfig({"model": "gpt-5.4-mini"}),
|
||||
human_input_mode="NEVER",
|
||||
)
|
||||
|
||||
@agent.register_for_llm(description="Update the language in shared state.")
|
||||
|
||||
@@ -64,6 +64,7 @@ You can use this when you want to keep your interface and backend agent state sy
|
||||
"Always respond in the current language."
|
||||
),
|
||||
llm_config=LLMConfig({"model": "gpt-5.4-mini"}),
|
||||
human_input_mode="NEVER",
|
||||
)
|
||||
|
||||
@agent.register_for_llm(description="Update the language in shared state.")
|
||||
|
||||
@@ -5,6 +5,11 @@ icon: "lucide/Play"
|
||||
hideTOC: true
|
||||
---
|
||||
|
||||
import {
|
||||
TailoredContent,
|
||||
TailoredContentOption,
|
||||
} from "@/components/react/tailored-content.tsx";
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
@@ -13,132 +18,442 @@ hideTOC: true
|
||||
## Getting started
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Install the Agent Spec AG‑UI adapter (backend)
|
||||
<TailoredContent
|
||||
className="step"
|
||||
id="agent-spec-quickstart-path"
|
||||
header={
|
||||
<div>
|
||||
<p className="text-xl font-semibold">Choose your starting point</p>
|
||||
<p className="text-base">
|
||||
You can either start fresh with our starter template or connect CopilotKit to an existing Agent Spec agent.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TailoredContentOption
|
||||
id="starter"
|
||||
title="Start from scratch"
|
||||
description="Get started quickly with our ready-to-go Agent Spec starter."
|
||||
>
|
||||
<Step>
|
||||
### Install the Agent Spec AG‑UI adapter (backend)
|
||||
|
||||
The AG‑UI integration for Agent Spec lives in `ag-ui/integrations/agent-spec/python`. Here's how to install it:
|
||||
The AG‑UI integration for Agent Spec lives in `ag-ui/integrations/agent-spec/python`. You will need it to activate your agent environment in the starter. Here's how to install it:
|
||||
|
||||
```bash
|
||||
# Clone the adapter and move into the Python package
|
||||
git clone https://github.com/ag-ui-protocol/ag-ui.git
|
||||
cd ag-ui/integrations/agent-spec/python
|
||||
```bash
|
||||
# Clone the adapter and move into the Python package
|
||||
git clone --depth 1 --filter=blob:none --sparse https://github.com/ag-ui-protocol/ag-ui.git
|
||||
cd ag-ui
|
||||
git sparse-checkout set integrations/agent-spec/python
|
||||
cd integrations/agent-spec/python
|
||||
```
|
||||
|
||||
This will setup AG-UI integration which will be used by the starter repository to install starter templates agent. This is only for agent environment setup
|
||||
As this integration package uses `uv` as the package manager, you can easily install it with:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
Agent Spec is a specification language that declares the structure of your agents and workflows. Agent Spec agents can be run on various agent frameworks. Currently, we support LangGraph and WayFlow (Oracle's reference agent framework, with native support for Agent Spec).
|
||||
Here are the different installation options depending on which agent framework you want to execute your Agent Spec agent on:
|
||||
|
||||
```bash
|
||||
uv sync --extra langgraph # for LangGraph
|
||||
uv sync --extra wayflow # for WayFlow
|
||||
uv sync --extra langgraph --extra wayflow # for both
|
||||
```
|
||||
|
||||
Alternatively, you can use `pip`:
|
||||
|
||||
```bash
|
||||
pip install -e .[wayflow]
|
||||
pip install -e .[langgraph]
|
||||
pip install -e .[wayflow,langgraph]
|
||||
```
|
||||
|
||||
Note: these commands would install [`pyagentspec`](https://github.com/oracle/agent-spec) and [`wayflowcore`](https://github.com/oracle/wayflow) packages from source (i.e. the respective GitHub repos).
|
||||
Instead, you can install these packages from PyPI separately:
|
||||
|
||||
```bash
|
||||
pip install pyagentspec[langgraph]
|
||||
pip install wayflowcore
|
||||
```
|
||||
|
||||
or you can also use uv (In case you encounter any issues with pip installation)
|
||||
|
||||
```bash
|
||||
uv add pyagentspec[langgraph]
|
||||
uv add wayflowcore
|
||||
```
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Configure your environment
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=...
|
||||
export OPENAI_MODEL=gpt-5.4
|
||||
```
|
||||
|
||||
Note that these environment variables can point to any OpenAI-compatible LLM provider (e.g., local vLLM server, Together AI), but the variable names need to be `OPENAI_API_KEY` and `OPENAI_MODEL`.
|
||||
|
||||
Reference: Agent Spec docs AG‑UI tutorial at https://oracle.github.io/agent-spec/26.1.0/howtoguides/howto_ag_ui.html.
|
||||
</Step>
|
||||
<Step>
|
||||
### Scaffold the UI
|
||||
|
||||
Use our starter repo template: https://github.com/CopilotKit/with-agent-spec. It includes an example definition of an Agent Spec agent [here](https://github.com/CopilotKit/with-agent-spec/blob/main/agent/src/agentspec_agent.py). Run the following commands
|
||||
|
||||
Go to your root directory
|
||||
|
||||
```bash
|
||||
# If you are in the path integrations/agent-spec/python, then run the following command to go to root
|
||||
cd ../../../../
|
||||
```
|
||||
Clone the starter template
|
||||
|
||||
```bash
|
||||
# If you are in the path integrations/agent-spec/python, then run the following command to go to root
|
||||
git clone https://github.com/CopilotKit/with-agent-spec.git
|
||||
cd with-agent-spec
|
||||
```
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Install Dependencies
|
||||
|
||||
Run the following commands to install your dependencies in the start repository
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
### Run your project
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
# or npm run dev / yarn dev / bun dev
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### 🎉 Start chatting!
|
||||
|
||||
Your AI agent is now ready to use! Navigate to `localhost:3000` and try asking it some questions:
|
||||
|
||||
```
|
||||
Can you tell me a joke?
|
||||
```
|
||||
|
||||
As this integration package uses `uv` as the package manager, you can easily install it with:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
Can you help me understand AI?
|
||||
```
|
||||
|
||||
Agent Spec is a specification language that declares the structure of your agents and workflows. Agent Spec agents can be run on various agent frameworks. Currently, we support LangGraph and WayFlow (Oracle's reference agent framework, with native support for Agent Spec).
|
||||
Here are the different installation options depending on which agent framework you want to execute your Agent Spec agent on:
|
||||
|
||||
```bash
|
||||
uv sync --extra langgraph # for LangGraph
|
||||
uv sync --extra wayflow # for WayFlow
|
||||
uv sync --extra langgraph --extra wayflow # for both
|
||||
```
|
||||
What do you think about React?
|
||||
```
|
||||
|
||||
Alternatively, you can use `pip`:
|
||||
<Accordions className="mb-4">
|
||||
<Accordion title="Troubleshooting">
|
||||
- If you're having connection issues, try using `0.0.0.0` or `127.0.0.1` instead of `localhost`
|
||||
- Make sure your agent is running on port 8000
|
||||
- Check that your OpenAI API key is correctly set
|
||||
- Verify that the `@ag-ui/client` package is installed in your frontend
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
```bash
|
||||
pip install -e .[wayflow]
|
||||
pip install -e .[langgraph]
|
||||
pip install -e .[wayflow,langgraph]
|
||||
```
|
||||
|
||||
Note: these commands would install [`pyagentspec`](https://github.com/oracle/agent-spec) and [`wayflowcore`](https://github.com/oracle/wayflow) packages from source (i.e. the respective GitHub repos).
|
||||
Instead, you can install these packages from PyPI separately:
|
||||
|
||||
```bash
|
||||
pip install pyagentspec[langgraph]
|
||||
pip install wayflowcore
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Configure your environment
|
||||
|
||||
</TailoredContentOption>
|
||||
<TailoredContentOption
|
||||
id="bring-your-own"
|
||||
title="Use an existing agent"
|
||||
description="I already have an Agent Spec setup and want to connect CopilotKit UI."
|
||||
>
|
||||
<Step>
|
||||
### Install the Agent Spec AG‑UI adapter (backend)
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=...
|
||||
export OPENAI_MODEL=gpt-5.4
|
||||
The AG‑UI integration for Agent Spec lives in `ag-ui/integrations/agent-spec/python`. Here's how to install it:
|
||||
|
||||
```bash
|
||||
# Clone the adapter and move into the Python package
|
||||
git clone --depth 1 --filter=blob:none --sparse https://github.com/ag-ui-protocol/ag-ui.git
|
||||
cd ag-ui
|
||||
git sparse-checkout set integrations/agent-spec/python
|
||||
cd integrations/agent-spec/python
|
||||
```
|
||||
|
||||
As this integration package uses `uv` as the package manager, you can easily install it with:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
Agent Spec is a specification language that declares the structure of your agents and workflows. Agent Spec agents can be run on various agent frameworks. Currently, we support LangGraph and WayFlow (Oracle's reference agent framework, with native support for Agent Spec).
|
||||
Here are the different installation options depending on which agent framework you want to execute your Agent Spec agent on:
|
||||
|
||||
```bash
|
||||
uv sync --extra langgraph # for LangGraph
|
||||
uv sync --extra wayflow # for WayFlow
|
||||
uv sync --extra langgraph --extra wayflow # for both
|
||||
```
|
||||
|
||||
Alternatively, you can use `pip`:
|
||||
|
||||
```bash
|
||||
pip install -e .[wayflow]
|
||||
pip install -e .[langgraph]
|
||||
pip install -e .[wayflow,langgraph]
|
||||
```
|
||||
|
||||
Note: these commands would install [`pyagentspec`](https://github.com/oracle/agent-spec) and [`wayflowcore`](https://github.com/oracle/wayflow) packages from source (i.e. the respective GitHub repos).
|
||||
Instead, you can install these packages from PyPI separately:
|
||||
|
||||
```bash
|
||||
pip install pyagentspec[langgraph]
|
||||
pip install wayflowcore
|
||||
```
|
||||
|
||||
or you can also use uv (In case you encounter any issues with pip installation)
|
||||
|
||||
```bash
|
||||
uv add pyagentspec[langgraph]
|
||||
uv add wayflowcore
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Configure your environment
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=...
|
||||
export OPENAI_MODEL=gpt-5.4
|
||||
```
|
||||
|
||||
Note that these environment variables can point to any OpenAI-compatible LLM provider (e.g., local vLLM server, Together AI), but the variable names need to be `OPENAI_API_KEY` and `OPENAI_MODEL`.
|
||||
|
||||
Reference: Agent Spec docs AG‑UI tutorial at https://oracle.github.io/agent-spec/26.1.0/howtoguides/howto_ag_ui.html.
|
||||
</Step>
|
||||
<Step>
|
||||
### Set up your Agent
|
||||
|
||||
Go to ag_ui_agentspec directory
|
||||
|
||||
```bash
|
||||
cd ag_ui_agentspec
|
||||
```
|
||||
|
||||
create a main.py file in the ag_ui_agentspec directory
|
||||
|
||||
|
||||
```bash
|
||||
#file path: ag-ui/integrations/agent-spec/python/ag_ui_agentspec/main.py
|
||||
from pyagentspec.agent import Agent
|
||||
from pyagentspec.llms import OpenAiCompatibleConfig
|
||||
from pyagentspec.serialization import AgentSpecSerializer
|
||||
from fastapi import FastAPI
|
||||
from ag_ui_agentspec.agent import AgentSpecAgent
|
||||
from ag_ui_agentspec.endpoint import add_agentspec_fastapi_endpoint
|
||||
import uvicorn
|
||||
|
||||
agentspec_agent = Agent(
|
||||
name="AgentSpecAgent",
|
||||
description="A starter Agent that can call tools.",
|
||||
system_prompt="You are a helpful assistant, named Specky, that speaks a lot.",
|
||||
llm_config=OpenAiCompatibleConfig(
|
||||
name="my-llm",
|
||||
model_id="gpt-5.4",
|
||||
url="https://api.openai.com/v1",
|
||||
),
|
||||
)
|
||||
|
||||
agent_spec_config = AgentSpecSerializer().to_json(agentspec_agent)
|
||||
|
||||
|
||||
#OR you can specify your own agent_spec_config like below
|
||||
#agent_spec_config = <loaded json/yaml string of your Agent Spec agent>
|
||||
|
||||
runtime = "langgraph" # or "wayflow"
|
||||
|
||||
app = FastAPI()
|
||||
agent = AgentSpecAgent(agent_spec_config=agent_spec_config, runtime=runtime)
|
||||
add_agentspec_fastapi_endpoint(app, agentspec_agent=agent, path="/")
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Create your frontend
|
||||
|
||||
CopilotKit works with any React-based frontend. We'll use Next.js for this example.
|
||||
Go to your root directory, then create a Next.js project
|
||||
|
||||
```bash
|
||||
npx create-next-app@latest my-copilot-app
|
||||
cd my-copilot-app
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Install CopilotKit packages
|
||||
|
||||
```npm
|
||||
npm install @copilotkit/react-ui @copilotkit/react-core @copilotkit/runtime @ag-ui/client
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Setup Copilot Runtime
|
||||
|
||||
Create an API route to connect CopilotKit to your Pydantic AI agent:
|
||||
|
||||
```tsx title="app/api/copilotkit/route.ts"
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const serviceAdapter = new ExperimentalEmptyAdapter();
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
my_agent: new HttpAgent({ url: "http://localhost:8000/" }),
|
||||
}
|
||||
});
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
runtime,
|
||||
serviceAdapter,
|
||||
endpoint: "/api/copilotkit",
|
||||
});
|
||||
|
||||
return handleRequest(req);
|
||||
};
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Configure CopilotKit Provider
|
||||
|
||||
Wrap your application with the CopilotKit provider:
|
||||
|
||||
```tsx title="app/layout.tsx"
|
||||
import { CopilotKit } from "@copilotkit/react-core"; // [!code highlight]
|
||||
import "@copilotkit/react-ui/v2/styles.css";
|
||||
import './globals.css';
|
||||
|
||||
// ...
|
||||
|
||||
export default function RootLayout({ children }: {children: React.ReactNode}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
{/* [!code highlight:3] */}
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="my_agent">
|
||||
{children}
|
||||
</CopilotKit>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Add the chat interface
|
||||
|
||||
Add the CopilotSidebar component to your page:
|
||||
|
||||
```tsx title="app/page.tsx"
|
||||
import { CopilotSidebar } from "@copilotkit/react-core/v2"; // [!code highlight:1]
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<h1>Your App</h1>
|
||||
{/* [!code highlight:1] */}
|
||||
<CopilotSidebar />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Start your agent
|
||||
|
||||
From your agent directory, start the agent server:
|
||||
|
||||
```bash
|
||||
cd ..
|
||||
cd ag-ui/integrations/agent-spec/python/ag_ui_agentspec
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
Your agent will be available at `http://localhost:8000`.
|
||||
</Step>
|
||||
<Step>
|
||||
### Start your UI
|
||||
|
||||
In a separate terminal, navigate to your frontend directory and start the development server:
|
||||
|
||||
<Tabs groupId="package-manager" items={['npm', 'pnpm', 'yarn', 'bun']}>
|
||||
<Tab value="npm">
|
||||
```bash
|
||||
cd my-copilot-app
|
||||
npm run dev
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="pnpm">
|
||||
```bash
|
||||
cd my-copilot-app
|
||||
pnpm dev
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="yarn">
|
||||
```bash
|
||||
cd my-copilot-app
|
||||
yarn dev
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="bun">
|
||||
```bash
|
||||
cd my-copilot-app
|
||||
bun dev
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Step>
|
||||
<Step>
|
||||
### 🎉 Start chatting!
|
||||
|
||||
Your AI agent is now ready to use! Navigate to `localhost:3000` and try asking it some questions:
|
||||
|
||||
```
|
||||
Can you tell me a joke?
|
||||
```
|
||||
|
||||
Note that these environment variables can point to any OpenAI-compatible LLM provider (e.g., local vLLM server, Together AI), but the variable names need to be `OPENAI_API_KEY` and `OPENAI_MODEL`.
|
||||
|
||||
Reference: Agent Spec docs AG‑UI tutorial at https://oracle.github.io/agent-spec/26.1.0/howtoguides/howto_ag_ui.html.
|
||||
</Step>
|
||||
<Step>
|
||||
### Scaffold the UI
|
||||
|
||||
Use our starter repo template: https://github.com/CopilotKit/with-agent-spec. It includes an example definition of an Agent Spec agent [here](https://github.com/CopilotKit/with-agent-spec/blob/main/agent/src/agentspec_agent.py).
|
||||
|
||||
#### Minimal starter Agent Spec agent definition
|
||||
|
||||
```python agentspec_agent.py
|
||||
from pyagentspec.agent import Agent
|
||||
from pyagentspec.llms import OpenAiCompatibleConfig
|
||||
from pyagentspec.serialization import AgentSpecSerializer
|
||||
|
||||
agentspec_agent = Agent(
|
||||
name="AgentSpecAgent",
|
||||
description="A starter Agent that can call tools.",
|
||||
system_prompt="You are a helpful assistant, named Specky, that speaks a lot.",
|
||||
llm_config=OpenAiCompatibleConfig(
|
||||
name="my-llm",
|
||||
model_id="gpt-5.4",
|
||||
url="https://api.openai.com/v1",
|
||||
),
|
||||
)
|
||||
|
||||
agent_spec_config = AgentSpecSerializer().to_json(agentspec_agent)
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Add a minimal FastAPI endpoint (backend)
|
||||
|
||||
Create a FastAPI app that loads your Agent Spec file and exposes an AG‑UI FastAPI endpoint. Replace the `runtime` to match your adapter (`langgraph` or `wayflow`).
|
||||
|
||||
```python src/main.py
|
||||
from fastapi import FastAPI
|
||||
from ag_ui_agentspec.agent import AgentSpecAgent
|
||||
from ag_ui_agentspec.endpoint import add_agentspec_fastapi_endpoint
|
||||
|
||||
agent_spec_config = <loaded json/yaml string of your Agent Spec agent>
|
||||
runtime = "langgraph" # or "wayflow"
|
||||
|
||||
app = FastAPI()
|
||||
agent = AgentSpecAgent(agent_spec_config=agent_spec_config, runtime=runtime)
|
||||
add_agentspec_fastapi_endpoint(app, agentspec_agent=agent, path="/")
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
Can you help me understand AI?
|
||||
```
|
||||
|
||||
Here, we use the `add_agentspec_fastapi_endpoint` utility from the integration package. It sets up the endpoint and the wiring of Agent Spec Tracing events to AG-UI events.
|
||||
|
||||
To run the backend agent:
|
||||
|
||||
```bash
|
||||
uv run src/main.py
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
### Connect the UI to your frontend server
|
||||
|
||||
Make sure the frontend UI server knows what host/port the backend agent is running on. In this tutorial, we use http://localhost:8000/ as the host/port.
|
||||
</Step>
|
||||
<Step>
|
||||
### Run Next.js
|
||||
|
||||
From the root directory of [our starter repo](https://github.com/CopilotKit/with-agent-spec/), run:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
# or npm run dev / yarn dev / bun dev
|
||||
What do you think about React?
|
||||
```
|
||||
|
||||
Note that this command also launches the agent backend in `agent/src`. Now, open http://localhost:3000 and start chatting with your agent.
|
||||
<Accordions className="mb-4">
|
||||
<Accordion title="Troubleshooting">
|
||||
- If you're having connection issues, try using `0.0.0.0` or `127.0.0.1` instead of `localhost`
|
||||
- Make sure your agent is running on port 8000
|
||||
- Check that your OpenAI API key is correctly set
|
||||
- Verify that the `@ag-ui/client` package is installed in your frontend
|
||||
</Accordion>
|
||||
</Accordions>
|
||||
|
||||
</Step>
|
||||
</TailoredContentOption>
|
||||
</TailoredContent>
|
||||
</Steps>
|
||||
|
||||
## Tools and tool registry
|
||||
|
||||
@@ -39,6 +39,7 @@ is a situation where a user and an agent are working together to solve a problem
|
||||
Configure your Strands agent to maintain state. Here's an example that tracks searches:
|
||||
|
||||
```python title="agent/main.py"
|
||||
import os
|
||||
import json
|
||||
from ag_ui_strands import StrandsAgent, StrandsAgentConfig, ToolBehavior, create_strands_app
|
||||
from strands import Agent, tool
|
||||
@@ -89,7 +90,7 @@ is a situation where a user and an agent are working together to solve a problem
|
||||
|
||||
agui_agent = StrandsAgent(
|
||||
agent=strands_agent,
|
||||
name="searchAgent",
|
||||
name="strands_agent",
|
||||
description="A helpful assistant for storing searches",
|
||||
config=config,
|
||||
)
|
||||
@@ -122,7 +123,7 @@ is a situation where a user and an agent are working together to solve a problem
|
||||
// [!code highlight:13]
|
||||
// styles omitted for brevity
|
||||
useAgent({
|
||||
agentId: "searchAgent",
|
||||
agentId: "strands_agent",
|
||||
render: ({ state }) => (
|
||||
<div>
|
||||
{state.searches?.map((search, index) => (
|
||||
@@ -168,7 +169,7 @@ is a situation where a user and an agent are working together to solve a problem
|
||||
|
||||
// [!code highlight:3]
|
||||
const { agent } = useAgent({
|
||||
agentId: "searchAgent",
|
||||
agentId: "strands_agent",
|
||||
})
|
||||
|
||||
// ...
|
||||
|
||||
@@ -96,7 +96,7 @@ state updates, you can reflect these updates natively in your application.
|
||||
function YourMainContent() {
|
||||
// [!code highlight:5]
|
||||
const { agent } = useAgent({
|
||||
agentId: "languageAgent",
|
||||
agentId: "strands_agent",
|
||||
// optionally provide a type-safe initial state
|
||||
initialState: { language: "spanish" }
|
||||
});
|
||||
@@ -143,7 +143,7 @@ function YourMainContent() {
|
||||
// ...
|
||||
// [!code highlight:7]
|
||||
useAgent({
|
||||
agentId: "languageAgent",
|
||||
agentId: "strands_agent",
|
||||
render: ({ state }) => {
|
||||
if (!state.language) return null;
|
||||
return <div>Language: {state.language}</div>;
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useAgent } from "@copilotkit/react-core/v2"; // [!code highlight]
|
||||
function TaskBoard() {
|
||||
// [!code highlight:3]
|
||||
const { agent } = useAgent({
|
||||
agentId: "assistant",
|
||||
agentId: "default",
|
||||
});
|
||||
|
||||
// Read state set by the agent // [!code highlight]
|
||||
@@ -60,7 +60,7 @@ import { useAgent } from "@copilotkit/react-core/v2";
|
||||
|
||||
function SettingsPanel() {
|
||||
const { agent } = useAgent({
|
||||
agentId: "assistant",
|
||||
agentId: "default",
|
||||
});
|
||||
|
||||
const handleThemeChange = (theme: string) => {
|
||||
@@ -99,7 +99,7 @@ import { useAgent } from "@copilotkit/react-core/v2";
|
||||
|
||||
function TodoApp() {
|
||||
const { agent } = useAgent({
|
||||
agentId: "assistant",
|
||||
agentId: "default",
|
||||
});
|
||||
|
||||
const todos = (agent.state.todos as any[]) ?? [];
|
||||
|
||||
@@ -29,7 +29,7 @@ First, pass the configuration properties as you would like to receive them in th
|
||||
|
||||
```tsx title="app/page.tsx"
|
||||
import { useAgent } from "@copilotkit/react-core/v2"; // [!code highlight]
|
||||
|
||||
import { useEffect } from "react";
|
||||
function YourMainContent() {
|
||||
// ...
|
||||
|
||||
@@ -40,16 +40,18 @@ function YourMainContent() {
|
||||
|
||||
// Pass configuration when running the agent
|
||||
// [!code highlight:8]
|
||||
agent.runAgent({
|
||||
forwardedProps: {
|
||||
config: {
|
||||
configurable: {
|
||||
authToken: 'example-token'
|
||||
},
|
||||
recursion_limit: 50,
|
||||
useEffect(() => {
|
||||
agent.runAgent({
|
||||
forwardedProps: {
|
||||
config: {
|
||||
configurable: {
|
||||
authToken: 'example-token'
|
||||
},
|
||||
recursion_limit: 50,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ...
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"python": {
|
||||
"deps": ["copilotkit", "langgraph", "langchain-openai", "langchain-core", "fastapi", "uvicorn"]
|
||||
"deps": ["copilotkit", "langgraph", "langchain-openai", "langchain-core", "fastapi", "uvicorn", "ag-ui-langgraph", "dotenv"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,14 +111,12 @@ agent = create_agent(
|
||||
<Step>
|
||||
### Configure the runtime (TypeScript)
|
||||
|
||||
Enable A2UI in your CopilotRuntime:
|
||||
Enable A2UI in your CopilotRuntime. The middleware auto-detects A2UI operations in any tool result, so no tool injection is needed here — the agent's `search_flights` tool returns them directly.
|
||||
|
||||
```typescript title="app/api/copilotkit/route.ts"
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { default: myAgent },
|
||||
a2ui: {
|
||||
injectA2UITool: true,
|
||||
},
|
||||
a2ui: {},
|
||||
});
|
||||
```
|
||||
</Step>
|
||||
|
||||
@@ -73,6 +73,7 @@ Use state rendering when you want to:
|
||||
```python title="agent.py"
|
||||
import asyncio
|
||||
from copilotkit.langgraph import copilotkit_emit_state # [!code highlight]
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
async def chat_node(state: AgentState, config: RunnableConfig):
|
||||
state["searches"] = [
|
||||
|
||||
+8
@@ -176,6 +176,10 @@ We're going to have the agent ask us to name it, so we'll need a state property
|
||||
|
||||
To do this, we'll use the `useInterrupt` hook, give it a component to render, and then call `resolve` with the user's response.
|
||||
|
||||
<Callout type="warn">
|
||||
**`agentId` must match a runtime-registered agent.** If you omit `agentId`, the hook assumes `"default"`. If the IDs don't match, the interrupt will never fire.
|
||||
</Callout>
|
||||
|
||||
```tsx title="app/page.tsx"
|
||||
import { useInterrupt } from "@copilotkit/react-core/v2"; // [!code highlight]
|
||||
// ...
|
||||
@@ -185,6 +189,7 @@ We're going to have the agent ask us to name it, so we'll need a state property
|
||||
// [!code highlight:15]
|
||||
// styles omitted for brevity
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
render: ({ event, resolve }) => (
|
||||
<div>
|
||||
<p>{event.value}</p>
|
||||
@@ -326,6 +331,7 @@ For this reason, the hook can take an `enabled` argument which will apply it con
|
||||
// ...
|
||||
// [!code highlight:13]
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
enabled: ({ eventValue }) => eventValue.type === 'ask',
|
||||
render: ({ event, resolve }) => (
|
||||
<AskComponent question={event.value.content} onAnswer={answer => resolve(answer)} />
|
||||
@@ -333,6 +339,7 @@ For this reason, the hook can take an `enabled` argument which will apply it con
|
||||
});
|
||||
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
enabled: ({ eventValue }) => eventValue.type === 'approval',
|
||||
render: ({ event, resolve }) => (
|
||||
<ApproveComponent content={event.value.content} onAnswer={answer => resolve(answer)} />
|
||||
@@ -371,6 +378,7 @@ const YourMainContent = () => {
|
||||
// styles omitted for brevity
|
||||
// [!code highlight:28]
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
handler: async ({ result, event, resolve }) => {
|
||||
const { department } = await getUserByEmail(userEmail)
|
||||
if (event.value.accessDepartment === department || department === 'admin') {
|
||||
|
||||
@@ -176,6 +176,10 @@ We're going to have the agent ask us to name it, so we'll need a state property
|
||||
|
||||
To do this, we'll use the `useInterrupt` hook, give it a component to render, and then call `resolve` with the user's response.
|
||||
|
||||
<Callout type="warn">
|
||||
**`agentId` must match a runtime-registered agent.** If you omit `agentId`, the hook assumes `"default"`. If the IDs don't match, the interrupt will never fire.
|
||||
</Callout>
|
||||
|
||||
```tsx title="app/page.tsx"
|
||||
import { useInterrupt } from "@copilotkit/react-core/v2"; // [!code highlight]
|
||||
// ...
|
||||
@@ -185,6 +189,7 @@ We're going to have the agent ask us to name it, so we'll need a state property
|
||||
// [!code highlight:15]
|
||||
// styles omitted for brevity
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
render: ({ event, resolve }) => (
|
||||
<div>
|
||||
<p>{event.value}</p>
|
||||
@@ -326,6 +331,7 @@ For this reason, the hook can take an `enabled` argument which will apply it con
|
||||
// ...
|
||||
// [!code highlight:13]
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
enabled: ({ eventValue }) => eventValue.type === 'ask',
|
||||
render: ({ event, resolve }) => (
|
||||
<AskComponent question={event.value.content} onAnswer={answer => resolve(answer)} />
|
||||
@@ -333,6 +339,7 @@ For this reason, the hook can take an `enabled` argument which will apply it con
|
||||
});
|
||||
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
enabled: ({ eventValue }) => eventValue.type === 'approval',
|
||||
render: ({ event, resolve }) => (
|
||||
<ApproveComponent content={event.value.content} onAnswer={answer => resolve(answer)} />
|
||||
@@ -371,6 +378,7 @@ const YourMainContent = () => {
|
||||
// styles omitted for brevity
|
||||
// [!code highlight:28]
|
||||
useInterrupt({
|
||||
agentId: "starterAgent",
|
||||
handler: async ({ result, event, resolve }) => {
|
||||
const { department } = await getUserByEmail(userEmail)
|
||||
if (event.value.accessDepartment === department || department === 'admin') {
|
||||
|
||||
@@ -164,7 +164,7 @@ Before you begin, you'll need the following:
|
||||
Add LangGraph and the required AG-UI packages to your project:
|
||||
|
||||
```bash
|
||||
uv add langgraph copilotkit langchain-openai langchain-core
|
||||
uv add langgraph copilotkit langchain-openai langchain-core dotenv
|
||||
```
|
||||
</Step>
|
||||
<Step>
|
||||
@@ -177,9 +177,11 @@ Before you begin, you'll need the following:
|
||||
First, we'll create a simple LangChain agent:
|
||||
|
||||
```python title="main.py"
|
||||
from dotenv import load_dotenv
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.graph import END, START, MessagesState, StateGraph
|
||||
load_dotenv()
|
||||
|
||||
async def mock_llm(state: MessagesState):
|
||||
model = ChatOpenAI(model="gpt-4.1-mini")
|
||||
@@ -231,6 +233,7 @@ Before you begin, you'll need the following:
|
||||
import os
|
||||
|
||||
# [!code highlight:2]
|
||||
from dotenv import load_dotenv
|
||||
from ag_ui_langgraph import add_langgraph_fastapi_endpoint
|
||||
from copilotkit import LangGraphAGUIAgent
|
||||
from fastapi import FastAPI
|
||||
@@ -239,6 +242,7 @@ Before you begin, you'll need the following:
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
import uvicorn
|
||||
load_dotenv()
|
||||
|
||||
async def mock_llm(state: MessagesState):
|
||||
model = ChatOpenAI(model="gpt-4.1-mini")
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
---
|
||||
title: Readables
|
||||
title: Agent App Context
|
||||
icon: "lucide/BookA"
|
||||
description: Share app specific context with your agent.
|
||||
---
|
||||
|
||||
## What is this?
|
||||
|
||||
One of the most common use cases for CopilotKit is to register app state and context using `useCopilotReadble`.
|
||||
This way, you can notify your agent of what is going in your app in real time.
|
||||
One of the most common use cases for CopilotKit is to register app state and context using `useAgentContext`.
|
||||
This way, you can notify your agent of what is going on in your app in real time.
|
||||
|
||||
## When should I use this?
|
||||
|
||||
You can use this when you want to provide the user with feedback about what your working memory. As your agent's
|
||||
You can use this when you want to provide the user with feedback about what is in your working memory. As your agent's
|
||||
state updates, you can reflect these updates natively in your application.
|
||||
|
||||
Some examples might be: the current user, the current page, etc. This be shared with your agent in real time.
|
||||
Some examples might be: the current user, the current page, etc. This can be shared with your agent in real time.
|
||||
|
||||
## Implementation
|
||||
<Steps>
|
||||
<Step>
|
||||
### Wrap your data in a readable
|
||||
### Share data with your agent
|
||||
|
||||
The [`useAgentContext` hook](/reference/v2/hooks/useAgentContext) is used to add data as context to the Copilot.
|
||||
|
||||
```tsx title="YourComponent.tsx" showLineNumbers {1, 7-10}
|
||||
```tsx title="YourComponent.tsx" showLineNumbers
|
||||
"use client" // only necessary if you are using Next.js with the App Router. // [!code highlight]
|
||||
import { useAgentContext } from "@copilotkit/react-core/v2"; // [!code highlight]
|
||||
import { useState } from 'react';
|
||||
@@ -36,7 +36,7 @@ Some examples might be: the current user, the current page, etc. This be shared
|
||||
{ id: 3, name: "Bob Wilson", role: "Product Manager" }
|
||||
]);
|
||||
|
||||
// Define Copilot readable state
|
||||
// Define agent context
|
||||
// [!code highlight:4]
|
||||
useAgentContext({
|
||||
description: "The current user's colleagues",
|
||||
@@ -58,18 +58,22 @@ Some examples might be: the current user, the current page, etc. This be shared
|
||||
You can read more about it [here](https://mastra.ai/en/docs/agents/runtime-context)
|
||||
|
||||
```tsx title="agent.ts"
|
||||
export const colleaguesContactorAgent = new Agent({
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { Agent } from "@mastra/core/agent";
|
||||
|
||||
export const colleaguesContactAgent = new Agent({
|
||||
id: "colleague-agent",
|
||||
name: "Colleagues contact Agent",
|
||||
model: openai("gpt-5.4"),
|
||||
model: openai("gpt-4o"),
|
||||
// Use the injected runtime context
|
||||
// [!code highlight:9]
|
||||
instructions: ({ runtimeContext }) => {
|
||||
// AG-UI context is an array of items, the specific context can be grabbed by filtering
|
||||
const aguiContext = runtimeContext.get('ag-ui')?.context
|
||||
const colleaguesContextItem = aguiContext.find(contextItem => contextItem.description === 'The current user\'s colleagues"')
|
||||
const aguiContext = runtimeContext.get('ag-ui') as { context: Array<{ description: string; value: unknown }> } | undefined;
|
||||
const colleaguesContextItem = aguiContext?.context?.find((contextItem: { description: string; value: unknown }) => contextItem.description === "The current user's colleagues")
|
||||
return `
|
||||
You are a helpful assistant that can help emailing colleagues.
|
||||
The user's colleagues are: ${colleaguesContextItem.value}
|
||||
The user's colleagues are: ${JSON.stringify(colleaguesContextItem?.value, null, 2)}
|
||||
`
|
||||
},
|
||||
// ... Everything else used to configure your agent
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Human-in-the-Loop
|
||||
description: Learn how to implement Human-in-the-Loop (HITL) using Mastra Agents.
|
||||
icon: lucide/User
|
||||
---
|
||||
|
||||
import { CTACards } from "@/components/react/cta-cards";
|
||||
|
||||
<video
|
||||
src="https://cdn.copilotkit.ai/docs/copilotkit/images/coagents/human-in-the-loop-example.mp4"
|
||||
className="rounded-lg shadow-xl"
|
||||
loop
|
||||
playsInline
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
/>
|
||||
|
||||
## What is Human-in-the-Loop (HITL)?
|
||||
|
||||
Human-in-the-loop (HITL) allows agents to request human input or approval during execution, making AI systems more reliable and trustworthy. This pattern is essential when building AI applications that need to handle complex decisions or actions that require human judgment.
|
||||
|
||||
## When should I use this?
|
||||
|
||||
HITL combines the efficiency of AI with human judgment, creating a system that's both powerful and reliable. The key advantages include:
|
||||
|
||||
- **Quality Control**: Human oversight at critical decision points
|
||||
- **Edge Cases**: Graceful handling of low-confidence situations
|
||||
- **Expert Input**: Leverage human expertise when needed
|
||||
- **Reliability**: More robust system for real-world use
|
||||
|
||||
## How can I use this?
|
||||
|
||||
Mastra supports two approaches to HITL, each suited for different use cases.
|
||||
|
||||
<CTACards
|
||||
columns={2}
|
||||
cards={[
|
||||
{
|
||||
iconKey: "circlePause",
|
||||
title: "Interrupt-based",
|
||||
description:
|
||||
"Use Mastra's native suspend/resume to pause tool execution and collect user input via useInterrupt.",
|
||||
href: "/mastra/human-in-the-loop/interrupt-flow",
|
||||
},
|
||||
{
|
||||
iconKey: "share2",
|
||||
title: "Tool-based",
|
||||
description:
|
||||
"Register frontend tools with useHumanInTheLoop that render UI and wait for user responses.",
|
||||
href: "/mastra/human-in-the-loop/tool-based",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user