<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://mcgarrah.org/feed.xml" rel="self" type="application/atom+xml" /><link href="https://mcgarrah.org/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-05-15T18:47:17+00:00</updated><id>https://mcgarrah.org/feed.xml</id><title type="html">McGarrah Technical Blog</title><subtitle>Technical blog covering homelab infrastructure, Proxmox/Ceph, Dell Wyse 3040, Jekyll development, and networking. Deep dives into virtualization, storage clustering, and system administration.</subtitle><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><entry><title type="html">From Publish to Reader: The Content Distribution Pipeline Behind This Blog</title><link href="https://mcgarrah.org/jekyll-content-distribution-pipeline/" rel="alternate" type="text/html" title="From Publish to Reader: The Content Distribution Pipeline Behind This Blog" /><published>2026-05-15T00:00:00+00:00</published><updated>2026-05-15T00:00:00+00:00</updated><id>https://mcgarrah.org/jekyll-content-distribution-pipeline</id><content type="html" xml:base="https://mcgarrah.org/jekyll-content-distribution-pipeline/"><![CDATA[<p>I write a markdown file, push to GitHub, and GitHub Actions builds the site. That’s the publishing step. But publishing isn’t distribution — a post that exists on a server isn’t a post that reaches readers.</p>

<p>This blog has six distribution channels, each serving a different audience and timeline. The same content distribution pipeline concept applies to any content platform — documentation sites, developer portals, knowledge bases — the channels differ but the architecture is the same. Here’s how they work together.</p>

<!-- excerpt-end -->

<h2 id="the-distribution-channels">The Distribution Channels</h2>

<pre><code class="language-mermaid">graph TD
    A[git push] --&gt; B[GitHub Actions Build]
    B --&gt; C[GitHub Pages]
    B --&gt; D[RSS Feed - feed.xml]
    B --&gt; E[Sitemap - sitemap.xml]
    B --&gt; F[Sitemap Index - sitemapindex.xml]
    F --&gt; G[Google Search Console]
    C --&gt; H[Direct Readers]
    D --&gt; I[RSS Subscribers]
    G --&gt; J[Google Search Results]
    C --&gt; K[Substack Newsletter]
    K --&gt; L[Newsletter Subscribers]
    C --&gt; M[Social Sharing]
    M --&gt; N[Reddit / LinkedIn / etc.]
</code></pre>

<table>
  <thead>
    <tr>
      <th>Channel</th>
      <th>Audience</th>
      <th>Timeline</th>
      <th>Effort</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Direct URL</td>
      <td>Bookmarkers, repeat visitors</td>
      <td>Immediate</td>
      <td>Zero</td>
    </tr>
    <tr>
      <td>RSS feed</td>
      <td>Technical readers, feed aggregators</td>
      <td>Minutes after build</td>
      <td>Zero (automated)</td>
    </tr>
    <tr>
      <td>Google Search</td>
      <td>New readers searching for solutions</td>
      <td>Days to weeks</td>
      <td>Zero (automated via sitemap)</td>
    </tr>
    <tr>
      <td>Substack newsletter</td>
      <td>Subscribers, broader audience</td>
      <td>Manual, batched</td>
      <td>2-4 hours per newsletter</td>
    </tr>
    <tr>
      <td>Social sharing</td>
      <td>Reddit, LinkedIn, Hacker News</td>
      <td>Manual, per-post</td>
      <td>15 minutes per post</td>
    </tr>
    <tr>
      <td>Cross-references</td>
      <td>Readers of related posts</td>
      <td>At publish time</td>
      <td>Built into writing process</td>
    </tr>
  </tbody>
</table>

<h2 id="rss-feed">RSS Feed</h2>

<p>The RSS feed is the oldest and simplest distribution channel. The <code class="language-plaintext highlighter-rouge">jekyll-feed</code> plugin generates <code class="language-plaintext highlighter-rouge">feed.xml</code> automatically at build time.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="s">gem "jekyll-feed", "~&gt; 0.17.0"</span>
</code></pre></div></div>

<p>That’s it. Every post gets an entry in the feed with title, date, excerpt, and full content. Feed readers like Feedly, NewsBlur, and Miniflux pick it up automatically.</p>

<p>The feed URL is <code class="language-plaintext highlighter-rouge">https://mcgarrah.org/feed.xml</code> and is advertised via a <code class="language-plaintext highlighter-rouge">&lt;link&gt;</code> tag in the HTML head that feed readers auto-discover.</p>

<h3 id="what-rss-gets-right">What RSS Gets Right</h3>

<ul>
  <li><strong>Zero ongoing effort</strong> — Once configured, every new post is automatically in the feed</li>
  <li><strong>Reader-controlled</strong> — Subscribers choose when and how to read. No algorithm, no inbox competition</li>
  <li><strong>Full content</strong> — The feed includes the complete post, not just a teaser. Readers don’t have to click through</li>
</ul>

<h3 id="what-rss-misses">What RSS Misses</h3>

<ul>
  <li><strong>Shrinking audience</strong> — RSS readership has declined since Google Reader shut down in 2013. Most non-technical readers don’t use feed readers</li>
  <li><strong>No analytics</strong> — I can’t tell how many people read via RSS (by design — that’s a feature for privacy-conscious readers)</li>
</ul>

<h2 id="sitemap-and-sitemap-index">Sitemap and Sitemap Index</h2>

<p>The sitemap tells search engines what pages exist and when they were last modified. This blog has a two-level sitemap structure because it hosts two Jekyll sites under one domain.</p>

<h3 id="the-multi-site-problem">The Multi-Site Problem</h3>

<p>The main blog lives at <code class="language-plaintext highlighter-rouge">mcgarrah.org/</code> and the resume lives at <code class="language-plaintext highlighter-rouge">mcgarrah.org/resume/</code>. Each has its own <code class="language-plaintext highlighter-rouge">sitemap.xml</code> generated by <code class="language-plaintext highlighter-rouge">jekyll-sitemap</code>. But Google Search Console wants a single sitemap entry point for the domain.</p>

<p>The solution (added April 8, 2026) is a <code class="language-plaintext highlighter-rouge">sitemapindex.xml</code> at the domain root:</p>

<div class="language-xml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;?xml version="1.0" encoding="UTF-8"?&gt;</span>
<span class="nt">&lt;sitemapindex</span> <span class="na">xmlns=</span><span class="s">"http://www.sitemaps.org/schemas/sitemap/0.9"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;sitemap&gt;</span>
    <span class="nt">&lt;loc&gt;</span>https://mcgarrah.org/sitemap.xml<span class="nt">&lt;/loc&gt;</span>
  <span class="nt">&lt;/sitemap&gt;</span>
  <span class="nt">&lt;sitemap&gt;</span>
    <span class="nt">&lt;loc&gt;</span>https://mcgarrah.org/resume/sitemap.xml<span class="nt">&lt;/loc&gt;</span>
  <span class="nt">&lt;/sitemap&gt;</span>
<span class="nt">&lt;/sitemapindex&gt;</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">robots.txt</code> points to the index, not the individual sitemaps:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sitemap: https://mcgarrah.org/sitemapindex.xml
</code></pre></div></div>

<p>This was a direct fix for the fragmented sitemap problem documented in <a href="/managing-multiple-jekyll-sites-sitemap-challenges/">Managing Multiple Jekyll Sites: Sitemap Challenges</a>.</p>

<h3 id="sitemap-hygiene">Sitemap Hygiene</h3>

<p>The sitemap went through significant cleanup — from 434 URLs down to ~172 after excluding auto-generated tag pages, category pages, and pagination. That story is told in <a href="/jekyll-sitemap-bloat-tags-categories-pagination/">Your Jekyll Sitemap Is 60% Garbage</a>. The SEO health check workflow validates the sitemap on every build.</p>

<h2 id="google-search-console">Google Search Console</h2>

<p>Google Search Console (GSC) is where the sitemap meets Google’s crawler. Submitting the sitemap index tells Google about every page on both the blog and resume sites.</p>

<h3 id="the-indexing-journey">The Indexing Journey</h3>

<p>Getting Google to properly index the site was a multi-month process:</p>

<ol>
  <li><strong>Domain verification</strong> — Proved ownership of <code class="language-plaintext highlighter-rouge">mcgarrah.org</code> via DNS TXT record</li>
  <li><strong>Sitemap submission</strong> — Submitted <code class="language-plaintext highlighter-rouge">sitemapindex.xml</code> pointing to both sitemaps</li>
  <li><strong>Canonical URL fixes</strong> — Resolved “Duplicate without user-selected canonical” errors by aligning <code class="language-plaintext highlighter-rouge">url</code> and <code class="language-plaintext highlighter-rouge">canonical_url</code> in <code class="language-plaintext highlighter-rouge">_config.yml</code> (published <a href="/jekyll-seo-sitemap-canonical-url-fixes/">December 2025</a>)</li>
  <li><strong>404 cleanup</strong> — Removed testing artifacts from <code class="language-plaintext highlighter-rouge">_site/</code> that were generating crawl errors</li>
  <li><strong>Sitemap bloat fix</strong> — Excluded thin tag/category/pagination pages that Google flagged as “Discovered – currently not indexed”</li>
</ol>

<p>The SEO health check GitHub Actions workflow now validates all of this automatically on every push — canonical URL consistency, sitemap XML validity, correct domain usage, and broken links.</p>

<h3 id="what-gsc-tells-you">What GSC Tells You</h3>

<ul>
  <li><strong>Coverage</strong> — Which pages are indexed, which are excluded, and why</li>
  <li><strong>Performance</strong> — Search queries that lead to your site, click-through rates, average position</li>
  <li><strong>Core Web Vitals</strong> — Page speed and user experience metrics</li>
  <li><strong>Links</strong> — External sites linking to your content (this is where you see the Substack and Reddit inbound links)</li>
</ul>

<h2 id="substack-newsletter">Substack Newsletter</h2>

<p>Substack is the highest-effort, highest-impact distribution channel. Each newsletter is a curated collection of blog posts with narrative connecting them, aimed at a broader audience than the blog’s typical reader.</p>

<h3 id="the-cross-posting-workflow">The Cross-Posting Workflow</h3>

<ol>
  <li><strong>Write blog posts</strong> — Individual technical articles published on the blog over weeks/months</li>
  <li><strong>Identify a theme</strong> — Group related posts into a narrative arc</li>
  <li><strong>Write the newsletter</strong> — A 2,000-3,000 word article that tells the story across multiple posts, with links back to each one</li>
  <li><strong>Archive in <code class="language-plaintext highlighter-rouge">_substack/</code></strong> — Keep a markdown copy in the repository for version control</li>
</ol>

<p>The <code class="language-plaintext highlighter-rouge">_substack/</code> directory is excluded from the Jekyll build (the <code class="language-plaintext highlighter-rouge">_</code> prefix ensures Jekyll ignores it). It’s purely for archival:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>_substack/
├── README.md                                    # Publication schedule and tags
├── 2026-04-04-from-homelabs-to-machine-learning.md  # Published
└── 2026-04-20-from-markdown-to-production.md        # Published
</code></pre></div></div>

<h3 id="the-inbound-link-effect">The Inbound Link Effect</h3>

<p>Each Substack newsletter contains <strong>20-25 links back to specific blog posts</strong>:</p>

<table>
  <thead>
    <tr>
      <th>Newsletter</th>
      <th>Date</th>
      <th>Inbound Links</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>From Homelabs to Machine Learning</td>
      <td>2026-04-04</td>
      <td>24 links</td>
    </tr>
    <tr>
      <td>From Markdown to Production</td>
      <td>2026-04-20</td>
      <td>23 links</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td> </td>
      <td><strong>47 links</strong></td>
    </tr>
  </tbody>
</table>

<p>These aren’t generic “visit my blog” links — each one points to a specific post URL like <code class="language-plaintext highlighter-rouge">https://mcgarrah.org/proxmox-ceph-nearfull/</code>. This is why <a href="/jekyll-content-plumbing-permalinks-reading-time/">permalink stability</a> matters so much. If I changed the permalink structure, 47 newsletter links would break instantly, and I can’t edit published Substack articles retroactively.</p>

<p>The inbound links also serve as backlinks for SEO — external sites linking to your content is one of Google’s strongest ranking signals.</p>

<h3 id="publication-scheduling">Publication Scheduling</h3>

<p>Blog posts must be live before the newsletter that references them goes out. The DRAFTS.md tracker includes a dependency checklist for each Substack publication:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>The Apr 20 Substack references the following blog posts that should be live:
- 2026-04-14-ceph-osd-recovery-power-failure.md ✅
- 2026-04-15-zfs-ceph-overlapping-failures.md ✅
- 2026-04-18-jekyll-markdown-feature-reference.md ✅
- 2026-04-19-setting-up-jekyll-blog-github-pages.md ✅
</code></pre></div></div>

<h3 id="planned-newsletters">Planned Newsletters</h3>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>Theme</th>
      <th>Status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>Infrastructure (Proxmox, Ceph, Dell Wyse, monitoring)</td>
      <td>Published 2026-04-04</td>
    </tr>
    <tr>
      <td>2</td>
      <td>Web Development (Jekyll, SEO, GDPR, Pandoc, Mermaid)</td>
      <td>Published 2026-04-20</td>
    </tr>
    <tr>
      <td>3</td>
      <td>Machine Learning (AI/ML research, phonemes, cloud DS)</td>
      <td>Planned</td>
    </tr>
  </tbody>
</table>

<h2 id="social-sharing">Social Sharing</h2>

<p>Reddit, LinkedIn, and other platforms are manual, per-post distribution. The effort is low (15 minutes to write a post title and context) but the reach is unpredictable — a Reddit post might get 3 views or 3,000.</p>

<h3 id="what-makes-posts-shareable">What Makes Posts Shareable</h3>

<ul>
  <li><strong>Clean URLs</strong> — <code class="language-plaintext highlighter-rouge">mcgarrah.org/proxmox-ceph-nearfull/</code> looks better than a date-heavy URL in a Reddit title</li>
  <li><strong>Open Graph meta tags</strong> — When someone pastes a link on LinkedIn or Twitter, the <code class="language-plaintext highlighter-rouge">jekyll-seo-tag</code> plugin provides the title, description, and image for the preview card</li>
  <li><strong>Descriptive titles</strong> — “Your Jekyll Sitemap Is 60% Garbage” gets more clicks than “Sitemap Optimization Notes”</li>
</ul>

<h3 id="the-permalink-contract">The Permalink Contract</h3>

<p>Every external share creates a permanent reference to a specific URL. A Reddit post from 2025 still points to <code class="language-plaintext highlighter-rouge">mcgarrah.org/proxmox-8-dell-wyse-3040-upgrade/</code>. That URL must work forever — or at least redirect via <code class="language-plaintext highlighter-rouge">jekyll-redirect-from</code> if the post is renamed.</p>

<p>This is the same constraint as Substack links, but harder to track. I know exactly which URLs my newsletters reference (they’re in the <code class="language-plaintext highlighter-rouge">_substack/</code> archive). I don’t know which URLs have been shared on Reddit or bookmarked by readers.</p>

<h2 id="cross-references-between-posts">Cross-References Between Posts</h2>

<p>The newest distribution channel is the “Related Posts” section at the bottom of articles. Currently 16 of 139 posts have hand-curated cross-references — all from September 2025 onward.</p>

<p>These serve double duty:</p>

<ul>
  <li><strong>Reader navigation</strong> — A reader finishing the Ceph OSD recovery post sees links to the ZFS failure post and the SSD acceleration post</li>
  <li><strong>Internal linking for SEO</strong> — Google uses internal link structure to understand which pages are most important. Posts with many inbound internal links rank higher</li>
</ul>

<h2 id="how-the-channels-reinforce-each-other">How the Channels Reinforce Each Other</h2>

<p>The channels aren’t independent — they form a flywheel:</p>

<ol>
  <li><strong>Blog post published</strong> → appears in RSS feed and sitemap automatically</li>
  <li><strong>Google indexes it</strong> → organic search traffic starts arriving (days to weeks)</li>
  <li><strong>Substack newsletter</strong> bundles multiple posts → drives traffic spike to all referenced posts</li>
  <li><strong>Reddit/LinkedIn share</strong> → drives traffic spike to individual post</li>
  <li><strong>Inbound links from Substack and social</strong> → improve Google ranking → more organic traffic</li>
  <li><strong>Cross-references in new posts</strong> → drive traffic to older posts → keep them relevant</li>
</ol>

<p>The daily GitHub Actions cron build ensures future-dated posts enter this pipeline automatically. The SEO health check ensures the pipeline stays healthy.</p>

<h2 id="what-id-add-next">What I’d Add Next</h2>

<ul>
  <li><strong>Social sharing buttons on posts</strong> — Currently on the TODO list. Would reduce friction for readers who want to share</li>
  <li><strong>Substack RSS import</strong> — Substack can auto-import from an RSS feed, which would reduce the manual cross-posting effort</li>
  <li><strong>Analytics per channel</strong> — Google Analytics shows referral sources, but I don’t track which Substack newsletter drove which traffic spike</li>
  <li><strong>Automated cross-references</strong> — The 123 older posts without “Related Posts” sections could benefit from tag-based automated suggestions</li>
</ul>

<h2 id="related-posts">Related Posts</h2>

<ul>
  <li><a href="/jekyll-content-plumbing-permalinks-reading-time/">Jekyll Content Plumbing: Permalinks, Reading Time, Excerpts, and Redirects</a> — Why permalink stability matters for distribution</li>
  <li><a href="/jekyll-sitemap-bloat-tags-categories-pagination/">Your Jekyll Sitemap Is 60% Garbage</a> — Sitemap cleanup</li>
  <li><a href="/managing-multiple-jekyll-sites-sitemap-challenges/">Managing Multiple Jekyll Sites: Sitemap Challenges</a> — The multi-site sitemap problem</li>
  <li><a href="/jekyll-seo-sitemap-canonical-url-fixes/">Jekyll SEO, Sitemap, and Canonical URL Fixes</a> — Google Search Console indexing fixes</li>
  <li><a href="/jekyll-github-actions-cicd-pipeline/">The CI/CD Pipeline Behind This Jekyll Blog</a> — The build system that powers the pipeline</li>
  <li><a href="/setting-up-jekyll-blog-github-pages/">Building This Blog: Jekyll on GitHub Pages</a> — Overall setup guide</li>
