This week we turned a bug we'd already fixed three separate times into a static-analysis rule instead of writing it up a fourth time. GitHub Actions sets an unset workflow variable to an empty string, not undefined, so `?? default` silently never fires — a cost cap becomes zero, a target list becomes empty, and the run fails in a way that reads exactly like a broken permission.
Over five days we patched the same shape of defect in three unrelated places: a paywall flag that was absent rather than explicitly false, an issuer check whose fail-fast branch turned out to be unreachable, and a Stripe price map that resolved to empty instead of throwing. Each looked like its own one-off bug at the time. By the third fix it was clear they weren't independent — they were the same failure mode wearing different variable names, and writing a fourth incident report would have missed the point. So instead of a fifth write-up, we wrote a lint rule.
The trigger this time was our own automated pull-request lane, which decides how many repos to touch and caps how much it's allowed to do per run. Both numbers came from workflow variables guarded with `?? default`, on the reasonable assumption that an unset variable comes through as `undefined`. GitHub Actions doesn't do that. `env: FOO: ${{ vars.UNSET }}` resolves to the empty string, and `??` only catches `null` and `undefined` — an empty string sails straight past it. `Number("")` is `0`, so the cap silently became zero and processed nothing. `"".split(",").filter(Boolean)` is `[]`, so the target list silently became empty too. The run's own log read `repos=N opened=0` and exited 1, which is indistinguishable from a permissions failure unless you already know to suspect the variable underneath it.
An unset variable and an empty string look identical to every operator except the one built specifically to tell them apart.
The obvious version of this rule flags every `??` in the codebase, and we deliberately didn't write that version. A `??` guarding a variable that only ever comes from a shell environment or a `.env` file is fine — there, an absent value really is `undefined`, and the operator does exactly what it looks like it does. The bug only exists on variables a workflow file actually injects with `NAME: ${{ ... }}`. Widening the rule past that line would have buried the real hits under a pile of harmless matches — a lesson we'd already learned the hard way from an earlier rule that tried to catch too much and mostly just added noise nobody read.
Its own test suite caught it doing the wrong thing twice before we shipped it. First, an early version of the pattern didn't stop matching at the closing parenthesis, so a line that had already been fixed correctly — `(process.env.X ?? "").split(",")` — got its own already-safe default swallowed into the match and reported as a violation. Second, the very first live run flagged fifteen hits across the codebase, and three of them were the rule's own smoke and test fixtures, which necessarily contain the broken shape on purpose so the tests have something to catch. Both are now permanent regression locks in the test file, specifically so nobody quietly "cleans up" the exclusion later and reintroduces the false positive.
The rule ships report-only for now. Twelve pre-existing hits are sitting in the codebase, including one in the tool that lands automated pull requests — the same shape, currently harmless only because that particular variable happens to be set in production today. Nothing forced it to a hard fail on day one; getting visibility on all twelve first mattered more than being strict before anyone had looked at what strictness would actually block.
This post was drafted by an AI system from Dekimu's public engineering record and published with automated checks, without per-post human editing.
← Back to blog