<?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: Bobby Digital</title>
    <description>The latest articles on DEV Community by Bobby Digital (@bdigital00).</description>
    <link>https://dev.to/bdigital00</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3832880%2Fc007bda8-84ec-4444-9628-c6445efa34d1.jpg</url>
      <title>DEV Community: Bobby Digital</title>
      <link>https://dev.to/bdigital00</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bdigital00"/>
    <language>en</language>
    <item>
      <title>My Mastra Agent Found a Production Bug in Five Minutes</title>
      <dc:creator>Bobby Digital</dc:creator>
      <pubDate>Sat, 21 Mar 2026 20:37:18 +0000</pubDate>
      <link>https://dev.to/bdigital00/my-mastra-agent-found-a-production-bug-in-five-minutes-348e</link>
      <guid>https://dev.to/bdigital00/my-mastra-agent-found-a-production-bug-in-five-minutes-348e</guid>
      <description>&lt;p&gt;I saw a tweet about Sentry shipping an AI agent that watches your error logs and auto-suggests fixes. I thought: I can build that. So I did — from my phone, on a Friday afternoon, while walking around the house.&lt;/p&gt;

&lt;p&gt;I stood up a &lt;a href="https://mastra.ai" rel="noopener noreferrer"&gt;Mastra&lt;/a&gt; workflow via Telegram, pointed it at my three Cloudflare Workers sites, and ran it. Within five minutes it flagged &lt;code&gt;scriptThrewException&lt;/code&gt; errors on all three sites. Bots were hitting my media proxy endpoint, the Worker was crashing on every request, and my uptime monitor had been saying everything was fine for days.&lt;/p&gt;

&lt;p&gt;The whole thing — &lt;a href="https://mastra.ai" rel="noopener noreferrer"&gt;Mastra&lt;/a&gt; for the workflow engine, Cloudflare's GraphQL Analytics API for the data, a Telegram bot for alerts — runs locally on my Mac, costs nothing, and took about two hours to build. The same afternoon I &lt;a href="https://tech.bdigitalmedia.io/blog/pushing-to-prod-from-the-mountains" rel="noopener noreferrer"&gt;pushed a blog post from a mountain via Telegram&lt;/a&gt;. Here's how it works and what it found.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "Is It Up?" Isn't Enough
&lt;/h2&gt;

&lt;p&gt;I already had an uptime monitor running as a GitHub Action every 30 minutes. It hits each site, checks for a 200 response, and moves on. All three of my sites — &lt;a href="https://bdigitalmedia.io" rel="noopener noreferrer"&gt;bdigitalmedia.io&lt;/a&gt;, &lt;a href="https://tech.bdigitalmedia.io" rel="noopener noreferrer"&gt;tech.bdigitalmedia.io&lt;/a&gt;, and &lt;a href="https://truck.bdigitalmedia.io" rel="noopener noreferrer"&gt;truck.bdigitalmedia.io&lt;/a&gt; — were passing every check. Green across the board.&lt;/p&gt;

&lt;p&gt;But the sites were throwing &lt;code&gt;scriptThrewException&lt;/code&gt; errors on every Worker invocation. Bots and crawlers hitting API routes, the Worker script crashing, Cloudflare returning 500s — and the uptime monitor never noticed because it only checks static pages, which are served directly from Cloudflare's edge cache without invoking the Worker at all.&lt;/p&gt;

&lt;p&gt;I needed something that could look at the actual error rates across all HTTP traffic, not just ping the homepage and call it a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is Mastra
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://mastra.ai" rel="noopener noreferrer"&gt;Mastra&lt;/a&gt; is a TypeScript framework for building AI agents and workflows. It comes from the team behind Gatsby, it's open source (Apache 2.0), and it has over 22,000 GitHub stars. I use it at my day job and wanted to practice building workflows with it outside of work.&lt;/p&gt;

&lt;p&gt;The core concept is simple: you define &lt;strong&gt;steps&lt;/strong&gt; with Zod-validated input/output schemas and chain them together with &lt;code&gt;.then()&lt;/code&gt;. Each step is a typed function that receives data from the previous step and returns data for the next one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;workflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createWorkflow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;site-monitor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sites&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteSchema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;outputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fetchMetrics&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;analyzeFindings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sendTelegram&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three steps. Fetch the data, analyze it, send an alert if something is wrong. That's the entire workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Pulling Metrics from Cloudflare
&lt;/h2&gt;