</ul>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="web-development" /><category term="technical" /><category term="jekyll" /><category term="jekyll" /><category term="rss" /><category term="sitemap" /><category term="substack" /><category term="seo" /><category term="google-search-console" /><category term="content-distribution" /><category term="github-pages" /><category term="newsletter" /><summary type="html"><![CDATA[The complete content distribution pipeline for a Jekyll blog on GitHub Pages: RSS feed via jekyll-feed, XML sitemap and sitemap index for multi-site coverage, Google Search Console submission, Substack newsletter cross-posting with 47+ inbound links, permalink stability for external references, and the workflow that ties it all together.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/jekyll-content-distribution-pipeline.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/jekyll-content-distribution-pipeline.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Rebuilding My Resume Site From the Ground Up</title><link href="https://mcgarrah.org/resume-site-ground-up-rebuild/" rel="alternate" type="text/html" title="Rebuilding My Resume Site From the Ground Up" /><published>2026-05-14T00:00:00+00:00</published><updated>2026-05-14T00:00:00+00:00</updated><id>https://mcgarrah.org/resume-site-ground-up-rebuild</id><content type="html" xml:base="https://mcgarrah.org/resume-site-ground-up-rebuild/"><![CDATA[<p>My <a href="https://mcgarrah.org/resume/">resume site</a> started life in 2017 as a fork of the <a href="https://github.com/sharu725/developer-theme">orbit-theme</a> — a Bootstrap 3 sidebar layout with jQuery skill bar animations, Font Awesome via CDN, and IE8 conditional comments. It served its purpose for years, but the technical debt had compounded to the point where every change required understanding decisions made for a different era.</p>

<p>The trigger was a content rewrite. I sat down to update five years of work at Envestnet — a role that had evolved from platform engineer to cross-enterprise operator to compliance leader to AI/ML initiator — and realized the template was fighting me at every turn. Condensing that arc into clear, quantified impact statements was hard enough without also wrestling with a sidebar layout that wasted half the viewport and a Pandoc pipeline that needed 16 regex patterns to produce a clean PDF. The content challenge made the architectural debt impossible to ignore. To be fair, I built some of this complexity myself in the rush to get resume content out quickly — so the debt was partly my own making.</p>

<p>Over the past two weeks I executed a ground-up rebuild — 80 commits across 9 days. New architecture, new content voice, new export pipeline, and a few improvements to the blog along the way.</p>

<!-- excerpt-end -->

<h2 id="what-was-wrong">What Was Wrong</h2>

<p>The short version: the site loaded Bootstrap 3.4.1, jQuery, and the full Font Awesome library to render what is fundamentally a text document. The only JavaScript usage was animating skill progress bars — a feature I was removing anyway. IE8 shims were still in the <code class="language-plaintext highlighter-rouge">&lt;head&gt;</code>. A Liquid-based HTML minifier (<code class="language-plaintext highlighter-rouge">compress.html</code>) added complexity for negligible benefit. Nine SCSS color skins existed for a site that used exactly one.</p>

<p>The deeper problem was structural. The sidebar layout wasted horizontal space on a content-dense resume, and it made Pandoc exports painful. My <a href="/jekyll-pandoc-exports-plugin/">jekyll-pandoc-exports</a> plugin needed 16 regex patterns to strip sidebar markup, icon stacks, and CDN references before Pandoc could produce a clean PDF. That is not a sustainable architecture.</p>

<h2 id="the-sunk-cost-problem">The Sunk Cost Problem</h2>

<p>I had invested significantly in the old template. Over 107 commits across nine years, I had:</p>

<ul>
  <li>Upgraded jQuery from 1.11.3 to 3.7.1 (addressing security vulnerabilities)</li>
  <li>Upgraded Bootstrap from 3.3.6 to 3.4.1 (the last 3.x release)</li>
  <li>Upgraded Font Awesome from the original 4.x local copy to 5.1.1, then to 6.6.0, then to 6.7.2 via CDN</li>
  <li>Converted all static dependencies from local copies to CDN with SRI integrity hashes (saving ~2MB of repo bloat)</li>
  <li>Added Dependabot monitoring, then immediately had to pin Bootstrap to <code class="language-plaintext highlighter-rouge">~3.4.x</code> and Font Awesome to <code class="language-plaintext highlighter-rouge">^6.x</code> to prevent Dependabot from proposing breaking major version upgrades</li>
  <li>Added a print view, certifications section, publications section, OSS contributions</li>
  <li>Created dark mode SCSS skins (that I never shipped)</li>
  <li>Built the <code class="language-plaintext highlighter-rouge">details</code>/<code class="language-plaintext highlighter-rouge">summary</code> collapsible pattern for the brief view</li>
  <li>Added GitHub Actions deployment, replacing the old <code class="language-plaintext highlighter-rouge">github-pages</code> gem approach</li>
</ul>

<p>Each of those upgrades was defensible in isolation. jQuery 1.11.3 had known CVEs — upgrading to 3.7.1 was the responsible thing to do. Font Awesome 4.x was end-of-life — moving to 6.x was correct. Converting to CDN with SRI hashes was a security best practice.</p>

<p>But stepping back, the pattern was clear: I was spending effort maintaining dependencies the site did not actually need. Bootstrap provided a CSS reset and some utility classes — my layout was already CSS Grid. jQuery animated skill bars — a feature I wanted to remove. Font Awesome provided icons — I used fifteen of them. Every upgrade was polishing a dependency that should have been deleted.</p>

<p>This is the same pattern I see in enterprise architecture. Teams invest years upgrading Oracle 11g to 12c to 19c, carefully managing breaking changes and compatibility matrices, when the real question is whether they should be on PostgreSQL. The sunk cost of previous upgrades makes the “just upgrade again” path feel safer than the “start fresh” path — even when starting fresh is objectively less total effort and produces a better result.</p>

<p>The moment I realized I was writing commit messages like “Pin Bootstrap to 3.4.x to prevent major version upgrades” — actively fighting my own dependency management tooling to keep a library I barely used — was the moment the refactor became inevitable. The decision framework is simple: if you are spending more effort managing a dependency than the value it provides, the correct action is removal, not another upgrade cycle.</p>

<h2 id="the-rebuild-four-views-one-data-source">The Rebuild: Four Views, One Data Source</h2>

<p>Everything renders from <code class="language-plaintext highlighter-rouge">_data/data.yml</code>. No content duplication across views.</p>

<table>
  <thead>
    <tr>
      <th>View</th>
      <th>URL</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Brief</strong></td>
      <td><a href="https://mcgarrah.org/resume/"><code class="language-plaintext highlighter-rouge">/resume/</code></a></td>
      <td>Scannable, collapsible sections for recruiters</td>
    </tr>
    <tr>
      <td><strong>Print</strong></td>
      <td><a href="https://mcgarrah.org/resume/print/"><code class="language-plaintext highlighter-rouge">/resume/print/</code></a></td>
      <td>Fully expanded, comprehensive — the canonical reference</td>
    </tr>
    <tr>
      <td><strong>Ultra-Brief</strong></td>
      <td><a href="https://mcgarrah.org/resume/ultra-brief/"><code class="language-plaintext highlighter-rouge">/resume/ultra-brief/</code></a></td>
      <td>Two-page elevator pitch for job boards and quick reads</td>
    </tr>
    <tr>
      <td><strong>Machine</strong></td>
      <td><a href="https://mcgarrah.org/resume/machine/"><code class="language-plaintext highlighter-rouge">/resume/machine/</code></a></td>
      <td>JSON-LD + semantic HTML for AI agents and ATS systems</td>
    </tr>
  </tbody>
</table>

<p>The brief view uses native <code class="language-plaintext highlighter-rouge">&lt;details&gt;</code>/<code class="language-plaintext highlighter-rouge">&lt;summary&gt;</code> elements for progressive disclosure — no JavaScript required. The print view is linear HTML that Pandoc converts cleanly and serves as the comprehensive version that the shorter views link back to. The ultra-brief is a self-contained two-page resume — the kind you hand someone in an elevator — with every job title linking to its full entry in the print view via stable anchors. The machine view provides Schema.org structured data that makes the resume trivially parseable by recruiting tools.</p>

<h2 id="what-got-dropped">What Got Dropped</h2>

<ul>
  <li>Bootstrap 3.4.1 (replaced by ~200 lines of hand-rolled CSS with CSS Grid)</li>
  <li>jQuery (zero JavaScript for presentation)</li>
  <li>Font Awesome CDN (replaced by an inline SVG sprite with ~12 icons)</li>
  <li>IE8/IE9 conditional comments and shims</li>
  <li><code class="language-plaintext highlighter-rouge">compress.html</code> Liquid minifier</li>
  <li>Nine unused SCSS color skins</li>
  <li><code class="language-plaintext highlighter-rouge">github-pages</code> gem (80+ transitive dependencies, conflicts with Jekyll 4.x)</li>
  <li>Sidebar layout entirely</li>
  <li>Skill bar animations</li>
  <li>Legacy static PDF snapshots in <code class="language-plaintext highlighter-rouge">assets/pdf/</code></li>
  <li>Obsolete template files from the original orbit-theme</li>
</ul>

<h2 id="what-got-built">What Got Built</h2>

<h3 id="content-overhaul-first">Content Overhaul First</h3>

<p>Before touching the architecture, I rewrote the content. Five years at Envestnet meant five years of scope expansion — from a single platform to 20+ AWS accounts, from isolated evidence requests to leading eight simultaneous SOC audits, from supporting a data science team to delivering the first AI/ML production workload on the billing platform. Capturing that progression in concise, impact-focused language was the hardest part of the entire project. Every position got a fresh voice — clearer impact statements, better quantification, leadership framing where appropriate. I added consulting roles that had been missing (some dating back to the early 2000s), added recently published Python packages to the projects section, and restructured experience entries from flat markdown blobs into a structured <code class="language-plaintext highlighter-rouge">subsections</code> array with explicit titles. That last change solved a Pandoc rendering problem where job titles and subsection headings rendered at identical visual weight in PDF output.</p>

<h3 id="modern-css-with-lightdark-mode">Modern CSS with Light/Dark Mode</h3>

<p>The entire stylesheet is CSS custom properties with a <code class="language-plaintext highlighter-rouge">prefers-color-scheme</code> media query. The site respects the user’s OS setting automatically — no JavaScript toggle, no cookie, no flash of wrong theme.</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">:root</span> <span class="p">{</span>
  <span class="py">--bg</span><span class="p">:</span> <span class="nx">#ffffff</span><span class="p">;</span>
  <span class="py">--text</span><span class="p">:</span> <span class="nx">#3F4650</span><span class="p">;</span>
  <span class="py">--accent</span><span class="p">:</span> <span class="nx">#4B6A78</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">@media</span> <span class="p">(</span><span class="n">prefers-color-scheme</span><span class="p">:</span> <span class="n">dark</span><span class="p">)</span> <span class="p">{</span>
  <span class="nd">:root</span> <span class="p">{</span>
    <span class="py">--bg</span><span class="p">:</span> <span class="nx">#1a1a2e</span><span class="p">;</span>
    <span class="py">--text</span><span class="p">:</span> <span class="nx">#e0e0e0</span><span class="p">;</span>
    <span class="py">--accent</span><span class="p">:</span> <span class="nx">#7fb3c8</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="jinja2xelatex-pdf-pipeline">Jinja2/XeLaTeX PDF Pipeline</h3>

<p>The Pandoc-based export still works for quick DOCX generation, but for PDF I built a separate Python pipeline. A Jinja2 template reads the same <code class="language-plaintext highlighter-rouge">_data/data.yml</code> and produces LaTeX source that XeLaTeX compiles with full typographic control — proper font selection, precise spacing, and a visual hierarchy that CSS-to-PDF converters cannot match.</p>

<p>I evaluated WeasyPrint first (CSS-to-PDF via Python) and rejected it after a day. The rendering was acceptable for simple documents but lacked the fine-grained typographic control required for a professional resume — page break placement, precise header spacing, proper LaTeX ligatures, and conditional content based on template variant. XeLaTeX gives full control over every aspect of the output. The trade-off is build complexity (LaTeX toolchain installation), but that cost is paid once in CI and amortized across every subsequent build.</p>

<p>Three template variants exist:</p>

<ul>
  <li><strong>Long</strong> — Full detail, multi-page with company logos (34 pages with everything)</li>
  <li><strong>Brief</strong> — Curated highlights, 5 pages</li>
  <li><strong>Ultra-brief</strong> — Aggressive two-page format for job boards that penalize length</li>
</ul>

<p>The pipeline lives in <code class="language-plaintext highlighter-rouge">bin/generate-latex.py</code> and runs independently of Jekyll. Same YAML data, different rendering engine, purpose-built output.</p>

<h3 id="company-and-university-logos">Company and University Logos</h3>

<p>Both the HTML views and the LaTeX PDFs now include company and university logos alongside experience and education entries. I wanted the visual appeal that LinkedIn profiles have — a recognizable logo next to each role grounds the reader and adds credibility at a glance.</p>

<p>The implementation was more work than expected. The challenges fell into three categories:</p>

<p><strong>Finding logos for defunct companies.</strong> Q+E Software was acquired by Intersolv in 1994, which became Merant, then Serena, then Micro Focus. No digital logo assets survive online — the only path would be scanning physical materials from the 1990s. Hosted Solutions (a Raleigh ISP from 2004) required the Wayback Machine. NC LIVE’s original purple logo from 2000 was similarly archived. For companies truly lost to history, I created custom SVG icons — a generic tooth for a dental practice, a database cylinder for a database consulting firm.</p>

<p><strong>SVG clipping and viewBox manipulation.</strong> Many SVGs include both an icon mark and a wordmark. At 48px display size, the wordmark is unreadable — you want just the icon. The technique is adjusting the <code class="language-plaintext highlighter-rouge">viewBox</code> to “zoom in” on the icon portion: find the coordinate boundaries of the mark by examining path data, then set a cropped viewBox that frames just that area. This worked for USPS (eagle only), Measurement Incorporated (M+I+caliper only), and the AKC shield.</p>

<p>Except when it did not work. The AKC SVG uses a transform matrix (<code class="language-plaintext highlighter-rouge">matrix(9.37,0,0,9.37,-2975,-5501)</code>) with <code class="language-plaintext highlighter-rouge">overflow:visible</code> — the content renders at absolute positions regardless of viewBox. The fallback was rendering the full SVG to a high-resolution PNG with <code class="language-plaintext highlighter-rouge">cairosvg</code>, then cropping the raster image with Pillow. Sometimes the pragmatic solution wins.</p>

<p><strong>Converting for LaTeX.</strong> XeLaTeX cannot embed SVG files directly. A Python script (<code class="language-plaintext highlighter-rouge">bin/convert_logos_to_png.py</code>) converts all SVGs to 400px-wide PNGs with transparent backgrounds using <code class="language-plaintext highlighter-rouge">cairosvg</code>, then the LaTeX template includes them with <code class="language-plaintext highlighter-rouge">\includegraphics</code>. Getting <code class="language-plaintext highlighter-rouge">\IfFileExists</code> paths correct so missing logos degrade gracefully (rather than crashing the build) took more iterations than I would like to admit.</p>

<p>The result is a <code class="language-plaintext highlighter-rouge">preview.html</code> page that shows all logos at resume scale in both light and dark mode — essential for catching dark-fill logos that disappear on dark backgrounds. The full inventory, sources, and lessons learned live in the <a href="https://github.com/mcgarrah/resume/blob/main/assets/images/company-logos/README.md">logo README</a>.</p>

<h3 id="ultra-brief-view">Ultra-Brief View</h3>

<p>Beyond the three original planned views, I added an ultra-brief HTML page at <code class="language-plaintext highlighter-rouge">/resume/ultra-brief/</code> — a self-contained, two-page resume with inline styles. It is designed for the “paste your resume” fields on job boards where you need maximum density. The corresponding XeLaTeX template produces a matching PDF.</p>

<h3 id="stable-anchor-ids--linking-views-together">Stable Anchor IDs — Linking Views Together</h3>

<p>A problem I did not anticipate: how do the brief and ultra-brief views link back to the comprehensive view? When a recruiter reads the two-page ultra-brief PDF and wants more detail on a specific role, they need a reliable URL that takes them directly to that entry in the full <code class="language-plaintext highlighter-rouge">/resume/print/</code> view.</p>

<p>The solution was adding an <code class="language-plaintext highlighter-rouge">anchor</code> field to every experience and education entry in <code class="language-plaintext highlighter-rouge">_data/data.yml</code> — a stable, human-readable ID following the convention <code class="language-plaintext highlighter-rouge">{company-slug}-{start-year}</code> (e.g., <code class="language-plaintext highlighter-rouge">envestnet-2021</code>, <code class="language-plaintext highlighter-rouge">edu-gatech-2014</code>). These anchors are rendered as HTML <code class="language-plaintext highlighter-rouge">id</code> attributes in the print view, and the ultra-brief PDF links each job title to <code class="language-plaintext highlighter-rouge">mcgarrah.org/resume/print/#envestnet-2021</code>.</p>

<p>The critical constraint: <strong>these anchors must never change.</strong> They are embedded in PDFs I hand to recruiters, linked from LinkedIn posts, and referenced in external documents. A broken anchor link in a resume PDF reflects poorly in exactly the way you cannot afford when job hunting. I added a stability warning comment at the top of <code class="language-plaintext highlighter-rouge">data.yml</code> and wrote the anchor generation as a one-time script (<code class="language-plaintext highlighter-rouge">bin/add_anchors.py</code>) so the IDs are deterministic and reproducible — not generated dynamically from content that might shift.</p>

<p>This turned out to be one of the more important architectural decisions. The different views are not isolated documents — they are a connected system where the brief versions serve as entry points that funnel interested readers toward the comprehensive version.</p>

<h3 id="machine-readable-structured-data">Machine-Readable Structured Data</h3>

<p>The <code class="language-plaintext highlighter-rouge">/resume/machine/</code> view embeds two JSON-LD blocks — a <code class="language-plaintext highlighter-rouge">WebPage</code> descriptor and a full <code class="language-plaintext highlighter-rouge">Person</code> entity with 17 credentials, 27 occupations, and semantic markup on every content element. The HTML uses <code class="language-plaintext highlighter-rouge">&lt;article&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;section&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;time datetime="..."&gt;</code>, and Schema.org microdata attributes throughout.</p>

<p>After deploying to production, I ran Google’s Rich Results Test and fixed the Schema.org validation warnings it flagged — mostly <code class="language-plaintext highlighter-rouge">ScholarlyArticle</code> type issues in the publications section. The structured data now validates cleanly.</p>

<h3 id="seo-and-social-sharing--the-cascade-to-the-blog">SEO and Social Sharing — The Cascade to the Blog</h3>

<p>Adding an Open Graph image (<code class="language-plaintext highlighter-rouge">og:image</code>) to the resume site was straightforward — one branded SVG rendered to PNG, referenced in the config defaults. But validating the resume’s <code class="language-plaintext highlighter-rouge">/resume/machine/</code> view with Google’s Rich Results Test revealed a broader gap: Google’s Article rich results require an <code class="language-plaintext highlighter-rouge">image</code> property on every page. My blog had 117 published posts, none with OG images.</p>

<p>This became the single biggest change to the blog during this sprint. I wrote a Python script (<code class="language-plaintext highlighter-rouge">bin/generate-og-images.py</code>) that generates branded social preview cards — SVG templates with the post title rendered in, converted to PNG via <code class="language-plaintext highlighter-rouge">cairosvg</code>. A companion script (<code class="language-plaintext highlighter-rouge">bin/update-og-frontmatter.py</code>) added the <code class="language-plaintext highlighter-rouge">image:</code> front matter field to all 117 posts. One commit, 350 files.</p>

<p>The blog also gained <code class="language-plaintext highlighter-rouge">BlogPosting</code> structured data support in <code class="language-plaintext highlighter-rouge">_config.yml</code>, enabling Article rich results across all posts. Both changes — the OG images and the structured data — trace directly back to the resume’s machine view work. Building the resume’s JSON-LD made me realize the blog was missing the same SEO fundamentals that I had just implemented for the resume.</p>

<p>This is the kind of cross-pollination that happens when you maintain related sites on the same domain. Improving one surfaces gaps in the other.</p>

<h3 id="ci-pipeline-improvements">CI Pipeline Improvements</h3>

<p>The GitHub Actions workflow now handles the full build pipeline:</p>

<ul>
  <li>Jekyll build (HTML views)</li>
  <li>XeLaTeX compilation (long, brief, and ultra-brief PDFs)</li>
  <li><code class="language-plaintext highlighter-rouge">html-proofer</code> for link integrity</li>
  <li>Apt package caching to cut 6+ minutes off deploy time</li>
</ul>

<p>The apt caching was a nice win — XeLaTeX and its dependencies are large packages, and caching them between runs makes the CI feedback loop much tighter.</p>

<h3 id="developer-experience">Developer Experience</h3>

<p>Small things that made the iteration faster:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">jekyll-start.sh</code> with automatic port detection and a <code class="language-plaintext highlighter-rouge">--clean</code> flag</li>
  <li><code class="language-plaintext highlighter-rouge">jekyll-clean.sh</code> with soft/hard clean modes</li>
  <li>Sass modernization (<code class="language-plaintext highlighter-rouge">@import</code> → <code class="language-plaintext highlighter-rouge">@use</code>) to eliminate deprecation warnings</li>
  <li>Added Substack profile link across all views</li>
</ul>

<h2 id="the-dual-language-architecture">The Dual-Language Architecture</h2>

<p>The site now runs two rendering stacks deliberately:</p>

<ul>
  <li><strong>Ruby (Jekyll)</strong> — HTML views for the web (brief, print, machine, ultra-brief)</li>
  <li><strong>Python (Jinja2 + XeLaTeX)</strong> — PDF generation with full typographic control</li>
</ul>

<p>Both read from <code class="language-plaintext highlighter-rouge">_data/data.yml</code>. This is not accidental complexity — it is a deliberate architectural decision. Each tool does what it is best at. Jekyll excels at templating HTML for the web. XeLaTeX excels at typesetting documents for print. Trying to make one tool do both jobs is how you end up with 16 regex cleanup patterns — the same kind of impedance mismatch you see when teams force a single CI/CD tool to handle both container builds and infrastructure provisioning.</p>

<p>The shared data layer (<code class="language-plaintext highlighter-rouge">data.yml</code>) is the integration point. Content changes propagate to all outputs automatically. The rendering engines are independent and replaceable — if a better LaTeX alternative emerges, or if Jekyll is eventually replaced, the data layer remains stable.</p>

<h2 id="blog-improvements-along-the-way">Blog Improvements (Along the Way)</h2>

<p>Beyond the OG image and structured data work described above, the blog picked up one housekeeping item:</p>

<ul>
  <li><strong>OG image publishing workflow</strong> — Documented the generation and front matter update process so future posts get OG images as part of the standard publishing workflow rather than as a bulk backfill.</li>
</ul>

<h2 id="what-is-next">What is Next</h2>

<p>The structural rebuild is done — the foundation is solid. What remains is building on top of it:</p>

<ul>
  <li><strong>In-browser search</strong> — not Google Custom Search (which I actively dislike on the blog but needed something quick). For the resume, I want purpose-built semantic search that lets recruiters query by skill, role, or technology and get linked results across experiences. Pagefind is the pragmatic first step — static indexing at build time, swappable later for something smarter.</li>
  <li><strong>Per-entry skills taxonomy</strong> — explicit skill-to-experience mapping for ATS keyword matching</li>
  <li><strong>AI agent integration</strong> — conversational interface backed by the machine view’s structured data</li>
</ul>

<p>The skills taxonomy is the one I am most interested in. Right now, skills live in a flat list at the bottom of the resume — disconnected from the experiences that developed them. I want to build a semantic map: which skills were used at which jobs, for how long, and how they cluster. The stable anchor IDs already give each experience entry a permanent address. Adding per-entry skill annotations creates the edges in a graph — connecting “Kubernetes” not just to a skills list but to specific roles, specific years, specific outcomes.</p>

<p>That kind of structured relationship data opens up interesting possibilities — semantic search across the resume (“show me everything involving Kubernetes in production”), automatic keyword optimization for specific job descriptions, and eventually feeding richer context to an AI agent that can answer recruiter questions with grounded, specific evidence rather than generic summaries. When someone asks “how long have you worked with EKS?” the answer should not be a number — it should be a linked trail through five years of specific clusters, upgrades, and incidents.</p>

<p>The machine view’s JSON-LD already provides the foundation. The next step is enriching it with per-entry skill annotations and seeing what becomes possible when the resume is not just a document but a queryable knowledge graph.</p>

<p>The broader implication: a resume should not be a static document you update twice a year. It should be a living system — structured data that multiple consumers can query, render, and reason about in ways appropriate to their needs. The rebuild gives me that foundation.</p>

<p>The site is live at <a href="https://mcgarrah.org/resume/">mcgarrah.org/resume/</a> and the source is on <a href="https://github.com/mcgarrah/resume">GitHub</a>.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<p><strong>Legacy templates accumulate invisible debt.</strong> The orbit-theme worked fine in 2017. By 2026 it was carrying 500KB of unused dependencies and architectural decisions that actively fought every change. This is true of any system that grows by accretion — the cost is not in any single dependency but in the aggregate maintenance burden and the cognitive overhead of understanding why each piece exists.</p>

<p><strong>Separate concerns by output format.</strong> One rendering engine for web, another for print. The shared data layer (<code class="language-plaintext highlighter-rouge">data.yml</code>) is the integration point — not shared templates trying to serve both masters. This is the same principle behind separating API contracts from implementation: define the interface once, let each consumer render it appropriately.</p>

<p><strong>Evaluate quickly, decide quickly.</strong> I tried WeasyPrint, found it lacking for my requirements, and removed it the same day. The git history shows the full arc: add WeasyPrint → evaluate → remove → build XeLaTeX pipeline. Rapid prototyping with a willingness to discard is faster than extended analysis paralysis. The key is having clear acceptance criteria before you start evaluating.</p>

<p><strong>Structured data pays compound interest.</strong> The machine view took a day to build but serves three purposes: SEO, ATS compatibility, and future AI agent grounding context. Investments that serve multiple stakeholders from a single implementation are the highest-leverage work you can do.</p>

<p><strong>Treat your views as a linked system, not isolated documents.</strong> The brief versions are entry points that funnel readers toward the comprehensive version. Stable anchor IDs in the data layer make that linking reliable — and once those IDs are in external PDFs, they are a contract you cannot break. This is the same principle as API versioning: once you publish an interface, backward compatibility becomes a constraint.</p>

<p><strong>CSS has caught up.</strong> CSS Grid, custom properties, <code class="language-plaintext highlighter-rouge">clamp()</code>, <code class="language-plaintext highlighter-rouge">:has()</code>, <code class="language-plaintext highlighter-rouge">prefers-color-scheme</code> — you genuinely do not need a framework for a content site in 2026. The entire stylesheet is under 200 lines. Know when your dependencies have been superseded by the platform itself.</p>

<p><strong>Cache your CI dependencies.</strong> Six minutes of apt downloads on every push adds up fast when you are iterating on LaTeX templates. One caching step fixed it. Build pipeline optimization is not glamorous work, but it directly multiplies developer velocity.</p>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="web-development" /><category term="technical" /><category term="jekyll" /><category term="jekyll" /><category term="resume" /><category term="css" /><category term="pandoc" /><category term="pdf" /><category term="xelatex" /><category term="structured-data" /><category term="json-ld" /><category term="github-pages" /><category term="seo" /><summary type="html"><![CDATA[A walkthrough of rebuilding a Jekyll resume site from a legacy Bootstrap 3 template to a modern single-column layout with light/dark mode, semantic HTML, JSON-LD structured data, company logos in PDF exports, and a dual Ruby/Python pipeline producing professional PDFs via XeLaTeX.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/resume-site-ground-up-rebuild.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/resume-site-ground-up-rebuild.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Visual Indicators for Draft and Future Posts in Jekyll</title><link href="https://mcgarrah.org/jekyll-draft-future-visual-indicators/" rel="alternate" type="text/html" title="Visual Indicators for Draft and Future Posts in Jekyll" /><published>2026-05-13T00:00:00+00:00</published><updated>2026-05-13T00:00:00+00:00</updated><id>https://mcgarrah.org/jekyll-draft-future-visual-indicators</id><content type="html" xml:base="https://mcgarrah.org/jekyll-draft-future-visual-indicators/"><![CDATA[<p>My previous article on <a href="/jekyll-run-vscode-plugin-local-development/">Jekyll Run plugin configuration</a> documented a frustrating problem: when you run <code class="language-plaintext highlighter-rouge">jekyll serve --drafts --future</code>, draft and future-dated posts appear in your listings but look identical to published posts. You can’t tell at a glance which articles are live on production and which are still waiting.</p>

<p>After scrolling past 130+ posts trying to spot my drafts one too many times, I added visual indicators — a pencil icon for drafts, a robot icon for future-dated posts (because robots are cool and futuristic), and italic text for both. The indicators only appear during local development because drafts and future posts don’t exist in production builds. Making system state visible at a glance is a UX principle that applies equally to monitoring dashboards, CI/CD pipelines, and content management — if you have to dig to find the status, the status isn’t working.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">Drafts</th>
      <th style="text-align: center">Future</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><svg aria-label="Draft" class="icon icon-status" style="height:1.7em;width:1.7em" title="Draft"><use xlink:href="/assets/fontawesome/icons.svg#pencil-alt"></use></svg> (PENCIL)</td>
      <td style="text-align: center"><svg aria-label="Future" class="icon icon-status" style="height:1.7em;width:1.7em" title="Scheduled"><use xlink:href="/assets/fontawesome/icons.svg#robot"></use></svg> (ROBOT)</td>
    </tr>
  </tbody>
</table>

<!-- excerpt-end -->

<h2 id="the-problem">The Problem</h2>

<p>Running <code class="language-plaintext highlighter-rouge">jekyll serve --drafts --future --unpublished</code> renders everything into <code class="language-plaintext highlighter-rouge">site.posts</code>. The archive page, home page, and paginated listings all show drafts and future posts mixed in with published content. There’s no visual distinction.</p>

<p>This matters when you have 50 drafts and 5 future-dated posts queued up. You want to:</p>

<ul>
  <li>Quickly identify what’s live vs what’s scheduled</li>
  <li>Spot drafts that accidentally have <code class="language-plaintext highlighter-rouge">published: true</code> (they’ll deploy if moved to <code class="language-plaintext highlighter-rouge">_posts/</code>)</li>
  <li>Verify that future-dated posts have the correct dates before they go live</li>
</ul>

<h2 id="what-jekyll-exposes">What Jekyll Exposes</h2>

<p>Before building anything, I needed to confirm what data Jekyll makes available in templates.</p>

<p><strong>Future posts</strong> are straightforward. Every post has a <code class="language-plaintext highlighter-rouge">post.date</code>, and Jekyll provides <code class="language-plaintext highlighter-rouge">site.time</code> (the build timestamp). Compare them:</p>

<div class="language-liquid highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{%</span><span class="w"> </span><span class="nt">if</span><span class="w"> </span><span class="nv">post</span><span class="p">.</span><span class="nv">date</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="nv">site</span><span class="p">.</span><span class="nv">time</span><span class="w"> </span><span class="cp">%}</span>
  &lt;!-- this post is future-dated --&gt;
<span class="cp">{%</span><span class="w"> </span><span class="nt">endif</span><span class="w"> </span><span class="cp">%}</span>
</code></pre></div></div>

<p><strong>Drafts</strong> are trickier. Jekyll doesn’t set a <code class="language-plaintext highlighter-rouge">post.draft</code> flag or expose the source collection. But drafts come from the <code class="language-plaintext highlighter-rouge">_drafts/</code> directory, and that path is available:</p>

<div class="language-liquid highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{%</span><span class="w"> </span><span class="nt">if</span><span class="w"> </span><span class="nv">post</span><span class="p">.</span><span class="nv">path</span><span class="w"> </span><span class="ow">contains</span><span class="w"> </span><span class="s1">'_drafts/'</span><span class="w"> </span><span class="cp">%}</span>
  &lt;!-- this post is a draft --&gt;
<span class="cp">{%</span><span class="w"> </span><span class="nt">endif</span><span class="w"> </span><span class="cp">%}</span>
</code></pre></div></div>

<p>This works because <code class="language-plaintext highlighter-rouge">post.path</code> contains the relative path from the site root, including the directory name.</p>

<p><strong>In production</strong>, neither check matters — drafts and future posts aren’t in <code class="language-plaintext highlighter-rouge">site.posts</code> at all when building without <code class="language-plaintext highlighter-rouge">--drafts</code> and <code class="language-plaintext highlighter-rouge">--future</code>. The conditionals are inert. No performance cost, no risk of leaking unpublished content.</p>

<h2 id="the-implementation">The Implementation</h2>

<h3 id="adding-icons-to-the-svg-sprite">Adding Icons to the SVG Sprite</h3>

<p>This blog uses a Font Awesome SVG sprite that’s built at compile time from icons referenced in <code class="language-plaintext highlighter-rouge">_config.yml</code>. Only icons listed under <code class="language-plaintext highlighter-rouge">navigation</code> and <code class="language-plaintext highlighter-rouge">external</code> get included. To add the draft and future icons without polluting those lists, I added a new config key:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># _config.yml</span>
<span class="na">post_status_icons</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="pi">{</span><span class="nv">icon</span><span class="pi">:</span> <span class="nv">pencil-alt</span><span class="pi">}</span>     <span class="c1"># draft posts</span>
  <span class="pi">-</span> <span class="pi">{</span><span class="nv">icon</span><span class="pi">:</span> <span class="nv">robot</span><span class="pi">}</span>          <span class="c1"># future-dated posts</span>
</code></pre></div></div>

<p>And extended the sprite template to include them:</p>

<div class="language-liquid highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;!-- assets/fontawesome/icons.svg --&gt;
<span class="cp">{%</span><span class="w"> </span><span class="nt">assign</span><span class="w"> </span><span class="nv">keys</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">'navigation,external,post_status_icons'</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">split</span><span class="p">:</span><span class="w"> </span><span class="s1">','</span><span class="w"> </span><span class="cp">%}</span>
<span class="cp">{%</span><span class="w"> </span><span class="nt">for</span><span class="w"> </span><span class="nv">key</span><span class="w"> </span><span class="nt">in</span><span class="w"> </span><span class="nv">keys</span><span class="w"> </span><span class="cp">%}</span>
<span class="cp">{%</span><span class="w"> </span><span class="nt">for</span><span class="w"> </span><span class="nv">link</span><span class="w"> </span><span class="nt">in</span><span class="w"> </span><span class="nv">site</span><span class="p">[</span><span class="nv">key</span><span class="p">]</span><span class="w"> </span><span class="cp">%}</span>
  <span class="cp">{%</span><span class="w"> </span><span class="nt">assign</span><span class="w"> </span><span class="nv">icon</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">link</span><span class="p">.</span><span class="nv">icon</span><span class="w"> </span><span class="cp">%}</span>
  <span class="cp">{%</span><span class="w"> </span><span class="nt">assign</span><span class="w"> </span><span class="nv">svg</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">site</span><span class="p">.</span><span class="nv">data</span><span class="p">.</span><span class="nv">font-awesome</span><span class="p">.</span><span class="nv">icons</span><span class="p">[</span><span class="nv">icon</span><span class="p">].</span><span class="nv">svg</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">first</span><span class="w"> </span><span class="cp">%}</span>
  &lt;symbol id="<span class="cp">{{</span><span class="w"> </span><span class="nv">icon</span><span class="w"> </span><span class="cp">}}</span>" viewBox="0 0 <span class="cp">{{</span><span class="w"> </span><span class="nv">svg</span><span class="p">[</span><span class="mi">1</span><span class="p">].</span><span class="nv">width</span><span class="w"> </span><span class="cp">}}</span> <span class="cp">{{</span><span class="w"> </span><span class="nv">svg</span><span class="p">[</span><span class="mi">1</span><span class="p">].</span><span class="nv">height</span><span class="w"> </span><span class="cp">}}</span>"&gt;
    &lt;path d="<span class="cp">{{</span><span class="w"> </span><span class="nv">svg</span><span class="p">[</span><span class="mi">1</span><span class="p">].</span><span class="nv">path</span><span class="w"> </span><span class="cp">}}</span>" /&gt;
  &lt;/symbol&gt;
<span class="cp">{%</span><span class="w"> </span><span class="nt">endfor</span><span class="w"> </span><span class="cp">%}</span>
<span class="cp">{%</span><span class="w"> </span><span class="nt">endfor</span><span class="w"> </span><span class="cp">%}</span>
</code></pre></div></div>

<p>This adds exactly two SVG symbols to the sprite — no CDN load, no external requests.</p>

<h3 id="archive-page">Archive Page</h3>

<p>The <code class="language-plaintext highlighter-rouge">_includes/archive.html</code> gets the detection logic and conditional rendering:</p>

<div class="language-liquid highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{%</span><span class="w"> </span><span class="nt">for</span><span class="w"> </span><span class="nv">post</span><span class="w"> </span><span class="nt">in</span><span class="w"> </span><span class="nv">site</span><span class="p">.</span><span class="nv">posts</span><span class="w"> </span><span class="cp">%}</span>
<span class="cp">{%-</span><span class="w"> </span><span class="nt">assign</span><span class="w"> </span><span class="nv">is_draft</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">false</span><span class="w"> </span><span class="cp">-%}</span>
<span class="cp">{%-</span><span class="w"> </span><span class="nt">assign</span><span class="w"> </span><span class="nv">is_future</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">false</span><span class="w"> </span><span class="cp">-%}</span>
<span class="cp">{%-</span><span class="w"> </span><span class="nt">if</span><span class="w"> </span><span class="nv">post</span><span class="p">.</span><span class="nv">path</span><span class="w"> </span><span class="ow">contains</span><span class="w"> </span><span class="s1">'_drafts/'</span><span class="w"> </span><span class="cp">-%}{%-</span><span class="w"> </span><span class="nt">assign</span><span class="w"> </span><span class="nv">is_draft</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="cp">-%}{%-</span><span class="w"> </span><span class="nt">endif</span><span class="w"> </span><span class="cp">-%}</span>
<span class="cp">{%-</span><span class="w"> </span><span class="nt">if</span><span class="w"> </span><span class="nv">post</span><span class="p">.</span><span class="nv">date</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="nv">site</span><span class="p">.</span><span class="nv">time</span><span class="w"> </span><span class="cp">-%}{%-</span><span class="w"> </span><span class="nt">assign</span><span class="w"> </span><span class="nv">is_future</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="cp">-%}{%-</span><span class="w"> </span><span class="nt">endif</span><span class="w"> </span><span class="cp">-%}</span>
&lt;li&gt;
  &lt;time datetime="<span class="cp">{{</span><span class="w"> </span><span class="nv">post</span><span class="p">.</span><span class="nv">date</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">date_to_xmlschema</span><span class="w"> </span><span class="cp">}}</span>"&gt;<span class="cp">{{</span><span class="w"> </span><span class="nv">post</span><span class="p">.</span><span class="nv">date</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">date</span><span class="p">:</span><span class="w"> </span><span class="s2">"%Y-%m-%d"</span><span class="w"> </span><span class="cp">}}</span>&lt;/time&gt;
  <span class="cp">{%-</span><span class="w"> </span><span class="nt">if</span><span class="w"> </span><span class="nv">is_draft</span><span class="w"> </span><span class="cp">%}</span>
  &lt;svg aria-label="Draft" class="icon icon-status" title="Draft"&gt;
    &lt;use xlink:href="<span class="cp">{{</span><span class="w"> </span><span class="s2">"/assets/fontawesome/icons.svg"</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">relative_url</span><span class="w"> </span><span class="cp">}}</span>#pencil-alt"&gt;&lt;/use&gt;
  &lt;/svg&gt;
  <span class="cp">{%-</span><span class="w"> </span><span class="nt">elsif</span><span class="w"> </span><span class="nv">is_future</span><span class="w"> </span><span class="cp">%}</span>
  &lt;svg aria-label="Future" class="icon icon-status" title="Scheduled"&gt;
    &lt;use xlink:href="<span class="cp">{{</span><span class="w"> </span><span class="s2">"/assets/fontawesome/icons.svg"</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">relative_url</span><span class="w"> </span><span class="cp">}}</span>#robot"&gt;&lt;/use&gt;
  &lt;/svg&gt;
  <span class="cp">{%-</span><span class="w"> </span><span class="nt">endif</span><span class="w"> </span><span class="cp">%}</span>
  <span class="cp">{%-</span><span class="w"> </span><span class="nt">if</span><span class="w"> </span><span class="nv">is_draft</span><span class="w"> </span><span class="ow">or</span><span class="w"> </span><span class="nv">is_future</span><span class="w"> </span><span class="cp">%}</span>
  &lt;em&gt;&lt;a href="<span class="cp">{{</span><span class="w"> </span><span class="nv">post</span><span class="p">.</span><span class="nv">url</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">relative_url</span><span class="w"> </span><span class="cp">}}</span>"&gt;<span class="cp">{{</span><span class="w"> </span><span class="nv">post</span><span class="p">.</span><span class="nv">title</span><span class="w"> </span><span class="cp">}}</span>&lt;/a&gt;&lt;/em&gt;
  <span class="cp">{%-</span><span class="w"> </span><span class="nt">else</span><span class="w"> </span><span class="cp">%}</span>
  &lt;a href="<span class="cp">{{</span><span class="w"> </span><span class="nv">post</span><span class="p">.</span><span class="nv">url</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">relative_url</span><span class="w"> </span><span class="cp">}}</span>"&gt;<span class="cp">{{</span><span class="w"> </span><span class="nv">post</span><span class="p">.</span><span class="nv">title</span><span class="w"> </span><span class="cp">}}</span>&lt;/a&gt;
  <span class="cp">{%-</span><span class="w"> </span><span class="nt">endif</span><span class="w"> </span><span class="cp">%}</span>
