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.
