Skip to main content

Replacing a GitHub Action with 15 Lines of Shell

A funnel filtering and sorting items
Apr 7, 20262 min readGitHub Actions, CI/CD, Bash

The silent failure

I wanted one 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.

I configured it, tested it, and it never worked: docs-only PRs ran the full CI suite every single time, with no error, no warning, and no hint that anything was off.

The cause turned out to be two bugs compounding. First, paths-filter doesn't support the predicate-quantifier option I'd set, and it silently ignores it. Second, its glob matching failed to match .claude/** files, so my Claude Code config changes were never classified as docs-only.

I burned three commits fighting the configuration: adjusting globs, adding predicate-quantifier, inverting the pattern logic. None of it worked, because the underlying Action just wasn't behaving the way it was documented.

The shell replacement

The whole paths-filter dependency was really doing one job: deciding whether the PR's files were 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, via 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 out of the loop.

The gate job

The ci-status gate job is what makes this play nicely 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 on a docs-only PR, ci-status still runs and passes; when ci fails, ci-status fails right alongside it. The gate job is always green or red and never skipped, so branch protection just works.

The nx affected optimization

A related change rode along in the same PR cluster: nx affected was always diffing against main, even for feature branches targeting develop, so every feature-branch CI run compared against main and rebuilt more than it needed to.

The fix passes ${{ github.base_ref || 'main' }} as the --base flag, so PRs into develop now diff against develop while pushes to develop still diff against main. One line, and a pile of unnecessary rebuilds gone.

The takeaway

Third-party Actions are dependencies, and they can silently break, silently ignore your configuration, and silently change behavior between versions. For simple file-classification logic, shell is more transparent, easier to debug, and carries no upstream risk. Save the Actions for when they genuinely cut complexity: caching, deployment orchestration, multi-platform matrices. "Classify files against a list of patterns" is exactly what shell's case already does natively.