<?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: Abhiuday</title>
    <description>The latest articles on DEV Community by Abhiuday (@abhiudayg).</description>
    <link>https://dev.to/abhiudayg</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%2F3979903%2Fd9ec6150-0f9d-4fa4-97ec-8367faa62c6d.png</url>
      <title>DEV Community: Abhiuday</title>
      <link>https://dev.to/abhiudayg</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abhiudayg"/>
    <language>en</language>
    <item>
      <title>Why I Built pipeline-compose: Ordered GitHub Actions Without YAML Hell</title>
      <dc:creator>Abhiuday</dc:creator>
      <pubDate>Sun, 14 Jun 2026 10:25:25 +0000</pubDate>
      <link>https://dev.to/abhiudayg/why-i-built-pipeline-compose-ordered-github-actions-without-yaml-hell-14ef</link>
      <guid>https://dev.to/abhiudayg/why-i-built-pipeline-compose-ordered-github-actions-without-yaml-hell-14ef</guid>
      <description>&lt;p&gt;You push &lt;code&gt;v1.2.3&lt;/code&gt; and expect a predictable sequence: tests pass, version resolves, GitHub Release publishes. In practice, most teams pick one of two painful options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One giant workflow&lt;/strong&gt; — every stage in a single YAML file. It works until you need reuse, different triggers per stage, or a &lt;code&gt;workflow_call&lt;/code&gt; boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;workflow_run&lt;/code&gt; chains&lt;/strong&gt; — workflow A triggers workflow B. Passing outputs between runs is awkward, and renaming a workflow breaks the chain silently.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I kept hitting both while building release automation for my own repos. The debugging pattern was always the same: click through six job logs to find which dependency edge failed, then grep generated YAML to see if it drifted from the source pipeline file.&lt;/p&gt;

&lt;p&gt;That is why I built &lt;strong&gt;&lt;a href="https://github.com/aeswibon/pipeline-compose" rel="noopener noreferrer"&gt;pipeline-compose&lt;/a&gt;&lt;/strong&gt; — a way to declare &lt;strong&gt;stage order in one pipeline file&lt;/strong&gt;, keep &lt;strong&gt;small focused workflow files&lt;/strong&gt;, and orchestrate them with a single &lt;code&gt;pipeline-compose-run&lt;/code&gt; step. v0.3.0 shipped this week; this post is the "why" behind it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem: orchestration is not the same as workflow definition
&lt;/h2&gt;

&lt;p&gt;GitHub Actions is good at running jobs inside a workflow. It is less ergonomic when your &lt;em&gt;unit of reuse&lt;/em&gt; is an entire workflow file.&lt;/p&gt;

&lt;p&gt;Consider a tag release flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ci → version sync → release publish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each stage is legitimately its own workflow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CI also runs on branch pushes&lt;/li&gt;
&lt;li&gt;Version sync only makes sense on tags&lt;/li&gt;
&lt;li&gt;Release publish needs &lt;code&gt;contents: write&lt;/code&gt; and different inputs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Native &lt;code&gt;needs:&lt;/code&gt; works when all jobs live in one file. The moment you split into separate workflows, you are choosing between:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;What breaks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mega-workflow&lt;/td&gt;
&lt;td&gt;Stages cannot have independent triggers; file grows without bound&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;workflow_run&lt;/code&gt; chains&lt;/td&gt;
&lt;td&gt;Output passing is indirect; renames fail quietly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compile-to-YAML tools&lt;/td&gt;
&lt;td&gt;Generated workflow becomes a second codebase to review&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I tried the mega-workflow first. It shipped. Then every release was a small archaeology exercise — which job failed, which upstream output was empty, whether the &lt;code&gt;if:&lt;/code&gt; on job 7 was wrong or job 3 never ran.&lt;/p&gt;

&lt;p&gt;The insight: &lt;strong&gt;order and wiring&lt;/strong&gt; are a separate concern from &lt;strong&gt;stage implementation&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Design: run vs compile
&lt;/h2&gt;

