Skip to main content

Replacing a GitHub Action with 15 Lines of Shell

Apr 7, 20262 min readGitHub Actions, CI/CD, Shell, DevOps

The Silent Failure

We wanted a simple optimization: skip CI when a pull request only touches documentation files (.md, .claude/*, .vscode/*, workflow files). The standard approach is dorny/paths-filter, a GitHub Action that checks changed files against glob patterns.

We configured it, tested it, and it never worked. Docs-only PRs ran the full CI suite every time. No error, no warning, no indication that anything was wrong.

The cause was two bugs compounding. First, paths-filter does not support the predicate-quantifier option we set. It silently ignores it. Second, its glob matching failed to match .claude/** files, so our Claude Code config changes were never classified as docs-only.

We spent three commits trying to fix the configuration: adjusting globs, adding predicate-quantifier, inverting the pattern logic. None of it worked because the underlying Action was not behaving as documented.

The Shell Replacement

The entire paths-filter dependency was doing one thing: classifying PR files as docs-only or not. A case statement does that in 15 lines:

- name: Check for docs-only changes
  id: check
  env:
    GH_TOKEN: ${{ github.token }}
  run: |
    if [ "${{ github.event_name }}" = "pull_request" ]; then
      CHANGED=$(gh pr view ${{ github.event.pull_request.number }} \
        --json files --jq '.files[].path')
    else
      echo "docs-only=false" >> "$GITHUB_OUTPUT"
      exit 0
    fi
 
    DOCS_ONLY=true
    while IFS= read -r file; do
      [ -z "$file" ] && continue
      case "$file" in
        *.md) ;;
        .claude/*) ;;
        .mcp.json) ;;
        .vscode/*) ;;
        .github/ISSUE_TEMPLATE/*|.github/prompts/*) ;;
        .github/skills/*|.github/agents/*|.github/workflows/*) ;;
        .husky/*) ;;
        .prettierrc|.nvmrc|.sentryclirc|.editorconfig|.nxignore|LICENSE) ;;
        *) DOCS_ONLY=false; break ;;
      esac
    done <<< "$CHANGED"
 
    echo "docs-only=$DOCS_ONLY" >> "$GITHUB_OUTPUT"

Push events to develop always run CI (the early exit). For PRs, the gh CLI fetches the file list and the case statement classifies each one. The first non-docs file sets DOCS_ONLY=false and breaks.

The Gate Job

The ci-status gate job makes this work with branch protection:

ci-status:
  if: always()
  needs: [changes, ci]
  runs-on: ubuntu-latest
  steps:
    - run: |
        if [ "${{ needs.ci.result }}" = "failure" ] || \
           [ "${{ needs.ci.result }}" = "cancelled" ]; then
          echo "CI failed"
          exit 1
        fi
        echo "CI passed or was skipped (docs-only change)"

Branch protection requires ci-status, not ci. When the ci job is skipped (docs-only PR), ci-status will run and pass. When ci fails, ci-status fails too. The gate job is always green or red, never skipped, so branch protection just works.

The nx affected Optimization

A related change in the same PR cluster: nx affected was always diffing against main, even for feature branches targeting develop. This meant every feature branch CI run compared against main and rebuilt more than necessary.

The fix passes ${{ github.base_ref || 'main' }} as the --base flag. PRs into develop now diff against develop. Pushes to develop still diff against main. One line, fewer unnecessary rebuilds.

The Takeaway

Third-party Actions are dependencies. They can silently break, silently ignore configuration, and silently change behavior between versions. For simple file classification logic, shell is more transparent, more debuggable, and has no upstream risk. Reserve Actions for when they genuinely reduce complexity — caching, deployment orchestration, multi-platform matrices. "Classify files against a list of patterns" is exactly what shell's case does natively.