<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ github-actions - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ github-actions - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Mon, 18 May 2026 22:34:52 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/github-actions-1/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Production-Ready Flutter CI/CD Pipeline with GitHub Actions: Quality Gates, Environments, and Store Deployment ]]>
                </title>
                <description>
                    <![CDATA[ Mobile application development has evolved over the years. The processes, structure, and syntax we use has changed, as well as the quality and flexibility of the apps we build. One of the major improv ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-production-ready-flutter-ci-cd-pipeline-with-github-actions-quality-gates-environments-and-store-deployment/</link>
                <guid isPermaLink="false">69bb2e078c55d6eefb6c2e8d</guid>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github-actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github copilot ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CI/CD pipelines ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Productivity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Wed, 18 Mar 2026 22:58:15 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8c9d9384-ff02-47d7-aa69-42db2ebae247.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Mobile application development has evolved over the years. The processes, structure, and syntax we use has changed, as well as the quality and flexibility of the apps we build.</p>
<p>One of the major improvements has been a properly automated CI/CD pipeline flow that gives us seamless automation, continuous integration, and continuous deployment.</p>
<p>In this article, I'll break down how you can automate and build a production ready CI/CD pipeline for your Flutter application using GitHub Actions.</p>
<p>Note that there are other ways to do this, like with Codemagic (built specifically for Flutter apps – which I'll cover in a subsequent tutorial), but in this article we'll focus on GitHub Actions instead.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-the-typical-workflow">The Typical Workflow</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-pipeline-architecture">Pipeline Architecture</a></p>
</li>
<li><p><a href="#heading-writing-the-workflows">Writing the Workflows</a></p>
<ul>
<li><p><a href="#heading-the-helper-scripts">The Helper Scripts</a></p>
<ul>
<li><p><a href="#heading-script-1-generateconfigsh">generate_config.sh</a></p>
</li>
<li><p><a href="#heading-script-2-qualitygatesh">quality_gate.sh</a></p>
</li>
<li><p><a href="#heading-script-3-uploadsymbolssh-sentry">upload_symbols.sh</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-workflow-1-prchecksyml">PR Quality Gate (pr_checks.yml)</a></p>
</li>
<li><p><a href="#heading-workflow-2-androidyml">Android CI/CD Pipeline (android.yml)</a></p>
</li>
<li><p><a href="#heading-workflow-3-iosyml">iOS CI/CD Pipeline (ios.yml)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-secrets-and-configuration-reference">Secrets and Configuration Reference</a></p>
</li>
<li><p><a href="#heading-end-to-end-flow">End-to-End Flow</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-the-typical-workflow">The Typical Workflow</h2>
<p>First, let's define the common approach to deploying production-ready Flutter apps.</p>
<p>The development team does their work on local, pushes to the repository for merge or review, and eventually runs <code>flutter build apk</code> or <code>flutter build appbundle</code> to generate the apk file. This then gets shared with the QA team manually, or deployed to Firebase app distribution for testing. If it's a production move, the app bundle is submitted to the Google Play store for review and then deployed.</p>
<p>This process is often fully manual with no automated checks, validation, or control over quality, speed, and seamlessness. Manually shipping a Flutter app starts out relatively simply, but can quickly and quietly turn into a liability. You run <code>flutter build</code>, switch configs, sign the build, upload it somewhere, and hope you didn’t mix up staging keys with production ones.</p>
<p>As teams grow and release updates more and more quickly, these manual steps become real risks. A skipped quality check, a missing keystore, or an incorrect base URL deployed to production can cost hours of debugging or worse – it can affect your users.</p>
<p>Automating this process fully involves some high level configuration and predefined scripting. It completely takes control of the deployment process from the moment the developer raised a PR into the common or base branch (for example, the <code>develop</code> branch).</p>
<p>This automated process takes care of everything that needs to be done – provided it has been predefined, properly scripted, and aligns with the use case of the team.</p>
<h3 id="heading-what-well-do-here">What we'll do here:</h3>
<p>In this tutorial, we'll build a production-grade CI/CD pipeline for a Flutter app using GitHub Actions. The pipeline automates the entire lifecycle: pull-request quality checks, environment-specific configuration injection, Android and iOS builds, Firebase App Distribution for testers, Sentry symbol uploads, and final deployment to the Play Store and App Store.</p>
<p>By the end, every release – from a developer opening a PR to the final build landing in users' hands – will be fully automated, with no one touching a terminal.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before starting, you should have:</p>
<ol>
<li><p>A Flutter app with working Android and iOS builds</p>
</li>
<li><p>Basic familiarity with <a href="https://www.freecodecamp.org/news/automate-cicd-with-github-actions-streamline-workflow/">GitHub Actions</a> (workflows and jobs)</p>
</li>
<li><p>A Firebase project with App Distribution enabled</p>
</li>
<li><p>A Sentry project for error tracking</p>
</li>
<li><p>A Google Play Console app already created</p>
</li>
<li><p>An Apple Developer account with App Store Connect access</p>
</li>
<li><p>Fastlane configured for your iOS project</p>
</li>
<li><p>Basic Bash knowledge (I’ll explain the important parts)</p>
</li>
</ol>
<h2 id="heading-pipeline-architecture">Pipeline Architecture</h2>
<p>In this guide, we'll be building a CI/CD pipeline with very precise instructions and use cases. These use cases determine the way your pipeline is built.</p>
<p>For this tutorial, we'll use this use case:</p>
<p>I want to automate the workflow on my development team based on the following criteria:</p>
<ol>
<li><p>When a developer on the team raises a PR into the common working branch <code>develop</code> in most cases), a workflow is triggered to run quality checks on the code. It only allows the merge to happen if all checks (like tests coverage, quality checks, and static analysis) pass.</p>
</li>
<li><p>Code that's moving from the develop branch to the staging branch goes through another workflow that injects staging configurations/secret keys, does all the necessary checks, and distributes the application for testing on Firebase App Distribution for android as well as Testflight for iOS.</p>
</li>
<li><p>Code that's moving from the staging to the production branch goes through the production level workflow which involves apk secured signing, production configuration injection, running tests to ensure nothing breaks, Sentry analysis for monitoring, and submission to App Store Connect as well as Google Play Console.</p>
</li>
</ol>
<p>These are our predefined conditions which help with the construction of our workflows.</p>
<h2 id="heading-writing-the-workflows">Writing the Workflows</h2>
<p>We'll split this pipeline into three GitHub Actions workflows.</p>
<p>We'll also be taking it a notch higher by creating three helper .sh scripts for a cleaner and more maintainable workflow.</p>
<p>In your project root, create two folders:</p>
<ol>
<li><p>.github/</p>
</li>
<li><p>scripts.</p>
</li>
</ol>
<p>The <strong>.github/</strong> folder will hold the workflows we'll be creating for each use case, while the <strong>scripts/</strong> folder will hold the helper scripts that we can easily call in our CLI or in the workflows directly.</p>
<p>After this, we'll create three workflow .yaml files:</p>
<ol>
<li><p>pr_checks.yaml</p>
</li>
<li><p>android.yaml</p>
</li>
<li><p>ios.yaml</p>
</li>
</ol>
<p>Also in the scripts folder, let's create three .sh files:</p>
<ol>
<li><p>generate_config.sh</p>
</li>
<li><p>quality_checks.sh</p>
</li>
<li><p>upload_symbols.sh</p>
</li>
</ol>
<pre><code class="language-yaml">.github/
  workflows/
    pr_checks.yml
    android.yml
    ios.yml

scripts/
  generate_config.sh
  quality_checks.sh
  upload_symbols.sh
</code></pre>
<p>This workflow architecture ensures that a push to <code>develop</code> automatically produces a tester build. Also, merging to <code>production</code> ships directly to the stores without manual commands or config changes.</p>
<p>The scripts live outside the YAML on purpose. This lets you run the same logic locally.</p>
<h3 id="heading-the-helper-scripts">The Helper Scripts</h3>
<p>The scripts form the backbone of the pipeline. Each one has a single responsibility and is reused across workflows.</p>
<p>Instead of cramming logic into YAML, we'll move it into <strong>reusable scripts</strong>. This keeps workflows clean and lets you run the same logic locally. Let's go through each one now.</p>
<h3 id="heading-script-1-generateconfigsh">Script #1: <code>generate_config.sh</code></h3>
<p>Injecting secrets safely is one of the hardest CI/CD problems in mobile apps.</p>
<p>The strategy:</p>
<ul>
<li><p>Commit a Dart template file with placeholders</p>
</li>
<li><p>Replace placeholders at build time using secrets from GitHub Actions</p>
</li>
<li><p>Never commit real credentials</p>
</li>
</ul>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail


ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}

TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env_ci.g.dart"

if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
  echo "Usage: $0 &lt;env-name&gt; &lt;base-url&gt; &lt;encryption-key&gt;"
  exit 2
fi

sed -e "s|&lt;&lt;BASE_URL&gt;&gt;|$BASE_URL|g" \
    -e "s|&lt;&lt;ENCRYPTION_KEY&gt;&gt;|$ENCRYPTION_KEY|g" \
    -e "s|&lt;&lt;ENV_NAME&gt;&gt;|$ENV_NAME|g" \
    "\(TEMPLATE" &gt; "\)OUT"

echo "Generated config for $ENV_NAME"
</code></pre>
<p>This script is responsible for injecting environment-specific configuration into the Flutter app at build time, without ever committing secrets to source control.</p>
<p>Let’s walk through it carefully.</p>
<h4 id="heading-1-shebang-choosing-the-shell">1. Shebang: Choosing the Shell</h4>
<pre><code class="language-yaml">#!/usr/bin/env bash
</code></pre>
<p>This line tells the system to execute the script using <strong>Bash</strong>, regardless of where Bash is installed on the machine.</p>
<p>Using <code>/usr/bin/env bash</code> instead of <code>/bin/bash</code> makes the script more portable across local machines, GitHub Actions runners, and Docker containers.</p>
<h4 id="heading-2-fail-fast-fail-loud">2. Fail Fast, Fail Loud</h4>
<pre><code class="language-yaml">set -euo pipefail
</code></pre>
<p>This is one of the most important lines in the script.</p>
<p>It enables three strict Bash modes:</p>
<ul>
<li><p><code>-e</code>: Exit immediately if any command fails</p>
</li>
<li><p><code>-u</code>: Exit if an undefined variable is used</p>
</li>
<li><p><code>-o pipefail</code>: Fail if any command in a pipeline fails, not just the last one</p>
</li>
</ul>
<p>This matters in CI because silent failures are dangerous, partial config generation can break production builds, and CI should stop immediately when something is wrong.</p>
<p>This line ensures that no broken config ever makes it into a build.</p>
<h4 id="heading-3-reading-input-arguments">3. Reading Input Arguments</h4>
<pre><code class="language-yaml">
ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}
</code></pre>
<p>These lines read <strong>positional arguments</strong> passed to the script:</p>
<ul>
<li><p><code>$1</code>: Environment name (<code>dev</code>, <code>staging</code>, <code>production</code>)</p>
</li>
<li><p><code>$2</code>: API base URL</p>
</li>
<li><p><code>$3</code>: Encryption or API key</p>
</li>
</ul>
<p>The <code>${1:-}</code> syntax means:</p>
<p><em>“If the argument is missing, default to an empty string instead of crashing.”</em></p>
<p>This works hand-in-hand with <code>set -u</code> , we control the failure explicitly instead of letting Bash explode unexpectedly.</p>
<h4 id="heading-4-defining-input-and-output-files">4. Defining Input and Output Files</h4>
<pre><code class="language-yaml">TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env_ci.g.dart"
</code></pre>
<p>Here we define two files:</p>
<ul>
<li><p><strong>Template file (</strong><code>env_ci.dart</code><strong>)</strong></p>
<ul>
<li><p>Contains placeholder values like <code>&lt;&lt;BASE_URL&gt;&gt;</code></p>
</li>
<li><p>Safe to commit to Git</p>
</li>
</ul>
</li>
<li><p><strong>Generated file (</strong><code>env_ci.g.dart</code><strong>)</strong></p>
<ul>
<li><p>Contains real environment values</p>
</li>
<li><p>Must be ignored by Git (<code>.gitignore</code>)</p>
</li>
</ul>
</li>
</ul>
<p>At the heart of this approach are two Dart files with very different responsibilities. They may look similar, but they play completely different roles in the system.</p>
<h4 id="heading-envcidart"><code>env.ci.dart</code>:</h4>
<pre><code class="language-java">// lib/core/env/env_ci.dart

class EnvConfig {
  static const String baseUrl = '&lt;&lt;BASE_URL&gt;&gt;';
  static const String encryptionKey = '&lt;&lt;ENCRYPTION_KEY&gt;&gt;';
  static const String environment = '&lt;&lt;ENV_NAME&gt;&gt;';
}
</code></pre>
<p>This file is <strong>safe</strong>, <strong>static</strong>, and <strong>version-controlled</strong>. It contains placeholders, not real values.</p>
<p>Some of its key characteristics are:</p>
<ul>
<li><p>Contains no real secrets</p>
</li>
<li><p>Uses obvious placeholders (<code>&lt;&lt;BASE_URL&gt;&gt;</code>, etc.)</p>
</li>
<li><p>Safe to commit to Git</p>
</li>
<li><p>Reviewed like normal source code</p>
</li>
<li><p>Serves as the single source of truth for required config fields</p>
</li>
</ul>
<p>Think of this file as a contract:</p>
<p><em>“These are the configuration values the app expects at runtime.”</em></p>
<h4 id="heading-envcigdart"><code>env.ci.g.dart</code>:</h4>
<p>This file is created at <strong>build time</strong> by <code>generate_config.sh</code>. After substitution, it looks like this:</p>
<pre><code class="language-java">// lib/core/env/env_ci.g.dart
// GENERATED FILE — DO NOT COMMIT

class EnvConfig {
  static const String baseUrl = 'https://staging.api.example.com';
  static const String encryptionKey = 'sk_live_xxxxx';
  static const String environment = 'staging';
}
</code></pre>
<p>Key characteristics:</p>
<ul>
<li><p>Contains real environment values</p>
</li>
<li><p>Generated dynamically in CI</p>
</li>
<li><p>Differs per environment (dev / staging / production)</p>
</li>
<li><p>Must <strong>never</strong> be committed to source control</p>
</li>
</ul>
<p>This file exists only on a developer’s machine (if generated locally), inside the CI runner during a build. Once the job finishes, it disappears.</p>
<h4 id="heading-gitignore"><code>.gitignore</code>:</h4>
<p>To guarantee the generated file never leaks, it must be ignored:</p>
<h4 id="heading-why-this-separation-is-critical">Why This Separation Is Critical</h4>
<p>This design solves several hard problems at once.</p>
<p><strong>Security:</strong></p>
<ul>
<li><p>Secrets live <strong>only</strong> in GitHub Actions secrets</p>
</li>
<li><p>They never appear in the repository</p>
</li>
<li><p>They never appear in PRs</p>
</li>
<li><p>They never appear in Git history</p>
</li>
</ul>
<p><strong>Environment Isolation:</strong></p>
<p>Each environment gets its own generated config:</p>
<ul>
<li><p><code>develop</code>: dev API</p>
</li>
<li><p><code>staging</code>: staging API</p>
</li>
<li><p><code>production</code>: production API</p>
</li>
</ul>
<p>The same codebase behaves differently <strong>without branching logic in Dart</strong>.</p>
<p><strong>Deterministic Builds:</strong></p>
<p>Every build is fully reproducible, fully automated, and explicit about which environment it targets.</p>
<p>There are no “it worked locally” scenarios.</p>
<h4 id="heading-5-validating-required-arguments">5. Validating Required Arguments</h4>
<pre><code class="language-java">if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
  echo "Usage: $0 &lt;env-name&gt; &lt;base-url&gt; &lt;encryption-key&gt;"
  exit 2
fi
</code></pre>
<p>This block enforces correct usage.</p>
<ul>
<li><p><code>-z</code> checks whether a variable is empty</p>
</li>
<li><p>If any required argument is missing:</p>
<ul>
<li><p>A helpful usage message is printed</p>
</li>
<li><p>The script exits with a non-zero status code</p>
</li>
</ul>
</li>
<li><p><code>0</code>: success</p>
</li>
<li><p><code>1+</code>: failure</p>
</li>
<li><p><code>2</code> conventionally means incorrect usage</p>
</li>
</ul>
<p>In CI, this immediately fails the job and prevents an invalid build.</p>
<h4 id="heading-6-injecting-environment-values">6. Injecting Environment Values</h4>
<pre><code class="language-java">sed -e "s|&lt;&lt;BASE_URL&gt;&gt;|$BASE_URL|g" \
    -e "s|&lt;&lt;ENCRYPTION_KEY&gt;&gt;|$ENCRYPTION_KEY|g" \
    -e "s|&lt;&lt;ENV_NAME&gt;&gt;|$ENV_NAME|g" \
    "\(TEMPLATE" &gt; "\)OUT"
</code></pre>
<p>This is the heart of the script.</p>
<p>What’s happening here:</p>
<ol>
<li><p><code>sed</code> performs <strong>stream editing</strong>: it reads text, transforms it, and outputs the result</p>
</li>
<li><p>Each <code>-e</code> flag defines a replacement rule:</p>
<ul>
<li><p>Replace <code>&lt;&lt;BASE_URL&gt;&gt;</code> with the actual API URL</p>
</li>
<li><p>Replace <code>&lt;&lt;ENCRYPTION_KEY&gt;&gt;</code> with the real key</p>
</li>
<li><p>Replace <code>&lt;&lt;ENV_NAME&gt;&gt;</code> with the environment label</p>
</li>
</ul>
</li>
<li><p>The transformed output is written to <code>env_ci.g.dart</code></p>
</li>
</ol>
<p>This entire operation happens <strong>at build time</strong>:</p>
<ul>
<li><p>No secrets are committed</p>
</li>
<li><p>No secrets are logged</p>
</li>
<li><p>No secrets persist beyond the CI run</p>
</li>
</ul>
<h4 id="heading-7-success-feedback">7. Success Feedback</h4>
<pre><code class="language-java">echo "Generated config for $ENV_NAME"
</code></pre>
<p>This line provides a clear success signal in CI logs.</p>
<p>It answers three important questions instantly:</p>
<ul>
<li><p>Did the script run?</p>
</li>
<li><p>Did it finish successfully?</p>
</li>
<li><p>Which environment was generated?</p>
</li>
</ul>
<p>In long CI logs, these small confirmations matter.</p>
<p>Alright, now let's move on to the second script.</p>
<h3 id="heading-script-2-qualitygatesh">Script #2: <code>quality_gate.sh</code></h3>
<p>This script defines what <em>“good code”</em> means for your team.</p>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail

echo "Running quality checks"

dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test --no-pub --coverage

if command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1; then
  dart_code_metrics analyze lib --reporter=console || true
fi

echo "Quality checks passed"
</code></pre>
<p>Lets break down this script bit by bit.</p>
<h4 id="heading-1-start-amp-end-log-markers">1. Start &amp; End Log Markers</h4>
<pre><code class="language-yaml">echo "Running quality checks"
...
echo "Quality checks passed"
</code></pre>
<p>These two lines act as <strong>visual boundaries</strong> in CI logs.</p>
<p>In large pipelines (especially when Android and iOS jobs run in parallel), logs can be very noisy. Clear markers:</p>
<ul>
<li><p>Help developers quickly find the quality phase</p>
</li>
<li><p>Make debugging faster</p>
</li>
<li><p>Confirm that the script completed successfully</p>
</li>
</ul>
<p>The final success message only prints if <strong>everything above it passed</strong>, because <code>set -e</code> would have terminated the script earlier on failure.</p>
<p>So this line effectively means: All quality gates passed. Safe to proceed.</p>
<h4 id="heading-2-running-the-test-suite">2. Running the Test Suite</h4>
<pre><code class="language-yaml">flutter test --no-pub --coverage
</code></pre>
<p>This line executes your entire Flutter test suite.</p>
<p>Let’s break it down carefully.</p>
<p>1. <code>flutter test</code></p>
<p>This runs unit tests, widget tests, and any test under the <code>test/</code> directory. If <strong>any test fails</strong>, the command exits with a non-zero status code.</p>
<p>Because we enabled <code>set -e</code> earlier, that immediately stops the script and fails the CI job.</p>
<p>2. <code>--coverage</code></p>
<p>This flag generates a coverage report at:</p>
<pre><code class="language-yaml">coverage/lcov.info
</code></pre>
<p>This file can later be uploaded to Codecov, used to enforce minimum coverage thresholds, and tracked over time for quality improvement.</p>
<p>Even if you’re not enforcing coverage yet, generating it now future-proofs your pipeline.</p>
<h4 id="heading-3-optional-code-metrics">3. Optional Code Metrics</h4>
<pre><code class="language-yaml">if command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1; then
  dart_code_metrics analyze lib --reporter=console || true