&lt;/li&gt;
<span class="cp">{%</span><span class="w"> </span><span class="nt">endfor</span><span class="w"> </span><span class="cp">%}</span>
</code></pre></div></div>

<p>Draft entries get a <svg aria-label="Draft" class="icon icon-status" style="height:1em;width:1em" title="Draft"><use xlink:href="/assets/fontawesome/icons.svg#pencil-alt"></use></svg> pencil icon. Future entries get a <svg aria-label="Future" class="icon icon-status" style="height:1em;width:1em" title="Scheduled"><use xlink:href="/assets/fontawesome/icons.svg#robot"></use></svg> robot icon. Both get italic text. Published posts render normally with no extra markup.</p>

<p>Here’s what future-dated posts look like in the archive with the robot icon and italic styling:</p>

<p><a href="/assets/images/jekyll-visual-indicator-future-robot.png" target="_blank"><img src="/assets/images/jekyll-visual-indicator-future-robot.png" alt="Future post indicators in the archive view" width="75%" height="75%" style="display:block; margin-left:auto; margin-right:auto" /></a></p>

<p>And drafts with the pencil icon:</p>

<p><a href="/assets/images/jekyll-visual-indicator-drafts-pencil.png" target="_blank"><img src="/assets/images/jekyll-visual-indicator-drafts-pencil.png" alt="Draft post indicators in the archive view" width="75%" height="75%" style="display:block; margin-left:auto; margin-right:auto" /></a></p>

<h3 id="home-page-excerpt-views">Home Page Excerpt Views</h3>

<p>The <code class="language-plaintext highlighter-rouge">_includes/meta.html</code> header component passes the detection through as include parameters:</p>

<div class="language-liquid highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{%</span><span class="w"> </span><span class="nt">include</span><span class="w"> </span>meta.html<span class="w"> </span><span class="na">post</span><span class="o">=</span><span class="nv">post</span><span class="w"> </span><span class="na">preview</span><span class="o">=</span><span class="kc">true</span><span class="w"> </span><span class="na">is_draft</span><span class="o">=</span><span class="nv">is_draft</span><span class="w"> </span><span class="na">is_future</span><span class="o">=</span><span class="nv">is_future</span><span class="w"> </span><span class="cp">%}</span>
</code></pre></div></div>

<p>Inside <code class="language-plaintext highlighter-rouge">meta.html</code>, the icon renders next to the post title:</p>

<div class="language-liquid highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;h1&gt;
  &lt;a href="<span class="cp">{{</span><span class="w"> </span><span class="nv">include</span><span class="p">.</span><span class="nv">post</span><span class="p">.</span><span class="nv">url</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">relative_url</span><span class="w"> </span><span class="cp">}}</span>"&gt;<span class="cp">{{</span><span class="w"> </span><span class="nv">include</span><span class="p">.</span><span class="nv">post</span><span class="p">.</span><span class="nv">title</span><span class="w"> </span><span class="cp">}}</span>&lt;/a&gt;
  <span class="cp">{%-</span><span class="w"> </span><span class="nt">if</span><span class="w"> </span><span class="nv">include</span><span class="p">.</span><span class="nv">is_draft</span><span class="w"> </span><span class="cp">%}</span>
  &lt;svg aria-label="Draft" class="icon icon-status" title="Draft"&gt;
    &lt;use xlink:href="<span class="cp">{{</span><span class="w"> </span><span class="s2">"/assets/fontawesome/icons.svg"</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">relative_url</span><span class="w"> </span><span class="cp">}}</span>#pencil-alt"&gt;&lt;/use&gt;
  &lt;/svg&gt;
  <span class="cp">{%-</span><span class="w"> </span><span class="nt">elsif</span><span class="w"> </span><span class="nv">include</span><span class="p">.</span><span class="nv">is_future</span><span class="w"> </span><span class="cp">%}</span>
  &lt;svg aria-label="Future" class="icon icon-status" title="Scheduled"&gt;
    &lt;use xlink:href="<span class="cp">{{</span><span class="w"> </span><span class="s2">"/assets/fontawesome/icons.svg"</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nf">relative_url</span><span class="w"> </span><span class="cp">}}</span>#robot"&gt;&lt;/use&gt;
  &lt;/svg&gt;
  <span class="cp">{%-</span><span class="w"> </span><span class="nt">endif</span><span class="w"> </span><span class="cp">%}</span>
&lt;/h1&gt;
</code></pre></div></div>

<p>The excerpt <code class="language-plaintext highlighter-rouge">&lt;article&gt;</code> wrapper also gets a class for italic styling:</p>

<div class="language-liquid highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;article<span class="cp">{%</span><span class="w"> </span><span class="nt">if</span><span class="w"> </span><span class="nv">is_draft</span><span class="w"> </span><span class="ow">or</span><span class="w"> </span><span class="nv">is_future</span><span class="w"> </span><span class="cp">%}</span> class="post-preview-unpublished"<span class="cp">{%</span><span class="w"> </span><span class="nt">endif</span><span class="w"> </span><span class="cp">%}</span>&gt;
</code></pre></div></div>

<h3 id="css">CSS</h3>

<p>Two additions to <code class="language-plaintext highlighter-rouge">_sass/classes.sass</code>:</p>

<div class="language-sass highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.icon-status</span>
  <span class="nl">height</span><span class="p">:</span> <span class="mi">.85em</span>
  <span class="nl">width</span><span class="p">:</span> <span class="mi">.85em</span>
  <span class="nl">opacity</span><span class="p">:</span> <span class="mi">.6</span>
  <span class="nl">margin</span><span class="p">:</span> <span class="m">0</span> <span class="mi">.2em</span>

<span class="nc">.post-preview-unpublished</span>
  <span class="nl">font-style</span><span class="p">:</span> <span class="nb">italic</span>
</code></pre></div></div>

<p>The status icons are slightly smaller and more transparent than navigation icons — they’re informational, not interactive. The italic class applies to the entire excerpt card for draft and future posts.</p>

<h2 id="files-changed">Files Changed</h2>

<table>
  <thead>
    <tr>
      <th>File</th>
      <th>Change</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">_config.yml</code></td>
      <td>Added <code class="language-plaintext highlighter-rouge">post_status_icons</code> with <code class="language-plaintext highlighter-rouge">pencil-alt</code> and <code class="language-plaintext highlighter-rouge">robot</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">assets/fontawesome/icons.svg</code></td>
      <td>Extended sprite loop to include <code class="language-plaintext highlighter-rouge">post_status_icons</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">_includes/archive.html</code></td>
      <td>Draft/future detection with icons and italics</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">_includes/home.html</code></td>
      <td>Same treatment for paginated excerpt view</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">_includes/meta.html</code></td>
      <td>Icon badge next to post title in headers</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">_layouts/home.html</code></td>
      <td>Same treatment for list-style home layout</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">_layouts/paginate.html</code></td>
      <td>Same treatment for paginate layout</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">_layouts/archive.html</code></td>
      <td>Same treatment for archive layout</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">_sass/classes.sass</code></td>
      <td>Added <code class="language-plaintext highlighter-rouge">.icon-status</code> and <code class="language-plaintext highlighter-rouge">.post-preview-unpublished</code></td>
    </tr>
  </tbody>
</table>

<h2 id="why-these-icons">Why These Icons</h2>

<p><strong>Pencil (pencil-alt)</strong> for drafts — universally understood as “editing” or “work in progress.” It’s already in Font Awesome Free and visually distinct at small sizes.</p>

<p><strong>Robot</strong> for future posts — a nod to the automated scheduled publishing via GitHub Actions cron. The daily build at 00:05 UTC is the “robot” that publishes future-dated posts when their date arrives. It’s also visually distinctive and unlikely to be confused with any other status.</p>

<p>I considered <code class="language-plaintext highlighter-rouge">clock</code> and <code class="language-plaintext highlighter-rouge">hourglass</code> for future posts but they’re too generic — they could mean “reading time” or “loading.” The robot is unambiguous in context.</p>

<h2 id="production-safety">Production Safety</h2>

<p>This feature is inherently safe for production:</p>

<ul>
  <li><strong>No <code class="language-plaintext highlighter-rouge">--drafts</code> flag</strong> → no drafts in <code class="language-plaintext highlighter-rouge">site.posts</code> → no pencil icons rendered</li>
  <li><strong>No <code class="language-plaintext highlighter-rouge">--future</code> flag</strong> → no future posts in <code class="language-plaintext highlighter-rouge">site.posts</code> → no robot icons rendered</li>
  <li>The Liquid conditionals evaluate to false and produce zero HTML output</li>
  <li>The SVG sprite includes the two extra icon symbols (~2KB), but they’re never referenced in production HTML</li>
</ul>

<p>The only “cost” in production is two unused <code class="language-plaintext highlighter-rouge">&lt;symbol&gt;</code> elements in the SVG sprite file. They add negligible bytes and are never rendered by the browser.</p>

<h2 id="related-posts">Related Posts</h2>

<ul>
  <li><a href="/jekyll-run-vscode-plugin-local-development/">Jekyll Run Plugin: Local Development Settings That Actually Work</a> — The predecessor article on configuring <code class="language-plaintext highlighter-rouge">--drafts</code> and <code class="language-plaintext highlighter-rouge">--future</code> flags</li>
  <li><a href="/jekyll-markdown-feature-reference/">How the Sausage Is Made: Every Feature Powering This Jekyll Blog</a> — Complete feature reference</li>
  <li><a href="/setting-up-jekyll-blog-github-pages/">Building This Blog: Jekyll on GitHub Pages from Zero to 130+ Posts</a> — Blog setup guide</li>
  <li><a href="/jekyll-github-actions-cicd-pipeline/">The CI/CD Pipeline Behind This Jekyll Blog</a> — How scheduled builds publish future-dated posts</li>
</ul>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://jekyllrb.com/docs/posts/#drafts">Jekyll Drafts Documentation</a> — Official docs on the <code class="language-plaintext highlighter-rouge">_drafts</code> folder</li>
  <li><a href="https://jekyllrb.com/docs/configuration/options/#build-command-options">Jekyll Configuration: Show Drafts</a> — CLI flags for draft and future rendering</li>
  <li><a href="https://fontawesome.com/icons?d=gallery&amp;m=free">Font Awesome Free Icons</a> — Icon browser</li>