&lt;p&gt;My sites run on Cloudflare Workers. Cloudflare exposes a GraphQL Analytics API that gives you zone-level HTTP request data — total requests, status codes, response times — broken down by hostname.&lt;/p&gt;

&lt;p&gt;The key insight: I needed &lt;strong&gt;zone-level&lt;/strong&gt; HTTP analytics, not Worker invocation metrics. My sites use Cloudflare's &lt;code&gt;[assets]&lt;/code&gt; directive, which means static pages are served directly from the edge without invoking the Worker script. If you only look at Worker invocations, you're seeing a tiny slice of traffic — mostly API routes and bot requests — and the error rates look wildly wrong.&lt;/p&gt;

&lt;p&gt;The first version of the monitor used &lt;code&gt;workersInvocationsAdaptive&lt;/code&gt; and reported 100% error rates on all three sites. That's because the only Worker invocations were bots crashing on API routes. The real traffic — visitors reading blog posts, browsing the portfolio, buying &lt;a href="https://bdigitalmedia.io/clips" rel="noopener noreferrer"&gt;video clips&lt;/a&gt; — was invisible.&lt;/p&gt;

&lt;p&gt;Switching to &lt;code&gt;httpRequestsAdaptiveGroups&lt;/code&gt; at the zone level gave me the complete picture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bdigitalmedia.io:       348 requests | 46 5xx | 13.2% error rate
tech.bdigitalmedia.io:  299 requests | 7 5xx  | 2.3% error rate
truck.bdigitalmedia.io: 231 requests | 2 5xx  | 0.9% error rate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's real data. And the 46 errors on the main site were real errors worth investigating.&lt;/p&gt;

&lt;p&gt;The analysis step takes the raw metrics and classifies each site based on 5xx error rate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Healthy:&lt;/strong&gt; under 5% error rate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Degraded:&lt;/strong&gt; 5-20% — something is wrong but the site is mostly functional&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Down:&lt;/strong&gt; over 20% — significant portion of requests failing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It formats a clean message with status icons, request counts, error breakdowns, and HTTP status code distributions. The format is designed to be scannable in a Telegram notification — you should be able to glance at it and know if you need to act.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Telegram Alerts (Only When It Matters)
&lt;/h2&gt;

&lt;p&gt;This is the part I spent the most time thinking about. A monitor that sends a message every time it runs is a monitor you learn to ignore. The alert has to mean something.&lt;/p&gt;

&lt;p&gt;The workflow runs in two modes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check mode&lt;/strong&gt; (every 4 hours): Looks at the last 4 hours of traffic. If all sites are healthy, it stays completely silent. No message, no notification. You only hear from it when something is actually wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Summary mode&lt;/strong&gt; (daily at 7am): Always sends a full traffic report regardless of status. Total requests, error counts, status code breakdowns across all three sites. This is the morning briefing — a quick glance at how the sites performed over the last 24 hours.&lt;/p&gt;

&lt;p&gt;The Telegram integration is straightforward. A bot token, a chat ID, and a &lt;code&gt;fetch&lt;/code&gt; call to the Bot API. The interesting part is the suppression logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;check&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;inputData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasIssues&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;All sites healthy — no alert sent.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Silence is the signal that everything is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  It Found a Real Bug
&lt;/h2&gt;

&lt;p&gt;The first time I ran the monitor with real data, it flagged &lt;code&gt;scriptThrewException&lt;/code&gt; errors on all three sites. The main site had 66 exceptions in the last 24 hours.&lt;/p&gt;

&lt;p&gt;I dug into the Cloudflare GraphQL data with status dimensions and found the root cause: the media proxy endpoint at &lt;code&gt;/api/media/[...path]&lt;/code&gt; had an unguarded &lt;code&gt;r2.sign()&lt;/code&gt; call in the presigned URL fallback path. When a bot hit the endpoint and the R2 signing failed for any reason — bad path, network hiccup, missing object — the Worker crashed instead of returning a 404.&lt;/p&gt;

