Scenario
Section titled “Scenario”A workflow that “worked yesterday” is failing. The log is long, the error is near the bottom, and it does not obviously relate to the change that triggered it.
The failures in this lab are chosen because each produces a misleading symptom. The skill being practised is reading the actual cause rather than pattern-matching on the message.
Objective
Section titled “Objective”Diagnose and fix four classes of CI failure, and build the habit of asking “what does this job actually need?” before changing anything.
Prerequisites
Section titled “Prerequisites”- A GitHub repository you can push to
- GitHub Actions fundamentals
- Understanding of workflow permissions
Starting state
Section titled “Starting state”Create the broken workflow:
mkdir -p .github/workflowscat > .github/workflows/broken.yml <<'EOF'name: Broken CIon: pull_request:
jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7
- name: Count changed files run: | git diff --name-only origin/main...HEAD | wc -l
- name: Comment on the PR run: gh pr comment "$PR" --body "Checked." env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.number }}EOFgit add . && git commit -m "Add CI" && git pushOpen a pull request against main. Both steps fail.
-
Read the first failure properly. “Count changed files” fails with something like
fatal: ambiguous argument 'origin/main...HEAD'. Predict the cause before reading on. -
Diagnose it. Add a diagnostic step temporarily:
- name: What do we have?run: |git log --oneline -5 || truegit branch -agit rev-parse --is-shallow-repositoryNote what
--is-shallow-repositoryreports. -
Fix it so the diff step works, without making the checkout needlessly expensive.
-
Now the second failure. “Comment on the PR” fails with a
403. The token exists, so why is it refused? -
Fix the permission, granting only what the job needs.
-
Introduce a third failure. Add a path filter so the job is skipped:
on:pull_request:paths: ['src/**']Make the job a required status check in branch protection, then open a PR touching only
README.md. Observe what happens to the pull request. -
Introduce a fourth. Add caching with a key that never changes:
- uses: actions/cache@v6with:path: node_moduleskey: cache-keyExplain why this is worse than no cache at all.
Step 1–3. actions/checkout defaults to fetch-depth: 1 — a shallow clone of one commit.
origin/main...HEAD needs a merge base, which requires history that is not there. The naive fix
is fetch-depth: 0; the better fix keeps the transfer small.
Step 4–5. The default GITHUB_TOKEN permission set may be read-only. Commenting needs
pull-requests: write. Grant it on the job, not the workflow, so other jobs stay read-only.
Step 6. A skipped job does not report its status. A required check that never reports leaves the pull request permanently “Expected — waiting for status to be reported”.
Step 7. A constant cache key means the first run’s cache is restored forever. Stale
node_modules produces failures that do not match the lockfile — and reruns do not clear it.
Solution
Section titled “Solution”name: CIon: pull_request:
permissions: contents: read
jobs: check: runs-on: ubuntu-latest permissions: contents: read pull-requests: write # only this job can comment steps: - uses: actions/checkout@v7 with: fetch-depth: 0 # full history for the merge base… filter: blob:none # …without downloading file contents
- name: Count changed files run: | git diff --name-only "origin/${{ github.base_ref }}...HEAD" | wc -l
- name: Comment on the PR run: gh pr comment "$PR" --body "Checked." env: GH_TOKEN: ${{ github.token }} PR: ${{ github.event.number }}Cache key that actually works:
- uses: actions/cache@v6 with: path: node_modules key: node-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}Required-check fix: either drop the path filter, or make the job always run and exit early — so it always reports a status:
- name: Skip when irrelevant if: ${{ !contains(steps.changed.outputs.all, 'src/') }} run: echo "No source changes"; exit 0Explanation
Section titled “Explanation”Shallow clones lack merge bases. fetch-depth: 1 is the right default for a build, and wrong
for anything comparing against a base branch. fetch-depth: 0 with filter: blob:none gives full
history at a fraction of the transfer — the combination most people miss.
Token permissions are least-privilege by design. A 403 from gh is usually the token
lacking a scope, not a broken token. Job-level permissions: is the correct granularity.
A skipped job reports nothing. This is the interaction between path filtering and required checks, and it blocks pull requests in a way that looks like a platform fault.
A constant cache key is a permanent stale cache. Cache keys must derive from the thing being cached — normally a lockfile hash.
The general lesson: CI failures frequently present as one thing and are caused by another.
“Ambiguous argument” was really “the history is not here”. 403 was really “this token was never
granted that”. Read what the job needs before changing what it does.
Related lessons
Section titled “Related lessons”Next lab
Section titled “Next lab”Secure an unsafe GitHub Actions workflow — from broken to dangerous.