<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jay Wilson</title>
    <description>The latest articles on DEV Community by Jay Wilson (@heyjaywilson).</description>
    <link>https://dev.to/heyjaywilson</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3881038%2F004aef41-85b9-41d0-8374-9294704fafbd.png</url>
      <title>DEV Community: Jay Wilson</title>
      <link>https://dev.to/heyjaywilson</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/heyjaywilson"/>
    <language>en</language>
    <item>
      <title>How I Automated JABA's Developer Update Posts</title>
      <dc:creator>Jay Wilson</dc:creator>
      <pubDate>Thu, 16 Apr 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/heyjaywilson/how-i-automated-jabas-developer-update-posts-1dhh</link>
      <guid>https://dev.to/heyjaywilson/how-i-automated-jabas-developer-update-posts-1dhh</guid>
      <description>&lt;p&gt;&lt;a href="https://jaba.cctplus.dev" rel="noopener noreferrer"&gt;JABA&lt;/a&gt; has three active repos: iOS, backend, and website. Meaningful work ships across all of them every two weeks, and users and newsletter subscribers deserve to know what changed. Consistent content about what's being built matters for SEO too. The problem was that writing updates kept falling off my plate. I'd miss a cycle, feel behind, and the gap would grow.&lt;/p&gt;

&lt;p&gt;I built a pipeline to fix that. It pulls from all three repos, collects merged PRs and commits, and turns them into a published blog post every two weeks without me writing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Pipeline Does
&lt;/h2&gt;

&lt;p&gt;A GitHub Actions workflow runs on odd-numbered Thursdays. It collects recent merged PRs and commits from all three repos, hands them to Claude with a carefully defined prompt, validates the output, and opens a PR on the Hugo site for my review. I approve it, Cloudflare builds, and the post goes live. Newsletter content falls out the other side automatically.&lt;/p&gt;

&lt;p&gt;Here's the full flow:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The Five Scripts
&lt;/h2&gt;

&lt;p&gt;The pipeline is five Node.js scripts chained with piped I/O. Each reads from stdin and writes to stdout. Every script is independently testable, and failed runs leave intermediate files in &lt;code&gt;/temp&lt;/code&gt; so it's easy to see exactly where things broke.&lt;/p&gt;

&lt;p&gt;Here's how the repo is structured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;automated-dev-updates-newsletter/
  ├── config/
  │   ├── frontmatter-template.yaml
  │   ├── repos.json
  │   └── system-prompt.md
  ├── output/
  │   └── pending-newsletter.md
  ├── scripts/
  │   ├── collect-changes.js
  │   ├── create-pr.js
  │   ├── fetch-newsletter-content.js
  │   ├── generate-post.js
  │   ├── run-pipeline.js
  │   ├── validate-post.js
  │   └── write-hugo-file.js
  ├── state/
  │   └── last-run.json
  └── temp/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  1. Collect Changes
&lt;/h3&gt;

&lt;p&gt;This script hits the GitHub API via &lt;code&gt;@octokit/rest&lt;/code&gt; and fetches merged PRs from all three repos within the lookback window, plus commits from the iOS repo specifically. Two filtering passes happen here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dependency noise removal&lt;/strong&gt;: Anything from Dependabot or matching &lt;code&gt;chore(deps)&lt;/code&gt; patterns gets dropped. Real changes, but meaningless to users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deduplication&lt;/strong&gt;: Commits that belong to a merged PR are already represented by that PR. Including them again would double-count the work and give Claude redundant signal.&lt;/p&gt;

&lt;p&gt;What comes out is a clean JSON object with the actual meaningful changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Generate Post
&lt;/h3&gt;

&lt;p&gt;The filtered changeset goes into a user message, and the system prompt (which lives in &lt;code&gt;config/system-prompt.md&lt;/code&gt;, not hardcoded) defines JABA's voice, the output structure, and hard rules about what not to include.&lt;/p&gt;

&lt;p&gt;Claude writes a 300 to 600 word post as a single cohesive narrative, not a bulleted list of changes. The goal is something a non-technical user can read and understand, not a changelog.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Validate
&lt;/h3&gt;

&lt;p&gt;This is the most opinionated part of the system.&lt;/p&gt;

&lt;p&gt;The validator checks five things before the post can proceed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Minimum word count&lt;/strong&gt;: a post that's too short probably missed something&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No commit SHAs&lt;/strong&gt;: Claude sometimes copies them verbatim from the input&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No PR numbers&lt;/strong&gt;: same problem&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No internal repo names&lt;/strong&gt;: users don't need to know how the codebase is organized&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No speculative language&lt;/strong&gt;: phrases like "coming soon", "planned feature", and "in development" are signs Claude invented roadmap items that don't exist
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MIN_WORD_COUNT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HALLUCINATION_PHRASES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;coming soon&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;planned feature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;in the future&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;will be available&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;upcoming feature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;roadmap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;we plan to&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;we intend to&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;we will be adding&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stay tuned&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Minimum word count on body&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wordCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;countWords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wordCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MIN_WORD_COUNT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`body is too short (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;wordCount&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; words, minimum is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MIN_WORD_COUNT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Commit SHA patterns&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shaMatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{7,40}\b&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shaMatch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`body contains what looks like a commit SHA: "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;shaMatch&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. PR number patterns&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prMatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/#&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prMatch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`body contains a PR/issue number: "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;prMatch&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 4. Internal repo names&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;internalNames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getInternalRepoNames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reposConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source_repos&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;internalNames&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;b&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;.*+?^${}()|[&lt;/span&gt;&lt;span class="se"&gt;\]\\]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;$&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;b`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;i&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`body contains internal repo name: "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 5. Hallucination phrases&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bodyLower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;phrase&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;HALLUCINATION_PHRASES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bodyLower&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;phrase&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`body contains hallucination signal phrase: "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;phrase&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// All checks passed — pass JSON through to stdout&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any failure halts the pipeline entirely. The partial output gets saved to &lt;code&gt;/temp&lt;/code&gt; for debugging, but nothing moves forward. Validation runs before the PR is created, so bad output never reaches reviewers at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Write Hugo File
&lt;/h3&gt;