&lt;p&gt;The fix was a try/catch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signedRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;r2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r2Endpoint&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/bdigital-clips/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?X-Amz-Expires=300`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;aws&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signQuery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signedRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five lines. Zero new errors since the deploy.&lt;/p&gt;

&lt;p&gt;A monitor that finds real bugs on day one has already paid for itself. The entire tool took two hours to build, and it caught an issue that had been silently crashing in production. The uptime check said everything was fine. The error rate data told a different story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running It Locally
&lt;/h2&gt;

&lt;p&gt;The whole thing runs locally with &lt;code&gt;tsx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run check      &lt;span class="c"&gt;# 4h lookback, alerts only on errors&lt;/span&gt;
npm run summary    &lt;span class="c"&gt;# 24h report, always sends to Telegram&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No cloud compute, no hosting costs. Mastra runs on Node.js, the Cloudflare API is free, and the Telegram Bot API is free. The only cost is the electricity to keep my Mac awake.&lt;/p&gt;

&lt;p&gt;For scheduling, I use cron jobs that fire the check every 4 hours and the summary every morning. If the Mac is asleep, the check runs when it wakes up. If I need persistent monitoring, the same workflow can deploy to Cloudflare Workers using Mastra's &lt;code&gt;@mastra/deployer-cloudflare&lt;/code&gt; package — but for three personal sites, local is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Comes Next
&lt;/h2&gt;

&lt;p&gt;The monitor currently tells me something is wrong. The next step is having it fix the problem.&lt;/p&gt;

&lt;p&gt;Mastra supports agent steps — workflow steps that can reason about data, read code, and take action. The natural evolution is: when the monitor detects elevated errors, it reads the Cloudflare logs, identifies the failing endpoint, greps the codebase for the relevant file, analyzes the crash pattern, and either fixes it directly or opens a PR with the proposed fix.&lt;/p&gt;

&lt;p&gt;I already have &lt;a href="https://tech.bdigitalmedia.io/blog/claude-code-remote-control-mobile-workflow" rel="noopener noreferrer"&gt;Claude Code running remotely&lt;/a&gt; and &lt;a href="https://tech.bdigitalmedia.io/blog/pushing-to-prod-from-the-mountains" rel="noopener noreferrer"&gt;pushing to production from my phone&lt;/a&gt;. Connecting the monitor to an agent that can diagnose and patch production issues closes the loop. The workflow becomes: detect, diagnose, fix, deploy, verify — all without human intervention.&lt;/p&gt;

&lt;p&gt;That's not built yet. But the foundation is here, and the workflow architecture supports it. Steps chain together. Each step has typed inputs and outputs. Adding a "diagnose" step and a "fix" step is just more &lt;code&gt;.then()&lt;/code&gt; calls on the same workflow.&lt;/p&gt;

&lt;p&gt;For now, the monitor runs every 4 hours, stays silent when things are healthy, and pings me on Telegram when they're not. It found a real bug on day one. That's a good start.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The full monitor workflow is about 350 lines of TypeScript. If you're building something similar with Mastra or want to talk about Cloudflare Workers monitoring, &lt;a href="https://bdigitalmedia.io/contact" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt; or find me on &lt;a href="https://instagram.com/bdigital00" rel="noopener noreferrer"&gt;Instagram&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.bdigitalmedia.io/blog/mastra-site-monitor-cloudflare-telegram/" rel="noopener noreferrer"&gt;https://tech.bdigitalmedia.io/blog/mastra-site-monitor-cloudflare-telegram/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>cloudflare</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I've Started Using Dumber Models on Purpose</title>
      <dc:creator>Bobby Digital</dc:creator>
      <pubDate>Thu, 19 Mar 2026 19:45:08 +0000</pubDate>
      <link>https://dev.to/bdigital00/ive-started-using-dumber-models-on-purpose-1309</link>
      <guid>https://dev.to/bdigital00/ive-started-using-dumber-models-on-purpose-1309</guid>
      <description>&lt;p&gt;Here's something that felt wrong at first: I've started reaching for less capable models when I'm writing code.&lt;/p&gt;

&lt;p&gt;Not because they're cheaper. Because they make me think.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with Too-Capable Tools
&lt;/h2&gt;

&lt;p&gt;Opus 4.5 will take a half-baked prompt and ship working code. You describe a vague idea, and 30 seconds later you've got something that compiles. Magic, right?&lt;/p&gt;

&lt;p&gt;Except... did you actually think about what you were building?&lt;/p&gt;

&lt;p&gt;The risk with ultra-capable models isn't wrong code - it's skipping the part where you understand the problem. You get a solution before you've defined what you're solving.&lt;/p&gt;

&lt;p&gt;I found myself in meetings defending decisions I hadn't consciously made. "Why did you structure it this way?" Uh, because the model did, and it worked?&lt;/p&gt;

&lt;p&gt;That's a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Friction Is the Feature
&lt;/h2&gt;

&lt;p&gt;Sonnet makes you think first. When a model requires precision in your prompts, you're forced to actually articulate what you want. That articulation &lt;em&gt;is&lt;/em&gt; the architecture work.&lt;/p&gt;

&lt;p&gt;My architecture decisions are sharper when the model requires precision. The prompt becomes the design doc. If I can't explain it clearly enough for a mid-tier model to execute, maybe I don't understand it well enough yet.&lt;/p&gt;

&lt;p&gt;This isn't about the model being bad. It's about the model being appropriately demanding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The New Workflow
&lt;/h2&gt;

&lt;p&gt;Here's what works for me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exploration and research:&lt;/strong&gt; Use the biggest brain available. Opus 4.5 for understanding complex codebases, exploring possibilities, asking "what if" questions. Let it synthesize.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First-draft implementation:&lt;/strong&gt; Dial it back. Sonnet forces me to write actual specs. If the prompt has to be precise, the thinking has already happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code review and debugging:&lt;/strong&gt; Back to powerful models. They catch things I miss, suggest better patterns, and explain why something is wrong - not just that it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refactoring:&lt;/strong&gt; Sonnet again. If I can't describe the refactor clearly, I'm not ready to do it.&lt;/p&gt;

&lt;p&gt;The pattern: powerful for exploration and review, constrained for creation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the Design Doc Before the Prompt
&lt;/h2&gt;

&lt;p&gt;This is the real insight: if your prompt could substitute for a design doc, you've done the thinking. If your prompt is "make it work," you haven't.&lt;/p&gt;

&lt;p&gt;Ultra-capable models let you skip writing that design doc. Which is exactly why you shouldn't always use them.&lt;/p&gt;

&lt;p&gt;I've started treating prompts like I treat commit messages - they should explain the &lt;em&gt;why&lt;/em&gt;, not just the &lt;em&gt;what&lt;/em&gt;. If the prompt is just "add user authentication," I haven't done my job. What kind of auth? Where does it live? What's the session strategy?&lt;/p&gt;

&lt;p&gt;Forcing yourself to answer those questions in the prompt forces you to answer them in your head first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Counterintuitive Truth
&lt;/h2&gt;

&lt;p&gt;The tool that makes you think less isn't always the better tool.&lt;/p&gt;

&lt;p&gt;When I reach for the most powerful model, I'm implicitly saying "I don't need to think about this." Sometimes that's true - you're doing rote work, or you genuinely need the extra capability.&lt;/p&gt;

&lt;p&gt;But for design decisions? For architecture? For anything you'll need to explain to another human?&lt;/p&gt;

&lt;p&gt;The friction is a feature. The struggle to articulate is the work.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm curious - has anyone else found themselves deliberately using less capable tools for certain tasks? Not for cost or speed, but because the constraint improves the output?&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Join the conversation:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/posts/bobulrich42_ive-started-using-dumber-models-on-purpose-activity-7415101334896685058-27Wz" rel="noopener noreferrer"&gt;Comment on LinkedIn&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.bdigitalmedia.io/blog/dumber-models-on-purpose/" rel="noopener noreferrer"&gt;https://tech.bdigitalmedia.io/blog/dumber-models-on-purpose/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>productivity</category>
      <category>linkedin</category>
    </item>
    <item>
      <title>Building a Filterable Resume Page with PDF Export and CI/CD for Astro</title>
      <dc:creator>Bobby Digital</dc:creator>
      <pubDate>Thu, 19 Mar 2026 01:37:15 +0000</pubDate>
      <link>https://dev.to/bdigital00/building-a-filterable-resume-page-with-pdf-export-and-cicd-for-astro-4mkn</link>
      <guid>https://dev.to/bdigital00/building-a-filterable-resume-page-with-pdf-export-and-cicd-for-astro-4mkn</guid>
      <description>&lt;p&gt;Yesterday I built a Christmas gift tracker. Today I decided my portfolio site needed a resume page. And while I was at it, I set up a proper CI/CD pipeline because pushing to production without tests is chaos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: The Resume Page
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The "I Haven't Needed a Resume in 10 Years" Problem
&lt;/h3&gt;

&lt;p&gt;Here's an admission: I haven't thought about my resume in probably a decade. When you're happily employed and not job hunting, that document just... sits there. Somewhere. Maybe in a Google Doc from 2015?&lt;/p&gt;

&lt;p&gt;So when I decided to build a resume page, my first challenge wasn't technical - it was "where is my career history even stored?"&lt;/p&gt;

&lt;p&gt;The answer, of course, is LinkedIn. I've been dutifully updating my LinkedIn profile for years, but I'd never actually &lt;em&gt;extracted&lt;/em&gt; that data. Turns out LinkedIn has a feature for this that I'd completely forgotten about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Getting Your Data Out of LinkedIn
&lt;/h3&gt;

&lt;p&gt;LinkedIn lets you &lt;a href="https://www.linkedin.com/help/linkedin/answer/a1339364" rel="noopener noreferrer"&gt;download your account data&lt;/a&gt; - and it's more detailed than you'd expect:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Settings &amp;amp; Privacy&lt;/strong&gt; → &lt;strong&gt;Data Privacy&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;"Get a copy of your data"&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;"The works"&lt;/strong&gt; (or pick specific categories like Positions, Education, Skills)&lt;/li&gt;
&lt;li&gt;Wait for the email (minutes for specific data, up to 24 hours for everything)&lt;/li&gt;
&lt;li&gt;Download and extract the ZIP file&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What you get is a folder full of CSV files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Profile.csv&lt;/code&gt; - Your headline, summary, location&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Positions.csv&lt;/code&gt; - Every job with company, title, dates, description&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Education.csv&lt;/code&gt; - Schools, degrees, dates&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Skills.csv&lt;/code&gt; - All those endorsements you've collected&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Certifications.csv&lt;/code&gt; - Professional certifications&lt;/li&gt;
&lt;li&gt;And more...&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Converting LinkedIn Export to JSON Resume
&lt;/h3&gt;

&lt;p&gt;CSV files aren't directly usable for a web page, so I wrote a script to convert them to &lt;a href="https://jsonresume.org" rel="noopener noreferrer"&gt;JSON Resume&lt;/a&gt; format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node scripts/linkedin-to-resume.mjs ~/Downloads/Basic_LinkedInDataExport_12-22-2025/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Parses all the CSV files (handling LinkedIn's multiline quoted fields)&lt;/li&gt;
&lt;li&gt;Maps fields to JSON Resume schema&lt;/li&gt;
&lt;li&gt;Extracts bullet points from job descriptions as highlights&lt;/li&gt;
&lt;li&gt;Auto-categorizes skills (Cloud, Programming, Databases, etc.)&lt;/li&gt;
&lt;li&gt;Outputs a JSON file ready for editing
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The script handles LinkedIn's CSV format&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;positions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readCSVFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exportDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Positions.csv&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;work&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;positions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Company Name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Started On&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;  &lt;span class="c1"&gt;// "Jan 2020" → "2020-01"&lt;/span&gt;
  &lt;span class="na"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Finished On&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Description&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;highlights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;extractBulletPoints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Description&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What LinkedIn doesn't export:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your profile photo (you'll need to save it manually)&lt;/li&gt;
&lt;li&gt;Detailed job highlights (the descriptions are often sparse)&lt;/li&gt;
&lt;li&gt;Your LinkedIn URL (ironically)&lt;/li&gt;
&lt;li&gt;Recommendations text&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After running the script, I spent about 30 minutes enriching the data - adding specific achievements, metrics, and details that make a resume actually useful to recruiters.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Build a Custom Resume Page?
&lt;/h3&gt;

&lt;p&gt;Most developers either link to a PDF or use LinkedIn. I wanted something better:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filterable&lt;/strong&gt; - Recruiters can filter by years of experience or company&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple formats&lt;/strong&gt; - JSON, YAML, PDF, and DOCX exports&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single source of truth&lt;/strong&gt; - Edit one JSON file, everything updates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SEO friendly&lt;/strong&gt; - Proper meta tags and structured data&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Architecture
&lt;/h3&gt;

&lt;p&gt;The resume uses the &lt;a href="https://jsonresume.org" rel="noopener noreferrer"&gt;JSON Resume&lt;/a&gt; schema - a standardized format that means the data is portable. Edit one file, everything updates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/data/resume.json          # Single source of truth
    ↓
src/pages/resume.astro        # Interactive page with filtering
src/pages/api/resume.json.ts  # JSON export endpoint
src/pages/api/resume.yaml.ts  # YAML export endpoint
public/resume.pdf             # Pre-generated PDF
public/resume.docx            # Pre-generated DOCX
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Client-Side Filtering
&lt;/h3&gt;

&lt;p&gt;The page includes JavaScript for filtering without page reloads. Filter by years of experience (1Y, 2Y, 5Y, 10Y) or by company:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filterExperience&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;years&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cutoffDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;cutoffDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFullYear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cutoffDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFullYear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;years&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.experience-item&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// Show if current role OR ended within the timeframe&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;show&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;endDate&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;cutoffDate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;show&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means a recruiter looking for "last 2 years of experience" can click a button and see exactly that.&lt;/p&gt;

&lt;h3&gt;
  
  
  The PDF Generation Challenge
&lt;/h3&gt;

&lt;p&gt;My first attempt used &lt;code&gt;html2pdf.js&lt;/code&gt; for client-side PDF generation. Problem: Content Security Policy blocked the CDN script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The solution: Build-time PDF generation with Puppeteer&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of generating PDFs in the browser, I created a Node.js script that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Builds the site&lt;/li&gt;
&lt;li&gt;Starts a local static server&lt;/li&gt;
&lt;li&gt;Uses Puppeteer to render the resume page&lt;/li&gt;
&lt;li&gt;Injects compact styling for a 2-page PDF&lt;/li&gt;
&lt;li&gt;Saves the result
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generatePDF&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;headless&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3456/resume/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;networkidle0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Inject compact styling for 2-page resume&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;style&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
      body { font-size: 11px !important; }
      h1 { font-size: 24px !important; }
      /* Hide nav and footer for clean PDF */
    `&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nav&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dist/resume.pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Letter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;printBackground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.4in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.4in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.4in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.4in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key insight:&lt;/strong&gt; Puppeteer and &lt;code&gt;docx&lt;/code&gt; are installed locally but NOT in &lt;code&gt;package.json&lt;/code&gt;. This avoids CI conflicts - they're only needed for local PDF generation, not for Cloudflare builds.&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;# Local only - not in package.json&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;puppeteer docx

