diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1f515a10..3be15d40f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,6 +168,10 @@ jobs: node node_modules/typescript/bin/tsc -p scripts/react-parity/tsconfig.native-mastra.json node node_modules/tsx/dist/cli.mjs --tsconfig tsconfig.base.json scripts/react-parity/verify-native-mastra.ts - run: npx nx test langgraph --coverage --maxWorkers=2 --reporter=default + - name: Prepare pinned PostgreSQL integration image + run: | + docker pull public.ecr.aws/docker/library/postgres@sha256:ca0bd484cb98bf4b24eb1010e73fb3fcbd6714d240fbc1a10eea5b7dbecb641d + docker tag public.ecr.aws/docker/library/postgres@sha256:ca0bd484cb98bf4b24eb1010e73fb3fcbd6714d240fbc1a10eea5b7dbecb641d postgres:16 - name: Verify durable tool ownership and PostgreSQL migration run: node node_modules/tsx/dist/cli.mjs --tsconfig tsconfig.base.json scripts/react-parity/verify-tool-claims.ts - run: npx nx run-many -t test --projects=chat,ag-ui,render,a2ui,telemetry,middleware --coverage --parallel=1 --maxWorkers=2 @@ -1485,8 +1489,9 @@ jobs: # 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: + # successful, non-stale run of this job, including a no-change run. + # It records the last processed baseline, so the range covers all + # relevant changes 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)" @@ -1516,7 +1521,7 @@ jobs: 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|demo-routes)\.ts)$' >/dev/null; then + if printf '%s\n' "$changed_files" | grep -E '^(examples/ag-ui/.*|libs/.*|scripts/(ag-ui-demo-middleware|assemble-ag-ui-demo|demo-routes)\.ts|package\.json|package-lock\.json|nx\.json|tsconfig\.base\.json|\.github/workflows/ci\.yml)$' >/dev/null; then ag_ui_changed=true fi echo "changed=$ag_ui_changed" >> "$GITHUB_OUTPUT" @@ -1563,10 +1568,11 @@ jobs: 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. + # Advance only when the whole job succeeded and is non-stale, including + # no-change runs that successfully processed the baseline. 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" diff --git a/scripts/ci-workflow.spec.mjs b/scripts/ci-workflow.spec.mjs index e53fcff8b..52ec35c7b 100644 --- a/scripts/ci-workflow.spec.mjs +++ b/scripts/ci-workflow.spec.mjs @@ -1,4 +1,6 @@ -import { readdir, readFile } from 'node:fs/promises'; +import { chmod, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { privateScaffoldProjects } from './react-parity/package-policy.mjs'; @@ -78,7 +80,84 @@ function readNamedStep(job, name) { return job.slice(start, end); } +const pinnedPostgresImage = + 'public.ecr.aws/docker/library/postgres@sha256:ca0bd484cb98bf4b24eb1010e73fb3fcbd6714d240fbc1a10eea5b7dbecb641d'; + +function readStepRunBlock(step) { + const match = /^ {8}run: \|\n((?: {10}[^\n]*\n?)+)/m.exec(step); + assert.ok(match, 'expected a workflow shell run block'); + return match[1].replace(/^ {10}/gm, '').trimEnd(); +} + +async function runPostgresPreparation(failingCommand) { + const job = readJobBlock(await readFile('.github/workflows/ci.yml', 'utf8'), 'library'); + const script = readStepRunBlock(readNamedStep(job, 'Prepare pinned PostgreSQL integration image')); + const directory = await mkdtemp(join(tmpdir(), 'ci-postgres-preparation-')); + const log = join(directory, 'docker-arguments.jsonl'); + try { + // Only the Docker command boundary is replaced; Bash executes the production block. + const docker = join(directory, 'docker'); + await writeFile(docker, `#!${process.execPath}\n` + + `const { appendFileSync } = require('node:fs');\n` + + `const args = process.argv.slice(2);\n` + + `appendFileSync(process.env.DOCKER_ARGUMENT_LOG, JSON.stringify(args) + '\\n');\n` + + `if (args[0] === process.env.FAIL_DOCKER_COMMAND) process.exit(args[0] === 'pull' ? 41 : 42);\n`); + await chmod(docker, 0o755); + const result = spawnSync('/bin/bash', ['-e', '-c', script], { + encoding: 'utf8', + env: { + ...process.env, + PATH: directory, + DOCKER_ARGUMENT_LOG: log, + FAIL_DOCKER_COMMAND: failingCommand ?? '', + }, + }); + assert.ifError(result.error); + const calls = (await readFile(log, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)); + return { result, calls }; + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + describe('CI workflow', () => { + it('requires unconditional pinned PostgreSQL preparation immediately before the real durable tool verifier', async () => { + const job = readJobBlock(await readFile('.github/workflows/ci.yml', 'utf8'), 'library'); + const prepare = readNamedStep(job, 'Prepare pinned PostgreSQL integration image'); + const verify = readNamedStep(job, 'Verify durable tool ownership and PostgreSQL migration'); + assert.equal((job.match(/- name: Prepare pinned PostgreSQL integration image/g) ?? []).length, 1); + assert.equal(readStepRunBlock(prepare), `docker pull ${pinnedPostgresImage}\ndocker tag ${pinnedPostgresImage} postgres:16`); + assert.equal(job.slice(job.indexOf(prepare) + prepare.length).trimStart().startsWith(verify.trimStart()), true, + 'the real verifier must be the next GitHub step after successful preparation'); + for (const step of [prepare, verify]) { + assert.doesNotMatch(step, /if:|continue-on-error|\|\|\s*true|set\s+\+e|secrets\.|credentials|docker login/); + } + assert.equal(verify.trimEnd(), ' - name: Verify durable tool ownership and PostgreSQL migration\n' + + ' run: node node_modules/tsx/dist/cli.mjs --tsconfig tsconfig.base.json scripts/react-parity/verify-tool-claims.ts'); + const source = await readFile('scripts/react-parity/verify-tool-claims.ts', 'utf8'); + assert.match(source, /'--network',\s*'none'/); + assert.match(source, /'postgres:16'/); + assert.match(source, /await migrationScenario\(\)/); + }); + + it('executes the production PostgreSQL preparation block with exactly the pinned pull and local tag', async () => { + const { result, calls } = await runPostgresPreparation(); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(calls, [['pull', pinnedPostgresImage], ['tag', pinnedPostgresImage, 'postgres:16']]); + }); + + it('stops the production PostgreSQL preparation block before tagging when pull fails', async () => { + const { result, calls } = await runPostgresPreparation('pull'); + assert.equal(result.status, 41, result.stderr); + assert.deepEqual(calls, [['pull', pinnedPostgresImage]]); + }); + + it('fails the production PostgreSQL preparation block when the local tag fails', async () => { + const { result, calls } = await runPostgresPreparation('tag'); + assert.equal(result.status, 42, result.stderr); + assert.deepEqual(calls, [['pull', pinnedPostgresImage], ['tag', pinnedPostgresImage, 'postgres:16']]); + }); + it('runs both graph streaming regressions with locked Python before cockpit smoke can pass', async () => { const job = readJobBlock(await readFile('.github/workflows/ci.yml', 'utf8'), 'cockpit-smoke'); const setup = readNamedStep(job, 'Set up Python for graph streaming regressions'); @@ -410,6 +489,73 @@ describe('CI workflow', () => { return readJobBlock(await readWorkflow(), 'ag-ui-demo-deploy'); } + // Exercise grep's ERE semantics with the selector read from the deploy step, + // so these cases cannot pass against a separately maintained test regex. + for (const [path, changed] of [ + ['libs/ag-ui/src/public-api.ts', true], + ['libs/chat/src/lib/compositions/chat/chat.component.ts', true], + ['libs/render/src/public-api.ts', true], + ['libs/telemetry/src/browser/public-api.ts', true], + ['libs/langgraph/src/public-api.ts', true], + ['libs/core/src/index.ts', true], + ['libs/a2ui/src/index.ts', true], + ['libs/future-library/src/index.ts', true], + ['package.json', true], + ['package-lock.json', true], + ['nx.json', true], + ['tsconfig.base.json', true], + ['.github/workflows/ci.yml', true], + ['examples/ag-ui/angular/src/app/app.ts', true], + ['examples/ag-ui/python/src/agent.py', true], + ['scripts/ag-ui-demo-middleware.ts', true], + ['scripts/assemble-ag-ui-demo.ts', true], + ['scripts/demo-routes.ts', true], + ['README.md', false], + ['apps/website/content/docs/ag-ui.mdx', false], + ['deployments/ag-ui-mastra/src/index.ts', false], + ['examples/chat/python/src/agent.py', false], + ['scripts/ci-workflow.spec.mjs', false], + ['scripts/demo-routes.spec.ts', false], + ['scripts/assemble-ag-ui-demo.spec.ts', false], + ['library/ag-ui/src/index.ts', false], + ['libs-extra/ag-ui/src/index.ts', false], + ['nested/libs/ag-ui/src/index.ts', false], + ['examples/ag-ui-extra/angular/src/app.ts', false], + ['nested/examples/ag-ui/angular/src/app.ts', false], + ['scripts/demo-routes.ts.bak', false], + ['scripts/not-demo-routes.ts', false], + ['nested/package.json', false], + ['packageXjson', false], + ['package-lockXjson', false], + ['package.json.bak', false], + ['nxXjson', false], + ['tsconfigXbaseXjson', false], + ['.github/workflows/ciXyml', false], + ['.github/workflows/ci.yml.bak', false], + ['.github/workflows/release.yml', false], + ['', false], + ]) { + it(`AG-UI demo deployment ${changed ? 'includes' : 'excludes'} ${path || 'an empty change list'}`, async () => { + const step = readNamedStep(await readAgUiDemoJob(), 'Check if AG-UI demo changed'); + const selectors = [...step.matchAll(/grep -E '([^']+)'/g)]; + assert.equal(selectors.length, 1, 'expected exactly one deployment path selector'); + const result = spawnSync('grep', ['-E', selectors[0][1]], { + input: `${path}\n`, + encoding: 'utf8', + }); + assert.ifError(result.error); + assert.equal(result.status, changed ? 0 : 1, `${path}: ${result.stderr}`); + }); + } + + it('runs the deployment selector regressions on every pull request', async () => { + const job = await readCiScopeJob(); + const step = readNamedStep(job, 'Validate CI workflow guards'); + assert.match(step, /run: node --test scripts\/ci-workflow\.spec\.mjs\s*$/); + assert.doesNotMatch(step, /if:|continue-on-error|\|\|\s*true|--test-name-pattern/); + assert.doesNotMatch(job, /^ {4}if:/m); + }); + async function readProductionSmokeJob() { return readJobBlock(await readWorkflow(), 'production-smoke'); } diff --git a/scripts/react-parity/baseline.json b/scripts/react-parity/baseline.json index f05cce68e..2d054291e 100644 --- a/scripts/react-parity/baseline.json +++ b/scripts/react-parity/baseline.json @@ -1,11 +1,8 @@ { "schemaVersion": 1, - "baselineHead": "b5ba906aa73768003dd1697c435a96b67c010d88", + "baselineHead": "beabb1d39164a7ff6cf8141faa18f64c7d90ee4e", "sourceState": { - "modified": [ - "apps/website/content/docs/chat/guides/generative-ui.mdx", - "libs/cockpit-registry/src/lib/content-descriptors.ts" - ], + "modified": [], "untracked": [] }, "scope": { @@ -2151,7 +2148,7 @@ "id": "config:.github/workflows/ci.yml", "kind": "config", "path": ".github/workflows/ci.yml", - "sha256": "e267321eb5a1e8a4c88a5cbd2b4c5a20551a2dcde270389a16048ffc887d6d2a" + "sha256": "aaa0cda27d8b4074673bca49a24e5c42e81af7200d9e482c4f2e89a9f2a998bb" }, { "id": "config:.github/workflows/publish-middleware-npm.yml",