<?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: Francesc Gil</title>
    <description>The latest articles on DEV Community by Francesc Gil (@xescugc).</description>
    <link>https://dev.to/xescugc</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3957091%2Fac26491c-000c-4961-baba-e282dfdd4cfe.png</url>
      <title>DEV Community: Francesc Gil</title>
      <link>https://dev.to/xescugc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/xescugc"/>
    <language>en</language>
    <item>
      <title>Why PikoCI uses HCL instead of YAML</title>
      <dc:creator>Francesc Gil</dc:creator>
      <pubDate>Tue, 21 Jul 2026 12:31:54 +0000</pubDate>
      <link>https://dev.to/xescugc/why-pikoci-uses-hcl-instead-of-yaml-3i9e</link>
      <guid>https://dev.to/xescugc/why-pikoci-uses-hcl-instead-of-yaml-3i9e</guid>
      <description>&lt;h1&gt;
  
  
  Why PikoCI uses HCL instead of YAML
&lt;/h1&gt;

&lt;p&gt;Almost every CI/CD tool uses YAML. GitHub Actions, GitLab CI, Concourse, Drone, Woodpecker, all YAML. Before I wrote a single line of PikoCI's code, the first decision I made was that it would not be YAML. I spent time writing pipelines in HCL just to see how they would look compared to the YAML ones I was writing in Concourse. They looked better. That was enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's wrong with YAML
&lt;/h2&gt;

&lt;p&gt;YAML's problems are well documented but worth stating plainly for CI/CD specifically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No types.&lt;/strong&gt; Everything is a string unless you're careful. &lt;code&gt;true&lt;/code&gt; is a boolean. &lt;code&gt;"true"&lt;/code&gt; is a string. &lt;code&gt;yes&lt;/code&gt; is also a boolean. &lt;code&gt;no&lt;/code&gt; is a boolean too. &lt;code&gt;0755&lt;/code&gt; is an integer (octal). These are actual footguns that show up in pipeline config regularly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Indentation is syntax.&lt;/strong&gt; One extra space and your pipeline silently does something different. No error, just wrong behavior. In a format that humans edit frequently under time pressure, whitespace-sensitive syntax is a constant source of mistakes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No expressions.&lt;/strong&gt; If you want to construct a string from two variables in YAML you need template syntax bolted on top, like &lt;code&gt;${{ }}&lt;/code&gt; in GitHub Actions, or just shell interpolation inside a string value. The language itself has no way to express computation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anchors and aliases.&lt;/strong&gt; YAML has a reuse mechanism but it's awkward (&lt;code&gt;&amp;amp;anchor&lt;/code&gt; and &lt;code&gt;*alias&lt;/code&gt;) and most CI tools don't fully support it anyway. You end up copying and pasting configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why HCL
&lt;/h2&gt;

&lt;p&gt;HCL is HashiCorp Configuration Language. If you've used Terraform you already know it. It was designed specifically for configuration files that humans write and machines parse, exactly the use case for CI/CD pipeline definitions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real types.&lt;/strong&gt; Strings, numbers, booleans, lists, maps, all distinct. &lt;code&gt;true&lt;/code&gt; is always a boolean. &lt;code&gt;"true"&lt;/code&gt; is always a string. No ambiguity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expressions built in.&lt;/strong&gt; String interpolation with &lt;code&gt;${}&lt;/code&gt;, function calls like &lt;code&gt;upper()&lt;/code&gt;, &lt;code&gt;join()&lt;/code&gt;, &lt;code&gt;format()&lt;/code&gt;, all part of the language without bolting on a template layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Variables with types and defaults.&lt;/strong&gt; Declare what a pipeline accepts, what type each parameter is, and what the default is. The parser validates on load, so you get an error immediately, not at runtime.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"go_image"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"golang:1.24"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Job generation from expressions.&lt;/strong&gt; &lt;code&gt;for_each&lt;/code&gt; and &lt;code&gt;matrix&lt;/code&gt; generate job instances from sets or maps, no template engine, no copy-paste:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="s2"&gt;"validate"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;toset&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"lint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"vet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

  &lt;span class="nx"&gt;get&lt;/span&gt; &lt;span class="s2"&gt;"git"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;trigger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="s2"&gt;"run"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="s2"&gt;"docker"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;go_image&lt;/span&gt;
      &lt;span class="nx"&gt;cmd&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"make ${each.value}"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates three jobs (&lt;code&gt;validate--lint&lt;/code&gt;, &lt;code&gt;validate--vet&lt;/code&gt;, &lt;code&gt;validate--test&lt;/code&gt;) each running its respective make target. In YAML you'd copy the job block three times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Readable without a reference.&lt;/strong&gt; HCL reads more like a configuration file and less like a serialization format. Block structure maps naturally to the concepts it represents: a job is a block, a task is a block inside it, a run is a block inside that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side by side
&lt;/h2&gt;