&lt;p&gt;pipeline-compose splits the problem into two actions:&lt;/p&gt;

&lt;h3&gt;
  
  
  pipeline-compose-run (start here)
&lt;/h3&gt;

&lt;p&gt;One step on your entry workflow reads &lt;code&gt;.github/pipelines/pipeline.yml&lt;/code&gt;, dispatches each stage workflow via &lt;code&gt;workflow_dispatch&lt;/code&gt;, waits for completion, collects outputs, and evaluates optional &lt;code&gt;when:&lt;/code&gt; expressions.&lt;/p&gt;

&lt;p&gt;No generated workflow to commit.&lt;/p&gt;

&lt;h3&gt;
  
  
  pipeline-compose-compile (optional)
&lt;/h3&gt;

&lt;p&gt;If your org requires a static workflow file with native &lt;code&gt;needs:&lt;/code&gt; for review or compliance, compile the pipeline into a generated workflow. Most repos do not need this — but some CI policies do.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Committed artifact&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Run&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pipeline YAML only&lt;/td&gt;
&lt;td&gt;Tag releases, multi-stage deploys, runtime &lt;code&gt;when:&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Compile&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pipeline YAML + generated workflow&lt;/td&gt;
&lt;td&gt;Teams that require native &lt;code&gt;needs:&lt;/code&gt; in review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hand-written mega-workflow&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One large YAML&lt;/td&gt;
&lt;td&gt;Simple repos with 2–3 jobs total&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I default to &lt;strong&gt;run&lt;/strong&gt;. Compile stays an escape hatch.&lt;/p&gt;




&lt;h2&gt;
  
  
  Concrete example: tag release in three stages
&lt;/h2&gt;

&lt;p&gt;The repo ships a copy-paste example: &lt;a href="https://github.com/aeswibon/pipeline-compose/tree/master/examples/run-tag-release" rel="noopener noreferrer"&gt;&lt;code&gt;examples/run-tag-release&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;git push origin v*&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;release.yml          ← one job, one action step
  └─ pipeline.yml    ← declares order + wiring
       ├─ ci.yml
       ├─ stage-version-sync.yml     → exports version
       └─ stage-release-publish.yml  ← receives version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Entry workflow
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;.github/workflows/release.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Release&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&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;run-pipeline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aeswibon/pipeline-compose-run@v0.3.0&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;pipeline_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.github/pipelines/pipeline.yml&lt;/span&gt;
          &lt;span class="na"&gt;github_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.token }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;actions: write&lt;/code&gt; is required — the run action dispatches stage workflows programmatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pipeline file (order only)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;.github/pipelines/pipeline.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pipeline&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&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;ci&lt;/span&gt;
    &lt;span class="na"&gt;workflow&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.github/workflows/ci.yml&lt;/span&gt;

  &lt;span class="pi"&gt;-&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;version-sync&lt;/span&gt;
    &lt;span class="na"&gt;workflow&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.github/workflows/stage-version-sync.yml&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ci&lt;/span&gt;
    &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;version&lt;/span&gt;

  &lt;span class="pi"&gt;-&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;release-publish&lt;/span&gt;
    &lt;span class="na"&gt;workflow&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.github/workflows/stage-release-publish.yml&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;version-sync&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ context.version-sync.version }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This file is the &lt;strong&gt;source of truth for order&lt;/strong&gt;. Stage implementations stay in normal workflow files you can run manually or reuse via &lt;code&gt;workflow_call&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;${{ context.version-sync.version }}&lt;/code&gt; syntax resolves at runtime from the completed &lt;strong&gt;version-sync&lt;/strong&gt; stage.&lt;/p&gt;




&lt;h2&gt;
  
  
  The output-passing detail that actually matters
&lt;/h2&gt;

