Skip to content

Lab: Debug a Failing GitHub Actions Workflow

Lesson 4 of 4Intermediate3 min readHands-On Git & GitHub Labs · CI/CD Labs
Time25 minutes
LevelIntermediate
You needA GitHub repository you can push to (a private scratch repo is ideal)

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.

Diagnose and fix four classes of CI failure, and build the habit of asking “what does this job actually need?” before changing anything.

Create the broken workflow:

Terminal window
mkdir -p .github/workflows
cat > .github/workflows/broken.yml <<'EOF'
name: Broken CI
on:
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 }}
EOF
git add . && git commit -m "Add CI" && git push

Open a pull request against main. Both steps fail.

  1. Read the first failure properly. “Count changed files” fails with something like fatal: ambiguous argument 'origin/main...HEAD'. Predict the cause before reading on.

  2. Diagnose it. Add a diagnostic step temporarily:

    - name: What do we have?
    run: |
    git log --oneline -5 || true
    git branch -a
    git rev-parse --is-shallow-repository

    Note what --is-shallow-repository reports.

  3. Fix it so the diff step works, without making the checkout needlessly expensive.

  4. Now the second failure. “Comment on the PR” fails with a 403. The token exists, so why is it refused?

  5. Fix the permission, granting only what the job needs.

  6. 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.

  7. Introduce a fourth. Add caching with a key that never changes:

    - uses: actions/cache@v6
    with:
    path: node_modules
    key: cache-key

    Explain 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.

name: CI
on:
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 0

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.

Secure an unsafe GitHub Actions workflow — from broken to dangerous.

Choose a learning pathA sequenced route through the curriculum for wherever you are now.