DEV Community

Sho Naka
Sho Naka

Posted on

My GitHub Actions run was green. It deployed nothing.

I opened the production URL and got a 404. The cron that should have published that morning had finished with success.

Green means it didn't crash. It doesn't mean it ran.

TL;DR: GitHub Actions marks a run success when nothing raises failure — and a guard that silently skips on missing config exits 0, same as a guard that ran correctly. Watch the artifact your job was supposed to produce, not the checkmark.

You probably have a workflow like this too — something on a schedule, and a row of green checkmarks in the Actions tab that you glance at and trust. That glance is only half right. GitHub Actions marks a run success the moment every step either does its job or quietly exits 0, and those two look identical in the run list. Skips exit 0 too.

Can you say when the workflow that's green in your repo right now last actually did something? If you can't, keep reading.

Quick answer: GitHub Actions succeeded but did nothing — why, and how to catch it

  • success means "no step reported failure." It does not mean the intended work ran.
  • A guard that does nothing and exits 0 on missing config produces the exact same green as a guard that ran correctly. You cannot tell them apart from the run list.
  • Fix: replace silent no-ops with ${VAR:?message} (or an equivalent explicit check), so missing configuration fails loud while legitimately nothing to do today still exits 0.
  • That's not the whole fix. A gate can look like it's checking the right thing and still never read the one signal that would prove the work happened — I found exactly that bug in my own patch while writing this post.
  • The only reliable signal is the artifact the job was supposed to produce — not the run's conclusion column.

I spent the first ten minutes in the wrong place. My own status script was reporting that the site needed a redeploy, so I went digging into the static site build. That wasn't it. The publish job had never contacted the deploy target at all.

What the guard actually did

A scheduled workflow publishes queued posts every weekday morning:

on:
  schedule:
    - cron: "0 21 * * 0-4"   # 21:00 UTC = 06:00 next day, Mon–Fri (JST)
Enter fullscreen mode Exit fullscreen mode

The log for that run contained one line of interest:

##[notice]WEBSITE_OWNER/WEBSITE_REPO/WEBSITE_RECEIVE_WORKFLOW not set. skipping publish
Enter fullscreen mode Exit fullscreen mode

Here is the step that printed it. I wrote it to be safe:

- name: Validate required vars (no-op if missing)
  id: gate
  run: |
    if [ -z "${WEBSITE_OWNER}" ] || [ -z "${WEBSITE_REPO}" ] || [ -z "${WEBSITE_RECEIVE_WORKFLOW}" ]; then
      echo "::notice::WEBSITE_OWNER/WEBSITE_REPO/WEBSITE_RECEIVE_WORKFLOW not set. skipping publish"
      echo "enabled=false" >> "$GITHUB_OUTPUT"
    else
      echo "enabled=true" >> "$GITHUB_OUTPUT"
    fi
  env:
    WEBSITE_OWNER: ${{ vars.WEBSITE_OWNER }}
    WEBSITE_REPO: ${{ vars.WEBSITE_REPO }}
    WEBSITE_RECEIVE_WORKFLOW: ${{ vars.WEBSITE_RECEIVE_WORKFLOW }}
Enter fullscreen mode Exit fullscreen mode

Every step after it carries if: steps.gate.outputs.enabled == 'true'.

The whole bug lives in the gap between three words, so it's worth being exact:

  • The guard step ran and exited 0.
  • Every step after it never executed. They have no exit code at all — their conclusion is skipped.
  • No step reported failure, so the job's conclusion is success.

success doesn't say the work happened. It says no step raised its hand.

Actions expressions turn an undefined vars.FOO into an empty string, so -z catches it either way. "I forgot to set this in production" and "this is deliberately blank" are the same input to that if.

What "success" actually promised

A skipped step isn't a failure. Something that isn't a failure doesn't fail the job. A job that doesn't fail is green. Nobody has a reason to reopen a green run — nothing looks wrong, so nothing prompts a second look either.

::notice:: does render as an annotation on the run summary, for what it's worth. It wasn't hidden. I just had no reason to look, because the list was green, and green is the state that makes you stop looking.

What actually stopped this one was noticing the production site was missing a post — not any system built to notice. If I hadn't happened to check, this could have run green for weeks. Six clean runs followed by catching the seventh isn't evidence the design was safe. It's evidence I got lucky about when I happened to look.

You cannot tell a good green from a bad green

The six scheduled runs before this one were also green, and those six actually deployed. I couldn't tell them apart by looking at them. I had to open the receiving repository and check whether its workflow fired within a minute of each run.

The change nobody reviewed