fi
</code></pre>
<p>This block is intentionally designed to be optional and non-blocking.</p>
<p><strong>Step 1 – Check If the Tool Exists:</strong></p>
<pre><code class="language-yaml">command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1
</code></pre>
<p>This checks whether <code>dart_code_metrics</code> is installed.</p>
<ul>
<li><p>If installed, proceed</p>
</li>
<li><p>If not installed, skip silently</p>
</li>
</ul>
<p>The redirection:</p>
<ul>
<li><p><code>&gt;/dev/null</code> hides normal output</p>
</li>
<li><p><code>2&gt;&amp;1</code> hides errors</p>
</li>
</ul>
<p>This makes the script portable:</p>
<ul>
<li><p>Developers without the tool can still run the script</p>
</li>
<li><p>CI can enforce it if configured</p>
</li>
</ul>
<p><strong>Step 2 – Run Metrics (Soft Enforcement):</strong></p>
<pre><code class="language-yaml">dart_code_metrics analyze lib --reporter=console || true
</code></pre>
<p>This analyzes the <code>lib/</code> directory and prints results in the console.</p>
<p>The important part is:</p>
<pre><code class="language-yaml">|| true
</code></pre>
<p>Because we enabled <code>set -e</code>, any failing command would normally stop the script.</p>
<p>Adding <code>|| true</code> overrides that behavior:</p>
<ul>
<li><p>If metrics report issues,</p>
</li>
<li><p>The script continues,</p>
</li>
<li><p>CI does not fail.</p>
</li>
</ul>
<p>Why design it this way? Because metrics are often gradual improvements, technical debt indicators, or advisory rather than blocking.</p>
<p>You can later remove <code>|| true</code> to make metrics mandatory.</p>
<h4 id="heading-4-final-success-message"><strong>4. Final Success Message</strong></h4>
<pre><code class="language-yaml">echo "✅ Quality checks passed"
</code></pre>
<p>This line only executes if formatting passed, static analysis passed, and tests passed.</p>
<p>If you see this in CI logs, it means the branch has successfully cleared the quality gate. It’s your automated approval before deployment steps begin.</p>
<h4 id="heading-what-this-script-guarantees">What This Script Guarantees</h4>
<p>With this in place, every branch must satisfy:</p>
<ul>
<li><p>Clean formatting</p>
</li>
<li><p>No analyzer errors</p>
</li>
<li><p>Passing tests</p>
</li>
<li><p>(Optional) Healthy metrics</p>
</li>
</ul>
<p>That’s how you move from <strong>“We try to maintain quality”</strong> to <strong>“Quality is enforced automatically.”</strong></p>
<p>Alright, on to the third script.</p>
<h3 id="heading-script-3-uploadsymbolssh-sentry"><strong>Script #3:</strong> <code>upload_symbols.sh</code> <strong>(Sentry)</strong></h3>
<p>This script is responsible for uploading <strong>obfuscation debug symbols</strong> to Sentry so production crashes remain readable.</p>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail

RELEASE=${1:-}

[ -z "$RELEASE" ] &amp;&amp; exit 2

if ! command -v sentry-cli &gt;/dev/null 2&gt;&amp;1; then
  exit 0
fi

sentry-cli releases new "$RELEASE" || true

sentry-cli upload-dif build/symbols || true

sentry-cli releases finalize "$RELEASE" || true

echo "✅ Symbols uploaded for release $RELEASE"
</code></pre>
<p>Let's go through it step by step.</p>
<h4 id="heading-1-reading-the-release-identifier">1. Reading the Release Identifier</h4>
<pre><code class="language-yaml">RELEASE=${1:-}
</code></pre>
<p>This reads the first positional argument passed to the script.</p>
<p>When you call the script in CI, it typically looks like:</p>
<pre><code class="language-yaml">./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
</code></pre>
<p>So <code>$1</code> becomes the short Git commit SHA.</p>
<p>Using <code>${1:-}</code> ensures:</p>
<ul>
<li><p>If no argument is passed, the variable becomes an empty string</p>
</li>
<li><p>The script does not crash due to <code>set -u</code></p>
</li>
</ul>
<p>This release value ties the uploaded symbols, deployed build, and crash reports all to the exact same commit. This linkage is critical for production debugging.</p>
<h4 id="heading-2-validating-the-release-argument">2. Validating the Release Argument</h4>
<pre><code class="language-yaml">[ -z "$RELEASE" ] &amp;&amp; exit 2
</code></pre>
<p>This is a compact validation check.</p>
<ul>
<li><p><code>-z</code> checks whether the string is empty</p>
</li>
<li><p>If it is empty → exit with status code 2</p>
</li>
</ul>
<p>Conventionally:</p>
<ul>
<li><p><code>0</code> = success</p>
</li>
<li><p><code>1+</code> = failure</p>
</li>
<li><p><code>2</code> = incorrect usage</p>
</li>
</ul>
<p>This prevents symbol uploads from running without a release identifier, which would break traceability in Sentry.</p>
<h4 id="heading-3-checking-if-sentry-cli-exists">3. Checking If <code>sentry-cli</code> Exists</h4>
<pre><code class="language-yaml">if ! command -v sentry-cli &gt;/dev/null 2&gt;&amp;1; then
  exit 0