</ul>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="web-development" /><category term="technical" /><category term="jekyll" /><category term="jekyll" /><category term="drafts" /><category term="future-posts" /><category term="font-awesome" /><category term="local-development" /><category term="github-pages" /><category term="ux" /><summary type="html"><![CDATA[How to add visual indicators for draft and future-dated posts in Jekyll templates. Uses Font Awesome SVG icons (pencil for drafts, robot for future), italic styling, and Liquid conditionals that only activate during local development with --drafts and --future flags. Covers archive pages, paginated home pages, and excerpt views.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/jekyll-draft-future-visual-indicators.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/jekyll-draft-future-visual-indicators.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Jekyll Run Plugin: Local Development Settings That Actually Work</title><link href="https://mcgarrah.org/jekyll-run-vscode-plugin-local-development/" rel="alternate" type="text/html" title="Jekyll Run Plugin: Local Development Settings That Actually Work" /><published>2026-05-11T00:00:00+00:00</published><updated>2026-05-11T00:00:00+00:00</updated><id>https://mcgarrah.org/jekyll-run-vscode-plugin-local-development</id><content type="html" xml:base="https://mcgarrah.org/jekyll-run-vscode-plugin-local-development/"><![CDATA[<p>The <a href="https://marketplace.visualstudio.com/items?itemName=Dedsec727.jekyll-run">Jekyll Run</a> VS Code extension (Dedsec727.jekyll-run) gives you a one-click button to build and serve your Jekyll site. It works well for basic use, but if you write future-dated posts, use drafts, or run a multi-root workspace, the defaults will bite you.</p>

<p>This post covers the configuration I use, the settings precedence that tripped me up, and a bash script fallback for when the extension gets confused.</p>

<!-- excerpt-end -->

<h2 id="why-jekyll-run">Why Jekyll Run</h2>

<p>Without the extension, local development means opening a terminal and running:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">exec </span>jekyll serve <span class="nt">--trace</span> <span class="nt">--drafts</span> <span class="nt">--future</span> <span class="nt">--unpublished</span> <span class="nt">--livereload</span> <span class="nt">--incremental</span>
</code></pre></div></div>

<p>Jekyll Run wraps this into a button in the VS Code status bar. Click to start, click to stop. It picks up your workspace settings for command-line arguments and handles the process lifecycle.</p>

<p>For a blog with 130+ posts where I’m constantly previewing drafts and future-dated articles, the convenience is worth the occasional quirk.</p>

<p>Developer experience is a platform engineering concern, whether you’re standardizing IDE settings across a 50-person engineering organization or configuring a Jekyll plugin for a solo blog. The time lost to tooling friction — a missing CLI flag, a settings precedence conflict, a stale incremental build — compounds. Getting the local development environment right once means every future writing session starts clean.</p>

<h2 id="the-flags-that-matter">The Flags That Matter</h2>

<p>Here’s what each flag does and why I use all of them:</p>

<table>
  <thead>
    <tr>
      <th>Flag</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--trace</code></td>
      <td>Show full Ruby backtraces on errors instead of cryptic one-liners</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--drafts</code></td>
      <td>Render files in <code class="language-plaintext highlighter-rouge">_drafts/</code> as if they were published</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--future</code></td>
      <td>Include posts with dates in the future</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--unpublished</code></td>
      <td>Render posts with <code class="language-plaintext highlighter-rouge">published: false</code> in front matter</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--livereload</code></td>
      <td>Auto-refresh the browser when files change</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--incremental</code></td>
      <td>Only rebuild changed pages (faster, but occasionally stale)</td>
    </tr>
  </tbody>
</table>

<p>The critical ones are <code class="language-plaintext highlighter-rouge">--future</code> and <code class="language-plaintext highlighter-rouge">--drafts</code>. Without them, you can’t preview the content you’re actively writing.</p>

<h2 id="configuring-the-extension">Configuring the Extension</h2>

<p>The extension reads its command-line arguments from the <code class="language-plaintext highlighter-rouge">jekyll-run.commandLineArguments</code> setting. Add this to your workspace’s <code class="language-plaintext highlighter-rouge">.vscode/settings.json</code>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"jekyll-run.commandLineArguments"</span><span class="p">:</span><span class="w"> </span><span class="s2">"--trace --drafts --future --unpublished --livereload --incremental"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"jekyll-run.stopServerOnExit"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">stopServerOnExit</code> setting kills the Jekyll process when you close VS Code, preventing orphaned processes that block port 4000 on the next launch.</p>

<h2 id="settings-precedence-where-it-gets-tricky">Settings Precedence: Where It Gets Tricky</h2>

<p>VS Code settings come from multiple sources, and higher-priority settings silently override lower ones. For the Jekyll Run extension, the precedence is:</p>

<ol>
  <li><strong>Multi-root workspace file</strong> (<code class="language-plaintext highlighter-rouge">.code-workspace</code>) — highest priority</li>
  <li><strong>Workspace folder settings</strong> (<code class="language-plaintext highlighter-rouge">.vscode/settings.json</code>)</li>
  <li><strong>User settings</strong> (<code class="language-plaintext highlighter-rouge">~/.vscode-server/data/User/settings.json</code>)</li>
  <li><strong>Machine settings</strong> (<code class="language-plaintext highlighter-rouge">~/.vscode-server/data/Machine/settings.json</code>)</li>
</ol>

<h3 id="the-multi-root-workspace-trap">The Multi-Root Workspace Trap</h3>

<p>If you use a multi-root workspace (multiple folders in one VS Code window), the <code class="language-plaintext highlighter-rouge">.code-workspace</code> file’s settings override per-folder <code class="language-plaintext highlighter-rouge">.vscode/settings.json</code> files. An empty settings block in the workspace file:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"folders"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="p">{</span><span class="w"> </span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"my-blog"</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="p">{</span><span class="w"> </span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"resume"</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"settings"</span><span class="p">:</span><span class="w"> </span><span class="p">{}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>…means the extension falls through to machine or user settings, which may not have your flags. The fix is to add the settings explicitly:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"folders"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="p">{</span><span class="w"> </span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"my-blog"</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="p">{</span><span class="w"> </span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"resume"</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"settings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"jekyll-run.commandLineArguments"</span><span class="p">:</span><span class="w"> </span><span class="s2">"--trace --drafts --future --unpublished --livereload --incremental"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"jekyll-run.stopServerOnExit"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>macOS users:</strong> If you see <code class="language-plaintext highlighter-rouge">TypeError: Cannot read properties of null (reading 'toString')</code> in a multi-root workspace, the problem is deeper than settings — it’s related to macOS GUI PATH inheritance and Ruby version management. See <a href="/jekyll-run-plugin-multiroot-workspace-bug/">Jekyll Run Plugin: Fixing the Multi-Root Workspace Crash</a> for the full diagnosis and fix.</p>

<h3 id="the-machine-settings-trap">The Machine Settings Trap</h3>

<p>On VS Code Remote (SSH, WSL, etc.), machine settings live at <code class="language-plaintext highlighter-rouge">~/.vscode-server/data/Machine/settings.json</code> on the remote host. If someone — or an extension update — writes a <code class="language-plaintext highlighter-rouge">jekyll-run.commandLineArguments</code> value there with fewer flags, it can override your workspace settings depending on how the extension resolves precedence.</p>

<p>Check what’s actually there:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> ~/.vscode-server/data/Machine/settings.json
</code></pre></div></div>

<h3 id="diagnosing-which-settings-win">Diagnosing Which Settings Win</h3>

<p>The fastest way to confirm what the extension is actually using:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ps aux | <span class="nb">grep </span>jekyll | <span class="nb">grep</span> <span class="nt">-v</span> <span class="nb">grep</span>
</code></pre></div></div>

<p>This shows the exact command line. If you see <code class="language-plaintext highlighter-rouge">--trace --drafts</code> but not <code class="language-plaintext highlighter-rouge">--future</code>, your workspace settings aren’t being picked up.</p>

<h2 id="the-_configyml-trap">The _config.yml Trap</h2>

<p>Even with <code class="language-plaintext highlighter-rouge">--future</code> in your CLI flags, Jekyll will ignore it if <code class="language-plaintext highlighter-rouge">_config.yml</code> explicitly sets:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">future</span><span class="pi">:</span> <span class="kc">false</span>
</code></pre></div></div>

<p>The config file value <strong>overrides</strong> the command-line flag. This is counterintuitive — you’d expect CLI flags to win — but that’s how Jekyll works.</p>

<p>The fix is to not set <code class="language-plaintext highlighter-rouge">future</code> in <code class="language-plaintext highlighter-rouge">_config.yml</code> at all. The default is <code class="language-plaintext highlighter-rouge">false</code>, which means:</p>

<ul>
  <li><strong>Production builds</strong> (GitHub Actions without <code class="language-plaintext highlighter-rouge">--future</code>) won’t publish future posts</li>
  <li><strong>Local development</strong> (with <code class="language-plaintext highlighter-rouge">--future</code> flag) will show them</li>
</ul>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Don't do this — it overrides the --future CLI flag</span>
<span class="c1"># future: false</span>

<span class="c1"># Do this — let the CLI flag control it</span>
<span class="c1"># (just leave it out entirely, or comment it)</span>
</code></pre></div></div>

<p>This applies to <code class="language-plaintext highlighter-rouge">show_drafts</code> and <code class="language-plaintext highlighter-rouge">unpublished</code> as well. If you set them explicitly in <code class="language-plaintext highlighter-rouge">_config.yml</code>, the CLI flags become useless.</p>

<h2 id="the-draft-and-unpublished-visibility-trap">The Draft and Unpublished Visibility Trap</h2>

<p>This one cost me hours. The <code class="language-plaintext highlighter-rouge">--drafts</code> and <code class="language-plaintext highlighter-rouge">--unpublished</code> flags make Jekyll <strong>render</strong> draft files and posts with <code class="language-plaintext highlighter-rouge">published: false</code> — but they don’t appear in <code class="language-plaintext highlighter-rouge">site.posts</code>. That means:</p>

<ul>
  <li>They won’t show in your archive page</li>
  <li>They won’t show in tag or category listings</li>
  <li>They won’t show on the homepage pagination</li>
  <li>They <strong>are</strong> accessible by direct URL</li>
</ul>

<p>The files exist in <code class="language-plaintext highlighter-rouge">_site/</code>. Jekyll built them. But any template that iterates <code class="language-plaintext highlighter-rouge">site.posts</code> (which is nearly every listing page) silently excludes them.</p>

<h3 id="how-to-confirm-they-exist">How to Confirm They Exist</h3>

<p>Check the build output directly:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ls </span>_site/your-draft-slug/
</code></pre></div></div>

<p>If <code class="language-plaintext highlighter-rouge">index.html</code> is there, Jekyll rendered it. You can view it at <code class="language-plaintext highlighter-rouge">http://127.0.0.1:4000/your-draft-slug/</code>. It just won’t appear in any listing.</p>

<h3 id="draft-sorting-theyre-at-the-bottom-not-the-top">Draft Sorting: They’re at the Bottom, Not the Top</h3>

<p>Even when drafts do appear in <code class="language-plaintext highlighter-rouge">site.posts</code>, they show up <strong>at the end of the archive</strong> — after your oldest post. You’ll scroll past 100+ articles looking for your new draft at the top and conclude it’s missing.</p>

<p>Jekyll appends drafts after all regular posts in <code class="language-plaintext highlighter-rouge">site.posts</code> rather than sorting them into chronological position by their assigned date. So a draft dated 2026-05-10 appears after a post from 2001, not at the top of the list where you’d expect it.</p>

<p>Check the bottom of your archive page, not the top.</p>

<h3 id="the-workaround">The Workaround</h3>

<p>Set <code class="language-plaintext highlighter-rouge">published: true</code> in your draft’s front matter. The file is still in <code class="language-plaintext highlighter-rouge">_drafts/</code> so it won’t deploy to production (GitHub Actions doesn’t use <code class="language-plaintext highlighter-rouge">--drafts</code>), but locally it will appear in all listings.</p>

<p>The <code class="language-plaintext highlighter-rouge">published: false</code> flag is useful for posts in <code class="language-plaintext highlighter-rouge">_posts/</code> that you want to temporarily hide. For files in <code class="language-plaintext highlighter-rouge">_drafts/</code>, it’s counterproductive — you’re already using the drafts folder to prevent publication, and adding <code class="language-plaintext highlighter-rouge">published: false</code> makes them invisible even in local development.</p>

<h2 id="the-bash-script-fallback">The Bash Script Fallback</h2>

<p>The Jekyll Run extension occasionally gets into a bad state — it thinks a server is running when nothing is on port 4000. When that happens, I fall back to a bash script:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
bundle <span class="nb">exec </span>jekyll serve <span class="nt">--trace</span> <span class="nt">--drafts</span> <span class="nt">--future</span> <span class="nt">--unpublished</span> <span class="nt">--livereload</span> <span class="nt">--incremental</span>
</code></pre></div></div>

<p>Save this as <code class="language-plaintext highlighter-rouge">start-jekyll.sh</code> in your project root. When the extension misbehaves:</p>

<ol>
  <li>Try <strong>Jekyll Run: Stop Server</strong> from the Command Palette (<code class="language-plaintext highlighter-rouge">Ctrl+Shift+P</code>)</li>
  <li>If that doesn’t work, run <strong>Developer: Reload Window</strong></li>
  <li>If that doesn’t work, use the script: <code class="language-plaintext highlighter-rouge">bash start-jekyll.sh</code></li>
</ol>

<h3 id="clearing-stale-state">Clearing Stale State</h3>

<p>If you’re getting build errors that don’t match your current code, incremental builds may be serving cached content. Clear everything:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">rm</span> <span class="nt">-rf</span> .jekyll-cache _site <span class="o">&amp;&amp;</span> bash start-jekyll.sh
</code></pre></div></div>

<p>This forces a full rebuild from scratch.</p>

<h2 id="my-complete-setup">My Complete Setup</h2>

<p>For reference, here’s every file involved in my local development configuration:</p>

<p><strong><code class="language-plaintext highlighter-rouge">.vscode/settings.json</code></strong> (per workspace folder):</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"jekyll-run.commandLineArguments"</span><span class="p">:</span><span class="w"> </span><span class="s2">"--trace --drafts --future --unpublished --livereload --incremental"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"jekyll-run.stopServerOnExit"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">blog-workspace.code-workspace</code></strong> (multi-root workspace):</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"folders"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="p">{</span><span class="w"> </span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mcgarrah.github.io"</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="p">{</span><span class="w"> </span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"resume"</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"settings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"jekyll-run.commandLineArguments"</span><span class="p">:</span><span class="w"> </span><span class="s2">"--trace --drafts --future --unpublished --livereload --incremental"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"jekyll-run.stopServerOnExit"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">start-jekyll.sh</code></strong> (fallback script):</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
bundle <span class="nb">exec </span>jekyll serve <span class="nt">--trace</span> <span class="nt">--drafts</span> <span class="nt">--future</span> <span class="nt">--unpublished</span> <span class="nt">--livereload</span> <span class="nt">--incremental</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">_config.yml</code></strong> (no explicit <code class="language-plaintext highlighter-rouge">future</code> setting):</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Future posts controlled by CLI --future flag</span>
<span class="c1"># Default is false, so production builds exclude future posts</span>
<span class="c1"># Local development uses --future to preview them</span>
</code></pre></div></div>

<h2 id="related-posts">Related Posts</h2>

<ul>
  <li><a href="/github-pages-jekyll-locally/">Running GitHub Pages Jekyll Locally</a> — Initial local development setup</li>
  <li><a href="/jekyll-markdown-feature-reference/">How the Sausage Is Made: Every Feature Powering This Jekyll Blog</a> — Complete feature reference</li>
</ul>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://marketplace.visualstudio.com/items?itemName=Dedsec727.jekyll-run">Jekyll Run Extension</a> — VS Code Marketplace</li>
  <li><a href="https://jekyllrb.com/docs/configuration/options/">Jekyll Configuration Options</a> — Official docs on CLI flags and config precedence</li>
  <li><a href="https://code.visualstudio.com/docs/getstarted/settings#_settings-precedence">VS Code Settings Precedence</a> — How VS Code resolves settings from multiple sources</li>
</ul>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="web-development" /><category term="technical" /><category term="jekyll" /><category term="jekyll" /><category term="vscode" /><category term="local-development" /><category term="github-pages" /><category term="future-posts" /><category term="drafts" /><category term="configuration" /><summary type="html"><![CDATA[Complete guide to configuring the Jekyll Run VS Code extension for local Jekyll development. Covers command-line arguments, settings precedence across workspace, machine, and multi-root workspace files, the _config.yml future flag trap, and a fallback bash script for when the extension misbehaves.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/jekyll-run-vscode-plugin-local-development.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/jekyll-run-vscode-plugin-local-development.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Upgrading from a Basic Favicon to a Modern Device Set in Jekyll</title><link href="https://mcgarrah.org/jekyll-modern-favicon-set/" rel="alternate" type="text/html" title="Upgrading from a Basic Favicon to a Modern Device Set in Jekyll" /><published>2026-05-09T00:00:00+00:00</published><updated>2026-05-09T00:00:00+00:00</updated><id>https://mcgarrah.org/jekyll-modern-favicon-set</id><content type="html" xml:base="https://mcgarrah.org/jekyll-modern-favicon-set/"><![CDATA[<p>In my recent article on <a href="/jekyll-small-things-polish-features/">The Small Things: Polish Features That Make a Jekyll Blog Feel Professional</a>, I confessed that my favicon implementation was the bare minimum: a single resolution <code class="language-plaintext highlighter-rouge">favicon.ico</code> file dropped in the site root. It barely qualifies as solving the problem.</p>

<p>While that prevents the standard 404 error when a desktop browser requests the icon, it completely ignores the modern web. Mobile devices, tablets, and modern browsers expect high-resolution PNGs, Apple Touch icons, and web manifests. Without them, users bookmarking the site to their home screens get a generic, ugly letter block instead of a proper logo.</p>

<p>It was sitting on my internal <code class="language-plaintext highlighter-rouge">TODO.md</code> as a “Quick Win” for far too long. Here is how I finally fixed it and implemented a modern favicon set.</p>

<blockquote>
  <p>“It’s the small things that matter. The details.” — The Twelfth Doctor</p>
</blockquote>

<!-- excerpt-end -->

<h2 id="the-problem-with-just-faviconico">The Problem with Just <code class="language-plaintext highlighter-rouge">favicon.ico</code></h2>

<p>If you check your web server logs, you’ll likely see a stream of 404 errors for files you never created:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">/apple-touch-icon-precomposed.png</code></li>
  <li><code class="language-plaintext highlighter-rouge">/apple-touch-icon.png</code></li>
</ul>

<p>iOS devices automatically request these files when a user adds your site to their home screen. Android devices look for an Android Chrome manifest. If they aren’t there, you get degraded UX and messy logs.</p>

<p>Here is the source image I started with — a TARDIS icon at 1067x1067 pixels with a solid white background:</p>

<p><img src="/assets/images/favicon/tardis-icon-1067x1067px.png" alt="Original TARDIS icon with white background" width="256" /></p>

<h2 id="step-1-generate-the-assets">Step 1: Generate the Assets</h2>

<p>You have two choices here. I went with the CLI route because I wanted control over the transparency — the web generators do not offer that option — and because I am a UNIX guy of old who is perfectly comfortable in a terminal. That said, if you just want a zip file of correctly sized icons without thinking about it, the web generator is the faster path.</p>

<h3 id="option-a-the-fast-track-web-generator">Option A: The Fast Track (Web Generator)</h3>

<p>If you do not need transparency or custom processing, this is the easiest route.</p>

<ol>
  <li>Start with a high-resolution version of your logo (ideally a square PNG or SVG, at least 260x260 pixels).</li>
  <li>Go to <a href="https://realfavicongenerator.net/">RealFaviconGenerator.net</a>.</li>
  <li>Upload your image and configure the exact padding, background colors, and styling for iOS, Android, Windows Metro, and macOS Safari pinned tabs.</li>
  <li>Generate and download the resulting <code class="language-plaintext highlighter-rouge">.zip</code> package.</li>
</ol>

<h3 id="option-b-the-local-cli-route-imagemagick">Option B: The Local CLI Route (ImageMagick)</h3>

<p>This is the route I took. If you want full control over the output — especially transparency — ImageMagick gives you that. Assuming your source image is <code class="language-plaintext highlighter-rouge">logo.png</code>:</p>

<p><strong>Optional: Make the background transparent</strong></p>

<p>If your source image has a solid background (like white) and you want it transparent, you can strip it out first. This is the main reason I chose the CLI route — none of the web generators I found offered this.</p>

<p>The trick is the fuzz factor. A naive <code class="language-plaintext highlighter-rouge">-transparent white</code> only removes exact <code class="language-plaintext highlighter-rouge">#FFFFFF</code> pixels and misses the slightly blended, anti-aliased pixels at the edges of the logo, leaving a visible white halo. I started at 5% and it still had noticeable halo artifacts, so I bumped it 5% at a time until 10% produced a clean result:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>convert logo.png <span class="nt">-fuzz</span> 10% <span class="nt">-transparent</span> white transparent-logo.png
</code></pre></div></div>

<p>Here is the result — the same icon after stripping the white background at 10% fuzz. The anti-aliased edges are clean with no visible halo:</p>

<p><img src="/assets/images/favicon/tardis-icon-transparent.png" alt="TARDIS icon after transparency processing" width="256" style="background: #ccc; padding: 8px;" /></p>

<p><em>(If you do this, just substitute <code class="language-plaintext highlighter-rouge">transparent-logo.png</code> for <code class="language-plaintext highlighter-rouge">logo.png</code> in the sizing commands below.)</em></p>

<p><strong>Convert files</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Generate the Apple Touch icon</span>
convert logo.png <span class="nt">-resize</span> 180x180 apple-touch-icon.png

<span class="c"># Generate the standard PNG favicons</span>
convert logo.png <span class="nt">-resize</span> 32x32 favicon-32x32.png
convert logo.png <span class="nt">-resize</span> 16x16 favicon-16x16.png

<span class="c"># Combine them into a multi-resolution favicon.ico</span>
convert favicon-16x16.png favicon-32x32.png favicon.ico

<span class="c"># Generate Android Chrome icons for the manifest</span>
convert logo.png <span class="nt">-resize</span> 192x192 favicon-192x192.png
convert logo.png <span class="nt">-resize</span> 512x512 favicon-512x512.png
</code></pre></div></div>

<p>You will also need to manually create the <code class="language-plaintext highlighter-rouge">site.webmanifest</code> JSON file to register your Android icons:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"McGarrah Technical Blog"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"short_name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"McGarrah"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"icons"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w"> </span><span class="nl">"src"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/favicon-192x192.png"</span><span class="p">,</span><span class="w"> </span><span class="nl">"sizes"</span><span class="p">:</span><span class="w"> </span><span class="s2">"192x192"</span><span class="p">,</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"image/png"</span><span class="w"> </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w"> </span><span class="nl">"src"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/favicon-512x512.png"</span><span class="p">,</span><span class="w"> </span><span class="nl">"sizes"</span><span class="p">:</span><span class="w"> </span><span class="s2">"512x512"</span><span class="p">,</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"image/png"</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"theme_color"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#ffffff"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"background_color"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#ffffff"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"display"</span><span class="p">:</span><span class="w"> </span><span class="s2">"standalone"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="step-2-add-files-to-the-jekyll-root">Step 2: Add Files to the Jekyll Root</h2>

<p>If you used the web generator, extract the <code class="language-plaintext highlighter-rouge">.zip</code> directly into your Jekyll project’s root directory. If you used ImageMagick, move the generated files there along with your hand-written <code class="language-plaintext highlighter-rouge">site.webmanifest</code>. Either way, you should end up with a collection of files including:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">apple-touch-icon.png</code> (180x180)</li>
  <li><code class="language-plaintext highlighter-rouge">favicon-32x32.png</code></li>
  <li><code class="language-plaintext highlighter-rouge">favicon-16x16.png</code></li>
  <li><code class="language-plaintext highlighter-rouge">site.webmanifest</code></li>
  <li><code class="language-plaintext highlighter-rouge">favicon.ico</code></li>
</ul>

<p><em>Note:</em> It’s important to keep these at the root level (<code class="language-plaintext highlighter-rouge">/</code>), as many tools and legacy browsers request them from the root by default without parsing your HTML.</p>

<h2 id="step-3-update-the-html-head">Step 3: Update the HTML Head</h2>

<p>The web generator provides a block of HTML to paste into your site. In a standard Jekyll setup, this goes into <code class="language-plaintext highlighter-rouge">_includes/head.html</code> or directly inside the <code class="language-plaintext highlighter-rouge">&lt;head&gt;</code> block of <code class="language-plaintext highlighter-rouge">_layouts/default.html</code>. If you took the ImageMagick route, the block below is what you need.</p>

<p>To ensure the links work regardless of environment or subdirectories, use Jekyll’s <code class="language-plaintext highlighter-rouge">relative_url</code> filter. I added this block just before the closing <code class="language-plaintext highlighter-rouge">&lt;/head&gt;</code> tag:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- Favicons --&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"apple-touch-icon"</span> <span class="na">sizes=</span><span class="s">"180x180"</span> <span class="na">href=</span><span class="s">"{{ '/apple-touch-icon.png' | relative_url }}"</span><span class="nt">&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"icon"</span> <span class="na">type=</span><span class="s">"image/png"</span> <span class="na">sizes=</span><span class="s">"32x32"</span> <span class="na">href=</span><span class="s">"{{ '/favicon-32x32.png' | relative_url }}"</span><span class="nt">&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"icon"</span> <span class="na">type=</span><span class="s">"image/png"</span> <span class="na">sizes=</span><span class="s">"16x16"</span> <span class="na">href=</span><span class="s">"{{ '/favicon-16x16.png' | relative_url }}"</span><span class="nt">&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"manifest"</span> <span class="na">href=</span><span class="s">"{{ '/site.webmanifest' | relative_url }}"</span><span class="nt">&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"shortcut icon"</span> <span class="na">href=</span><span class="s">"{{ '/favicon.ico' | relative_url }}"</span><span class="nt">&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"theme-color"</span> <span class="na">content=</span><span class="s">"#ffffff"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>Using <code class="language-plaintext highlighter-rouge">relative_url</code> ensures that if I ever preview the site in a sub-path or change domains, the asset links won’t break.</p>

<h2 id="testing-locally-before-pushing">Testing Locally Before Pushing</h2>

<p>Before pushing any of this to GitHub Pages, I tested it locally with <code class="language-plaintext highlighter-rouge">bundle exec jekyll serve</code> several times. Favicons are notoriously sticky in browser caches, so I made a habit of hard-refreshing (<code class="language-plaintext highlighter-rouge">Ctrl+Shift+R</code>) and checking the Network tab in DevTools to confirm the browser was actually fetching the new assets instead of serving stale ones.</p>

<p>Beyond the icons themselves, I verified that the HTML changes in <code class="language-plaintext highlighter-rouge">_layouts/default.html</code> were rendering correctly by viewing the page source and confirming all six <code class="language-plaintext highlighter-rouge">&lt;link&gt;</code> tags appeared in the <code class="language-plaintext highlighter-rouge">&lt;head&gt;</code>. I also hit <code class="language-plaintext highlighter-rouge">http://localhost:4000/site.webmanifest</code> directly in the browser to make sure Jekyll was serving the manifest file and that the JSON was valid with the correct icon paths. A missing or malformed manifest is silent — no errors in the console, just a broken “Add to Home Screen” experience that you would never notice without checking.</p>

<p>The transparency fuzz factor was the main thing I iterated on locally. Each time I regenerated the PNGs with a different percentage, I restarted Jekyll and checked the Apple Touch icon at 180x180 to see if the halo was gone. That feedback loop — regenerate, restart, hard-refresh — is much faster against a local server than waiting for a GitHub Pages deploy.</p>

<p>Once everything looked right locally, I pushed to GitHub and verified on the live site at <a href="https://mcgarrah.org">mcgarrah.org</a>. The deploy picked it up on the next build and the icons rendered identically to what I saw in local testing.</p>

<h2 id="the-result">The Result</h2>

<p>The whole process took about thirty minutes of actual work, spread across an hour of wall time because I was multitasking. Most of that was the local iteration on ImageMagick transparency — the favicon generation and HTML changes themselves were genuinely quick. After deploying, the stream of <code class="language-plaintext highlighter-rouge">/apple-touch-icon.png</code> 404 errors in my server logs disappeared immediately. More importantly, bookmarking the site to an iPhone home screen now shows my actual logo instead of a generic gray letter “M” in a rounded square.</p>

<p>Here is the final set of generated icons at their actual sizes, from the 512x512 Android Chrome icon down to the 16x16 browser tab favicon:</p>

<table>
  <thead>
    <tr>
      <th>Icon</th>
      <th>Size</th>
      <th>Preview</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>favicon-512x512.png</td>
      <td>512x512</td>
      <td><img src="/favicon-512x512.png" alt="512x512 favicon" width="128" /></td>
    </tr>
    <tr>
      <td>favicon-192x192.png</td>
      <td>192x192</td>
      <td><img src="/favicon-192x192.png" alt="192x192 favicon" width="96" /></td>
    </tr>
    <tr>
      <td>apple-touch-icon.png</td>
      <td>180x180</td>
      <td><img src="/apple-touch-icon.png" alt="Apple Touch icon" width="90" /></td>
    </tr>
    <tr>
      <td>favicon-32x32.png</td>
      <td>32x32</td>
      <td><img src="/favicon-32x32.png" alt="32x32 favicon" /></td>
    </tr>
    <tr>
      <td>favicon-16x16.png</td>
      <td>16x16</td>
      <td><img src="/favicon-16x16.png" alt="16x16 favicon" /></td>
    </tr>
  </tbody>
</table>

<p>If you have a Jekyll site with just a bare <code class="language-plaintext highlighter-rouge">favicon.ico</code>, check your logs — you are almost certainly serving 404s for assets that mobile devices expect to exist. A few generated PNGs and six lines of HTML in your layout is all it takes to fix it.</p>

<p>My <a href="https://www.mcgarrah.org/resume/">resume site</a> runs as a sub-path off the same domain, so it picks up the root-level favicon assets automatically. Its Jekyll theme has its own <code class="language-plaintext highlighter-rouge">_includes/head.html</code>, which needed the same <code class="language-plaintext highlighter-rouge">&lt;link&gt;</code> block added, but the image files are shared. If you run multiple Jekyll projects under one domain, that is one less thing to duplicate.</p>

<blockquote>
  <p>“Never ignore the little things. In the whole wide universe, the little things are the most important.” — The Eleventh Doctor</p>
</blockquote>

<p><img src="/assets/images/gallifreyan-script.png" alt="Gallifreyan script" /></p>

<p><em>Gallifreyan script generated with the <a href="https://mightyfrong.github.io/gallifreyan-translation-helper/">Gallifreyan Translation Helper</a>.</em></p>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="web-development" /><category term="technical" /><category term="jekyll" /><category term="jekyll" /><category term="favicon" /><category term="ux" /><category term="github-pages" /><category term="html" /><category term="imagemagick" /><summary type="html"><![CDATA[How to properly implement a modern favicon set in a Jekyll blog on GitHub Pages. Covers using RealFaviconGenerator for the fast track, or ImageMagick for local CLI generation, plus the necessary HTML head tags.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/jekyll-modern-favicon-set.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/jekyll-modern-favicon-set.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The CI/CD Pipeline Behind This Jekyll Blog</title><link href="https://mcgarrah.org/jekyll-github-actions-cicd-pipeline/" rel="alternate" type="text/html" title="The CI/CD Pipeline Behind This Jekyll Blog" /><published>2026-05-08T00:00:00+00:00</published><updated>2026-05-08T00:00:00+00:00</updated><id>https://mcgarrah.org/jekyll-github-actions-cicd-pipeline</id><content type="html" xml:base="https://mcgarrah.org/jekyll-github-actions-cicd-pipeline/"><![CDATA[<p>Any content platform that publishes on a schedule, validates quality automatically, and scans for security regressions needs a CI/CD pipeline — even if the “platform” is a personal blog. This site runs three GitHub Actions workflows that form a feedback loop: build and deploy with scheduled future-post publishing, CodeQL security scanning of the workflow YAML itself, and an SEO health check that validates sitemaps, canonical URLs, structured data, and accessibility on every content push. Dependabot closes the loop on dependency hygiene across three package ecosystems.</p>

<p>Most Jekyll blogs on GitHub Pages use the default build. Push to main, GitHub builds it, done. That worked for me too — until I needed custom plugins, scheduled future posts, and wanted to stop deploying broken sitemaps.</p>

<p>Here’s how it all fits together.</p>

<!-- excerpt-end -->

<h2 id="the-pipeline-at-a-glance">The Pipeline at a Glance</h2>

<pre><code class="language-mermaid">graph LR
    A[git push to main] --&gt; B[Jekyll Build &amp; Deploy]
    A --&gt; C[CodeQL Security Scan]
    A --&gt; D[SEO Health Check]
    E[Daily 10:05 UTC] --&gt; B
    F[Weekly Friday 02:43 UTC] --&gt; C
    G[Weekly Monday 06:00 UTC] --&gt; D
    H[Dependabot] --&gt; I[PRs for dependency updates]
    I --&gt; A
</code></pre>

<table>
  <thead>
    <tr>
      <th>Workflow</th>
      <th>File</th>
      <th>Triggers</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Build &amp; Deploy</td>
      <td><code class="language-plaintext highlighter-rouge">jekyll.yml</code></td>
      <td>Push, daily cron, manual</td>
      <td>Build site, validate sitemap, deploy to GitHub Pages</td>
    </tr>
    <tr>
      <td>CodeQL</td>
      <td><code class="language-plaintext highlighter-rouge">codeql.yml</code></td>
      <td>Push, PR, weekly cron</td>
      <td>Security scanning of GitHub Actions workflows</td>
    </tr>
    <tr>
      <td>SEO Health Check</td>
      <td><code class="language-plaintext highlighter-rouge">seo-health-check.yml</code></td>
      <td>Push (path-filtered), weekly cron, manual</td>
      <td>Lighthouse CI, link checking, SEO validation</td>
    </tr>
    <tr>
      <td>Dependabot</td>
      <td><code class="language-plaintext highlighter-rouge">dependabot.yml</code></td>
      <td>Daily (Actions, Bundler), weekly (npm)</td>
      <td>Dependency update PRs</td>
    </tr>
  </tbody>
</table>

<h2 id="workflow-1-build-and-deploy">Workflow 1: Build and Deploy</h2>

<p>This is the core workflow. It replaced the default GitHub Pages build in August 2024 when I needed custom plugins that aren’t in the <a href="https://pages.github.com/versions/">GitHub Pages whitelist</a>.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Deploy Jekyll site to Pages</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">main"</span><span class="pi">]</span>
  <span class="na">schedule</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">cron</span><span class="pi">:</span> <span class="s1">'</span><span class="s">5</span><span class="nv"> </span><span class="s">10</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*'</span>
  <span class="na">workflow_dispatch</span><span class="pi">:</span>

<span class="na">permissions</span><span class="pi">:</span>
  <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
  <span class="na">pages</span><span class="pi">:</span> <span class="s">write</span>
  <span class="na">id-token</span><span class="pi">:</span> <span class="s">write</span>

<span class="na">concurrency</span><span class="pi">:</span>
  <span class="na">group</span><span class="pi">:</span> <span class="s2">"</span><span class="s">pages"</span>
  <span class="na">cancel-in-progress</span><span class="pi">:</span> <span class="kc">false</span>
</code></pre></div></div>

<h3 id="why-a-custom-build">Why a Custom Build?</h3>

<p>The default GitHub Pages Jekyll build is convenient but limiting:</p>

<ul>
  <li><strong>No custom plugins</strong> — Only <a href="https://pages.github.com/versions/">whitelisted gems</a> run. My <a href="/jekyll-tag-category-generator-plugin/">tag/category generator</a> and <a href="/jekyll-pandoc-exports-plugin/">Pandoc exports plugin</a> need a custom build.</li>
  <li><strong>No Ruby version pinning</strong> — GitHub controls the Ruby version. I pin to 3.2.6 for reproducibility.</li>
  <li><strong>No build validation</strong> — The default build deploys whatever Jekyll produces. I validate the sitemap before deploying.</li>
</ul>

<h3 id="scheduled-builds-for-future-posts">Scheduled Builds for Future Posts</h3>

<p>The <code class="language-plaintext highlighter-rouge">schedule</code> trigger is the key feature that makes future-dated posts work:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">schedule</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">cron</span><span class="pi">:</span> <span class="s1">'</span><span class="s">5</span><span class="nv"> </span><span class="s">10</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*'</span>  <span class="c1"># 10:05 UTC daily (6:05 AM EDT)</span>
</code></pre></div></div>

<p>See <a href="https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule">GitHub’s scheduled events documentation</a> for cron syntax details.</p>

<p>Jekyll’s <code class="language-plaintext highlighter-rouge">future: false</code> setting (the default) excludes posts with dates in the future from the build output. When a post’s date arrives, the next build picks it up. The daily cron at 10:05 UTC (6:05 AM EDT) means a post dated <code class="language-plaintext highlighter-rouge">2026-05-15</code> will go live within 24 hours of that date — close enough for a blog.</p>

<p>Without this, I’d have to manually push a commit or trigger a build on the day I want a post to go live.</p>

<h3 id="the---future-flag-gotcha">The <code class="language-plaintext highlighter-rouge">--future</code> Flag Gotcha</h3>

<p>During local development, <code class="language-plaintext highlighter-rouge">bundle exec jekyll serve</code> also respects <code class="language-plaintext highlighter-rouge">future: false</code> — future-dated posts won’t render locally unless you add the <code class="language-plaintext highlighter-rouge">--future</code> flag:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">exec </span>jekyll serve <span class="nt">--future</span>
</code></pre></div></div>

<p>This confused me early on. I’d write a post with tomorrow’s date, run the local server, and the post wouldn’t appear. The <code class="language-plaintext highlighter-rouge">future: false</code> config setting only controls the <em>build output</em>, not whether the file is recognized. The <code class="language-plaintext highlighter-rouge">--future</code> flag overrides it for local previewing. In production, the daily cron handles it — you never need <code class="language-plaintext highlighter-rouge">future: true</code> in <code class="language-plaintext highlighter-rouge">_config.yml</code>.</p>

<h3 id="sitemap-validation">Sitemap Validation</h3>

<p>After discovering that a bad build once deployed a sitemap full of <code class="language-plaintext highlighter-rouge">localhost</code> URLs, I added a pre-deploy validation step:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Validate sitemap URLs</span>
  <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">if grep -q 'localhost' ./_site/sitemap.xml; then</span>
      <span class="s">echo "::error::sitemap.xml contains localhost URLs"</span>
      <span class="s">grep 'localhost' ./_site/sitemap.xml | head -5</span>
      <span class="s">exit 1</span>
    <span class="s">fi</span>
    <span class="s">echo "Sitemap OK: all URLs use production domain"</span>
</code></pre></div></div>

<p>This is a cheap check that has saved me at least once. The <code class="language-plaintext highlighter-rouge">JEKYLL_ENV: production</code> environment variable is also critical — without it, Jekyll may use development URLs.</p>

<h3 id="build-steps">Build Steps</h3>

<p>The full build job:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v6</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">ruby/setup-ruby@v1.300.0</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">ruby-version</span><span class="pi">:</span> <span class="s1">'</span><span class="s">3.2.6'</span>
          <span class="na">bundler-cache</span><span class="pi">:</span> <span class="kc">true</span>
          <span class="na">cache-version</span><span class="pi">:</span> <span class="m">1</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/configure-pages@v6</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build with Jekyll</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}"</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">JEKYLL_ENV</span><span class="pi">:</span> <span class="s">production</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Validate sitemap URLs</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">if grep -q 'localhost' ./_site/sitemap.xml; then</span>
            <span class="s">echo "::error::sitemap.xml contains localhost URLs"</span>
            <span class="s">exit 1</span>
          <span class="s">fi</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/upload-pages-artifact@v4</span>
</code></pre></div></div>

<p>Key details:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">bundler-cache: true</code></strong> — Caches installed gems between runs. Cuts build time significantly.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">cache-version: 1</code></strong> — Increment this to force a fresh gem install if the cache gets corrupted.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">configure-pages</code></strong> — Sets the base path for GitHub Pages. Required for correct URL generation.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">cancel-in-progress: false</code></strong> — Don’t cancel a running deployment if a new push arrives. Let it finish.</li>
</ul>

<h3 id="evolution">Evolution</h3>

<p>The build workflow has been through nine commits since August 2024:</p>

<ol>
  <li><strong>Aug 2024</strong> — Initial creation, replacing default GitHub Pages build</li>
  <li><strong>Jan 2025</strong> — Ruby version updates and deploy comments</li>
  <li><strong>May 2025</strong> — Fix caching issues during build</li>
  <li><strong>Sep 2025</strong> — Add scheduled daily builds for future posts</li>
  <li><strong>Sep 2025</strong> — Dependabot bumps for checkout and upload-pages-artifact</li>
  <li><strong>Apr 2026</strong> — Add sitemap localhost validation</li>
  <li><strong>Apr 2026</strong> — Update all actions to Node 24 compatible versions</li>
</ol>

<h2 id="workflow-2-codeql-security-scanning">Workflow 2: CodeQL Security Scanning</h2>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">CodeQL</span><span class="nv"> </span><span class="s">Advanced"</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">main"</span><span class="pi">]</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">main"</span><span class="pi">]</span>
  <span class="na">schedule</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">cron</span><span class="pi">:</span> <span class="s1">'</span><span class="s">43</span><span class="nv"> </span><span class="s">2</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">5'</span>  <span class="c1"># Weekly Friday at 02:43 UTC</span>
</code></pre></div></div>

<h3 id="what-does-codeql-scan-on-a-jekyll-blog">What Does CodeQL Scan on a Jekyll Blog?</h3>

<p>Not much, honestly. The initial setup in September 2025 included Ruby and JavaScript language analysis, but those were removed the same day — CodeQL’s Ruby analysis isn’t useful for Jekyll plugins (they’re too simple), and the JavaScript is mostly CDN-loaded.</p>

<p>What remains is <strong>GitHub Actions workflow analysis</strong> (<code class="language-plaintext highlighter-rouge">language: actions</code>), which scans the workflow YAML files themselves for security issues like:</p>

<ul>
  <li>Untrusted input in <code class="language-plaintext highlighter-rouge">run</code> steps</li>
  <li>Missing permission restrictions</li>
  <li>Vulnerable action versions</li>
  <li>Script injection via <code class="language-plaintext highlighter-rouge">${{ }}</code> expressions</li>
</ul>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">strategy</span><span class="pi">:</span>
  <span class="na">fail-fast</span><span class="pi">:</span> <span class="kc">false</span>
  <span class="na">matrix</span><span class="pi">:</span>
    <span class="na">include</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">language</span><span class="pi">:</span> <span class="s">actions</span>
      <span class="na">build-mode</span><span class="pi">:</span> <span class="s">none</span>
</code></pre></div></div>

<h3 id="is-it-worth-it">Is It Worth It?</h3>

<p>For a static blog? Marginally. The Actions language scanner has caught zero issues so far. But it’s free, runs weekly, and takes under a minute. The real value is that it’s already configured — if I add more complex JavaScript or Ruby in the future, I can re-enable those language scanners with one line change.</p>

<h2 id="workflow-3-seo-health-check">Workflow 3: SEO Health Check</h2>

<p>This is the most complex workflow and has its own <a href="/jekyll-seo-health-checks/">dedicated article</a>. Here’s the summary of what it validates on every content push and weekly:</p>

<h3 id="triggers">Triggers</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">on</span><span class="pi">:</span>
  <span class="na">schedule</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">cron</span><span class="pi">:</span> <span class="s1">'</span><span class="s">0</span><span class="nv"> </span><span class="s">6</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">*</span><span class="nv"> </span><span class="s">1'</span>  <span class="c1"># Weekly Monday at 6 AM UTC</span>
  <span class="na">workflow_dispatch</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>
    <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">_config.yml'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">_layouts/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">_includes/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">_plugins/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">_posts/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">_sass/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">assets/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">robots.txt'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">.github/workflows/seo-health-check.yml'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">.lighthouserc.json'</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">paths</code> filter is important — this workflow only runs on pushes that change content or configuration, not on README edits or draft changes. This saves CI minutes.</p>

<h3 id="what-it-checks">What It Checks</h3>

<p>The workflow builds the site, serves it locally, then runs a gauntlet of checks:</p>

<ol>
  <li><strong>Lighthouse CI</strong> — Performance (≥0.8), accessibility (≥0.9), best practices (≥0.8), SEO (≥0.9 — hard fail). Runs 3 times per URL and averages. Blocks AdSense and Analytics scripts to get clean scores.</li>
  <li><strong>Canonical URL consistency</strong> — Every <code class="language-plaintext highlighter-rouge">&lt;link rel="canonical"&gt;</code> tag must use <code class="language-plaintext highlighter-rouge">mcgarrah.org</code></li>
  <li><strong>Sitemap validation</strong> — Valid XML, correct domain, no <code class="language-plaintext highlighter-rouge">www</code> prefix</li>
  <li><strong>Sitemap index validation</strong> — References both blog and resume sitemaps</li>
  <li><strong>Robots.txt</strong> — Exists, references correct sitemap index</li>
  <li><strong>Meta tags</strong> — Description and Open Graph tags on homepage</li>
  <li><strong>RSS feed</strong> — Valid XML</li>
  <li><strong>Link checking</strong> — <a href="https://github.com/lycheeverse/lychee-action">Lychee</a> for broken links across all HTML</li>
  <li><strong>Structured data</strong> — JSON-LD presence</li>
  <li><strong>Image optimization</strong> — Missing alt text, oversized images (&gt;500KB)</li>
  <li><strong>Content quality</strong> — Duplicate titles, duplicate meta descriptions, generic link text (“click here”, “read more”)</li>
  <li><strong>Mobile optimization</strong> — Viewport meta tag coverage</li>
  <li><strong>Accessibility indicators</strong> — Invalid anchors, small tap targets</li>
</ol>

<h3 id="lighthouse-configuration">Lighthouse Configuration</h3>

<p>The <code class="language-plaintext highlighter-rouge">.lighthouserc.json</code> blocks ad and analytics scripts to get clean performance scores:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"ci"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"collect"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"numberOfRuns"</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w">
      </span><span class="nl">"settings"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"blockedUrlPatterns"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
          </span><span class="s2">"**/pagead/js/adsbygoogle.js*"</span><span class="p">,</span><span class="w">
          </span><span class="s2">"**/googlesyndication.com/**"</span><span class="p">,</span><span class="w">
          </span><span class="s2">"**/googletagmanager.com/**"</span><span class="w">
        </span><span class="p">]</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"assert"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"assertions"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"categories:performance"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"warn"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="nl">"minScore"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.8</span><span class="p">}],</span><span class="w">
        </span><span class="nl">"categories:accessibility"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"warn"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="nl">"minScore"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.9</span><span class="p">}],</span><span class="w">
        </span><span class="nl">"categories:seo"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"error"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="nl">"minScore"</span><span class="p">:</span><span class="w"> </span><span class="mf">0.9</span><span class="p">}]</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>SEO is the only hard fail (<code class="language-plaintext highlighter-rouge">error</code>). Performance and accessibility are warnings — I want to know about regressions but don’t want to block deploys over a 0.79 performance score.</p>

