Common Gotchas
Each of these has cost thousands of developers an afternoon. The pattern to notice: most aren't errors at all — they're workflows silently not running, or values silently absent, with no red X to warn you. Silence is the hard failure mode.
💡 The Bottom Line
When a workflow misbehaves: is the file on the branch that needs it (and valid YAML)? Is the trigger context what you think — fork PRs get no secrets? Is the working directory where you assume? Those three questions resolve most mysteries before deep debugging.
1. The workflow that never runs
No error, no run, nothing. The checklist in likelihood order: the file isn't in
.github/workflows/ at the repo root (a nested project folder doesn't count);
invalid YAML (check the Actions tab for a parse-error banner); the trigger doesn't cover the
branch you pushed (branches: [main], pushed to a feature branch); a
paths: filter excluded your change; or — for schedule and
workflow_dispatch — the file isn't on the default branch yet, which is
where those triggers read from.
2. Fork PRs: secrets vanish by design
A contributor forks your public repo, opens a PR — and your CI fails on their PR because
${{ secrets.X }} is empty. That's deliberate: if fork PRs got secrets, anyone
could open a PR whose "test" step exfiltrates them. Design around it: keep the default PR
checks secret-free (build + test need none), and put secret-needing steps behind
push to main or guard them with an
if: github.event.pull_request.head.repo.fork == false condition. Related trap:
pull_request_target does expose secrets — combining it with a checkout
of the fork's code is a known vulnerability pattern. Don't, unless you deeply know why.
3. Where am I? Working-directory assumptions
Every run: step starts in the repo root — each step, every time.
A cd frontend in one step does not carry into the next: steps are separate shell
invocations. For one step, use working-directory: frontend on that step; for a
whole job, set defaults: run: working-directory: frontend. The tell:
"no such file or directory" for a file you can see in the repo — you're just not standing
where you think.
4. The minutes bill
Three quiet multipliers on private-repo minutes: macOS runners cost 10×
Linux (use macos-latest only when you truly need it);
matrices multiply (3 versions × 3 OSes = 9 VMs per push); and
double-triggering (Lesson 3's push + pull_request overlap) doubles
everything. Also set timeout-minutes: on jobs — the default is 6 hours, and a
hung test will happily bill all 360 of them.
5. Old action versions and the deprecation wall
Actions pinned years ago (actions/upload-artifact@v1) eventually stop working
when GitHub retires the platform features they used — and the failure arrives without you
changing anything ("it broke on its own" usually means this). Skim deprecation warnings in
run logs (they appear long before removal), and let Dependabot watch your workflow files:
a few lines in .github/dependabot.yml with
package-ecosystem: github-actions opens PRs when your actions age.
🙋 There Are No Dumb Questions
Q: Can I run all this locally instead of debugging via push-wait-read-logs?
A: Partially. The act tool runs many workflows in local Docker containers —
great for fast YAML iteration, imperfect fidelity (some events and contexts differ). The
pragmatic combo: keep your pipeline's meat in scripts (npm test,
./build.sh) that run anywhere, keep the YAML a thin wrapper, and use a scratch
branch with workflow_dispatch when you must test the wrapper itself.
⚠️ Watch Out
A step that fails but shouldn't block everything (posting a comment, a metrics upload)
gets continue-on-error: true — but never on steps whose failure means
something (tests!). A test job with continue-on-error is a pipeline that lies
green — the CI/CD track's trust-erosion gotcha, self-inflicted with one line.
🔧 Try It Yourself
Prove the working-directory gotcha in your scratch repo:
jobs:
demo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: mkdir -p sub && cd sub && pwd
- run: pwd # back at the repo root — cd did not persist
- run: pwd
working-directory: sub # the right way
Read the three pwd outputs in the log — thirty seconds of proof that saves
the afternoon later.
✅ Quick Check
Why are secrets unavailable in workflows triggered by a fork's pull request?
Track complete — the recap gathers the whole toolkit, and points at continuous deployment as the natural next move.