fi
</code></pre>
<p>This block checks whether the <code>sentry-cli</code> tool is available in the environment.</p>
<p>What’s happening:</p>
<ul>
<li><p><code>command -v sentry-cli</code> checks if it exists</p>
</li>
<li><p><code>&gt;/dev/null 2&gt;&amp;1</code> suppresses all output</p>
</li>
<li><p><code>!</code> negates the condition</p>
</li>
</ul>
<p>So this reads as: <em>"If</em> <code>sentry-cli</code> <em>is NOT installed, exit successfully."</em></p>
<p>Why exit with <code>0</code> instead of failing?</p>
<p>Because not every environment needs symbol uploads. Also, dev builds may not install Sentry, and you don’t want CI to fail just because Sentry isn’t configured.</p>
<p>This makes symbol uploading <strong>environment-aware</strong> and <strong>optional</strong>.</p>
<p>Production environments can install <code>sentry-cli</code>, while dev environments skip it cleanly.</p>
<h4 id="heading-4-creating-a-new-release-in-sentry">4. Creating a New Release in Sentry</h4>
<pre><code class="language-yaml">sentry-cli releases new "$RELEASE" || true
</code></pre>
<p>This tells Sentry: “A new release exists with this version identifier.”</p>
<p>Even if the release already exists, the script continues because of:</p>
<pre><code class="language-yaml">|| true
</code></pre>
<p>This prevents the build from failing if:</p>
<ul>
<li><p>The release was already created</p>
</li>
<li><p>The command returns a non-critical error</p>
</li>
</ul>
<p>The goal is resilience, not strict enforcement.</p>
<h4 id="heading-5-uploading-debug-information-files-difs">5. Uploading Debug Information Files (DIFs)</h4>
<pre><code class="language-yaml">sentry-cli upload-dif build/symbols || true
</code></pre>
<p>This is the core step.</p>
<p><code>build/symbols</code> is generated when you build Flutter with:</p>
<pre><code class="language-yaml">--obfuscate --split-debug-info=build/symbols
</code></pre>
<p>When you obfuscate Flutter builds:</p>
<ul>
<li><p>Method names are renamed</p>
</li>
<li><p>Stack traces become unreadable</p>
</li>
</ul>
<p>The symbol files allow Sentry to reverse-map obfuscated stack traces and show readable crash reports.</p>
<p>Without this step, production crashes look like:</p>
<pre><code class="language-yaml">a.b.c.d (Unknown Source)
</code></pre>
<p>With this step, you get:</p>
<pre><code class="language-yaml">AuthRepository.login()
</code></pre>
<p>Again, <code>|| true</code> ensures the build doesn’t fail if:</p>
<ul>
<li><p>The directory doesn’t exist</p>
</li>
<li><p>No symbols were generated</p>
</li>
<li><p>Upload encounters a transient issue</p>
</li>
</ul>
<p>Symbol uploads should not block deployment.</p>
<h4 id="heading-6-finalizing-the-release">6. Finalizing the Release</h4>
<pre><code class="language-yaml">sentry-cli releases finalize "$RELEASE" || true
</code></pre>
<p>This marks the release as complete in Sentry.</p>
<p>Finalizing signals:</p>
<ul>
<li><p>The release is deployed</p>
</li>
<li><p>It can begin aggregating crash reports</p>
</li>
<li><p>It’s ready for production monitoring</p>
</li>
</ul>
<p>Like the previous steps, this is soft-failed with <code>|| true</code> to keep CI robust.</p>
<h4 id="heading-what-this-script-guarantees">What This Script Guarantees</h4>
<p>When everything is configured correctly:</p>
<ol>
<li><p>Production build is obfuscated</p>
</li>
<li><p>Debug symbols are generated</p>
</li>
<li><p>Symbols are uploaded to Sentry</p>
</li>
<li><p>Crashes map back to real source code</p>
</li>
<li><p>Release version matches commit SHA</p>
</li>
</ol>
<p>That’s production-grade crash observability.</p>
<p>Now that we've gone through the three helper scripts we've created to optimize and enhance this process, lets now dive into the three workflow .yaml files we're going to create.</p>
<h2 id="heading-workflow-1-prchecksyml">Workflow #1: <code>PR_CHECKS.YML</code></h2>
<p>This workflow will be designed to help ensure a certain standard is met once a PR is raised into a certain common or base branch. This will ensure that all quality checks in the incoming code pass before allowing any merge into the base branch.</p>
<p>This is basically a gate that verifies the quality of the code that's about to be merged into the base branch. If your pipeline allows unverified code into your base branch, then your CI becomes decorative, not protective.</p>
<p>Lets break down what's actually needed during every PR Check.</p>
<h3 id="heading-1-dependency-integrity">1. Dependency Integrity</h3>
<p>For Flutter apps, where we manage dependencies with the <strong>pub get</strong> command, it's important to make sure that the integrity of all dependencies are confirmed – up to date as well as compatible.</p>
<p>Every PR should begin with:</p>
<pre><code class="language-yaml">flutter pub get
</code></pre>
<p>This ensures:</p>
<ul>
<li><p><code>pubspec.yaml</code> is valid</p>
</li>
<li><p>Dependency constraints are consistent</p>
</li>
<li><p>Lockfiles are not broken</p>
</li>
<li><p>The project is buildable in a clean environment</p>
</li>
</ul>
<p>If this fails, the branch is not deployable.</p>
<h3 id="heading-2-static-analysis">2. Static Analysis</h3>
<p>This ensures code quality and architecture integrity. Static analysis helps prevent common issues like forgotten await, dead code, null safety violations, async misuse, and so on.</p>
<p>Most production bugs aren't business logic errors – they're structural carelessness. Static analysis helps enforce consistency automatically, so code reviews focus on intent, not linting.</p>
<pre><code class="language-yaml">flutter analyze --fatal-infos --fatal-warnings
</code></pre>
<h3 id="heading-3-formatting">3. Formatting</h3>
<p>This command ensures that your code is properly formatted based on your organization's coding standard and policies.</p>
<pre><code class="language-yaml">dart format --output=none --set-exit-if-changed .
</code></pre>
<h3 id="heading-4-tests">4. Tests</h3>
<p>This runs the unit, widget and business logic tests to ensure quality and avoid regression leaks, silent behavior changes and feature drift.</p>
<pre><code class="language-yaml">flutter test --coverage
</code></pre>
<h3 id="heading-5-test-coverage-enforcement">5. Test Coverage Enforcement</h3>
<p>Ideally, running tests is not enough. Your workflow should also enforce a minimum threshold:</p>
<pre><code class="language-yaml">if [ \((lcov --summary coverage/lcov.info | grep lines | awk '{print \)2}' | sed 's/%//') -lt 70 ]; then
  echo "Coverage too low"
  exit 1
fi
</code></pre>
<p>The command above ensures that a minimum test coverage of 70% is met, with this quality becomes measurable.</p>
<p>The five commands above must be checked (at least) for a <strong>quality gate</strong> to guarantee code quality, security, and integrity.</p>
<p>Now here is the full <strong>pr_checks.yml</strong> file:</p>
<pre><code class="language-yaml">name: PR Quality Gate

on:
  pull_request:
    branches: develop
    types: [opened, synchronize, reopened, ready_for_review]

jobs:
  pr-checks:
    name: Run quality checks on this pull request
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Setup Java
        uses: actions/setup-java@v1
        with:
          java-version: "12.x"

      - name: Setup Flutter
        uses: subosito/flutter-action@v1
        with:
          channel: "stable"

      - name: Install dependencies
        run: flutter pub get

      - name: Run quality checks
        run: ./scripts/quality_checks.sh

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "PR Quality Checks PASSED"
          echo "PR: ${{ github.event.pull_request.html_url }}"
          echo "Branch: \({{ github.head_ref }} → \){{ github.base_ref }}"
          echo "By: @${{ github.actor }}"
          echo "Team notification: @foluwaseyi-dev @olabodegbolu"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "PR Quality Checks FAILED"
          echo "PR: ${{ github.event.pull_request.html_url }}"
          echo "Branch: \({{ github.head_ref }} → \){{ github.base_ref }}"
          echo "By: @${{ github.actor }}"
          echo "Please fix the issues before requesting review 🔧"
          echo "Team notification: @foluwaseyi-dev @olabodegbolu"
</code></pre>
<p>Every time a developer opens (or updates) a pull request targeting the <code>develop</code> branch, this workflow kicks in automatically. Think of it as a bouncer at the door: no code gets through without passing inspection first.</p>
<h3 id="heading-what-triggers-it">What Triggers it?</h3>
<p>The workflow fires on four events: when a PR is <code>opened</code>, <code>synchronized</code> (new commits pushed), <code>reopened</code>, or marked <code>ready_for_review</code>. So drafts won't trigger it – only PRs that are actually ready to be looked at.</p>
<h3 id="heading-what-does-it-actually-do">What Does it Actually Do?</h3>
<p>It spins up a fresh Ubuntu machine and runs five steps in sequence:</p>
<ol>
<li><p><strong>Checkout</strong>: pulls down the branch's code</p>
</li>
<li><p><strong>Setup Java 12</strong>: installs the JDK (likely a dependency for some tooling or build process)</p>
</li>
<li><p><strong>Setup Flutter (stable channel)</strong>: this is a Flutter project, so it grabs the stable Flutter SDK</p>
</li>
<li><p><strong>Install dependencies</strong>: runs <code>flutter pub get</code> to pull all Dart/Flutter packages</p>
</li>
<li><p><strong>Run quality checks</strong>: executes the helper shell script (<code>./scripts/quality_checks.sh</code>) that we created which runs linting, tests, formatting checks, or all of the above</p>
</li>
</ol>
<h3 id="heading-the-notification-layer">The Notification Layer</h3>
<p>After the checks run, the workflow reports the verdict and it's context-aware:</p>
<ul>
<li><p><strong>If everything passes</strong>, it logs a success message with the PR URL, branch info, and the person who opened it</p>
</li>
<li><p><strong>If something fails</strong>, it logs a failure message and nudges the author to fix issues before requesting a review</p>
</li>
</ul>
<p>Both outcomes tag <code>@foluwaseyi-dev</code> and <code>@olabodegbolu</code> – the two team members responsible for staying in the loop.</p>
<p>This workflow enforces a "fix it before you merge it" culture. No one can merge broken code into <code>develop</code> without the team knowing about it.</p>
<h2 id="heading-workflow-2-androidyml">Workflow #2: Android.yml</h2>
<p>It's a better practice to split your workflows based on platform. This helps you properly manage the instructions regarding each platform. This is the reason behind keeping the Android workflow separate.</p>
<p>Unlike <code>PR _Checks</code>, this workflow presumes that all checks for quality and standards have been done and the code that runs this workflow already meets the required standards.</p>
<p>Based on our predefined use case, let's create a workflow to handle test deployments when merged to develop or staging, and production level activities when merged to production.</p>
<pre><code class="language-yaml">name: Android Build &amp; Release

on:
  push:
    branches:
      - develop
      - staging
      - production

jobs:
  android:
    runs-on: ubuntu-latest
    env:
      FLUTTER_VERSION: 'stable'

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Java
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '11'

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}

      - name: Install dependencies
        run: flutter pub get

      - name: Determine environment
        id: env
        run: |
          echo "branch=\({GITHUB_REF##*/}" &gt;&gt; \)GITHUB_OUTPUT
          if [ "${GITHUB_REF##*/}" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $GITHUB_OUTPUT
          elif [ "${GITHUB_REF##*/}" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $GITHUB_OUTPUT
          else
            echo "ENV=production" &gt;&gt; $GITHUB_OUTPUT
          fi

      # Dev uses hardcoded values no secrets needed
      - name: Generate config (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"

      # Staging and production inject real secrets
      - name: Generate config (staging/production)
        if: steps.env.outputs.ENV != 'dev'
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
            ./scripts/generate_config.sh staging \
              "${{ secrets.STAGING_BASE_URL }}" \
              "${{ secrets.STAGING_API_KEY }}"
          else
            ./scripts/generate_config.sh production \
              "${{ secrets.PROD_BASE_URL }}" \
              "${{ secrets.PROD_API_KEY }}"
          fi

      # Keystore is only needed for signed builds (staging &amp; production)
      - name: Restore Keystore
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode &gt; android/app/upload-keystore.jks

      # Production builds are obfuscated + split debug info for Play Store
      - name: Build artifact
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "production" ]; then
            flutter build appbundle --release \
              --obfuscate \
              --split-debug-info=build/symbols
          else
            flutter build appbundle --release
          fi

      # Dev and staging go to Firebase App Distribution for internal testing
      - name: Upload to Firebase App Distribution
        if: steps.env.outputs.ENV == 'dev' || steps.env.outputs.ENV == 'staging'
        env:
          FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
          FIREBASE_ANDROID_APP_ID: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
          FIREBASE_GROUPS: ${{ secrets.FIREBASE_GROUPS }}
        run: |
          firebase appdistribution:distribute \
            build/app/outputs/bundle/release/app-release.aab \
            --app "$FIREBASE_ANDROID_APP_ID" \
            --groups "$FIREBASE_GROUPS" \
            --token "$FIREBASE_TOKEN"

      # Only production goes to the Play Store
      - name: Upload to Play Store
        if: steps.env.outputs.ENV == 'production'
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.your.package
          releaseFiles: build/app/outputs/bundle/release/app-release.aab
          track: production

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "Android Build &amp; Release PASSED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "Android Build &amp; Release FAILED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"
          echo "Check the logs and fix the issue before retrying"
</code></pre>
<p>This workflow ensures that whenever code lands on the <strong>develop, staging or production</strong> branch, this action is triggered on a fresh Ubuntu machine.</p>
<p>This is triggered by a simple push to any of the tracked branches, no manual intervention needed.</p>
<p>Let's walk through it piece by piece.</p>
<h3 id="heading-1-the-setup-phase">1. The Setup Phase</h3>
<p>Before any Flutter-specific work happens, the workflow lays the foundation:</p>
<ol>
<li><p><strong>Checkout</strong>: grabs the latest code from the branch that triggered the run (using the more modern <code>actions/checkout@v3</code>).</p>
</li>
<li><p><strong>Java 11 via Temurin</strong>: this is an upgrade from the first workflow we created. Instead of a generic <code>setup-java@v1</code>, this uses the <code>temurin</code> distribution which is the Eclipse's open-source JDK build. It's the current industry standard for Android toolchains.</p>
</li>
<li><p><strong>Flutter (stable)</strong>: this pulls the stable Flutter SDK, version pinned via an environment variable (<code>FLUTTER_VERSION: 'stable'</code>) defined at the job level.</p>
</li>
<li><p><strong>Install dependencies</strong>: this ensures we run <code>flutter pub get</code> to pull all packages</p>
</li>
</ol>
<h3 id="heading-2-environment-detection">2. Environment Detection</h3>
<p>This is where it gets interesting. This workflow also checks and determines the environment which will help us define the next set of instructions to run.</p>
<p>This command reads the branch name from <strong>GITHUB REF</strong> and maps it to its environment label which we already created in one of our helper scripts.</p>
<ul>
<li><p>develop → ENV=dev</p>
</li>
<li><p>staging → ENV=staging</p>
</li>
<li><p>production → ENV=production</p>
</li>
</ul>
<p>It strips the branch name from the full ref path using <code>\({GITHUB_REF##*/}</code>, then writes both the branch name and the resolved <code>ENV</code> value to <code>\)GITHUB_OUTPUT</code>, making them available as named outputs (<code>steps.env.outputs.ENV</code>) for every subsequent step.</p>
<p>This means the rest of the pipeline can branch its behaviour based on which environment it's building for, different API keys, different signing configs, different targets – whatever the app needs.</p>
<h3 id="heading-3-config-injection">3. Config Injection</h3>
<p>With the environment resolved, the next step is injecting the right configuration into the app. This is where the <code>generate_config.sh</code> script we built earlier gets called directly from the workflow.</p>
<p>For the <code>dev</code> environment, hardcoded placeholder values are used. No real secrets are needed, since this build is only meant for internal developer testing:</p>
<pre><code class="language-yaml">- name: Generate config (dev)
  if: steps.env.outputs.ENV == 'dev'
  run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"
</code></pre>
<p>For staging and production, however, real secrets are pulled from GitHub Actions secrets and passed directly into the script:</p>
<pre><code class="language-yaml">- name: Generate config (staging/production)
  if: steps.env.outputs.ENV != 'dev'
  run: |
    if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
      ./scripts/generate_config.sh staging \
        "${{ secrets.STAGING_BASE_URL }}" \
        "${{ secrets.STAGING_API_KEY }}"
    else
      ./scripts/generate_config.sh production \
        "${{ secrets.PROD_BASE_URL }}" \
        "${{ secrets.PROD_API_KEY }}"
    fi
</code></pre>
<p>Notice that these two steps use an <code>if</code> condition to make them mutually exclusive. Only one will ever run per job. This keeps the pipeline clean: no complicated branching logic inside the script itself, just a clear decision at the workflow level.</p>
<h3 id="heading-4-keystore-restoration">4. Keystore Restoration</h3>
<p>Android requires signed builds for distribution. The signing keystore file cannot be committed to the repository for obvious security reasons, so it's stored as a Base64-encoded GitHub secret and decoded at build time.</p>
<pre><code class="language-yaml">- name: Restore Keystore
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode &gt; android/app/upload-keystore.jks
</code></pre>
<p>This step is skipped entirely for the <code>dev</code> environment because dev builds are unsigned debug artifacts meant purely for internal testing on Firebase App Distribution. Only staging and production builds need to be properly signed.</p>
<p>To encode your keystore file as a Base64 string for storing in GitHub secrets, you have to run this locally:</p>
<pre><code class="language-yaml">base64 -i upload-keystore.jks | pbcopy
</code></pre>
<p>This copies the encoded string to your clipboard, which you can then paste directly into your GitHub repository secrets.</p>
<h3 id="heading-5-building-the-artifact">5. Building the Artifact</h3>
<p>With the environment configured and the keystore in place, the workflow builds the app bundle:</p>
<pre><code class="language-yaml">- name: Build artifact
  run: |
    if [ "${{ steps.env.outputs.ENV }}" = "production" ]; then
      flutter build appbundle --release \
        --obfuscate \
        --split-debug-info=build/symbols
    else
      flutter build appbundle --release
    fi
</code></pre>
<p>There's a deliberate difference between how production and non-production builds are compiled.</p>
<p>For production:</p>
<ul>
<li><p><code>--obfuscate</code> renames method and class names in the compiled output, making it significantly harder to reverse engineer the app</p>
</li>
<li><p><code>--split-debug-info=build/symbols</code> extracts the debug symbols into a separate directory at <code>build/symbols</code></p>
</li>
</ul>
<p>These symbols are what <code>upload_symbols.sh</code> later ships to Sentry, so obfuscated crash reports remain readable in your monitoring dashboard.</p>
<p>For dev and staging, neither flag is used. This keeps build times faster and makes local debugging easier since stack traces remain human-readable.</p>
<h3 id="heading-6-distributing-to-firebase-app-distribution">6. Distributing to Firebase App Distribution</h3>
<p>Once the app bundle is built, dev and staging builds are uploaded to Firebase App Distribution so testers can install them immediately:</p>
<pre><code class="language-yaml">- name: Upload to Firebase App Distribution
  if: steps.env.outputs.ENV == 'dev' || steps.env.outputs.ENV == 'staging'
  env:
    FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
    FIREBASE_ANDROID_APP_ID: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
    FIREBASE_GROUPS: ${{ secrets.FIREBASE_GROUPS }}
  run: |
    firebase appdistribution:distribute \
      build/app/outputs/bundle/release/app-release.aab \
      --app "$FIREBASE_ANDROID_APP_ID" \
      --groups "$FIREBASE_GROUPS" \
      --token "$FIREBASE_TOKEN"
</code></pre>
<p>Three secrets power this step:</p>
<ul>
<li><p><code>FIREBASE_TOKEN</code>: the authentication token generated from <code>firebase login:ci</code></p>
</li>
<li><p><code>FIREBASE_ANDROID_APP_ID</code>: the app identifier from the Firebase console</p>
</li>
<li><p><code>FIREBASE_GROUPS</code>: the tester group(s) that should receive the build notification</p>
</li>
</ul>
<p>Once this step completes, every tester in the specified groups receives an email with a direct download link. No one needs to manually share an APK file over Slack or email.</p>
<h3 id="heading-7-deploying-to-the-play-store">7. Deploying to the Play Store</h3>
<p>Production builds skip Firebase entirely and goes straight to the Google Play Store:</p>
<pre><code class="language-yaml">- name: Upload to Play Store
  if: steps.env.outputs.ENV == 'production'
  uses: r0adkll/upload-google-play@v1
  with:
    serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
    packageName: com.your.package
    releaseFiles: build/app/outputs/bundle/release/app-release.aab
    track: production
</code></pre>
<p>This uses the <code>r0adkll/upload-google-play</code> GitHub Action, which handles the Google Play API interaction under the hood. The only requirements are:</p>
<ul>
<li><p>A Google Play service account with the correct permissions, stored as a JSON secret</p>
</li>
<li><p>The correct package name matching what is registered in your Play Console</p>
</li>
<li><p>The <code>track</code> set to <code>production</code> (you can also use <code>internal</code>, <code>alpha</code>, or <code>beta</code> depending on your release strategy)</p>
</li>
</ul>
<p>Replace <code>com.your.package</code> with your actual application ID (the same one defined in your <code>build.gradle</code> file).</p>
<h3 id="heading-8-the-notification-layer">8. The Notification Layer</h3>
<p>Just like the PR checks workflow, this workflow reports its outcome clearly:</p>
<pre><code class="language-yaml">- name: Notify Team (Success)
  if: success()
  run: |
    echo "Android Build &amp; Release PASSED"
    echo "Environment: ${{ steps.env.outputs.ENV }}"
    echo "Branch: ${{ steps.env.outputs.branch }}"
    echo "By: @${{ github.actor }}"
    echo "Commit: ${{ github.sha }}"

- name: Notify Team (Failure)
  if: failure()
  run: |
    echo "Android Build &amp; Release FAILED"
    echo "Environment: ${{ steps.env.outputs.ENV }}"
    echo "Branch: ${{ steps.env.outputs.branch }}"
    echo "By: @${{ github.actor }}"
    echo "Commit: ${{ github.sha }}"
    echo "Check the logs and fix the issue before retrying 🔧"
</code></pre>
<p>The success notification includes the environment, branch, actor, and shares everything needed to trace exactly what was deployed and who triggered it.</p>
<p>The failure notification includes the same context, with a clear call to action.</p>
<h2 id="heading-workflow-3-iosyml">Workflow #3: iOS.yml</h2>
<p>iOS CI/CD is more complex than Android by nature. This is because Apple's signing requirements involve certificates, provisioning profiles, and entitlements that all need to be in the right place before Xcode will produce a valid archive.</p>
<p>Fastlane helps us handles all of that complexity, and the workflow simply calls into it.</p>
<p>Here is the full <code>ios.yml</code>:</p>
<pre><code class="language-yaml">name: iOS Build &amp; Release

on:
  push:
    branches:
      - develop
      - staging
      - production

jobs:
  ios:
    runs-on: macos-latest
    env:
      FLUTTER_VERSION: 'stable'

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}

      - name: Install dependencies
        run: flutter pub get

      - name: Determine environment
        id: env
        run: |
          echo "branch=\({GITHUB_REF##*/}" &gt;&gt; \)GITHUB_OUTPUT
          if [ "${GITHUB_REF##*/}" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $GITHUB_OUTPUT
          elif [ "${GITHUB_REF##*/}" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $GITHUB_OUTPUT
          else
            echo "ENV=production" &gt;&gt; $GITHUB_OUTPUT
          fi

      - name: Generate config (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"

      - name: Generate config (staging/production)
        if: steps.env.outputs.ENV != 'dev'
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
            ./scripts/generate_config.sh staging \
              "${{ secrets.STAGING_BASE_URL }}" \
              "${{ secrets.STAGING_API_KEY }}"
          else
            ./scripts/generate_config.sh production \
              "${{ secrets.PROD_BASE_URL }}" \
              "${{ secrets.PROD_API_KEY }}"
          fi

      - name: Install Fastlane
        run: |
          cd ios
          gem install bundler
          bundle install

      - name: Import signing certificate
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.IOS_CERTIFICATE_BASE64 }}" | base64 --decode &gt; ios/cert.p12
          security create-keychain -p "" build.keychain
          security import ios/cert.p12 -k build.keychain -P "${{ secrets.IOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign
          security list-keychains -s build.keychain
          security default-keychain -s build.keychain
          security unlock-keychain -p "" build.keychain
          security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain

      - name: Install provisioning profile
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode &gt; profile.mobileprovision
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/

      - name: Build iOS (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: flutter build ios --release --no-codesign

      - name: Build &amp; distribute to TestFlight (staging)
        if: steps.env.outputs.ENV == 'staging'
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
        run: |
          cd ios
          bundle exec fastlane beta

      - name: Build &amp; release to App Store (production)
        if: steps.env.outputs.ENV == 'production'
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
        run: |
          cd ios
          bundle exec fastlane release

      - name: Upload Sentry symbols (production only)
        if: steps.env.outputs.ENV == 'production'
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
        run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "iOS Build &amp; Release PASSED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "iOS Build &amp; Release FAILED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"
          echo "Check the logs and fix the issue before retrying 🔧"
</code></pre>
<p>Let's walk through what is different about this workflow compared to that of android.</p>
<h3 id="heading-1-macos-runner">1. MacOS Runner</h3>
<pre><code class="language-yaml">runs-on: macos-latest
</code></pre>
<p>This is the major difference.</p>
<p>iOS builds require Xcode, which only runs on macOS. GitHub Actions provides hosted macOS runners, but they are significantly more expensive in terms of compute minutes than Ubuntu runners. Just keep that in mind when thinking about build frequency.</p>
<p>No Java setup is needed here. Flutter on iOS compiles through Xcode directly, so the toolchain requirements are different.</p>
<h3 id="heading-2-installing-fastlane">2. Installing Fastlane</h3>
<pre><code class="language-yaml">- name: Install Fastlane
  run: |
    cd ios
    gem install bundler
    bundle install
</code></pre>
<p>Fastlane is a Ruby-based automation tool that handles certificate management, building, and uploading to TestFlight and the App Store.</p>
<p>This step navigates into the <code>ios/</code> directory and installs Fastlane along with all its dependencies as defined in the project's <code>Gemfile</code>.</p>
<p>Your <code>ios/Gemfile</code> should look something like this:</p>
<pre><code class="language-ruby">source "https://rubygems.org"

gem "fastlane"
</code></pre>
<p>And your <code>ios/fastlane/Fastfile</code> should define at minimum two lanes: one for staging (TestFlight) and one for production (App Store):</p>
<pre><code class="language-ruby">default_platform(:ios)

platform :ios do
  lane :beta do
    build_app(scheme: "Runner", export_method: "app-store")
    upload_to_testflight(skip_waiting_for_build_processing: true)
  end

  lane :release do
    build_app(scheme: "Runner", export_method: "app-store")
    upload_to_app_store(force: true, skip_screenshots: true, skip_metadata: true)
  end
end
</code></pre>
<h3 id="heading-3-certificate-and-provisioning-profile-setup">3. Certificate and Provisioning Profile Setup</h3>
<p>This is the step that trips most teams up the first time. Apple's code signing requires two things to be present on the machine:</p>
<ol>
<li><p>The signing certificate (a <code>.p12</code> file)</p>
</li>
<li><p>The provisioning profile</p>
</li>
</ol>
<p>Both are stored as Base64-encoded GitHub secrets and restored at build time.</p>
<pre><code class="language-yaml">- name: Import signing certificate
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.IOS_CERTIFICATE_BASE64 }}" | base64 --decode &gt; ios/cert.p12
    security create-keychain -p "" build.keychain
    security import ios/cert.p12 -k build.keychain -P "${{ secrets.IOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign
    security list-keychains -s build.keychain
    security default-keychain -s build.keychain
    security unlock-keychain -p "" build.keychain
    security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain
</code></pre>
<p>Breaking this down step by step:</p>
<ul>
<li><p>Decodes the Base64 certificate and write it to <code>cert.p12</code></p>
</li>
<li><p>Creates a temporary keychain called <code>build.keychain</code> with an empty password</p>
</li>
<li><p>Imports the certificate into that keychain, granting codesign access</p>
</li>
<li><p>Sets it as the default keychain so Xcode finds it automatically</p>
</li>
<li><p>Unlocks the keychain so it can be used non-interactively</p>
</li>
<li><p>Sets partition list to allow access without repeated prompts</p>
</li>
</ul>
<p>The provisioning profile step is simpler:</p>
<pre><code class="language-yaml">- name: Install provisioning profile
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode &gt; profile.mobileprovision
    mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
    cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
</code></pre>
<p>It decodes the profile and copies it into the exact directory where Xcode expects to find provisioning profiles on any macOS system.</p>
<p>To encode your certificate and profile locally, you can run these:</p>
<pre><code class="language-bash">base64 -i Certificates.p12 | pbcopy   # for the certificate
base64 -i YourApp.mobileprovision | pbcopy   # for the provisioning profile
</code></pre>
<h3 id="heading-4-building-for-each-environment">4. Building for Each Environment</h3>
<p>Dev builds skip signing entirely. They're built without code signing just to verify the project compiles correctly on a clean machine:</p>
<pre><code class="language-yaml">- name: Build iOS (dev)
  if: steps.env.outputs.ENV == 'dev'
  run: flutter build ios --release --no-codesign
</code></pre>
<p>Staging builds go through Fastlane's <code>beta</code> lane, which builds and uploads to TestFlight. Production builds go through Fastlane's <code>release</code> lane, which submits directly to App Store Connect.</p>
<p>Both staging and production steps consume the same three App Store Connect API key secrets: the key ID, the issuer ID, and the key content itself.</p>
<p>Fastlane uses these to authenticate with Apple's API without requiring a manual Apple ID login.</p>
<h3 id="heading-5-sentry-symbol-upload">5. Sentry Symbol Upload</h3>
<p>On production iOS builds, the <code>upload_symbols.sh</code> script runs after the build completes, passing the current short commit SHA as the release identifier:</p>
<pre><code class="language-yaml">- name: Upload Sentry symbols (production only)
  if: steps.env.outputs.ENV == 'production'
  env:
    SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
    SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
    SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
  run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
</code></pre>
<p>This is the same script explained earlier in the helper scripts section. It creates a Sentry release, uploads the debug information files, and finalizes the release. Any production crash from this point forward will map back to real, readable source code in your Sentry dashboard.</p>
<h2 id="heading-secrets-and-configuration-reference">Secrets and Configuration Reference</h2>
<p>For this entire pipeline to work, you need to configure the following secrets in your GitHub repository. Go to <strong>Settings → Secrets and variables → Actions → New repository secret</strong> to add each one.</p>
<p><strong>Shared (used across environments):</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>FIREBASE_TOKEN</code></td>
<td>Generated via <code>firebase login:ci</code> on your local machine</td>
</tr>
<tr>
<td><code>FIREBASE_ANDROID_APP_ID</code></td>
<td>Android app ID from your Firebase console</td>
</tr>
<tr>
<td><code>FIREBASE_GROUPS</code></td>
<td>Comma-separated tester group names in Firebase</td>
</tr>
<tr>
<td><code>SENTRY_AUTH_TOKEN</code></td>
<td>Auth token from your Sentry account settings</td>
</tr>
<tr>
<td><code>SENTRY_ORG</code></td>
<td>Your Sentry organization slug</td>
</tr>
<tr>
<td><code>SENTRY_PROJECT</code></td>
<td>Your Sentry project slug</td>
</tr>
</tbody></table>
<p><strong>Staging:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>STAGING_BASE_URL</code></td>
<td>Your staging API base URL</td>
</tr>
<tr>
<td><code>STAGING_API_KEY</code></td>
<td>Your staging API or encryption key</td>
</tr>
</tbody></table>
<p><strong>Production:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>PROD_BASE_URL</code></td>
<td>Your production API base URL</td>
</tr>
<tr>
<td><code>PROD_API_KEY</code></td>
<td>Your production API or encryption key</td>
</tr>
</tbody></table>
<p><strong>Android:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>ANDROID_KEYSTORE_BASE64</code></td>
<td>Base64-encoded <code>.jks</code> keystore file</td>
</tr>
<tr>
<td><code>GOOGLE_PLAY_SERVICE_ACCOUNT_JSON</code></td>
<td>Full JSON content of your Play Console service account</td>
</tr>
</tbody></table>
<p><strong>iOS:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>IOS_CERTIFICATE_BASE64</code></td>
<td>Base64-encoded <code>.p12</code> signing certificate</td>
</tr>
<tr>
<td><code>IOS_CERTIFICATE_PASSWORD</code></td>
<td>Password protecting the <code>.p12</code> file</td>
</tr>
<tr>
<td><code>IOS_PROVISIONING_PROFILE_BASE64</code></td>
<td>Base64-encoded <code>.mobileprovision</code> file</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_KEY_ID</code></td>
<td>Key ID from App Store Connect → Users &amp; Access → Keys</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_ISSUER_ID</code></td>
<td>Issuer ID from the same App Store Connect page</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_KEY_CONTENT</code></td>
<td>The full content of the downloaded <code>.p8</code> key file</td>
</tr>
</tbody></table>
<p>None of these values should ever appear in your codebase. If any secret is accidentally committed, rotate it immediately.</p>
<h2 id="heading-end-to-end-flow">End-to-End Flow</h2>
<p>With all three workflows in place, here is exactly what happens from the moment a developer opens a pull request to the moment a user receives an update:</p>
<h3 id="heading-1-developer-opens-a-pr-into-develop">1. Developer Opens a PR into <code>develop</code></h3>
<p>The <code>pr_checks.yml</code> workflow fires. It runs formatting checks, static analysis, and the full test suite. If anything fails, the PR cannot be merged and the team is notified immediately. The developer fixes the issues and pushes again, which triggers a fresh run.</p>
<h3 id="heading-2-pr-is-approved-and-merged-into-develop">2. PR is Approved and Merged into <code>develop</code></h3>
<p>The <code>android.yml</code> and <code>ios.yml</code> workflows both fire on the push event. They detect the environment as <code>dev</code>, inject placeholder config, build unsigned artifacts, and upload them to Firebase App Distribution. Testers receive an email and can install the build on their devices within minutes – no one shared a file manually.</p>
<h3 id="heading-3-develop-is-merged-into-staging">3. <code>develop</code> is Merged into <code>staging</code></h3>
<p>Both platform workflows fire again. This time the environment resolves to <code>staging</code>. Real secrets are injected, builds are properly signed, and the artifacts go to Firebase App Distribution (Android) and TestFlight (iOS). QA begins testing the staging build against the staging API.</p>
<h3 id="heading-4-staging-is-merged-into-production">4. <code>staging</code> is merged into <code>production</code></h3>
<p>Both workflows fire one final time. Production secrets are injected, builds are obfuscated and signed, debug symbols are uploaded to Sentry, and the final artifacts are submitted to the Google Play Store and App Store Connect. The release goes live on Apple and Google's review timelines with no further human intervention required.</p>
<p>From that first PR to a production submission, not a single command was run manually.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Building this pipeline is an upfront investment that pays off from the very first release cycle. What used to be a sequence of error-prone manual steps building locally, signing, uploading, switching configs, and hoping nothing was mixed up is now a fully automated, auditable, and repeatable process that runs the moment code moves between branches.</p>
<p>The architecture we built here does more than just automate builds. The PR quality gate enforces team standards consistently, so code review becomes a conversation about intent rather than a hunt for formatting issues. The environment-aware config injection eliminates an entire class of production incidents where staging keys made it into a live release. The Sentry symbol upload means your team can debug production crashes with full source visibility even from an obfuscated binary.</p>
<p>Every piece of this pipeline also runs locally. The helper scripts in the <code>scripts/</code> folder are plain Bash so you can call them from your terminal the same way CI calls them. This eliminates the frustrating cycle of pushing a commit just to test a pipeline change.</p>
<p>As your team grows, this foundation scales with you. You can extend the <code>pr_checks.yml</code> to enforce code coverage thresholds, add a performance benchmarking job, or introduce a dedicated security scanning step. You can extend the platform workflows to support multiple flavors, multiple Firebase projects, or staged rollouts on the Play Store. The architecture stays the same – you're just adding new steps to an already working system.</p>
<p>This ensures that standards are met, code quality remains high, you have a proper team structure, clear process and automated post development activities are in place – and at the end of the day, you'll have an optimized engineering approach that will help your team in so many ways.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Automate API Documentation Updates with GitHub Actions and OpenAPI Specifications ]]>
                </title>
                <description>
                    <![CDATA[ Maintaining up-to-date API documentation is often one of the biggest pain points for developers and teams. Too often, the API spec changes but the docs lag behind, leaving developers with outdated or inconsistent information. This frustrates consumer... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-automate-api-documentation-updates-with-github-actions-and-openapi-specifications/</link>
                <guid isPermaLink="false">68c0398aeff4d53c8494c5ab</guid>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github-actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ OpenApi ]]>
                    </category>
                
                    <category>
                        <![CDATA[ documentation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ EZINNE ANNE EMILIA ]]>
                </dc:creator>
                <pubDate>Tue, 09 Sep 2025 14:28:26 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1757428080226/175085d0-cfea-41a0-aa52-a50ad8212980.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Maintaining up-to-date API documentation is often one of the biggest pain points for developers and teams. Too often, the API spec changes but the docs lag behind, leaving developers with outdated or inconsistent information. This frustrates consumers of your API and increases support overhead.</p>
<p>This is where automation comes in. By combining OpenAPI specifications with GitHub Actions, you can ensure your documentation is always in sync with your API changes.</p>
<ul>
<li><p><strong>OpenAPI</strong> acts as the single reference point for your API design, keeping your docs consistent, accurate, and aligned with your API.</p>
</li>
<li><p><strong>GitHub Actions</strong> automates the workflow, validating your spec, building docs, and publishing to GitHub Pages in seconds.</p>
</li>
</ul>
<p>This tutorial walks you through a working example of how to use GitHub Actions to auto-update your docs.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-your-repository">How to set up your repository</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-create-the-openapi-specification">How to Create the OpenAPI Specification</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-test-the-api-spec-locally">How to Test the API Spec Locally</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-install-tools">Install tools</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-create-a-landing-page">Create a landing page</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-validate-your-spec">Validate Your Spec</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-preview-in-the-browser">Preview in the Browser</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-push-local-changes-to-github">How to Push Local Changes to GitHub</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-your-github-actions-workflow">How to Set Up Your GitHub Actions Workflow</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-github-pages">How to Set Up GitHub Pages</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-is-github-pages">What is GitHub Pages?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-setting-up-github-pages">Setting Up GitHub Pages</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-handle-multiple-versions">How to Handle Multiple Versions</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-about-the-versions">About the Versions</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-version-1-v1">Version 1 (v1)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-version-2-v2">Version 2 (v2)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-version-3-v3">Version 3 (v3)</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-the-versions-locally">How to Set Up the Versions Locally</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-validate-the-api-specs">How to Validate the API Specs</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-update-the-github-actions-workflow">How to Update the GitHub Actions Workflow</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p><a target="_blank" href="https://docs.npmjs.com/downloading-and-installing-node-js-and-npm">Node.js and npm installed.</a></p>
</li>
<li><p><a target="_blank" href="https://www.freecodecamp.org/news/gitting-things-done-book/">A GitHub account with basic Git knowledge.</a></p>
</li>
<li><p><a target="_blank" href="https://code.visualstudio.com/download">Visual Studio Code Editor</a>.</p>
</li>
<li><p><a target="_blank" href="https://idratherbewriting.com/learnapidoc/">Basic knowledge of documentation and OpenAPI</a>.</p>
</li>
</ul>
<h2 id="heading-how-to-set-up-your-repository">How to Set Up Your Repository</h2>
<p>If you don’t already have one, create a GitHub repository. For this tutorial, I’ll use <code>api-docs</code> as the repo name.</p>
<p>Then open VSCode and create a folder with the same name.</p>
<h2 id="heading-how-to-create-the-openapi-specification">How to Create the OpenAPI Specification</h2>
<p>Inside the folder you just created, create a folder called <code>spec</code> and add a file named <code>greetings.yaml</code> with the following content:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">openapi:</span> <span class="hljs-number">3.0</span><span class="hljs-number">.3</span>
<span class="hljs-attr">info:</span>
  <span class="hljs-attr">title:</span> <span class="hljs-string">Greetings</span> <span class="hljs-string">API</span>
  <span class="hljs-attr">version:</span> <span class="hljs-number">1.0</span><span class="hljs-number">.0</span>
  <span class="hljs-attr">description:</span> <span class="hljs-string">This</span> <span class="hljs-string">is</span> <span class="hljs-string">a</span> <span class="hljs-string">greetings</span> <span class="hljs-string">API</span> <span class="hljs-string">demonstrating</span> <span class="hljs-string">a</span> <span class="hljs-string">simple</span> <span class="hljs-string">greeting</span> <span class="hljs-string">endpoint</span> <span class="hljs-string">with</span> <span class="hljs-string">query</span> <span class="hljs-string">parameters</span> <span class="hljs-string">and</span> <span class="hljs-string">multilingual</span> <span class="hljs-string">support.</span>
  <span class="hljs-attr">license:</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">MIT</span>
    <span class="hljs-attr">url:</span> <span class="hljs-string">https://opensource.org/licenses/MIT</span>
<span class="hljs-attr">servers:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">url:</span> <span class="hljs-string">https://api.yourdomain.com/v1</span>
    <span class="hljs-attr">description:</span> <span class="hljs-string">Production</span> <span class="hljs-string">server(v1)</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">url:</span> <span class="hljs-string">https://staging.yourdomain.com/v1</span>
    <span class="hljs-attr">description:</span> <span class="hljs-string">Staging</span> <span class="hljs-string">server(v1)</span>
<span class="hljs-attr">security:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">api_key:</span> []
<span class="hljs-attr">paths:</span>
  <span class="hljs-string">/hello:</span>
    <span class="hljs-attr">get:</span>
      <span class="hljs-attr">summary:</span> <span class="hljs-string">Returns</span> <span class="hljs-string">a</span> <span class="hljs-string">greeting</span>
      <span class="hljs-attr">operationId:</span> <span class="hljs-string">getGreeting</span>
      <span class="hljs-attr">parameters:</span>
        <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">name</span>
          <span class="hljs-attr">in:</span> <span class="hljs-string">query</span>
          <span class="hljs-attr">required:</span> <span class="hljs-literal">false</span>
          <span class="hljs-attr">description:</span> <span class="hljs-string">Name</span> <span class="hljs-string">of</span> <span class="hljs-string">the</span> <span class="hljs-string">person</span> <span class="hljs-string">to</span> <span class="hljs-string">greet</span>
          <span class="hljs-attr">schema:</span>
            <span class="hljs-attr">type:</span> <span class="hljs-string">string</span>
            <span class="hljs-attr">example:</span> <span class="hljs-string">Ezinne</span>
        <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">lang</span>
          <span class="hljs-attr">in:</span> <span class="hljs-string">query</span>
          <span class="hljs-attr">required:</span> <span class="hljs-literal">false</span>
          <span class="hljs-attr">description:</span> <span class="hljs-string">Language</span> <span class="hljs-string">of</span> <span class="hljs-string">the</span> <span class="hljs-string">greeting</span> <span class="hljs-string">(default</span> <span class="hljs-string">is</span> <span class="hljs-string">English)</span>
          <span class="hljs-attr">schema:</span>
            <span class="hljs-attr">type:</span> <span class="hljs-string">string</span>
            <span class="hljs-attr">enum:</span> [<span class="hljs-string">en</span>, <span class="hljs-string">fr</span>, <span class="hljs-string">es</span>, <span class="hljs-string">ig</span>]
            <span class="hljs-attr">example:</span> <span class="hljs-string">en</span>
      <span class="hljs-attr">responses:</span>
        <span class="hljs-attr">'200':</span>
          <span class="hljs-attr">description:</span> <span class="hljs-string">Successful</span> <span class="hljs-string">response</span>
          <span class="hljs-attr">content:</span>
            <span class="hljs-attr">application/json:</span>
              <span class="hljs-attr">schema:</span>
                <span class="hljs-attr">type:</span> <span class="hljs-string">object</span>
                <span class="hljs-attr">properties:</span>
                  <span class="hljs-attr">message:</span>
                    <span class="hljs-attr">type:</span> <span class="hljs-string">string</span>
              <span class="hljs-attr">examples:</span>
                <span class="hljs-attr">english:</span>
                  <span class="hljs-attr">value:</span> { <span class="hljs-attr">message:</span> <span class="hljs-string">"Hello, Ezinne!"</span> }
                <span class="hljs-attr">french:</span>
                  <span class="hljs-attr">value:</span> { <span class="hljs-attr">message:</span> <span class="hljs-string">"Bonjour, Ezinne!"</span> }
                <span class="hljs-attr">spanish:</span>
                  <span class="hljs-attr">value:</span> { <span class="hljs-attr">message:</span> <span class="hljs-string">"¡Hola, Ezinne!"</span> }
                <span class="hljs-attr">igbo:</span>
                  <span class="hljs-attr">value:</span> { <span class="hljs-attr">message:</span> <span class="hljs-string">"Ndeewo, Ezinne!"</span> }
<span class="hljs-attr">components:</span>
  <span class="hljs-attr">securitySchemes:</span>
    <span class="hljs-attr">api_key:</span>
      <span class="hljs-attr">type:</span> <span class="hljs-string">apiKey</span>
      <span class="hljs-attr">name:</span> <span class="hljs-string">Authorization</span>
      <span class="hljs-attr">in:</span> <span class="hljs-string">header</span>
</code></pre>
<p>This is a simple spec with multilingual greetings. As your API grows (say more languages or versions), keeping docs in sync manually might get tedious. That’s why automation helps.</p>
<h2 id="heading-how-to-test-the-api-spec-locally">How to Test the API Spec Locally</h2>
<h3 id="heading-install-tools">Install tools:</h3>
<p>Before setting GitHub Actions, you can test the API Spec locally on your machine by setting up <a target="_blank" href="https://github.com/Redocly">Redocly</a> (used to be called Redoc) and testing it in an HTML environment.</p>
<p>Redocly is a lightweight, customizable tool to render OpenAPI specs as an interactive HTML documentation. It’s ideal for static site deployment which makes it ideal for this scenario.</p>
<ul>
<li><p>Install Redoc globally with <code>npm install -g @redocly/cli</code></p>
</li>
<li><p>Install http-server globally with <code>npm install -g http-server</code></p>
</li>
</ul>
<p>The http-server is a local server you can use to test the doc on your machine before you push to GitHub and deploy to GitHub Pages.</p>
<h3 id="heading-create-a-landing-page">Create a landing page:</h3>
<p>In your project, make a <code>docs</code> folder and add <code>index.html</code>:</p>
<pre><code class="lang-xml"><span class="hljs-meta">&lt;!DOCTYPE <span class="hljs-meta-keyword">html</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">html</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">title</span>&gt;</span>API Documentation<span class="hljs-tag">&lt;/<span class="hljs-name">title</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">charset</span>=<span class="hljs-string">"utf-8"</span>/&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"viewport"</span> <span class="hljs-attr">content</span>=<span class="hljs-string">"width=device-width, initial-scale=1"</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">body</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">redoc</span> <span class="hljs-attr">spec-url</span>=<span class="hljs-string">"../spec/greetings.yaml"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">redoc</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">body</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">html</span>&gt;</span>
</code></pre>
<h3 id="heading-validate-your-spec">Validate Your Spec:</h3>
<p><code>redocly lint spec/greetings.yaml</code></p>
<p>You should see this if there are no errors or warnings:</p>
<pre><code class="lang-powershell">Woohoo! Your API description is valid. 🎉
</code></pre>
<p><strong>Note:</strong> Validating your API Spec before testing is important as it’ll flag any possible errors. This is because Redocly will fail to run the preview if there are any errors in your spec. </p>
<h3 id="heading-preview-in-the-browser">Preview in the browser:</h3>
<p>Run <code>http-server</code>, and you should see this in the terminal:</p>
<pre><code class="lang-powershell">Starting up http<span class="hljs-literal">-server</span>, serving ./
Available on:
  http://<span class="hljs-number">127.0</span>.<span class="hljs-number">0.1</span>:<span class="hljs-number">8080</span>
  http://<span class="hljs-number">192.168</span>.x.x:<span class="hljs-number">8080</span>
Hit CTRL<span class="hljs-literal">-C</span> to stop the server
</code></pre>
<p>Open <a target="_blank" href="http://localhost:8080/docs/index.html"><code>http://127.0.0.1:8080/</code></a> and navigate to <code>/docs</code> to see your docs.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1756983802999/944b8603-7b2e-477a-8156-fdaa60f7e0af.png" alt="A preview of the API Specification in a Html page" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<h2 id="heading-how-to-push-local-changes-to-github">How to Push Local Changes to GitHub</h2>
<p>After making local changes, you need to set up the API documentation so it can update automatically whenever you make changes.</p>
<p>Run these commands if you are pushing to the repository for the first time:</p>
<pre><code class="lang-powershell">git init
git add .
git commit <span class="hljs-literal">-m</span> <span class="hljs-string">"first commit"</span>
git branch <span class="hljs-literal">-M</span> main
git remote add origin &lt;your<span class="hljs-literal">-repo</span><span class="hljs-literal">-url</span>&gt;
git push <span class="hljs-literal">-u</span> origin main
</code></pre>
<h2 id="heading-how-to-set-up-your-github-actions-workflow">How to Set Up Your GitHub Actions Workflow</h2>
<p>You can set up your GitHub workflow by creating a few folders.</p>
<p>First, create <code>.github/workflows/</code> in the <code>api-docs</code> folder. Then, inside the <code>workflows</code> folder, create a <code>docs.yml</code>. This is the workflow file that will serve as a trigger to run validation, generate the HTML with Redocly, and deploy to GitHub Pages at the same time.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">name:</span> <span class="hljs-string">Build</span> <span class="hljs-string">API</span> <span class="hljs-string">Documentation</span> <span class="hljs-string">and</span> <span class="hljs-string">Deploy</span> <span class="hljs-string">to</span> <span class="hljs-string">GitHub</span> <span class="hljs-string">Pages</span>

<span class="hljs-attr">on:</span>
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">main</span>
    <span class="hljs-attr">paths:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">'spec/greetings.yaml'</span>

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">build-spec:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">permissions:</span>
      <span class="hljs-attr">contents:</span> <span class="hljs-string">write</span> <span class="hljs-comment"># needed for gh-pages deployment</span>

    <span class="hljs-attr">steps:</span>
      <span class="hljs-comment"># 1. Checkout repository</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Checkout</span> <span class="hljs-string">code</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v4</span>

      <span class="hljs-comment"># 2. Set up Node.js</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Set</span> <span class="hljs-string">up</span> <span class="hljs-string">Node.js</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-node@v4</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">node-version:</span> <span class="hljs-string">'20'</span>

      <span class="hljs-comment"># 3. Install Redocly CLI</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Install</span> <span class="hljs-string">Redocly</span> <span class="hljs-string">CLI</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">install</span> <span class="hljs-string">-g</span> <span class="hljs-string">@redocly/cli</span>

      <span class="hljs-comment"># 4. Validate OpenAPI spec</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Validate</span> <span class="hljs-string">OpenAPI</span> <span class="hljs-string">Spec</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">redocly</span> <span class="hljs-string">lint</span> <span class="hljs-string">spec/greetings.yaml</span>

      <span class="hljs-comment"># 5. Build output directory</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Create</span> <span class="hljs-string">build</span> <span class="hljs-string">directory</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">mkdir</span> <span class="hljs-string">-p</span> <span class="hljs-string">public</span>

      <span class="hljs-comment"># 6. Copy spec</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Copy</span> <span class="hljs-string">spec</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">mkdir</span> <span class="hljs-string">-p</span> <span class="hljs-string">public/spec</span> <span class="hljs-string">&amp;&amp;</span> <span class="hljs-string">cp</span> <span class="hljs-string">spec/greetings.yaml</span> <span class="hljs-string">public/spec/</span>

      <span class="hljs-comment"># 7. Copy landing page</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Copy</span> <span class="hljs-string">landing</span> <span class="hljs-string">page</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">cp</span> <span class="hljs-string">docs/index.html</span> <span class="hljs-string">public/index.html</span>

      <span class="hljs-comment"># 8. Deploy to GitHub Pages</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Deploy</span> <span class="hljs-string">to</span> <span class="hljs-string">GitHub</span> <span class="hljs-string">Pages</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">peaceiris/actions-gh-pages@v4</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">github_token:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.GITHUB_TOKEN</span> <span class="hljs-string">}}</span>
          <span class="hljs-attr">publish_dir:</span> <span class="hljs-string">./public</span>
</code></pre>
<p>Here’s what’s going on in this code:</p>
<ul>
<li><p>Runs when changes are pushed to <code>main</code> that affect <code>spec/greetings.yaml</code>.</p>
</li>
<li><p>Checks out the repo code.</p>
</li>
<li><p>Sets up Node.js and installs Redocly.</p>
</li>
<li><p>Validates your OpenAPI spec (so broken specs won’t deploy).</p>
</li>
<li><p>Copies the spec and index page into a <code>public/</code> folder.</p>
</li>
<li><p>Deploys <code>public/</code> to the <code>gh-pages</code> branch with GitHub Pages.</p>
</li>
</ul>
<p>Since we’re done with local testing, update the file path in the <code>index.html</code>:</p>
<pre><code class="lang-xml"><span class="hljs-meta">&lt;!DOCTYPE <span class="hljs-meta-keyword">html</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">html</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">title</span>&gt;</span>API Documentation<span class="hljs-tag">&lt;/<span class="hljs-name">title</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">charset</span>=<span class="hljs-string">"utf-8"</span>/&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"viewport"</span> <span class="hljs-attr">content</span>=<span class="hljs-string">"width=device-width, initial-scale=1"</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">body</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">redoc</span> <span class="hljs-attr">spec-url</span>=<span class="hljs-string">"./spec/greetings.yaml"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">redoc</span>&gt;</span> <span class="hljs-comment">&lt;!--update the filepath to match your gh config--&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">body</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">html</span>&gt;</span>
</code></pre>
<p>This is so the <code>public</code> directory in the workflow will be able to access it correctly.</p>
<p>This workflow will only run when it detects changes in the API Spec (<code>greetings.yml</code>). To see the workflow in action, make a minor edit in the <code>greetings.yaml</code>.</p>
<p>Push the changes to your GitHub repository:</p>
<pre><code class="lang-powershell">git add .
git commit <span class="hljs-literal">-m</span> <span class="hljs-string">'add changes'</span>
git push
</code></pre>
<h2 id="heading-how-to-set-up-github-pages">How to Set Up GitHub Pages</h2>
<h3 id="heading-what-is-github-pages">What is GitHub Pages?</h3>
<p><a target="_blank" href="https://docs.github.com/en/pages/getting-started-with-github-pages/what-is-github-pages">GitHub Pages</a> is a hosting platform owned by GitHub where you can host websites directly from your GitHub account. This means you can publish static sites on the internet using a GitHub domain and anyone with the website link can access it.</p>
<p>There are other hosting platforms you can use to deploy static websites such as <a target="_blank" href="https://www.netlify.com/">Netlify</a> and <a target="_blank" href="https://vercel.com/">Vercel</a>. But using GitHub Pages for this documentation is easier to set up as it’s on the same platform.</p>
<h3 id="heading-setting-up-github-pages">Setting up GitHub Pages</h3>
<p>Set up GitHub Pages by clicking on the Settings tab in your repository.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1756985360548/fa3518a7-0b44-4c7b-ae7f-d0e0b17a84c6.png" alt="A preview of the settings tab in the `api-docs` repository" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>Under Source, choose:</p>
<ul>
<li><p>Deploy from branch: <code>gh-pages</code></p>
</li>
<li><p>Folder: <code>/ (root)</code></p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1756985446692/a4774bcc-1a42-49f8-a9fd-8ca9339808ef.png" alt="A step-by-step preview of the gh-pages and root setup" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>Then save and wait for the workflow to finish.</p>
<p>Your docs will be live at: <code>https://&lt;username&gt;.github.io/api-docs</code>.</p>
<h2 id="heading-how-to-handle-multiple-versions">How to Handle Multiple Versions</h2>
<p>What if you had multiple API versions to update? Let’s assume the simple greetings API in this tutorial had more features added to it across different versions. In this case, you can manage the APIs for the different versions in a single page and also build and deploy it automatically. </p>
<h3 id="heading-about-the-versions">About the Versions</h3>
<h4 id="heading-version-1-v1">Version 1 (v1)</h4>
<p>This is the starting point which is <code>greetings.yaml</code>. The API only has a single <code>/hello</code> endpoint that returns a greeting in four languages (English, French, Spanish, or Igbo).</p>
<h4 id="heading-version-2-v2">Version 2 (v2)</h4>
<p>In version 2, the API adds create and read features. You can:</p>
<ul>
<li><p>Use <code>POST /hello</code> to create and save a greeting.</p>
</li>
<li><p>Retrieve greetings by their unique ID with <code>GET /hello/{id}</code>.</p>
</li>
</ul>
<h4 id="heading-version-3-v3">Version 3 (v3)</h4>
<p>Version 3 builds on top of v2 by adding an update functionality. Along with creating and retrieving greetings, you can now update an existing greeting using <code>PUT /hello/{id}</code>.</p>
<h3 id="heading-how-to-set-up-the-versions-locally">How to Set Up the Versions Locally</h3>
<p>First, create a <code>v1</code> folder and move the <code>greetings.yaml</code> file to it. Since we are going to be using versions, you can delete the existing <code>spec</code> folder.</p>
<p>Then, create a <code>v2</code> folder and create a <code>greetings-v2.yaml</code> file. <a target="_blank" href="https://ezinneanne.github.io/api-doc/v2/greetings-v2.yaml">Get the greetings API for version 2 here</a>.</p>
<p>Next, create a <code>v3</code> folder and add <code>greetings-v3.yaml</code> file. <a target="_blank" href="https://ezinneanne.github.io/api-doc/v3/greetings-v3.yaml">Get the greetings API for version 3 here</a>.</p>
<p>To follow the same pattern with others, rename the version 1 file to <code>greetings-v1.yaml</code>. Then update your <code>index.html</code> to accommodate the other two versions.</p>
<pre><code class="lang-xml"><span class="hljs-meta">&lt;!DOCTYPE <span class="hljs-meta-keyword">html</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">html</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">title</span>&gt;</span>API Documentation<span class="hljs-tag">&lt;/<span class="hljs-name">title</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">charset</span>=<span class="hljs-string">"utf-8"</span>/&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"viewport"</span> <span class="hljs-attr">content</span>=<span class="hljs-string">"width=device-width, initial-scale=1"</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">style</span>&gt;</span><span class="css">
      <span class="hljs-selector-tag">body</span> {
        <span class="hljs-attribute">font-family</span>: Arial, sans-serif;
        <span class="hljs-attribute">margin</span>: <span class="hljs-number">0</span>;
      }
      <span class="hljs-selector-tag">header</span> {
        <span class="hljs-attribute">background</span>: <span class="hljs-number">#2c3e50</span>;
        <span class="hljs-attribute">color</span>: white;
        <span class="hljs-attribute">padding</span>: <span class="hljs-number">1rem</span>;
        <span class="hljs-attribute">display</span>: flex;
        <span class="hljs-attribute">justify-content</span>: space-between;
        <span class="hljs-attribute">align-items</span>: center;
      }
      <span class="hljs-selector-tag">select</span> {
        <span class="hljs-attribute">padding</span>: <span class="hljs-number">0.4rem</span>;
        <span class="hljs-attribute">font-size</span>: <span class="hljs-number">1rem</span>;
      }
    </span><span class="hljs-tag">&lt;/<span class="hljs-name">style</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">body</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">header</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">h2</span>&gt;</span>API Documentation<span class="hljs-tag">&lt;/<span class="hljs-name">h2</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">for</span>=<span class="hljs-string">"version"</span>&gt;</span>Version: <span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">select</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"version"</span> <span class="hljs-attr">onchange</span>=<span class="hljs-string">"loadSpec()"</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">option</span> <span class="hljs-attr">value</span>=<span class="hljs-string">"./v1/greetings-v1.yaml"</span>&gt;</span>v1<span class="hljs-tag">&lt;/<span class="hljs-name">option</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">option</span> <span class="hljs-attr">value</span>=<span class="hljs-string">"./v2/greetings-v2.yaml"</span>&gt;</span>v2<span class="hljs-tag">&lt;/<span class="hljs-name">option</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">option</span> <span class="hljs-attr">value</span>=<span class="hljs-string">"./v3/greetings-v3.yaml"</span>&gt;</span>v3<span class="hljs-tag">&lt;/<span class="hljs-name">option</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">select</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">header</span>&gt;</span>

    <span class="hljs-comment">&lt;!-- ReDoc container --&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"redoc-container"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

    <span class="hljs-comment">&lt;!-- ReDoc script --&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">script</span>&gt;</span><span class="javascript">
      <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">loadSpec</span>(<span class="hljs-params"></span>) </span>{
        <span class="hljs-keyword">const</span> version = <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"version"</span>).value;
        Redoc.init(version, {}, <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"redoc-container"</span>));
      }
      <span class="hljs-comment">// Load default (v1) on first load</span>
      <span class="hljs-built_in">window</span>.onload = loadSpec;
    </span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">body</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">html</span>&gt;</span>