<h3 id="the-canonical-url-bug">The Canonical URL Bug</h3>

<p>The SEO health check went through five rapid-fire commits on April 8, 2026 — all fixing the same class of problem. The canonical URL check was matching <code class="language-plaintext highlighter-rouge">mcgarrah.org</code> in blog post <em>content</em> (syntax-highlighted code examples), not just in <code class="language-plaintext highlighter-rouge">&lt;link&gt;</code> tags. The fix narrowed the grep to match only actual <code class="language-plaintext highlighter-rouge">&lt;link rel="canonical"&gt;</code> tags:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>find _site <span class="nt">-name</span> <span class="s2">"*.html"</span> <span class="nt">-exec</span> <span class="nb">grep</span> <span class="nt">-h</span> <span class="s1">'&lt;link[^&gt;]*rel="canonical"'</span> <span class="o">{}</span> <span class="se">\;</span>
</code></pre></div></div>

<p>Lesson: when your blog posts contain code examples about your own blog’s configuration, your CI checks will match the examples. Always be specific in your grep patterns.</p>

<h2 id="dependabot">Dependabot</h2>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">version</span><span class="pi">:</span> <span class="m">2</span>
<span class="na">updates</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">package-ecosystem</span><span class="pi">:</span> <span class="s2">"</span><span class="s">github-actions"</span>
    <span class="na">directory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">."</span>
    <span class="na">schedule</span><span class="pi">:</span>
      <span class="na">interval</span><span class="pi">:</span> <span class="s2">"</span><span class="s">daily"</span>
  <span class="pi">-</span> <span class="na">package-ecosystem</span><span class="pi">:</span> <span class="s2">"</span><span class="s">bundler"</span>
    <span class="na">directory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">./"</span>
    <span class="na">schedule</span><span class="pi">:</span>
      <span class="na">interval</span><span class="pi">:</span> <span class="s2">"</span><span class="s">daily"</span>
  <span class="pi">-</span> <span class="na">package-ecosystem</span><span class="pi">:</span> <span class="s2">"</span><span class="s">npm"</span>
    <span class="na">directory</span><span class="pi">:</span> <span class="s2">"</span><span class="s">./"</span>
    <span class="na">schedule</span><span class="pi">:</span>
      <span class="na">interval</span><span class="pi">:</span> <span class="s2">"</span><span class="s">weekly"</span>
</code></pre></div></div>

<p>Three ecosystems, three schedules:</p>

<ul>
  <li><strong>GitHub Actions</strong> (daily) — Catches action version bumps quickly. These are the most security-sensitive since they run with repo permissions.</li>
  <li><strong>Bundler</strong> (daily) — Jekyll and Ruby gem updates. The <code class="language-plaintext highlighter-rouge">Gemfile.lock</code> pins exact versions.</li>
  <li><strong>npm</strong> (weekly) — Tracks CDN library versions in <code class="language-plaintext highlighter-rouge">package.json</code> for security scanning, even though the actual libraries load from CDN at runtime. See <a href="/implementing-gdpr-compliance-jekyll-adsense/">the security dependency management</a> post for why.</li>
</ul>

<p>Dependabot creates PRs automatically. Each PR triggers the build and CodeQL workflows, so I get a test build before merging.</p>

<h2 id="how-the-pieces-interact">How the Pieces Interact</h2>

<p>The workflows aren’t isolated — they form a feedback loop:</p>

<ol>
  <li><strong>Dependabot</strong> creates a PR to bump <code class="language-plaintext highlighter-rouge">actions/checkout</code> from v5 to v6</li>
  <li>The PR triggers <strong>CodeQL</strong> (scans the updated workflow YAML) and <strong>Build</strong> (tests the build with the new action version)</li>
  <li>I merge the PR → push to main</li>
  <li>Push triggers all three workflows: <strong>Build</strong> deploys, <strong>CodeQL</strong> scans, <strong>SEO Health Check</strong> validates</li>
  <li>If the SEO check finds a regression (broken link, missing meta tag), I fix it and push again</li>
</ol>

<p>The <strong>daily cron</strong> on the build workflow handles future-dated posts without any manual intervention. The <strong>weekly crons</strong> on CodeQL and SEO catch drift — a dependency that introduced a vulnerability, or an external link that went dead.</p>

<h2 id="cost">Cost</h2>

<p>All of this runs on GitHub’s free tier for public repositories. The monthly usage is minimal:</p>

<ul>
  <li>Build &amp; Deploy: ~30 runs/month (daily cron + pushes), ~2 min each</li>
  <li>CodeQL: ~8 runs/month (weekly + pushes), ~1 min each</li>
  <li>SEO Health Check: ~12 runs/month (weekly + content pushes), ~4 min each</li>
  <li>Total: ~100 minutes/month, well within the 2,000 free minutes</li>
</ul>

<h2 id="what-id-add-next">What I’d Add Next</h2>

<ul>
  <li><strong>HTML-Proofer</strong> — More thorough internal link validation than my custom script</li>
  <li><strong>Pa11y</strong> — Automated accessibility testing beyond Lighthouse</li>
  <li><strong>Build time tracking</strong> — Alert if build time exceeds a threshold (currently ~90 seconds)</li>
  <li><strong>Deployment notifications</strong> — Slack or email on successful deploy of future-dated posts</li>
</ul>

<h2 id="related-posts">Related Posts</h2>

<ul>
  <li><a href="/jekyll-seo-health-checks/">Advanced Jekyll SEO Health Checks</a> — Deep dive into the SEO workflow</li>
  <li><a href="/setting-up-jekyll-blog-github-pages/">Building This Blog: Jekyll on GitHub Pages</a> — Setup guide with CI/CD overview</li>
  <li><a href="/github-actions-pip-audit-pr/">Using GitHub Actions with pip-audit</a> — Similar CI pattern for Python projects</li>
  <li><a href="/jekyll-tag-category-generator-plugin/">Building a Custom Tag and Category Generator Plugin</a> — One of the custom plugins that requires this pipeline</li>
  <li><a href="/jekyll-sitemap-bloat-tags-categories-pagination/">Your Jekyll Sitemap Is 60% Garbage</a> — The sitemap problem that led to the validation step</li>
  <li><a href="/jekyll-markdown-feature-reference/">How the Sausage Is Made</a> — Full feature inventory</li>
  <li><a href="/ruby-gem-release-automation/">Ruby Gem Release Automation</a> — CI/CD for the Pandoc exports gem</li>
</ul>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="web-development" /><category term="technical" /><category term="jekyll" /><category term="jekyll" /><category term="github-actions" /><category term="ci-cd" /><category term="codeql" /><category term="seo" /><category term="lighthouse" /><category term="dependabot" /><category term="automation" /><category term="github-pages" /><category term="security" /><summary type="html"><![CDATA[A complete walkthrough of the GitHub Actions CI/CD pipeline for a Jekyll blog on GitHub Pages: custom build and deploy with scheduled future posts, CodeQL security scanning, SEO health checks with Lighthouse CI, Dependabot dependency management, and the lessons learned building it over two years.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/jekyll-github-actions-cicd-pipeline.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/jekyll-github-actions-cicd-pipeline.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Adding Comments to a Static Site: Why I Chose Giscus for Jekyll</title><link href="https://mcgarrah.org/jekyll-giscus-comments-implementation/" rel="alternate" type="text/html" title="Adding Comments to a Static Site: Why I Chose Giscus for Jekyll" /><published>2026-05-06T00:00:00+00:00</published><updated>2026-05-06T00:00:00+00:00</updated><id>https://mcgarrah.org/jekyll-giscus-comments-implementation</id><content type="html" xml:base="https://mcgarrah.org/jekyll-giscus-comments-implementation/"><![CDATA[<p>Jekyll is a static site generator. There’s no server, no database, no backend. When someone visits a page, they get pre-built HTML files served from a CDN. That’s the whole point — it’s fast, cheap, and secure.</p>

<p>But comments need state. Someone writes a comment, it has to be stored somewhere, and the next visitor needs to see it. Adding state to a stateless platform is a fundamental design tension — you need a data store, but you chose a platform specifically because it doesn’t have one.</p>

<!-- excerpt-end -->

<h2 id="the-problem">The Problem</h2>

<p>I wanted comments on this blog for a simple reason: readers ask good questions. When someone finds a gap in a Proxmox walkthrough or catches an error in a Ceph command, that feedback is valuable — not just to me, but to the next person reading the same post. Email works for one-to-one, but comments are one-to-many.</p>

<p>The requirements:</p>

<ol>
  <li><strong>No self-hosted infrastructure</strong> — I’m not running a database for blog comments</li>
  <li><strong>Free or very cheap</strong> — This is a hobby blog</li>
  <li><strong>GitHub-friendly</strong> — My readers are technical; most have GitHub accounts</li>
  <li><strong>GDPR-compatible</strong> — No third-party tracking cookies</li>
  <li><strong>Persistent</strong> — Comments survive site rebuilds and theme changes</li>
  <li><strong>Searchable</strong> — Ideally indexed and findable</li>
  <li><strong>Low maintenance</strong> — No moderation queue to babysit</li>
</ol>

<h2 id="the-alternatives-i-evaluated">The Alternatives I Evaluated</h2>

<h3 id="disqus--the-default-choice-rejected">Disqus — The Default Choice (Rejected)</h3>

<p><a href="https://disqus.com/">Disqus</a> is the most common comment system for static sites. It’s easy to embed and has a large user base.</p>

<p>Why I rejected it:</p>

<ul>
  <li><strong>Ads on the free tier</strong> — Disqus injects ads into your comment section unless you pay</li>
  <li><strong>Tracking and privacy</strong> — Disqus loads significant third-party JavaScript and tracks users across sites. This is a GDPR nightmare for a blog that already went through <a href="/implementing-gdpr-compliance-jekyll-adsense/">extensive GDPR compliance work</a></li>
  <li><strong>Data ownership</strong> — Comments live on Disqus’s servers. If they shut down or change terms, your comments are gone</li>
  <li><strong>Heavy JavaScript</strong> — The embed script is large and slows page load</li>
</ul>

<p>The original Jekyll theme I forked (Contrast) actually had Disqus support built in. The dead code is still in my <code class="language-plaintext highlighter-rouge">post.html</code> layout — a <code class="language-plaintext highlighter-rouge">disqus_thread</code> div that never renders because <code class="language-plaintext highlighter-rouge">site.comments.disqus_shortname</code> is never set.</p>

<h3 id="isso--self-hosted-alternative-rejected">Isso — Self-Hosted Alternative (Rejected)</h3>

<p><a href="https://isso-comments.de/">Isso</a> is a self-hosted, lightweight commenting server. It stores comments in a SQLite database and has a clean, minimal interface.</p>

<p>Why I rejected it:</p>

<ul>
  <li><strong>Requires a server</strong> — You need to run the Isso daemon somewhere. That’s infrastructure I don’t want to maintain for blog comments</li>
  <li><strong>SQLite on a server</strong> — Backups, uptime, security patches — all for a comment system</li>
</ul>

<p>The theme also had Isso support built in. Same dead code situation — <code class="language-plaintext highlighter-rouge">isso_domain</code> is never configured.</p>

<h3 id="github-issues-api--custom-lambda-evaluated-deeply">GitHub Issues API — Custom Lambda (Evaluated Deeply)</h3>

<p>This approach uses GitHub Issues as the comment store. Each blog post maps to a GitHub Issue. Comments on the Issue appear as comments on the post. <a href="https://www.aleksandrhovhannisyan.com/blog/jekyll-comment-system-github-issues/">Aleksandr Hovhannisyan’s implementation</a> is the best-known version.</p>

<p>I went deep on this one — deep enough to have <a href="https://chatgpt.com/c/67804dbd-2d38-8010-8074-f43b50bee567">ChatGPT convert the original Netlify serverless function to a Python Lambda</a>. The full prototype:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">get_comments_for_post</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="n">context</span><span class="p">):</span>
    <span class="sh">"""</span><span class="s">
    Lambda function to fetch comments for a GitHub issue dynamically.
    </span><span class="sh">"""</span>

    <span class="k">try</span><span class="p">:</span>
        <span class="c1"># Extract query parameters
</span>        <span class="n">query_params</span> <span class="o">=</span> <span class="n">event</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">queryStringParameters</span><span class="sh">"</span><span class="p">,</span> <span class="p">{})</span>
        <span class="n">issue_number</span> <span class="o">=</span> <span class="n">query_params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">id</span><span class="sh">"</span><span class="p">)</span>
        <span class="n">github_url</span> <span class="o">=</span> <span class="n">query_params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">url</span><span class="sh">"</span><span class="p">)</span>

        <span class="k">if</span> <span class="ow">not</span> <span class="n">issue_number</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">issue_number</span><span class="p">.</span><span class="nf">isdigit</span><span class="p">():</span>
            <span class="k">return</span> <span class="p">{</span>
                <span class="sh">"</span><span class="s">statusCode</span><span class="sh">"</span><span class="p">:</span> <span class="mi">400</span><span class="p">,</span>
                <span class="sh">"</span><span class="s">body</span><span class="sh">"</span><span class="p">:</span> <span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">({</span><span class="sh">"</span><span class="s">error</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">You must specify a valid issue ID.</span><span class="sh">"</span><span class="p">}),</span>
            <span class="p">}</span>

        <span class="c1"># Determine owner and repo
</span>        <span class="k">if</span> <span class="n">github_url</span><span class="p">:</span>
            <span class="n">owner</span><span class="p">,</span> <span class="n">repo</span> <span class="o">=</span> <span class="nf">extract_owner_and_repo</span><span class="p">(</span><span class="n">github_url</span><span class="p">)</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">owner</span> <span class="o">=</span> <span class="n">query_params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">owner</span><span class="sh">"</span><span class="p">)</span>
            <span class="n">repo</span> <span class="o">=</span> <span class="n">query_params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">repo</span><span class="sh">"</span><span class="p">)</span>

        <span class="k">if</span> <span class="ow">not</span> <span class="n">owner</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">repo</span><span class="p">:</span>
            <span class="k">return</span> <span class="p">{</span>
                <span class="sh">"</span><span class="s">statusCode</span><span class="sh">"</span><span class="p">:</span> <span class="mi">400</span><span class="p">,</span>
                <span class="sh">"</span><span class="s">body</span><span class="sh">"</span><span class="p">:</span> <span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">({</span><span class="sh">"</span><span class="s">error</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">You must specify </span><span class="sh">'</span><span class="s">owner</span><span class="sh">'</span><span class="s"> and </span><span class="sh">'</span><span class="s">repo</span><span class="sh">'</span><span class="s"> or provide a valid GitHub URL.</span><span class="sh">"</span><span class="p">}),</span>
            <span class="p">}</span>

        <span class="n">issue_number</span> <span class="o">=</span> <span class="nf">int</span><span class="p">(</span><span class="n">issue_number</span><span class="p">)</span>

        <span class="c1"># Check API rate limit
</span>        <span class="n">rate_limit</span> <span class="o">=</span> <span class="n">octokit</span><span class="p">.</span><span class="nf">request</span><span class="p">(</span><span class="sh">"</span><span class="s">GET /rate_limit</span><span class="sh">"</span><span class="p">)[</span><span class="sh">"</span><span class="s">rate</span><span class="sh">"</span><span class="p">]</span>
        <span class="n">remaining_requests</span> <span class="o">=</span> <span class="n">rate_limit</span><span class="p">[</span><span class="sh">"</span><span class="s">remaining</span><span class="sh">"</span><span class="p">]</span>
        <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">GitHub API requests remaining: </span><span class="si">{</span><span class="n">remaining_requests</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">remaining_requests</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
            <span class="k">return</span> <span class="p">{</span>
                <span class="sh">"</span><span class="s">statusCode</span><span class="sh">"</span><span class="p">:</span> <span class="mi">503</span><span class="p">,</span>
                <span class="sh">"</span><span class="s">body</span><span class="sh">"</span><span class="p">:</span> <span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">({</span><span class="sh">"</span><span class="s">error</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">API rate limit exceeded.</span><span class="sh">"</span><span class="p">}),</span>
            <span class="p">}</span>

        <span class="c1"># Fetch comments for the given issue
</span>        <span class="n">comments_response</span> <span class="o">=</span> <span class="n">octokit</span><span class="p">.</span><span class="nf">paginate</span><span class="p">(</span>
            <span class="sh">"</span><span class="s">GET /repos/{owner}/{repo}/issues/{issue_number}/comments</span><span class="sh">"</span><span class="p">,</span>
            <span class="p">{</span><span class="sh">"</span><span class="s">owner</span><span class="sh">"</span><span class="p">:</span> <span class="n">owner</span><span class="p">,</span> <span class="sh">"</span><span class="s">repo</span><span class="sh">"</span><span class="p">:</span> <span class="n">repo</span><span class="p">,</span> <span class="sh">"</span><span class="s">issue_number</span><span class="sh">"</span><span class="p">:</span> <span class="n">issue_number</span><span class="p">},</span>
        <span class="p">)</span>

        <span class="c1"># Process comments
</span>        <span class="n">response</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="k">for</span> <span class="n">comment</span> <span class="ow">in</span> <span class="n">comments_response</span><span class="p">:</span>
            <span class="n">response</span><span class="p">.</span><span class="nf">append</span><span class="p">({</span>
                <span class="sh">"</span><span class="s">user</span><span class="sh">"</span><span class="p">:</span> <span class="p">{</span>
                    <span class="sh">"</span><span class="s">avatarUrl</span><span class="sh">"</span><span class="p">:</span> <span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">user</span><span class="sh">"</span><span class="p">][</span><span class="sh">"</span><span class="s">avatar_url</span><span class="sh">"</span><span class="p">],</span>
                    <span class="sh">"</span><span class="s">name</span><span class="sh">"</span><span class="p">:</span> <span class="nf">escape</span><span class="p">(</span><span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">user</span><span class="sh">"</span><span class="p">][</span><span class="sh">"</span><span class="s">login</span><span class="sh">"</span><span class="p">]),</span>
                    <span class="sh">"</span><span class="s">isAuthor</span><span class="sh">"</span><span class="p">:</span> <span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">author_association</span><span class="sh">"</span><span class="p">]</span> <span class="o">==</span> <span class="sh">"</span><span class="s">OWNER</span><span class="sh">"</span><span class="p">,</span>
                <span class="p">},</span>
                <span class="sh">"</span><span class="s">dateTime</span><span class="sh">"</span><span class="p">:</span> <span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">created_at</span><span class="sh">"</span><span class="p">],</span>
                <span class="sh">"</span><span class="s">dateRelative</span><span class="sh">"</span><span class="p">:</span> <span class="nf">str</span><span class="p">((</span><span class="n">datetime</span><span class="p">.</span><span class="nf">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">datetime</span><span class="p">.</span><span class="nf">fromisoformat</span><span class="p">(</span>
                    <span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">created_at</span><span class="sh">"</span><span class="p">].</span><span class="nf">replace</span><span class="p">(</span><span class="sh">"</span><span class="s">Z</span><span class="sh">"</span><span class="p">,</span> <span class="sh">""</span><span class="p">))).</span><span class="n">days</span><span class="p">)</span> <span class="o">+</span> <span class="sh">"</span><span class="s"> days ago</span><span class="sh">"</span><span class="p">,</span>
                <span class="sh">"</span><span class="s">isEdited</span><span class="sh">"</span><span class="p">:</span> <span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">created_at</span><span class="sh">"</span><span class="p">]</span> <span class="o">!=</span> <span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">updated_at</span><span class="sh">"</span><span class="p">],</span>
                <span class="sh">"</span><span class="s">body</span><span class="sh">"</span><span class="p">:</span> <span class="nf">escape</span><span class="p">(</span><span class="nf">markdown</span><span class="p">(</span><span class="n">comment</span><span class="p">[</span><span class="sh">"</span><span class="s">body</span><span class="sh">"</span><span class="p">])),</span>
            <span class="p">})</span>

        <span class="k">return</span> <span class="p">{</span>
            <span class="sh">"</span><span class="s">statusCode</span><span class="sh">"</span><span class="p">:</span> <span class="mi">200</span><span class="p">,</span>
            <span class="sh">"</span><span class="s">body</span><span class="sh">"</span><span class="p">:</span> <span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">({</span><span class="sh">"</span><span class="s">data</span><span class="sh">"</span><span class="p">:</span> <span class="n">response</span><span class="p">}),</span>
        <span class="p">}</span>

    <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
        <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Error: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">return</span> <span class="p">{</span>
            <span class="sh">"</span><span class="s">statusCode</span><span class="sh">"</span><span class="p">:</span> <span class="mi">500</span><span class="p">,</span>
            <span class="sh">"</span><span class="s">body</span><span class="sh">"</span><span class="p">:</span> <span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">({</span><span class="sh">"</span><span class="s">error</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">Unable to fetch comments for this post.</span><span class="sh">"</span><span class="p">}),</span>
        <span class="p">}</span>
</code></pre></div></div>

<p>This prototype handled input validation, GitHub API rate limiting, pagination, relative date formatting, edit detection, and Markdown rendering. It worked — but it was a lot of moving parts for blog comments.</p>

<p>Why I ultimately rejected it:</p>

<ul>
  <li><strong>Requires a serverless function</strong> — Whether it’s Netlify Functions, AWS Lambda, or Cloudflare Workers, you need a backend to proxy the GitHub API (to avoid exposing tokens client-side)</li>
  <li><strong>GitHub Issues aren’t designed for comments</strong> — Issues have a flat structure. No threading, no reactions on individual comments (only on the issue itself)</li>
  <li><strong>Manual issue creation</strong> — You have to create a GitHub Issue for each post and link them. That’s a maintenance burden</li>
  <li><strong>API rate limits</strong> — The GitHub API has rate limits that could be hit on popular posts</li>
</ul>

<h3 id="utterances--github-issues-client-side-close-second">Utterances — GitHub Issues, Client-Side (Close Second)</h3>

<p><a href="https://github.com/utterance/utterances">Utterances</a> solves the serverless function problem by using a GitHub App to authenticate directly from the client. It still uses GitHub Issues as the backend but doesn’t need a proxy.</p>

<p>Why I almost chose it:</p>

<ul>
  <li>No server needed — just a <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag</li>
  <li>Clean, minimal UI</li>
  <li>GitHub authentication (my audience has GitHub accounts)</li>
  <li>Open source</li>
</ul>

<p>Why I chose Giscus instead:</p>

<ul>
  <li>Utterances uses <strong>Issues</strong>, Giscus uses <strong>Discussions</strong> — Discussions have threading, categories, and reactions</li>
  <li>Utterances was less actively maintained at the time I evaluated it</li>
  <li>Giscus is essentially “Utterances but better” — same concept, newer implementation, more features</li>
</ul>

<h3 id="staticman--git-based-comments-rejected">Staticman — Git-Based Comments (Rejected)</h3>

<p><a href="https://staticman.net/">Staticman</a> takes a different approach: comments are submitted via a form, processed by a bot, and committed to your repository as data files (YAML/JSON). Jekyll then renders them at build time.</p>

<p>Why I rejected it:</p>

<ul>
  <li><strong>Build required for every comment</strong> — Each comment triggers a site rebuild. That’s slow and burns CI minutes</li>
  <li><strong>Moderation via pull requests</strong> — Clever, but adds friction</li>
  <li><strong>Self-hosted bot or shared instance</strong> — The shared instance has availability issues; self-hosting is more infrastructure</li>
</ul>

<h3 id="gdpr-compliant-approaches">GDPR-Compliant Approaches</h3>

<p>The <a href="https://jekyllcodex.org/blog/gdpr-compliant-comment/">Jekyll Codex GDPR-compliant comments</a> guide was useful for understanding the privacy landscape. Any solution that loads third-party JavaScript or sends user data to external servers needs consent management.</p>

<h2 id="why-giscus-won">Why Giscus Won</h2>

<p><a href="https://giscus.app/">Giscus</a> uses <strong>GitHub Discussions</strong> as the comment backend. It’s a single <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag that embeds a widget powered by the GitHub Discussions API via a GitHub App.</p>

<p>The key insight: <strong>everything stays in the GitHub ecosystem</strong>. The blog source is on GitHub. The build runs on GitHub Actions. The site deploys to GitHub Pages. Comments live in GitHub Discussions. There’s no external service, no separate database, no additional account.</p>

<p>What sealed the decision:</p>

<ul>
  <li><strong>Threaded replies</strong> — Discussions support nested replies, Issues don’t</li>
  <li><strong>Reactions</strong> — Readers can react to individual comments, not just the top-level post</li>
  <li><strong>Categories</strong> — Comments go into the “Announcements” category, keeping them organized</li>
  <li><strong>Automatic mapping</strong> — <code class="language-plaintext highlighter-rouge">pathname</code> mapping means Giscus creates a Discussion for each post URL automatically. No manual issue creation</li>
  <li><strong>Lazy loading</strong> — The widget loads only when scrolled into view (<code class="language-plaintext highlighter-rouge">loading: lazy</code>)</li>
  <li><strong>Theme matching</strong> — <code class="language-plaintext highlighter-rouge">preferred_color_scheme</code> follows the reader’s dark/light mode preference</li>
  <li><strong>Searchable</strong> — GitHub Discussions are fully searchable, both on GitHub and via search engines</li>
  <li><strong>No server</strong> — Just a <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag and a <code class="language-plaintext highlighter-rouge">_config.yml</code> entry</li>
  <li><strong>GDPR-friendly</strong> — Giscus loads from <code class="language-plaintext highlighter-rouge">giscus.app</code> (a GitHub App), not a third-party ad network. No tracking cookies. The <a href="https://github.com/giscus/giscus/blob/main/SELF-HOSTING.md">self-hosting option</a> exists if you want full control</li>
</ul>

<h2 id="implementation">Implementation</h2>

<h3 id="step-1-enable-github-discussions">Step 1: Enable GitHub Discussions</h3>

<p>In the repository settings (<code class="language-plaintext highlighter-rouge">mcgarrah/mcgarrah.github.io</code>), enable the Discussions feature and create an “Announcements” category.</p>

<h3 id="step-2-install-the-giscus-github-app">Step 2: Install the Giscus GitHub App</h3>

<p>Go to <a href="https://giscus.app/">giscus.app</a>, select your repository, and configure the options. It generates the <code class="language-plaintext highlighter-rouge">&lt;script&gt;</code> tag and gives you the <code class="language-plaintext highlighter-rouge">repo_id</code> and <code class="language-plaintext highlighter-rouge">category_id</code> values.</p>

<h3 id="step-3-add-configuration-to-_configyml">Step 3: Add Configuration to <code class="language-plaintext highlighter-rouge">_config.yml</code></h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">giscus</span><span class="pi">:</span>
  <span class="na">repo</span><span class="pi">:</span> <span class="s">mcgarrah/mcgarrah.github.io</span>
  <span class="na">repo_id</span><span class="pi">:</span> <span class="s">R_kgDOKBKIdw</span>
  <span class="na">category</span><span class="pi">:</span> <span class="s">Announcements</span>
  <span class="na">category_id</span><span class="pi">:</span> <span class="s">DIC_kwDOKBKId84Cq3DK</span>
  <span class="na">mapping</span><span class="pi">:</span> <span class="s">pathname</span>
  <span class="na">strict</span><span class="pi">:</span> <span class="m">0</span>
  <span class="na">reactions_enabled</span><span class="pi">:</span> <span class="m">1</span>
  <span class="na">emit_metadata</span><span class="pi">:</span> <span class="m">0</span>
  <span class="na">input_position</span><span class="pi">:</span> <span class="s">bottom</span>
  <span class="na">theme</span><span class="pi">:</span> <span class="s">preferred_color_scheme</span>
  <span class="na">lang</span><span class="pi">:</span> <span class="s">en</span>
  <span class="na">loading</span><span class="pi">:</span> <span class="s">lazy</span>
</code></pre></div></div>