&lt;p&gt;The validated post gets wrapped in TOML front matter and saved to the correct path in the Hugo content structure. Date, title, and slug are derived from the run metadata.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Create PR
&lt;/h3&gt;

&lt;p&gt;The file is committed to a new branch on the Hugo site repo and a PR is opened with me tagged as reviewer. Nothing publishes automatically. I get a notification, check the post, and can see a live Cloudflare preview of exactly what it'll look like before approving anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  After the Merge
&lt;/h2&gt;

&lt;p&gt;When I merge the PR, Cloudflare kicks off a new site build and the post goes live. At the same time, a webhook on the Hugo repo fires a &lt;code&gt;repository_dispatch&lt;/code&gt; event back to the pipeline repo.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foytbco1f18dnkewtdp7v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foytbco1f18dnkewtdp7v.png" alt="GitHub Pull request showing the cloudflare integration" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A second GitHub Actions workflow picks that up, strips the TOML front matter from the published file, and writes the clean post body to &lt;code&gt;output/pending-newsletter.md&lt;/code&gt;. Ready to paste into Loops.so with no reformatting needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Notify Internal Tools on Changelog PR Merge&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;closed&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.event.pull_request.merged == &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="s"&gt; &amp;amp;&amp;amp; startsWith(github.event.pull_request.head.ref,&lt;/span&gt;
&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;changelog/')&lt;/span&gt;
    &lt;span class="s"&gt;runs-on&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="s"&gt;steps&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Get merged file path&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;get-file&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.PRIVATE_REPO }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;FILE_PATH=$(gh api \&lt;/span&gt;
            &lt;span class="s"&gt;repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files \&lt;/span&gt;
            &lt;span class="s"&gt;--jq '.[0].filename')&lt;/span&gt;
          &lt;span class="s"&gt;echo "file_path=$FILE_PATH" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Fire repository_dispatch to jaba-internal-tools&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.PRIVATE_REPO }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;jq -n \&lt;/span&gt;
            &lt;span class="s"&gt;--arg fp "${{ steps.get-file.outputs.file_path }}" \&lt;/span&gt;
            &lt;span class="s"&gt;'{"event_type":"changelog-pr-merged","client_payload":{"file_path":$fp}}' | \&lt;/span&gt;
          &lt;span class="s"&gt;gh api repos/cctPlus/jaba-internal-tools/dispatches \&lt;/span&gt;
            &lt;span class="s"&gt;--method POST \&lt;/span&gt;
            &lt;span class="s"&gt;--input -&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two-phase design is intentional. &lt;code&gt;last-run.json&lt;/code&gt; updates immediately after a successful pipeline run so the next collection window is correct. &lt;code&gt;pending-newsletter.md&lt;/code&gt; only updates after the PR merges, so the newsletter always reflects human-approved content, not just whatever Claude generated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Config-Driven, Not Code-Driven
&lt;/h2&gt;

&lt;p&gt;Adding a new source repo should not require touching pipeline logic. The repo list lives in &lt;code&gt;config/repos.json&lt;/code&gt;. The system prompt lives in &lt;code&gt;config/system-prompt.md&lt;/code&gt;. Adding a new source is a one-line config change.&lt;/p&gt;

&lt;p&gt;The system prompt took iteration to get right. It defines voice, tone, what to include, what to avoid, and explicit output constraints. It's doing a lot of the work that keeps the output clean, ahead of the validation step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cross-Repo Webhook Bridge
&lt;/h2&gt;

&lt;p&gt;The Hugo site repo can't directly trigger workflows in the pipeline repo because they're separate repositories. Rather than share secrets across repos, there's a minimal workflow on the Hugo site side that fires a &lt;code&gt;repository_dispatch&lt;/code&gt; event on merge. The pipeline repo listens for that event type and handles it.&lt;/p&gt;

&lt;p&gt;Each repo only knows its own side of the contract. No credentials need to be shared across repo boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;The pipeline runs every two weeks whether or not I think about it. I get a PR notification, review a Cloudflare preview, hit merge, and the post is live. The newsletter content is already waiting to be pasted.&lt;/p&gt;

&lt;p&gt;The only step I kept for myself is the approval, and that's on purpose. Everything else was just friction between shipping and communicating.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>product</category>
    </item>
  </channel>
</rss>