</code></pre>
<h3 id="heading-how-to-validate-the-api-specs">How to Validate the API Specs</h3>
<p>Earlier in this article, I mentioned testing your specification locally. Now that you have two more versions of the greetings API, run the test to highlight and fix any existing errors.</p>
<ul>
<li><p>For the version V2: <code>redocly lint v2/greetings-v2.yaml</code></p>
</li>
<li><p>For the version V3: <code>redocly lint v3/greetings-v3.yaml</code></p>
</li>
</ul>
<h3 id="heading-how-to-update-the-github-actions-workflow">How to Update the GitHub Actions Workflow</h3>
<p>Now that you have three API Spec versions, you need to update your workflow so it will monitor the three spec files and the HTML document for changes, and then push and deploy them to GitHub Pages as well.</p>
<p>Add this to your <code>.github/workflows/docs.yml</code>:</p>
<pre><code class="lang-yaml"><span class="hljs-comment"># Name of the workflow</span>
<span class="hljs-attr">name:</span> <span class="hljs-string">Build</span> <span class="hljs-string">and</span> <span class="hljs-string">Deploy</span> <span class="hljs-string">API</span> <span class="hljs-string">Documentation</span>

<span class="hljs-attr">on:</span>
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span> [ <span class="hljs-string">main</span> ]
    <span class="hljs-attr">paths:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">'docs/index.html'</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">'v1/greetings-v1.yaml'</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">'v2/greetings-v2.yaml'</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">'v3/greetings-v3.yaml'</span>

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">build-and-deploy:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>

    <span class="hljs-attr">permissions:</span>
      <span class="hljs-attr">contents:</span> <span class="hljs-string">write</span>

    <span class="hljs-attr">steps:</span>
      <span class="hljs-comment"># 1. Checkout the repository</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Checkout</span> <span class="hljs-string">repository</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v4</span>

      <span class="hljs-comment"># 2. Create build directory</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Create</span> <span class="hljs-string">build</span> <span class="hljs-string">directory</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">mkdir</span> <span class="hljs-string">-p</span> <span class="hljs-string">public</span>

      <span class="hljs-comment"># 3. Copy YAML specs into public folder</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Copy</span> <span class="hljs-string">v1</span> <span class="hljs-string">spec</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">mkdir</span> <span class="hljs-string">-p</span> <span class="hljs-string">public/v1</span> <span class="hljs-string">&amp;&amp;</span> <span class="hljs-string">cp</span> <span class="hljs-string">v1/greetings-v1.yaml</span> <span class="hljs-string">public/v1/</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Copy</span> <span class="hljs-string">v2</span> <span class="hljs-string">spec</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">mkdir</span> <span class="hljs-string">-p</span> <span class="hljs-string">public/v2</span> <span class="hljs-string">&amp;&amp;</span> <span class="hljs-string">cp</span> <span class="hljs-string">v2/greetings-v2.yaml</span> <span class="hljs-string">public/v2/</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Copy</span> <span class="hljs-string">v3</span> <span class="hljs-string">spec</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">mkdir</span> <span class="hljs-string">-p</span> <span class="hljs-string">public/v3</span> <span class="hljs-string">&amp;&amp;</span> <span class="hljs-string">cp</span> <span class="hljs-string">v3/greetings-v3.yaml</span> <span class="hljs-string">public/v3/</span>

      <span class="hljs-comment"># 4. Copy landing page into public</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Copy</span> <span class="hljs-string">landing</span> <span class="hljs-string">page</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">cp</span> <span class="hljs-string">docs/index.html</span> <span class="hljs-string">public/index.html</span>

      <span class="hljs-comment"># 5. Deploy to GitHub Pages</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Deploy</span> <span class="hljs-string">to</span> <span class="hljs-string">GitHub</span> <span class="hljs-string">Pages</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">peaceiris/actions-gh-pages@v4</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">github_token:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.GITHUB_TOKEN</span> <span class="hljs-string">}}</span>
          <span class="hljs-attr">publish_dir:</span> <span class="hljs-string">./public</span>
