Browse Source

ci(packages): gate packages/** PRs with build, test, lint and typecheck checks

Add a has-packages branch to detect-pr-changes.ts change detection with
unit tests, and a packages-checks.yml workflow: blocking build/test/
typecheck for packages/cli (bun) and build/lint/test for
packages/compatibility-layer (npm+vitest), plus a non-blocking status
job for packages/plugin-abilities, which is pre-existing red (92 tsc
errors, 23 failing tests, broken lint script).

Task: canonical-refactor-01
darrenhinde 2 weeks ago
parent
commit
abeb154fe0

+ 207 - 0
.github/workflows/packages-checks.yml

@@ -0,0 +1,207 @@
+name: Packages Checks
+
+on:
+  pull_request:
+    paths:
+      - 'packages/**'
+      - '.github/workflows/packages-checks.yml'
+      - 'scripts/validation/detect-pr-changes.ts'
+
+permissions:
+  contents: read
+
+jobs:
+  check-changes:
+    name: Detect Package Changes
+    runs-on: ubuntu-latest
+    outputs:
+      has-packages: ${{ steps.filter.outputs.has-packages }}
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+        with:
+          fetch-depth: 0
+          persist-credentials: false
+
+      - name: Setup Bun
+        uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+        with:
+          bun-version: 1.3.14
+
+      - name: Check changed files
+        id: filter
+        run: |
+          echo "Changed files:" >&2
+          git diff --name-only -z "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}" |
+            tee >(while IFS= read -r -d '' path; do printf '  %q\n' "$path" >&2; done) |
+            bun run scripts/validation/detect-pr-changes.ts
+
+  cli-checks:
+    name: CLI (build + test + typecheck)
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+    needs: check-changes
+    if: needs.check-changes.outputs.has-packages == 'true'
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+        with:
+          persist-credentials: false
+
+      - name: Setup Bun
+        uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+        with:
+          bun-version: 1.3.14
+
+      # packages/cli is a root bun workspace member; the root lockfile covers it.
+      - name: Install workspace dependencies
+        run: bun install --frozen-lockfile
+
+      # cli tsconfig paths point at ../compatibility-layer/dist, so build it first.
+      - name: Build compatibility-layer dependency
+        working-directory: packages/compatibility-layer
+        run: bun run build
+
+      - name: Typecheck
+        working-directory: packages/cli
+        run: bun run typecheck
+
+      - name: Build
+        working-directory: packages/cli
+        run: bun run build
+
+      # packages/cli tests use Bun-only APIs (Bun.file, Bun.spawn, import.meta.dir)
+      # until Stage 5 removes them — they must run under `bun test`, not vitest.
+      - name: Test
+        working-directory: packages/cli
+        run: bun run test
+
+  compatibility-layer-checks:
+    name: Compatibility Layer (build + test + lint)
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+    needs: check-changes
+    if: needs.check-changes.outputs.has-packages == 'true'
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+        with:
+          persist-credentials: false
+
+      - name: Setup Node.js
+        uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+        with:
+          node-version: '20'
+          cache: 'npm'
+          cache-dependency-path: 'packages/compatibility-layer/package-lock.json'
+
+      - name: Install dependencies
+        working-directory: packages/compatibility-layer
+        run: npm ci
+
+      # build runs tsc, which is also the typecheck for this package.
+      - name: Build (tsc typecheck)
+        working-directory: packages/compatibility-layer
+        run: npm run build
+
+      - name: Lint
+        working-directory: packages/compatibility-layer
+        run: npm run lint
+
+      - name: Test (vitest)
+        working-directory: packages/compatibility-layer
+        run: npm test
+
+  # Non-blocking: packages/plugin-abilities is known-red today (92 tsc errors,
+  # 23 failing bun tests; its lint script is broken — eslint is not a dependency
+  # and no config exists). This job reports build/test status without failing
+  # PRs until the package is repaired. Once it is green, promote the build and
+  # test commands to normal failing steps and gate the summary on this job.
+  plugin-abilities-checks:
+    name: Plugin Abilities (build + test, non-blocking)
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+    needs: check-changes
+    if: needs.check-changes.outputs.has-packages == 'true'
+    outputs:
+      status: ${{ steps.nonblocking.outputs.status }}
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+        with:
+          persist-credentials: false
+
+      - name: Setup Bun
+        uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+        with:
+          bun-version: 1.3.14
+
+      # Not a root workspace member — it has its own lockfile.
+      - name: Install dependencies
+        working-directory: packages/plugin-abilities
+        run: bun install --frozen-lockfile
+
+      - name: Build and test (non-blocking)
+        id: nonblocking
+        working-directory: packages/plugin-abilities
+        run: |
+          STATUS=pass
+          bun run build || STATUS=fail
+          bun test || STATUS=fail
+          echo "status=$STATUS" >> "$GITHUB_OUTPUT"
+          if [ "$STATUS" = "fail" ]; then
+            echo "::warning title=plugin-abilities red::packages/plugin-abilities build/test failing (pre-existing, non-blocking)"
+          fi
+
+  summary:
+    name: Packages Checks Summary
+    runs-on: ubuntu-latest
+    needs: [check-changes, cli-checks, compatibility-layer-checks, plugin-abilities-checks]
+    if: always()
+
+    steps:
+      - name: Generate summary
+        run: |
+          echo "## 📦 Packages Checks Summary" >> $GITHUB_STEP_SUMMARY
+          echo "" >> $GITHUB_STEP_SUMMARY
+
+          report() {
+            local label="$1" result="$2"
+            case "$result" in
+              success) echo "✅ **${label}:** Passed" >> $GITHUB_STEP_SUMMARY ;;
+              skipped) echo "⏭️ **${label}:** Skipped (no packages/** changes)" >> $GITHUB_STEP_SUMMARY ;;
+              *) echo "❌ **${label}:** ${result}" >> $GITHUB_STEP_SUMMARY ;;
+            esac
+          }
+
+          report "Change Detection" "${{ needs.check-changes.result }}"
+          report "CLI" "${{ needs.cli-checks.result }}"
+          report "Compatibility Layer" "${{ needs.compatibility-layer-checks.result }}"
+
+          case "${{ needs.plugin-abilities-checks.result }}/${{ needs.plugin-abilities-checks.outputs.status }}" in
+            success/pass) echo "✅ **Plugin Abilities (non-blocking):** Passed" >> $GITHUB_STEP_SUMMARY ;;
+            success/fail) echo "⚠️ **Plugin Abilities (non-blocking):** Failing (pre-existing, does not block this PR)" >> $GITHUB_STEP_SUMMARY ;;
+            skipped/*) echo "⏭️ **Plugin Abilities (non-blocking):** Skipped (no packages/** changes)" >> $GITHUB_STEP_SUMMARY ;;
+            *) echo "⚠️ **Plugin Abilities (non-blocking):** ${{ needs.plugin-abilities-checks.result }}" >> $GITHUB_STEP_SUMMARY ;;
+          esac
+
+          echo "" >> $GITHUB_STEP_SUMMARY
+
+          FAILED=0
+          [ "${{ needs.check-changes.result }}" != "success" ] && FAILED=1
+          for result in "${{ needs.cli-checks.result }}" "${{ needs.compatibility-layer-checks.result }}"; do
+            if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then
+              FAILED=1
+            fi
+          done
+
+          if [ $FAILED -eq 0 ]; then
+            echo "### ✅ Required Package Checks Passed" >> $GITHUB_STEP_SUMMARY
+          else
+            echo "### ❌ Some Package Checks Failed" >> $GITHUB_STEP_SUMMARY
+            exit 1
+          fi

+ 58 - 4
scripts/validation/detect-pr-changes.test.ts

@@ -13,10 +13,12 @@ const NO_CHANGES = {
   'has-evals': false,
   'has-docs': false,
   'has-workflows': false,
+  'has-packages': false,
 }
 
 const SCRIPT_PATH = join(import.meta.dir, 'detect-pr-changes.ts')
 const WORKFLOW_PATH = join(import.meta.dir, '..', '..', '.github', 'workflows', 'pr-checks.yml')
+const PACKAGES_WORKFLOW_PATH = join(import.meta.dir, '..', '..', '.github', 'workflows', 'packages-checks.yml')
 
 type CliResult = {
   exitCode: number
@@ -76,18 +78,35 @@ describe('classifyChangedPaths', () => {
     })
   })
 
+  test('detects packages changes', () => {
+    expect(classifyChangedPaths(['packages/cli/src/index.ts'])).toEqual({
+      ...NO_CHANGES,
+      'has-packages': true,
+    })
+    expect(classifyChangedPaths(['packages/compatibility-layer/package.json'])).toEqual({
+      ...NO_CHANGES,
+      'has-packages': true,
+    })
+    expect(classifyChangedPaths(['packages/plugin-abilities/src/index.ts'])).toEqual({
+      ...NO_CHANGES,
+      'has-packages': true,
+    })
+  })
+
   test('detects mixed changes', () => {
     expect(
       classifyChangedPaths([
         'evals/framework/package.json',
         'docs/README.md',
         '.github/workflows/release.yml',
+        'packages/cli/package.json',
         'src/index.ts',
       ]),
     ).toEqual({
       'has-evals': true,
       'has-docs': true,
       'has-workflows': true,
+      'has-packages': true,
     })
   })
 
@@ -106,6 +125,9 @@ describe('classifyChangedPaths', () => {
         'docs.md',
         '.github/workflows-old/check.yml',
         'nested/evals/test.ts',
+        'packages-old/cli/index.ts',
+        'packages.json',
+        'nested/packages/cli/index.ts',
       ]),
     ).toEqual(NO_CHANGES)
   })
@@ -127,8 +149,9 @@ describe('formatGitHubOutput', () => {
         'has-evals': true,
         'has-docs': false,
         'has-workflows': true,
+        'has-packages': false,
       }),
-    ).toBe('has-evals=true\nhas-docs=false\nhas-workflows=true\n')
+    ).toBe('has-evals=true\nhas-docs=false\nhas-workflows=true\nhas-packages=false\n')
   })
 })
 
@@ -142,7 +165,7 @@ describe('CLI', () => {
 
       expect(result).toEqual({ exitCode: 0, stderr: '', stdout: '' })
       expect(await readFile(outputPath, 'utf8')).toBe(
-        'existing=value\nhas-evals=true\nhas-docs=false\nhas-workflows=false\n',
+        'existing=value\nhas-evals=true\nhas-docs=false\nhas-workflows=false\nhas-packages=false\n',
       )
     })
   })
@@ -155,7 +178,7 @@ describe('CLI', () => {
 
       expect(result.exitCode).toBe(0)
       expect(await readFile(outputPath, 'utf8')).toBe(
-        'has-evals=false\nhas-docs=false\nhas-workflows=false\n',
+        'has-evals=false\nhas-docs=false\nhas-workflows=false\nhas-packages=false\n',
       )
     })
   })
@@ -186,13 +209,14 @@ describe('CLI', () => {
         'evals/ leading and trailing .ts ',
         'docs/雪\n$HOME;$(touch never).md',
         '.github/workflows/[check]& weird.yml',
+        'packages/cli/src/ spaced 雪.ts',
       ]
 
       const result = await runCli(`${paths.join('\0')}\0`, outputPath)
 
       expect(result.exitCode).toBe(0)
       expect(await readFile(outputPath, 'utf8')).toBe(
-        'has-evals=true\nhas-docs=true\nhas-workflows=true\n',
+        'has-evals=true\nhas-docs=true\nhas-workflows=true\nhas-packages=true\n',
       )
     })
   })
@@ -232,3 +256,33 @@ describe('PR checks workflow contract', () => {
     expect(overallStatus).toContain('exit 1')
   })
 })
+
+describe('Packages checks workflow contract', () => {
+  test('uses the NUL-delimited detector and the has-packages output', async () => {
+    const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
+
+    expect(workflow).toContain('git diff --name-only -z')
+    expect(workflow).toMatch(/git diff --name-only -z[^\n]*\|[\s\S]*bun run scripts\/validation\/detect-pr-changes\.ts/)
+    expect(workflow).toContain('has-packages: ${{ steps.filter.outputs.has-packages }}')
+    expect(workflow).toContain('oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6')
+    expect(workflow).toContain('bun-version: 1.3.14')
+  })
+
+  test('gates every package check job on the has-packages flag', async () => {
+    const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
+
+    expect(workflow).toContain('cli-checks:')
+    expect(workflow).toContain('compatibility-layer-checks:')
+    expect(workflow).toContain('plugin-abilities-checks:')
+
+    const gates = workflow.match(/needs\.check-changes\.outputs\.has-packages == 'true'/g) ?? []
+    expect(gates.length).toBeGreaterThanOrEqual(3)
+  })
+
+  test('triggers on packages/** pull request changes', async () => {
+    const workflow = await readFile(PACKAGES_WORKFLOW_PATH, 'utf8')
+
+    expect(workflow).toContain('pull_request:')
+    expect(workflow).toContain("- 'packages/**'")
+  })
+})

+ 3 - 0
scripts/validation/detect-pr-changes.ts

@@ -6,6 +6,7 @@ export type ChangeFlags = {
   'has-evals': boolean
   'has-docs': boolean
   'has-workflows': boolean
+  'has-packages': boolean
 }
 
 export function parseChangedPaths(input: string): string[] {
@@ -17,6 +18,7 @@ export function classifyChangedPaths(paths: readonly string[]): ChangeFlags {
     'has-evals': paths.some((path) => path.startsWith('evals/')),
     'has-docs': paths.some((path) => path.startsWith('docs/')),
     'has-workflows': paths.some((path) => path.startsWith('.github/workflows/')),
+    'has-packages': paths.some((path) => path.startsWith('packages/')),
   }
 }
 
@@ -25,6 +27,7 @@ export function formatGitHubOutput(flags: ChangeFlags): string {
     `has-evals=${flags['has-evals']}`,
     `has-docs=${flags['has-docs']}`,
     `has-workflows=${flags['has-workflows']}`,
+    `has-packages=${flags['has-packages']}`,
   ].join('\n') + '\n'
 }