diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91df669b2..300658eb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: 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 }} @@ -399,10 +400,7 @@ jobs: examples-ag-ui-e2e: name: 'examples/ag-ui — e2e' needs: ci-scope - # No dedicated ci-scope output exists for examples/ag-ui yet, so this job - # runs on every push and pull_request rather than being scope-gated. It is - # deliberately NOT wired into the require_scoped aggregation in - # `required-pr-checks` (see Step 1 report) to avoid breaking that logic. + if: github.event_name == 'push' || needs.ci-scope.outputs.examples_ag_ui == 'true' runs-on: ubuntu-latest timeout-minutes: 35 steps: @@ -581,6 +579,7 @@ jobs: - cockpit-deploy-smoke - examples-chat-smoke - examples-chat-e2e + - examples-ag-ui-e2e - cockpit-e2e-summary - website-e2e - posthog-sync-plan @@ -600,6 +599,7 @@ jobs: 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_POSTHOG: ${{ needs.posthog-sync-plan.result }} @@ -612,6 +612,7 @@ jobs: 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 }} @@ -665,6 +666,11 @@ jobs: 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_scoped "posthog" "PostHog — dashboards-as-code drift check" "$RESULT_POSTHOG" "$SCOPE_POSTHOG" diff --git a/examples/ag-ui/python/project.json b/examples/ag-ui/python/project.json index 82f9909ad..296a94982 100644 --- a/examples/ag-ui/python/project.json +++ b/examples/ag-ui/python/project.json @@ -1,5 +1,6 @@ { "name": "examples-ag-ui-python", + "tags": ["scope:examples-ag-ui"], "$schema": "../../../node_modules/nx/schemas/project-schema.json", "projectType": "application", "sourceRoot": "examples/ag-ui/python/src", diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs index 29306692c..394f73fbe 100644 --- a/scripts/ci-scope.mjs +++ b/scripts/ci-scope.mjs @@ -15,6 +15,7 @@ export const SCOPE_KEYS = [ 'cockpit_deploy_smoke', 'cockpit_e2e', 'examples_chat', + 'examples_ag_ui', 'posthog', 'scripts_tests', ]; diff --git a/scripts/ci-scope.spec.mjs b/scripts/ci-scope.spec.mjs index 3da77cacb..e096e8352 100644 --- a/scripts/ci-scope.spec.mjs +++ b/scripts/ci-scope.spec.mjs @@ -395,8 +395,59 @@ describe('classifyFromAffected — tag isolation', () => { }); }); +describe('classifyFromAffected — examples/ag-ui', () => { + // examples/ag-ui/{,angular/}project.json already carried + // `scope:examples-ag-ui`, but `examples_ag_ui` was missing from SCOPE_KEYS, + // so classifyFromAffected read the tag and dropped it on the floor. The + // examples-ag-ui-e2e job therefore had no scope to gate on and ran on every + // PR — a 35-minute-timeout Playwright job on docs-only changes. + it('an affected examples/ag-ui project selects examples_ag_ui only', async () => { + const project = JSON.parse( + await readFile('examples/ag-ui/angular/project.json', 'utf8') + ); + const scope = classifyFromAffected( + ['examples/ag-ui/angular/src/app/app.ts'], + [{ name: project.name, tags: project.tags }] + ); + + assert.equal(scope.examples_ag_ui, true); + assert.equal(scope.examples_chat, false); + assert.equal(scope.cockpit_e2e, false); + assert.equal(scope.website, false); + }); + + it('the python backend the e2e job uv-syncs owns the scope too', async () => { + // The examples-ag-ui-e2e job runs `uv sync` in examples/ag-ui/python and + // then drives the Angular app against it. An untagged backend would mean + // a python-only change silently skips the suite — scoping that buys speed + // by dropping coverage. + const project = JSON.parse( + await readFile('examples/ag-ui/python/project.json', 'utf8') + ); + + assert.ok( + project.tags?.includes('scope:examples-ag-ui'), + 'examples/ag-ui/python must select the ag-ui e2e suite' + ); + + const scope = classifyFromAffected( + ['examples/ag-ui/python/src/agent.py'], + [{ name: project.name, tags: project.tags }] + ); + assert.equal(scope.examples_ag_ui, true); + }); + + it('a website-only change leaves examples_ag_ui false', () => { + const scope = classifyFromAffected( + ['apps/website/src/app/layout.tsx'], + [{ name: 'website', tags: WEBSITE_TAGS }] + ); + assert.equal(scope.examples_ag_ui, false); + }); +}); + describe('SCOPE_KEYS export', () => { - it('contains the 12 documented scope keys', () => { + it('contains the 13 documented scope keys', () => { assert.deepEqual(SCOPE_KEYS, [ 'library', 'angular_compatibility', @@ -408,6 +459,7 @@ describe('SCOPE_KEYS export', () => { 'cockpit_deploy_smoke', 'cockpit_e2e', 'examples_chat', + 'examples_ag_ui', 'posthog', 'scripts_tests', ]); diff --git a/scripts/ci-workflow.spec.mjs b/scripts/ci-workflow.spec.mjs index 5112716ed..977751be5 100644 --- a/scripts/ci-workflow.spec.mjs +++ b/scripts/ci-workflow.spec.mjs @@ -409,6 +409,29 @@ describe('CI workflow', () => { ); }); + it('scope-gates the examples/ag-ui e2e job and requires it', async () => { + // This job had no `if:` at all — it ran on every push and pull_request, + // including docs-only ones, at a 35-minute timeout. It was also left out + // of required-pr-checks, so an ag-ui e2e failure did not block a merge. + // Both halves are fixed together: gating it without requiring it would + // leave a suite that is skipped often and ignored when it fails. + const workflow = await readWorkflow(); + const job = readJobBlock(workflow, 'examples-ag-ui-e2e'); + + assert.match(job, /needs\.ci-scope\.outputs\.examples_ag_ui == 'true'/); + + const required = readJobBlock(workflow, 'required-pr-checks'); + assert.match( + required, + /require_scoped \\\n\s*"examples_ag_ui"/, + 'required-pr-checks should aggregate the ag-ui e2e result' + ); + assert.ok( + readJobNeeds(required).includes('examples-ag-ui-e2e'), + 'required-pr-checks should depend on examples-ag-ui-e2e' + ); + }); + it('lets the cockpit e2e summary inspect CI scope outputs', async () => { const cockpitE2eSummaryJob = await readCockpitE2eSummaryJob(); @@ -520,6 +543,7 @@ describe('CI workflow', () => { 'cockpit-deploy-smoke', 'examples-chat-smoke', 'examples-chat-e2e', + 'examples-ag-ui-e2e', 'cockpit-e2e-summary', 'website-e2e', 'posthog-sync-plan',