</code></pre>
<p>And finally, push the changes and reload the site. This should showcase the updated documentation.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1756986868235/9de187f1-12c4-46ca-a73b-daafa353ed1f.png" alt="A preview of the API documentation in a hosted GitHub Pages environment" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<h2 id="heading-summary">Summary</h2>
<p>In this tutorial, you have learned how to auto-update your API docs. We started with a single OpenAPI spec and a basic HTML page rendered by Redocly, and tested it locally. We then set up GitHub Actions to automatically validate the spec, copy the files, and deploy the docs to GitHub Pages. Finally, we extended the setup to handle multiple API versions in one place.</p>
<p>With this workflow, your documentation stays accurate, up-to-date, and hassle-free so every change you make to your API spec goes live when you push the changes.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Automate Flutter Testing and Builds with GitHub Actions for Android and iOS ]]>
                </title>
                <description>
                    <![CDATA[ GitHub Actions is a CI/CD (Continuous Integration and Continuous Deployment) tool built directly into GitHub. It allows developers to define workflows, which are sequences of automated steps triggered by events such as pushing code, opening pull requ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-automate-flutter-testing-and-builds-with-github-actions-for-android-and-ios/</link>
                <guid isPermaLink="false">68a796b236a9f930ef67366b</guid>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ flutter-aware ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github-actions ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Atuoha Anthony ]]>
                </dc:creator>
                <pubDate>Thu, 21 Aug 2025 21:59:14 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1755808732085/6fdd754a-39d4-40d1-8dea-0eb16cc45063.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>GitHub Actions is a CI/CD (Continuous Integration and Continuous Deployment) tool built directly into GitHub. It allows developers to define <strong>workflows</strong>, which are sequences of automated steps triggered by events such as pushing code, opening pull requests, or creating releases.</p>
<p>For Flutter developers, GitHub Actions is a powerful way to automate testing, builds, and deployment across multiple platforms.</p>
<p>This guide will walk you through setting up GitHub Actions for a Flutter project, covering everything from prerequisites to detailed explanations of the workflow.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a class="post-section-overview" href="#heading-why-use-github-actions-in-flutter-development">Why Use GitHub Actions in Flutter Development?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-1-create-a-new-flutter-project">Step 1: Create a New Flutter Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-2-push-the-project-to-github">Step 2: Push the Project to GitHub</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-3-create-a-github-actions-workflow">Step 3: Create a GitHub Actions Workflow</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-triggers">Triggers</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-jobs">Jobs</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-step-4-generate-and-add-a-github-token">Step 4: Generate and Add a GitHub Token</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-5-understanding-the-workflow">Step 5: Understanding the Workflow</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-flutter-test-job">Flutter Test Job</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-ios-app-build-job">iOS App Build Job</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-android-apk-build-job">Android APK Build Job</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-step-6-push-and-enable-the-workflow">Step 6: Push and Enable the Workflow</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-final-notes">Final Notes</a></p>
</li>
</ol>
<h2 id="heading-why-use-github-actions-in-flutter-development">Why Use GitHub Actions in Flutter Development?</h2>
<p>GitHub Actions automated testing ensures that all code changes are validated with unit and integration tests. Continuous integration builds Flutter apps automatically to confirm that new code integrates correctly.</p>
<p>Code analysis and linting can run automatically to enforce style and maintain code quality. Automated releases streamline the process of packaging and distributing apps. Custom workflows can be tailored to fit project-specific needs. Collaboration is also improved because developers can see workflow results directly in pull requests.</p>
<p>By introducing GitHub Actions, Flutter projects become more reliable, maintainable, and efficient.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before setting up GitHub Actions for your Flutter project, make sure you have:</p>
<ol>
<li><p><strong>Flutter SDK installed locally</strong> so you can create and test the project before pushing to GitHub.</p>
</li>
<li><p><strong>Git installed</strong> to manage version control and push your project to GitHub.</p>
</li>
<li><p><strong>A GitHub account</strong> and a <strong>new repository</strong> created for your Flutter project.</p>
</li>
<li><p><strong>Basic understanding of YAML syntax</strong>, since workflows are defined in <code>.yml</code> files.</p>
</li>
<li><p><strong>A GitHub personal access token</strong> (PAT) for releasing builds, which will be stored as a repository secret.</p>
</li>
</ol>
<h2 id="heading-step-1-create-a-new-flutter-project">Step 1: Create a New Flutter Project</h2>
<p>Start by creating a new Flutter project and navigating into it:</p>
<pre><code class="lang-bash">flutter create gh_flutter
<span class="hljs-built_in">cd</span> gh_flutter
</code></pre>
<p>Replace <code>gh_flutter</code> with your preferred project name. This initializes a Flutter project with the default structure and dependencies.</p>
<h2 id="heading-step-2-push-the-project-to-github">Step 2: Push the Project to GitHub</h2>
<p>Initialize Git inside your project and push it to GitHub:</p>
<pre><code class="lang-bash">git init
git add .
git commit -m <span class="hljs-string">"Initial commit"</span>
git remote add origin &lt;repository_url&gt;
git push -u origin main
</code></pre>
<p>Replace <code>&lt;repository_url&gt;</code> with the repository URL you created on GitHub. This links your local Flutter project to GitHub, allowing GitHub Actions to run on your repository.</p>
<h2 id="heading-step-3-create-a-github-actions-workflow">Step 3: Create a GitHub Actions Workflow</h2>
<p>Inside your project, create a workflow configuration file. Workflows must be placed inside <code>.github/workflows/</code>. Create a file named <code>ci.yml</code>:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">name:</span> <span class="hljs-string">CI</span>

<span class="hljs-attr">on:</span>
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">main</span>
  <span class="hljs-attr">pull_request:</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">main</span>

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">flutter_test:</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">Run</span> <span class="hljs-string">Flutter</span> <span class="hljs-string">Test</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-java@v3</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">distribution:</span> <span class="hljs-string">'temurin'</span>
          <span class="hljs-attr">java-version:</span> <span class="hljs-string">'17'</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">subosito/flutter-action@v2</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">channel:</span> <span class="hljs-string">'stable'</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">flutter</span> <span class="hljs-string">pub</span> <span class="hljs-string">get</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">flutter</span> <span class="hljs-string">--version</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">flutter</span> <span class="hljs-string">analyze</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">flutter</span> <span class="hljs-string">test</span>

  <span class="hljs-attr">build_iOSApp:</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">Build</span> <span class="hljs-string">Flutter</span> <span class="hljs-string">App</span> <span class="hljs-string">(iOS)</span>
    <span class="hljs-attr">needs:</span> [<span class="hljs-string">flutter_test</span>]
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">macos-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-java@v3</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">distribution:</span> <span class="hljs-string">'temurin'</span>
          <span class="hljs-attr">java-version:</span> <span class="hljs-string">'17'</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">subosito/flutter-action@v2</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">flutter-version:</span> <span class="hljs-string">'3.19.0'</span>
          <span class="hljs-attr">dart-verion:</span> <span class="hljs-string">'3.3.4'</span>
          <span class="hljs-attr">channel:</span> <span class="hljs-string">'stable'</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">flutter</span> <span class="hljs-string">pub</span> <span class="hljs-string">get</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">flutter</span> <span class="hljs-string">clean</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">|
          flutter build ios --no-codesign
          cd build/ios/iphoneos
          mkdir Payload
          cd Payload
          ln -s ../Runner.app
          cd ..
          zip -r app.ipa Payload
