The CI was green. Every single check passed.
I merged the PR with confidence. Three days later, a teammate pinged me: “Hey, there are TypeScript errors all over this file. Did you check this before merging?”
I had. Or I thought I had. The CI ran tsc --noEmit on every push. It was supposed to catch exactly this.
I opened the GitHub Actions logs. The TypeScript check showed “success.” But when I ran npx tsc --noEmit locally, errors screamed at me.
The CI had been lying to me. For weeks.
The Culprit: Two Characters That Break Everything
I found this in my workflow file:
- name: TypeScript Check
run: npx tsc --noEmit || echo "TypeScript check completed"
Do you see it?
That || echo at the end. It looks harmless. It’s catastrophic.
In shell scripting, || means “if the previous command fails, run this instead.” The echo command always succeeds. So the entire line always succeeds. Even when TypeScript finds 50 errors.
The exit code gets swallowed. GitHub Actions sees success. The check passes. Your broken code ships.
Why Developers Add This Pattern
I didn’t write this line randomly. I added it because:
- I wanted a “completed” message. Seemed helpful for debugging.
- I copied it from somewhere. Stack Overflow, a tutorial, another repo.
- It “worked” in testing. The workflow ran without errors.
The pattern feels right. It provides feedback. It handles edge cases gracefully.
Except it handles them too gracefully. It handles actual failures as gracefully as successes. Which defeats the entire point of CI.
Understanding GitHub Actions Shell Behavior
To understand why, you need to know how GitHub Actions handles shell commands.
By default, GitHub Actions runs bash with the -e flag: bash -e {0}. This means “exit immediately if any command returns a non-zero exit code.”
But there’s a catch. The -e flag gets bypassed when you use || or && operators. The shell considers these “handled” errors.
# This fails the step (good)
npx tsc --noEmit
# This NEVER fails the step (bad)
npx tsc --noEmit || echo "done"
The || operator tells bash: “I know this might fail, and I’m handling it.” Bash trusts you. It shouldn’t.
There’s another subtlety. The -o pipefail option, which fails pipelines when any command fails, is only enabled when you explicitly set shell: bash. The default shell doesn’t include it.
# Default: bash -e {0} (no pipefail)
- run: failing-command | tee output.txt # This might pass!
# Explicit: bash --noprofile --norc -eo pipefail {0}
- shell: bash
run: failing-command | tee output.txt # This fails correctly
This inconsistency has caused confusion across the GitHub community.
The Fix: Let Commands Fail Properly
The correct approach:
- name: TypeScript Check
run: |
echo "Running TypeScript check..."
npx tsc --noEmit
echo "TypeScript check passed"
The multi-line format (|) runs each command sequentially. If npx tsc --noEmit fails, the workflow fails. The final echo only runs if TypeScript passes.
No fallback. No error swallowing. Proper exit code propagation.
The Hidden Errors I Found
Once I fixed the CI, the floodgates opened. TypeScript errors that had been accumulating for weeks:
Error 1: Vitest config type mismatch
// vitest.config.ts - Before
export default getViteConfig({
test: { /* config */ }
});
// The Astro+Vitest integration has a known type incompatibility
// TypeScript was complaining, but CI never told me
Fix:
// vitest.config.ts - After
export default getViteConfig({
test: { /* config */ }
} as any); // Explicit type assertion for known incompatibility
Error 2: Mock function types in Vitest
// tests/export-issues.test.ts - Before
vi.mocked(fetchAllIssues).mockResolvedValue(mockIssues);
// Type error: mockResolvedValue doesn't exist on this type
The vi.mocked() helper is designed to give you proper TypeScript types for mocked functions. But the way you use it matters. Chaining directly can lose type information.
Fix:
// tests/export-issues.test.ts - After
const mockFetch = vi.mocked(fetchAllIssues);
mockFetch.mockResolvedValue(mockIssues);
// Assign to a variable first, then call mock methods
This pattern ensures TypeScript correctly infers the mock methods. The Vitest documentation recommends this approach for maintaining type safety.
These errors existed in production. The CI said everything was fine. It wasn’t.
Other Patterns That Silently Fail
The || echo trap isn’t unique. Watch out for these too:
Pattern 1: The True Fallback
# BAD - Always succeeds
run: npm test || true
# GOOD - Fails properly
run: npm test
Pattern 2: The Set +e Escape
# BAD - Disables error checking entirely
run: |
set +e
npm run lint
npm run test
# GOOD - Errors propagate
run: |
npm run lint
npm run test
Pattern 3: The Continue-on-Error Trap
# BAD - Step "fails" but workflow continues green
- name: Tests
run: npm test
continue-on-error: true
# GOOD - Only use continue-on-error for truly optional steps
- name: Optional Coverage Upload
run: npm run coverage:upload
continue-on-error: true # Actually optional
The continue-on-error trap is particularly insidious. When a step with continue-on-error: true fails, the step’s outcome is “failure” but its conclusion is “success.” The GitHub UI shows a green checkmark.
As one developer noted in a GitHub discussion: “If used at the step level, then everything appears green and the only way to notice the failure would be to manually open the step and carefully read the output.”
This makes continue-on-error barely better than || true for most use cases.
Pattern 4: The Grep False Positive
# BAD - grep returns 1 when no matches, failing the step
run: cat output.log | grep "error" || echo "No errors"
# GOOD - Handle grep's exit code properly
run: |
if grep -q "error" output.log; then
echo "Errors found!"
exit 1
fi
The Debugging Checklist
Before trusting your CI, verify:
-
Run the command locally. Does
npx tsc --noEmitactually pass on your machine? -
Introduce a deliberate error. Add
const x: string = 5;to a file. Does CI fail? -
Check for fallback patterns. Search your workflow files for
|| echo,|| true,set +e, andcontinue-on-error. -
Review the exit codes. GitHub Actions fails on non-zero exit codes. Make sure your commands return non-zero on failure.
The Complete Fix
My final workflow:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: TypeScript Check
run: |
echo "Running TypeScript type check..."
npx tsc --noEmit
echo "TypeScript check passed successfully"
- name: Lint
run: npm run lint
- name: Test
run: npm test
No fallbacks. No error swallowing. If TypeScript fails, CI fails. As it should.
The Impact
| Metric | Before Fix | After Fix |
|---|---|---|
| Hidden TypeScript errors | Unknown (at least 3) | 0 |
| False positive CI runs | Weeks worth | 0 |
| Confidence in CI | Low | High |
A problem that was silently breaking my codebase for weeks, finally caught and fixed.
This Problem Is More Common Than You Think
CI reliability issues plague teams of all sizes. A 2022 survey found that about half of software professionals experience flaky tests on a weekly basis. Even Google and Microsoft aren’t immune: roughly 41% of Google’s tests and 26% of Microsoft’s tests were discovered to be flaky.
The danger of “false positive” green builds is that they create a false sense of security. Teams stop trusting their CI. They start ignoring failures. They “just rerun” when something fails. Real bugs slip through.
One study found that 47% of failed CI jobs that were manually restarted succeeded on the second run. That’s not CI working. That’s CI becoming useless.
The || echo pattern I found is just one example of a broader problem: CI configurations that prioritize “green builds” over accurate feedback.
The Lesson
CI exists to catch problems before they ship. But CI only works if you let it fail.
Every time you add a fallback pattern, ask yourself: “Am I handling a legitimate edge case, or am I hiding failures?”
If the answer is “hiding failures,” delete the fallback. Let the command fail. Fix the underlying issue.
Green CI should mean working code. Not silenced errors.
Your Action Items
Right now:
- Open your GitHub Actions workflow files
- Search for
|| echo,|| true,set +e - If you find them, ask: should this really always succeed?
This week:
- Add a deliberate TypeScript error to your codebase
- Push to a branch and verify CI fails
- Remove the error once verified
Going forward: Your CI will tell you the truth. You just have to let it speak.
One Last Thing
If your CI has been green for months and you’ve never seen it fail, that’s not a sign of perfect code. That’s a sign your CI might not be checking anything.
Test your tests. Break your builds intentionally. Trust but verify.
The worst bugs are the ones your safety net was supposed to catch but didn’t.
Resources
For more on CI reliability and shell behavior:
- GitHub Docs: Setting Exit Codes for Actions
- How to Handle Step and Job Errors in GitHub Actions
- Shell Options in GitHub Actions Runner
- Vitest Mocking Guide
- Flaky Tests: How to Deal With Them
Found this helpful? Share it with a teammate whose CI is suspiciously green. They might have the same problem.