Skip to content

fix(hero): drop the internal tool name from the walkthrough prompt (#… #2723

fix(hero): drop the internal tool name from the walkthrough prompt (#…

fix(hero): drop the internal tool name from the walkthrough prompt (#… #2723

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# A merge queue runs each candidate on a gh-readonly-queue/* ref. Every
# verification job already falls through to the ci-scope outputs when the
# event is not `push`, so they scope themselves here exactly as they do on a
# pull request. The deploy jobs additionally require
# `github.ref == 'refs/heads/main'`, which a queue ref never matches, so a
# queued candidate can never promote to production.
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
env:
DO_NOT_TRACK: '1'
jobs:
ci-scope:
name: CI scope
runs-on: ubuntu-latest
outputs:
library: ${{ steps.scope.outputs.library }}
angular_compatibility: ${{ steps.scope.outputs.angular_compatibility }}
website: ${{ steps.scope.outputs.website }}
cockpit: ${{ steps.scope.outputs.cockpit }}
cockpit_examples: ${{ steps.scope.outputs.cockpit_examples }}
cockpit_smoke: ${{ steps.scope.outputs.cockpit_smoke }}
cockpit_deploy_smoke: ${{ steps.scope.outputs.cockpit_deploy_smoke }}
examples_chat: ${{ steps.scope.outputs.examples_chat }}
examples_ag_ui: ${{ steps.scope.outputs.examples_ag_ui }}
cockpit_e2e: ${{ steps.scope.outputs.cockpit_e2e }}
website_e2e: ${{ steps.scope.outputs.website_e2e }}
posthog: ${{ steps.scope.outputs.posthog }}
scripts_tests: ${{ steps.scope.outputs.scripts_tests }}
growth_lifecycle: ${{ steps.scope.outputs.growth_lifecycle }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
# ci-scope.mjs delegates project ownership to `nx show projects --affected`
# (introduced in PR 2 of the ci-scope thin-shim migration), which requires
# nx + the workspace's plugins to be installed. npm ci needs ~15-30s with
# the npm cache warm; ~60s cold. This is the one-time-per-PR cost of using
# nx's project graph as the source of truth instead of a hand-maintained
# classifier walk.
- run: npm ci
- name: Test CI scope classifier
run: node --test scripts/ci-scope.spec.mjs scripts/cockpit-matrix.spec.mjs scripts/cockpit-ports.spec.mjs scripts/cockpit-runtime-bridge-coverage.spec.mjs
- name: Detect changed CI surfaces
id: scope
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
node scripts/ci-scope.mjs \
--event push \
--output "$GITHUB_OUTPUT"
exit 0
fi
if [ "${{ github.event_name }}" = "merge_group" ]; then
node scripts/ci-scope.mjs \
--event pull_request \
--base "${{ github.event.merge_group.base_sha }}" \
--head "${{ github.event.merge_group.head_sha }}" \
--output "$GITHUB_OUTPUT"
exit 0
fi
base_sha="${{ github.event.pull_request.base.sha }}"
head_sha="${{ github.event.pull_request.head.sha }}"
if ! git cat-file -e "$base_sha^{commit}" 2>/dev/null || ! git cat-file -e "$head_sha^{commit}" 2>/dev/null; then
base_sha="$(git rev-parse HEAD^1)"
head_sha="$(git rev-parse HEAD^2)"
fi
node scripts/ci-scope.mjs \
--event pull_request \
--base "$base_sha" \
--head "$head_sha" \
--output "$GITHUB_OUTPUT"
- name: Validate CI workflow guards
run: node --test scripts/ci-workflow.spec.mjs
scripts-tests:
name: Scripts — generator / proxy vitest suites
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.scripts_tests == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- run: npm ci
# Vitest suites for the root deployment/proxy generators (drift checks,
# framework-adapter branching, requirements unions, rate limits). The
# node:test suites in scripts/ run in ci-scope and library instead —
# see scripts/vite.config.mts for the split.
- run: npx nx test scripts
library:
name: Library — lint / test / build
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.library == 'true' || needs.ci-scope.outputs.angular_compatibility == 'true'
runs-on: ubuntu-latest
env:
LIBS: chat,langgraph,ag-ui,render,a2ui,telemetry
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx nx run-many -t lint --projects=$LIBS
- run: npx nx test langgraph --coverage --maxWorkers=2 --reporter=default
- run: npx nx run-many -t test --projects=chat,ag-ui,render,a2ui,telemetry --coverage --parallel=1 --maxWorkers=2
- run: npx nx run-many -t build --projects=$LIBS --configuration=production
- run: node scripts/verify-release-versions.mjs
- name: DX-coverage — public dev-facing functions must have a JSDoc summary
run: node scripts/check-dx-coverage.mjs
- run: node --test examples/chat/smoke/*.spec.mjs scripts/verify-angular-support.spec.mjs
- run: node scripts/verify-angular-support.mjs
- name: Upload production library artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: threadplane-library-dist
path: dist/libs
if-no-files-found: error
retention-days: 1
angular-compatibility:
name: 'Angular ${{ matrix.angular }} — packaged consumer'
needs: [ci-scope, library]
if: github.event_name == 'push' || needs.ci-scope.outputs.angular_compatibility == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
angular: [20, 21, 22]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22.22.3
cache: npm
- run: npm ci
- name: Download production library artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: threadplane-library-dist
path: dist/libs
- name: Install Chromium
run: npx playwright install --with-deps chromium
- name: Generate, install, build, and run consumer
run: >-
node examples/chat/smoke/cli.mjs
--non-interactive --fresh
--target "${{ runner.temp }}/threadplane-angular-${{ matrix.angular }}"
--local-dist-root dist/libs
--angular-major "${{ matrix.angular }}"
--install --build --runtime
- name: Upload compatibility diagnostics on failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: angular-${{ matrix.angular }}-compatibility-diagnostics
path: |
${{ runner.temp }}/threadplane-angular-${{ matrix.angular }}/package.json
${{ runner.temp }}/threadplane-angular-${{ matrix.angular }}/package-lock.json
${{ runner.temp }}/threadplane-angular-${{ matrix.angular }}/runtime-smoke.png
${{ runner.temp }}/threadplane-angular-${{ matrix.angular }}/runtime-smoke-trace.zip
if-no-files-found: warn
retention-days: 7
website:
name: Website — lint / test / build
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.website == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
env:
# Server pages read the growth form policy while rendering, so the build
# needs the same switch the deployed environment sets. It is not a secret.
GROWTH_FORM_POLICY: growth_v1
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || github.sha }}
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx nx lint website
- run: npx nx test website
- run: npm run generate-api-docs
- name: Commit generated API docs to same-repo PR
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
env:
# Bind the attacker-controllable PR branch name to an env var so it is
# passed as a literal value, never interpolated into the script text
# (prevents shell injection — OSSF Scorecard Dangerous-Workflow).
HEAD_REF: ${{ github.head_ref }}
run: |
if git diff --quiet -- apps/website/content/docs/*/api/api-docs.json; then
echo "Generated API docs are already committed."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add apps/website/content/docs/*/api/api-docs.json
git commit -m "chore(docs): regenerate api docs"
git push origin "HEAD:$HEAD_REF"
- name: Verify generated API docs are committed
run: git diff --exit-code -- apps/website/content/docs/*/api/api-docs.json
- run: npx nx build website
growth-lifecycle:
name: Growth lifecycle — Node 22
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.growth_lifecycle == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx nx lint growth
- run: npx nx test growth
- run: npx nx run growth:test-operator-cli
- run: npx nx build growth
- run: npx nx test google-mailbox-poller
- run: npx nx lint google-mailbox-poller
lifecycle:
name: Lifecycle — Node 24
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.growth_lifecycle == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx nx lint lifecycle
- run: npx nx test lifecycle
- run: npx nx run lifecycle:check
- run: npx nx build lifecycle
cockpit:
name: Cockpit — build / test
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.cockpit == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx nx build cockpit --skip-nx-cache
# cockpit-docs, cockpit-registry and workspace-react carry `test` targets
# that nothing in CI invoked: `nx test` does not walk `^test`, and the
# `library` job runs a hardcoded LIBS list that excludes all three. Name
# them here so their specs actually execute. They all share the
# `scope:cockpit` tag, so ci-scope already gates this job correctly for
# changes under any of them. workspace-react's only other scope tag is
# `scope:shared`, which is not a SCOPE_KEY — adding it to LIBS would not
# have run it, because a workspace-react change never flips `library`.
- run: npx nx lint workspace-react
- run: npx nx run-many -t test --projects=cockpit,cockpit-docs,cockpit-registry,workspace-react --skip-nx-cache
cockpit-examples-build:
name: Cockpit — build all examples
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.cockpit_examples == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- run: npm ci
- name: Build cockpit examples (affected on PR, all on push)
run: |
if [ "${{ github.event_name }}" = "push" ]; then
npx nx run-many -t build --projects='cockpit-*-angular' --skip-nx-cache
else
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
AFFECTED=$(npx nx show projects --affected --base="$BASE" --head="$HEAD" \
| grep -E '^cockpit-.*-angular$' | paste -sd, - || true)
if [ -z "$AFFECTED" ]; then
echo "No affected cockpit angular projects; nothing to build."
else
echo "Building affected: $AFFECTED"
npx nx run-many -t build --projects="$AFFECTED" --skip-nx-cache
fi
fi
cockpit-smoke:
name: Cockpit — representative capability smoke
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.cockpit_smoke == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx nx run-many -t smoke --projects=cockpit-ag-ui-interrupts-python,cockpit-ag-ui-streaming-python,cockpit-deep-agents-planning-python,cockpit-deep-agents-filesystem-python,cockpit-deep-agents-subagents-python,cockpit-deep-agents-memory-python,cockpit-deep-agents-skills-python,cockpit-langgraph-persistence-python,cockpit-langgraph-durable-execution-python,cockpit-langgraph-streaming-python,cockpit-langgraph-interrupts-python,cockpit-langgraph-memory-python,cockpit-langgraph-subgraphs-python,cockpit-langgraph-time-travel-python,cockpit-langgraph-deployment-runtime-python,cockpit-chat-messages-python,cockpit-render-spec-rendering-python,cockpit-runtimes-microsoft-agent-framework-python,cockpit-runtimes-aws-strands-python --skip-nx-cache
cockpit-deploy-smoke:
name: Cockpit — deploy smoke dry-run
timeout-minutes: 30 # fail fast instead of blocking the main concurrency group on a hang
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.cockpit_deploy_smoke == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx tsx apps/cockpit/scripts/deploy-smoke.ts --url https://cockpit.threadplane.ai --mode preview --dry-run
cockpit-preview-smoke:
name: Cockpit — immutable preview smoke
timeout-minutes: 20
needs: ci-scope
# PR-side twin of the deploy job's cockpit verification. Deploys a
# throwaway preview of the redirect service and runs the exhaustive smoke
# against it, so platform behaviour (deployment protection, the CDN's
# slash collapse, route ordering) is exercised before merge. Needs
# repository secrets, so same-repo PRs and merge-queue candidates only;
# the token-free dry-run job above still covers forks.
if: >-
github.event_name != 'push' &&
needs.ci-scope.outputs.cockpit_deploy_smoke == 'true' &&
(github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- run: npm ci
- name: Prepare cockpit Vercel project (preview)
run: |
mkdir -p .vercel
cat > .vercel/project.json <<'EOF'
{"projectId":"${{ secrets.VERCEL_COCKPIT_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"threadplane-cockpit"}
EOF
npx vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
rm -rf .vercel/output
- name: Build cockpit redirect service (preview)
env:
COCKPIT_WEBSITE_ORIGIN: https://threadplane.ai
run: |
npx vercel build --local-config vercel.cockpit.json --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy throwaway cockpit preview
id: deploy_cockpit_preview
run: |
set -euo pipefail
url=$(npx vercel deploy --prebuilt --archive=tgz --yes --env COCKPIT_WEBSITE_ORIGIN=https://threadplane.ai --token=${{ secrets.VERCEL_TOKEN }} | tail -n 1)
url=$(node -e 'const parsed = new URL(process.argv[1]); if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) throw new Error("Vercel returned a non-origin cockpit preview URL"); process.stdout.write(parsed.origin)' "$url")
echo "deployment_url=$url" >> "$GITHUB_OUTPUT"
- name: Exhaustively verify the cockpit preview
run: |
if [ -z "${VERCEL_AUTOMATION_BYPASS_SECRET}" ]; then
echo "::error::VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET is unset — the protected cockpit preview cannot be verified. Enable 'Protection Bypass for Automation' on the Vercel threadplane-cockpit project and store the value as this repository secret."
exit 1
fi
npx tsx apps/cockpit/scripts/deploy-smoke.ts --url "${{ steps.deploy_cockpit_preview.outputs.deployment_url }}" --mode preview --retries 20 --retry-delay-ms 5000
env:
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET }}
- name: Remove the throwaway cockpit preview
# Removal runs on every outcome, including a failed smoke: the smoke
# log carries the full probe-by-probe report, and keeping a failed
# artifact would only accumulate protected deployments nobody can
# open without the bypass.
# The artifact exists only to be smoked. `--safe` refuses to remove a
# deployment that carries an alias, so this can never touch the
# production artifact. `remove` takes a bare URL, so, like `promote`,
# it needs the team scope spelled out.
if: always() && steps.deploy_cockpit_preview.outputs.deployment_url != ''
continue-on-error: true
run: npx vercel remove "${{ steps.deploy_cockpit_preview.outputs.deployment_url }}" --safe --yes --scope=${{ secrets.VERCEL_ORG_ID }} --token=${{ secrets.VERCEL_TOKEN }}
examples-chat-smoke:
name: examples/chat — python smoke
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.examples_chat == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
python-version: '3.12'
- run: npm ci
- name: Cache examples-chat python venv
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: examples/chat/python/.venv
key: uv-venv-${{ runner.os }}-py3.12-${{ hashFiles('examples/chat/python/uv.lock') }}
- working-directory: examples/chat/python
run: uv sync
- run: npx nx run examples-chat-python:smoke --skip-nx-cache
examples-chat-e2e:
name: 'examples/chat — e2e (${{ matrix.shard }}/4)'
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.examples_chat == 'true'
runs-on: ubuntu-latest
timeout-minutes: 35
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
python-version: '3.12'
- run: npm ci
- name: Cache examples-chat python venv
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: examples/chat/python/.venv
key: uv-venv-${{ runner.os }}-py3.12-${{ hashFiles('examples/chat/python/uv.lock') }}
- working-directory: examples/chat/python
run: uv sync
- name: Cache Playwright browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
playwright-${{ runner.os }}-
- run: npx playwright install --with-deps chromium
- run: npx nx e2e examples-chat-angular --skip-nx-cache -- --shard=${{ matrix.shard }}/4
- name: Upload Playwright trace on failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: examples-chat-e2e-trace-shard-${{ matrix.shard }}
path: |
test-results/
examples/chat/angular/e2e/test-results/
retention-days: 7
examples-chat-e2e-summary:
name: 'examples/chat — e2e'
needs: [ci-scope, examples-chat-e2e]
if: always() && (github.event_name == 'push' || needs.ci-scope.outputs.examples_chat == 'true')
runs-on: ubuntu-latest
steps:
- name: Aggregate matrix outcome
run: |
if [[ "${{ needs.examples-chat-e2e.result }}" != "success" ]]; then
echo "Matrix outcome: ${{ needs.examples-chat-e2e.result }}"
exit 1
fi
echo "All examples-chat-e2e matrix expansions passed."
examples-ag-ui-e2e:
name: 'examples/ag-ui — e2e'
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.examples_ag_ui == 'true'
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
python-version: '3.12'
- run: npm ci
- name: Cache examples-ag-ui python venv
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: examples/ag-ui/python/.venv
key: uv-venv-${{ runner.os }}-py3.12-${{ hashFiles('examples/ag-ui/python/uv.lock') }}
- working-directory: examples/ag-ui/python
run: uv sync
- name: Check requirements.txt matches uv.lock
working-directory: examples/ag-ui/python
run: |
# requirements.txt is what the Dockerfile installs from; uv.lock is what
# dependency bumps actually update. Regenerate in place with the very
# command the file records in its own header — uv stamps both the flags
# and the -o path there, so exporting to a scratch path or dropping
# --no-dev can never match a correctly-generated file — and let
# `git diff` compare against the committed version.
if ! head -1 requirements.txt | grep -q 'autogenerated by uv'; then
echo "examples/ag-ui/python/requirements.txt is hand-maintained, not a uv export — skipping drift check."
exit 0
fi
flags="--no-hashes"
if sed -n 2p requirements.txt | grep -q -- '--no-dev'; then
flags="$flags --no-dev"
fi
uv export $flags -o requirements.txt
if ! git diff --exit-code -- requirements.txt; then
echo "::error::examples/ag-ui/python/requirements.txt is stale — the Railway image installs from this file, not uv.lock. Run 'uv export --no-hashes -o requirements.txt' in examples/ag-ui/python and commit the result."
exit 1
fi
- name: Cache Playwright browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
playwright-${{ runner.os }}-
- run: npx playwright install --with-deps chromium
- run: npx nx e2e examples-ag-ui-angular --skip-nx-cache
- name: Upload Playwright trace on failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: examples-ag-ui-e2e-trace
path: |
test-results/
examples/ag-ui/angular/e2e/test-results/
retention-days: 7
cockpit-e2e-dispatcher:
name: Cockpit — e2e dispatcher
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.cockpit_e2e == 'true'
runs-on: ubuntu-latest
outputs:
caps: ${{ steps.matrix.outputs.caps }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- run: npm ci
- name: Compute affected base + head
id: refs
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT"
echo "head=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT"
echo "full=false" >> "$GITHUB_OUTPUT"
else
echo "base=HEAD~1" >> "$GITHUB_OUTPUT"
echo "head=HEAD" >> "$GITHUB_OUTPUT"
echo "full=true" >> "$GITHUB_OUTPUT"
fi
- name: Emit cap matrix
id: matrix
run: |
node scripts/cockpit-matrix.mjs \
--base "${{ steps.refs.outputs.base }}" \
--head "${{ steps.refs.outputs.head }}" \
--full-fleet "${{ steps.refs.outputs.full }}"
cockpit-e2e:
name: 'Cockpit — e2e (${{ matrix.cap.angular }})'
needs: [ci-scope, cockpit-e2e-dispatcher]
if: github.event_name == 'push' || needs.ci-scope.outputs.cockpit_e2e == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 5
matrix:
cap: ${{ fromJson(needs.cockpit-e2e-dispatcher.outputs.caps) }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- name: Install uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
python-version: '3.12'
- run: npm ci
# matrix.cap.python is '' for caps whose backend is not Python (e.g.
# cockpit-runtimes-mastra-angular — its backend is the
# deployments/ag-ui-mastra Node service, installed by the example's
# own e2e global setup). Skip the uv/venv steps for those.
- name: Cache cap python venv
if: matrix.cap.python != ''
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ matrix.cap.python }}/.venv
key: uv-venv-${{ runner.os }}-py3.12-${{ matrix.cap.python }}-${{ hashFiles(format('{0}/uv.lock', matrix.cap.python)) }}
- name: uv sync per-cap python
if: matrix.cap.python != ''
working-directory: ${{ matrix.cap.python }}
run: uv sync
- name: Check requirements.txt matches uv.lock
if: matrix.cap.python != ''
working-directory: ${{ matrix.cap.python }}
run: |
# Only a handful of caps (the ones deployed via a Railway/Docker
# image, e.g. cockpit/ag-ui/*) check in a requirements.txt export
# alongside pyproject.toml/uv.lock. Skip caps that don't have one.
if [ ! -f requirements.txt ]; then
echo "No requirements.txt in ${{ matrix.cap.python }} — skipping drift check."
exit 0
fi
# requirements.txt is what the Dockerfile installs from; uv.lock is what
# dependency bumps actually update. Regenerate in place with the very
# command the file records in its own header — uv stamps both the flags
# and the -o path there, so exporting to a scratch path or dropping
# --no-dev can never match a correctly-generated file — and let
# `git diff` compare against the committed version.
if ! head -1 requirements.txt | grep -q 'autogenerated by uv'; then
echo "${{ matrix.cap.python }}/requirements.txt is hand-maintained, not a uv export — skipping drift check."
exit 0
fi
flags="--no-hashes"
if sed -n 2p requirements.txt | grep -q -- '--no-dev'; then
flags="$flags --no-dev"
fi
uv export $flags -o requirements.txt
if ! git diff --exit-code -- requirements.txt; then
echo "::error::${{ matrix.cap.python }}/requirements.txt is stale relative to uv.lock — deployments/ag-ui-dev is generated from this file. Run 'uv export --no-hashes -o requirements.txt' in ${{ matrix.cap.python }} and commit the result."
exit 1
fi
- name: Cache Playwright browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
playwright-${{ runner.os }}-
- run: npx playwright install --with-deps chromium
- name: nx e2e ${{ matrix.cap.angular }}
run: npx nx e2e "${{ matrix.cap.angular }}" --skip-nx-cache
- name: Upload Playwright trace on failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: cockpit-e2e-trace-${{ matrix.cap.angular }}
path: |
cockpit/**/angular/e2e/test-results/
retention-days: 7
cockpit-e2e-summary:
name: 'Cockpit — e2e'
needs: [ci-scope, cockpit-e2e]
if: always() && (github.event_name == 'push' || needs.ci-scope.outputs.cockpit_e2e == 'true')
runs-on: ubuntu-latest
steps:
- name: Aggregate matrix outcome
run: |
if [[ "${{ needs.cockpit-e2e.result }}" != "success" ]]; then
echo "Matrix outcome: ${{ needs.cockpit-e2e.result }}"
exit 1
fi
echo "All cockpit-e2e matrix expansions passed."
website-e2e:
name: Website — e2e
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.website_e2e == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
playwright-${{ runner.os }}-
- run: npx playwright install --with-deps chromium
- run: npx nx e2e website --skip-nx-cache
# The public-copy gate reads served output, and `next dev` serves a
# different bundle than production. Run it a second time against a real
# production build so a claim that only appears in the built output — in a
# generated bundle or a response body — cannot reach the site unnoticed.
- name: Public copy boundary (production build)
env:
GROWTH_FORM_POLICY: growth_v1
WEBSITE_E2E_MODE: production
run: npx nx e2e website --skip-nx-cache --grep "public copy boundary|canonical policy surface"
# The production-smoke spec is testIgnore'd outside PRODUCTION_SMOKE mode,
# so a module-load error in it is invisible until the post-merge Production
# smoke job runs against main — too late to gate a PR. Collect it here:
# --list loads every spec without hitting production.
- name: Production-smoke spec must load
env:
PRODUCTION_SMOKE: 'true'
BASE_URL: https://threadplane.ai
run: |
npx playwright test apps/website/e2e/platform-production-smoke.spec.ts \
--config apps/website/playwright.config.ts --list
website-preview-e2e:
name: Website — e2e (deployed preview)
timeout-minutes: 40
needs: ci-scope
# PR-side twin of the deploy job's post-promotion verification. The
# ordinary suite runs against a real, protected Vercel preview of the
# Website whose runtime iframe loads from a matching examples preview, so
# remote-target assumptions and platform behaviour surface before merge.
# Two deterministic aliases break the ordering problem: the examples are
# assembled with the Website alias in their parent-origin policy, and the
# Website is built with the examples alias as its runtime base. A later
# push re-points both aliases; the deployments behind them are kept for
# inspection. Needs repository secrets, so same-repo PRs and merge-queue
# candidates only.
if: >-
github.event_name != 'push' &&
needs.ci-scope.outputs.website_e2e == 'true' &&
(github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
playwright-${{ runner.os }}-
- run: npx playwright install --with-deps chromium
- name: Derive deterministic preview aliases
id: aliases
run: |
if [ "${{ github.event_name }}" = "merge_group" ]; then
key="mq-$(echo "${{ github.event.merge_group.head_sha }}" | cut -c1-8)"
else
key="pr-${{ github.event.pull_request.number }}"
fi
echo "website=threadplane-${key}-cacheplane.vercel.app" >> "$GITHUB_OUTPUT"
echo "examples=threadplane-examples-${key}-cacheplane.vercel.app" >> "$GITHUB_OUTPUT"
- name: Require preview bypass secrets
# Both previews sit behind deployment protection, and bypass secrets
# are issued per Vercel project. Fail before creating anything.
run: |
if [ -z "${VERCEL_AUTOMATION_BYPASS_SECRET}" ]; then
echo "::error::VERCEL_AUTOMATION_BYPASS_SECRET is unset — the protected Website preview cannot be verified. Enable 'Protection Bypass for Automation' on the Vercel threadplane project and store the value as this repository secret."
exit 1
fi
if [ -z "${VERCEL_EXAMPLES_AUTOMATION_BYPASS_SECRET}" ]; then
echo "::error::VERCEL_EXAMPLES_AUTOMATION_BYPASS_SECRET is unset — the runtime iframe from the protected examples preview cannot load. Enable 'Protection Bypass for Automation' on the Vercel threadplane-examples project and store the value as this repository secret."
exit 1
fi
env:
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
VERCEL_EXAMPLES_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_EXAMPLES_AUTOMATION_BYPASS_SECRET }}
- name: Build and assemble Angular examples for the preview
run: npx tsx scripts/assemble-examples.ts
env:
RUNTIME_PARENT_PREVIEW_ORIGINS: https://${{ steps.aliases.outputs.website }}
- name: Deploy examples preview and alias it
working-directory: deploy/examples
run: |
set -euo pipefail
mkdir -p .vercel
cat > .vercel/project.json <<'EOF'
{"projectId":"${{ secrets.VERCEL_EXAMPLES_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"threadplane-examples"}
EOF
npx vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
url=$(npx vercel deploy --prebuilt --yes --token=${{ secrets.VERCEL_TOKEN }} | tail -n 1)
url=$(node -e 'const parsed = new URL(process.argv[1]); if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) throw new Error("Vercel returned a non-origin examples preview URL"); process.stdout.write(parsed.origin)' "$url")
echo "examples deployment: $url"
npx vercel alias set "$url" "${{ steps.aliases.outputs.examples }}" --scope=${{ secrets.VERCEL_ORG_ID }} --token=${{ secrets.VERCEL_TOKEN }}
- name: Build, deploy, and alias the Website preview
env:
GROWTH_FORM_POLICY: growth_v1
NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL: https://${{ steps.aliases.outputs.examples }}
run: |
set -euo pipefail
mkdir -p .vercel
cat > .vercel/project.json <<'EOF'
{"projectId":"${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"threadplane"}
EOF
npx vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
rm -rf .vercel/output
npx vercel build --token=${{ secrets.VERCEL_TOKEN }}
url=$(npx vercel deploy --prebuilt --archive=tgz --yes --token=${{ secrets.VERCEL_TOKEN }} | tail -n 1)
url=$(node -e 'const parsed = new URL(process.argv[1]); if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) throw new Error("Vercel returned a non-origin Website preview URL"); process.stdout.write(parsed.origin)' "$url")
echo "website deployment: $url"
npx vercel alias set "$url" "${{ steps.aliases.outputs.website }}" --scope=${{ secrets.VERCEL_ORG_ID }} --token=${{ secrets.VERCEL_TOKEN }}
- name: Run the Website suite against the aliased preview
run: npx nx e2e website --skip-nx-cache
env:
BASE_URL: https://${{ steps.aliases.outputs.website }}
RUNTIME_BYPASS_ORIGIN: https://${{ steps.aliases.outputs.examples }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
VERCEL_EXAMPLES_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_EXAMPLES_AUTOMATION_BYPASS_SECRET }}
required-pr-checks:
name: CI — required
needs:
- ci-scope
- library
- angular-compatibility
- website
- cockpit
- cockpit-examples-build
- cockpit-smoke
- cockpit-deploy-smoke
- examples-chat-smoke
- examples-chat-e2e
- examples-ag-ui-e2e
- cockpit-e2e-summary
- website-e2e
- website-preview-e2e
- cockpit-preview-smoke
- posthog-sync-plan
- scripts-tests
- growth-lifecycle
- lifecycle
# `CI — required` is the only required status context. A merge queue
# waits on it for each candidate, so it must report on merge_group too —
# otherwise every queued merge blocks forever on a check that never runs.
if: ${{ always() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}
runs-on: ubuntu-latest
steps:
- name: Verify scoped CI jobs
env:
RESULT_CI_SCOPE: ${{ needs.ci-scope.result }}
RESULT_LIBRARY: ${{ needs.library.result }}
RESULT_ANGULAR_COMPATIBILITY: ${{ needs.angular-compatibility.result }}
RESULT_WEBSITE: ${{ needs.website.result }}
RESULT_COCKPIT: ${{ needs.cockpit.result }}
RESULT_COCKPIT_EXAMPLES: ${{ needs.cockpit-examples-build.result }}
RESULT_COCKPIT_SMOKE: ${{ needs.cockpit-smoke.result }}
RESULT_COCKPIT_DEPLOY_SMOKE: ${{ needs.cockpit-deploy-smoke.result }}
RESULT_EXAMPLES_CHAT_SMOKE: ${{ needs.examples-chat-smoke.result }}
RESULT_EXAMPLES_CHAT_E2E: ${{ needs.examples-chat-e2e.result }}
RESULT_EXAMPLES_AG_UI_E2E: ${{ needs.examples-ag-ui-e2e.result }}
RESULT_COCKPIT_E2E: ${{ needs.cockpit-e2e-summary.result }}
RESULT_WEBSITE_E2E: ${{ needs.website-e2e.result }}
RESULT_WEBSITE_PREVIEW_E2E: ${{ needs.website-preview-e2e.result }}
RESULT_COCKPIT_PREVIEW_SMOKE: ${{ needs.cockpit-preview-smoke.result }}
RESULT_POSTHOG: ${{ needs.posthog-sync-plan.result }}
RESULT_SCRIPTS_TESTS: ${{ needs.scripts-tests.result }}
RESULT_GROWTH_LIFECYCLE: ${{ needs.growth-lifecycle.result }}
RESULT_LIFECYCLE: ${{ needs.lifecycle.result }}
SCOPE_LIBRARY: ${{ needs.ci-scope.outputs.library }}
SCOPE_ANGULAR_COMPATIBILITY: ${{ needs.ci-scope.outputs.angular_compatibility }}
SCOPE_WEBSITE: ${{ needs.ci-scope.outputs.website }}
SCOPE_COCKPIT: ${{ needs.ci-scope.outputs.cockpit }}
SCOPE_COCKPIT_EXAMPLES: ${{ needs.ci-scope.outputs.cockpit_examples }}
SCOPE_COCKPIT_SMOKE: ${{ needs.ci-scope.outputs.cockpit_smoke }}
SCOPE_COCKPIT_DEPLOY_SMOKE: ${{ needs.ci-scope.outputs.cockpit_deploy_smoke }}
SCOPE_EXAMPLES_CHAT: ${{ needs.ci-scope.outputs.examples_chat }}
SCOPE_EXAMPLES_AG_UI: ${{ needs.ci-scope.outputs.examples_ag_ui }}
SCOPE_COCKPIT_E2E: ${{ needs.ci-scope.outputs.cockpit_e2e }}
SCOPE_WEBSITE_E2E: ${{ needs.ci-scope.outputs.website_e2e }}
SCOPE_POSTHOG: ${{ needs.ci-scope.outputs.posthog }}
SCOPE_SCRIPTS_TESTS: ${{ needs.ci-scope.outputs.scripts_tests }}
SCOPE_GROWTH_LIFECYCLE: ${{ needs.ci-scope.outputs.growth_lifecycle }}
# The preview lanes need repository secrets, so they skip on fork
# PRs. Their scope keys are computed from changed files alone, so on
# a fork they can be in scope yet legitimately skipped. This mirrors
# the lanes' own `if` so the gate only demands them when they could run.
PREVIEW_LANES_ELIGIBLE: ${{ github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository }}
run: |
set -euo pipefail
failed=0
require_always() {
local label="$1"
local result="$2"
if [[ "$result" != "success" ]]; then
echo "::error::${label} finished with ${result}; refusing to report CI green."
failed=1
fi
}
require_scoped() {
local scope_key="$1"
local label="$2"
local result="$3"
local scoped="$4"
if [[ "$scoped" == "true" ]]; then
if [[ "$result" != "success" ]]; then
echo "::error::${label} is required by scope ${scope_key} but finished with ${result}."
failed=1
fi
return
fi
if [[ "$result" == "failure" || "$result" == "cancelled" ]]; then
echo "::error::${label} was not selected by scope ${scope_key} but finished with ${result}."
failed=1
fi
}
# Scoped check for the PR-side preview lanes: enforced only when the
# lanes were eligible to run; otherwise treated as unselected, where
# only a real failure or cancellation is an error.
require_preview() {
local scope_key="$1"
local label="$2"
local result="$3"
local scoped="$4"
if [[ "$PREVIEW_LANES_ELIGIBLE" == "true" && "$scoped" == "true" && "$result" == "skipped" ]]; then
echo "::error::${label} was eligible and in scope ${scope_key} but was skipped — its job-level if no longer matches the gate's PREVIEW_LANES_ELIGIBLE expression."
failed=1
return
fi
if [[ "$PREVIEW_LANES_ELIGIBLE" == "true" ]]; then
require_scoped "$scope_key" "$label" "$result" "$scoped"
else
require_scoped "$scope_key" "$label" "$result" "false"
fi
}
require_always "CI scope" "$RESULT_CI_SCOPE"
require_scoped "library" "Library — lint / test / build" "$RESULT_LIBRARY" "$SCOPE_LIBRARY"
require_scoped \
"angular_compatibility" \
"Angular compatibility matrix" \
"$RESULT_ANGULAR_COMPATIBILITY" \
"$SCOPE_ANGULAR_COMPATIBILITY"
require_scoped "website" "Website — lint / test / build" "$RESULT_WEBSITE" "$SCOPE_WEBSITE"
require_scoped "cockpit" "Cockpit — build / test" "$RESULT_COCKPIT" "$SCOPE_COCKPIT"
require_scoped "cockpit_examples" "Cockpit — build all examples" "$RESULT_COCKPIT_EXAMPLES" "$SCOPE_COCKPIT_EXAMPLES"
require_scoped "cockpit_smoke" "Cockpit — representative capability smoke" "$RESULT_COCKPIT_SMOKE" "$SCOPE_COCKPIT_SMOKE"
require_scoped "cockpit_deploy_smoke" "Cockpit — deploy smoke dry-run" "$RESULT_COCKPIT_DEPLOY_SMOKE" "$SCOPE_COCKPIT_DEPLOY_SMOKE"
require_scoped "examples_chat" "examples/chat — python smoke" "$RESULT_EXAMPLES_CHAT_SMOKE" "$SCOPE_EXAMPLES_CHAT"
require_scoped "examples_chat" "examples/chat — e2e" "$RESULT_EXAMPLES_CHAT_E2E" "$SCOPE_EXAMPLES_CHAT"
require_scoped \
"examples_ag_ui" \
"examples/ag-ui — e2e" \
"$RESULT_EXAMPLES_AG_UI_E2E" \
"$SCOPE_EXAMPLES_AG_UI"
require_scoped "cockpit_e2e" "Cockpit — e2e" "$RESULT_COCKPIT_E2E" "$SCOPE_COCKPIT_E2E"
require_scoped "website_e2e" "Website — e2e" "$RESULT_WEBSITE_E2E" "$SCOPE_WEBSITE_E2E"
require_preview "website_e2e" "Website — e2e (deployed preview)" "$RESULT_WEBSITE_PREVIEW_E2E" "$SCOPE_WEBSITE_E2E"
require_preview "cockpit_deploy_smoke" "Cockpit — immutable preview smoke" "$RESULT_COCKPIT_PREVIEW_SMOKE" "$SCOPE_COCKPIT_DEPLOY_SMOKE"
require_scoped "posthog" "PostHog — dashboards-as-code drift check" "$RESULT_POSTHOG" "$SCOPE_POSTHOG"
require_scoped "scripts_tests" "Scripts — generator / proxy vitest suites" "$RESULT_SCRIPTS_TESTS" "$SCOPE_SCRIPTS_TESTS"
require_scoped "growth_lifecycle" "Growth lifecycle — Node 22" "$RESULT_GROWTH_LIFECYCLE" "$SCOPE_GROWTH_LIFECYCLE"
require_scoped "growth_lifecycle" "Lifecycle — Node 24" "$RESULT_LIFECYCLE" "$SCOPE_GROWTH_LIFECYCLE"
if [[ "$failed" -ne 0 ]]; then
exit 1
fi
echo "All scoped PR checks passed."
deploy:
name: Deploy → Vercel
timeout-minutes: 30 # fail fast instead of blocking the main concurrency group on a hang
needs:
- library
- website
- cockpit
- cockpit-examples-build
- cockpit-smoke
- cockpit-deploy-smoke
- examples-chat-smoke
- examples-chat-e2e
- cockpit-e2e-summary
- website-e2e
- growth-lifecycle
- lifecycle
runs-on: ubuntu-latest
outputs:
runtime_parent_preview_origin: ${{ steps.deploy_website.outputs.preview_origin }}
# Only deploy on pushes to main, not on pull requests
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
# Needed to advance refs/deploy/last-promoted (see "Resolve deploy
# baseline"). Nothing else in this job writes to the repo.
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
# Pushes to main do not cancel in-progress runs, so a slower older run can
# reach this job after a newer commit has already been promoted. Building
# --prod from this run's (older) checkout would then overwrite production
# with stale content. Seen 2026-08-14: the run for #813 promoted at 04:37
# and silently un-published two blog posts that #814 had shipped at 04:13.
- name: Check this commit is still the tip of main
id: freshness
run: |
# ls-remote, not fetch: a --depth fetch would mark this clone shallow
# and break the full-history --affected checks later in the job.
tip="$(git ls-remote origin refs/heads/main | cut -f1)"
if [ -z "$tip" ]; then
echo "::error::Could not resolve the tip of main; refusing to guess whether this deploy is current."
exit 1
fi
if [ "$tip" != "${{ github.sha }}" ]; then
echo "stale=true" >> "$GITHUB_OUTPUT"
echo "::warning::main has advanced to ${tip} since this run started (${{ github.sha }}); skipping production promotion so the newer commit's deploy stands."
else
echo "stale=false" >> "$GITHUB_OUTPUT"
fi
- name: Resolve deploy baseline
id: deploy_baseline
run: |
# Gating on `github.event.before..sha` asks "what changed in THIS
# push", which silently drops work whenever a run does not reach the
# deploy steps. Pushes to main do not cancel in-progress runs, but
# GitHub keeps only the newest QUEUED run per concurrency group and
# cancels the rest, and a run can also fail on an unrelated job — in
# both cases that push's diff range is never re-examined. The next
# push only diffs its own range, so the skipped change stays
# undeployed until some later commit happens to touch a gated path.
#
# Seen live 2026-08-31: #902 (cockpit/ag-ui/subagents/angular) failed
# on an unrelated website e2e regression, and the two green runs after
# it touched no gated path — so the fix sat on main, undeployed,
# alongside the cockpit/runtimes examples from two culled runs. All
# three shipped only when #907 incidentally touched a gated file.
#
# refs/deploy/last-promoted advances only after a fully successful
# deploy job, so this range covers everything not yet promoted.
# Falls back to the old behaviour when the marker is absent
# (first run after this lands) or no longer resolves (history rewrite).
base_sha=""
marker="$(git ls-remote origin refs/deploy/last-promoted 2>/dev/null | cut -f1)"
if [ -n "$marker" ] && git cat-file -e "${marker}^{commit}" 2>/dev/null; then
base_sha="$marker"
echo "::notice::Deploy baseline: last promoted commit ${marker}."
else
base_sha="${{ github.event.before }}"
echo "::notice::No usable refs/deploy/last-promoted marker; falling back to github.event.before (${base_sha})."
fi
head_sha="${{ github.sha }}"
if [ -z "$base_sha" ] || [ "$base_sha" = "0000000000000000000000000000000000000000" ]; then
base_sha="$(git rev-parse "$head_sha^")"
fi
# Guarantee every consumer gets a resolvable commit: the fallback can
# name a commit this clone does not have (force-push, deleted branch).
if ! git cat-file -e "${base_sha}^{commit}" 2>/dev/null; then
echo "::warning::Deploy baseline ${base_sha} does not resolve; using ${head_sha}^ instead."
base_sha="$(git rev-parse "$head_sha^")"
fi
echo "base=$base_sha" >> "$GITHUB_OUTPUT"
- name: Detect deploy-relevant changes
id: deploy_preflight
run: |
base_sha="${{ steps.deploy_baseline.outputs.base }}"
head_sha="${{ github.sha }}"
if ! git cat-file -e "$base_sha^{commit}" 2>/dev/null; then
git fetch --no-tags origin "$base_sha"
fi
changed_files="$(git diff --name-only "$base_sha" "$head_sha")"
deploy_relevant=false
if printf '%s\n' "$changed_files" | grep -E '^(\.github/workflows/ci\.yml|runtime-parent-origins\.json|vercel\.(json|cockpit\.json|examples\.json)|apps/(website|cockpit)/.*|cockpit/.*|examples/chat/.*|libs/.*|scripts/(assemble-examples|deploy-smoke|demo-middleware|generate-runtime-parent-origins|langgraph-proxy|rate-limit)\.ts|scripts/assemble-demo\.ts)$' >/dev/null; then
deploy_relevant=true
fi
echo "relevant=$deploy_relevant" >> "$GITHUB_OUTPUT"
if [ "$deploy_relevant" != "true" ]; then
echo "::notice::No deploy-relevant files changed; skipping Vercel dependency setup."
fi
# ── Angular examples deploy ──────────────────────────────────────────
- name: Check if examples changed
id: examples_changed
run: |
base_sha="${{ steps.deploy_baseline.outputs.base }}"
head_sha="${{ github.sha }}"
changed_files="$(git diff --name-only "$base_sha" "$head_sha")"
examples_changed=false
if printf '%s\n' "$changed_files" | grep -E '^cockpit/.*/angular/' >/dev/null; then
examples_changed=true
fi
if printf '%s\n' "$changed_files" | grep -E '^(runtime-parent-origins\.json|vercel\.examples\.json|scripts/(assemble-examples|examples-middleware|generate-runtime-parent-origins|langgraph-proxy|upstash-rate-limit)\.ts)$' >/dev/null; then
examples_changed=true
fi
if printf '%s\n' "$changed_files" | grep -E '^(\.github/workflows/ci\.yml|vercel\.json|apps/website/.*)$' >/dev/null; then
examples_changed=true
fi
# Any libs/ change retriggers examples deploy. Previous hand-maintained
# allow-list silently broke whenever a new lib was added; cost of a
# spurious example rebuild is far cheaper than a missed deploy.
if printf '%s\n' "$changed_files" | grep -E '^libs/' >/dev/null; then
examples_changed=true
fi
echo "changed=$examples_changed" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
if: steps.deploy_preflight.outputs.relevant == 'true' || steps.examples_changed.outputs.changed == 'true'
with:
node-version: 22
cache: npm
# Required GitHub secrets (Settings → Secrets and variables → Actions):
# VERCEL_TOKEN — vercel.com/account/tokens
# VERCEL_ORG_ID — Vercel team id
# VERCEL_WEBSITE_PROJECT_ID — website project id
# VERCEL_COCKPIT_PROJECT_ID — cockpit project id
# VERCEL_EXAMPLES_PROJECT_ID — examples project id
- if: steps.deploy_preflight.outputs.relevant == 'true' || steps.examples_changed.outputs.changed == 'true'
run: npm ci
- name: Resolve deploy targets
if: steps.deploy_preflight.outputs.relevant == 'true'
id: affected
run: |
base_sha="${{ steps.deploy_baseline.outputs.base }}"
head_sha="${{ github.sha }}"
if ! git cat-file -e "$base_sha^{commit}" 2>/dev/null; then
git fetch --no-tags origin "$base_sha"
fi
affected_projects="$(npx nx show projects --affected --base="$base_sha" --head="$head_sha")"
changed_files="$(git diff --name-only "$base_sha" "$head_sha")"
website_changed=false
cockpit_changed=false
if printf '%s\n' "$affected_projects" | grep -Fx 'website' >/dev/null; then
website_changed=true
fi
if printf '%s\n' "$affected_projects" | grep -Fx 'cockpit' >/dev/null; then
cockpit_changed=true
fi
if printf '%s\n' "$changed_files" | grep -E '^(\.github/workflows/ci\.yml|vercel\.json)$' >/dev/null; then
website_changed=true
fi
if printf '%s\n' "$changed_files" | grep -E '^(\.github/workflows/ci\.yml|vercel\.cockpit\.json)$' >/dev/null; then
cockpit_changed=true
fi
echo "website=$website_changed" >> "$GITHUB_OUTPUT"
echo "cockpit=$cockpit_changed" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
if: steps.freshness.outputs.stale != 'true' && (steps.affected.outputs.website == 'true' || steps.affected.outputs.cockpit == 'true')
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright browsers
if: steps.freshness.outputs.stale != 'true' && (steps.affected.outputs.website == 'true' || steps.affected.outputs.cockpit == 'true')
run: npx playwright install --with-deps chromium
- name: Prepare website Vercel project
if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true'
run: |
mkdir -p .vercel
cat > .vercel/project.json <<EOF
{"projectId":"${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"threadplane"}
EOF
npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
rm -rf .vercel/output
- name: Deploy immutable Website preview
if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true'
id: deploy_website
env:
# Server pages read the growth form policy while rendering, so this
# build needs it exactly as the `website` job does. It cannot come
# from `vercel pull`: the project marks GROWTH_FORM_POLICY sensitive
# for preview/production, and Vercel never returns a sensitive value,
# so the pulled .env omits it and `vercel build` throws
# "GROWTH_FORM_POLICY must be growth_v1". It is not a secret.
GROWTH_FORM_POLICY: growth_v1
run: |
npx vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
url=$(npx vercel deploy --prebuilt --archive=tgz --prod --skip-domain --yes --token=${{ secrets.VERCEL_TOKEN }} | tail -n 1)
preview_origin=$(node -e 'const parsed = new URL(process.argv[1]); if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) throw new Error("Vercel returned a non-origin Website preview URL"); process.stdout.write(parsed.origin)' "$url")
echo "deployment_url=$preview_origin" >> "$GITHUB_OUTPUT"
echo "preview_origin=$preview_origin" >> "$GITHUB_OUTPUT"
- name: Build and assemble Angular examples
if: steps.freshness.outputs.stale != 'true' && steps.examples_changed.outputs.changed == 'true'
run: npx tsx scripts/assemble-examples.ts
env:
RUNTIME_PARENT_PREVIEW_ORIGINS: ${{ steps.deploy_website.outputs.preview_origin }}
- name: Deploy Angular examples to Vercel (production)
if: steps.freshness.outputs.stale != 'true' && steps.examples_changed.outputs.changed == 'true'
working-directory: deploy/examples
run: |
mkdir -p .vercel
cat > .vercel/project.json <<EOF
{"projectId":"${{ secrets.VERCEL_EXAMPLES_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"threadplane-examples"}
EOF
npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
npx vercel deploy --prebuilt --prod --yes --token=${{ secrets.VERCEL_TOKEN }}
- name: Verify Website preview runtime embedding policy
if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true'
run: |
# Without the bypass the preview answers 302 -> vercel.com/sso-api and
# this check times out on an SSO page instead of the app. Say so.
if [ -z "${VERCEL_AUTOMATION_BYPASS_SECRET}" ]; then
echo "::error::VERCEL_AUTOMATION_BYPASS_SECRET is unset — the protected Website preview cannot be verified. Enable 'Protection Bypass for Automation' on the Vercel website project and store the value as this repository secret."
exit 1
fi
npx playwright test apps/website/e2e/platform-production-smoke.spec.ts --config apps/website/playwright.config.ts --grep "unified runtime embedding policy" --reporter=list
env:
PRODUCTION_SMOKE: 'true'
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
BASE_URL: ${{ steps.deploy_website.outputs.preview_origin }}
WEBSITE_URL: ${{ steps.deploy_website.outputs.preview_origin }}
EXAMPLES_URL: https://examples.threadplane.ai
RUNTIME_PARENT_PREVIEW_ORIGINS: ${{ steps.deploy_website.outputs.preview_origin }}
- name: Check this commit is still the tip before Website promotion
if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true'
id: website_promotion_freshness
run: |
tip="$(git ls-remote origin refs/heads/main | cut -f1)"
if [ -z "$tip" ]; then
echo "::error::Could not resolve the tip of main after Website preview verification; refusing promotion."
exit 1
fi
if [ "$tip" != "${{ github.sha }}" ]; then
echo "fresh=false" >> "$GITHUB_OUTPUT"
echo "::warning::main advanced to ${tip} during Website preview verification; leaving the production alias untouched for the newer run."
else
echo "fresh=true" >> "$GITHUB_OUTPUT"
fi
- name: Promote verified Website artifact unchanged
if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.website == 'true' && steps.website_promotion_freshness.outputs.fresh == 'true'
# `promote` takes a bare deployment URL, so unlike build/deploy/pull it
# cannot read the team from .vercel/project.json and falls back to the
# token's default team — a personal one here, which fails with
# "Deployment doesn't belong to current team". Scope it explicitly.
run: npx vercel promote "${{ steps.deploy_website.outputs.deployment_url }}" --scope=${{ secrets.VERCEL_ORG_ID }} --yes --token=${{ secrets.VERCEL_TOKEN }}
- name: Verify deployed website
if: steps.freshness.outputs.stale != 'true' && ((steps.affected.outputs.website == 'true' && steps.website_promotion_freshness.outputs.fresh == 'true') || steps.affected.outputs.cockpit == 'true')
run: npx nx e2e website --skip-nx-cache
env:
BASE_URL: https://threadplane.ai
- name: Prepare cockpit Vercel project
if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true'
run: |
mkdir -p .vercel
cat > .vercel/project.json <<EOF
{"projectId":"${{ secrets.VERCEL_COCKPIT_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"threadplane-cockpit"}
EOF
npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
rm -rf .vercel/output
- name: Build cockpit redirect service
if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true'
env:
COCKPIT_WEBSITE_ORIGIN: https://threadplane.ai
run: |
npx vercel build --prod --local-config vercel.cockpit.json --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy immutable cockpit artifact
if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true'
id: deploy_cockpit
run: |
url=$(npx vercel deploy --prebuilt --archive=tgz --prod --skip-domain --yes --env COCKPIT_WEBSITE_ORIGIN=https://threadplane.ai --token=${{ secrets.VERCEL_TOKEN }} | tail -n 1)
echo "deployment_url=$url" >> "$GITHUB_OUTPUT"
- name: Exhaustively verify immutable cockpit preview
if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true'
run: |
# Deployment protection answers every path on the unaliased artifact
# with 302 -> vercel.com/sso-api, which the smoke reports as
# "expected 308, received 302". Bypass secrets are issued per Vercel
# project, so the Website secret cannot open this one. Say so.
if [ -z "${VERCEL_AUTOMATION_BYPASS_SECRET}" ]; then
echo "::error::VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET is unset — the protected immutable cockpit preview cannot be verified. Enable 'Protection Bypass for Automation' on the Vercel threadplane-cockpit project and store the value as this repository secret."
exit 1
fi
npx tsx apps/cockpit/scripts/deploy-smoke.ts --url "${{ steps.deploy_cockpit.outputs.deployment_url }}" --mode preview --retries 20 --retry-delay-ms 5000
env:
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_COCKPIT_AUTOMATION_BYPASS_SECRET }}
- name: Check this commit is still the tip before cockpit promotion
if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true'
id: cockpit_promotion_freshness
run: |
tip="$(git ls-remote origin refs/heads/main | cut -f1)"
if [ -z "$tip" ]; then
echo "::error::Could not resolve the tip of main after preview verification; refusing the irreversible Cockpit redirect promotion."
exit 1
fi
if [ "$tip" != "${{ github.sha }}" ]; then
echo "fresh=false" >> "$GITHUB_OUTPUT"
echo "::warning::main advanced to ${tip} during Cockpit preview verification; leaving the production alias untouched for the newer run."
else
echo "fresh=true" >> "$GITHUB_OUTPUT"
fi
- name: Promote verified cockpit artifact unchanged
if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' && steps.cockpit_promotion_freshness.outputs.fresh == 'true'
# `promote` takes a bare deployment URL, so unlike build/deploy/pull it
# cannot read the team from .vercel/project.json and falls back to the
# token's default team — a personal one here, which fails with
# "Deployment doesn't belong to current team". Scope it explicitly.
run: npx vercel promote "${{ steps.deploy_cockpit.outputs.deployment_url }}" --scope=${{ secrets.VERCEL_ORG_ID }} --yes --token=${{ secrets.VERCEL_TOKEN }}
- name: Verify production cockpit redirects
if: steps.freshness.outputs.stale != 'true' && steps.affected.outputs.cockpit == 'true' && steps.cockpit_promotion_freshness.outputs.fresh == 'true'
run: npx tsx apps/cockpit/scripts/deploy-smoke.ts --url https://cockpit.threadplane.ai --mode production --retries 20 --retry-delay-ms 5000
demo-deploy:
name: Canonical demo → Vercel
timeout-minutes: 30 # fail fast instead of blocking the main concurrency group on a hang
needs: [examples-chat-smoke, examples-chat-e2e]
runs-on: ubuntu-latest
if: ${{ always() && !cancelled() && github.ref == 'refs/heads/main' && github.event_name == 'push' }}
steps:
- name: Require demo prerequisite jobs
run: |
if [ "${{ needs.examples-chat-smoke.result }}" != "success" ]; then
echo "::error::examples/chat — python smoke finished with ${{ needs.examples-chat-smoke.result }}; refusing to deploy the canonical demo."
exit 1
fi
if [ "${{ needs.examples-chat-e2e.result }}" != "success" ]; then
echo "::error::examples/chat — e2e finished with ${{ needs.examples-chat-e2e.result }}; refusing to deploy the canonical demo."
exit 1
fi
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
# Same staleness guard as the deploy job: a slower older run must not
# promote its (older) build over a newer commit's demo deployment.
- name: Check this commit is still the tip of main
id: freshness
run: |
# ls-remote, not fetch: a --depth fetch would mark this clone shallow
# and break the full-history --affected checks later in the job.
tip="$(git ls-remote origin refs/heads/main | cut -f1)"
if [ -z "$tip" ]; then
echo "::error::Could not resolve the tip of main; refusing to guess whether this deploy is current."
exit 1
fi
if [ "$tip" != "${{ github.sha }}" ]; then
echo "stale=true" >> "$GITHUB_OUTPUT"
echo "::warning::main has advanced to ${tip} since this run started (${{ github.sha }}); skipping the canonical demo promotion."
else
echo "stale=false" >> "$GITHUB_OUTPUT"
fi
- run: npm ci
if: steps.freshness.outputs.stale != 'true'
- name: Build and assemble canonical demo
if: steps.freshness.outputs.stale != 'true'
env:
# Baked into the SPA bundle by inject-env at build time (assemble-demo.ts
# runs `nx build examples-chat-angular`, whose build target dependsOn
# inject-env). Without the key the App-mode map toggle is disabled in
# prod. The key ships in the public bundle (normal for Maps JS) — restrict
# it by HTTP referrer in the Google Cloud console. The Map ID is not a secret.
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
GOOGLE_MAPS_MAP_ID: 86d464ea7d5306034fe2a254
run: npx tsx scripts/assemble-demo.ts
- name: Deploy canonical demo to Vercel (production)
if: steps.freshness.outputs.stale != 'true'
working-directory: deploy/demo
run: |
mkdir -p .vercel
cat > .vercel/project.json <<EOF
{"projectId":"${{ secrets.VERCEL_DEMO_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"threadplane-demo"}
EOF
npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
npx vercel deploy --prebuilt --prod --yes --token=${{ secrets.VERCEL_TOKEN }}
- name: Verify canonical demo build stamp
if: steps.freshness.outputs.stale != 'true'
env:
DEMO_URL: https://demo.threadplane.ai
EXPECTED_SHA: ${{ github.sha }}
run: |
node <<'NODE'
const { setTimeout: sleep } = require('node:timers/promises');
async function main() {
const demoUrl = process.env.DEMO_URL;
const expectedSha = process.env.EXPECTED_SHA;
let last = 'no response yet';
for (let attempt = 1; attempt <= 20; attempt += 1) {
try {
const response = await fetch(`${demoUrl}/__build.json?t=${Date.now()}`);
last = `HTTP ${response.status}`;
if (response.ok) {
const metadata = await response.json();
last = JSON.stringify(metadata);
if (metadata.sha === expectedSha) {
console.log(`Canonical demo is serving ${expectedSha}.`);
return;
}
}
} catch (error) {
last = error instanceof Error ? error.message : String(error);
}
console.log(`Waiting for canonical demo stamp ${expectedSha}; attempt ${attempt}/20. Last: ${last}`);
await sleep(5000);
}
throw new Error(`Canonical demo did not serve build stamp ${expectedSha}. Last: ${last}`);
}
main().catch((error) => {
console.error(`::error::${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
NODE
ag-ui-demo-deploy:
name: AG-UI demo → Vercel
timeout-minutes: 30 # fail fast instead of blocking the main concurrency group on a hang
needs: [examples-ag-ui-e2e]
runs-on: ubuntu-latest
if: ${{ always() && !cancelled() && github.ref == 'refs/heads/main' && github.event_name == 'push' }}
permissions:
# Needed to advance refs/deploy/ag-ui-demo-last-promoted. Nothing else in
# this job writes to the repo.
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Require AG-UI demo prerequisite jobs
run: |
if [ "${{ needs.examples-ag-ui-e2e.result }}" != "success" ]; then
echo "::error::examples/ag-ui — e2e finished with ${{ needs.examples-ag-ui-e2e.result }}; refusing to deploy the AG-UI demo."
exit 1
fi
# Same staleness guard as the other production promotions.
- name: Check this commit is still the tip of main
id: freshness
run: |
# ls-remote, not fetch: a --depth fetch would mark this clone shallow
# and break the full-history --affected checks later in the job.
tip="$(git ls-remote origin refs/heads/main | cut -f1)"
if [ -z "$tip" ]; then
echo "::error::Could not resolve the tip of main; refusing to guess whether this deploy is current."
exit 1
fi
if [ "$tip" != "${{ github.sha }}" ]; then
echo "stale=true" >> "$GITHUB_OUTPUT"
echo "::warning::main has advanced to ${tip} since this run started (${{ github.sha }}); skipping the AG-UI demo promotion."
else
echo "stale=false" >> "$GITHUB_OUTPUT"
fi
- name: Resolve AG-UI demo deploy baseline
id: demo_baseline
run: |
# Same hazard the deploy job fixed in #909, on a different target:
# diffing `github.event.before..sha` asks "what changed in THIS
# push", so any run that does not reach the deploy steps — cancelled
# in the concurrency queue, or failed on an unrelated job — has its
# range dropped for good. The next push diffs only its own range, so
# the skipped change stays undeployed until a later commit happens to
# touch a gated path.
#
# refs/deploy/ag-ui-demo-last-promoted advances only after a fully
# successful, non-stale run of this job, so the range covers
# everything not yet promoted. A separate ref from the deploy job's:
# the two promote different targets and go stale independently.
base_sha=""
marker="$(git ls-remote origin refs/deploy/ag-ui-demo-last-promoted 2>/dev/null | cut -f1)"
if [ -n "$marker" ] && git cat-file -e "${marker}^{commit}" 2>/dev/null; then
base_sha="$marker"
echo "::notice::AG-UI demo baseline: last promoted commit ${marker}."
else
base_sha="${{ github.event.before }}"
echo "::notice::No usable refs/deploy/ag-ui-demo-last-promoted marker; falling back to github.event.before (${base_sha})."
fi
head_sha="${{ github.sha }}"
if [ -z "$base_sha" ] || [ "$base_sha" = "0000000000000000000000000000000000000000" ]; then
base_sha="$(git rev-parse "$head_sha^")"
fi
if ! git cat-file -e "${base_sha}^{commit}" 2>/dev/null; then
git fetch --no-tags origin "$base_sha" 2>/dev/null || true
fi
if ! git cat-file -e "${base_sha}^{commit}" 2>/dev/null; then
echo "::warning::AG-UI demo baseline ${base_sha} does not resolve; using ${head_sha}^ instead."
base_sha="$(git rev-parse "$head_sha^")"
fi
echo "base=$base_sha" >> "$GITHUB_OUTPUT"
- name: Check if AG-UI demo changed
id: ag_ui_changed
run: |
base_sha="${{ steps.demo_baseline.outputs.base }}"
head_sha="${{ github.sha }}"
changed_files="$(git diff --name-only "$base_sha" "$head_sha")"
ag_ui_changed=false
if printf '%s\n' "$changed_files" | grep -E '^(examples/ag-ui/.*|scripts/(ag-ui-demo-middleware|assemble-ag-ui-demo)\.ts)$' >/dev/null; then
ag_ui_changed=true
fi
echo "changed=$ag_ui_changed" >> "$GITHUB_OUTPUT"
if [ "$ag_ui_changed" != "true" ]; then
echo "::notice::No AG-UI demo files changed; skipping AG-UI demo deploy."
fi
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
if: steps.freshness.outputs.stale != 'true' && steps.ag_ui_changed.outputs.changed == 'true'
with:
node-version: 22
cache: npm
- name: Install uv
if: steps.freshness.outputs.stale != 'true' && steps.ag_ui_changed.outputs.changed == 'true'
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with:
python-version: '3.12'
- if: steps.freshness.outputs.stale != 'true' && steps.ag_ui_changed.outputs.changed == 'true'
run: npm ci
- name: Build and assemble AG-UI demo
if: steps.freshness.outputs.stale != 'true' && steps.ag_ui_changed.outputs.changed == 'true'
env:
# Baked into the SPA bundle by inject-env at build time. Without the
# key the App-mode map toggle is disabled in prod. The key ships in
# the public bundle (normal for Maps JS) — restrict it by HTTP
# referrer in the Google Cloud console. The Map ID is not a secret.
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
GOOGLE_MAPS_MAP_ID: 86d464ea7d5306034fe2a254
run: npx tsx scripts/assemble-ag-ui-demo.ts
- name: Deploy AG-UI demo SPA to Vercel (production)
if: steps.freshness.outputs.stale != 'true' && steps.ag_ui_changed.outputs.changed == 'true'
run: |
mkdir -p deploy/ag-ui-demo/.vercel
cat > deploy/ag-ui-demo/.vercel/project.json <<EOF
{"projectId":"${{ secrets.VERCEL_AG_UI_DEMO_PROJECT_ID }}","orgId":"${{ secrets.VERCEL_ORG_ID }}","projectName":"threadplane-ag-ui-demo"}
EOF
cd deploy/ag-ui-demo
npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
npx vercel deploy --prebuilt --prod --yes --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy AG-UI backend to Railway
if: steps.freshness.outputs.stale != 'true' && steps.ag_ui_changed.outputs.changed == 'true'
working-directory: examples/ag-ui/python
env:
RAILWAY_TOKEN: ${{ secrets.AG_UI_DEMO_RAILWAY_TOKEN }}
run: |
npx --yes @railway/cli@4 up --service ag-ui-demo --detach
# Advance only when the whole job succeeded and actually promoted. A
# stale run, a failure, or a partial deploy leaves the marker put, so the
# next run's range still covers whatever did not ship. Never `always()`:
# advancing past unshipped work is the bug this exists to prevent.
- name: Record this commit as AG-UI demo promoted
if: success() && steps.freshness.outputs.stale != 'true'
run: git push origin --force "${{ github.sha }}:refs/deploy/ag-ui-demo-last-promoted"
production-smoke:
name: Production smoke
needs: [deploy, demo-deploy, ag-ui-demo-deploy]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- run: npm ci
- name: Verify shared LangGraph backend
run: npx tsx scripts/verify-shared-deployment.ts
env:
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
- name: Cache Playwright browsers
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
playwright-${{ runner.os }}-
- run: npx playwright install --with-deps chromium
- name: Run production smoke tests
run: npx playwright test apps/website/e2e/platform-production-smoke.spec.ts --config apps/website/playwright.config.ts --reporter=list
env:
PRODUCTION_SMOKE: 'true'
BASE_URL: https://threadplane.ai
COCKPIT_URL: https://cockpit.threadplane.ai
WEBSITE_URL: https://threadplane.ai
EXAMPLES_URL: https://examples.threadplane.ai
RUNTIME_PARENT_PREVIEW_ORIGINS: ${{ needs.deploy.outputs.runtime_parent_preview_origin }}
DEMO_URL: https://demo.threadplane.ai
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Check this commit is still the tip before recording promotion
id: freshness
run: |
tip="$(git ls-remote origin refs/heads/main | cut -f1)"
if [ -z "$tip" ]; then
echo "::error::Could not resolve the tip of main; refusing to advance the deploy marker."
exit 1
fi
if [ "$tip" != "${{ github.sha }}" ]; then
echo "stale=true" >> "$GITHUB_OUTPUT"
echo "::warning::main advanced to ${tip}; leaving refs/deploy/last-promoted untouched for the newer run."
else
echo "stale=false" >> "$GITHUB_OUTPUT"
fi
# Advance only after immutable preview verification, promotion,
# representative redirects, and the full cross-host platform smoke pass.
- name: Record this commit as promoted
if: success() && steps.freshness.outputs.stale != 'true'
run: git push origin --force "${{ github.sha }}:refs/deploy/last-promoted"
posthog-sync-plan:
name: PostHog — dashboards-as-code drift check
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.posthog == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Detect PostHog-relevant changes
id: posthog_preflight
run: |
if [ "${{ github.event_name }}" = "push" ]; then
base_sha="${{ github.event.before }}"
head_sha="${{ github.sha }}"
if [ -z "$base_sha" ] || [ "$base_sha" = "0000000000000000000000000000000000000000" ]; then
base_sha="$(git rev-parse "$head_sha^")"
fi
else
base_sha=$(git merge-base origin/main HEAD)
head_sha=$(git rev-parse HEAD)
fi
changed_files="$(git diff --name-only "$base_sha" "$head_sha")"
posthog_relevant=false
if printf '%s\n' "$changed_files" | grep -E '^(tools/posthog/|package(-lock)?\.json|nx\.json|tsconfig\.base\.json|\.github/workflows/ci\.yml)$' >/dev/null; then
posthog_relevant=true
fi
echo "relevant=$posthog_relevant" >> "$GITHUB_OUTPUT"
if [ "$posthog_relevant" != "true" ]; then
echo "::notice::No PostHog tooling files changed — skipping dependency setup and drift check."
fi
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
if: steps.posthog_preflight.outputs.relevant == 'true'
with:
node-version: '20'
cache: 'npm'
- if: steps.posthog_preflight.outputs.relevant == 'true'
run: npm ci
- name: Detect affected
if: steps.posthog_preflight.outputs.relevant == 'true'
id: affected
run: |
if [ "${{ github.event_name }}" = "push" ]; then
base_sha="${{ github.event.before }}"
head_sha="${{ github.sha }}"
if [ -z "$base_sha" ] || [ "$base_sha" = "0000000000000000000000000000000000000000" ]; then
base_sha="$(git rev-parse "$head_sha^")"
fi
else
base_sha=$(git merge-base origin/main HEAD)
head_sha=$(git rev-parse HEAD)
fi
affected="$(npx nx show projects --affected --base=$base_sha --head=$head_sha)"
if printf '%s\n' "$affected" | grep -Fx 'posthog-tools' >/dev/null; then
echo "is_affected=yes" >> "$GITHUB_OUTPUT"
else
echo "is_affected=no" >> "$GITHUB_OUTPUT"
echo "::notice::posthog-tools not in affected projects — skipping drift check."
fi
- name: posthog:sync --plan
if: steps.affected.outputs.is_affected == 'yes'
env:
POSTHOG_PERSONAL_API_KEY: ${{ secrets.POSTHOG_PERSONAL_API_KEY_READONLY }}
POSTHOG_HOST: https://us.i.posthog.com
POSTHOG_PROJECT_ID: ${{ secrets.POSTHOG_PROJECT_ID }}
run: |
if [ -z "$POSTHOG_PERSONAL_API_KEY" ]; then
echo "::notice::POSTHOG_PERSONAL_API_KEY_READONLY not set — soft skip for contributor PRs."
exit 0
fi
npx nx run posthog-tools:sync:plan