Skip to content

Change Detection

Detect which components changed and skip unnecessary builds. This reduces CI time and resource usage in monorepos.


Overview

Change detection identifies modified files and maps them to components:

flowchart LR
    subgraph detection[Detection]
        Files[Changed Files] --> Categories[Categorize]
    end

    subgraph cascade[Cascade]
        Categories --> Contracts{Shared Code?}
        Contracts -->|yes| Backend[Backend]
        Contracts -->|yes| Frontend[Frontend]
        Categories --> Backend
        Categories --> Frontend
    end

    subgraph build[Build]
        Backend --> BuildB[Build Backend]
        Frontend --> BuildF[Build Frontend]
    end

    %% Ghostty Hardcore Theme
    style Files fill:#65d9ef,color:#1b1d1e
    style Categories fill:#fd971e,color:#1b1d1e
    style Contracts fill:#9e6ffe,color:#1b1d1e
    style Backend fill:#a7e22e,color:#1b1d1e
    style Frontend fill:#a7e22e,color:#1b1d1e
    style BuildB fill:#a7e22e,color:#1b1d1e
    style BuildF fill:#a7e22e,color:#1b1d1e

Using tj-actions/changed-files

The tj-actions/changed-files action provides file-based change detection with YAML configuration:

jobs:
  detect-changes:
    name: Detect Changes
    runs-on: ubuntu-latest
    outputs:
      contracts_changed: ${{ steps.changes.outputs.contracts_any_changed }}
      backend_changed: ${{ steps.changes.outputs.backend_any_changed }}
      frontend_changed: ${{ steps.changes.outputs.frontend_any_changed }}
      charts_changed: ${{ steps.changes.outputs.charts_any_changed }}
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Required for change detection
      - name: Detect changed paths
        id: changes
        uses: tj-actions/changed-files@v45
        with:
          files_yaml: |
            contracts:
              - packages/contracts/**
              - package.json
              - package-lock.json
            backend:
              - packages/backend/**
            frontend:
              - packages/frontend/**
            charts:
              - charts/**

Output Naming Convention

With files_yaml, outputs follow the pattern <category>_any_changed:

YAML Category Output Variable
contracts: contracts_any_changed
backend: backend_any_changed
charts: charts_any_changed

fetch-depth: 0

Change detection requires commit history. Always set fetch-depth: 0 or a value large enough to include the base commit.


Cascade Dependencies

Shared code (like contracts or types) should trigger rebuilds of dependent components:

outputs:
  # Direct changes
  contracts_changed: ${{ steps.changes.outputs.contracts_any_changed }}
  backend_changed: ${{ steps.changes.outputs.backend_any_changed }}
  frontend_changed: ${{ steps.changes.outputs.frontend_any_changed }}

  # Cascade: contracts changes trigger dependent builds
  backend_needs_build: >-
    ${{ steps.changes.outputs.contracts_any_changed == 'true' ||
        steps.changes.outputs.backend_any_changed == 'true' }}
  frontend_needs_build: >-
    ${{ steps.changes.outputs.contracts_any_changed == 'true' ||
        steps.changes.outputs.frontend_any_changed == 'true' }}

  # Any Node.js code changed (for shared test step)
  any_node_changed: >-
    ${{ steps.changes.outputs.contracts_any_changed == 'true' ||
        steps.changes.outputs.backend_any_changed == 'true' ||
        steps.changes.outputs.frontend_any_changed == 'true' }}
flowchart TD
    subgraph detection[Direct Detection]
        CC[Contracts Changed?]
        BC[Backend Changed?]
        FC[Frontend Changed?]
    end

    subgraph cascade[Cascade Outputs]
        BNB[backend_needs_build]
        FNB[frontend_needs_build]
        ANC[any_node_changed]
    end

    CC -->|yes| BNB
    CC -->|yes| FNB
    CC -->|yes| ANC
    BC -->|yes| BNB
    BC -->|yes| ANC
    FC -->|yes| FNB
    FC -->|yes| ANC

    %% Ghostty Hardcore Theme
    style CC fill:#fd971e,color:#1b1d1e
    style BC fill:#fd971e,color:#1b1d1e
    style FC fill:#fd971e,color:#1b1d1e
    style BNB fill:#a7e22e,color:#1b1d1e
    style FNB fill:#a7e22e,color:#1b1d1e
    style ANC fill:#65d9ef,color:#1b1d1e

Conditional Job Execution

Use the needs and if keywords to conditionally run jobs:

jobs:
  detect-changes:
    # ... detection job from above

  test:
    name: Test Node Packages
    needs: detect-changes
    if: needs.detect-changes.outputs.any_node_changed == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

  build-backend:
    name: Build Backend
    needs: [detect-changes, test]
    if: |
      always() && !cancelled() &&
      needs.detect-changes.outputs.backend_needs_build == 'true' &&
      (needs.test.result == 'success' || needs.test.result == 'skipped')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: buildah bud -f Containerfile.backend .

The always() Pattern

When jobs depend on other jobs that might be skipped, use always() && !cancelled():

if: |
  always() && !cancelled() &&
  needs.detect-changes.outputs.backend_needs_build == 'true' &&
  (needs.test.result == 'success' || needs.test.result == 'skipped')
Condition Purpose
always() Run regardless of dependency status
!cancelled() Don't run if workflow was cancelled
result == 'success' Dependency completed successfully
result == 'skipped' Dependency was skipped (acceptable)

Why Both Checks?

Without always(), a job with skipped dependencies won't run. Without the result checks, a job would run even if dependencies failed.


Summary Job

Add a summary job for branch protection status checks:

build-status:
  name: Build Status
  runs-on: ubuntu-latest
  needs: [detect-changes, test, build-backend, build-frontend, helm-charts]
  if: always()
  steps:
    - name: Check build results
      run: |
        echo "## Build Summary" >> $GITHUB_STEP_SUMMARY
        echo "" >> $GITHUB_STEP_SUMMARY
        echo "| Component | Changed | Result |" >> $GITHUB_STEP_SUMMARY
        echo " | ----------- |--------- | -------- |" >> $GITHUB_STEP_SUMMARY
        echo "| Backend | ${{ needs.detect-changes.outputs.backend_needs_build }} | ${{ needs.build-backend.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY
        echo "| Frontend | ${{ needs.detect-changes.outputs.frontend_needs_build }} | ${{ needs.build-frontend.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY
        echo "| Charts | ${{ needs.detect-changes.outputs.charts_changed }} | ${{ needs.helm-charts.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY

    - name: Fail if any required job failed
      if: |
        needs.test.result == 'failure' ||
        needs.build-backend.result == 'failure' ||
        needs.build-frontend.result == 'failure' ||
        needs.helm-charts.result == 'failure'
      run: exit 1

Configure branch protection to require the Build Status job. This single check aggregates all component results.


Force Rebuild

Add a workflow_dispatch input for manual rebuilds:

on:
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      force_all:
        description: "Force build all components"
        type: boolean
        default: false

jobs:
  detect-changes:
    outputs:
      backend_needs_build: >-
        ${{ inputs.force_all == true ||
            steps.changes.outputs.backend_any_changed == 'true' }}

Common Patterns

Root Package Files

Include root-level dependency files in shared detection:

files_yaml: |
  contracts:
    - packages/contracts/**
    - package.json           # Root package.json
    - package-lock.json      # Lockfile changes affect all
    - tsconfig.json          # TypeScript config

Infrastructure Changes

Separate infrastructure from application code:

files_yaml: |
  crossplane:
    - infrastructure/**/*.yaml
    - compositions/**/*.yaml
  kubernetes:
    - k8s/**/*.yaml
    - charts/**
  application:
    - src/**

Documentation Only

Skip builds for documentation-only changes:

outputs:
  docs_only: >-
    ${{ steps.changes.outputs.docs_any_changed == 'true' &&
        steps.changes.outputs.code_any_changed != 'true' }}

Troubleshooting

Issue Cause Solution
All jobs skip Wrong output name Use <category>_any_changed pattern
Cascade not working Missing OR condition Check cascade logic includes shared dependencies
Dependent job fails Skipped dependency Add always() && !cancelled() pattern
No files detected Shallow clone Set fetch-depth: 0

Next Steps

Comments