<p>Key configuration choices:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">mapping: pathname</code></strong> — Maps posts to Discussions by URL path. This means <code class="language-plaintext highlighter-rouge">/proxmox-ceph-nearfull/</code> gets its own Discussion automatically</li>
  <li><strong><code class="language-plaintext highlighter-rouge">strict: 0</code></strong> — Fuzzy matching on the pathname. Tolerates minor URL changes</li>
  <li><strong><code class="language-plaintext highlighter-rouge">input_position: bottom</code></strong> — Comment box below existing comments (natural reading order)</li>
  <li><strong><code class="language-plaintext highlighter-rouge">loading: lazy</code></strong> — Don’t load the iframe until the reader scrolls to the comments section. Improves initial page load performance</li>
  <li><strong><code class="language-plaintext highlighter-rouge">theme: preferred_color_scheme</code></strong> — Matches the reader’s OS dark/light mode setting</li>
</ul>

<h3 id="step-4-add-the-widget-to-the-post-layout">Step 4: Add the Widget to the Post Layout</h3>

<p>In <code class="language-plaintext highlighter-rouge">_layouts/post.html</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{%- if site.giscus -%}
<span class="nt">&lt;section</span> <span class="na">class=</span><span class="s">"page__comments"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;script </span><span class="na">src=</span><span class="s">"https://giscus.app/client.js"</span>
          <span class="na">data-repo=</span><span class="s">"{{ site.giscus.repo }}"</span>
          <span class="na">data-repo-id=</span><span class="s">"{{ site.giscus.repo_id }}"</span>
          <span class="na">data-category=</span><span class="s">"{{ site.giscus.category }}"</span>
          <span class="na">data-category-id=</span><span class="s">"{{ site.giscus.category_id }}"</span>
          <span class="na">data-mapping=</span><span class="s">"{{ site.giscus.mapping }}"</span>
          <span class="na">data-strict=</span><span class="s">"{{ site.giscus.strict }}"</span>
          <span class="na">data-reactions-enabled=</span><span class="s">"{{ site.giscus.reactions_enabled }}"</span>
          <span class="na">data-emit-metadata=</span><span class="s">"{{ site.giscus.emit_metadata }}"</span>
          <span class="na">data-input-position=</span><span class="s">"{{ site.giscus.input_position }}"</span>
          <span class="na">data-theme=</span><span class="s">"{{ site.giscus.theme }}"</span>
          <span class="na">data-lang=</span><span class="s">"{{ site.giscus.lang }}"</span>
          <span class="na">data-loading=</span><span class="s">"{{ site.giscus.loading }}"</span>
          <span class="na">crossorigin=</span><span class="s">"anonymous"</span>
          <span class="na">async</span><span class="nt">&gt;</span>
  <span class="nt">&lt;/script&gt;</span>
<span class="nt">&lt;/section&gt;</span>
{%- endif -%}
</code></pre></div></div>

<p>Every <code class="language-plaintext highlighter-rouge">_config.yml</code> value is templated via Liquid — no hardcoded values in the layout. The <code class="language-plaintext highlighter-rouge">{%- if site.giscus -%}</code> guard means the widget only renders if Giscus is configured, so the theme works without it.</p>

<h3 id="legacy-dead-code">Legacy Dead Code</h3>

<p>The post layout still contains the original theme’s Isso and Disqus support:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{% if page.comments != false and site.comments.isso or site.comments.disqus %}
  {% if site.comments.isso_domain %}<span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"isso-thread"</span><span class="nt">&gt;&lt;/div&gt;</span>{% endif %}
  {% if site.comments.disqus_shortname %}<span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"disqus_thread"</span><span class="nt">&gt;&lt;/div&gt;</span>{% endif %}
{% endif %}
</code></pre></div></div>

<p>This never renders because neither <code class="language-plaintext highlighter-rouge">site.comments.isso_domain</code> nor <code class="language-plaintext highlighter-rouge">site.comments.disqus_shortname</code> is set in <code class="language-plaintext highlighter-rouge">_config.yml</code>. It’s harmless dead code from the Contrast theme fork. I’ve left it in case someone forks this blog and wants to use those systems instead.</p>

<h2 id="the-github-ecosystem-advantage">The GitHub Ecosystem Advantage</h2>

<p>What I find elegant about this setup is how the pieces reinforce each other:</p>

<table>
  <thead>
    <tr>
      <th>Component</th>
      <th>Service</th>
      <th>Data Location</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Source code</td>
      <td>GitHub repository</td>
      <td><code class="language-plaintext highlighter-rouge">mcgarrah/mcgarrah.github.io</code></td>
    </tr>
    <tr>
      <td>Build &amp; deploy</td>
      <td>GitHub Actions</td>
      <td><code class="language-plaintext highlighter-rouge">.github/workflows/jekyll.yml</code></td>
    </tr>
    <tr>
      <td>Hosting</td>
      <td>GitHub Pages</td>
      <td><code class="language-plaintext highlighter-rouge">mcgarrah.org</code> via CNAME</td>
    </tr>
    <tr>
      <td>Comments</td>
      <td>GitHub Discussions</td>
      <td>Same repository</td>
    </tr>
    <tr>
      <td>Security scanning</td>
      <td>GitHub CodeQL</td>
      <td><code class="language-plaintext highlighter-rouge">.github/workflows/codeql.yml</code></td>
    </tr>
    <tr>
      <td>Dependency updates</td>
      <td>GitHub Dependabot</td>
      <td><code class="language-plaintext highlighter-rouge">.github/dependabot.yml</code></td>
    </tr>
  </tbody>
</table>

<p>Everything is in one place. One login, one set of permissions, one backup strategy (the git repository itself). If GitHub goes down, the whole blog is down anyway — there’s no additional point of failure from the comment system.</p>

<p>Comments are also version-controlled in a sense — GitHub Discussions have full edit history, and they’re tied to the repository. If I ever migrate the blog, the Discussions come with the repo.</p>

<p>The broader pattern here is worth noting: keeping your entire operational surface area in a single ecosystem — source, build, hosting, comments, security scanning, dependency management — reduces the number of vendor relationships, authentication boundaries, and failure domains you have to manage. It’s the same principle behind choosing a single cloud provider for tightly coupled services.</p>

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<ul>
  <li><strong>Clean up the dead Isso/Disqus code</strong> — It’s been there since the fork. Time to remove it</li>
  <li><strong>Add a <code class="language-plaintext highlighter-rouge">comments: false</code> front matter option</strong> — The Isso/Disqus code checks <code class="language-plaintext highlighter-rouge">page.comments != false</code>, but the Giscus block doesn’t. Some posts (like the privacy policy) shouldn’t have comments</li>
  <li><strong>Consider self-hosting Giscus</strong> — The <a href="https://github.com/giscus/giscus/blob/main/SELF-HOSTING.md">self-hosting guide</a> would eliminate the dependency on <code class="language-plaintext highlighter-rouge">giscus.app</code>. Low priority since the service has been reliable</li>
</ul>

<h2 id="related-posts">Related Posts</h2>

<ul>
  <li><a href="/setting-up-jekyll-blog-github-pages/">Building This Blog: Jekyll on GitHub Pages</a> — Setup guide with brief Giscus section</li>
  <li><a href="/jekyll-markdown-feature-reference/">How the Sausage Is Made</a> — Feature inventory including comments</li>
  <li><a href="/implementing-gdpr-compliance-jekyll-adsense/">Implementing GDPR Compliance for Jekyll with AdSense</a> — The GDPR work that informed comment system requirements</li>
  <li><a href="/jekyll-github-actions-cicd-pipeline/">The CI/CD Pipeline Behind This Jekyll Blog</a> — How GitHub Actions ties into the ecosystem</li>
</ul>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="web-development" /><category term="technical" /><category term="jekyll" /><category term="jekyll" /><category term="giscus" /><category term="comments" /><category term="github-discussions" /><category term="github-pages" /><category term="gdpr" /><category term="engagement" /><summary type="html"><![CDATA[How to add a comment system to a Jekyll blog on GitHub Pages using Giscus and GitHub Discussions. Covers the evaluation of Disqus, Isso, Utterances, GitHub Issues API, Staticman, and Giscus, with implementation details, GDPR considerations, and the advantages of keeping everything in the GitHub ecosystem.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/jekyll-giscus-comments-implementation.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/jekyll-giscus-comments-implementation.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Building a Custom Tag and Category Generator Plugin for Jekyll</title><link href="https://mcgarrah.org/jekyll-tag-category-generator-plugin/" rel="alternate" type="text/html" title="Building a Custom Tag and Category Generator Plugin for Jekyll" /><published>2026-05-04T00:00:00+00:00</published><updated>2026-05-04T00:00:00+00:00</updated><id>https://mcgarrah.org/jekyll-tag-category-generator-plugin</id><content type="html" xml:base="https://mcgarrah.org/jekyll-tag-category-generator-plugin/"><![CDATA[<p>Content taxonomy at scale creates an architectural tension: you want granular tagging for discoverability, but every auto-generated tag page dilutes your sitemap and SEO signal. At 139 posts with 237 unique tags and 53 categories, this blog hit that tension head-on — and the solution evolved through three versions, each driven by a real operational signal.</p>

<p>GitHub Pages with Jekyll gives you tags and categories in front matter, but no pages for them. You can tag a post <code class="language-plaintext highlighter-rouge">proxmox</code> all day long — there’s no <code class="language-plaintext highlighter-rouge">/tags/proxmox/</code> page unless you build one. Manually creating a page for each tag doesn’t scale. This plugin solves it: a single Ruby file that generates a page for every tag and every category at build time.</p>

<!-- excerpt-end -->

<h2 id="the-problem">The Problem</h2>

<p>Jekyll’s built-in <code class="language-plaintext highlighter-rouge">site.tags</code> and <code class="language-plaintext highlighter-rouge">site.categories</code> hashes collect posts by taxonomy, but they don’t generate browsable pages. Most Jekyll themes include a <code class="language-plaintext highlighter-rouge">tags.html</code> that lists all tags on one page, but clicking a tag doesn’t go anywhere useful.</p>

<p>The common solutions are:</p>

<ol>
  <li><strong>Manual pages</strong> — Create a markdown file per tag. Doesn’t scale.</li>
  <li><strong>Third-party plugins</strong> — <a href="https://github.com/pattex/jekyll-tagging">jekyll-tagging</a>, <a href="https://github.com/jekyll/jekyll-archives">jekyll-archives</a>. Not whitelisted on GitHub Pages (if using the default build).</li>
  <li><strong>Custom generator plugin</strong> — Write your own. Works with GitHub Actions builds.</li>
</ol>

<p>Useful references I consulted during the decision:</p>

<ul>
  <li><a href="https://longqian.me/2017/02/09/github-jekyll-tag/">Long Qian’s Jekyll tag page guide</a> — The approach I adapted</li>
  <li><a href="https://www.untangled.dev/2020/06/02/tag-management-jekyll/">Untangled.dev tag management</a> — Alternative approach</li>
  <li><a href="https://jekyllrb.com/docs/posts/#tags">Jekyll docs on tags</a> — Official reference</li>
</ul>

<p>I went with option 3 because I was already using GitHub Actions for the build (required for other custom plugins), and I wanted full control over URL structure and SEO behavior.</p>

<h2 id="evolution-of-the-plugin">Evolution of the Plugin</h2>

<p>The plugin went through three distinct versions, each driven by a real problem. The git history tells the story.</p>

<h3 id="version-1-basic-generator-july-2025">Version 1: Basic Generator (July 2025)</h3>

<p>The initial version was straightforward — two Generator classes and two Page classes. It iterated <code class="language-plaintext highlighter-rouge">site.tags</code> and <code class="language-plaintext highlighter-rouge">site.categories</code>, created a page for each, and was done:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">Jekyll</span>
  <span class="k">class</span> <span class="nc">TagPageGenerator</span> <span class="o">&lt;</span> <span class="no">Generator</span>
    <span class="n">safe</span> <span class="kp">true</span>

    <span class="k">def</span> <span class="nf">generate</span><span class="p">(</span><span class="n">site</span><span class="p">)</span>
      <span class="n">site</span><span class="p">.</span><span class="nf">tags</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">tag</span><span class="p">,</span> <span class="n">posts</span><span class="o">|</span>
        <span class="n">site</span><span class="p">.</span><span class="nf">pages</span> <span class="o">&lt;&lt;</span> <span class="no">TagPage</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">site</span><span class="p">,</span> <span class="n">site</span><span class="p">.</span><span class="nf">source</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">posts</span><span class="p">)</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="c1"># ... CategoryPageGenerator identical pattern ...</span>

  <span class="k">class</span> <span class="nc">TagPage</span> <span class="o">&lt;</span> <span class="no">Page</span>
    <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">site</span><span class="p">,</span> <span class="n">base</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">posts</span><span class="p">)</span>
      <span class="vi">@site</span> <span class="o">=</span> <span class="n">site</span>
      <span class="vi">@base</span> <span class="o">=</span> <span class="n">base</span>
      <span class="vi">@dir</span> <span class="o">=</span> <span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s1">'tags'</span><span class="p">,</span> <span class="n">tag</span><span class="p">.</span><span class="nf">downcase</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="s1">' '</span><span class="p">,</span> <span class="s1">'-'</span><span class="p">))</span>
      <span class="vi">@name</span> <span class="o">=</span> <span class="s1">'index.html'</span>

      <span class="nb">self</span><span class="p">.</span><span class="nf">process</span><span class="p">(</span><span class="vi">@name</span><span class="p">)</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">read_yaml</span><span class="p">(</span><span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">base</span><span class="p">,</span> <span class="s1">'_layouts'</span><span class="p">),</span> <span class="s1">'tag_page.html'</span><span class="p">)</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'tag'</span><span class="p">]</span> <span class="o">=</span> <span class="n">tag</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'posts'</span><span class="p">]</span> <span class="o">=</span> <span class="n">posts</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'title'</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"Posts tagged with </span><span class="se">\"</span><span class="si">#{</span><span class="n">tag</span><span class="si">}</span><span class="se">\"</span><span class="s2">"</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>This worked fine for months. Then AdSense review forced a closer look at the sitemap.</p>

<h3 id="version-2-sitemap-exclusion-and-noindex-april-6-2026--morning">Version 2: Sitemap Exclusion and Noindex (April 6, 2026 — morning)</h3>

<p>During AdSense review preparation, I audited the sitemap and found it had <strong>434 URLs, 262 of which (60%) were auto-generated tag, category, and pagination pages</strong> with little unique content. This dilutes content quality signals.</p>

<p>The fix added two lines to each Page class and a <code class="language-plaintext highlighter-rouge">Jekyll::Hooks</code> callback for pagination:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Added to TagPage and CategoryPage:</span>
<span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'sitemap'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">false</span>
<span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'noindex'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">true</span> <span class="k">if</span> <span class="n">posts</span><span class="p">.</span><span class="nf">size</span> <span class="o">&lt;</span> <span class="mi">3</span>

<span class="c1"># Added for pagination pages:</span>
<span class="no">Jekyll</span><span class="o">::</span><span class="no">Hooks</span><span class="p">.</span><span class="nf">register</span> <span class="ss">:pages</span><span class="p">,</span> <span class="ss">:post_init</span> <span class="k">do</span> <span class="o">|</span><span class="n">page</span><span class="o">|</span>
  <span class="k">if</span> <span class="n">page</span><span class="p">.</span><span class="nf">url</span> <span class="o">=~</span> <span class="sr">%r{^/page</span><span class="se">\d</span><span class="sr">+/}</span>
    <span class="n">page</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'sitemap'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">false</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Result: sitemap dropped from 434 to 172 URLs. 195 thin tag/category pages got <code class="language-plaintext highlighter-rouge">noindex</code>.</p>

<h3 id="version-3-hook-to-generator-refactor-april-6-2026--afternoon">Version 3: Hook-to-Generator Refactor (April 6, 2026 — afternoon)</h3>

<p>The <code class="language-plaintext highlighter-rouge">Jekyll::Hooks :pages :post_init</code> approach broke pagination within hours. The hook fired during page initialization — <em>before</em> <code class="language-plaintext highlighter-rouge">jekyll-paginate</code> had created its pagination pages. This caused the homepage to display as <strong>page 32 of 32</strong> (oldest posts first) and prevented pagination directories from being created.</p>

<p>The fix replaced the hook with a lowest-priority Generator that runs <em>after</em> <code class="language-plaintext highlighter-rouge">jekyll-paginate</code> has finished:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">PaginationSitemapExcluder</span> <span class="o">&lt;</span> <span class="no">Generator</span>
  <span class="n">safe</span> <span class="kp">true</span>
  <span class="n">priority</span> <span class="ss">:lowest</span>

  <span class="k">def</span> <span class="nf">generate</span><span class="p">(</span><span class="n">site</span><span class="p">)</span>
    <span class="n">site</span><span class="p">.</span><span class="nf">pages</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">page</span><span class="o">|</span>
      <span class="k">if</span> <span class="n">page</span><span class="p">.</span><span class="nf">dir</span> <span class="o">=~</span> <span class="sr">%r{^/page</span><span class="se">\d</span><span class="sr">+}</span>
        <span class="n">page</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'sitemap'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">false</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The key insight: <code class="language-plaintext highlighter-rouge">priority :lowest</code> ensures this generator runs after all other generators (including <code class="language-plaintext highlighter-rouge">jekyll-paginate</code>), so the pagination pages exist by the time we iterate them. The hook approach was too early in the lifecycle.</p>

<p>Result: homepage showed newest posts again (page 1 of 32), all 31 pagination directories restored, pagination pages still excluded from sitemap.</p>

<h2 id="the-final-plugin">The Final Plugin</h2>

<p>The current version lives in <code class="language-plaintext highlighter-rouge">_plugins/tag_category_generator.rb</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">Jekyll</span>
  <span class="k">class</span> <span class="nc">TagPageGenerator</span> <span class="o">&lt;</span> <span class="no">Generator</span>
    <span class="n">safe</span> <span class="kp">true</span>

    <span class="k">def</span> <span class="nf">generate</span><span class="p">(</span><span class="n">site</span><span class="p">)</span>
      <span class="n">site</span><span class="p">.</span><span class="nf">tags</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">tag</span><span class="p">,</span> <span class="n">posts</span><span class="o">|</span>
        <span class="n">site</span><span class="p">.</span><span class="nf">pages</span> <span class="o">&lt;&lt;</span> <span class="no">TagPage</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">site</span><span class="p">,</span> <span class="n">site</span><span class="p">.</span><span class="nf">source</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">posts</span><span class="p">)</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="k">class</span> <span class="nc">CategoryPageGenerator</span> <span class="o">&lt;</span> <span class="no">Generator</span>
    <span class="n">safe</span> <span class="kp">true</span>

    <span class="k">def</span> <span class="nf">generate</span><span class="p">(</span><span class="n">site</span><span class="p">)</span>
      <span class="n">site</span><span class="p">.</span><span class="nf">categories</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">category</span><span class="p">,</span> <span class="n">posts</span><span class="o">|</span>
        <span class="n">site</span><span class="p">.</span><span class="nf">pages</span> <span class="o">&lt;&lt;</span> <span class="no">CategoryPage</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">site</span><span class="p">,</span> <span class="n">site</span><span class="p">.</span><span class="nf">source</span><span class="p">,</span> <span class="n">category</span><span class="p">,</span> <span class="n">posts</span><span class="p">)</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="k">class</span> <span class="nc">TagPage</span> <span class="o">&lt;</span> <span class="no">Page</span>
    <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">site</span><span class="p">,</span> <span class="n">base</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">posts</span><span class="p">)</span>
      <span class="vi">@site</span> <span class="o">=</span> <span class="n">site</span>
      <span class="vi">@base</span> <span class="o">=</span> <span class="n">base</span>
      <span class="vi">@dir</span> <span class="o">=</span> <span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s1">'tags'</span><span class="p">,</span> <span class="n">tag</span><span class="p">.</span><span class="nf">downcase</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="s1">' '</span><span class="p">,</span> <span class="s1">'-'</span><span class="p">))</span>
      <span class="vi">@name</span> <span class="o">=</span> <span class="s1">'index.html'</span>

      <span class="nb">self</span><span class="p">.</span><span class="nf">process</span><span class="p">(</span><span class="vi">@name</span><span class="p">)</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">read_yaml</span><span class="p">(</span><span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">base</span><span class="p">,</span> <span class="s1">'_layouts'</span><span class="p">),</span> <span class="s1">'tag_page.html'</span><span class="p">)</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'tag'</span><span class="p">]</span> <span class="o">=</span> <span class="n">tag</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'posts'</span><span class="p">]</span> <span class="o">=</span> <span class="n">posts</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'title'</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"Posts tagged with </span><span class="se">\"</span><span class="si">#{</span><span class="n">tag</span><span class="si">}</span><span class="se">\"</span><span class="s2">"</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'sitemap'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">false</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'noindex'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">true</span> <span class="k">if</span> <span class="n">posts</span><span class="p">.</span><span class="nf">size</span> <span class="o">&lt;</span> <span class="mi">3</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="k">class</span> <span class="nc">CategoryPage</span> <span class="o">&lt;</span> <span class="no">Page</span>
    <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">site</span><span class="p">,</span> <span class="n">base</span><span class="p">,</span> <span class="n">category</span><span class="p">,</span> <span class="n">posts</span><span class="p">)</span>
      <span class="vi">@site</span> <span class="o">=</span> <span class="n">site</span>
      <span class="vi">@base</span> <span class="o">=</span> <span class="n">base</span>
      <span class="vi">@dir</span> <span class="o">=</span> <span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s1">'categories'</span><span class="p">,</span> <span class="n">category</span><span class="p">.</span><span class="nf">downcase</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="s1">' '</span><span class="p">,</span> <span class="s1">'-'</span><span class="p">).</span><span class="nf">gsub</span><span class="p">(</span><span class="s1">'_'</span><span class="p">,</span> <span class="s1">'-'</span><span class="p">))</span>
      <span class="vi">@name</span> <span class="o">=</span> <span class="s1">'index.html'</span>

      <span class="nb">self</span><span class="p">.</span><span class="nf">process</span><span class="p">(</span><span class="vi">@name</span><span class="p">)</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">read_yaml</span><span class="p">(</span><span class="no">File</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">base</span><span class="p">,</span> <span class="s1">'_layouts'</span><span class="p">),</span> <span class="s1">'category_page.html'</span><span class="p">)</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'category'</span><span class="p">]</span> <span class="o">=</span> <span class="n">category</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'posts'</span><span class="p">]</span> <span class="o">=</span> <span class="n">posts</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'title'</span><span class="p">]</span> <span class="o">=</span> <span class="s2">"Posts in category </span><span class="se">\"</span><span class="si">#{</span><span class="n">category</span><span class="si">}</span><span class="se">\"</span><span class="s2">"</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'sitemap'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">false</span>
      <span class="nb">self</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'noindex'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">true</span> <span class="k">if</span> <span class="n">posts</span><span class="p">.</span><span class="nf">size</span> <span class="o">&lt;</span> <span class="mi">3</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="k">class</span> <span class="nc">PaginationSitemapExcluder</span> <span class="o">&lt;</span> <span class="no">Generator</span>
    <span class="n">safe</span> <span class="kp">true</span>
    <span class="n">priority</span> <span class="ss">:lowest</span>

    <span class="k">def</span> <span class="nf">generate</span><span class="p">(</span><span class="n">site</span><span class="p">)</span>
      <span class="n">site</span><span class="p">.</span><span class="nf">pages</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">page</span><span class="o">|</span>
        <span class="k">if</span> <span class="n">page</span><span class="p">.</span><span class="nf">dir</span> <span class="o">=~</span> <span class="sr">%r{^/page</span><span class="se">\d</span><span class="sr">+}</span>
          <span class="n">page</span><span class="p">.</span><span class="nf">data</span><span class="p">[</span><span class="s1">'sitemap'</span><span class="p">]</span> <span class="o">=</span> <span class="kp">false</span>
        <span class="k">end</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="design-decisions">Design Decisions</h3>

<p><strong><code class="language-plaintext highlighter-rouge">safe true</code></strong> — Marks the generator as safe for GitHub Pages compatibility. Even though we’re building with GitHub Actions, this is good practice.</p>

<p><strong>URL structure</strong> — Tags go to <code class="language-plaintext highlighter-rouge">/tags/tag-name/</code> and categories to <code class="language-plaintext highlighter-rouge">/categories/category-name/</code>. The <code class="language-plaintext highlighter-rouge">downcase.gsub(' ', '-')</code> normalizes spaces to hyphens. Categories also convert underscores to hyphens for consistency.</p>

<p><strong><code class="language-plaintext highlighter-rouge">sitemap: false</code></strong> — This was added after discovering that the generated pages were bloating the sitemap. With 237 tags, that’s 237 extra URLs in <code class="language-plaintext highlighter-rouge">sitemap.xml</code> — most pointing to pages with only one or two posts. See <a href="/jekyll-sitemap-bloat-tags-categories-pagination/">Your Jekyll Sitemap Is 60% Garbage</a> for the full story.</p>

<p><strong><code class="language-plaintext highlighter-rouge">noindex</code> for thin content</strong> — Tags with fewer than 3 posts get <code class="language-plaintext highlighter-rouge">noindex</code> to avoid search engines indexing low-value pages. This is a soft SEO signal — the page still exists for users, but search engines are asked to skip it.</p>

<p><strong><code class="language-plaintext highlighter-rouge">PaginationSitemapExcluder</code></strong> — A bonus generator that excludes <code class="language-plaintext highlighter-rouge">/page2/</code>, <code class="language-plaintext highlighter-rouge">/page3/</code>, etc. from the sitemap. These pagination pages add no SEO value and were another source of sitemap bloat. This was originally a <code class="language-plaintext highlighter-rouge">Jekyll::Hooks :pages :post_init</code> callback, but that broke <code class="language-plaintext highlighter-rouge">jekyll-paginate</code> because the hook fired before pagination pages existed. The <code class="language-plaintext highlighter-rouge">priority :lowest</code> Generator approach runs after all other generators have finished.</p>

<h2 id="the-layouts">The Layouts</h2>

<p>Both layouts are nearly identical. Here’s <code class="language-plaintext highlighter-rouge">_layouts/tag_page.html</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>---
layout: default
---

<span class="nt">&lt;article</span> <span class="na">class=</span><span class="s">"page"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;h1</span> <span class="na">class=</span><span class="s">"page-title"</span><span class="nt">&gt;</span>{{ page.title }}<span class="nt">&lt;/h1&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"page-content"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"posts-list"</span><span class="nt">&gt;</span>
      {% for post in page.posts %}
        <span class="nt">&lt;article</span> <span class="na">class=</span><span class="s">"post-item"</span><span class="nt">&gt;</span>
          <span class="nt">&lt;h2&gt;&lt;a</span> <span class="na">href=</span><span class="s">"{{ post.url | relative_url }}"</span><span class="nt">&gt;</span>{{ post.title }}<span class="nt">&lt;/a&gt;&lt;/h2&gt;</span>
          <span class="nt">&lt;time</span> <span class="na">datetime=</span><span class="s">"{{ post.date | date_to_xmlschema }}"</span><span class="nt">&gt;</span>{{ post.date | date: site.date_format }}<span class="nt">&lt;/time&gt;</span>
          {% if post.excerpt %}
            <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"post-excerpt"</span><span class="nt">&gt;</span>{{ post.excerpt }}<span class="nt">&lt;/div&gt;</span>
          {% endif %}
        <span class="nt">&lt;/article&gt;</span>
      {% endfor %}
    <span class="nt">&lt;/div&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/article&gt;</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">category_page.html</code> layout is identical. You could DRY this up with an include, but for two files it’s not worth the indirection.</p>

<h2 id="the-index-pages">The Index Pages</h2>

<p>Users need a way to browse all tags and categories. These are simple Liquid pages:</p>

<p><code class="language-plaintext highlighter-rouge">tags.html</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>---
layout: list_page
title: All Tags
---
<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"taxonomies-list"</span><span class="nt">&gt;</span>
  {% for tag_hash in site.tags %}
    {% assign tag_name = tag_hash[0] %}
    {% assign num_posts = tag_hash[1].size %}
    <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"{{ site.baseurl }}/tags/{{ tag_name | slugify }}/"</span> <span class="na">class=</span><span class="s">"taxonomy-item"</span><span class="nt">&gt;</span>
      {{ tag_name }} ({{ num_posts }})
    <span class="nt">&lt;/a&gt;</span>
  {% endfor %}
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">categories.html</code> follows the same pattern with <code class="language-plaintext highlighter-rouge">site.categories</code>.</p>

<h2 id="github-actions-requirement">GitHub Actions Requirement</h2>

<p>This plugin won’t work with the default GitHub Pages Jekyll build — only <a href="https://pages.github.com/versions/">whitelisted plugins</a> run there. You need a GitHub Actions workflow that runs <code class="language-plaintext highlighter-rouge">jekyll build</code> yourself and deploys the output.</p>

<p>If you’re already using GitHub Actions for your Jekyll build (which you’ll need for any custom plugin), this just works — drop the <code class="language-plaintext highlighter-rouge">.rb</code> file in <code class="language-plaintext highlighter-rouge">_plugins/</code> and it’s picked up automatically.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<p><strong>Tag proliferation is real.</strong> At 237 tags across 139 posts, many tags have only 1-2 posts. This creates thin content pages that dilute SEO. The <code class="language-plaintext highlighter-rouge">noindex</code> threshold helps, but the real fix is disciplined tagging — reuse existing tags rather than inventing new ones for every post.</p>