&lt;span class="c"&gt;# Generate resume files&lt;/span&gt;
npm run resume:generate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  DOCX Generation
&lt;/h3&gt;

&lt;p&gt;For Word documents, I used the &lt;code&gt;docx&lt;/code&gt; library to programmatically build the document from the same JSON data:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Packer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Paragraph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TextRun&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;docx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Paragraph&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextRun&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;resumeData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;basics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt; &lt;span class="p"&gt;})]&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="c1"&gt;// ... build document structure from resume.json&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}]&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Packer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dist/resume.docx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Part 2: The CI/CD Pipeline
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;Before today, pushing to &lt;code&gt;main&lt;/code&gt; would deploy directly to Cloudflare Pages with no checks. A bad dependency update could break production. Case in point: Dependabot opened a PR to upgrade Tailwind CSS from v3 to v4 - a complete rewrite that would have broken everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  GitHub Actions Setup
&lt;/h3&gt;

&lt;p&gt;I created two workflows:&lt;/p&gt;

&lt;h4&gt;
  
  
  Main CI Workflow
&lt;/h4&gt;

&lt;p&gt;Every push and PR runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build &amp;amp; Test&lt;/strong&gt; - Builds the site, validates all images&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security Audit&lt;/strong&gt; - &lt;code&gt;npm audit --audit-level=high&lt;/code&gt; fails on vulnerabilities&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lighthouse&lt;/strong&gt; - Performance/accessibility checks (PRs only)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build-and-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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@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;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;22'&lt;/span&gt;
          &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;npm'&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&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 run build&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 run validate:images&lt;/span&gt;

  &lt;span class="na"&gt;security-audit&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;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&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 audit --audit-level=high&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Dependabot Auto-Merge
