
I cut our CI validation time by 95%.
Not with caching. Not with sharding. Not with a fancy third-party tool that adds another dependency to your pipeline.
With 15 lines of bash. Zero dependencies. Zero supply chain risk.
The PR that shipped this change was smaller than most commit messages. And it saves the team 7-8 hours per month.
The Problem
Every PR ran the full CI pipeline. Backend linting, backend unit tests, frontend unit tests, E2E tests — the entire gauntlet. A developer changes a single E2E test file, pushes the branch, and waits 8 minutes and 18 seconds for backend linting to tell them their Python code is fine.
The Python code they didn’t touch.
This is the default behavior for most CI setups. A single workflow file, a single trigger, run everything on every push. It’s simple. It’s safe. It’s also burning developer time on validations that cannot possibly fail for the changes being made.
The Fix: 15 Lines of Bash
The idea is simple: use git diff --name-only to determine which files changed, then conditionally skip jobs that are irrelevant to those changes.
#!/bin/bash# detect-changes.sh — Determine which CI jobs to run based on changed files# Called by GitHub Actions workflow to set output variables
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
RUN_BACKEND=falseRUN_FRONTEND=falseRUN_E2E=false
for file in $CHANGED_FILES; do case "$file" in backend/*|api/*|*.py) RUN_BACKEND=true ;; frontend/*|src/*|*.ts|*.tsx) RUN_FRONTEND=true ;; tests/e2e/*|playwright/*) RUN_E2E=true ;; .github/*|docker-compose*|Makefile) # Infrastructure changes: run everything as a safety net RUN_BACKEND=true; RUN_FRONTEND=true; RUN_E2E=true ;; esacdone
echo "run-backend=$RUN_BACKEND" >> "$GITHUB_OUTPUT"echo "run-frontend=$RUN_FRONTEND" >> "$GITHUB_OUTPUT"echo "run-e2e=$RUN_E2E" >> "$GITHUB_OUTPUT"That’s it. The script reads the diff, classifies each changed file by directory, and sets output flags that downstream jobs check before running.
The GitHub Actions Workflow: Before and After
Before: Run Everything Always
# .github/workflows/ci.yml — BEFOREname: CIon: [push, pull_request]
jobs: backend-lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: pip install ruff - run: ruff check backend/
backend-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: pip install -r requirements.txt - run: pytest backend/tests/
frontend-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test
e2e-test: runs-on: ubuntu-latest needs: [backend-test, frontend-test] steps: - uses: actions/checkout@v4 - run: npm ci - run: npx playwright install --with-deps - run: npx playwright testEvery job runs on every PR. A typo fix in an E2E test file triggers backend linting, backend tests, frontend tests, and E2E tests. Total wall time: 8 minutes 18 seconds.
After: Run What’s Relevant
# .github/workflows/ci.yml — AFTERname: CIon: [push, pull_request]
jobs: detect-changes: runs-on: ubuntu-latest outputs: run-backend: ${{ steps.changes.outputs.run-backend }} run-frontend: ${{ steps.changes.outputs.run-frontend }} run-e2e: ${{ steps.changes.outputs.run-e2e }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # Full history for accurate git diff - id: changes run: bash .github/scripts/detect-changes.sh
backend-lint: needs: detect-changes if: needs.detect-changes.outputs.run-backend == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: pip install ruff - run: ruff check backend/
backend-test: needs: detect-changes if: needs.detect-changes.outputs.run-backend == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: pip install -r requirements.txt - run: pytest backend/tests/
frontend-test: needs: detect-changes if: needs.detect-changes.outputs.run-frontend == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm test
e2e-test: needs: detect-changes if: needs.detect-changes.outputs.run-e2e == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npx playwright install --with-deps - run: npx playwright testThe only addition is the detect-changes job (runs in ~5 seconds) and the if: conditionals on each downstream job. Everything else is identical.
The Results
| PR Type | Before | After | Savings |
|---|---|---|---|
| E2E-only changes | 8 min 18 sec | 39 sec | 95% |
| Frontend-only changes | 6 min 13 sec | 1 min 32 sec | 75% |
| Backend-only changes | 5 min 45 sec | 2 min 10 sec | 62% |
| Full-stack changes | 8 min 18 sec | 8 min 18 sec | 0% (runs everything) |
| CI/infra changes | 8 min 18 sec | 8 min 18 sec | 0% (safety net) |
Across a typical week — roughly 40% E2E-only PRs, 30% frontend-only, 20% backend-only, 10% full-stack — this projects to 7-8 hours per month of saved developer wait time.
The safety net is important: any change to .github/ workflows, Docker configurations, or the Makefile triggers the full pipeline. You never skip validation on the infrastructure that runs your validation.
Why Not Use a GitHub Action for This?
This is where the story gets interesting. I audited three popular GitHub Actions for path-based CI filtering before writing the bash script. What I found convinced me that zero dependencies was the right call.
Action 1: Phones Home to a Commercial API
One popular path-filtering action makes an API call to a commercial service on every CI run. Read that again: your CI pipeline phones home to a third party every time it runs. The action works correctly when the service is up and your subscription is active. When it’s not — and I found GitHub Issues from users reporting this — the action can hard-fail your build. Your CI pipeline now has an availability dependency on a SaaS product you don’t control.
Action 2: Supply Chain Attack Target (CVE-2025-30066)
In March 2025, the widely-used tj-actions/changed-files GitHub Action — used by over 23,000 repositories — was compromised in a supply chain attack. The attacker modified existing version tags to point to malicious code that dumped CI runner memory, exposing workflow secrets including API keys, tokens, and credentials. CISA issued a formal advisory (CVE-2025-30066). The attack was later traced to a compromised personal access token belonging to a bot account with repository write access.
This is not a theoretical risk. This happened. To an action that does exactly what my 15-line bash script does.
Action 3: Works Fine, But Unnecessary
The third action I evaluated is well-maintained, open-source, and has no known security issues. It works correctly. It also adds a dependency that needs to be version-pinned, monitored for updates, and periodically audited. For 15 lines of bash that do the same thing, the dependency isn’t justified.
The Security Argument for Zero Dependencies
Every GitHub Action you add to your workflow is code you execute with access to your repository secrets, your source code, and your CI environment. Each action is a trust decision. The tj-actions/changed-files incident demonstrated that even popular, widely-used actions can be compromised.
The bash script approach has a different security profile:
- No network calls. The script reads
git diffoutput. It doesn’t fetch anything, phone home to anything, or depend on anything outside the repository. - No third-party code execution. The script is checked into your repository and reviewed in the same PR process as your application code.
- No version pinning games. There’s no
@v4tag to worry about. No supply chain attack vector. The code is yours. - Full auditability. Anyone on the team can read 15 lines of bash and understand exactly what it does.
Sometimes the best dependency is no dependency.
Edge Cases and Safety Nets
A few things I handle that a naive implementation would miss:
New files without a clear category
# If a file doesn't match any known pattern, run everything.# Better to over-run than under-run.*) RUN_BACKEND=true; RUN_FRONTEND=true; RUN_E2E=true ;;The default case triggers a full run. An unrecognized file type should never silently skip validation.
Deleted files
git diff --name-only includes deleted files. A deleted backend file still needs the backend test suite to run — the deletion might break an import.
Merge commits
The fetch-depth: 0 in the checkout step is critical. Without full git history, the git diff against the base branch produces inaccurate results. I’ve seen CI setups with fetch-depth: 1 (the default) that miss changes because the shallow clone doesn’t contain the merge base.
What You Can Do Monday Morning
Measure your current baseline. What’s the average CI duration for your most common PR type? If you don’t know, check the last 20 workflow runs.
Categorize your CI jobs by directory. Which jobs correspond to which parts of the codebase? Most pipelines have a natural mapping.
Write the bash script. Start with the version above and adapt the directory patterns to match your repo structure.
Add the detect-changes job. Wire it into your workflow with
if:conditionals.Add the safety net. Any change to CI configuration, Docker files, or infrastructure scripts should trigger a full run. Always.
Measure the result. Compare the same PR types before and after. The numbers will make the case better than any argument.
The Broader Lesson
The CI pipeline is the most neglected infrastructure in most engineering organizations. Teams will spend weeks optimizing database queries that save 200ms per request, then watch every developer sit through 8 minutes of irrelevant CI validation 5 times a day without questioning it.
The fix here isn’t clever. It’s not novel. It’s 15 lines of bash that ask a simple question: did the changes in this PR actually affect the code this job validates?
If the answer is no, skip it. That’s not cutting corners. That’s respecting your team’s time.
15 lines. Zero dependencies. 95% faster. Sometimes the simplest solution is the one nobody tries because it feels too simple to work.