<p><strong>Sitemap exclusion matters.</strong> The original version of this plugin didn’t set <code class="language-plaintext highlighter-rouge">sitemap: false</code>. The result was a sitemap with 434 URLs where 262 were auto-generated junk. Google Search Console flagged many as “Discovered – currently not indexed.” Adding <code class="language-plaintext highlighter-rouge">sitemap: false</code> cut the sitemap to meaningful content only.</p>

<p><strong>Jekyll hook ordering is treacherous.</strong> The <code class="language-plaintext highlighter-rouge">:post_init</code> hook seemed like the right place to tag pagination pages for sitemap exclusion — it fires when a page is initialized. But <code class="language-plaintext highlighter-rouge">jekyll-paginate</code> creates pages <em>during</em> generation, not initialization. The hook fired too early, interfered with pagination’s template detection, and the homepage showed the oldest posts instead of the newest. The fix was moving to a <code class="language-plaintext highlighter-rouge">priority :lowest</code> Generator. Lesson: when modifying pages created by other plugins, use a Generator with lowest priority, not a hook.</p>

<p><strong>Categories vs tags</strong> — In practice, categories on this blog are broad buckets (<code class="language-plaintext highlighter-rouge">technical</code>, <code class="language-plaintext highlighter-rouge">homelab</code>, <code class="language-plaintext highlighter-rouge">web-development</code>) while tags are specific topics (<code class="language-plaintext highlighter-rouge">proxmox</code>, <code class="language-plaintext highlighter-rouge">ceph</code>, <code class="language-plaintext highlighter-rouge">jekyll</code>). The plugin treats them identically but they serve different navigation purposes.</p>

<h2 id="current-stats">Current Stats</h2>

<p>As of this writing:</p>

<ul>
  <li><strong>139</strong> published posts</li>
  <li><strong>237</strong> unique tags → 237 generated tag pages</li>
  <li><strong>53</strong> unique categories → 53 generated category pages</li>
  <li>Top tags: homelab (32), proxmox (23), jekyll (23), ceph (19), storage (18)</li>
  <li>Top categories: technical (63), homelab (22), web-development (21), hardware (20)</li>
</ul>

<h2 id="related-posts">Related Posts</h2>

<ul>
  <li><a href="/jekyll-sitemap-bloat-tags-categories-pagination/">Your Jekyll Sitemap Is 60% Garbage</a> — The sitemap bloat problem this plugin caused and how it was fixed</li>
  <li><a href="/setting-up-jekyll-blog-github-pages/">Building This Blog: Jekyll on GitHub Pages from Zero to 130+ Posts</a> — Broader setup guide that mentions this plugin</li>
  <li><a href="/jekyll-markdown-feature-reference/">How the Sausage Is Made: Every Feature Powering This Jekyll Blog</a> — Feature inventory including tag/category pages</li>