&lt;/h4&gt;

&lt;p&gt;Patch and minor updates auto-merge when CI passes. Major updates get a comment explaining they need manual review:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Auto-merge patch updates&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.metadata.outputs.update-type == 'version-update:semver-patch'&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;gh pr merge --auto --squash "$PR_URL"&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;Comment on major updates&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.metadata.outputs.update-type == 'version-update:semver-major'&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;gh pr comment "$PR_URL" --body "⚠️ Major version update - requires manual review"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Handling the Tailwind v4 PR
&lt;/h3&gt;

&lt;p&gt;Today I got a Dependabot PR trying to upgrade Tailwind CSS from v3 to v4. The CI would have caught the build failure, but beyond that - this is a migration project, not a routine update.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The right approach:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;CI detected it as a major update&lt;/li&gt;
&lt;li&gt;Auto-merge was skipped&lt;/li&gt;
&lt;li&gt;I closed the PR with an explanation&lt;/li&gt;
&lt;li&gt;Added an ignore rule to prevent future v4 PRs
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/dependabot.yml&lt;/span&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;dependency-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tailwindcss"&lt;/span&gt;
    &lt;span class="na"&gt;update-types&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;version-update:semver-major"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Security Vulnerability Handling
&lt;/h3&gt;

&lt;p&gt;The pipeline handles security at multiple levels:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;npm audit&lt;/code&gt; in CI&lt;/td&gt;
&lt;td&gt;Blocks PRs with high/critical vulnerabilities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dependabot alerts&lt;/td&gt;
&lt;td&gt;Notifies of known vulnerabilities in dependencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-merge patches&lt;/td&gt;
&lt;td&gt;Security fixes merge automatically when CI passes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual review&lt;/td&gt;
&lt;td&gt;Major updates require human approval&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;The resume page is now live with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Interactive filtering (1Y, 2Y, 5Y, 10Y experience views)&lt;/li&gt;
&lt;li&gt;Company filtering dropdown&lt;/li&gt;
&lt;li&gt;JSON/YAML API endpoints at &lt;code&gt;/api/resume.json&lt;/code&gt; and &lt;code&gt;/api/resume.yaml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Downloadable PDF (2 pages, professionally formatted)&lt;/li&gt;
&lt;li&gt;Downloadable DOCX&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The CI/CD pipeline provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Automatic builds and tests on every push&lt;/li&gt;
&lt;li&gt;Security scanning for vulnerabilities&lt;/li&gt;
&lt;li&gt;Auto-merging safe dependency updates&lt;/li&gt;
&lt;li&gt;Protection against breaking changes&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build-time vs runtime PDF generation&lt;/strong&gt; - Puppeteer at build time is more reliable than browser-based solutions that fight with CSP&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't put dev-only dependencies in package.json&lt;/strong&gt; - Puppeteer caused CI conflicts with Tailwind's peer dependencies. Keep build-only tools local.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Group Dependabot updates&lt;/strong&gt; - Individual PRs for every patch are noisy. Grouping makes review manageable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ignore major versions for complex deps&lt;/strong&gt; - Tailwind v4 is a migration project, not a routine update. Configure Dependabot to skip it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Auto-merge what's safe&lt;/strong&gt; - Patch updates rarely break things. Let CI validate and merge them automatically.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Security scanning should fail builds&lt;/strong&gt; - &lt;code&gt;npm audit --audit-level=high&lt;/code&gt; in CI catches vulnerabilities before they hit production.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;The resume lives at &lt;code&gt;/resume&lt;/code&gt; with all the export options. The JSON Resume format means I can also use it with other resume tools if I ever decide to switch.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.bdigitalmedia.io/blog/resume-page-and-cicd-pipeline/" rel="noopener noreferrer"&gt;https://tech.bdigitalmedia.io/blog/resume-page-and-cicd-pipeline/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>github</category>
      <category>linkedin</category>
    </item>
  </channel>
</rss>
