{"slug": "front-end-ci-cd-in-the-age-of-ai", "title": "Front end CI/CD in the age of AI", "summary": "Frontend CI/CD pipelines are slowing down as AI-generated code produces an explosion of tests, Neciu Dan writes in a technical blog post. Dan argues that CI and CD should be treated as separate problems and proposes building a fast CI pipeline by running only the tests affected by a code change, computed from the dependency graph, rather than running everything on every pull request.", "body_md": "· [ devops ](/category/devops) · 19 min read\n\n# Frontend CI/CD in the age of AI -- Part 1\n\nCode is written faster than ever with AI loving to write tests for everything it does. This makes our CI pipeline slower and slower. Here is how to build a fast CI/CD pipeline - Part 1 with focus on CI\n\n## Neciu Dan\n\nHi there, it's Dan, a technical co-founder of an ed-tech startup, host of Señors at Scale - a podcast for Senior Engineers, Organizer of ReactJS Barcelona meetup, international speaker and Staff Software Engineer, I'm here to share insights on combining\ntechnology and education to solve real problems.\n\nI write about startup challenges, tech innovations, and the Frontend Development.\nSubscribe to join me on this journey of transforming education through technology. Want to discuss\nTech, Frontend or Startup life? [Let's connect.](https://www.linkedin.com/in/neciudan/)\n\nContinuous Integration (CI) and Continuous Deployment(CD) are responsible for two things: making sure that when we merge our PR, it does not introduce any faults (bugs) into our main branch (Integration), and after every merge, our code gets released in some way to production in a timely manner (Deployment).\n\nThen, everything changed when the Fire Nation attacked (and by that I mean the AI Revolution)\n\nAI writes almost all the code now, and its particulary good at writing tests. It doesn’t always write good or useful tests, but it likes to write a lot of tests.\n\nAs a result, the time it takes our CI to run is growing exponentially, leaving developers waiting for PRs to build, run, and get feedback that everything is working or not.\n\nIt doesn’t have to work like this. Google claims the majority of its new code is now AI-generated and engineer-approved, and they got there by making verification cheap and rollout reversible.\n\nLet’s dig in.\n\nPS: You can find the whole demo and script files in this [repo](https://github.com/Cst2989/change-detection)\n\n## CI and CD are different problems\n\nThe first thing we need to establish is a clear separation between CI and CD. People treat it as a single concept and a single flow, which causes a lot of problems and headaches.\n\n**Continuous Integration answers: is this change correct?** It runs Lint, types, unit tests, e2e tests, builds. It runs before merging and slow CI compounds into batched PRs, stale branches, and agents idling.\n\n**Continuous Delivery answers: is this change safe in production?** Deployment, rollout, rollback. It runs after the merge.\n\nIn this article (part 1) I want to focus on building a robust and FAST CI pipeline for Frontend projects. In part we will tackle the deployment part.\n\n## CI, part 1: stop running everything\n\nImagine your app has a Dashboard, a Settings page, a Billing page, and folders for shared components and shared utilities. Everything lives in a single app and repo, with no workspaces or internal packages.\n\nYou change one line in `Button.tsx`\n\n, push the PR, and your pipeline lints every file you own, typechecks the whole project, runs all 340 unit tests, and builds the app.\n\nWhich is fine, but with exponential growth and some apps having millions lines of code, this can get slow fast (pun intented)\n\n```\n# .github/workflows/ci.yml\nname: CI\non: pull_request\n\njobs:\n  ci:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n      - run: npm ci\n      - run: npm run lint\n      - run: npm run typecheck\n      - run: npm run test\n      - run: npm run build\n```\n\nYou wait twenty-five minutes, and so does everyone queued behind you.\n\nWhat you actually want is narrow and specific.\n\nChange Button, and you want Button’s tests to run, plus the tests of everything that imports Button, however deep the chain goes, to also run.\n\nYou want the same for lint and typecheck. The Billing feature never imports Button, so Billing should not be touched at all.\n\nYou want that list computed from your code every time, so it is never wrong, and you never have to maintain it.\n\nThat setup takes away the thing most tooling relies on. You have no `package.json`\n\nboundaries, so nothing in your repo declares that Billing and Settings are separate.\n\nEvery tool that solves this for monorepos is reading workspace manifests that we do not have.\n\nWe will derive the same information from our imports instead.\n\n### Filtering by path\n\nThe first thing you might try is filtering by path within GitHub Actions itself. You attach a `paths`\n\nfilter to the trigger, and the workflow only runs when your PR touches files matching those patterns.\n\n```\n# .github/workflows/settings.yml\nname: Settings CI\non:\n  pull_request:\n    paths:\n      - 'src/features/settings/**'\n```\n\nChange something in Settings, and this runs. Change something in Billing, and it stays quiet.\n\nBut this has three problems.\n\n**First, it does not scale.** You maintain that list by hand, forever.\n\nEvery feature you add means another workflow file with another set of paths. Every folder you rename means hunting down whichever workflow referenced the old name.\n\n**Second, it does not know anything about your imports.** A path filter matches folder names, and that is the entire extent of its intelligence.\n\n**Third, required checks sit there waiting forever.** This one appears once somebody turns on branch protection.\n\nYou list the checks you care about, mark them as required, and GitHub refuses to merge until every check on that list passes.\n\nA check reports something when its workflow runs. If the workflow never starts, it never reports, and GitHub does not read that silence as success.\n\nSo you open a PR that only touches Billing. `Billing CI`\n\nruns and passes, `Settings CI`\n\nnever starts, and your PR shows this until the end of time:\n\n```\n✓  Billing CI   — Successful in 2m\n•  Settings CI  — Expected — Waiting for status to be reported\n```\n\nThe merge button is greyed out. You cannot merge.\n\nAll three problems come from the same mistake. We are asking the pipeline to decide what to run before it has looked at anything.\n\nWhat if instead we let every workflow run on every PR, but within the workflow, we determine what changed and run the necessary jobs.\n\n### Step one: ask git what you changed\n\nGit knows the files.\n\n```\ngit diff --name-only origin/main...HEAD\nsrc/components/ui/Button.tsx\n```\n\nOne file. That is the starting point.\n\n### Step two: walk your imports backward\n\nYour Button change renames a prop from `label`\n\nto `text`\n\n. Dashboard and Settings both import Button, and both still pass `label`\n\n.\n\nBilling does not import the Button at all.\n\nSo we want to run lint, type-check, and test for Button and every file that uses it.\n\nEvery `import`\n\nin your codebase is an edge, and you want to walk those edges backward from the file you changed.\n\nNow, this is a long script with many edge cases, which I won’t write here.\n\nBut for the curious, here is the [entire file](https://github.com/Cst2989/change-detection) in `scrips/affected.mjs`\n\n, this repo has all the demo code in this article and all scripts needed to build the same.\n\nRunning that script after changing the button component, and you get exactly four files:\n\n``` bash\n$ BASE_REF=main node scripts/affected.mjs --lines\nsrc/components/ui/Button.tsx\nsrc/features/dashboard/DashboardPage.tsx\nsrc/features/settings/SettingsPage.tsx\nsrc/features/dashboard/DashboardPage.test.tsx\n```\n\nButton, its two importers, and the test that covers one of them. Billing is not on that list, nor is anything else in your app.\n\n### Step three: hand that list to your three tools\n\nNow we have a list of files, and three tools need it: our linter, type checker, and test runner, and each one takes it differently.\n\n**Lint takes it directly.** ESLint accepts file paths as arguments, so pipe the list in.\n\n```\nnpx eslint $(node scripts/affected.mjs --lines)\n```\n\nBut an empty list turns into npx eslint with no arguments, and a global change prints ALL, which ESLint reads as a filename and fails.\n\nSo each tool gets a small wrapper instead.\n\nThey all follow the same three-branch shape: everything, nothing, or the list.\n\nThese wrappers are `scripts/lint-affected.mjs`\n\n, `scripts/tests-affected.mjs`\n\nand `scripts/typecheck-affected.mjs`\n\nand you can find all of them in the [same repo](https://github.com/Cst2989/change-detection).\n\nChange Button and you get exactly two test files, out of a suite that has more:\n\n✓ src/features/dashboard/DashboardPage.test.tsx (1 test) 4ms ✓ src/components/ui/Button.test.tsx (1 test) 3ms\n\nTest Files 2 passed (2) Tests 2 passed (2)\n\nIf you rename a prop and run the type check wrapper:\n\n``` bash\n$ node scripts/typecheck-affected.mjs\nsrc/features/dashboard/DashboardPage.tsx(3,51): error TS2353: Object literal may only\n  specify known properties, and 'label' does not exist in type '{ text: string; }'.\nsrc/features/settings/SettingsPage.tsx(2,50): error TS2353: Object literal may only\n  specify known properties, and 'label' does not exist in type '{ text: string; }'.\n```\n\nBoth importers are caught, with exact line numbers.\n\n### When you would rather not maintain a regex\n\nOur resolver supports relative imports, a single alias, and index files.\n\nIt will not handle barrel re-exports that hide the real path, or the seventeen other things your codebase eventually does.\n\nAt that point, we can stop maintaining it and use `dependency-cruiser`\n\n, which properly parses TypeScript, reads your tsconfig paths, follows dynamic imports, and ships it as a flag.\n\n```\nnpx depcruise src --affected origin/main --output-type json\n```\n\n`--affected`\n\ntakes a git revision and returns the changed modules and everything that depends on them, which is the whole script above in a single argument.\n\n### Step four: problems\n\nOn your laptop, the script is precise. On the runner, it dies.\n\n```\nfatal: ambiguous argument 'origin/main...HEAD': unknown revision\n```\n\n`actions/checkout`\n\nclones with `fetch-depth: 1`\n\nby default, so your runner holds one commit and has no `origin/main`\n\nto compare against.\n\nYou need history.\n\n```\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          filter: blob:none\n```\n\n`fetch-depth: 0`\n\nbrings down your full commit history, and `filter: blob:none`\n\nskips file contents for commits you will never check out.\n\nGit fetches those on demand if anything needs them.\n\nNever hardcode the base branch either. Once your PRs start targeting each other, the only comparison worth making is against the branch this one merges into.\n\n```\n        env:\n          BASE_REF: origin/${{ github.base_ref }}\n```\n\nThe workflow itself is now boring, which is the goal.\n\n```\n# .github/workflows/ci.yml\nname: CI\non: pull_request\n\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          filter: blob:none\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n      - run: npm ci\n      - run: node scripts/lint-affected.mjs\n        env:\n          BASE_REF: origin/${{ github.base_ref }}\n\n  typecheck:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          filter: blob:none\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n      - run: npm ci\n      - run: node scripts/typecheck-affected.mjs\n        env:\n          BASE_REF: origin/${{ github.base_ref }}\n\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          filter: blob:none\n      - uses: actions/setup-node@v4\n        with:\n          node-version: 22\n          cache: 'npm'\n      - run: npm ci\n      - run: node scripts/test-affected.mjs\n        env:\n          BASE_REF: origin/${{ github.base_ref }}\n```\n\nEvery job runs on every PR, so every required check reports a result, and nothing blocks your merge.\n\nChange Button and your lint job handles four files, your typecheck builds a four-file program, and your test job runs the handful of tests that reach Button.\n\nChange a README, and all three jobs finish before dependencies are installed.\n\n## CI, part 2: let’s make it fast\n\nThe workflow we finished part 1 with already runs its three jobs in parallel. GitHub gives each job its own machine, so lint, typecheck, and test start at the same moment, but two things are still slow (or will be).\n\nThe first is that every job installs your dependencies from scratch. The second is end-to-end tests, which we have not added yet and which will dwarf everything else the moment we do.\n\n### What `cache: 'npm'`\n\nactually caches\n\nLook at our workflow job again. It already has a cache line.\n\nWhat you cached there is `~/.npm`\n\n, npm’s download cache of package tarballs. You did not cache `node_modules`\n\n.\n\nSo on a cache hit, you skip the network, which is the fast part anyway. Then `npm ci`\n\ndeletes `node_modules`\n\n, unpacks every tarball again, and rebuilds the tree, which is the slow part and happens every single run.\n\nTo skip that, cache `node_modules`\n\nitself and only install when the cache misses.\n\n```\n      - id: modules\n        uses: actions/cache@v4\n        with:\n          path: node_modules\n          key: modules-v1-${{ runner.os }}-node22-${{ hashFiles('package-lock.json') }}\n\n      - if: steps.modules.outputs.cache-hit != 'true'\n        run: npm ci --prefer-offline --no-audit --fund=false\n```\n\nThe `if:`\n\nguard is not optional. Without it, `npm ci`\n\nruns anyway, and the first thing `npm ci`\n\ndoes is delete the `node_modules`\n\nyou just restored.\n\nEvery part of that key is doing a job.\n\nThe lockfile hash invalidates when your dependencies change. `runner.os`\n\nand the Node version keep you from restoring a tree built for a different platform, which matters because packages with native bindings compile against a specific Node ABI and fail in confusing ways when it moves under them.\n\nThe `v1`\n\nsegment is your manual escape hatch. GitHub caches cannot be edited or deleted from a workflow, so when a cache goes bad, your only move is to change the key, and bumping `v1`\n\nto `v2`\n\nis easy.\n\n### Extracting the setup\n\nAdding those cache steps makes the repetition across your three jobs worse.\n\nGitHub Actions has no YAML anchors, so pull the shared steps into a composite action in your repo.\n\n```\n# .github/actions/setup/action.yml\nname: Setup\ndescription: Node, dependencies, and the caches for both\n\nruns:\n  using: composite\n  steps:\n    - uses: actions/setup-node@v4\n      with:\n        node-version: 22\n        cache: 'npm'\n\n    - id: modules\n      uses: actions/cache@v4\n      with:\n        path: node_modules\n        key: modules-v1-${{ runner.os }}-node22-${{ hashFiles('package-lock.json') }}\n\n    - if: steps.modules.outputs.cache-hit != 'true'\n      run: npm ci --prefer-offline --no-audit --fund=false\n      shell: bash\n```\n\nComposite actions require `shell:`\n\non every `run`\n\nstep, and the action lives in your repository, so `actions/checkout`\n\nhas to run before you can call it.\n\nYour jobs collapse to four lines each.\n\n```\n# .github/workflows/ci.yml\nname: CI\non: pull_request\n\njobs:\n  lint:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          filter: blob:none\n      - uses: ./.github/actions/setup\n      - run: node scripts/lint-affected.mjs\n        env:\n          BASE_REF: origin/${{ github.base_ref }}\n\n  typecheck:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          filter: blob:none\n      - uses: ./.github/actions/setup\n      - run: node scripts/typecheck-affected.mjs\n        env:\n          BASE_REF: origin/${{ github.base_ref }}\n\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          filter: blob:none\n      - uses: ./.github/actions/setup\n      - run: node scripts/test-affected.mjs\n        env:\n          BASE_REF: origin/${{ github.base_ref }}\n```\n\n### Caching what the tools already know\n\nYour dependencies are handled, but the tools themselves also repeat work they could have remembered.\n\n**ESLint keeps a cache** of the files it has already checked and what it found, keyed on their contents. You point it at a path and persist that path between runs.\n\n```\n// scripts/lint-affected.mjs, the last line\nexecFileSync(\n  'npx',\n  ['eslint', '--cache', '--cache-location', '.eslintcache', ...lintable],\n  { stdio: 'inherit' }\n);\n- uses: actions/cache@v4\n        with:\n          path: .eslintcache\n          key: eslint-v1-${{ github.sha }}\n          restore-keys: eslint-v1-\n```\n\n`restore-keys`\n\nis safe here, unlike the dependency cache. A stale ESLint cache costs you a re-lint of files whose contents no longer match.\n\n**TypeScript writes a .tsbuildinfo** when you enable incremental mode, and on the next run it only rechecks what changed.\n\n```\n// tsconfig.json\n{\n  \"compilerOptions\": {\n    \"incremental\": true,\n    \"tsBuildInfoFile\": \".cache/tsconfig.tsbuildinfo\"\n  }\n}\n```\n\nCache `.cache/`\n\nthe same way, and your typecheck job stops rebuilding type information for files nobody touched.\n\n### The snails\n\nEverything so far is fast because unit tests are fast. End-to-end tests are not fast because browser tests are inherently slow and grow linearly with your product.\n\nPlaywright ships the answer as a flag. `--shard=1/4`\n\nruns a quarter of your suite, and a matrix runs all four quarters on four machines at once.\n\nStart with the config:\n\n``` js\n// playwright.config.ts\nimport { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n  testDir: './e2e',\n  fullyParallel: true,\n  reporter: process.env.CI ? 'blob' : 'html',\n  use: {\n    baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000',\n    trace: 'on-first-retry',\n  },\n});\n```\n\nWithout `fullyParallel: true`\n\n, Playwright distributes whole files across shards. Your one large spec file becomes a single shard, while the other three finish early and sit idle.\n\nNow the job, with browser caching:\n\n```\n  e2e:\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        shard: [1, 2, 3, 4]\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          filter: blob:none\n      - uses: ./.github/actions/setup\n\n      - name: Read the installed Playwright version\n        run: |\n          VERSION=$(node -p \"require('@playwright/test/package.json').version\")\n          echo \"PLAYWRIGHT_VERSION=$VERSION\" >> \"$GITHUB_ENV\"\n\n      - id: browsers\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: playwright-${{ runner.os }}-${{ env.PLAYWRIGHT_VERSION }}\n          # deliberately no restore-keys\n\n      - if: steps.browsers.outputs.cache-hit != 'true'\n        run: npx playwright install --with-deps chromium\n\n      - if: steps.browsers.outputs.cache-hit == 'true'\n        run: npx playwright install-deps chromium\n\n      - run: npx playwright test --shard=${{ matrix.shard }}/4\n\n      - uses: actions/upload-artifact@v4\n        if: always()\n        with:\n          name: blob-report-${{ matrix.shard }}\n          path: blob-report\n          retention-days: 7\n```\n\n### Four shards, four reports\n\nEach shard writes its own report, leaving you opening 4 artifacts to find 1 failure. That is what the `blob`\n\nreporter is for: it writes a mergeable intermediate format instead of finished HTML.\n\nWe need to add a job that stitches them together.\n\n```\n  e2e-report:\n    if: always()\n    needs: [e2e]\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: ./.github/actions/setup\n      - uses: actions/download-artifact@v4\n        with:\n          path: all-blob-reports\n          pattern: blob-report-*\n          merge-multiple: true\n      - run: npx playwright merge-reports --reporter html ./all-blob-reports\n      - uses: actions/upload-artifact@v4\n        with:\n          name: playwright-report\n          path: playwright-report\n```\n\n### Narrowing E2E tests\n\nSharding makes your suite four times faster, but we don’t want to run the entire E2E test suite for a button change.\n\nPlaywright seems to have an `--only-changed`\n\nparam that does this, but it doesn’t work as expected.\n\n```\nnpx playwright test --only-changed=origin/main\n```\n\nIt walks the import graph of your spec files and keeps the ones your diff can reach, exactly like `vitest related`\n\nin part 1.\n\nNow look at a real spec.\n\n``` js\n// e2e/dashboard.spec.ts\nimport { test, expect } from '@playwright/test';\n\ntest('dashboard loads', async ({ page }) => {\n  await page.goto('/dashboard');\n  await expect(page.getByRole('heading')).toBeVisible();\n});\n```\n\nIt imports nothing from your app. It navigates to a URL and communicates with the rendered page via the browser.\n\nSo when you change Button, that spec’s import graph never touches your diff, and `--only-changed`\n\nskips it. The dashboard renders Button, your change broke it, and your e2e job reports green.\n\nThe connection you need is not an import but the route.\n\nYour affected list from part 1 already contains the route files, because route entries sit at the top of your import tree. Change Button, and the walk climbs from Button to DashboardPage to `src/app/dashboard/page.tsx`\n\n, which is a URL you can name.\n\nComing the other way, each spec tells you which URLs it visits, because `page.goto`\n\ntakes them as literals you can read out of the source.\n\nSo we can build an `e2e-affected.mjs`\n\nscript file that handles this and can be used by the E2E job.\n\nChange Button.tsx in an app with four specs and you get three:\n\n``` bash\n$ BASE_REF=main node scripts/e2e-affected.mjs\ne2e/checkout.spec.ts\ne2e/dashboard.spec.ts\ne2e/settings.spec.ts\n```\n\nDashboard and Settings render Button. Checkout comes along because it walks through the `/dashboard`\n\npartway. Billing renders no Button, visits no affected route, and stays out.\n\nChange a Billing-only file instead, and the selection works as well.\n\n```\ne2e/billing.spec.ts\ne2e/checkout.spec.ts\n```\n\nHere is the full e2e job in the CI workflow:\n\n```\n  e2e:\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        shard: [1, 2, 3, 4]\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n          filter: blob:none\n      - uses: ./.github/actions/setup\n\n      - name: Read the installed Playwright version\n        run: |\n          VERSION=$(node -p \"require('@playwright/test/package.json').version\")\n          echo \"PLAYWRIGHT_VERSION=$VERSION\" >> \"$GITHUB_ENV\"\n\n      - id: browsers\n        uses: actions/cache@v4\n        with:\n          path: ~/.cache/ms-playwright\n          key: playwright-${{ runner.os }}-${{ env.PLAYWRIGHT_VERSION }}\n\n      - if: steps.browsers.outputs.cache-hit != 'true'\n        run: npx playwright install --with-deps chromium\n\n      - if: steps.browsers.outputs.cache-hit == 'true'\n        run: npx playwright install-deps chromium\n\n      # everything above is unchanged; the selection starts here\n      - id: specs\n        env:\n          BASE_REF: origin/${{ github.base_ref }}\n        run: |\n          SPECS=$(node scripts/e2e-affected.mjs | tr '\\n' ' ' | xargs)\n          echo \"list=$SPECS\" >> \"$GITHUB_OUTPUT\"\n\n      - name: No affected specs\n        if: steps.specs.outputs.list == ''\n        run: echo \"Nothing to test.\"\n\n      - name: Run affected specs\n        if: steps.specs.outputs.list != '' && steps.specs.outputs.list != 'ALL'\n        run: npx playwright test ${{ steps.specs.outputs.list }} --shard=${{ matrix.shard }}/4\n\n      - name: Run everything\n        if: steps.specs.outputs.list == 'ALL'\n        run: npx playwright test --shard=${{ matrix.shard }}/4\n\n      - uses: actions/upload-artifact@v4\n        if: always()\n        with:\n          name: blob-report-${{ matrix.shard }}\n          path: blob-report\n          retention-days: 7\n```\n\nJust to clarify. It’s still best practice to run the whole test suite on main regardless. This CI workflow is just for PRs to save time.\n\n## CI, part 3: fail before the PR exists\n\nSmarter and cheaper than running this in the PR is to run it locally first. This gives agents a fast feedback loop, and if your app is a million+ lines of code, it’s better to run locally anyway.\n\nNothing is stopping you. Everything in parts 1 and 2 is a plain Node script that reads a git diff.\n\nAn agent iterating on a failing typecheck will burn six push-and-wait cycles before you look up from whatever else you were doing.\n\nGive it one command to run.\n\n```\n// package.json\n{\n  \"scripts\": {\n    \"verify\": \"node scripts/lint-affected.mjs && node scripts/typecheck-affected.mjs && node scripts/test-affected.mjs\"\n  }\n}\n```\n\nSo `npm run verify`\n\nchains all three scripts, and on a change to Button, it looks like this:\n\n```\n> node scripts/lint-affected.mjs && node scripts/typecheck-affected.mjs && node scripts/test-affected.mjs\n\nlint: 7 files\ntypecheck: 7 files\ntest: 7 affected files\n\n RUN  v4.1.10\n\n ✓ src/components/ui/Button.test.tsx (1 test) 5ms\n ✓ src/features/dashboard/DashboardPage.test.tsx (1 test) 4ms\n\n Test Files  2 passed (2)\n      Tests  2 passed (2)\n   Duration  422ms\n```\n\nESLint ran on seven files, `tsc`\n\nbuilt a seven-file program, and Vitest ran the two specs that reach Button.\n\nThat is the entire local gate, and it is the same work CI does (minus the e2e, which needs a browser and takes time to run).\n\n### Wiring it into git\n\nLefthook installs the hooks for you, and Husky works the same way if you already have it.\n\n```\nnpm install --save-dev lefthook\nnpx lefthook install   # writes the hooks into .git/hooks\n```\n\nThen split the work by how much of it you can afford to wait for.\n\n```\n# lefthook.yml\npre-commit:\n  parallel: true\n  commands:\n    lint:\n      glob: '*.{ts,tsx}'\n      run: npx eslint {staged_files}\n    format:\n      glob: '*.{ts,tsx,css,md}'\n      run: npx prettier --write {staged_files}\n      stage_fixed: true\n\npre-push:\n  commands:\n    verify:\n      run: npm run verify\n```\n\nPre-commit stays on staged files only. `{staged_files}`\n\nexpands to exactly what is in the commit, so a one-file change lints one file, and `stage_fixed: true`\n\nre-stages whatever Prettier rewrites, so you do not commit the unformatted version.\n\nPre-push runs the real gate against the affected set rather than your whole app, which is the only reason a pre-push hook is tolerable at all.\n\n### The base ref problem, in reverse\n\nPart 1 broke in CI because the runner had no history. Locally, you have the opposite problem: your history is stale.\n\nYour `origin/main`\n\nis whatever it was when you last fetched.\n\nThe result is safe and slow, which is the correct direction to fail, but it makes the hook feel broken. Fetch first.\n\n```\npre-push:\n  commands:\n    verify:\n      run: git fetch origin main --quiet && npm run verify\n```\n\n### Agents will bypass this, so plan for it\n\nClaude Code and its peers know what `--no-verify`\n\ndoes. An agent blocked by a failing hook will sometimes reach for it, just as a person does at 6pm on a Friday.\n\nInstructions help, so write them. This lives in my CLAUDE.md:\n\n```\n## Definition of done\n\nA change is not done until this passes:\n\n    npm run verify\n\nRun it before every push. Fix what it reports.\n\nNEVER use --no-verify. NEVER edit or delete git hooks.\nIf a hook looks wrong, stop and ask rather than working around it.\n```\n\n## The shape of the whole thing\n\nPut the pieces in a line, and the pipeline looks like this.\n\nThe agent writes code and iterates against the local gate: lefthook running the affected lint, typecheck, and tests, the same commands CI will run.\n\nCI runs only the changed modules and their dependents, in parallel jobs, with Playwright running changed tests on the PR and the full sharded suite in the merge queue.\n\nWhether AI writes 27% of your code or 70% of it, the volume only goes one direction from here.\n\nNow you have a fast CI pipeline, next up is deploying correctly.\n\nStay tuned for part 2 next week.\n\n## References\n\n[Turborepo: Constructing CI](https://turborepo.dev/docs/crafting-your-repository/constructing-ci)and the`--affected`\n\nflag reference[Playwright: Sharding](https://playwright.dev/docs/test-sharding)and[Continuous Integration](https://playwright.dev/docs/ci)(including`--only-changed`\n\n)[Vercel: Rolling Releases](https://vercel.com/docs/rolling-releases)and the`vercel rolling-release`\n\nCLI reference[Cloudflare: Gradual deployments](https://developers.cloudflare.com/workers/configuration/versions-and-deployments/gradual-deployments/),[Rollbacks](https://developers.cloudflare.com/workers/configuration/versions-and-deployments/rollbacks/), and the[production safety launch post](https://blog.cloudflare.com/workers-production-safety/)[Netlify: Split Testing](https://docs.netlify.com/manage/monitoring/split-testing/)[Graphite CLI documentation](https://graphite.com/docs)— stacked PRs and the stack-aware merge queue[InfoQ: GitHub Targets Large Merge Problem with Stacked PRs](https://www.infoq.com/news/2026/04/github-stacked-prs/)— the`gh-stack`\n\nprivate preview[anthropics/claude-code-action](https://github.com/anthropics/claude-code-action)— Claude Code as a GitHub Action[Lefthook](https://github.com/evilmartians/lefthook)- Neciu Dan,\n[GitHub Actions Cache Poisoning is eating open source](https://neciudan.dev/github-actions-poisoning)and[How the Cline CI got compromised](https://neciudan.dev/cline-ci-got-compromised-here-is-how) - Adnan Khan,\n[The Monsters in Your Build Cache](https://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/)— the first cache poisoning research [zizmor](https://github.com/woodruffw/zizmor)— static analysis for GitHub Actions workflows\n\n### Discover more from The Neciu Dan Newsletter\n\nA weekly column on Tech & Education, startup building and occasional hot takes.\n\nOver 1,000 subscribers", "url": "https://wpnews.pro/news/front-end-ci-cd-in-the-age-of-ai", "canonical_source": "https://neciudan.dev/ci-cd-in-the-age-of-ai-part-1", "published_at": "2026-07-28 19:36:16+00:00", "updated_at": "2026-07-28 19:52:17.464644+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence"], "entities": ["Neciu Dan", "Google"], "alternates": {"html": "https://wpnews.pro/news/front-end-ci-cd-in-the-age-of-ai", "markdown": "https://wpnews.pro/news/front-end-ci-cd-in-the-age-of-ai.md", "text": "https://wpnews.pro/news/front-end-ci-cd-in-the-age-of-ai.txt", "jsonld": "https://wpnews.pro/news/front-end-ci-cd-in-the-age-of-ai.jsonld"}}