</ul>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="web-development" /><category term="technical" /><category term="jekyll" /><category term="jekyll" /><category term="ruby" /><category term="plugins" /><category term="tags" /><category term="categories" /><category term="github-pages" /><category term="seo" /><category term="github-actions" /><summary type="html"><![CDATA[How to build a custom Jekyll Ruby plugin that automatically generates tag and category pages, with SEO controls for sitemap exclusion and noindex on thin content. Includes the full plugin, layouts, and index pages.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/jekyll-tag-category-generator-plugin.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/jekyll-tag-category-generator-plugin.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Kiro IDE on Windows: WSL2 Support via Open Remote Extension</title><link href="https://mcgarrah.org/kiro-ide-wsl2-support-open-remote-extension/" rel="alternate" type="text/html" title="Kiro IDE on Windows: WSL2 Support via Open Remote Extension" /><published>2026-05-03T00:00:00+00:00</published><updated>2026-05-03T00:00:00+00:00</updated><id>https://mcgarrah.org/kiro-ide-wsl2-support-open-remote-extension</id><content type="html" xml:base="https://mcgarrah.org/kiro-ide-wsl2-support-open-remote-extension/"><![CDATA[<p>Kiro is a VS Code fork, but it doesn’t include Microsoft’s proprietary Remote - WSL extension. That extension isn’t published to Open VSX (the extension marketplace Kiro uses), and Microsoft’s marketplace terms restrict it to official VS Code builds. If you’re running Kiro on Windows with WSL2 as your primary development environment, this is a hard stop — unless you know where to look.</p>

<blockquote>
  <p><strong>Migration context:</strong> On May 1, 2026, AWS published the <a href="https://aws.amazon.com/blogs/devops/amazon-q-developer-end-of-support-announcement/">Amazon Q Developer end-of-support announcement</a>. If you’re migrating from Amazon Q Developer to Kiro on Windows, WSL2 support is likely a hard requirement. This article covers exactly that gap.</p>
</blockquote>

<p>The <a href="https://open-vsx.org/extension/jeanp413/open-remote-wsl">Open Remote - WSL</a> extension by jeanp413 fills this gap. It’s a community-built, Open VSX-compatible implementation that enables WSL2 support in any VS Code fork, including Kiro. The <a href="https://github.com/kirodotdev/Kiro/issues/17">Kiro GitHub issue #17</a> tracks the ongoing community experience — 38+ comments of workarounds, breakage reports, and fixes that inform everything in this article.</p>

<p>The extension works. It also breaks predictably on Kiro updates, has an easy-to-miss configuration requirement, and routes terminal commands to the wrong shell if you don’t set a default profile. None of these are dealbreakers, but they’ll cost you time if you don’t know about them going in.</p>

<!-- excerpt-end -->

<h2 id="the-problem">The Problem</h2>

<p>VS Code’s WSL integration is built on Microsoft’s proprietary Remote Development extensions. These extensions are:</p>

<ul>
  <li>Licensed exclusively for use with Microsoft’s VS Code builds</li>
  <li>Published only to Microsoft’s Visual Studio Marketplace, not Open VSX</li>
  <li>Not available in Kiro’s extension marketplace</li>
</ul>

<p>Without WSL support, Kiro on Windows can only access the Windows filesystem. Your Linux development environments, toolchains, and MCP servers running inside WSL2 are unreachable from the IDE. As multiple users in the tracking issue put it bluntly: Kiro is “completely useless” for Windows developers who do all their work inside WSL2.</p>

<p>Kiro itself acknowledges the gap — if you install the Linux version inside WSL2, it displays a message directing you to install the Windows version instead and use the remote extension approach.</p>

<h2 id="installation-and-the-argvjson-requirement">Installation and the argv.json Requirement</h2>

<p>Install the extension from Kiro’s extension panel or the command line:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">kiro</span><span class="w"> </span><span class="nt">--install-extension</span><span class="w"> </span><span class="nx">jeanp413.open-remote-wsl</span><span class="w">
</span></code></pre></div></div>

<p><strong>This is the step most people miss.</strong> The extension requires proposed API access to function. Without it, the extension installs silently but does nothing. Enable it in your <code class="language-plaintext highlighter-rouge">~/.kiro/argv.json</code> file (or open it via the command palette: <code class="language-plaintext highlighter-rouge">Preferences: Configure Runtime Arguments</code>):</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"enable-proposed-api"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="s2">"jeanp413.open-remote-wsl"</span><span class="w">
    </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Restart Kiro after making this change. If you have other entries in <code class="language-plaintext highlighter-rouge">argv.json</code>, add the <code class="language-plaintext highlighter-rouge">enable-proposed-api</code> array alongside them — don’t replace the file contents. If WSL commands don’t appear in the command palette after installing the extension, this missing configuration is almost certainly the reason.</p>

<p>After installation, the command palette gains WSL-specific commands:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">WSL: New Window</code> — Open a new Kiro window connected to your default WSL distribution</li>
  <li><code class="language-plaintext highlighter-rouge">WSL: New Window using Distro...</code> — Choose a specific distribution</li>
  <li><code class="language-plaintext highlighter-rouge">WSL: Open Folder in WSL...</code> — Open a Linux folder directly</li>
  <li><code class="language-plaintext highlighter-rouge">WSL: Reopen Folder in WSL</code> — Switch the current folder to WSL context</li>
</ul>

<h2 id="the-terminal-default-profile-gotcha">The Terminal Default Profile Gotcha</h2>

<p>With the extension installed and a WSL2 folder open, Kiro’s agentic chat will attempt to run terminal commands. By default, it routes them to PowerShell — not the WSL terminal. This means commands that should execute in your Linux environment run in Windows instead, producing confusing failures.</p>

<p>The fix is to set the WSL distribution as your default terminal profile. Add this to your Kiro settings (<code class="language-plaintext highlighter-rouge">%APPDATA%\Kiro\User\settings.json</code>):</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"wsl.defaultDistro"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Ubuntu"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"terminal.integrated.defaultProfile.windows"</span><span class="p">:</span><span class="w"> </span><span class="s2">"WSL (Ubuntu)"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"terminal.integrated.profiles.windows"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"WSL (Ubuntu)"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"path"</span><span class="p">:</span><span class="w"> </span><span class="s2">"C:</span><span class="se">\\</span><span class="s2">Windows</span><span class="se">\\</span><span class="s2">System32</span><span class="se">\\</span><span class="s2">wsl.exe"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"args"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"-d"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Ubuntu"</span><span class="p">]</span><span class="w">
        </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Replace <code class="language-plaintext highlighter-rouge">Ubuntu</code> with your distribution name. This ensures both manual terminal sessions and agent-initiated commands execute inside WSL2.</p>

<h2 id="opening-wsl2-folders">Opening WSL2 Folders</h2>

<p>From PowerShell or the Windows command line:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Open a specific Linux folder</span><span class="w">
</span><span class="n">kiro</span><span class="w"> </span><span class="nt">--folder-uri</span><span class="w"> </span><span class="s2">"vscode-remote://wsl+Ubuntu/home/username/projects"</span><span class="w">

</span><span class="c"># Open with the --remote flag</span><span class="w">
</span><span class="n">kiro</span><span class="w"> </span><span class="nt">--remote</span><span class="w"> </span><span class="nx">wsl</span><span class="o">+</span><span class="nx">Ubuntu</span><span class="w">
</span></code></pre></div></div>

<p>From inside a WSL2 terminal:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># If the kiro CLI is on PATH (installed by the Windows Kiro installer)</span>
kiro <span class="nb">.</span>
</code></pre></div></div>

<p>Note: launching <code class="language-plaintext highlighter-rouge">kiro .</code> from inside WSL2 doesn’t always auto-detect the WSL context correctly. If Kiro opens in Windows mode instead of WSL mode, use the explicit <code class="language-plaintext highlighter-rouge">--folder-uri</code> or <code class="language-plaintext highlighter-rouge">--remote</code> flags from PowerShell, or use the command palette (<code class="language-plaintext highlighter-rouge">WSL: Open Folder in WSL...</code>) from within Kiro.</p>

<p>A community-contributed launcher script addresses this by detecting whether the path is a WSL path and automatically constructing the correct <code class="language-plaintext highlighter-rouge">--folder-uri</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="c"># Save as /usr/local/bin/kiro-wsl or replace the Kiro bin/kiro script</span>
<span class="nv">KIRO_ROOT</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">dirname</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">dirname</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">readlink</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$0</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>

<span class="nv">ARGS</span><span class="o">=()</span>
<span class="k">for </span>arg <span class="k">in</span> <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
    if</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="o">!=</span> -<span class="k">*</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="o">[</span> <span class="nt">-d</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">"."</span> <span class="o">]]</span> <span class="o">||</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="o">==</span> <span class="s2">".."</span> <span class="o">]]</span><span class="p">;</span> <span class="o">}</span><span class="p">;</span> <span class="k">then
        </span><span class="nv">FOLDER</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">realpath</span> <span class="nt">-m</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>
        ARGS+<span class="o">=(</span><span class="s2">"--folder-uri"</span> <span class="s2">"vscode-remote://wsl+</span><span class="k">${</span><span class="nv">WSL_DISTRO_NAME</span><span class="k">}${</span><span class="nv">FOLDER</span><span class="k">}</span><span class="s2">"</span><span class="o">)</span>
    <span class="k">elif</span> <span class="o">[[</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="o">!=</span> -<span class="k">*</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">[</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
        </span><span class="nv">FILE</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">realpath</span> <span class="nt">-m</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>
        ARGS+<span class="o">=(</span><span class="s2">"--file-uri"</span> <span class="s2">"vscode-remote://wsl+</span><span class="k">${</span><span class="nv">WSL_DISTRO_NAME</span><span class="k">}${</span><span class="nv">FILE</span><span class="k">}</span><span class="s2">"</span><span class="o">)</span>
    <span class="k">else
        </span>ARGS+<span class="o">=(</span><span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span><span class="o">)</span>
    <span class="k">fi
done</span>

<span class="s2">"</span><span class="nv">$KIRO_ROOT</span><span class="s2">/Kiro.exe"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">ARGS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span> &lt;/dev/null &amp;&gt;/dev/null &amp;
<span class="nb">disown</span>
</code></pre></div></div>

<h2 id="the-kiro-update-breakage-cycle">The Kiro Update Breakage Cycle</h2>

<p>This is the most significant operational issue. When Kiro updates, the kiro-server binary installed inside WSL2 (at <code class="language-plaintext highlighter-rouge">~/.kiro-server/</code>) becomes incompatible with the new Kiro version. The extension tries to install a new server, and the installation script can fail — sometimes due to stale cached binaries, sometimes due to quoting bugs in the generated bash script.</p>

<p>The symptom is always the same:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[Error] Error resolving authority
Error: Couldn't install vscode server on remote server,
       install script returned non-zero exit status
</code></pre></div></div>

<h3 id="recovery-procedure">Recovery Procedure</h3>

<p>When WSL2 connectivity breaks after a Kiro update:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Inside WSL2</span>
<span class="nb">rm</span> <span class="nt">-rf</span> ~/.kiro-server
</code></pre></div></div>

<p>Then restart Kiro and reconnect to WSL2. The extension will perform a fresh server installation. This has been the reliable fix across multiple Kiro versions (confirmed working through v0.1.25 and beyond in the tracking issue).</p>

<h3 id="the-server-installation-script-quoting-bug">The Server Installation Script Quoting Bug</h3>

<p>In some Kiro versions, the server installation script that the extension generates contains improperly escaped single quotes (<code class="language-plaintext highlighter-rouge">'\''</code>) that fail in bash. If the <code class="language-plaintext highlighter-rouge">rm -rf ~/.kiro-server</code> approach doesn’t resolve the issue:</p>

<ol>
  <li>Open Kiro’s Output panel and select the WSL extension output channel</li>
  <li>Copy the full bash script content from the output</li>
  <li>Save it to a file inside WSL2 (e.g., <code class="language-plaintext highlighter-rouge">~/kiro-server-install.sh</code>)</li>
  <li>Fix the three quoting errors (look for <code class="language-plaintext highlighter-rouge">'\''</code> patterns that break bash parsing)</li>
  <li>Run the fixed script manually: <code class="language-plaintext highlighter-rouge">bash ~/kiro-server-install.sh</code></li>
</ol>

<p>This is a known issue in the community extension’s server bootstrapping code. The manual fix is tedious but reliable.</p>

<h2 id="wsl2-path-escaping-issues">WSL2 Path Escaping Issues</h2>

<p>Kiro’s agent sometimes generates Windows-style UNC paths when it should be using Linux paths inside WSL2. Commands like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cd "\\wsl.localhost\Ubuntu\home\username\project"
</code></pre></div></div>

<p>…fail because the agent is constructing a Windows UNC path instead of the native Linux path <code class="language-plaintext highlighter-rouge">/home/username/project</code>. This is a known limitation of how the remote extension bridges the two filesystems. The agent doesn’t always correctly detect that it’s operating in a Linux context.</p>

<p>There’s no configuration fix for this — it’s a behavioral issue in how Kiro’s agent interacts with the remote extension’s filesystem abstraction. When it happens, the workaround is to manually correct the path in the terminal or re-prompt the agent with explicit Linux path context.</p>

<h2 id="chat-window-disabled-unsafe-environment">Chat Window Disabled: “Unsafe Environment”</h2>

<p>Some users report that after connecting to WSL2, the Kiro chat sidebar shows “Drag a view here to display” or displays an “unsafe environment” warning that disables the chat window entirely. This appears to be related to workspace trust settings.</p>

<p>If you encounter this:</p>

<ol>
  <li>Open the command palette and run <code class="language-plaintext highlighter-rouge">Workspaces: Manage Workspace Trust</code></li>
  <li>Trust the WSL2 workspace folder</li>
  <li>If the chat window still doesn’t appear, close the WSL2 window and reopen it via <code class="language-plaintext highlighter-rouge">WSL: New Window</code></li>
</ol>

<p>The alternative approach — running the Linux version of Kiro natively inside WSL2 via WSLg — avoids this issue entirely but introduces its own problems (GUI scaling issues on multi-monitor setups, occasional terminal hangs, and general WSLg instability).</p>

<h2 id="mcp-servers-in-wsl2">MCP Servers in WSL2</h2>

<p>This is where WSL2 support becomes particularly relevant for Kiro’s agentic workflows. MCP servers that depend on Linux toolchains — Python <code class="language-plaintext highlighter-rouge">uvx</code> packages, Node.js tools, Docker containers — run natively inside WSL2 rather than through Windows compatibility layers.</p>

<p>When Kiro connects to WSL2 via the Open Remote extension, the kiro-agent’s MCP server processes spawn inside the Linux environment. This means:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">uvx</code>-based MCP servers use the Linux Python installation</li>
  <li>Docker-based MCP servers connect to the WSL2 Docker daemon</li>
  <li>File paths in MCP server configs use Linux paths (<code class="language-plaintext highlighter-rouge">/home/...</code>), not Windows paths</li>
  <li>Environment variables resolve from the Linux shell, not PowerShell</li>
</ul>

<h3 id="wsl2-side-configuration">WSL2-Side Configuration</h3>

<p>The <code class="language-plaintext highlighter-rouge">~/.kiro</code> directory inside WSL2 is independent from <code class="language-plaintext highlighter-rouge">%USERPROFILE%\.kiro</code> on the Windows side. When connected to WSL2, Kiro reads:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/home/username/.kiro/
├── settings/mcp.json    # MCP servers configured for Linux execution
├── powers/              # Powers available in Linux context
├── hooks/               # Hooks that run in Linux shell
├── steering/            # Steering rules
└── secrets/             # Linux-side credentials
</code></pre></div></div>

<p>This natural separation means your Windows-side Kiro configuration (with Windows-native MCP servers) and your WSL2-side configuration (with Linux-native MCP servers) are already isolated by the filesystem boundary. You don’t need the <a href="/kiro-ide-parallel-personas-director-developer/">persona isolation techniques</a> to separate Windows and Linux configs — the remote extension handles that by virtue of running in a different filesystem.</p>

<h2 id="the-alternative-running-kiro-natively-in-wsl2">The Alternative: Running Kiro Natively in WSL2</h2>

<p>Several users in the tracking issue have tried running the Linux <code class="language-plaintext highlighter-rouge">.deb</code> version of Kiro directly inside WSL2 using WSLg (Windows Subsystem for Linux GUI). This bypasses the remote extension entirely — Kiro runs as a native Linux application with direct filesystem access.</p>

<p>It works, with caveats:</p>

<ul>
  <li><strong>Multi-monitor scaling</strong>: WSLg doesn’t respect Windows display scaling. On multi-monitor setups (especially mixed DPI), the mouse cursor may be oversized and click targets may be offset.</li>
  <li><strong>Terminal stability</strong>: The integrated terminal can hang after extended use in some configurations.</li>
  <li><strong>GTK dependencies</strong>: Requires up-to-date GTK packages in the WSL2 distribution. Missing or outdated libraries cause rendering issues.</li>
  <li><strong>Performance</strong>: Noticeably slower than the remote extension approach for UI rendering.</li>
</ul>

<p>Kiro itself discourages this approach — the Linux installer displays a message suggesting you use the Windows version with the remote extension instead. But for developers who find the remote extension’s breakage cycle unacceptable, it’s a viable if rough alternative.</p>

<h2 id="relationship-to-persona-isolation">Relationship to Persona Isolation</h2>

<p>If you’re running <a href="/kiro-ide-parallel-personas-director-developer/">parallel Kiro personas</a> on Windows, WSL2 adds a third dimension to the isolation story. The Windows-side personas (director and developer) each have their own <code class="language-plaintext highlighter-rouge">%USERPROFILE%\.kiro-*</code> directories. When either persona connects to WSL2, it sees the single <code class="language-plaintext highlighter-rouge">/home/username/.kiro/</code> inside Linux.</p>

<p>For most workflows this is fine — the WSL2 config is developer-focused by nature. The more common pattern is: director persona runs on Windows (Atlassian, GitLab, observability MCPs), developer persona connects to WSL2 (code toolchains, Docker, Linux-native MCP servers).</p>

<h2 id="setup-checklist">Setup Checklist</h2>

<ol>
  <li>Install WSL2 with your preferred distribution (<code class="language-plaintext highlighter-rouge">wsl --install -d Ubuntu</code>)</li>
  <li>Install the Open Remote - WSL extension in Kiro (<code class="language-plaintext highlighter-rouge">jeanp413.open-remote-wsl</code>)</li>
  <li><strong>Enable proposed API</strong> in <code class="language-plaintext highlighter-rouge">~/.kiro/argv.json</code> — this is the step most people miss</li>
  <li>Restart Kiro</li>
  <li>Set WSL as the default terminal profile in Kiro settings</li>
  <li>Open a WSL2 folder (command palette → <code class="language-plaintext highlighter-rouge">WSL: New Window</code>)</li>
  <li>On first connection, allow the kiro-server installation inside WSL2</li>
  <li>Configure <code class="language-plaintext highlighter-rouge">~/.kiro/settings/mcp.json</code> inside WSL2 for Linux-native MCP servers</li>
  <li>Verify MCP servers start correctly from the Kiro terminal (should show Linux paths)</li>
</ol>

<h3 id="after-every-kiro-update">After Every Kiro Update</h3>

<ol>
  <li>If WSL2 connectivity breaks: <code class="language-plaintext highlighter-rouge">rm -rf ~/.kiro-server</code> inside WSL2, then restart Kiro</li>
  <li>If that doesn’t work: extract the server install script from Kiro’s Output panel, fix quoting, run manually</li>
  <li>Verify <code class="language-plaintext highlighter-rouge">argv.json</code> still contains the <code class="language-plaintext highlighter-rouge">enable-proposed-api</code> entry (updates occasionally reset it)</li>
</ol>

<h2 id="the-state-of-things">The State of Things</h2>

<p>WSL2 support in Kiro is functional but fragile. The community extension works, the configuration is straightforward once you know about <code class="language-plaintext highlighter-rouge">argv.json</code>, and the recovery procedure after updates is reliable. But it’s a community-maintained bridge over a gap that arguably shouldn’t exist in a product targeting Windows developers.</p>

<p>The <a href="https://github.com/kirodotdev/Kiro/issues/17">tracking issue</a> remains open with the <code class="language-plaintext highlighter-rouge">pending-maintainer-response</code> label. Multiple users have requested either native WSL support or at minimum official documentation for the community extension setup. Until one of those happens, this article and that issue thread are the primary references.</p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://github.com/kirodotdev/Kiro/issues/17">Kiro GitHub Issue #17: WSL Support</a> — The canonical community thread</li>
  <li><a href="https://open-vsx.org/extension/jeanp413/open-remote-wsl">Open Remote - WSL on Open VSX</a></li>
  <li><a href="https://github.com/jeanp413/open-remote-wsl">jeanp413/open-remote-wsl on GitHub</a></li>
  <li><a href="https://learn.microsoft.com/en-us/windows/wsl/">WSL2 Documentation</a></li>
  <li><a href="/kiro-ide-parallel-personas-director-developer/">Kiro IDE: Running Parallel Personas</a> — Companion article on persona isolation</li>
</ul>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="technical" /><category term="devtools" /><category term="kiro" /><category term="kiro" /><category term="vscode" /><category term="wsl2" /><category term="windows" /><category term="linux" /><category term="mcp" /><category term="remote-development" /><category term="open-vsx" /><summary type="html"><![CDATA[Complete guide to enabling WSL2 support in Kiro IDE on Windows using the Open Remote - WSL community extension. Covers the required argv.json configuration, kiro-server breakage recovery after updates, terminal default profile fix, MCP server execution within Linux, and known limitations.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/kiro-ide-wsl2-support-open-remote-extension.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/kiro-ide-wsl2-support-open-remote-extension.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Kiro IDE: Running Parallel Personas for Director and Developer Workflows</title><link href="https://mcgarrah.org/kiro-ide-parallel-personas-director-developer/" rel="alternate" type="text/html" title="Kiro IDE: Running Parallel Personas for Director and Developer Workflows" /><published>2026-05-02T00:00:00+00:00</published><updated>2026-05-02T00:00:00+00:00</updated><id>https://mcgarrah.org/kiro-ide-parallel-personas-director-developer</id><content type="html" xml:base="https://mcgarrah.org/kiro-ide-parallel-personas-director-developer/"><![CDATA[<p>Kiro is a compelling IDE — particularly for agentic workflows where powers, hooks, and MCP servers turn it into something closer to a command center than a text editor. The problem surfaces when you need that command center configured two fundamentally different ways at the same time.</p>

<blockquote>
  <p><strong>Migration context:</strong> On May 1, 2026, AWS published the <a href="https://aws.amazon.com/blogs/devops/amazon-q-developer-end-of-support-announcement/">Amazon Q Developer end-of-support announcement</a>. If you’re evaluating Kiro as the migration path from Amazon Q Developer, the timeline is now official. The configuration depth covered here is worth understanding before you commit to the switch.</p>
</blockquote>

<p>My use case: a Senior Director persona loaded with Atlassian (Jira, Confluence), GitLab, NewRelic, Wiz, and AWS pricing MCPs for ticket management, architecture reviews, and operational oversight — running alongside a developer persona stripped down to Terraform, AWS docs, and code-focused tooling. These configurations don’t overlap well. The director’s MCP servers add latency, consume resources, and clutter the tool list when I’m writing code. The developer’s minimal setup lacks the integrations I need when triaging incidents or managing a sprint.</p>

<p>VS Code solved this years ago with profiles. Kiro inherited the <code class="language-plaintext highlighter-rouge">--profile</code> flag, but it doesn’t do what you’d expect.</p>

<!-- excerpt-end -->

<h2 id="the-problem-what---profile-actually-isolates">The Problem: What <code class="language-plaintext highlighter-rouge">--profile</code> Actually Isolates</h2>

<p>Kiro has two separate configuration stores:</p>

<table>
  <thead>
    <tr>
      <th>Store</th>
      <th>Location (macOS)</th>
      <th>Location (Windows)</th>
      <th><code class="language-plaintext highlighter-rouge">--profile</code> isolates?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>VS Code state</td>
      <td><code class="language-plaintext highlighter-rouge">~/Library/Application Support/Kiro/</code></td>
      <td><code class="language-plaintext highlighter-rouge">%APPDATA%\Kiro\</code></td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Kiro agent config</td>
      <td><code class="language-plaintext highlighter-rouge">~/.kiro/</code></td>
      <td><code class="language-plaintext highlighter-rouge">%USERPROFILE%\.kiro\</code></td>
      <td>No</td>
    </tr>
  </tbody>
</table>

<p>The <code class="language-plaintext highlighter-rouge">--profile</code> flag creates named profiles under the VS Code state directory — separate editor settings, extensions, keybindings, and UI state. That’s the inherited VS Code profile system working as designed.</p>

<p>But everything that makes Kiro <em>Kiro</em> lives in <code class="language-plaintext highlighter-rouge">~/.kiro/</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/.kiro/
├── agents/          # Agent persona definitions
├── hooks/           # Automation hooks (AWS guard rails, VPN checks)
├── powers/          # MCP power integrations (Atlassian, GitLab, etc.)
├── settings/
│   └── mcp.json     # MCP server configurations
├── steering/        # Steering rules
├── skills/          # Custom skills
└── secrets/         # Encrypted credentials
</code></pre></div></div>

<p>All profiles share this single <code class="language-plaintext highlighter-rouge">~/.kiro/</code> directory. There’s no flag, environment variable, or configuration option to redirect it.</p>

<h2 id="how-kiro-resolves-the-kiro-path">How Kiro Resolves the <code class="language-plaintext highlighter-rouge">~/.kiro</code> Path</h2>

<p>I traced the path resolution through Kiro’s source to understand the full chain. There are two independent code paths that both land on <code class="language-plaintext highlighter-rouge">~/.kiro</code>:</p>

<h3 id="main-process-electron-layer">Main Process (Electron Layer)</h3>

<p>The main Electron process resolves user data through a priority chain:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. VSCODE_PORTABLE env var  → join(PORTABLE, "user-data")
2. VSCODE_APPDATA env var   → join(VSCODE_APPDATA, nameShort)
3. --user-data-dir flag     → direct path
4. Platform default         → macOS: ~/Library/Application Support/Kiro
                              Windows: %APPDATA%\Kiro
</code></pre></div></div>

<p>For <code class="language-plaintext highlighter-rouge">~/.kiro</code> specifically (argv.json, extensions, policy), it uses:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// product.json: dataFolderName = ".kiro"</span>
<span class="kd">get</span> <span class="nf">argvResource</span><span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">portable</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">VSCODE_PORTABLE</span><span class="p">;</span>
    <span class="k">return</span> <span class="nx">portable</span>
        <span class="p">?</span> <span class="nx">URI</span><span class="p">.</span><span class="nf">file</span><span class="p">(</span><span class="nf">join</span><span class="p">(</span><span class="nx">portable</span><span class="p">,</span> <span class="dl">"</span><span class="s2">argv.json</span><span class="dl">"</span><span class="p">))</span>
        <span class="p">:</span> <span class="nf">joinPath</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">userHome</span><span class="p">,</span> <span class="k">this</span><span class="p">.</span><span class="nx">product</span><span class="p">.</span><span class="nx">dataFolderName</span><span class="p">,</span> <span class="dl">"</span><span class="s2">argv.json</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">this.userHome</code> comes from <code class="language-plaintext highlighter-rouge">os.homedir()</code>, which on both macOS and Windows reads the <code class="language-plaintext highlighter-rouge">HOME</code> / <code class="language-plaintext highlighter-rouge">USERPROFILE</code> environment variable.</p>

<h3 id="kiro-agent-extension-powers-hooks-agents">Kiro Agent Extension (Powers, Hooks, Agents)</h3>

<p>The kiro-agent extension — the code that actually loads powers, hooks, agents, and steering — has its own <code class="language-plaintext highlighter-rouge">getHomeDir</code> function:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">var</span> <span class="nx">getHomeDir</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="p">{</span> <span class="nx">HOME</span><span class="p">,</span> <span class="nx">USERPROFILE</span><span class="p">,</span> <span class="nx">HOMEPATH</span><span class="p">,</span> <span class="nx">HOMEDRIVE</span> <span class="o">=</span> <span class="s2">`C:</span><span class="p">${</span><span class="nx">path</span><span class="p">.</span><span class="nx">sep</span><span class="p">}</span><span class="s2">`</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">;</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">HOME</span><span class="p">)</span> <span class="k">return</span> <span class="nx">HOME</span><span class="p">;</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">USERPROFILE</span><span class="p">)</span> <span class="k">return</span> <span class="nx">USERPROFILE</span><span class="p">;</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">HOMEPATH</span><span class="p">)</span> <span class="k">return</span> <span class="s2">`</span><span class="p">${</span><span class="nx">HOMEDRIVE</span><span class="p">}${</span><span class="nx">HOMEPATH</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
    <span class="c1">// ... fallback to os.homedir()</span>
<span class="p">};</span>
</code></pre></div></div>

<p>Then it hardcodes the <code class="language-plaintext highlighter-rouge">.kiro</code> string:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">powersPath</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">homeDir</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.kiro</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">powers</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">installed</span><span class="dl">"</span><span class="p">,</span> <span class="nx">name</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">mcpSettingsPath</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">homeDir</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.kiro</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">settings</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">mcp.json</span><span class="dl">"</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">steeringDir</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">homeDir</span><span class="p">,</span> <span class="dl">"</span><span class="s2">.kiro</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">steering</span><span class="dl">"</span><span class="p">);</span>
</code></pre></div></div>

<p>The string <code class="language-plaintext highlighter-rouge">".kiro"</code> is not read from <code class="language-plaintext highlighter-rouge">product.json</code> — it’s a literal in the extension bundle. This is the key constraint that shapes every solution.</p>

<h2 id="what-doesnt-work">What Doesn’t Work</h2>

<p>Before covering what does work, here’s what I evaluated and rejected:</p>

<p><strong><code class="language-plaintext highlighter-rouge">--profile</code> flag</strong>: Only isolates VS Code state, not <code class="language-plaintext highlighter-rouge">~/.kiro/</code>. Useless for this problem.</p>

<p><strong><code class="language-plaintext highlighter-rouge">VSCODE_PORTABLE</code> env var</strong>: Redirects the VS Code data path chain (user-data, extensions, argv), but the kiro-agent extension ignores it entirely and still reads <code class="language-plaintext highlighter-rouge">~/.kiro/</code> from <code class="language-plaintext highlighter-rouge">HOME</code>/<code class="language-plaintext highlighter-rouge">USERPROFILE</code>. Partial solution at best.</p>

<p><strong><code class="language-plaintext highlighter-rouge">--user-data-dir</code> flag</strong>: Same limitation — redirects VS Code state but not the kiro-agent’s <code class="language-plaintext highlighter-rouge">~/.kiro/</code> resolution.</p>

<p><strong>Symlink swapping</strong>: Works for sequential use but not parallel. Two simultaneous Kiro instances would race on the symlink target.</p>

<h2 id="approach-1-homeuserprofile-override">Approach 1: HOME/USERPROFILE Override</h2>

<p>The kiro-agent checks <code class="language-plaintext highlighter-rouge">HOME</code> (macOS/Linux) or <code class="language-plaintext highlighter-rouge">USERPROFILE</code> (Windows) before falling back to <code class="language-plaintext highlighter-rouge">os.homedir()</code>. Override it per instance, and each Kiro resolves a different <code class="language-plaintext highlighter-rouge">~/.kiro</code>.</p>

<h3 id="macos">macOS</h3>

<p>Create a fake home directory per persona that symlinks everything back to the real home except <code class="language-plaintext highlighter-rouge">.kiro</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># One-time setup</span>
<span class="nv">REAL_HOME</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">"</span>
<span class="nv">PERSONA_HOME</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/kiro-homes/engineer"</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$PERSONA_HOME</span><span class="s2">"</span>

<span class="c"># Symlink .kiro to persona-specific config</span>
<span class="nb">cp</span> <span class="nt">-R</span> ~/.kiro ~/.kiro-engineer
<span class="nb">ln</span> <span class="nt">-sfn</span> ~/.kiro-engineer <span class="s2">"</span><span class="nv">$PERSONA_HOME</span><span class="s2">/.kiro"</span>

<span class="c"># Symlink everything else Kiro or shells might need</span>
<span class="k">for </span>item <span class="k">in</span> .zshrc .zshenv .ssh .gnupg .gitconfig .config Library<span class="p">;</span> <span class="k">do
    </span><span class="nb">ln</span> <span class="nt">-sfn</span> <span class="s2">"</span><span class="nv">$REAL_HOME</span><span class="s2">/</span><span class="nv">$item</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$PERSONA_HOME</span><span class="s2">/</span><span class="nv">$item</span><span class="s2">"</span> 2&gt;/dev/null
<span class="k">done</span>

<span class="c"># Launch</span>
<span class="nb">env </span><span class="nv">HOME</span><span class="o">=</span><span class="s2">"</span><span class="nv">$PERSONA_HOME</span><span class="s2">"</span> <span class="se">\</span>
    kiro <span class="nt">--user-data-dir</span> <span class="s2">"</span><span class="nv">$REAL_HOME</span><span class="s2">/Library/Application Support/Kiro-Engineer"</span>
</code></pre></div></div>

<h3 id="windows-powershell">Windows (PowerShell)</h3>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$realProfile</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">USERPROFILE</span><span class="w">
</span><span class="nv">$personaHome</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$realProfile</span><span class="s2">\kiro-homes\engineer"</span><span class="w">
</span><span class="n">New-Item</span><span class="w"> </span><span class="nt">-ItemType</span><span class="w"> </span><span class="nx">Directory</span><span class="w"> </span><span class="nt">-Force</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$personaHome</span><span class="w">

</span><span class="c"># Copy .kiro config</span><span class="w">
</span><span class="n">Copy-Item</span><span class="w"> </span><span class="nt">-Recurse</span><span class="w"> </span><span class="s2">"</span><span class="nv">$realProfile</span><span class="s2">\.kiro"</span><span class="w"> </span><span class="s2">"</span><span class="nv">$realProfile</span><span class="s2">\.kiro-engineer"</span><span class="w">

</span><span class="c"># Symlink .kiro in fake home (requires Developer Mode or admin)</span><span class="w">
</span><span class="n">New-Item</span><span class="w"> </span><span class="nt">-ItemType</span><span class="w"> </span><span class="nx">SymbolicLink</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">"</span><span class="nv">$personaHome</span><span class="s2">\.kiro"</span><span class="w"> </span><span class="nt">-Target</span><span class="w"> </span><span class="s2">"</span><span class="nv">$realProfile</span><span class="s2">\.kiro-engineer"</span><span class="w">

</span><span class="c"># Symlink essentials back to real profile</span><span class="w">
</span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$item</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="p">@(</span><span class="s1">'.ssh'</span><span class="p">,</span><span class="w"> </span><span class="s1">'.gitconfig'</span><span class="p">,</span><span class="w"> </span><span class="s1">'AppData'</span><span class="p">,</span><span class="w"> </span><span class="s1">'Documents'</span><span class="p">,</span><span class="w"> </span><span class="s1">'Downloads'</span><span class="p">))</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="n">Test-Path</span><span class="w"> </span><span class="s2">"</span><span class="nv">$realProfile</span><span class="s2">\</span><span class="nv">$item</span><span class="s2">"</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="n">New-Item</span><span class="w"> </span><span class="nt">-ItemType</span><span class="w"> </span><span class="nx">SymbolicLink</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">"</span><span class="nv">$personaHome</span><span class="s2">\</span><span class="nv">$item</span><span class="s2">"</span><span class="w"> </span><span class="nt">-Target</span><span class="w"> </span><span class="s2">"</span><span class="nv">$realProfile</span><span class="s2">\</span><span class="nv">$item</span><span class="s2">"</span><span class="w"> </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="nx">SilentlyContinue</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="c"># Launch</span><span class="w">
</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">USERPROFILE</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$personaHome</span><span class="w">
</span><span class="o">&amp;</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">LOCALAPPDATA</span><span class="s2">\Programs\Kiro\Kiro.exe"</span><span class="w"> </span><span class="nt">--user-data-dir</span><span class="w"> </span><span class="s2">"</span><span class="nv">$realProfile</span><span class="s2">\AppData\Roaming\Kiro-Engineer"</span><span class="w">
</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">USERPROFILE</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$realProfile</span><span class="w">
</span></code></pre></div></div>

<p><strong>Trade-offs</strong>: Works on both platforms. The fake home directory is the main annoyance — any tool that resolves paths relative to <code class="language-plaintext highlighter-rouge">HOME</code>/<code class="language-plaintext highlighter-rouge">USERPROFILE</code> sees the fake home. The symlinks cover common cases, but you’ll occasionally discover something missing and need to add another symlink. On Windows, creating symlinks requires either Developer Mode enabled or an elevated prompt.</p>

<h2 id="approach-2-duplicate-app-with-patched-productjson">Approach 2: Duplicate App with Patched product.json</h2>

<p>Copy the Kiro installation, modify <code class="language-plaintext highlighter-rouge">product.json</code> to change the <code class="language-plaintext highlighter-rouge">dataFolderName</code>, and patch the kiro-agent extension to match. Each copy is a fully independent Kiro instance.</p>

<h3 id="macos-1">macOS</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Copy the app bundle</span>
<span class="nb">cp</span> <span class="nt">-R</span> /Applications/Kiro.app /Applications/Kiro-Engineer.app

<span class="c"># Patch product.json</span>
python3 <span class="nt">-c</span> <span class="s2">"
import json
p = '/Applications/Kiro-Engineer.app/Contents/Resources/app/product.json'
with open(p) as f: d = json.load(f)
d['dataFolderName'] = '.kiro-engineer'
d['darwinBundleIdentifier'] = 'dev.kiro.desktop.engineer'
with open(p, 'w') as f: json.dump(d, f, indent=2)
"</span>

<span class="c"># Patch the kiro-agent extension (hardcoded ".kiro" string)</span>
<span class="nb">sed</span> <span class="nt">-i</span> <span class="s1">''</span> <span class="s1">'s/".kiro"/".kiro-engineer"/g'</span> <span class="se">\</span>
    /Applications/Kiro-Engineer.app/Contents/Resources/app/extensions/kiro.kiro-agent/dist/extension.js

<span class="c"># Re-sign (macOS requires valid signature)</span>
codesign <span class="nt">--remove-signature</span> /Applications/Kiro-Engineer.app
codesign <span class="nt">--force</span> <span class="nt">--deep</span> <span class="nt">--sign</span> - /Applications/Kiro-Engineer.app

<span class="c"># Create the engineer's config directory</span>
<span class="nb">cp</span> <span class="nt">-R</span> ~/.kiro ~/.kiro-engineer
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">darwinBundleIdentifier</code> change is critical — macOS uses it to distinguish app instances. With different bundle IDs, both apps appear separately in the Dock and can run truly in parallel.</p>

<h3 id="windows">Windows</h3>

<p>No code signing needed. The parallel-instance constraint on Windows is a named mutex:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Copy the installation</span><span class="w">
</span><span class="nv">$source</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">LOCALAPPDATA</span><span class="s2">\Programs\Kiro"</span><span class="w">
</span><span class="nv">$dest</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">LOCALAPPDATA</span><span class="s2">\Programs\Kiro-Engineer"</span><span class="w">
</span><span class="n">Copy-Item</span><span class="w"> </span><span class="nt">-Recurse</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$source</span><span class="w"> </span><span class="nt">-Destination</span><span class="w"> </span><span class="nv">$dest</span><span class="w">

</span><span class="c"># Patch product.json</span><span class="w">
</span><span class="nv">$productJson</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$dest</span><span class="s2">\resources\app\product.json"</span><span class="w">
</span><span class="nv">$product</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="nv">$productJson</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
</span><span class="nv">$product</span><span class="o">.</span><span class="nf">dataFolderName</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">".kiro-engineer"</span><span class="w">
</span><span class="nv">$product</span><span class="o">.</span><span class="nf">win32MutexName</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"kiro-engineer"</span><span class="w">
</span><span class="nv">$product</span><span class="o">.</span><span class="nf">win32AppUserModelId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"Kiro-Engineer"</span><span class="w">
</span><span class="nv">$product</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertTo-Json</span><span class="w"> </span><span class="nt">-Depth</span><span class="w"> </span><span class="nx">10</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Set-Content</span><span class="w"> </span><span class="nv">$productJson</span><span class="w">

</span><span class="c"># Patch kiro-agent extension</span><span class="w">
</span><span class="nv">$extJs</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$dest</span><span class="s2">\resources\app\extensions\kiro.kiro-agent\dist\extension.js"</span><span class="w">
</span><span class="p">(</span><span class="n">Get-Content</span><span class="w"> </span><span class="nv">$extJs</span><span class="w"> </span><span class="nt">-Raw</span><span class="p">)</span><span class="w"> </span><span class="o">-replace</span><span class="w"> </span><span class="s1">'".kiro"'</span><span class="p">,</span><span class="w"> </span><span class="s1">'".kiro-engineer"'</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Set-Content</span><span class="w"> </span><span class="nv">$extJs</span><span class="w">

</span><span class="c"># Create the engineer's config directory</span><span class="w">
</span><span class="n">Copy-Item</span><span class="w"> </span><span class="nt">-Recurse</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">USERPROFILE</span><span class="s2">\.kiro"</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">USERPROFILE</span><span class="s2">\.kiro-engineer"</span><span class="w">

</span><span class="c"># Optional: create Start Menu shortcut</span><span class="w">
</span><span class="nv">$shell</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">New-Object</span><span class="w"> </span><span class="nt">-ComObject</span><span class="w"> </span><span class="nx">WScript.Shell</span><span class="w">
</span><span class="nv">$shortcut</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$shell</span><span class="o">.</span><span class="nf">CreateShortcut</span><span class="p">(</span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">APPDATA</span><span class="s2">\Microsoft\Windows\Start Menu\Programs\Kiro Engineer.lnk"</span><span class="p">)</span><span class="w">
</span><span class="nv">$shortcut</span><span class="o">.</span><span class="nf">TargetPath</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$dest</span><span class="s2">\Kiro.exe"</span><span class="w">
</span><span class="nv">$shortcut</span><span class="o">.</span><span class="nf">Save</span><span class="p">()</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">win32MutexName</code> change is the Windows equivalent of <code class="language-plaintext highlighter-rouge">darwinBundleIdentifier</code> — without it, the second instance would detect the mutex and hand off to the first instance instead of launching independently.</p>

<p><strong>Trade-offs</strong>: Cleanest runtime behavior — no fake home directories, no symlink gymnastics, both instances use your real home directory for git, SSH, and everything else. The cost is maintenance: you re-apply the patch after every Kiro update. A 10-line script handles it.</p>

<h2 id="approach-3-portable-mode-partial-solution">Approach 3: Portable Mode (Partial Solution)</h2>

<p>Kiro inherits VS Code’s portable mode. If a specific directory exists, Kiro auto-sets <code class="language-plaintext highlighter-rouge">VSCODE_PORTABLE</code> and redirects all VS Code state into it:</p>

<table>
  <thead>
    <tr>
      <th>Platform</th>
      <th>Portable directory</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>macOS</td>
      <td><code class="language-plaintext highlighter-rouge">/Applications/Kiro.app/Contents/Resources/kiro-portable-data/</code></td>
    </tr>
    <tr>
      <td>Windows</td>
      <td><code class="language-plaintext highlighter-rouge">%LOCALAPPDATA%\Programs\Kiro\data\</code></td>
    </tr>
  </tbody>
</table>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># macOS: enable portable mode</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"/Applications/Kiro.app/Contents/Resources/kiro-portable-data/tmp"</span>

<span class="c"># Windows (PowerShell): enable portable mode</span>
New-Item <span class="nt">-ItemType</span> Directory <span class="nt">-Force</span> <span class="nt">-Path</span> <span class="s2">"</span><span class="nv">$env</span><span class="s2">:LOCALAPPDATA</span><span class="se">\P</span><span class="s2">rograms</span><span class="se">\K</span><span class="s2">iro</span><span class="se">\d</span><span class="s2">ata</span><span class="se">\t</span><span class="s2">mp"</span>
</code></pre></div></div>

<p>This redirects user-data, extensions, argv.json, and policy — but <strong>not</strong> the kiro-agent’s <code class="language-plaintext highlighter-rouge">~/.kiro/</code> resolution. Powers, hooks, agents, and steering still come from the home directory. Portable mode is useful for carrying a self-contained Kiro on a USB drive, but it doesn’t solve the persona isolation problem on its own.</p>

<h2 id="recommended-setup">Recommended Setup</h2>

<p>For parallel personas, I recommend Approach 2 (duplicate app + patch) because:</p>

<ul>
  <li>Both instances run simultaneously with full isolation</li>
  <li>No fake home directories or symlink maintenance</li>
  <li>Each instance appears as a separate app in the Dock / Taskbar</li>
  <li>Git, SSH, and shell tools work normally from both instances</li>
  <li>The only maintenance is re-running the patch script after Kiro updates</li>
</ul>

<h3 id="post-setup-stripping-the-developer-persona">Post-Setup: Stripping the Developer Persona</h3>

<p>After creating <code class="language-plaintext highlighter-rouge">~/.kiro-engineer</code>, remove the director-specific integrations:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Remove director powers</span>
<span class="nb">rm</span> <span class="nt">-rf</span> ~/.kiro-engineer/powers/atlassian
<span class="nb">rm</span> <span class="nt">-rf</span> ~/.kiro-engineer/powers/gitlab
<span class="nb">rm</span> <span class="nt">-rf</span> ~/.kiro-engineer/powers/github

<span class="c"># Remove director agents</span>
<span class="nb">rm</span> ~/.kiro-engineer/agents/app-expert-<span class="k">*</span>.md
<span class="nb">rm</span> ~/.kiro-engineer/agents/iac-expert-<span class="k">*</span>.md

<span class="c"># Remove director hooks</span>
<span class="nb">rm</span> ~/.kiro-engineer/hooks/aws-<span class="k">*</span>.kiro.hook
<span class="nb">rm</span> ~/.kiro-engineer/hooks/vpn-check-gitlab.kiro.hook
</code></pre></div></div>

<p>Then edit <code class="language-plaintext highlighter-rouge">~/.kiro-engineer/settings/mcp.json</code> to keep only coding-relevant MCP servers (Terraform, AWS docs) and remove Atlassian, GitLab, NewRelic, and Wiz.</p>

<h3 id="update-automation">Update Automation</h3>

<h4 id="macos-update-kiro-engineersh">macOS (<code class="language-plaintext highlighter-rouge">update-kiro-engineer.sh</code>)</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="nv">APP</span><span class="o">=</span><span class="s2">"/Applications/Kiro-Engineer.app"</span>
<span class="nv">SRC</span><span class="o">=</span><span class="s2">"/Applications/Kiro.app"</span>
<span class="nv">PRODUCT</span><span class="o">=</span><span class="s2">"</span><span class="nv">$APP</span><span class="s2">/Contents/Resources/app/product.json"</span>
<span class="nv">EXTENSION</span><span class="o">=</span><span class="s2">"</span><span class="nv">$APP</span><span class="s2">/Contents/Resources/app/extensions/kiro.kiro-agent/dist/extension.js"</span>

<span class="nb">echo</span> <span class="s2">"Updating Kiro-Engineer from Kiro..."</span>
<span class="nb">rm</span> <span class="nt">-rf</span> <span class="s2">"</span><span class="nv">$APP</span><span class="s2">"</span>
<span class="nb">cp</span> <span class="nt">-R</span> <span class="s2">"</span><span class="nv">$SRC</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$APP</span><span class="s2">"</span>

python3 <span class="nt">-c</span> <span class="s2">"
import json
with open('</span><span class="nv">$PRODUCT</span><span class="s2">') as f: d = json.load(f)
d['dataFolderName'] = '.kiro-engineer'
d['darwinBundleIdentifier'] = 'dev.kiro.desktop.engineer'
with open('</span><span class="nv">$PRODUCT</span><span class="s2">', 'w') as f: json.dump(d, f, indent=2)
"</span>

<span class="nb">sed</span> <span class="nt">-i</span> <span class="s1">''</span> <span class="s1">'s/\".kiro\"/\".kiro-engineer\"/g'</span> <span class="s2">"</span><span class="nv">$EXTENSION</span><span class="s2">"</span>

codesign <span class="nt">--remove-signature</span> <span class="s2">"</span><span class="nv">$APP</span><span class="s2">"</span>
codesign <span class="nt">--force</span> <span class="nt">--deep</span> <span class="nt">--sign</span> - <span class="s2">"</span><span class="nv">$APP</span><span class="s2">"</span>

<span class="nb">echo</span> <span class="s2">"Done. ~/.kiro-engineer/ config is preserved."</span>
</code></pre></div></div>

<h4 id="windows-update-kiroengineerps1">Windows (<code class="language-plaintext highlighter-rouge">Update-KiroEngineer.ps1</code>)</h4>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$source</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">LOCALAPPDATA</span><span class="s2">\Programs\Kiro"</span><span class="w">
</span><span class="nv">$dest</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">LOCALAPPDATA</span><span class="s2">\Programs\Kiro-Engineer"</span><span class="w">

</span><span class="n">Write-Host</span><span class="w"> </span><span class="s2">"Updating Kiro-Engineer from Kiro..."</span><span class="w">
</span><span class="n">Remove-Item</span><span class="w"> </span><span class="nt">-Recurse</span><span class="w"> </span><span class="nt">-Force</span><span class="w"> </span><span class="nv">$dest</span><span class="w"> </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="nx">SilentlyContinue</span><span class="w">
</span><span class="n">Copy-Item</span><span class="w"> </span><span class="nt">-Recurse</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$source</span><span class="w"> </span><span class="nt">-Destination</span><span class="w"> </span><span class="nv">$dest</span><span class="w">

</span><span class="nv">$productJson</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$dest</span><span class="s2">\resources\app\product.json"</span><span class="w">
</span><span class="nv">$product</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="nv">$productJson</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
</span><span class="nv">$product</span><span class="o">.</span><span class="nf">dataFolderName</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">".kiro-engineer"</span><span class="w">
</span><span class="nv">$product</span><span class="o">.</span><span class="nf">win32MutexName</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"kiro-engineer"</span><span class="w">
</span><span class="nv">$product</span><span class="o">.</span><span class="nf">win32AppUserModelId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"Kiro-Engineer"</span><span class="w">
</span><span class="nv">$product</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertTo-Json</span><span class="w"> </span><span class="nt">-Depth</span><span class="w"> </span><span class="nx">10</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Set-Content</span><span class="w"> </span><span class="nv">$productJson</span><span class="w">

</span><span class="nv">$extJs</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"</span><span class="nv">$dest</span><span class="s2">\resources\app\extensions\kiro.kiro-agent\dist\extension.js"</span><span class="w">
</span><span class="p">(</span><span class="n">Get-Content</span><span class="w"> </span><span class="nv">$extJs</span><span class="w"> </span><span class="nt">-Raw</span><span class="p">)</span><span class="w"> </span><span class="o">-replace</span><span class="w"> </span><span class="s1">'".kiro"'</span><span class="p">,</span><span class="w"> </span><span class="s1">'".kiro-engineer"'</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Set-Content</span><span class="w"> </span><span class="nv">$extJs</span><span class="w">

</span><span class="n">Write-Host</span><span class="w"> </span><span class="s2">"Done. </span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">USERPROFILE</span><span class="s2">\.kiro-engineer\ config is preserved."</span><span class="w">
</span></code></pre></div></div>

<h2 id="platform-specific-notes">Platform-Specific Notes</h2>

<h3 id="macos-2">macOS</h3>

<ul>
  <li>Ad-hoc code signing (<code class="language-plaintext highlighter-rouge">codesign --force --deep --sign -</code>) is required after modifying any file inside the <code class="language-plaintext highlighter-rouge">.app</code> bundle. macOS Gatekeeper will block unsigned modified apps.</li>
  <li>The <code class="language-plaintext highlighter-rouge">darwinBundleIdentifier</code> must differ between copies for macOS to treat them as separate applications. Without this, the second instance may not launch or may share window state with the first.</li>
  <li>Kiro auto-updates only affect the original <code class="language-plaintext highlighter-rouge">/Applications/Kiro.app</code>. The engineer copy must be manually updated via the script above.</li>
</ul>

<h3 id="windows-11-pro">Windows 11 Pro</h3>

<ul>
  <li>No code signing is required for locally installed applications.</li>
  <li>The <code class="language-plaintext highlighter-rouge">win32MutexName</code> must differ between copies. Windows uses a named kernel mutex for single-instance enforcement — identical mutex names cause the second launch to hand off to the existing instance.</li>
  <li>If Kiro was installed via the system installer (to <code class="language-plaintext highlighter-rouge">C:\Program Files\Kiro\</code>), copying and patching requires administrator privileges. The user installer (<code class="language-plaintext highlighter-rouge">%LOCALAPPDATA%\Programs\Kiro\</code>) does not have this limitation.</li>
  <li>Creating symbolic links on Windows requires either Developer Mode enabled in Settings or an elevated PowerShell prompt. This only matters for Approach 1 (HOME override). Approach 2 doesn’t need symlinks.</li>
  <li>Kiro auto-updates only affect the original installation. The engineer copy must be manually updated.</li>
</ul>

<h2 id="what-kiro-could-do-natively">What Kiro Could Do Natively</h2>

<p>The underlying issue is that the kiro-agent extension hardcodes <code class="language-plaintext highlighter-rouge">".kiro"</code> as a string literal rather than reading <code class="language-plaintext highlighter-rouge">dataFolderName</code> from <code class="language-plaintext highlighter-rouge">product.json</code> or exposing a configuration option. If the agent resolved its config directory through the same <code class="language-plaintext highlighter-rouge">dataFolderName</code> mechanism the main process uses, the <code class="language-plaintext highlighter-rouge">--profile</code> flag or <code class="language-plaintext highlighter-rouge">--user-data-dir</code> could potentially isolate everything.</p>

<p>Even simpler: a <code class="language-plaintext highlighter-rouge">KIRO_HOME</code> environment variable that overrides the <code class="language-plaintext highlighter-rouge">~/.kiro</code> path would eliminate the need for any of these workarounds. VS Code’s <code class="language-plaintext highlighter-rouge">VSCODE_PORTABLE</code> demonstrates the pattern — Kiro just needs to extend it to the agent layer.</p>

<p>Until then, the duplicate-and-patch approach works reliably on both platforms. The friction is real, but the workaround is a one-time setup plus a 10-second script after updates.</p>]]></content><author><name>Michael McGarrah</name><email>mcgarrah@gmail.com</email><uri>https://mcgarrah.org/about/</uri></author><category term="technical" /><category term="devtools" /><category term="kiro" /><category term="kiro" /><category term="vscode" /><category term="ide" /><category term="personas" /><category term="macos" /><category term="windows" /><category term="mcp" /><category term="powers" /><category term="configuration" /><category term="productivity" /><summary type="html"><![CDATA[Deep technical analysis of Kiro IDE's configuration path resolution and practical approaches to running parallel Kiro instances with isolated powers, hooks, agents, and MCP server configurations on macOS and Windows 11 Pro.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://mcgarrah.org/assets/images/og/kiro-ide-parallel-personas-director-developer.png" /><media:content medium="image" url="https://mcgarrah.org/assets/images/og/kiro-ide-parallel-personas-director-developer.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>