&lt;p&gt;GitHub's API does &lt;strong&gt;not&lt;/strong&gt; return job outputs for &lt;code&gt;workflow_dispatch&lt;/code&gt; runs the way it does for jobs in a single workflow. pipeline-compose collects stage outputs from an artifact instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Artifact name:&lt;/strong&gt; &lt;code&gt;pipeline-compose-&amp;lt;stage-id&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File:&lt;/strong&gt; &lt;code&gt;outputs.json&lt;/code&gt; with your output keys&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For version sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Export outputs for pipeline-compose&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;success()&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;VERSION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.version.outputs.value }}&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;mkdir -p pipeline-compose&lt;/span&gt;
    &lt;span class="s"&gt;jq -n --arg version "$VERSION" '{version: $version}' &amp;gt; pipeline-compose/outputs.json&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;success()&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pipeline-compose-version-sync&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pipeline-compose/outputs.json&lt;/span&gt;
    &lt;span class="na"&gt;retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The artifact name matches the stage id: &lt;code&gt;pipeline-compose-version-sync&lt;/code&gt;. Downstream stages receive values as &lt;code&gt;workflow_dispatch&lt;/code&gt; inputs — wired in the pipeline file, not copy-pasted between YAML files.&lt;/p&gt;

&lt;p&gt;This is the mechanism that makes multi-workfile orchestration tractable. Without it, you are back to stringly-typed env vars in &lt;code&gt;workflow_run&lt;/code&gt; payloads.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why a TypeScript monorepo for CI tooling
&lt;/h2&gt;

&lt;p&gt;pipeline-compose v0.3.0 is a pnpm workspace: shared &lt;code&gt;@aeswibon/pipeline-compose-core&lt;/code&gt;, four action packages (run, compile, eval, context-merge), Vitest tests, and a single &lt;code&gt;pnpm run publish:actions&lt;/code&gt; path to ship all four Marketplace actions from one repo.&lt;/p&gt;

&lt;p&gt;That choice is pragmatic, not ideological:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub Actions composite and JavaScript actions already run on Node&lt;/li&gt;
&lt;li&gt;One core library, four thin action wrappers — edit orchestration logic once&lt;/li&gt;
&lt;li&gt;Vitest + schema validation (&lt;code&gt;pipeline-v1.schema.json&lt;/code&gt;) catch pipeline mistakes before they hit CI&lt;/li&gt;
&lt;li&gt;Bundling produces the single-file dist each action repo publishes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My other backend work is Go and Java. For &lt;em&gt;Actions orchestration tooling&lt;/em&gt;, shipping a typed TypeScript core with a schema was faster than fighting JVM startup or cross-compiling Go inside action bundles. Different layer, different tradeoff.&lt;/p&gt;




&lt;h2&gt;
  
  
  What v0.3.0 changed
&lt;/h2&gt;

&lt;p&gt;The recent release refactored the development surface:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Monorepo with pnpm workspaces instead of scattered packages&lt;/li&gt;
&lt;li&gt;Schema moved to &lt;code&gt;packages/core/schema/pipeline-v1.schema.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;CI publishes all four action repos from a single &lt;code&gt;publish-actions&lt;/code&gt; stage&lt;/li&gt;
&lt;li&gt;Rebuilt run/compile/eval bundles from shared core v0.3.0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you used an earlier version, the pipeline file format is stable (&lt;code&gt;version: 1&lt;/code&gt;); the action tags are what you pin in &lt;code&gt;release.yml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Full changelog: &lt;a href="https://github.com/aeswibon/pipeline-compose/releases/tag/v0.3.0" rel="noopener noreferrer"&gt;v0.3.0 release notes&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  When pipeline-compose is the wrong tool
&lt;/h2&gt;

&lt;p&gt;Be honest about limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Simple repos with 2–3 jobs in one workflow&lt;/strong&gt; — native Actions is fine; do not add orchestration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Teams that forbid programmatic workflow dispatch&lt;/strong&gt; — run mode will not pass security review&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heavy cross-run state&lt;/strong&gt; — you still need artifacts or a external store; this is not a database&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have six stages across four workflow files and tag-triggered releases, that is the sweet spot.&lt;/p&gt;




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

&lt;ol&gt;
&lt;li&gt;Copy &lt;a href="https://github.com/aeswibon/pipeline-compose/tree/master/examples/run-tag-release" rel="noopener noreferrer"&gt;&lt;code&gt;examples/run-tag-release/.github&lt;/code&gt;&lt;/a&gt; into your repo&lt;/li&gt;
&lt;li&gt;Replace &lt;code&gt;ci.yml&lt;/code&gt; test steps with your commands&lt;/li&gt;
&lt;li&gt;Push a tag: &lt;code&gt;git tag v0.0.1 &amp;amp;&amp;amp; git push origin v0.0.1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Watch &lt;strong&gt;Actions → Release&lt;/strong&gt; — stages run in pipeline order&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Marketplace action: &lt;a href="https://github.com/marketplace/actions/pipeline-compose-run" rel="noopener noreferrer"&gt;pipeline-compose-run&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Longer tutorial in the repo: &lt;a href="https://github.com/aeswibon/pipeline-compose/blob/master/docs/tutorials/tag-release-pipeline.md" rel="noopener noreferrer"&gt;&lt;code&gt;docs/tutorials/tag-release-pipeline.md&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Feedback welcome
&lt;/h2&gt;

&lt;p&gt;I opened an RFC on the repo for stage output passing and failure policy: &lt;a href="https://github.com/aeswibon/pipeline-compose/discussions/1" rel="noopener noreferrer"&gt;https://github.com/aeswibon/pipeline-compose/discussions/1&lt;/a&gt;&lt;/p&gt;




</description>
      <category>githubactions</category>
      <category>devops</category>
      <category>typescript</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Tag release pipelines without a 400-line GitHub Actions workflow</title>
      <dc:creator>Abhiuday</dc:creator>
      <pubDate>Sun, 14 Jun 2026 06:20:35 +0000</pubDate>
      <link>https://dev.to/abhiudayg/tag-release-pipelines-without-a-400-line-github-actions-workflow-e12</link>
      <guid>https://dev.to/abhiudayg/tag-release-pipelines-without-a-400-line-github-actions-workflow-e12</guid>
      <description>&lt;p&gt;You push &lt;code&gt;v1.2.3&lt;/code&gt; and expect a predictable sequence: &lt;strong&gt;tests pass → version is resolved → GitHub Release is created&lt;/strong&gt;. In practice, teams usually pick one of two painful options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One giant workflow&lt;/strong&gt; — every stage in a single YAML file. It works until you need reuse, &lt;code&gt;workflow_call&lt;/code&gt;, or different triggers per stage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;workflow_run&lt;/code&gt; chains&lt;/strong&gt; — workflow A triggers workflow B. Passing outputs between runs is awkward, and renaming a workflow breaks the chain silently.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is a middle path: keep &lt;strong&gt;small, focused stage workflows&lt;/strong&gt; (the ones you already have), declare &lt;strong&gt;order and wiring in one pipeline file&lt;/strong&gt;, and use a single orchestrator step on tag push.&lt;/p&gt;

&lt;p&gt;This tutorial uses &lt;strong&gt;&lt;a href="https://github.com/marketplace/actions/pipeline-compose-run" rel="noopener noreferrer"&gt;pipeline-compose-run&lt;/a&gt;&lt;/strong&gt; — available on the GitHub Marketplace — and a copy-paste example you can drop into any repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full example (copy &lt;code&gt;.github/&lt;/code&gt;):&lt;/strong&gt; &lt;a href="https://github.com/aeswibon/pipeline-compose/tree/master/examples/run-tag-release" rel="noopener noreferrer"&gt;examples/run-tag-release&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What we are building
&lt;/h2&gt;

&lt;p&gt;On &lt;code&gt;git push origin v*&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;release.yml          ← one job, one action step
  └─ pipeline.yml    ← declares order + wiring
       ├─ ci.yml
       ├─ stage-version-sync.yml     → exports version
       └─ stage-release-publish.yml  ← receives version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No generated workflow to commit. No manual &lt;code&gt;workflow_run&lt;/code&gt; graph.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1 — Entry workflow
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;.github/workflows/release.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Release&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&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;run-pipeline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aeswibon/pipeline-compose-run@v0.3.0&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;pipeline_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.github/pipelines/pipeline.yml&lt;/span&gt;
          &lt;span class="na"&gt;github_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.token }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;actions: write&lt;/code&gt; permission is required because the action dispatches your stage workflows via &lt;code&gt;workflow_dispatch&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2 — Pipeline file (order only)
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;.github/pipelines/pipeline.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pipeline&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&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;ci&lt;/span&gt;
    &lt;span class="na"&gt;workflow&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.github/workflows/ci.yml&lt;/span&gt;

  &lt;span class="pi"&gt;-&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;version-sync&lt;/span&gt;
    &lt;span class="na"&gt;workflow&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.github/workflows/stage-version-sync.yml&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ci&lt;/span&gt;
    &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;version&lt;/span&gt;

  &lt;span class="pi"&gt;-&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;release-publish&lt;/span&gt;
    &lt;span class="na"&gt;workflow&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.github/workflows/stage-release-publish.yml&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;version-sync&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ context.version-sync.version }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This file is the &lt;strong&gt;source of truth for order&lt;/strong&gt;. Stage implementations stay in normal workflow files you can also run manually or reuse via &lt;code&gt;workflow_call&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;${{ context.version-sync.version }}&lt;/code&gt; syntax resolves at runtime from the completed &lt;strong&gt;version-sync&lt;/strong&gt; stage.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3 — Stage workflows
&lt;/h2&gt;

&lt;p&gt;Each stage must include &lt;code&gt;workflow_dispatch&lt;/code&gt;. Downstream stages that receive values need matching &lt;code&gt;workflow_dispatch&lt;/code&gt; inputs.&lt;/p&gt;

&lt;h3&gt;
  
  
  CI (&lt;code&gt;.github/workflows/ci.yml&lt;/code&gt;)
&lt;/h3&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;CI&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;master&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;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "Replace with your test/lint commands"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Version sync — export outputs
&lt;/h3&gt;

&lt;p&gt;GitHub’s API does &lt;strong&gt;not&lt;/strong&gt; return job outputs for &lt;code&gt;workflow_dispatch&lt;/code&gt; runs. pipeline-compose collects stage outputs from an artifact:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Artifact name:&lt;/strong&gt; &lt;code&gt;pipeline-compose-&amp;lt;stage-id&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File:&lt;/strong&gt; &lt;code&gt;outputs.json&lt;/code&gt; with your output keys
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Version sync&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;version-sync&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Resolve semver from ref&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;version&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;ref="${GITHUB_REF}"&lt;/span&gt;
          &lt;span class="s"&gt;if [[ "$ref" =~ ^refs/tags/v(.+)$ ]]; then&lt;/span&gt;
            &lt;span class="s"&gt;echo "value=${BASH_REMATCH[1]}" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
          &lt;span class="s"&gt;else&lt;/span&gt;
            &lt;span class="s"&gt;echo "Expected a version tag ref, got: $ref" &amp;gt;&amp;amp;2&lt;/span&gt;
            &lt;span class="s"&gt;exit 1&lt;/span&gt;
          &lt;span class="s"&gt;fi&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;Export outputs for pipeline-compose&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;success()&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;VERSION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.version.outputs.value }}&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;mkdir -p pipeline-compose&lt;/span&gt;
          &lt;span class="s"&gt;jq -n --arg version "$VERSION" '{version: $version}' &amp;gt; pipeline-compose/outputs.json&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;success()&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pipeline-compose-version-sync&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pipeline-compose/outputs.json&lt;/span&gt;
          &lt;span class="na"&gt;retention-days&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the artifact name matches the stage id: &lt;code&gt;pipeline-compose-version-sync&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Release publish — consume version input
&lt;/h3&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;Release publish&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Semver without v prefix&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;create-release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&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;Create release&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.GITHUB_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;VERSION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ inputs.version }}&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;tag="v${VERSION}"&lt;/span&gt;
          &lt;span class="s"&gt;if gh release view "$tag" &amp;gt;/dev/null 2&amp;gt;&amp;amp;1; then&lt;/span&gt;
            &lt;span class="s"&gt;echo "Release $tag already exists"&lt;/span&gt;
            &lt;span class="s"&gt;exit 0&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;
          &lt;span class="s"&gt;gh release create "$tag" --title "$tag" --generate-notes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 4 — Ship it
&lt;/h2&gt;



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

&lt;/div&gt;



&lt;p&gt;Open &lt;strong&gt;Actions → Release&lt;/strong&gt; in your repo. Stages run in pipeline order; publish receives the version from sync.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why not other approaches?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Pain point&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Monolithic workflow&lt;/td&gt;
&lt;td&gt;Hard to reuse stages; noisy diffs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;workflow_run&lt;/code&gt; chains&lt;/td&gt;
&lt;td&gt;Fragile; outputs don’t flow cleanly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generated workflow (compile)&lt;/td&gt;
&lt;td&gt;Works, but you commit generated YAML&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;pipeline-compose-run&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ordered dispatch + context; one pipeline file&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you prefer committing a static graph with native GitHub &lt;code&gt;needs:&lt;/code&gt;, see &lt;a href="https://github.com/marketplace/actions/pipeline-compose-compile" rel="noopener noreferrer"&gt;pipeline-compose-compile&lt;/a&gt; instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;403&lt;/code&gt; on dispatch&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;actions: write&lt;/code&gt; on the entry job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publish stage missing version&lt;/td&gt;
&lt;td&gt;Artifact must be &lt;code&gt;pipeline-compose-version-sync&lt;/code&gt; with &lt;code&gt;outputs.json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stage never runs&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;workflow_dispatch&lt;/code&gt; to that workflow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong ref in stage&lt;/td&gt;
&lt;td&gt;Pass &lt;code&gt;ref:&lt;/code&gt; to the run action if needed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Marketplace:&lt;/strong&gt; &lt;a href="https://github.com/marketplace/actions/pipeline-compose-run" rel="noopener noreferrer"&gt;pipeline-compose-run&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy-paste example:&lt;/strong&gt; &lt;a href="https://github.com/aeswibon/pipeline-compose/tree/master/examples/run-tag-release" rel="noopener noreferrer"&gt;examples/run-tag-release&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More examples:&lt;/strong&gt; &lt;a href="https://github.com/aeswibon/pipeline-compose/tree/master/examples" rel="noopener noreferrer"&gt;pipeline-compose/examples&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Part of &lt;a href="https://github.com/aeswibon/pipeline-compose" rel="noopener noreferrer"&gt;pipeline-compose&lt;/a&gt;. MIT License.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>cicd</category>
      <category>automation</category>
    </item>
    <item>
      <title>Observation Haki for Manga: Why I Built a Change Data Capture (CDC) Pipeline Just to Read Manga</title>
      <dc:creator>Abhiuday</dc:creator>
      <pubDate>Thu, 11 Jun 2026 16:52:40 +0000</pubDate>
      <link>https://dev.to/abhiudayg/observation-haki-for-manga-why-i-built-a-change-data-capture-cdc-pipeline-just-to-read-manga-gi4</link>
      <guid>https://dev.to/abhiudayg/observation-haki-for-manga-why-i-built-a-change-data-capture-cdc-pipeline-just-to-read-manga-gi4</guid>
      <description>&lt;h2&gt;
  
  
  Observation Haki for Manga: Why I Built a Change Data Capture (CDC) Pipeline Just to Read Manga
&lt;/h2&gt;

&lt;p&gt;If you are a manga reader, you know the pain. &lt;/p&gt;

&lt;p&gt;New chapters are scattered across a dozen scanlation sites (MangaDex, MangaFire, MangaPlus, Asura Scans). Each site has a different layout, different APIs (or none at all), and zero unified way to track updates. Manually checking six websites daily is tedious and slow. &lt;/p&gt;

&lt;p&gt;In &lt;em&gt;One Piece&lt;/em&gt;, &lt;strong&gt;Observation Haki&lt;/strong&gt; (&lt;em&gt;Kenbunshoku Haki&lt;/em&gt;) allows a warrior to sense the presence, strength, and movements of others before they can act. &lt;/p&gt;

&lt;p&gt;I wanted that power for my manga reading list. So, I did what any software engineer would do: I over-engineered a real-time &lt;strong&gt;Change Data Capture (CDC) pipeline&lt;/strong&gt; using Go, PostgreSQL, Redpanda, and Spring Boot. &lt;/p&gt;

&lt;p&gt;Here is how I built it—and why this hybrid stack is the ultimate pattern for building robust, self-hosted web trackers.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Grand Line: System Architecture
&lt;/h2&gt;

&lt;p&gt;Rather than writing a monolithic scraper that directly makes API requests and spams webhooks, I designed a decoupled, event-driven architecture:&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%2Foxnvg0kbuuci3hnbiu4c.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%2Foxnvg0kbuuci3hnbiu4c.png" alt="Architecture" width="799" height="232"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let's break down the main components and how they map to our engineering (and anime) concepts.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The Scraper: Shadow Clone Jutsu (Go + Colly)
&lt;/h2&gt;

&lt;p&gt;To scrape multiple sites quickly, the scraper must be fast, lightweight, and concurrent. Go is the perfect fit.&lt;/p&gt;

&lt;p&gt;I built a concurrent runner that spins up separate workers (Goroutines) for each scanlation platform. Think of them as Naruto’s &lt;strong&gt;Shadow Clones&lt;/strong&gt; (&lt;em&gt;Kage Bunshin no Jutsu&lt;/em&gt;). They spread out across the web, gather pages using the &lt;a href="https://github.com/gocolly/colly" rel="noopener noreferrer"&gt;Colly&lt;/a&gt; scraping framework, extract metadata, and send it back to the main thread.&lt;/p&gt;

&lt;p&gt;Here’s a snippet of how the scraper fetches chapters using HTML selectors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// From scraper/internal/adapter/asurascans.go&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OnHTML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"a[href*=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;/chapter/&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;colly&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTMLElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;href&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"href"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;href&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"/chapter/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;chapterNum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimRight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;chapters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chapters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Chapter&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Number&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chapterNum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;asurascansBase&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;IsNew&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  2. The Change Engine: Observation Haki (PostgreSQL Diff)
&lt;/h2&gt;

&lt;p&gt;Once the raw data is fetched, the system needs to determine what has actually changed. We don't want to receive duplicate alerts. This is where &lt;strong&gt;Observation Haki&lt;/strong&gt; (our Diff Engine) comes in.&lt;/p&gt;