&lt;p&gt;The same pipeline (get from git, run tests, deploy on success) in YAML (GitHub Actions style) and HCL (PikoCI).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YAML:&lt;/strong&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;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run tests&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;go test ./...&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&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;${{ github.sha }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;HCL (PikoCI):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"git"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/myorg/myapp"&lt;/span&gt;
    &lt;span class="nx"&gt;branch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;get&lt;/span&gt; &lt;span class="s2"&gt;"git"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;trigger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="s2"&gt;"run-tests"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"go"&lt;/span&gt;
      &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"test"&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="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="s2"&gt;"deploy"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;get&lt;/span&gt; &lt;span class="s2"&gt;"git"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;trigger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="nx"&gt;passed&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="s2"&gt;"deploy"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"./deploy.sh"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things stand out in the comparison.&lt;/p&gt;

&lt;p&gt;The HCL version makes the dependency between &lt;code&gt;test&lt;/code&gt; and &lt;code&gt;deploy&lt;/code&gt; explicit via &lt;code&gt;passed = ["test"]&lt;/code&gt;, not just a &lt;code&gt;needs:&lt;/code&gt; list but an actual resource version constraint. The deploy job only runs when the same git version that passed the test job is available. This is a fundamentally different model: jobs don't just "wait for each other", they share versioned resources.&lt;/p&gt;

&lt;p&gt;The resource is declared once and referenced by both jobs. In YAML each job does its own checkout, there's no concept of a shared resource with versioning.&lt;/p&gt;

&lt;p&gt;The HCL version is more verbose upfront, you declare the resource explicitly instead of relying on implicit &lt;code&gt;checkout&lt;/code&gt;. That explicitness pays off when you have 10 jobs sharing the same resource, or when you need to pin a version across the whole pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  More declarative, easier to maintain
&lt;/h2&gt;

&lt;p&gt;HCL is declarative in a way that YAML isn't. In YAML-based CI/CD you describe what commands to run and in what order, it's closer to a script. In HCL you describe what things are and how they relate to each other. The jobs, resources, and dependencies are named, typed, and structured entities, not a sequence of shell commands glued together with indentation.&lt;/p&gt;

&lt;p&gt;This makes a real difference as pipelines grow. A 10-job pipeline in YAML is already hard to navigate. A 10-job pipeline in HCL reads like a map, you can see the structure at a glance, jump to any job by name, and understand how things connect without reading the whole file top to bottom.&lt;/p&gt;

&lt;p&gt;Variables, resource declarations, and job definitions all live in clearly separated blocks. When something breaks you know where to look. When you want to change the Go image across all jobs you change one variable, not ten &lt;code&gt;image:&lt;/code&gt; lines scattered through the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tooling
&lt;/h2&gt;

&lt;p&gt;Two things PikoCI ships that make working with HCL pipelines smoother:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;pikoci pipeline validate&lt;/code&gt;&lt;/strong&gt; validates a pipeline file without a running server. Catches syntax errors, type mismatches, invalid references, and unknown fields. Good for pre-commit hooks or CI checks on the pipeline config itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pikoci pipeline validate pipeline.hcl
pikoci pipeline validate pipeline.hcl &lt;span class="nt"&gt;--var&lt;/span&gt; &lt;span class="nv"&gt;environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;pikoci pipeline edit&lt;/code&gt;&lt;/strong&gt; opens a browser-based editor for a local HCL file. The editor validates as you type and shows errors inline. Edit, validate, save, no round-trip to git required for quick fixes. You can also edit pipelines directly from the PikoCI web UI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pikoci pipeline edit pipeline.hcl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuoxawk0k88e0x69a2akp.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuoxawk0k88e0x69a2akp.png" alt=" " width="800" height="573"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest tradeoff
&lt;/h2&gt;

&lt;p&gt;HCL has one real disadvantage: it's less universally known than YAML. Every developer knows YAML. HCL is familiar to Terraform users but not everyone has used Terraform.&lt;/p&gt;

&lt;p&gt;In practice this hasn't been a problem, HCL is readable enough that most engineers understand a PikoCI pipeline without having seen HCL before. And the Terraform ecosystem is large enough that "if you know Terraform you already know this" covers a significant part of the DevOps audience.&lt;/p&gt;




&lt;p&gt;github.com/pikoci/pikoci · pikoci.com · Apache 2.0&lt;/p&gt;

</description>
      <category>go</category>
      <category>devops</category>
      <category>cicd</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Why I replaced queue-based worker communication with gRPC</title>
      <dc:creator>Francesc Gil</dc:creator>
      <pubDate>Thu, 16 Jul 2026 13:12:52 +0000</pubDate>
      <link>https://dev.to/xescugc/why-i-replaced-queue-based-worker-communication-with-grpc-32nd</link>
      <guid>https://dev.to/xescugc/why-i-replaced-queue-based-worker-communication-with-grpc-32nd</guid>
      <description>&lt;h1&gt;
  
  
  Replacing Queues with gRPC in PikoCI
&lt;/h1&gt;

&lt;p&gt;When I started building PikoCI, I kept coming back to a simplification: at its core, a CI/CD system is just jobs and workers waiting to be told to do them. Strip away everything else and what you have is a notification problem: the server knows a job is ready, a worker needs to find out.&lt;/p&gt;

&lt;p&gt;That's a textbook pub/sub pattern. So I built PikoCI on queues from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why queues were the right call
&lt;/h2&gt;

&lt;p&gt;I used go-cloud's pubsub abstraction, which let me support multiple backends (NATS, Kafka, RabbitMQ, or an in-memory queue for local development) without rewriting the worker logic for each one. People could plug in whatever they already run, or use nothing at all for local setups.&lt;/p&gt;

&lt;p&gt;Queues also solve worker registration cleanly. A worker just subscribes to a topic. It doesn't need to be reachable by the server, doesn't need a stable address, doesn't need anything beyond outbound access to the queue. Adding a worker anywhere is trivial.&lt;/p&gt;

&lt;p&gt;But there was a recurring question I kept arguing with myself about: was the queue actually buying me anything over a direct connection? Running a queue is one more thing to deploy, one more thing to operate, for self-hosters who just want a binary. I kept using it anyway because few CI/CD tools use this pattern and I wanted to see it through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it started breaking down
&lt;/h2&gt;

&lt;p&gt;The cracks showed up gradually, mostly while implementing features that needed more from the queue than "deliver this message."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ordering wasn't guaranteed.&lt;/strong&gt; go-cloud's in-memory pubsub doesn't guarantee FIFO delivery. I found this out while building worker tagging, when jobs started arriving out of order in ways that broke the scheduler's assumptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrying was painful.&lt;/strong&gt; If a worker needed to re-queue a job (say, after a transient failure) there was no guarantee it would come back to the front of the line. It could end up anywhere, with no way to reason about when it would actually run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tagging didn't map across backends.&lt;/strong&gt; Worker tagging needed dynamic routing: a worker with tags like &lt;code&gt;gpu&lt;/code&gt; and &lt;code&gt;linux&lt;/code&gt; should only receive matching jobs. NATS does this naturally with subject-based routing. Kafka and SQS don't, not without real workarounds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cancellation was slow.&lt;/strong&gt; Workers had no way to learn that a build had been cancelled other than polling the server. That meant a cancelled build could keep running for seconds before the worker noticed, wasteful and visibly wrong in the UI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log streaming was a separate problem entirely.&lt;/strong&gt; Build logs needed their own mechanism to get from worker to server, completely unrelated to the job dispatch queue. Two communication paths to build and maintain for what is fundamentally one relationship: server talks to worker.&lt;/p&gt;

&lt;p&gt;Each of these pushed me toward solving the problem outside the queue rather than with it. The message stopped carrying the job payload entirely and became just a ping, a doorbell. The worker would get notified, then immediately turn around and ask the server over HTTP: "what do I need to do?" The server's scheduler held all the actual logic about what was next, regardless of which queue backend was configured underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  Considering NATS-only
&lt;/h2&gt;

&lt;p&gt;Before dropping queues entirely, I seriously considered the opposite move: drop every backend except NATS and the in-memory option. NATS could actually do what I needed. Subject-based routing handled tagging naturally, and it's about as lightweight as a queue gets, which fit PikoCI's "easy to deploy" goal better than Kafka or RabbitMQ ever would.&lt;/p&gt;

&lt;p&gt;But getting there required NATS-specific configuration: JetStream settings, subject hierarchies tuned for tagging, ordering guarantees that the generic abstraction didn't give me for free. That's the part I didn't like. It stopped being "bring whatever queue you already run" and became "run NATS, configured exactly this way, or tagging won't work." That's not a pluggable abstraction anymore, that's a hard dependency wearing an abstraction's clothes.&lt;/p&gt;

&lt;p&gt;And even with NATS configured perfectly, I'd still need the worker to ask the scheduler what to do next, because the scheduler is the only thing that actually knows the answer. The queue would just be there to make the connection happen. At that point I was about to add an entire piece of required infrastructure whose only job was to say "hey, ping."&lt;/p&gt;

&lt;h2&gt;
  
  
  The moment it clicked
&lt;/h2&gt;

&lt;p&gt;By the time I was deep into the tagging work, the pattern was obvious: the worker was already asking the scheduler directly for its next job. The queue's only remaining job was to say "hey, something happened, go ask." That's not nothing, but it's also not worth running a separate piece of infrastructure for.&lt;/p&gt;

&lt;p&gt;If the server already knows which workers exist, it can just tell them directly. No doorbell needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why gRPC
&lt;/h2&gt;

&lt;p&gt;gRPC gives me a persistent bidirectional stream between server and worker. The worker opens one connection and keeps it open. Over that single connection:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The server pushes jobs to the worker the moment they're ready. No asking, no doorbell&lt;/li&gt;
&lt;li&gt;The server pushes a cancellation message instantly when a build is cancelled, milliseconds instead of the multi-second polling interval cancellation needed before&lt;/li&gt;
&lt;li&gt;The worker streams build logs back in real time&lt;/li&gt;
&lt;li&gt;Heartbeats flow both ways, so the server always knows which workers are alive&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One connection, one mental model, no external infrastructure to run or reason about. Workers still only need outbound network access; that part of the queue model's appeal didn't have to be sacrificed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this replaced
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before:
  go-cloud pubsub (NATS / Kafka / RabbitMQ / in-memory)
  --pubsub-system flag
  Queue message as a doorbell, worker asks server what's next
  Worker polls GET /api/builds/:id/status for cancellation
  Separate mechanism for log streaming

After:
  gRPC bidirectional stream on a dedicated port
  No --pubsub-system flag, removed entirely
  Server pushes jobs directly on the open stream
  Server pushes CancelJob message on the same stream
  Logs stream on the same connection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The architecture today
&lt;/h2&gt;

&lt;p&gt;Two ports. Port 8080 serves the HTTP JSON API: everything the frontend and CLI talk to, unchanged. Port 9090 serves gRPC, workers only. The two transports never mix.&lt;/p&gt;

&lt;p&gt;Multi-server deployments still use Postgres LISTEN/NOTIFY to coordinate which server instance dispatches a job; that part didn't change. What changed is how the chosen server instance gets the job to the worker: a direct push on an open stream instead of a queue message.&lt;/p&gt;

&lt;p&gt;The migration was a clean break. v0.5.0 dropped the &lt;code&gt;--pubsub-system&lt;/code&gt; flag entirely. Workers needed to update to the matching version, but since PikoCI ships as a single binary that's both server and worker, upgrading one means upgrading the other. No transition period with both protocols running in parallel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Removed an entire category of optional infrastructure. Self-hosted PikoCI users no longer need to think about which queue backend to run, or whether to run one at all. Cancellation now takes effect in milliseconds instead of seconds. Log streaming and job dispatch share one connection instead of two separate mechanisms.&lt;/p&gt;

&lt;p&gt;Shipped in v0.5.0.&lt;/p&gt;




&lt;p&gt;github.com/pikoci/pikoci · pikoci.com · Apache 2.0&lt;/p&gt;

</description>
      <category>go</category>
      <category>devops</category>
      <category>architecture</category>
      <category>grpc</category>
    </item>
    <item>
      <title>PikoCI v0.7.0: Approval gates, audit log, OIDC, secrets masking, and build reports</title>
      <dc:creator>Francesc Gil</dc:creator>
      <pubDate>Wed, 08 Jul 2026 18:37:08 +0000</pubDate>
      <link>https://dev.to/xescugc/pikoci-v070-approval-gates-audit-log-oidc-secrets-masking-and-build-reports-hlm</link>
      <guid>https://dev.to/xescugc/pikoci-v070-approval-gates-audit-log-oidc-secrets-masking-and-build-reports-hlm</guid>
      <description>&lt;p&gt;PikoCI is an open-source CI/CD server written in Go. A single binary, no dependencies, pipeline-as-code in HCL. v0.7.0 is the security and compliance release. A lot shipped in this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approval gates
&lt;/h2&gt;

&lt;p&gt;Jobs can now require human approval before continuing. Add an &lt;code&gt;approve&lt;/code&gt; block to any job and the pipeline pauses with a purple "Waiting for Approval" status until someone with Maintain access approves or rejects it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="s2"&gt;"deploy"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;get&lt;/span&gt; &lt;span class="s2"&gt;"git"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;passed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;approve&lt;/span&gt; &lt;span class="s2"&gt;"deploy to production"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;approvals&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="nx"&gt;notify&lt;/span&gt; &lt;span class="s2"&gt;"discord"&lt;/span&gt; &lt;span class="s2"&gt;"deploy-alerts"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="s2"&gt;"deploy"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"./deploy.sh"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Approve or reject via the UI, CLI (&lt;code&gt;pikoci builds approve&lt;/code&gt;), or API. Each approval is recorded with who approved and when. If you need two people to sign off before a production deploy, set &lt;code&gt;approvals = 2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0lyu8cu7c8rfhw5gsk2o.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0lyu8cu7c8rfhw5gsk2o.png" alt=" " width="799" height="128"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit log
&lt;/h2&gt;

&lt;p&gt;Every significant action on a team now has a permanent append-only record: pipeline changes, manual triggers, approvals, secret modifications, membership changes. 16 event types in total, filterable by user, event type, and date range. Available via the UI, CLI, and API. Read access and above can view it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F28sjsjhbtm2ezn8khvsy.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F28sjsjhbtm2ezn8khvsy.png" alt=" " width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  OIDC and OAuth2
&lt;/h2&gt;

&lt;p&gt;Single sign-on with GitHub, Google, GitLab, Keycloak, Microsoft, or any OIDC provider. Configure providers from the admin UI with built-in templates, link existing accounts, and optionally disable local username/password auth entirely.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F48va6um6u9guo1x6oj27.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F48va6um6u9guo1x6oj27.png" alt=" " width="800" height="527"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3h5q469q9si5fjkdhorz.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3h5q469q9si5fjkdhorz.png" alt=" " width="413" height="519"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets masking in build logs
&lt;/h2&gt;

&lt;p&gt;Secret values are now automatically replaced with &lt;code&gt;***&lt;/code&gt; in build log output, both in real-time streaming and stored logs. If a task accidentally echoes a secret, it never appears in the UI. Values shorter than 3 characters are skipped to avoid masking noise.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2s4i8u4jf9lvcg1xqagp.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2s4i8u4jf9lvcg1xqagp.png" alt=" " width="639" height="47"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Build report export
&lt;/h2&gt;

&lt;p&gt;Export a complete JSON report for any build containing the full status, each step's result, logs, approval records, and resource version data. Available from the UI (Export button on the build detail page), CLI (&lt;code&gt;pikoci builds report my-pipeline deploy 42&lt;/code&gt;), and API.&lt;/p&gt;

&lt;p&gt;Useful for compliance, incident review, or just keeping a record of what deployed and when.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secret chaining
&lt;/h2&gt;

&lt;p&gt;Secret &lt;code&gt;path&lt;/code&gt; and &lt;code&gt;key&lt;/code&gt; fields can now reference other variables, resolved in dependency order with cycle detection. This means you can build secrets that depend on other secrets. Useful for patterns like using a vault token to fetch another secret's path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worker team isolation
&lt;/h2&gt;

&lt;p&gt;Team-scoped worker tokens restrict workers to only run builds for their team. Global workers automatically defer to team workers when available, so teams with dedicated infrastructure get their own capacity without affecting others.&lt;/p&gt;

&lt;h2&gt;
  
  
  Role rename
&lt;/h2&gt;

&lt;p&gt;The role names have been updated to match GitHub's model: Viewer→Read, Operator→Write, Maintainer→Maintain. The database migrates automatically on upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Session security
&lt;/h2&gt;

&lt;p&gt;Two new session security improvements: configurable session expiration via &lt;code&gt;--session-lifetime&lt;/code&gt; (e.g. &lt;code&gt;24h&lt;/code&gt;, &lt;code&gt;7d&lt;/code&gt;, &lt;code&gt;30d&lt;/code&gt;), and automatic session invalidation when a password is changed. All existing sessions for that user are revoked immediately. Passwords now also enforce a minimum 8-character length.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance
&lt;/h2&gt;

&lt;p&gt;N+1 query elimination across pipeline, job list, and resource list endpoints. Build polling now fetches all active builds in a single request instead of one HTTP call per build every 2 seconds. Modulepreload hints collapse 4 browser discovery rounds to 1 parallel fetch.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://github.com/PikoCI/pikoci/releases/tag/v0.7.0" rel="noopener noreferrer"&gt;Release notes&lt;/a&gt; · &lt;a href="https://pikoci.com" rel="noopener noreferrer"&gt;pikoci.com&lt;/a&gt; · &lt;a href="https://github.com/pikoci/pikoci" rel="noopener noreferrer"&gt;github.com/pikoci/pikoci&lt;/a&gt; · Apache 2.0&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>go</category>
      <category>opensource</category>
    </item>
    <item>
      <title>PikoCI v0.6: New pipeline views, version tracking, and goodbye Backbone</title>
      <dc:creator>Francesc Gil</dc:creator>
      <pubDate>Thu, 25 Jun 2026 15:27:23 +0000</pubDate>
      <link>https://dev.to/xescugc/pikoci-v06-new-pipeline-views-version-tracking-and-goodbye-backbone-1cd7</link>
      <guid>https://dev.to/xescugc/pikoci-v06-new-pipeline-views-version-tracking-and-goodbye-backbone-1cd7</guid>
      <description>&lt;p&gt;PikoCI is a self-hosted CI/CD system, one binary, any database, runs anywhere. v0.6 is the biggest UI release yet: a complete frontend rewrite and new ways to explore pipeline state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Goodbye Backbone, hello Preact
&lt;/h2&gt;

&lt;p&gt;The frontend has been migrated from BackboneJS + jQuery + Underscore to Preact + HTM. No build step, no bundler, no npm. Same philosophy as the rest of PikoCI, just a binary. The virtual DOM eliminates the flickering that came from Backbone's full re-renders.&lt;/p&gt;

&lt;h2&gt;
  
  
  New pipeline views
&lt;/h2&gt;

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

&lt;p&gt;The pipeline page now has two view modes: Graph and List, switchable via a toolbar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Graph view&lt;/strong&gt; is the existing DAG view with two new toggles via a gear icon:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hide intermediate resources&lt;/strong&gt; removes resource nodes between jobs and draws direct job-to-job edges. Trigger resources stay visible. Useful for complex pipelines where the resource nodes add visual noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Group parallel jobs&lt;/strong&gt; collapses jobs sharing identical upstream parents into a single compact node, showing a colored status dot per job. One click to expand and see each job individually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;List view&lt;/strong&gt; shows jobs in a left panel and build detail on the right. Jobs are displayed in a tree reflecting the pipeline DAG. Parallel siblings are grouped under collapsible headers. Clicking a job loads its build history on the right. A resource selector at the top lets you switch between trigger resources. View preference, selected resource, and collapsed groups all persist across reloads.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Resources panel
&lt;/h2&gt;

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

&lt;p&gt;A new Resources button on the pipeline toolbar opens a slide-out panel showing all pipeline resources: name, type, latest version, build status, last check time, and a Check Now button. Available from both views. Version list is expandable per resource with Track, Trigger, and Pin actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version tracking
&lt;/h2&gt;

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

&lt;p&gt;Track a specific resource version through the pipeline to see which jobs it passed through and their build status.&lt;/p&gt;

&lt;p&gt;Available from the resource versions page, the Resources panel, and a version dropdown in the list view. When tracking, both graph and list views scope to only that version's path. A banner shows progress.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resource version status dots
&lt;/h2&gt;

&lt;p&gt;Resource version rows now show a colored status dot indicating the aggregate build status of all jobs that consumed that version. No more clicking into each build to know if a version is good.&lt;/p&gt;

&lt;h2&gt;
  
  
  Share and embed
&lt;/h2&gt;

&lt;p&gt;A new share panel on the pipeline toolbar provides copyable URLs for SVG and PNG pipeline images, plus a Markdown snippet for embedding pipeline badges in READMEs. Public pipelines can be embedded without authentication.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other improvements
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Deterministic retries&lt;/strong&gt;: Retrying a build now reuses the resource versions from the original build instead of fetching latest. This means retries reproduce the same build conditions, making failures easier to debug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Correct parallel step durations&lt;/strong&gt;: &lt;code&gt;in_parallel&lt;/code&gt; sub-step durations now display correctly instead of raw nanoseconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smart polling&lt;/strong&gt;: Polling pauses when the browser tab is hidden, reducing wasted network requests by around 50%.&lt;/p&gt;

&lt;p&gt;v0.6.1 and v0.6.2 included minor fixes for graceful shutdown timeouts and build recovery after server restarts. See the &lt;a href="https://github.com/PikoCI/pikoci/releases" rel="noopener noreferrer"&gt;release notes&lt;/a&gt; for details.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://github.com/PikoCI/pikoci/releases/tag/v0.6.0" rel="noopener noreferrer"&gt;Release notes&lt;/a&gt; · &lt;a href="https://pikoci.com" rel="noopener noreferrer"&gt;pikoci.com&lt;/a&gt; · &lt;a href="https://github.com/pikoci/pikoci" rel="noopener noreferrer"&gt;github.com/pikoci/pikoci&lt;/a&gt; · Apache 2.0&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devop</category>
      <category>go</category>
      <category>opensource</category>
    </item>
    <item>
      <title>PikoCI v0.5.0: worker tagging, in_parallel, Linux packages, and more</title>
      <dc:creator>Francesc Gil</dc:creator>
      <pubDate>Tue, 16 Jun 2026 12:14:39 +0000</pubDate>
      <link>https://dev.to/xescugc/pikoci-v050-worker-tagging-inparallel-linux-packages-and-more-32k0</link>
      <guid>https://dev.to/xescugc/pikoci-v050-worker-tagging-inparallel-linux-packages-and-more-32k0</guid>
      <description>&lt;p&gt;Six months into building PikoCI and v0.5.0 is the biggest release yet. This one is almost entirely about workers: routing work to the right place, running things in parallel, and knowing what your workers are doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worker tagging
&lt;/h2&gt;

&lt;p&gt;You can now route jobs and resource checks to specific workers using tags. Add &lt;code&gt;tags = ["gpu"]&lt;/code&gt; to a job in your pipeline HCL, start a worker with &lt;code&gt;--tags gpu&lt;/code&gt;, and that job will only run on workers with the matching tag. Matching uses AND logic: all job tags must be present on the worker.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="s2"&gt;"train-model"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"gpu"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="s2"&gt;"train"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"python"&lt;/span&gt;
      &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"train.py"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Workers can also be made exclusive. The &lt;code&gt;--exclusive-tags&lt;/code&gt; flag restricts a worker to only handle tagged work, keeping it free for the jobs that need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worker health monitoring
&lt;/h2&gt;

&lt;p&gt;Workers now send periodic heartbeats to the server. A new admin dashboard shows all connected workers with status, platform, version, and uptime. If a worker goes stale (no heartbeat for 90 seconds), a warning appears and admins can remove it from the UI or via the API.&lt;/p&gt;

&lt;p&gt;There is also a &lt;code&gt;pikoci_workers&lt;/code&gt; Prometheus gauge with a &lt;code&gt;status&lt;/code&gt; label if you want to alert on missing workers.&lt;/p&gt;

&lt;h2&gt;
  
  
  in_parallel step
&lt;/h2&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%2Fcx3z66431xirzghktlv5.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%2Fcx3z66431xirzghktlv5.png" alt=" " width="800" height="571"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Run multiple steps concurrently within a job. Useful for running tests against multiple configurations simultaneously, or for parallelizing independent tasks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;in_parallel&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;limit&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="nx"&gt;fail_fast&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

    &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="s2"&gt;"test-postgres"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"make"&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"test-postgres"&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="nx"&gt;task&lt;/span&gt; &lt;span class="s2"&gt;"test-mysql"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"make"&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"test-mysql"&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="nx"&gt;task&lt;/span&gt; &lt;span class="s2"&gt;"test-sqlite"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"make"&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"test-sqlite"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;limit&lt;/code&gt; caps how many run simultaneously. &lt;code&gt;fail_fast&lt;/code&gt; cancels remaining steps on the first failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  pipeline validate
&lt;/h2&gt;

&lt;p&gt;Validate a pipeline HCL file without a running server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pikoci pipeline validate pipeline.hcl
pikoci pipeline validate pipeline.hcl &lt;span class="nt"&gt;--var&lt;/span&gt; &lt;span class="nb"&gt;env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;staging
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful for pre-commit hooks and CI checks on the pipeline config itself. Catches syntax errors, structural errors, and invalid cross-references, all reported at once with line numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Linux packages
&lt;/h2&gt;

&lt;p&gt;PikoCI now ships as &lt;code&gt;.deb&lt;/code&gt; and &lt;code&gt;.rpm&lt;/code&gt; packages alongside the existing binaries. Both amd64 and arm64. A SHA256SUMS file is included in every release.&lt;/p&gt;

&lt;h2&gt;
  
  
  fs resource type
&lt;/h2&gt;

&lt;p&gt;A built-in resource type for watching local files and directories. Triggers jobs when file contents change via SHA256 hash comparison.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource_type&lt;/span&gt; &lt;span class="s2"&gt;"fs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pikoci://fs"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"fs"&lt;/span&gt; &lt;span class="s2"&gt;"config"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/etc/myapp/config.yaml"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful for local development workflows and watching config files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worker communication now uses gRPC
&lt;/h2&gt;

&lt;p&gt;Workers connect to the server via a persistent gRPC stream instead of the previous queue-based system. Job dispatch is instant, cancellation signals reach the worker in milliseconds instead of waiting for a polling interval, and log streaming happens on the same connection. No external queue needed for distributed workers.&lt;/p&gt;

&lt;p&gt;A dedicated post covering the full migration from queues to gRPC is coming soon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other fixes
&lt;/h2&gt;

&lt;p&gt;New jobs no longer replay the entire version backlog on creation: they record a baseline at creation time and only see newer versions. Downstream jobs now trigger immediately when upstream builds succeed, instead of waiting for the next scheduler tick. Cross-reference validation catches invalid references at save time with suggestions for typos.&lt;/p&gt;




&lt;p&gt;&lt;a href="//github.com/pikoci/pikoci"&gt;github.com/pikoci/pikoci&lt;/a&gt; · &lt;a href="//pikoci.com"&gt;pikoci.com&lt;/a&gt; · Apache 2.0&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/PikoCI/pikoci/releases/tag/v0.5.0" rel="noopener noreferrer"&gt;v0.5.0 release notes&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devop</category>
      <category>go</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Services without Docker-in-Docker: how PikoCI handles test dependencies</title>
      <dc:creator>Francesc Gil</dc:creator>
      <pubDate>Tue, 09 Jun 2026 08:13:24 +0000</pubDate>
      <link>https://dev.to/xescugc/services-without-docker-in-docker-how-pikoci-handles-test-dependencies-5c74</link>
      <guid>https://dev.to/xescugc/services-without-docker-in-docker-how-pikoci-handles-test-dependencies-5c74</guid>
      <description>&lt;p&gt;If you've ever needed a database for your integration tests in CI, you've probably encountered one of these solutions:&lt;/p&gt;

&lt;p&gt;Docker-in-Docker. A separate docker-compose file you run alongside CI. A pre-provisioned shared database that everyone fights over. Or just skipping integration tests in CI entirely.&lt;/p&gt;

&lt;p&gt;None of these are great. Docker-in-Docker requires privileged containers and is notoriously fragile. docker-compose alongside CI is manual, error-prone, and hard to clean up. Shared databases cause flaky tests. Skipping integration tests defeats the purpose.&lt;/p&gt;

&lt;p&gt;PikoCI takes a different approach: services as a first-class concept.&lt;/p&gt;

&lt;h2&gt;
  
  
  What services are
&lt;/h2&gt;

&lt;p&gt;A service in PikoCI is an ephemeral process that runs alongside your job's tasks. Services start where they are defined in the job plan, if you define them first, they start first; if you define them after a &lt;code&gt;get&lt;/code&gt; step, they start after the &lt;code&gt;get&lt;/code&gt;. They always stop unconditionally when the job ends, regardless of where they were defined or whether tasks succeeded or failed.&lt;/p&gt;

&lt;p&gt;You define a &lt;code&gt;service_type&lt;/code&gt; once and reference it from any job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;service_type&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/bin/sh"&lt;/span&gt;
    &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-ec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;-&lt;/span&gt;&lt;span class="no"&gt;EOT&lt;/span&gt;&lt;span class="sh"&gt;
      NAME="pikoci-${BUILD_PIPELINE_NAME}-${BUILD_JOB_NAME}-pg"
      docker rm -f $NAME 2&amp;gt;/dev/null || true
      docker run -d --name $NAME -p 5432:5432 \
        -e POSTGRES_PASSWORD=test \
        postgres:$param_version
&lt;/span&gt;&lt;span class="no"&gt;    EOT
&lt;/span&gt;    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;ready_check&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/bin/sh"&lt;/span&gt;
    &lt;span class="nx"&gt;args&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-ec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"docker exec pikoci-${BUILD_PIPELINE_NAME}-${BUILD_JOB_NAME}-pg pg_isready"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;interval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2s"&lt;/span&gt;
    &lt;span class="nx"&gt;timeout&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"30s"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;stop&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/bin/sh"&lt;/span&gt;
    &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-ec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"docker rm -f pikoci-${BUILD_PIPELINE_NAME}-${BUILD_JOB_NAME}-pg 2&amp;gt;/dev/null || true"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="s2"&gt;"integration"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;get&lt;/span&gt; &lt;span class="s2"&gt;"git"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;trigger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"16"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"make"&lt;/span&gt;
      &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"integration-test"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PikoCI starts Postgres, waits for &lt;code&gt;pg_isready&lt;/code&gt; to return 0, runs your tasks, then stops and removes the container. Your task connects to &lt;code&gt;localhost:5432&lt;/code&gt; like it would anywhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lifecycle
&lt;/h2&gt;

&lt;p&gt;Services start in the order they appear in the job plan. You control placement:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Define a &lt;code&gt;get&lt;/code&gt; step first if you need the code pulled before services start&lt;/li&gt;
&lt;li&gt;Define services wherever makes sense for your job&lt;/li&gt;
&lt;li&gt;After all steps complete, success or failure, &lt;code&gt;stop&lt;/code&gt; runs for every started service&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The stop step always runs. If your task panics, if the job is cancelled, if the worker gets a SIGTERM, stop still runs. No orphaned containers.&lt;/p&gt;

&lt;h2&gt;
  
  
  No Docker-in-Docker
&lt;/h2&gt;

&lt;p&gt;Services don't run inside a container. They run on the worker host directly. Your tasks can run inside Docker containers via the docker runner, and they connect to services on &lt;code&gt;localhost&lt;/code&gt; because everything is on the same host network.&lt;/p&gt;

&lt;p&gt;This is the key difference from Docker-in-Docker. With DinD you're running a Docker daemon inside a container, which requires &lt;code&gt;--privileged&lt;/code&gt;, has kernel-level implications, and is generally fragile. With PikoCI services, Docker is just a process manager, the worker runs &lt;code&gt;docker run&lt;/code&gt;, the container starts on the host network, your tasks connect normally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Orphan prevention
&lt;/h2&gt;

&lt;p&gt;If a worker crashes mid-job, the &lt;code&gt;stop&lt;/code&gt; block never runs and Docker containers keep running. The next job tries to start on the same port and fails.&lt;/p&gt;

&lt;p&gt;The solution is stable container names with pre-start cleanup. Use &lt;code&gt;$BUILD_PIPELINE_NAME&lt;/code&gt; and &lt;code&gt;$BUILD_JOB_NAME&lt;/code&gt;, stable across runs, and always clean up at the start of the &lt;code&gt;start&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"pikoci-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BUILD_PIPELINE_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BUILD_JOB_NAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-postgres"&lt;/span&gt;
docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$NAME&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;   &lt;span class="c"&gt;# kill orphan if exists&lt;/span&gt;
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$NAME&lt;/span&gt; ...           &lt;span class="c"&gt;# start fresh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;|| true&lt;/code&gt; means cleanup never fails the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not just Docker
&lt;/h2&gt;

&lt;p&gt;Services aren't Docker-specific. You can start any process: a local daemon, a background script, anything the worker can run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;service_type&lt;/span&gt; &lt;span class="s2"&gt;"redis"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/bin/sh"&lt;/span&gt;
    &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-ec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"redis-server --daemonize yes --dir $WORKDIR"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;stop&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/bin/sh"&lt;/span&gt;
    &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-ec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"redis-cli shutdown"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No ready check needed, Redis starts fast enough that the job can proceed immediately after start completes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sourceable and reusable
&lt;/h2&gt;

&lt;p&gt;Like all PikoCI abstractions, &lt;code&gt;service_type&lt;/code&gt; definitions are sourceable from a URL. Write a service type once, host it in a git repo or anywhere with an HTTPS endpoint, and reference it from any pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;service_type&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;#source = "https://raw.githubusercontent.com/myorg/pikoci-services/main/postgres.hcl"&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pikoci://postgresql"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The built-in types use the same mechanism, &lt;code&gt;pikoci://postgres&lt;/code&gt; is just a URL that ships with the binary. Community-built service types work identically.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if my worker runs in Docker?
&lt;/h2&gt;

&lt;p&gt;If your worker runs as a Docker container, services that use &lt;code&gt;docker run&lt;/code&gt; need the Docker socket mounted so the worker spawns sibling containers on the host instead of nested ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-v&lt;/span&gt; /var/run/docker.sock:/var/run/docker.sock pikoci-worker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alternatively, if your worker's Docker image already has the service preinstalled (Postgres, MySQL, Redis) you can start it as a local process directly, no socket needed. It's a less common setup but works cleanly for fixed environments.&lt;/p&gt;

&lt;p&gt;The simplest approach is to run the worker as a plain process on the host. Then any service can use Docker freely, just the worker running containers, no nesting involved. That's how the PikoCI dogfooding pipeline runs its workers.&lt;/p&gt;

&lt;h2&gt;
  
  
  PikoCI uses this itself
&lt;/h2&gt;

&lt;p&gt;The pipeline that tests PikoCI runs integration tests against six backends simultaneously using services: MariaDB, PostgreSQL, NATS, RabbitMQ, Kafka, and Vault. All six are defined in the job plan, all six stop when the job ends. The pipeline is publicly visible at ci.pikoci.com/teams/main/pipelines/pikoci, no account needed.&lt;/p&gt;




&lt;p&gt;This is how integration tests should work in CI. Not Docker-in-Docker, not shared test databases, not skipped tests. Just services that start where you need them and stop when the job ends.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pikoci.com" rel="noopener noreferrer"&gt;pikoci.com&lt;/a&gt; · &lt;a href="https://github.com/pikoci/pikoci" rel="noopener noreferrer"&gt;github.com/pikoci/pikoci&lt;/a&gt; · &lt;a href="https://docs.pikoci.com/Services/" rel="noopener noreferrer"&gt;docs.pikoci.com/Services&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>go</category>
      <category>docker</category>
    </item>
    <item>
      <title>How hard can it be to build a CI/CD system?</title>
      <dc:creator>Francesc Gil</dc:creator>
      <pubDate>Thu, 28 May 2026 22:31:06 +0000</pubDate>
      <link>https://dev.to/xescugc/how-hard-can-it-be-to-build-a-cicd-system-1cnj</link>
      <guid>https://dev.to/xescugc/how-hard-can-it-be-to-build-a-cicd-system-1cnj</guid>
      <description>&lt;p&gt;How hard can it be to build a CI/CD system?&lt;/p&gt;

&lt;p&gt;That question stuck with me long enough that I actually started building one. Not because someone asked me to. Not because I spotted a market gap. Just because the question wouldn't go away.&lt;/p&gt;

&lt;p&gt;The trigger was Concourse CI. I've been using it for a while and what I love about it is the resource abstraction, an interface that anything external has to follow. Check for new versions, pull them, push back. As a Go developer this kind of clean interface resonates with me. Everything in the pipeline is just something that implements that contract.&lt;/p&gt;

&lt;p&gt;But the operational overhead is significant. And I needed CI for my own side projects anyway, games and open source tools that require custom environments GitHub Actions can't provide. So I started building.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I wanted
&lt;/h2&gt;

&lt;p&gt;A single binary that could also scale horizontally when needed. Start with nothing, grow when you need to.&lt;/p&gt;

&lt;p&gt;You start like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./pikoci server &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-system&lt;/span&gt; mem &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--pubsub-system&lt;/span&gt; mem &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--run-worker&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--pipeline-config&lt;/span&gt; pipeline.hcl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a complete CI/CD system. In memory, no files, no external services. When you want persistence, add &lt;code&gt;--db-system sqlite&lt;/code&gt;. When you need distributed workers, add NATS and start workers on other machines. The pipeline config never changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interesting parts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Four pluggable abstractions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PikoCI has four concepts you define in HCL and can source from a URL: resource types, runners, service types, and secret types. Each follows the same pattern: define the type once, instantiate it with params.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;resource_type&lt;/strong&gt; defines how to watch something for changes and fetch it. A &lt;strong&gt;resource&lt;/strong&gt; is an instance of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource_type&lt;/span&gt; &lt;span class="s2"&gt;"git"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pikoci://git"&lt;/span&gt;  &lt;span class="c1"&gt;# built-in&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"git"&lt;/span&gt; &lt;span class="s2"&gt;"my-app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/org/app"&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;"app"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;check_interval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"@every 1m"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;strong&gt;runner_type&lt;/strong&gt; defines where tasks execute. The &lt;code&gt;docker&lt;/code&gt; and &lt;code&gt;exec&lt;/code&gt; runners are built-in, no declaration needed. Here's what the docker runner looks like under the hood, in case you want to define your own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# this is what pikoci://docker looks like&lt;/span&gt;
&lt;span class="c1"&gt;# define your own to customize or replace it&lt;/span&gt;
&lt;span class="nx"&gt;runner_type&lt;/span&gt; &lt;span class="s2"&gt;"docker"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"docker"&lt;/span&gt;
    &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s2"&gt;"run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"--rm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"-v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"$WORKDIR:/workdir"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"-w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/workdir"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"$image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"/bin/sh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"-ec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"$cmd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;get&lt;/span&gt; &lt;span class="s2"&gt;"git"&lt;/span&gt; &lt;span class="s2"&gt;"my-app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;trigger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="s2"&gt;"run-tests"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="s2"&gt;"docker"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"golang:1.25"&lt;/span&gt;
      &lt;span class="nx"&gt;cmd&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cd app &amp;amp;&amp;amp; make test"&lt;/span&gt;
      &lt;span class="nx"&gt;args&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/cache/go:/root/go/pkg/mod"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;strong&gt;secret_type&lt;/strong&gt; defines where credentials come from. Secrets are bound to variables and referenced anywhere in the pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;secret_type&lt;/span&gt; &lt;span class="s2"&gt;"vault"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pikoci://vault"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"db_password"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="s2"&gt;"vault"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"secret/data/db"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"password"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;strong&gt;service_type&lt;/strong&gt; defines processes that run alongside your tasks, started before, stopped after, guaranteed regardless of outcome. This is the feature I'm most proud of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;service_type&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# source = "pikoci://postgres" — or define inline&lt;/span&gt;
  &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/bin/sh"&lt;/span&gt;
    &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-ec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"docker run -d --name db -p 5432:5432 postgres:16"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;ready_check&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/bin/sh"&lt;/span&gt;
    &lt;span class="nx"&gt;args&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-ec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"pg_isready -h localhost"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"30s"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;stop&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/bin/sh"&lt;/span&gt;
    &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-ec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"docker rm -f db"&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="nx"&gt;job&lt;/span&gt; &lt;span class="s2"&gt;"integration"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nx"&gt;get&lt;/span&gt; &lt;span class="s2"&gt;"git"&lt;/span&gt; &lt;span class="s2"&gt;"my-app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;trigger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"make"&lt;/span&gt;
      &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"integration-test"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Docker-in-Docker. No docker-compose alongside CI. The service stops regardless of whether the job passed or failed.&lt;/p&gt;

&lt;p&gt;All four types are sourceable from a URL. The built-ins use &lt;code&gt;pikoci://&lt;/code&gt;, the same mechanism as anything you host yourself. If you need a runner that executes jobs in Kubernetes or Azure, write it once, host it anywhere, reference it by URL.&lt;/p&gt;

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

&lt;p&gt;Here's a small pipeline that uses all four abstractions at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource_type&lt;/span&gt; &lt;span class="s2"&gt;"git"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pikoci://git"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"git"&lt;/span&gt; &lt;span class="s2"&gt;"my-app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://github.com/org/app"&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;"app"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;check_interval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"@every 1m"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;secret_type&lt;/span&gt; &lt;span class="s2"&gt;"vault"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pikoci://vault"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"db_password"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="s2"&gt;"vault"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"secret/data/db"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"password"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;service_type&lt;/span&gt; &lt;span class="s2"&gt;"postgresql"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pikoci://postgresql"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="s2"&gt;"test"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;get&lt;/span&gt; &lt;span class="s2"&gt;"git"&lt;/span&gt; &lt;span class="s2"&gt;"my-app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;trigger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="s2"&gt;"postgresql"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;version&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"17"&lt;/span&gt;
    &lt;span class="nx"&gt;port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"5432"&lt;/span&gt;
    &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_password&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="s2"&gt;"run-tests"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="s2"&gt;"docker"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"golang:1.25"&lt;/span&gt;
      &lt;span class="nx"&gt;cmd&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cd app &amp;amp;&amp;amp; make integration-test"&lt;/span&gt;
      &lt;span class="nx"&gt;args&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/cache/go:/root/go/pkg/mod"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A git resource watches for changes, a Vault secret feeds the Postgres password, Postgres starts as a service, and the task runs inside Docker. All four abstractions, one pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Running pipelines locally&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pikoci run &lt;span class="nt"&gt;--pipeline-config&lt;/span&gt; pipeline.hcl &lt;span class="nt"&gt;--job&lt;/span&gt; &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any job, on your laptop, no server required. Override resources with local paths, inject secrets via &lt;code&gt;--var&lt;/code&gt;. The same pipeline that runs in CI runs on your laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The queue decision
&lt;/h2&gt;

&lt;p&gt;Workers don't connect directly to the server, they subscribe to a queue. This means workers can be behind NAT, on a laptop, in a different data center, or completely ephemeral. The server never needs to know where workers are. It made distributed workers trivially easy to add. Start a worker anywhere with network access to the queue and it just works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it is now
&lt;/h2&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%2Fm1zg17gwturrjhnqf7gb.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%2Fm1zg17gwturrjhnqf7gb.png" alt=" " width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;PikoCI deploys itself. The pipeline runs PR checks, mock tests, and integration tests against six different database and queue backends: MariaDB, PostgreSQL, NATS, RabbitMQ, Kafka, and Vault, all running as services. Then it builds multi-arch Docker images and redeploys itself with zero downtime.&lt;/p&gt;

&lt;p&gt;Those six backends are not just targets, they are the pluggable abstractions themselves. The same PikoCI binary connects to any of them depending on how you start it. Testing against all of them is how I make sure the abstractions actually hold.&lt;/p&gt;

&lt;p&gt;All of it is publicly visible at &lt;a href="https://ci.pikoci.com/teams/main/pipelines/pikoci" rel="noopener noreferrer"&gt;ci.pikoci.com/teams/main/pipelines/pikoci&lt;/a&gt;. No account needed.&lt;/p&gt;

&lt;p&gt;It's Apache 2.0, written in Go, and very much something I use for my own projects every day.&lt;/p&gt;

&lt;p&gt;If you try it and something is broken, I want to know. If something is missing, I also want to know.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pikoci.com" rel="noopener noreferrer"&gt;pikoci.com&lt;/a&gt; · &lt;a href="https://github.com/pikoci/pikoci" rel="noopener noreferrer"&gt;github.com/pikoci/pikoci&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>go</category>
      <category>selfhosted</category>
    </item>
  </channel>
</rss>