</span>
  <span class="hljs-attr">build_androidApk:</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">Build</span> <span class="hljs-string">Flutter</span> <span class="hljs-string">App</span> <span class="hljs-string">(Android)</span>
    <span class="hljs-attr">needs:</span> [<span class="hljs-string">flutter_test</span>]
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-java@v3</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">distribution:</span> <span class="hljs-string">'temurin'</span>
          <span class="hljs-attr">java-version:</span> <span class="hljs-string">'17'</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">subosito/flutter-action@v2</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">channel:</span> <span class="hljs-string">'stable'</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">flutter</span> <span class="hljs-string">pub</span> <span class="hljs-string">get</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">flutter</span> <span class="hljs-string">clean</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">flutter</span> <span class="hljs-string">build</span> <span class="hljs-string">apk</span> <span class="hljs-string">--debug</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">ncipollo/release-action@v1</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">artifacts:</span> <span class="hljs-string">"build/app/outputs/apk/debug/*"</span>
          <span class="hljs-attr">tag:</span> <span class="hljs-string">v1.0.${{</span> <span class="hljs-string">github.run_number}}</span>
          <span class="hljs-attr">token:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.TOKEN}}</span>
</code></pre>
<p>This workflow is named <code>CI</code> and is meant for <strong>Continuous Integration</strong> (running tests and building apps automatically whenever code is pushed or a pull request is created).</p>
<h3 id="heading-triggers"><strong>Triggers</strong></h3>
<p>In GitHub Actions, <strong>triggers</strong> define the events that cause a workflow to run. For this workflow, it runs automatically when certain events happen in the repository. Specifically, it listens to:</p>
<ol>
<li><p><code>push</code>: Whenever new code is pushed to the <code>main</code> branch, the workflow will start.</p>
</li>
<li><p><code>pull_request</code>: Whenever a pull request is opened or updated that targets the <code>main</code> branch, the workflow will also start.</p>
</li>
</ol>
<p>This ensures that both direct updates to the main branch and contributions through pull requests are validated and tested.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">on:</span>
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">main</span>
  <span class="hljs-attr">pull_request:</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">main</span>
</code></pre>
<p>This code runs the workflow when:</p>
<ul>
<li><p>You push commits to the <code>main</code> branch.</p>
</li>
<li><p>A pull request is opened or updated targeting <code>main</code>.</p>
</li>
</ul>
<h3 id="heading-jobs"><strong>Jobs</strong></h3>
<p>There are 3 jobs in the workflow:</p>
<p><strong>Job 1:</strong> <code>flutter_test</code> runs unit tests and analysis.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">flutter_test:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
</code></pre>
<p>It uses <strong>Ubuntu</strong> as the runner.</p>
<p>Here are the steps it follows:</p>
<ol>
<li><p>Checks out code:</p>
<pre><code class="lang-yaml"> <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>
</code></pre>
<p> Downloads your repo into the runner.</p>
</li>
<li><p>Sets up Java (needed for Flutter Android builds):</p>
<pre><code class="lang-yaml"> <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-java@v3</span>
   <span class="hljs-attr">with:</span>
     <span class="hljs-attr">distribution:</span> <span class="hljs-string">'temurin'</span>
     <span class="hljs-attr">java-version:</span> <span class="hljs-string">'17'</span>
</code></pre>
</li>
<li><p>Sets up Flutter SDK:</p>
<pre><code class="lang-yaml"> <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">subosito/flutter-action@v2</span>
   <span class="hljs-attr">with:</span>
     <span class="hljs-attr">channel:</span> <span class="hljs-string">'stable'</span>
</code></pre>
<p> This installs the Flutter stable channel.</p>
</li>
<li><p>Runs commands:</p>
<ol>
<li><p><code>flutter pub get</code> installs dependencies.</p>
</li>
<li><p><code>flutter --version</code> checks installed Flutter version.</p>
</li>
<li><p><code>flutter analyze</code> analyzes Dart code for errors.</p>
</li>
<li><p><code>flutter test</code> runs unit/widget tests.</p>
</li>
</ol>
</li>
</ol>
<p>If this job fails, later jobs won’t run.</p>
<p><strong>Job 2</strong>: <code>build_iOSApp</code> builds an iOS <code>.ipa</code> file.</p>
<pre><code class="lang-yaml">  <span class="hljs-attr">build_iOSApp:</span>
    <span class="hljs-attr">needs:</span> [<span class="hljs-string">flutter_test</span>]
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">macos-latest</span>

  <span class="hljs-attr">steps:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>

  <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">subosito/flutter-action@v2</span>
    <span class="hljs-attr">with:</span>
      <span class="hljs-attr">flutter-version:</span> <span class="hljs-string">'3.22.0'</span>

  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Install</span> <span class="hljs-string">CocoaPods</span> <span class="hljs-string">dependencies</span>
    <span class="hljs-attr">run:</span> <span class="hljs-string">|
      cd ios
      pod install
</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Build</span> <span class="hljs-string">iOS</span> <span class="hljs-string">App</span>
    <span class="hljs-attr">run:</span> <span class="hljs-string">flutter</span> <span class="hljs-string">build</span> <span class="hljs-string">ipa</span> <span class="hljs-string">--release</span> <span class="hljs-string">--no-codesign</span>
</code></pre>
<p>This runs only <strong>after</strong> <code>flutter_test</code> succeeds and uses <strong>macOS</strong> runner (needed for iOS builds).</p>
<p>After installing CocoaPods dependencies, the workflow executes <code>flutter build ipa --release --no-codesign</code>. This shell command tells Flutter to package your iOS app into an <code>.ipa</code> file inside the runner’s build directory. The <code>--no-codesign</code> flag allows building without signing credentials, which is convenient for CI pipelines.</p>
<p>Here are the steps it follows:</p>
<ol>
<li><p>Checks out repo + sets up Java (same as before).</p>
</li>
<li><p>Sets up Flutter but this time pins:</p>
<pre><code class="lang-yaml"> <span class="hljs-attr">flutter-version:</span> <span class="hljs-string">'3.19.0'</span>
 <span class="hljs-attr">dart-verion:</span> <span class="hljs-string">'3.3.4'</span>   <span class="hljs-comment"># typo: should be `dart-version`</span>
 <span class="hljs-attr">channel:</span> <span class="hljs-string">'stable'</span>
</code></pre>
</li>
<li><p>Runs build:</p>
<ol>
<li><p><code>flutter pub get</code> fetches packages.</p>
</li>
<li><p><code>flutter clean</code> cleans old builds.</p>
</li>
<li><p><code>flutter build ios --no-codesign</code> builds iOS app without signing.</p>
</li>
<li><p>After building:</p>
<ol>
<li><p>Goes into <code>build/ios/iphoneos</code></p>
</li>
<li><p>Creates a <code>Payload</code> folder (needed for IPA structure).</p>
</li>
<li><p>Symlinks the generated <code>Runner.app</code> into <code>Payload</code>.</p>
</li>
<li><p>Zips the folder to <code>app.ipa</code>.</p>
</li>
</ol>
</li>
</ol>
</li>
</ol>
<p>Result: An unsigned <code>.ipa</code> file.</p>
<p><strong>Job 3</strong>: <code>build_androidApk</code> builds a debug Android <code>.apk</code> and uploads it as a release artifact.</p>
<pre><code class="lang-yaml">  <span class="hljs-attr">build_androidApk:</span>
    <span class="hljs-attr">needs:</span> [<span class="hljs-string">flutter_test</span>]
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>

  <span class="hljs-attr">steps:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>

  <span class="hljs-bullet">-</span> <span class="hljs-attr">uses:</span> <span class="hljs-string">subosito/flutter-action@v2</span>
    <span class="hljs-attr">with:</span>
      <span class="hljs-attr">flutter-version:</span> <span class="hljs-string">'3.22.0'</span>

  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Build</span> <span class="hljs-string">Android</span> <span class="hljs-string">APK</span>
    <span class="hljs-attr">run:</span> <span class="hljs-string">flutter</span> <span class="hljs-string">build</span> <span class="hljs-string">apk</span> <span class="hljs-string">--release</span>
</code></pre>
<p>This runs only after tests pass.</p>
<p>For Android, after setting up the Flutter environment, the workflow calls <code>flutter build apk --release</code>. This command compiles and packages the Android app into an <code>.apk</code> file ready for distribution. The resulting file is placed inside the <code>build/app/outputs/flutter-apk</code> directory of the project.</p>
<p>Here are the steps it follows:</p>
<ol>
<li><p>Checks out repo, sets up Java, and sets up Flutter.</p>
</li>
<li><p>Runs:</p>
<ol>
<li><p><code>flutter pub get</code></p>
</li>
<li><p><code>flutter clean</code></p>
</li>
<li><p><code>flutter build apk --debug</code> creates a debug APK.</p>
</li>
</ol>
</li>
<li><p>Uploads APK using <code>ncipollo/release-action@v1</code>:</p>
<pre><code class="lang-yaml"> <span class="hljs-attr">artifacts:</span> <span class="hljs-string">"build/app/outputs/apk/debug/*"</span>
 <span class="hljs-attr">tag:</span> <span class="hljs-string">v1.0.${{</span> <span class="hljs-string">github.run_number</span> <span class="hljs-string">}}</span>
 <span class="hljs-attr">token:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.TOKEN</span> <span class="hljs-string">}}</span>
</code></pre>
<ol>
<li><p>Uploads all debug APKs as release artifacts.</p>
</li>
<li><p>Tags release as <code>v1.0.&lt;run_number&gt;</code> (e.g., <code>v1.0.5</code>).</p>
</li>
<li><p>Uses a GitHub <strong>Personal Access Token</strong> (<code>TOKEN</code>) stored in repo secrets.</p>
</li>
</ol>
</li>
</ol>
<h2 id="heading-step-4-generate-and-add-a-github-token">Step 4: Generate and Add a GitHub Token</h2>
<p>The Android build job releases APKs using the <code>release-action</code>. To authenticate, you must provide a GitHub personal access token. To do this, go to <strong>GitHub Settings → Developer settings → Personal access tokens</strong>.</p>
<p>Generate a new token with <code>repo</code> permissions and copy the token immediately. Then go to your repository → Settings → Secrets → New repository secret. Add the token with the name <code>TOKEN</code>.</p>
<p>Now the workflow can use <code>${{ secrets.TOKEN }}</code> securely.</p>
<h2 id="heading-step-5-understanding-the-workflow">Step 5: Understanding the Workflow</h2>
<p>This workflow is triggered when code is pushed to the <code>main</code> branch or when a pull request is opened against it. Let’s break it down:</p>
<h3 id="heading-flutter-test-job">Flutter Test Job</h3>
<ul>
<li><strong>Environment:</strong> Runs on <code>ubuntu-latest</code>.</li>
</ul>
<p><strong>Steps:</strong></p>
<ol>
<li><p><code>actions/checkout@v3</code> fetches the source code.</p>
</li>
<li><p><code>actions/setup-java@v3</code> installs Java, required for some Flutter tools.</p>
</li>
<li><p><code>subosito/flutter-action@v2</code> installs Flutter on the runner.</p>
</li>
<li><p><code>flutter pub get</code> installs dependencies.</p>
</li>
<li><p><code>flutter analyze</code> checks for code issues.</p>
</li>
<li><p><code>flutter test</code> runs test cases.</p>
</li>
</ol>
<p>This job ensures your code compiles, passes linting, and has no failing tests.</p>
<h3 id="heading-ios-app-build-job">iOS App Build Job</h3>
<ul>
<li><p><strong>Environment:</strong> Runs on <code>macos-latest</code> because iOS builds require macOS.</p>
</li>
<li><p><strong>Dependencies:</strong> This job runs only if <code>flutter_test</code> passes (<code>needs: [flutter_test]</code>).</p>
</li>
</ul>
<p><strong>Steps:</strong> Similar setup as before, but after cleaning old builds with <code>flutter clean</code>, it runs <code>flutter build ios --no-codesign</code> to build an iOS app without requiring a signing certificate. The shell commands package the app into an <code>.ipa</code> file.</p>
<h3 id="heading-android-apk-build-job">Android APK Build Job</h3>
<ul>
<li><p><strong>Environment:</strong> Runs on <code>ubuntu-latest</code>.</p>
</li>
<li><p><strong>Dependencies:</strong> Also depends on <code>flutter_test</code>.</p>
</li>
</ul>
<p><strong>Steps:</strong></p>
<ol>
<li><p>Installs Flutter.</p>
</li>
<li><p>Runs <code>flutter clean</code> and then builds the Android APK.</p>
</li>
<li><p>Uses <code>ncipollo/release-action@v1</code> to upload the APK as a GitHub release, tagged automatically with a version like <code>v1.0.&lt;run_number&gt;</code>.</p>
</li>
</ol>
<h2 id="heading-step-6-push-and-enable-the-workflow">Step 6: Push and Enable the Workflow</h2>
<p>Save your file as <code>.github/workflows/ci.yml</code> and push the changes:</p>
<pre><code class="lang-bash">git add .
git commit -m <span class="hljs-string">"Add GitHub Actions workflow"</span>
git push
</code></pre>
<p>When you push your changes to GitHub, the workflow file is picked up automatically. To confirm that it is running, open your repository on GitHub and click on the <strong>Actions</strong> tab at the top of the page. You will see a list of workflow runs, each tied to the commit message that triggered them.</p>
<p>Click on the most recent run to expand the details. Inside, you’ll find separate jobs for <strong>Android</strong> and <strong>iOS</strong> builds. Each job will show its status in real time:</p>
<ol>
<li><p>A <strong>yellow dot</strong> with “In progress” indicates the job is still running.</p>
</li>
<li><p>A <strong>green check mark</strong> with “Success” means the job finished successfully.</p>
</li>
<li><p>A <strong>red cross</strong> with “Failed” means something went wrong.</p>
</li>
</ol>
<p>This way, you can immediately tell whether your Android and iOS builds passed or if one of them needs attention.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1701417493045/31e8f9db-b4b7-445c-ab6d-caa8cbc8dfdf.png" alt="Running for Flutter Test" class="image--center mx-auto" width="1901" height="875" loading="lazy"></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1701417512470/d27378ea-5bf2-487e-a0b0-9c2a9ffaa92e.png" alt="Building for iOS" class="image--center mx-auto" width="1901" height="882" loading="lazy"></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1701417526668/31b6e5c9-c43f-46ef-a25c-7f2321eda443.png" alt="Building for Android" class="image--center mx-auto" width="1901" height="913" loading="lazy"></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1701417477229/cbae6ba2-f51c-4f2a-81bc-cdb3345a5319.png" alt="Jobs completed" class="image--center mx-auto" width="1901" height="822" loading="lazy"></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1701439224779/7fb85ed3-00ae-4154-8032-ffeb9bb5e1b1.png" alt="Showcase 2 app releases on the right hand side with versions" class="image--center mx-auto" width="1902" height="859" loading="lazy"></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1701439234845/02c24d1e-09cd-4b4e-b9ea-fe96d4516f0c.png" alt="Detailed app release versioning showcase" class="image--center mx-auto" width="1902" height="915" loading="lazy"></p>
<h2 id="heading-final-notes">Final Notes</h2>
<p>With this setup, you now have:</p>
<ul>
<li><p>Automated testing whenever you push or open a pull request.</p>
</li>
<li><p>Automatic iOS builds on macOS runners.</p>
</li>
<li><p>Automatic Android builds with APKs released to GitHub.</p>
</li>
</ul>
<p>This ensures that every change is tested and that builds are consistently generated without manual steps.</p>
<p>For more details, see the official GitHub Actions documentation: <a target="_blank" href="https://docs.github.com/en/actions">https://docs.github.com/en/actions</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Automate CI/CD with GitHub Actions and Streamline Your Workflow ]]>
                </title>
                <description>
                    <![CDATA[ CI/CD stands for Continuous Integration and Continuous Delivery. It is a system or set of processes and methodologies that help developers quickly update codebases and deploy applications. The Continuous Integration (CI) part of CI/CD means that deve... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/automate-cicd-with-github-actions-streamline-workflow/</link>
                <guid isPermaLink="false">67fd2db6c741f3f1aec5ecd2</guid>
                
                    <category>
                        <![CDATA[ github-actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ workflow ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Productivity ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chidiadi Anyanwu ]]>
                </dc:creator>
                <pubDate>Mon, 14 Apr 2025 15:45:58 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1744638276204/5cf04403-6bf0-4bf1-b9d3-89722bd90425.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>CI/CD stands for Continuous Integration and Continuous Delivery. It is a system or set of processes and methodologies that help developers quickly update codebases and deploy applications.</p>
<p>The Continuous Integration (CI) part of CI/CD means that developers can always integrate or merge their changes into the shared repository without breaking anything. Continuous Delivery, on the other hand, means that the code changes are automatically prepared for release after testing and validation.</p>
<p>CI/CD primarily involves various stages like building, testing, staging and deployment.</p>
<ul>
<li><p><strong>Build phase:</strong> This is where the code and its dependencies are compiled into a single executable. This is the first phase of Continuous Integration, and is triggered by an event like pushing code to the repository.</p>
</li>
<li><p><strong>Test phase:</strong> Here, the built artifacts are tested to be sure that the code runs as expected.</p>
</li>
<li><p><strong>Staging:</strong> Here, the application is run in a production-like environment so as to be sure it is production ready.</p>
</li>
<li><p><strong>Deployment:</strong> Here, the application is automatically deployed to the end-users.</p>
</li>
</ul>
<p>In this article, I’m going to explain how GitHub Actions works. I’ll also talk about basic GitHub Actions concepts, and then we’ll use it to build an example CI/CD pipeline.</p>
<h2 id="heading-what-is-github-actions">What is GitHub Actions?</h2>
<p>GitHub Actions is a service or feature of the GitHub platform that lets developers create their own CI/CD workflows directly on GitHub. It runs jobs on containers hosted by GitHub. The tasks are executed as defined in a YAML file called a workflow. This workflow file has to live on the <em>.github/workflows</em> folder on the repository for it to work.</p>
<h2 id="heading-basic-github-actions-concepts">Basic GitHub Actions Concepts</h2>
<p>GitHub Actions consists of events, jobs, tasks, runners, workflows, and various other features. Here is a brief explanation of the main concepts:</p>
<p><strong>Events:</strong> An event is basically something that happened. With GitHub, an event can be a push (when you push your code to the repository), a pull request, or even a cron job. These events trigger the CI/CD process.</p>
<p><strong>Tasks:</strong> When you use CI/CD, you want to be able to trigger an activity that should be done automatically. That activity is known as a task or step in GitHub. It could be building your code or testing it or deploying it.</p>
<p>Each of those tasks has to be defined by commands. A GitHub Actions task usually consists of the name, and the instructions on what to do in the form of a command which starts with  <code>- run:</code> or an Action which starts with <code>- uses:</code>.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">steps:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Checkout</span> <span class="hljs-string">code</span>
    <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span>

  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Set</span> <span class="hljs-string">up</span> <span class="hljs-string">Node.js</span>
    <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-node@v3</span>
    <span class="hljs-attr">with:</span>
      <span class="hljs-attr">node-version:</span> <span class="hljs-number">16</span>

  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Install</span> <span class="hljs-string">dependencies</span>
    <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">install</span>

  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Run</span> <span class="hljs-string">tests</span>
    <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">test</span>

  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Build</span> <span class="hljs-string">project</span>
    <span class="hljs-attr">run:</span> <span class="hljs-string">npm</span> <span class="hljs-string">run</span> <span class="hljs-string">build</span>

  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Deploy</span>
    <span class="hljs-attr">run:</span> <span class="hljs-string">echo</span> <span class="hljs-string">"Deploy step goes here"</span>
</code></pre>
<p><strong>Runner:</strong> A GitHub runner is a server that runs your tasks. It executes what is defined in your GitHub workflow. You can use your own runners or you can use the GitHub runners.</p>
<p><strong>Job:</strong> A job is a collection of steps that are being executed on the same runner. Jobs are defined in a file called the workflow.</p>
<p><strong>Workflow:</strong> The GitHub workflow is a series of jobs defined in a YAML file, that are triggered upon an event. The events do not trigger individual tasks. They can only trigger workflows. Then the tasks in the jobs of the workflow are executed.</p>
<p><strong>Contexts:</strong> These provide a way to access information about workflows, jobs, and environments in GitHub. They are accessed with the expression <code>$${{ &lt;context&gt; }}</code>. Examples include <code>github</code>, <code>env</code>, <code>vars</code>, and <code>secrets</code>. The <code>github</code> context is used to access information about the workflow. For example:</p>
<pre><code class="lang-yaml"><span class="hljs-string">$${{github.repository}}</span> <span class="hljs-comment"># should tell the name of the repository</span>

<span class="hljs-string">$${{github.actor}}</span>  <span class="hljs-comment"># should tell the username of user that initially triggered the workflow</span>
</code></pre>
<p><strong>Secrets:</strong> This is used to store and access sensitive information that’s used by, and is available to, the workflow. Secrets are redacted when printed to the log. An example is $${{secrets.GITHUB_TOKEN}}.</p>
<h2 id="heading-how-to-build-a-simple-cicd-pipeline">How to Build a Simple CI/CD Pipeline</h2>
<p>Here, we’re going to build an example workflow to deploy a simple HTML and CSS website to GitHub Pages. Follow the steps below:</p>
<ol>
<li><p>Go to the sample code in my repository and fork it from <a target="_blank" href="https://github.com/chidiadi01/StaticPage-Starter">here</a>.</p>
</li>
<li><p>Go to the settings tab in the GitHub repository:</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1744220928970/d2f62ae0-49be-4770-b931-59e5bc28e20e.png" alt="Settings tab" class="image--center mx-auto" width="908" height="39" loading="lazy"></p>
<ol start="3">
<li><p>Go to the Pages settings:</p>
<p> <img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1744220974335/4aeac1df-be0d-493d-98d3-fb9ea4d48ca0.png" alt="Pages settings menu" class="image--center mx-auto" width="347" height="480" loading="lazy"></p>
</li>
<li><p>Set the deployment source to the <code>main</code> branch:</p>
<p> <img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1744290941501/365d8b5d-1265-42be-9de4-d3a07b984736.png" alt="Setting deployment source to main branch in GitHub pages" class="image--center mx-auto" width="858" height="369" loading="lazy"></p>
</li>
<li><p>Go to the General Actions settings and scroll down to the bottom:</p>
</li>
<li><p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1744221468786/d94fe477-65a3-49dc-85ea-6ab8cb2f9c63.png" alt="Find General Actions setting" class="image--center mx-auto" width="355" height="568" loading="lazy"></p>
<p> At the bottom, set the Workflow permissions to read and write:</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1744221589612/4f5b1cf2-8343-4da8-ad61-21ba76319ffc.png" alt="Set workflow permissions to read and write" class="image--center mx-auto" width="898" height="394" loading="lazy"></p>
<ol start="7">
<li><p>In the GitHub repository, you can clone it to your PC or press the fullstop (.) on your keyboard to open GitHub Codespaces, the online version of VS Code.</p>
</li>
<li><p>Go to the sidebar and click on create a new file:</p>
<p> <img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1744292745424/a3ca0a1e-13cf-4182-8425-9c3500e01e3d.png" alt="Creating new file" class="image--center mx-auto" width="924" height="394" loading="lazy"></p>
</li>
<li><p>Create a workflows folder and file. You can call it <code>deploy.yaml</code>.</p>
<p> <img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1744292931628/300665a5-d517-46b1-9b30-21dd4bc228a6.png" alt="Creating a workflows folder and file named deploy.yaml" class="image--center mx-auto" width="335" height="215" loading="lazy"></p>
</li>
<li><p>Copy this code into the file:</p>
</li>
</ol>
<pre><code class="lang-yaml"><span class="hljs-attr">name:</span> <span class="hljs-string">Deploy</span> <span class="hljs-string">Static</span> <span class="hljs-string">HTML</span> <span class="hljs-string">and</span> <span class="hljs-string">CSS</span> <span class="hljs-string">to</span> <span class="hljs-string">GitHub</span> <span class="hljs-string">Pages</span>

<span class="hljs-comment"># Trigger the workflow on push to the main branch</span>

<span class="hljs-attr">on:</span>
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">main</span>
<span class="hljs-comment"># Define what operating system the job should run on</span>
<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">deploy:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">permissions:</span>
      <span class="hljs-attr">contents:</span> <span class="hljs-string">write</span>

    <span class="hljs-attr">steps:</span>
    <span class="hljs-comment"># Step 1: Checkout the repository</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Checkout</span> <span class="hljs-string">Code</span>
      <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v4</span>

    <span class="hljs-comment"># Step 2: Check the files that have been checked out</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Display</span> <span class="hljs-string">files</span>
      <span class="hljs-attr">run:</span> <span class="hljs-string">ls</span>

    <span class="hljs-comment"># Step 3: Deploy to GitHub Pages</span>
    <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Deploy</span>
      <span class="hljs-attr">uses:</span> <span class="hljs-string">peaceiris/actions-gh-pages@v4</span>
      <span class="hljs-attr">with:</span>
        <span class="hljs-attr">github_token:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.GITHUB_TOKEN</span> <span class="hljs-string">}}</span>
        <span class="hljs-attr">publish_dir:</span> <span class="hljs-string">./</span> <span class="hljs-comment"># The HTML and CSS files lie in the root directory, hence that should be the publish directory</span>
</code></pre>
<ol start="11">
<li>Commit the code. You should see the job running when you go back to the repo:</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1744292376966/93d6078c-02c3-41f4-a4b5-09639522bbbe.png" alt="Running job" class="image--center mx-auto" width="904" height="53" loading="lazy"></p>
<p>When you’re done, go back to the home page of the repository and click on the Deployments section. There, you will see the GitHub Pages link to the deployment:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1744293345646/e523a2b9-73a6-4ecf-9df8-99e03b457ad1.png" alt="GitHub Pages link" class="image--center mx-auto" width="516" height="338" loading="lazy"></p>
<p>When you’re done, your repository should look like <a target="_blank" href="https://github.com/chidiadi01/StaticPage-Final">this</a>.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this article, you learned about how the CI/CD process works. We also covered the basic concepts of GitHub Actions. Finally, we created an example CI/CD pipeline with GitHub Actions. If you enjoyed this article, share it with others. You can also reach me on <a target="_blank" href="https://linkedin.com/in/chidiadi-anyanwu">LinkedIn</a> or <a target="_blank" href="https://x.com/chidiadi01">X</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Set Up Documentation as Code with Docusaurus and GitHub Actions ]]>
                </title>
                <description>
                    <![CDATA[ For technical writers, keeping documentation up to date manually can be really frustrating. Issues like outdated guides, broken links, and missing updates are a pain, and they can make writers less productive. These issues can also make it harder for... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/set-up-docs-as-code-with-docusaurus-and-github-actions/</link>
                <guid isPermaLink="false">67a39f467e716749ae8c7b7e</guid>
                
                    <category>
                        <![CDATA[ documentation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ docs-as-code ]]>
                    </category>
                
                    <category>
                        <![CDATA[ docusaurus ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github-actions ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ EZINNE ANNE EMILIA ]]>
                </dc:creator>
                <pubDate>Wed, 05 Feb 2025 17:26:30 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1738248926082/9a2a6855-00d4-4e25-a8bd-c1d645f21de5.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>For technical writers, keeping documentation up to date manually can be really frustrating. Issues like outdated guides, broken links, and missing updates are a pain, and they can make writers less productive. These issues can also make it harder for people to effectively use the docs and get correct information.</p>
<p>Documentation as code, or docs as code, is an approach to managing documentation that treats the docs like a codebase. It lets you version, automatically update, and review your docs just like you would do in a codebase. Docs as code helps you make sure that your docs are up to date and that users can gain access to accurate information.</p>
<p>This tutorial will show you how to:</p>
<ul>
<li><p>Create a documentation website using Docusaurus.</p>
</li>
<li><p>Track changes with Git and GitHub.</p>
</li>
<li><p>Build and deploy it to a hosting platform.</p>
</li>
<li><p>Set up a workflow to perform grammatical reviews using GitHub Actions before you merge your changes.</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This tutorial is beginner-friendly, but there are some tools you’ll need to have or know in order to follow along:</p>
<ul>
<li><p><a target="_blank" href="https://code.visualstudio.com/download">VSCode IDE (or other IDE of your choice)</a>.</p>
</li>
<li><p><a target="_blank" href="https://docs.npmjs.com/downloading-and-installing-node-js-and-npm">Node.js and npm installed.</a></p>
</li>
<li><p><a target="_blank" href="https://github.com/">A GitHub account.</a></p>
</li>
<li><p><a target="_blank" href="https://www.freecodecamp.org/news/gitting-things-done-book/">A reasonable knowledge of how to use Git and GitHub.</a></p>
</li>
</ul>
<h2 id="heading-why-do-technical-writers-use-docs-as-code">Why Do Technical Writers Use Docs as Code?</h2>
<p>Before we dive in, let’s quickly talk about what "docs as code" is and why it matters. Back in 2015, two technical writers at Google came up with the idea to make it easier for developers to contribute to documentation and to better organize their company documents. There were times when they needed to write about an application they were working on, but things were really disorganized. So they came up with this process. Since then, many companies have adopted the approach.</p>
<p>Docs as code is now a popular approach to managing documentation, and it’s supported by many tools that are designed to treat documentation like code. Tom Johnson explains this concept in more detail in <a target="_blank" href="https://idratherbewriting.com/learnapidoc/pubapis_docs_as_code.html">his article on docs as code</a>.</p>
<p>Traditional documentation relies on Word documents and PDFs, where changes are tracked manually or through document revision history. Writers must update and publish content manually, with no way to automate routine tasks.</p>
<p>On the other hand, docs as code borrows principles and tools from software development to make documentation more structured, versioned, and automated. The documentation is stored in version control (like Git), written in lightweight markup languages, and gets updated alongside the code.</p>
<p>This approach ensures that documentation evolves alongside the software, maintains high quality, and allows for efficient collaboration, just like writing code.</p>
<h3 id="heading-tools-well-use-in-this-tutorial">Tools We’ll Use in This Tutorial</h3>
<p>Let’s review the main tools we’ll be using for this tutorial:</p>
<ol>
<li><p>Docusaurus is a tool created by Facebook for creating documentation websites. It supports markdown and mdx. It also supports versioning and custom themes, making it easy to create user-friendly and professional docs.</p>
</li>
<li><p>Vale is a customizable style and grammar checker for writers. It ensures consistent language, tone, and style across technical documents. There are other good linters you could use for review apart from Vale, but that’s what we’ll be using here.</p>
</li>
<li><p>GitHub Actions: A CI/CD tool for automating workflows directly in GitHub. It helps you test, build, and deploy code with ease.</p>
</li>
</ol>
<h2 id="heading-step-1-install-docusaurus">Step 1: Install Docusaurus</h2>
<p>Open your command line terminal and enter the following:</p>
<pre><code class="lang-javascript">npx create-docusaurus@latest docs-<span class="hljs-keyword">as</span>-code-tutorial classic
</code></pre>
<p><code>docs-as-code-tutorial</code> is the name I am using for the site. You can replace it with any other site name if you wish. Select JavaScript as the language you want to use. This will begin to create a new Docusaurus site. After running the code, you’ll see the <code>docs-as-code-tutorial</code> folder in your VSCode workspace. Navigate to the folder.</p>
<p>Next, start the development server so you can see your docs.</p>
<pre><code class="lang-javascript">cd docs-<span class="hljs-keyword">as</span>-code-tutorial
npm start
</code></pre>
<p>With this, the site will start running at <code>localhost:3000</code>.</p>
<p>When you view the site, you’ll see pre-generated content. So, in the next step, you’ll to create a repository and link the local folder to your remote repository.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1737868185569/0cf96b6c-770a-4965-b017-1fe54796c673.png" alt="the docusaurus homepage" class="image--center mx-auto" width="1335" height="698" loading="lazy"></p>
<h2 id="heading-step-2-create-a-repository">Step 2: Create a Repository</h2>
<p>Now, you need to create a repository for the <code>docs-as-code-tutorial</code>. So go to your GitHub account and create a new repository.</p>
<p>After creating the repository, you’ll need to link the repository to the folder in your VSCode workspace.</p>
<p>Open a new terminal and run these commands:</p>
<pre><code class="lang-javascript">git init
git add .
git commit -m <span class="hljs-string">"first commit"</span>
git branch -M main
git remote add origin https:<span class="hljs-comment">//github.com/myname/docs-as-code-tutorial.git</span>
git push -u origin main
</code></pre>
<p>With that, you have linked the repository, and Git will start tracking your changes.</p>
<h2 id="heading-step-3-customize-your-docs-in-the-docusaurusconfig-file">Step 3: Customize your Docs in the <code>docusaurus.config</code> File</h2>
<p>Before you begin customizing, create a branch where you can make your changes as you push it to the main branch.</p>
<pre><code class="lang-powershell">git checkout <span class="hljs-literal">-b</span> <span class="hljs-string">"new_branch"</span>
</code></pre>
<p>The <code>docusaurus.config.js</code> file is where you can make most of the edits to your site. Change the <code>title</code> property to <code>Docs as code</code>.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> config = {
  <span class="hljs-attr">title</span>: <span class="hljs-string">'Docs as code'</span>,
  <span class="hljs-attr">tagline</span>: <span class="hljs-string">'Documentation as code'</span>,
<span class="hljs-comment">//rest of your code</span>
   <span class="hljs-attr">navbar</span>: {
        <span class="hljs-attr">title</span>: <span class="hljs-string">'Docs as code'</span>,
<span class="hljs-comment">//rest of your code</span>
  }
}
</code></pre>
<p>That will show as the new title when you preview the docs. This is simply an illustration to display how Docusaurus works. You can further customize the site to your desired style, but we won’t go into more detail on that here (as the main purpose of this tutorial is to show how to set up your docs as code).</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1737869529640/c4dab104-9f8b-4dad-a3a5-250d15d4552d.png" alt="c4dab104-9f8b-4dad-a3a5-250d15d4552d" class="image--center mx-auto" width="1337" height="654" loading="lazy"></p>
<p>After making the changes, the site should look a bit different.</p>
<p>You can push the changes now.</p>
<pre><code class="lang-powershell">git commit <span class="hljs-literal">-am</span> <span class="hljs-string">"first commit"</span>
git push -<span class="hljs-literal">-set</span><span class="hljs-literal">-upstream</span> origin new_branch
</code></pre>
<h2 id="heading-step-4-edit-your-docs">Step 4: Edit Your Docs</h2>
<p>For this tutorial, I’ll be making edits in the <code>docs</code> section. Go to <code>intro.md</code> and replace the markdown text with this writeup:</p>
<pre><code class="lang-markdown"><span class="hljs-section"># How to set up docs-as-code</span>

Documentation-as-code is a great means to push changes made in your local machine to your docs live site. To accomplish this, you need an IDE, a static site generator, a Git repository, CI/CD to set up workflows, and a hosting platform. 

<span class="hljs-section">## Why do technical writers do docs-as-code?</span>

Documentation-as-code is a great means to push changes made in your local machine to your docs live site. To accomplish this, you need an IDE, a static site generator, a Git repository, CI/CD to set up workflows, and a hosting platform.
</code></pre>
<p>After making the edits, preview your docs.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1737870301247/dba83233-a11c-4ec0-aeaf-b11e525ca090.png" alt="intro.md displaying the writeup " class="image--center mx-auto" width="1335" height="650" loading="lazy"></p>
<h2 id="heading-step-5-add-the-linting-feature">Step 5: Add the Linting Feature</h2>
<p>Add the Vale linter to your docs to review errors. To do that, install the Vale CLI with any of these commands.</p>
<ul>
<li><p>Run <code>choco install vale</code> for Windows</p>
</li>
<li><p><code>brew install vale</code> for MacOs, or</p>
</li>
<li><p><code>snap install vale</code> for Linux</p>
</li>
</ul>
<h3 id="heading-how-to-set-up-vale"><strong>How to set up Vale</strong></h3>
<p>As I mentioned earlier, Vale is a customizable style and grammer checking tool. This means you can set it up to review your docs exactly how you want.</p>
<p>Vale uses the Vale style guide when performing reviews to spot errors and make suggestions. But you can add your company’s style guide or any other style guide to it if you prefer. There are public style guides you can use like the Google style guide, Microsoft style guide, and so on. For this tutorial, we’ll be using the Microsoft style guide.</p>
<p>If you don’t already have it, you’ll need to <a target="_blank" href="https://github.com/errata-ai/Microsoft/releases/download/v0.7.0/Microsoft.zip">get the Microsoft style guide</a>, download it, and unzip it. Create a styles folder and move the Microsoft folder to the styles folder.</p>
<p>This should be your file path:</p>
<pre><code class="lang-javascript">- docs-<span class="hljs-keyword">as</span>-code-tutorial
  <span class="hljs-comment">//other folders</span>
  - styles
    - Microsoft
  <span class="hljs-comment">//other folders</span>
</code></pre>
<p>In your docs, create a <code>.vale.ini</code> file and add it to your root. </p>
<p>Add this code in it:</p>
<pre><code class="lang-plaintext">StylesPath = styles

MinAlertLevel = suggestion

[*.md]

BasedOnStyles = Vale, Microsoft
</code></pre>
<p>Let’s understand what’s going on here:</p>
<ul>
<li><p>The <code>StylesPath</code> is set to the styles folder where you added the Microsoft style guide you downloaded. The MinAlertLevel sets Vale alerts to <code>suggestion</code> – this means that Vale will highlight suggestions, warnings, and errors found in your docs. If the MinAlertLevel is set to errors, then Vale will highlight errors only. If set to warnings, then it’ll highlight warnings and errors (and so on).</p>
</li>
<li><p><code>[*.md]</code> tells Vale to go through <code>.md</code> files only.</p>
</li>
<li><p><code>BasedOnStyles</code> indicates which style guide you are using for the linting. In this case, it’s the Microsoft style guide and Vale style guide. So when the linter is running, it will highlight suggestions, warnings, and errors using the specified style guides.</p>
</li>
</ul>
<p>To test your docs, run <code>vale intro.md</code> (assuming you still have the <code>intro.md</code> file).</p>
<p>This should be the output:</p>
<pre><code class="lang-plaintext">✔ 0 errors, 0 warnings and 0 suggestions in stdin.
</code></pre>
<h2 id="heading-step-6-build-the-site">Step 6: Build the Site</h2>
<p>To do this, run <code>npm run build</code>. After that, you can preview the build with <code>npm run serve</code>.</p>
<h2 id="heading-step-7-deploy-the-site">Step 7: Deploy the Site</h2>
<p>There are different hosting platforms where you can host your live site. This tutorial covers two hosting options: GitHub Pages and Netlify.</p>
<h3 id="heading-deploy-with-github-pages"><strong>Deploy with GitHub Pages</strong></h3>
<p>To deploy to GitHub Pages, you’ll need to set your repository name and GitHub username/organization name in the <code>docusauraus.config.js</code> file.</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// Set the production url of your site here</span>

  <span class="hljs-attr">url</span>: <span class="hljs-string">'https://ezinneanne.github.io/'</span>,

  <span class="hljs-comment">// Set the /&lt;baseUrl&gt;/ pathname under which your site is served</span>

  <span class="hljs-comment">// For GitHub pages deployment, it is often '/&lt;projectName&gt;/'</span>

  <span class="hljs-attr">baseUrl</span>: <span class="hljs-string">'/docs-as-code-tutorial/'</span>,

  <span class="hljs-comment">// GitHub pages deployment config.</span>

  <span class="hljs-comment">// If you aren't using GitHub pages, you don't need these.</span>

  <span class="hljs-attr">organizationName</span>: <span class="hljs-string">'ezinneanne'</span>, <span class="hljs-comment">// Usually your GitHub org/user name.</span>

  <span class="hljs-attr">projectName</span>: <span class="hljs-string">'docs-as-code-tutorial'</span>, <span class="hljs-comment">// Usually your repo name.</span>
</code></pre>
<p>You can deploy the site to GitHub Pages in the following ways:</p>
<ul>
<li><p>Using the Powershell terminal with this command:</p>
<p>  <code>cmd /C 'set "GIT_USER=&lt;GITHUB_USERNAME&gt;" &amp;&amp; yarn deploy'</code> </p>
</li>
<li><p>Using the Windows Command line terminal with this command:</p>
<p>  <code>cmd /C "set "GIT_USER=&lt;GITHUB_USERNAME&gt;" &amp;&amp; yarn deploy"</code></p>
</li>
<li><p>Using Bash with this command:<br>  <code>GIT_USER=&lt;GITHUB_USERNAME&gt; yarn deploy</code></p>
</li>
</ul>
<p>Just make sure you replace <code>&lt;GITHUB_USERNAME&gt;</code> with your username on GitHub.</p>
<p>Voilà! The site is deployed at <a target="_blank" href="https://ezinneanne.github.io/docs-as-code-tutorial/">https://ezinneanne.github.io/docs-as-code-tutorial/</a>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1737918709225/3eb12747-4a13-4c17-a7ad-ab6ee84b64ff.png" alt="the docs-as-code homepage deployed on Github Pages" class="image--center mx-auto" width="1336" height="690" loading="lazy"></p>
<h3 id="heading-deploy-with-netlify"><strong>Deploy with Netlify</strong></h3>
<p>To deploy to Netlify, you only need the production URL and base URL:</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// Set the production url of your site here</span>

  <span class="hljs-attr">url</span>: <span class="hljs-string">'https://docs-as-code-tutorial.netlify.app'</span>,

  <span class="hljs-attr">baseUrl</span>: <span class="hljs-string">'/'</span>,
</code></pre>
<ol>
<li><p>Go to your <a target="_blank" href="https://www.netlify.com/">Netlify account</a> and link your repository.</p>
</li>
<li><p>Click on <code>Add new site</code>.</p>
</li>
<li><p>Click on <code>import an existing project</code>.</p>
</li>
<li><p>Connect to your GitHub account and select the <code>docs-as-code-tutorial</code> repository.</p>
</li>
<li><p>Give your site a name, it should be the same as the URL in your <code>docusaurus.config.js</code>.</p>
</li>
<li><p>Add the publish directory which is <code>build</code> and the build command which is <code>npm run build</code>. Then Netlify will deploy to your default branch <code>main</code>, unless you specify otherwise.</p>
</li>
<li><p>Finally, deploy!</p>
</li>
</ol>
<p>You should see the site running at <a target="_blank" href="https://docs-as-code-tutorial.netlify.app/">https://docs-as-code-tutorial.netlify.app/</a>.</p>
<p>For other deployment options, <a target="_blank" href="https://docs-as-code-tutorial.netlify.app/">you can</a> <a target="_blank" href="https://docusaurus.io/docs/deployment">check out the Docusauraus documentation</a>.</p>
<h2 id="heading-step-8-set-up-a-documentation-workflow-using-github-actions">Step 8: Set Up a Documentation Workflow Using GitHub Actions</h2>
<p>Now we’ll set up a workflow for the documentation. In GitHub, when you deploy to GitHub Pages, it sets up a default workflow for you at <code>pages-build-deployments</code>.</p>
<p>Netlify also automates deployments but does not create a workflow file in your repository. Instead, it manages the process through its platform, monitoring your repository for changes and running builds based on your settings. In this tutorial, we will set up a workflow with GitHub Actions that automates Vale running linting checks through the docs.</p>
<p>Create a <code>.github/workflows</code> directory and add a <code>vale-linter.yml</code> file in it. </p>
<p>Add this code in it:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">name:</span> <span class="hljs-string">Vale</span> <span class="hljs-string">Lint</span> <span class="hljs-string">Checker</span>

<span class="hljs-comment"># Trigger the workflow on specific events.</span>
<span class="hljs-attr">on:</span>
  <span class="hljs-attr">push:</span> <span class="hljs-comment"># Run on every push to the main branch.</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">main</span>
  <span class="hljs-attr">pull_request:</span> <span class="hljs-comment"># Run on pull requests targeting any branch.</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">'*'</span>
  <span class="hljs-attr">workflow_dispatch:</span> <span class="hljs-comment"># Allow manual triggering from the Actions tab.</span>

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">prose:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-latest</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-comment"># Step 1: Check out the repository code.</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Checkout</span> <span class="hljs-string">Code</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v3</span> 

      <span class="hljs-comment"># Step 2: Set up Node.js</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Setup</span> <span class="hljs-string">Node.js</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/setup-node@v3</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">node-version:</span> <span class="hljs-number">16</span> <span class="hljs-comment"># Use Node.js 16 or higher</span>

      <span class="hljs-comment"># Step 3: Run Vale lint checks.</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Vale</span> <span class="hljs-string">Lint</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">errata-ai/vale-action@reviewdog</span>
        <span class="hljs-attr">with:</span>
          <span class="hljs-attr">files:</span> <span class="hljs-string">.</span>
        <span class="hljs-attr">env:</span>
          <span class="hljs-attr">GITHUB_TOKEN:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.GITHUB_TOKEN</span> <span class="hljs-string">}}</span>
</code></pre>
<p>After making these changes, run the following commands:</p>
<pre><code class="lang-powershell">git add .
git commit <span class="hljs-literal">-m</span> “changes”
</code></pre>
<p>Finally push to the repository with <code>git push</code>.</p>
<p>Go to the <code>Actions</code> tab on your repository. You should see the workflow running:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1737521586319/3d554246-f8e6-4885-bac5-2cead1b3dd56.png" alt="The github repository page with focus on the Actions tab showing the vale workflow" class="image--center mx-auto" width="1347" height="519" loading="lazy"></p>
<p>Click on the <code>changes</code> button and click on the job <code>prose</code>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1737970927236/632e2753-5d2e-474b-a05e-74a9affa634d.png" alt="A brief preview of the lint output from vale in the prose job run" class="image--center mx-auto" width="1317" height="657" loading="lazy"></p>
<p>Now, you should see all the lines in your <code>.md</code> files highlighted by Vale.</p>
<p>With this, your docs are set up to run like a codebase! You can make changes, and when you push, review, and merge, it will sync automatically.</p>
<p>Keep in mind that this is for Netlify. For GitHub Pages, you’ll need to set up a workflow for automatic deployment.</p>
<h2 id="heading-summary">Summary</h2>
<p>In this tutorial, you have learned how to set up documentation as code using Docusaurus. You also saw how to deploy your documentation to a live site, and automate the linting workflow with Vale and GitHub Actions.</p>
<p><a target="_blank" href="https://docs.github.com/en/actions/use-cases-and-examples/creating-an-example-workflow">There are other workflows</a> you can set up to ease the workload in managing your doc site. Remember, the main point is to organize and structure your docs while automating regular documentation practices using software development tools. This lets you focus on the most important thing which is creating quality content for your readers.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Run Integration Tests with GitHub Service Containers ]]>
                </title>
                <description>
                    <![CDATA[ Recently, I published an article about using Testcontainers to emulate external dependencies like a database and cache for backend integration tests. That article also explained the different ways of running the integration tests, environment scaffol... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-run-integration-tests-with-github-service-containers/</link>
                <guid isPermaLink="false">677d8125f9b13835118c7958</guid>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github-actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ containers ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Testing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CI/CD ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Alex Pliutau ]]>
                </dc:creator>
                <pubDate>Tue, 07 Jan 2025 19:31:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1735305764768/8e3d8980-456b-4828-abb7-dff749bbf1fd.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Recently, I published an <a target="_blank" href="https://www.freecodecamp.org/news/integration-tests-using-testcontainers/"><strong>article</strong></a> about using <a target="_blank" href="https://testcontainers.com/"><strong>Testcontainers</strong></a> to emulate external dependencies like a database and cache for backend integration tests. That article also explained the different ways of running the integration tests, environment scaffolding, and their pros and cons.</p>
<p>In this article, I want to show another alternative in case you use GitHub Actions as your CI platform (the most popular CI/CD solution at the moment). This alternative is called <a target="_blank" href="https://docs.github.com/en/actions/use-cases-and-examples/using-containerized-services/about-service-containers"><strong>Service Containers</strong></a>, and I’ve realized that not many developers seem to know about it.</p>
<p>In this hands-on tutorial, I’ll demonstrate how to create a GitHub Actions workflow for integration tests with external dependencies (MongoDB and Redis) using the <a target="_blank" href="https://github.com/plutov/packagemain/tree/master/testcontainers-demo">demo Go application</a> we created in that previous tutorial. We’ll also review the pros and cons of GitHub Service Containers.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>A basic understanding of GitHub Actions workflows.</p>
</li>
<li><p>Familiarity with Docker containers.</p>
</li>
<li><p>Basic knowledge of Go toolchain.</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-are-service-containers">What are Service Containers?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-why-not-docker-compose">Why not Docker Compose?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-job-runtime">Job Runtime</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-readiness-healthcheck">Readiness Healthcheck</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-private-container-registries">Private Container Registries</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-sharing-data-between-services">Sharing Data Between Services</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-golang-integration-tests">Golang Integration Tests</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-personal-experience-amp-limitations">Personal Experience &amp; Limitations</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-what-are-service-containers">What are Service Containers?</h2>
<p>Service Containers are Docker containers that offer a simple and portable way to host dependencies like databases (MongoDB in our example), web services, or caching systems (Redis in our example) that your application needs within a workflow.</p>
<p>This article focuses on integration tests, but there are many other possible applications for service containers. For example, you can also use them to run supporting tools required by your workflow, such as code analysis tools, linters, or security scanners.</p>
<h2 id="heading-why-not-docker-compose">Why Not Docker Compose?</h2>
<p>Sounds similar to <strong>services</strong> in Docker Compose, right? Well, that’s because it is.</p>
<p>But while you could technically <a target="_blank" href="https://github.com/marketplace/actions/docker-compose-action">use Docker Compose</a> within a GitHub Actions workflow by installing Docker Compose and running <strong>docker-compose up</strong>, service containers provide a more integrated and streamlined approach that’s specifically designed for the GitHub Actions environment.</p>
<p>Also, while they are similar, they solve different problems and have different general purposes:</p>
<ul>
<li><p>Docker Compose is good when you need to manage a multi-container application on your local machine or a single server. It’s best suited for long-living environments.</p>
</li>
<li><p>Service Containers are ephemeral and exist only for the duration of a workflow run, and they’re defined directly within your GitHub Actions workflow file.</p>
</li>
</ul>
<p>Just keep in mind that the feature set of service containers (at least as of now) is more limited compared to Docker Compose, so be ready to discover some potential bottlenecks. We will cover some of them at the end of this article.</p>
<h2 id="heading-job-runtime">Job Runtime</h2>
<p>You can run GitHub jobs directly on a runner machine or in a Docker container (by specifying the <strong>container</strong> property). The second option simplifies the access to your services by using labels you define in the <strong>services</strong> section.</p>
<p>To run directly on a runner machine:</p>
<p><strong>.github/workflows/test.yaml</strong></p>
<pre><code class="lang-yaml"><span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">integration-tests:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-24.04</span>

    <span class="hljs-attr">services:</span>
      <span class="hljs-attr">mongo:</span>
        <span class="hljs-attr">image:</span> <span class="hljs-string">mongodb/mongodb-community-server:7.0-ubi8</span>
        <span class="hljs-attr">ports:</span>
          <span class="hljs-bullet">-</span> <span class="hljs-number">27017</span><span class="hljs-string">:27017</span>

    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">|</span>
          <span class="hljs-string">echo</span> <span class="hljs-string">"addr 127.0.0.1:27017"</span>
</code></pre>
<p>Or you can run it in a container (<a target="_blank" href="https://images.chainguard.dev/directory/image/go/overview">Chainguard Go Image</a> in our case):</p>
<pre><code class="lang-yaml"><span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">integration-tests:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-24.04</span>
    <span class="hljs-attr">container:</span> <span class="hljs-string">cgr.dev/chainguard/go:latest</span>

    <span class="hljs-attr">services:</span>
      <span class="hljs-attr">mongo:</span>
        <span class="hljs-attr">image:</span> <span class="hljs-string">mongodb/mongodb-community-server:7.0-ubi8</span>
        <span class="hljs-attr">ports:</span>
          <span class="hljs-bullet">-</span> <span class="hljs-number">27017</span><span class="hljs-string">:27017</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">|</span>
          <span class="hljs-string">echo</span> <span class="hljs-string">"addr mongo:27017"</span>
</code></pre>
<p>You can also omit the host port, so the container port will be randomly assigned to a free port on the host. You can then access the port using the variable.</p>
<p>Benefits of omitting the host port:</p>
<ul>
<li><p>Avoids port conflicts – for example when you run many services on the same host.</p>
</li>
<li><p>Enhances Portability – your configurations become less dependent on the specific host environment.</p>
</li>
</ul>
<pre><code class="lang-yaml"><span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">integration-tests:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-24.04</span>
    <span class="hljs-attr">container:</span> <span class="hljs-string">cgr.dev/chainguard/go:1.23</span>

    <span class="hljs-attr">services:</span>
      <span class="hljs-attr">mongo:</span>
        <span class="hljs-attr">image:</span> <span class="hljs-string">mongodb/mongodb-community-server:7.0-ubi8</span>
        <span class="hljs-attr">ports:</span>
          <span class="hljs-bullet">-</span> <span class="hljs-number">27017</span><span class="hljs-string">/tcp</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">run:</span> <span class="hljs-string">|</span>
          <span class="hljs-string">echo</span> <span class="hljs-string">"addr mongo:$<span class="hljs-template-variable">{{ job.services.mongo.ports['27017'] }}</span>"</span>
</code></pre>
<p>Of course, there are pros and cons to each approach.</p>
<p>Running in a container:</p>
<ul>
<li><p><strong>Pros</strong>: Simplified network access (use labels as hostnames), and automatic port exposure within the container network. You also get better isolation/security as the job runs in an isolated environment.</p>
</li>
<li><p><strong>Cons</strong>: Implied overhead of containerization.</p>
</li>
</ul>
<p>Running on the runner machine:</p>
<ul>
<li><p><strong>Pros</strong>: Potentially less overhead than running the job inside a container.</p>
</li>
<li><p><strong>Cons</strong>: Requires manual port mapping for service container access (using localhost:). There’s also less isolation/security, as the job runs directly on the runner machine. This potentially affects other jobs or the runner itself if something goes wrong.</p>
</li>
</ul>
<h2 id="heading-readiness-healthcheck">Readiness Healthcheck</h2>
<p>Prior to running the integration tests that connect to your provisioned containers, you’ll often need to make sure that the services are ready. You can do this by specifying <a target="_blank" href="https://docs.docker.com/reference/cli/docker/container/create/#options">docker create options</a> such as <strong>health-cmd</strong>.</p>
<p>This is very important – otherwise the services may not be ready when you start accessing them.</p>
<p>In the case of MongoDB and Redis, these will be the following:</p>
<pre><code class="lang-yaml">    <span class="hljs-attr">services:</span>
      <span class="hljs-attr">mongo:</span>
        <span class="hljs-attr">image:</span> <span class="hljs-string">mongodb/mongodb-community-server:7.0-ubi8</span>
        <span class="hljs-attr">ports:</span>
          <span class="hljs-bullet">-</span> <span class="hljs-number">27017</span><span class="hljs-string">/27017</span>
        <span class="hljs-attr">options:</span> <span class="hljs-string">&gt;-
          --health-cmd "echo 'db.runCommand("ping").ok' | mongosh mongodb://localhost:27017/test --quiet"
          --health-interval 5s
          --health-timeout 10s
          --health-retries 10
</span>
      <span class="hljs-attr">redis:</span>
        <span class="hljs-attr">image:</span> <span class="hljs-string">redis:7</span>
        <span class="hljs-attr">ports:</span>
          <span class="hljs-bullet">-</span> <span class="hljs-number">6379</span><span class="hljs-string">:6379</span>
        <span class="hljs-attr">options:</span> <span class="hljs-string">&gt;-
          --health-cmd "redis-cli ping"
          --health-interval 5s
          --health-timeout 10s
          --health-retries 10</span>
</code></pre>
<p>In the Action logs, you can see the readiness status:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1736245987630/0b0bf229-b8d3-4e4e-8e0b-e3bbe5f9a6d8.png" alt="GitHub Actions Logs" class="image--center mx-auto" width="512" height="210" loading="lazy"></p>
<h2 id="heading-private-container-registries">Private Container Registries</h2>
<p>In our example, we’re using public images from Dockerhub, but it’s possible to use private images from you private registries as well, such as Amazon Elastic Container Registry (ECR), Google Artifact Registry, and so on.</p>
<p>Make sure to store the credentials in <a target="_blank" href="https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions">Secrets</a> and then reference them in the <strong>credentials</strong> section.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">services:</span>
  <span class="hljs-attr">private_service:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">ghcr.io/org/service_repo</span>
    <span class="hljs-attr">credentials:</span>
      <span class="hljs-attr">username:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.registry_username</span> <span class="hljs-string">}}</span>
      <span class="hljs-attr">password:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.registry_token</span> <span class="hljs-string">}}</span>
</code></pre>
<h2 id="heading-sharing-data-between-services">Sharing Data Between Services</h2>
<p>You can use volumes to share data between services or other steps in a job. You can specify named Docker volumes, anonymous Docker volumes, or bind mounts on the host. But it’s not directly possible to mount the source code as a container volume. You can refer to this <a target="_blank" href="https://github.com/orgs/community/discussions/42127">open discussion</a> for more context.</p>
<p>To specify a volume, you specify the source and destination path: <code>&lt;source&gt;:&lt;destinationPath&gt;</code></p>
<p>The <code>&lt;source&gt;</code> is a volume name or an absolute path on the host machine, and <code>&lt;destinationPath&gt;</code> is an absolute path in the container.</p>
<pre><code class="lang-yaml"><span class="hljs-attr">volumes:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-string">/src/dir:/dst/dir</span>
</code></pre>
<p>Volumes in Docker (and GitHub Actions using Docker) provide persistent data storage and sharing between containers or job steps, decoupling data from container images.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Before diving into the full source code, let's set up our project for running integration tests with GitHub Service Containers.</p>
<ol>
<li><p>Create a new GitHub repository.</p>
</li>
<li><p>Initialize a Go module using <code>go mod init</code></p>
</li>
<li><p>Create a simple Go application.</p>
</li>
<li><p>Add integration tests in <code>integration_test.go</code></p>
</li>
<li><p>Create a <code>.github/workflows</code> directory.</p>
</li>
<li><p>Create a file named <code>integration-tests.yaml</code> inside the <code>.github/workflows</code> directory.</p>
</li>
</ol>
<h2 id="heading-golang-integration-tests">Golang Integration Tests</h2>
<p>Now as we can provision our external dependencies, let’s have a look at how to run our integration tests in Go. We will do it in the <strong>steps</strong> section of our workflow file.</p>
<p>We will run our tests in a container which uses <a target="_blank" href="https://images.chainguard.dev/directory/image/go/overview">Chainguard Go image</a>. This means we don’t have to install/setup Go. If you want to run your tests directly on a runner machine, you need to use the <a target="_blank" href="https://github.com/actions/setup-go">setup-go</a> Action.</p>
<p>You can find the full source code with tests and this workflow <a target="_blank" href="https://github.com/plutov/service-containers">here</a>.</p>
<p><strong>.github/workflows/integration-tests.yaml</strong></p>
<pre><code class="lang-yaml"><span class="hljs-attr">name:</span> <span class="hljs-string">"integration-tests"</span>

<span class="hljs-attr">on:</span>
  <span class="hljs-attr">workflow_dispatch:</span>
  <span class="hljs-attr">push:</span>
    <span class="hljs-attr">branches:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">main</span>

<span class="hljs-attr">jobs:</span>
  <span class="hljs-attr">integration-tests:</span>
    <span class="hljs-attr">runs-on:</span> <span class="hljs-string">ubuntu-24.04</span>
    <span class="hljs-attr">container:</span> <span class="hljs-string">cgr.dev/chainguard/go:latest</span>

    <span class="hljs-attr">env:</span>
      <span class="hljs-attr">MONGO_URI:</span> <span class="hljs-string">mongodb://mongo:27017</span>
      <span class="hljs-attr">REDIS_URI:</span> <span class="hljs-string">redis://redis:6379</span>

    <span class="hljs-attr">services:</span>
      <span class="hljs-attr">mongo:</span>
        <span class="hljs-attr">image:</span> <span class="hljs-string">mongodb/mongodb-community-server:7.0-ubi8</span>
        <span class="hljs-attr">ports:</span>
          <span class="hljs-bullet">-</span> <span class="hljs-number">27017</span><span class="hljs-string">:27017</span>
        <span class="hljs-attr">options:</span> <span class="hljs-string">&gt;-
          --health-cmd "echo 'db.runCommand("ping").ok' | mongosh mongodb://localhost:27017/test --quiet"
          --health-interval 5s
          --health-timeout 10s
          --health-retries 10
</span>
      <span class="hljs-attr">redis:</span>
        <span class="hljs-attr">image:</span> <span class="hljs-string">redis:7</span>
        <span class="hljs-attr">ports:</span>
          <span class="hljs-bullet">-</span> <span class="hljs-number">6379</span><span class="hljs-string">:6379</span>
        <span class="hljs-attr">options:</span> <span class="hljs-string">&gt;-
          --health-cmd "redis-cli ping"
          --health-interval 5s
          --health-timeout 10s
          --health-retries 10
</span>
    <span class="hljs-attr">steps:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Check</span> <span class="hljs-string">out</span> <span class="hljs-string">repository</span> <span class="hljs-string">code</span>
        <span class="hljs-attr">uses:</span> <span class="hljs-string">actions/checkout@v4</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Download</span> <span class="hljs-string">dependencies</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">go</span> <span class="hljs-string">mod</span> <span class="hljs-string">download</span>

      <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Run</span> <span class="hljs-string">Integration</span> <span class="hljs-string">Tests</span>
        <span class="hljs-attr">run:</span> <span class="hljs-string">go</span> <span class="hljs-string">test</span> <span class="hljs-string">-tags=integration</span> <span class="hljs-string">-timeout=120s</span> <span class="hljs-string">-v</span> <span class="hljs-string">./...</span>
</code></pre>
<p>To summarize what’s going on here:</p>
<ol>
<li><p>We run our job in a container with Go (<strong>container</strong>)</p>
</li>
<li><p>We spin up two services: MongoDB and Redis (<strong>services</strong>)</p>
</li>
<li><p>We configure healthchecks to make sure our services are “Healthy” when we run the tests (<strong>options</strong>)</p>
</li>
<li><p>We perform a standard code checkout</p>
</li>
<li><p>Then we run the Go tests</p>
</li>
</ol>
<p>Once the Action is completed (it took <strong>~1 min</strong> for this example), all the services will be stopped and orphaned so we don’t need to worry about that.</p>
<p><img src="https://miro.medium.com/v2/resize:fit:480/0*QLl4vjotU6o1osy-.png" alt="GitHub Actions Logs: full run" width="480" height="409" loading="lazy"></p>
<h2 id="heading-personal-experience-amp-limitations">Personal Experience &amp; Limitations</h2>
<p>We’ve been using service containers for running backend integration tests at <a target="_blank" href="https://www.binarly.io/">BINARLY</a> for some time, and they work great. But the initial workflow creation took some time and we encountered the following bottlenecks:</p>
<ul>
<li><p>It’s not possible to override or run custom commands in an action service container (as you would do in Docker Compose using the <strong>command</strong> property). <a target="_blank" href="https://github.com/actions/runner/pull/1152">Open pull request</a></p>
<ul>
<li>Workaround: we had to find a solution that doesn’t require that. In our case, we were lucky and could do the same with environment variables.</li>
</ul>
</li>
<li><p>It’s not directly possible to mount the source code as a container volume. <a target="_blank" href="https://github.com/orgs/community/discussions/42127">Open discussion</a></p>
<ul>
<li>While this is indeed a big limitation, you can copy the code from your repository into your mounted directory after the service container has started.</li>
</ul>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>GitHub service containers are a great option to scaffold an ephemeral testing environment by configuring it directly in your GitHub workflow. With configuration being somewhat similar to Docker Compose, it’s easy to run any containerised application and communication with it in your pipeline. This ensures that GitHub runners take care of shutting everything down upon completion.</p>
<p>If you use Github Actions, this approach works extremely well as it is specifically designed for the GitHub Actions environment.</p>
<h3 id="heading-resources">Resources</h3>
<ul>
<li><p><a target="_blank" href="https://github.com/plutov/service-containers">Source Code</a></p>
</li>
<li><p><a target="_blank" href="https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#jobsjob_idservices">GitHub Documentation</a></p>
</li>
<li><p>Discover more articles on <a target="_blank" href="https://packagemain.tech/">packagemain.tech</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
