feat(growth): link install and development runtime to founder outreac… #2714
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |