From c1e7742f72458b9f9ad4f671e9e51d48f5882e6b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 16 May 2026 21:01:19 -0700 Subject: [PATCH] ci: tighten workflow foundation --- .github/workflows/aimock-drift.yml | 8 ++ .github/workflows/ci.yml | 105 ++++++++++++++++++++----- .github/workflows/deploy-langgraph.yml | 7 ++ .github/workflows/e2e.yml | 7 ++ .github/workflows/publish.yml | 4 + scripts/assemble-demo.ts | 20 +++++ 6 files changed, 131 insertions(+), 20 deletions(-) diff --git a/.github/workflows/aimock-drift.yml b/.github/workflows/aimock-drift.yml index c0f8e1791..55b25fc05 100644 --- a/.github/workflows/aimock-drift.yml +++ b/.github/workflows/aimock-drift.yml @@ -7,6 +7,14 @@ on: # lands recorded fixtures. Re-enable a `schedule:` trigger here at that # point (weekly cron is a reasonable starting cadence). +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + issues: write + jobs: drift: runs-on: ubuntu-latest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adad65d5f..a9534f0ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,13 @@ on: pull_request: branches: [main] +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 + jobs: library: name: Library — lint / test / build @@ -27,8 +34,12 @@ jobs: website: name: Website — lint / build runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@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@v6.3.0 with: node-version: 22 @@ -36,6 +47,19 @@ jobs: - run: npm ci - run: npx nx lint 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 + 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:${{ github.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 @@ -362,32 +386,31 @@ jobs: npx vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} npx vercel deploy --prebuilt --prod --yes --token=${{ secrets.VERCEL_TOKEN }} - # ── Canonical demo deploy ──────────────────────────────────────────── - - name: Check if demo changed - id: demo_changed + demo-deploy: + name: Canonical demo → Vercel + 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: | - 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 - changed_files="$(git diff --name-only "$base_sha" "$head_sha")" - demo_changed=false - if printf '%s\n' "$changed_files" | grep -E '^examples/chat/(angular|python)/' >/dev/null; then - demo_changed=true - fi - if printf '%s\n' "$changed_files" | grep -E '^(vercel\.demo\.json|scripts/(assemble-demo|demo-middleware|langgraph-proxy|rate-limit)\.ts)$' >/dev/null; then - demo_changed=true + 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 printf '%s\n' "$changed_files" | grep -E '^libs/' >/dev/null; then - demo_changed=true + 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 - echo "changed=$demo_changed" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-node@v6.3.0 + with: + node-version: 22 + cache: npm + - run: npm ci - name: Build and assemble canonical demo - if: steps.demo_changed.outputs.changed == 'true' run: npx tsx scripts/assemble-demo.ts - name: Deploy canonical demo to Vercel (production) - if: steps.demo_changed.outputs.changed == 'true' working-directory: deploy/demo run: | mkdir -p .vercel @@ -396,6 +419,48 @@ jobs: 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 + env: + DEMO_URL: https://demo.cacheplane.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 production-smoke: name: Production smoke diff --git a/.github/workflows/deploy-langgraph.yml b/.github/workflows/deploy-langgraph.yml index 28fb032bb..8d3937bdc 100644 --- a/.github/workflows/deploy-langgraph.yml +++ b/.github/workflows/deploy-langgraph.yml @@ -17,6 +17,13 @@ on: required: false type: string +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + jobs: deploy: name: Deploy shared cockpit-dev to LangGraph Cloud diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 385488421..18ad5f028 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -4,6 +4,13 @@ on: push: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + jobs: cockpit: runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7f3fd6d42..acdbd0511 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,6 +11,10 @@ on: type: boolean default: true +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest diff --git a/scripts/assemble-demo.ts b/scripts/assemble-demo.ts index ac9ec5d97..02b45c2f3 100644 --- a/scripts/assemble-demo.ts +++ b/scripts/assemble-demo.ts @@ -26,6 +26,24 @@ const root = resolve(__dirname, '..'); const deployDir = resolve(root, 'deploy/demo'); const skipBuild = process.argv.includes('--skip-build'); +function resolveBuildSha(): string { + return process.env['GITHUB_SHA'] ?? execSync('git rev-parse HEAD', { + cwd: root, + encoding: 'utf8', + }).trim(); +} + +const buildMetadata = { + sha: resolveBuildSha(), + runId: process.env['GITHUB_RUN_ID'] ?? null, + runAttempt: process.env['GITHUB_RUN_ATTEMPT'] ?? null, + builtAt: new Date().toISOString(), +}; + +function writeBuildMetadata(outDir: string): void { + writeFileSync(resolve(outDir, '__build.json'), JSON.stringify(buildMetadata, null, 2) + '\n'); +} + if (!skipBuild) { console.log('Building examples-chat-angular (production)...'); execSync('npx nx build examples-chat-angular --configuration=production --skip-nx-cache', { @@ -44,6 +62,7 @@ if (!existsSync(src)) { mkdirSync(deployDir, { recursive: true }); cpSync(src, deployDir, { recursive: true }); +writeBuildMetadata(deployDir); console.log(`✅ Copied SPA to ${deployDir}`); const outputDir = resolve(deployDir, '.vercel/output'); @@ -54,6 +73,7 @@ mkdirSync(staticDir, { recursive: true }); // Copy from the original dist (not deployDir) — Node's cpSync rejects // copying a directory to a subdirectory of itself, filter or no filter. cpSync(src, staticDir, { recursive: true }); +writeBuildMetadata(staticDir); mkdirSync(funcDir, { recursive: true }); execSync(`npx esbuild scripts/demo-middleware.ts --bundle --format=cjs --platform=node --outfile=${funcDir}/index.js`, {