&lt;p&gt;The Go scraper runs a Postgres transactional upsert:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It upserts the &lt;code&gt;manga_series&lt;/code&gt; table (updating headers, cover image, status).&lt;/li&gt;
&lt;li&gt;It attempts to insert the chapters to the &lt;code&gt;chapters&lt;/code&gt; table.&lt;/li&gt;
&lt;li&gt;The database schema has a unique constraint: &lt;code&gt;UNIQUE(series_id, chapter_num)&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Using &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt;, if a chapter already exists in our database, Postgres ignores it. If it is new, Postgres saves it and returns a new UUID. The Go code detects this database-generated ID and flags the chapter as a fresh release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// From scraper/internal/db/postgres.go&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;InsertChapter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seriesID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Chapter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;`
        INSERT INTO chapters (series_id, chapter_num, title, url, release_date, is_new)
        VALUES ($1, $2, $3, $4, $5, true)
        ON CONFLICT (series_id, chapter_num) DO NOTHING
        RETURNING id
    `&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seriesID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReleaseDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  3. The Eventing Layer: Domain Expansion (Redpanda / Kafka)
&lt;/h2&gt;

&lt;p&gt;Directly invoking notifications inside the scraper is a classic anti-pattern. If your Discord webhook rate-limits you, or your service goes offline, you lose the event. &lt;/p&gt;

&lt;p&gt;To solve this, I activated a &lt;strong&gt;Domain Expansion&lt;/strong&gt;: a pocket message broker (using &lt;strong&gt;Redpanda&lt;/strong&gt; locally and &lt;strong&gt;Aiven Kafka&lt;/strong&gt; in production) to act as a buffer. &lt;/p&gt;

&lt;p&gt;Instead of deploying a full, heavy Debezium Connector, the Go scraper serializes database changes into a Debezium-compatible JSON payload directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7ca648b2-5f65-4d2c-8067-27083042a3cf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"series_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bfd0d829-114c-47bc-ad6c-d2c67f407784"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"chapter_num"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1117.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mangaplus.shueisha.co.jp/viewer/1021287"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"is_new"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scraper pushes this event to the message topic, ensuring at-least-once delivery.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The Notifier: Hell Butterflies (Spring Boot)
&lt;/h2&gt;

&lt;p&gt;In &lt;em&gt;Bleach&lt;/em&gt;, Shinigami use &lt;strong&gt;Hell Butterflies&lt;/strong&gt; (&lt;em&gt;Jigokuchō&lt;/em&gt;) to safely guide messages between worlds. &lt;/p&gt;

&lt;p&gt;In our pipeline, the &lt;strong&gt;Spring Boot&lt;/strong&gt; application consumes events from the Redpanda stream and dispatches them as Discord, Slack, or Telegram webhook payloads. &lt;/p&gt;

&lt;p&gt;Spring Boot is the perfect choice for the consumer layer due to its rich ecosystem of integration libraries and battle-tested thread pool listeners.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// From notification-service/.../service/ChapterEventService.java&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;processChapterEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;JsonNode&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;readTree&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"op"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;asText&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="s"&gt;"c"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Only notify on 'create' updates&lt;/span&gt;

    &lt;span class="nc"&gt;JsonNode&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"after"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;chapterId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;asText&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;chapterNum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"chapter_num"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;asText&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"url"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;asText&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Route to active webhook endpoints&lt;/span&gt;
    &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notifierRegistry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendAll&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seriesTitle&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chapterNum&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Mark chapter as notified so it won't trigger alerts again&lt;/span&gt;
    &lt;span class="n"&gt;chapterRepo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;markNotified&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chapterId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is how the notification alerts look in action:&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%2Fvwa9hl848s0p5saq5q56.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%2Fvwa9hl848s0p5saq5q56.png" alt=" " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Developer Experience (DX) First
&lt;/h2&gt;

&lt;p&gt;A major problem with multi-service stacks is setup complexity. To fix this, I created an interactive setup CLI wizard written in Go. Running &lt;code&gt;go run ./configure&lt;/code&gt; automatically guides you through the process:&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%2Fvdd3r1ddkiqbfwtxxtii.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%2Fvdd3r1ddkiqbfwtxxtii.png" alt=" " width="800" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With a single &lt;code&gt;docker compose up -d&lt;/code&gt;, you can have a running Kafka, Prometheus monitoring dashboard, PostgreSQL cluster, and the scraper active in seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion &amp;amp; Code
&lt;/h2&gt;

&lt;p&gt;Building &lt;code&gt;manga-cdc&lt;/code&gt; proved that enterprise concepts like CDC, decoupled message brokers, and hybrid-language microservices aren't just for scaling huge web companies. They are powerful tools for building side-projects that are resilient, modular, and extremely fun to build in public.&lt;/p&gt;

&lt;p&gt;You can inspect the complete source code, set up the wizard, and run it yourself on GitHub:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/aeswibon/manga-cdc" rel="noopener noreferrer"&gt;GitHub Repository: aeswibon/manga-cdc&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Let me know in the comments: What is the most over-engineered side project you have built to solve a daily minor annoyance?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>springboot</category>
      <category>kafka</category>
      <category>webscraping</category>
    </item>
  </channel>
</rss>