The guard shipped inside a PR titled "Confirm 8 posts for publish + apply the byline design." Merged the previous afternoon. The 404 was the next morning. The diff touched 96 files, and nearly all of them were post bodies and drafting docs — the delivery-workflow change rode along inside it.

That PR contained two things. One was a perfectly good refactor — pulling a hardcoded destination out into repository variables:

-          owner: <org>
-          repositories: <repo>
+          owner: ${{ vars.WEBSITE_OWNER }}
+          repositories: ${{ vars.WEBSITE_REPO }}
Enter fullscreen mode Exit fullscreen mode

The other was the guard, with its intent written out in a comment:

# no-op if WEBSITE_OWNER / WEBSITE_REPO are unset (to suppress publish in test environments)
Enter fullscreen mode Exit fullscreen mode

Which is a reasonable thing to want. Don't let a test repo push to the production destination.

Then I didn't set the variables in production. I wrote the workflow, I merged it, and I forgot.

Would splitting the PR have saved me? Probably not. A diff can show you that the workflow now reads vars.WEBSITE_OWNER. A diff cannot show you that WEBSITE_OWNER is empty in the repo settings. And if I'd reviewed that same PR myself, I think I would have skipped the workflow diff too — a title that announces "this is about publishing posts" is the only thing it makes you go looking for. Nothing in it hinted the workflow was touched at all.

Making it loud

The fix is smaller than the guard it replaced. Bash already has this:

- name: Require config (fail loud)
  run: |
    : "${WEBSITE_OWNER:?vars.WEBSITE_OWNER is not set}"
    : "${WEBSITE_REPO:?vars.WEBSITE_REPO is not set}"
    : "${WEBSITE_RECEIVE_WORKFLOW:?vars.WEBSITE_RECEIVE_WORKFLOW is not set}"
  env:
    WEBSITE_OWNER: ${{ vars.WEBSITE_OWNER }}
    WEBSITE_REPO: ${{ vars.WEBSITE_REPO }}
    WEBSITE_RECEIVE_WORKFLOW: ${{ vars.WEBSITE_RECEIVE_WORKFLOW }}
Enter fullscreen mode Exit fullscreen mode

${VAR:?msg} exits non-zero and prints msg when the variable is empty. The step fails, the job fails, the run goes red.

state before after
config present success (deploys) success (deploys)
config missing success (does nothing) failure (red)

The happy path is untouched. The unhappy path stopped being quiet.

I put this at the top of the cron that already runs every weekday, which means I didn't add a monitor. A monitor is one more thing that can stop without telling you. (If you need this across many workflows, script it — I keep a small checker that reads a declared list of required secrets and vars and exits 1 with the missing names. For one workflow, :? is the whole fix.)

"But sometimes a no-op is correct"

It is. Skipping when a fork PR can't see your secrets is right.

The defect wasn't the no-op, it was the condition. The variable is missing is not evidence that this is a test environment — forgetting to set it in production produces exactly the same shape.

So the repaired workflow still does nothing sometimes. It just distinguishes two kinds of nothing:

gate condition result
Gate 1 required config missing failure — this can never be intentional
Gate 2 nothing queued to publish today success, skip — the ordinary case

If you want a test repo to stay quiet, make it say so out loud (PUBLISH_DISABLED=true) and fail when nothing says anything.

The fix had the same bug

While writing this post, I went and checked whether Gate 2 actually judges "nothing queued today." Here's what it does:

TODAY=$(TZ='Asia/Tokyo' date +%Y-%m-%d)
for f in posts/*.md; do
  base=$(basename "$f")
  fdate="${base:0:10}"                                   # first 10 chars of the filename
  case "$fdate" in [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;; *) continue ;; esac
  if [[ "$fdate" > "$TODAY" ]]; then continue; fi        # future-dated files don't count
  if ! grep -q '^draft: true' "$f"; then TARGET="$f"; break; fi   # first match wins
done
[ -n "$TARGET" ] && echo "has_target=true"  >> "$GITHUB_OUTPUT" \
                 || echo "has_target=false" >> "$GITHUB_OUTPUT"
Enter fullscreen mode Exit fullscreen mode

It looks at the date in the filename and a draft flag. It never once looks at the state of the destination. So the question this gate is actually answering isn't "is there a post to publish today" — it's "does posts/ contain at least one past-dated file that isn't draft: true."

Published posts stay in posts/ forever, so has_target=false only happens if every file is future-dated or every past-dated file is a draft. I counted the real numbers to check:

$ ls posts/*.md | wc -l
     117          # .md files in posts/, including one template (116 real posts)

$ # "past-dated AND not draft:true" = eligible to publish
     70

$ ls ../website/src/content/blog/*.md | wc -l
      69          # what my local checkout of the destination had
Enter fullscreen mode Exit fullscreen mode

I'll admit the last number up front: 69 was stale. Cross-checking later against the destination repo's real commit history, a bulk sync had landed a few hours before this check — the live count was already 117, and my local checkout just hadn't pulled it. I thought I was "counting real data," and I was counting old real data. I made the exact mistake I was diagnosing, inside the diagnosis.

It doesn't change the finding, though — not because the gap was small, but because this gate's flaw doesn't depend on the number at all. Whether the gap is one post or zero, this gate never counts unpublished posts. It counts whether posts/ is non-empty. Clear the entire queue, and as long as one stale post from April is still sitting in posts/, the gate keeps returning the same has_target=true it always does.

And the "intentional skip" I put in the table above — the green stop that's supposed to happen when the queue is legitimately empty — never actually happens in this gate. If the upstream sync breaks and the queue silently empties, it returns the identical green as a healthy "nothing scheduled today."

It turned out the thing actually deciding "nothing to do" wasn't this gate at all — it was downstream, in the workflow that receives the dispatch:

- name: Check for changes
  id: changes
  run: |
    git add src/content/blog/
    if git diff --staged --quiet; then
      echo "changed=false" >> $GITHUB_OUTPUT
      echo "ℹ️  No diff. Skipping"
    else
      echo "changed=true" >> $GITHUB_OUTPUT
    fi
Enter fullscreen mode Exit fullscreen mode

No diff, green stop. Nothing was queued upstream, the checkout came up empty, or the sync script failed to pick anything up — this green can't tell you which. I hadn't fixed this one either by the time I sat down to write this sentence.

I'd split the guard into two, put an "intentional skip" label on one half, and never checked whether its false branch was actually reachable. The label made me feel like I'd already told the two cases apart. That's the identical shape as the incident at the top of this post.

Fail-loud shouldn't apply only to a missing variable. Every branch that reports "did nothing" has to be able to prove why nothing was the correct call. This gate can't say "I read the destination and confirmed zero posts were missing," because it never reads the destination. You can't prove nothing happened by inspecting internal state. You have to look at what's outside: the artifact.

Both gaps above got closed the same night I finished this draft — Gate 2 now diffs against the destination's real file list instead of trusting posts/, and the downstream step verifies the files it expected to sync actually landed. By the time you're reading this, both fixes are live.

The hole this leaves

Fail-loud only catches failures that run. If the cron never fires, there is no red and no green.

That's not hypothetical here. My schedule says 0 21 * * 0-4. The last seven runs actually started at 21:41, 21:49, 21:55, 22:00, 22:06, 22:09 and 22:16 UTC. Not one of them on time. GitHub's scheduler is best-effort: runs get delayed under load, and can be dropped. In public repos, scheduled workflows are also auto-disabled after 60 days with no repository activity.

The standard answer is a dead man's switch — Healthchecks.io, Dead Man's Snitch, absent() in Prometheus. I haven't adopted one, because it's an external dependency I'd then have to keep alive, and the point of the fix above was to avoid adding infrastructure that can itself go quiet.

You can test tomorrow without waiting for it

After the fix, one question was still open: would it actually run correctly tomorrow morning? I could wait for the next cron and check the production URL again — and if it was still broken, that's one more post that didn't ship while I waited to find out.

The sync script accepted BUILD_DATE as an override for "today" in JST, so I ran it locally with tomorrow's date instead:

$ BUILD_DATE=2026-07-15 python3 sync-blog.py ./posts ./src/content/blog
sync cutoff date (JST): 2026-07-15
SKIP (draft:true): ...
SKIP (future:2026-07-16 > 2026-07-15): ...
SYNC: 2026-07-15-<post-slug>.md
✅ 1 change
changed=true
Enter fullscreen mode Exit fullscreen mode

Without ever triggering the real production run, I reproduced exactly what tomorrow's execution would pick up and drop. Tomorrow's post was in scope, and nothing existing got deleted (DELETE count: 0). The only thing I couldn't rehearse locally was whether the schedule actually fires at the appointed time — that still needed one look at the production URL the next morning.

This only worked because "today" was designed as an input you can inject from outside. A datetime.now() call baked directly into a script can be frozen too, with a mock — but that means reaching into production code for the sake of a test, not something you can run on a whim the moment you think of it.

What you want to verify How to avoid waiting What it requires
What tomorrow's run will pick up Re-run locally with the date overridden "Today" is an externally injectable input
What happens when config is missing Empty the variable, check the exit code The check is a separable script, not buried in a bigger job

Green while it was already broken

As long as I believed "green means it's running," I never asked what green actually guaranteed. In practice, all it guaranteed was that nothing had thrown an exception.

The trigger was a single config value I forgot to set in production. If the run had simply gone red, the fix would have been one line. What amplified it was a fallback that quietly did nothing when that variable was missing, dressing the mistake up as a harmless state. What hid it was exit 0, indistinguishable from a healthy run to anything watching. What delayed discovery was the fact that the change rode inside a PR about publishing posts, not a PR about workflows.

Mistakes are unavoidable. What's avoidable is a mistake staying quiet.

And I didn't notice Gate 2 wasn't judging anything until I reread my own fix to write this post. The person who wrote fail-loud had placed a brand-new silent skip right next to it. The green you distrust has to include code you wrote today.

Switching to fail-loud, and rehearsing tomorrow by injecting a date instead of waiting for it, look like two different fixes. They do the same thing: they shrink the time between something breaking and you finding out — from "whenever a human happens to remember to check" to "tomorrow at 6am," and then to "right now." Once you stop trusting green, what's left is refusing to let the passage of time be the thing that tells you whether something is broken.

A silent bug and a silent healthy day produce the identical green from the outside. Staring harder at the conclusion column doesn't separate them. There's only one way: not whether it failed, but whether the artifact actually showed up.

You don't have to fix everything today. Pick one of your own scheduled workflows, open its most recent run, and look past the conclusion column at the artifact. If it publishes posts, check production. If it's a batch job, check the record it was supposed to touch. Confirm that one thing actually moved, outside of green. That's enough to tell you whether your repo has the same hole mine did.

Copy-paste checklist: audit your own workflows before you publish

# guards that can swallow the real work and still leave the run green
grep -rn "enabled=false\|continue-on-error:\s*true" .github/workflows/

# steps gated on an output a guard can flip
grep -rn "if: steps\..*outputs\." .github/workflows/
Enter fullscreen mode Exit fullscreen mode

For each hit, ask one question: can the code tell "deliberately disabled" apart from "someone forgot to configure it"? If both take the same branch, your pipeline can die the same way mine did.

If you're thinking of moving the guard up to the job level: a job whose if is false gets conclusion skipped, and the run shows skipped only when every job skips. Mine was a single-job workflow, which is why that was even visible. Add a gate job that computes the condition and succeeds, and the run is green again. Everywhere else, the guard still has to fail.

FAQ

Why not just add set -e to the workflow?

set -e stops a script at the first command that returns non-zero. It doesn't help here — the no-op branch was never an error. echo "enabled=false" exits 0 on purpose. set -e only catches failures that already look like failures.

Doesn't a required status check catch this?

Only if the check itself would go red. A required check enforces that a job succeeded; it can't tell you the job's success was empty. The bug here is inside what counts as success, not whether a required check ran at all.

What if my shell doesn't support ${VAR:?message}?

It's POSIX sh syntax, so bash, zsh, and dash all support it on the Actions runner images without extra setup. If you're not in a shell step, the same idea still applies: replace "if missing, silently continue" with "if missing, exit non-zero and say why."

Isn't skipping when a fork PR can't see secrets a valid reason to no-op?

Yes — that's exactly the case Gate 2 in this post exists to protect. What matters is why nothing happened. "Missing configuration" should never take the same silent branch as "correctly nothing to do today."

Does making it fail-loud slow down my other runs?

No. The check that got stricter is one that ran every time anyway — it now returns a non-zero exit instead of a friendly no-op when configuration is missing. The path where configuration is present, which is nearly every run, is byte-for-byte unchanged.

How I verified this

Run history for this post came from gh run list --workflow publish.yml --json conclusion,createdAt,headSha. The population is every scheduled run from when the cron was added through the time I wrote this — eight runs, none excluded. Workflow YAML and diffs came from the actual commits in the repository. Both exit-code behaviors above (config present, config missing) were run by hand, not inferred.

Gate 2's behavior wasn't just read off the source — I extracted its decision logic, ran it against the real post count (117 posts) with the date varied, and confirmed it returns has_target=true on every date tested. The 117 figure is the real count at observation time. The 69 figure for the destination was ls | wc -l against my local checkout; cross-checking it later against the destination repo's commit history showed it was a stale snapshot, which is called out above rather than quietly corrected.

Organization name, repository name, and the specific post slug are withheld. Workflow structure, exit codes, and command output are reproduced as they actually occurred.

References


Sho Naka (nomurasan). Originally written in Japanese; the English here is an AI-assisted re-adaptation of my own text, not a literal translation. The incident, the logs, the code, and the conclusions are mine.

Top comments (0)