Building a Complete CI Pipeline and Automatic Release Workflow on GitHub

A friendly, no-panic walkthrough for building a CI pipeline that tests your code, tags it, packages it, and ships a GitHub Release all by itself. You write code, GitHub does the rest. Towels optional.

Building a Complete CI Pipeline and Automatic Release Workflow on GitHub
GitHub workflow automation

1. Introduction — or: How We Stopped Worrying and Let GitHub Actions Do the Work

You commit code. GitHub runs tests. Everything passes.
You push to your release branch.
Boom — a new version is tagged, zipped, and published as a Release.

Sounds simple, right?

Well… yes. But also no. This post walks you through every step from zero to the final release ZIP file sitting proudly in GitHub.

We’ll build:

  • A test pipeline
  • A compatibility test matrix
  • A security scan
  • A conditional release workflow
  • Automatic tagging
  • Changelog generation
  • Release ZIP packaging
  • Publishing the Release + assets to GitHub

And we’ll do it gently, like explaining Git to a cat.

2. The Big Picture — How Our Workflow Works

Before writing any YAML (don’t worry, we’ll get there), let’s understand the flow:

GitHub workflow

The pipeline structure

  1. Every push & PR
    Runs:
    • Unit tests
    • Compatibility tests
    • Security scans
  2. Only on specific branches (main, release/,)
    And only when all previous jobs succeeded, the Release workflow.
  3. The Release workflow:
    • Determine version number
    • Tag the commit
    • Generate changelog
    • Build ZIP package
    • Create GitHub Release
    • Upload ZIP asset

Like a factory assembly line… but for code.

3. Preparing Your Repository

Before writing CI scripts, make sure your repo is ready.

3.1. Folder Structure

Suggested structure:

.
├── modules/
├── bash-utils.sh
├── README.md
├── LICENSE
├── .github/
│   └── workflows/
│       ├── test.yml
│       └── release.yml
└── .gitignore

In my little project, I put everything into ci.yml.

3.2. Create the .github/workflows directory

Place all workflow YAML files inside:

mkdir -p .github/workflows

4. Writing the Test Workflow (CI Stage 1)

This workflow runs for all pushes and pull requests.
It ensures your code isn’t quietly plotting world domination.

Example file:

ci.yml

    - name: Run complete test suite
      run: |
        echo "==> Running complete test suite"
        bats tests/*.bats

5. Compatibility Testing (CI Stage 2)

This is your matrix (Bash versions, Python versions, OS variations — whatever you need).

Example matrix:

runs-on: ubuntu-latest
    strategy:
      matrix:
        bash-version: ['4.4', '5.0', '5.1', 'latest']

6. Security Scan (CI Stage 3)

Even Bash scripts deserve a security scan, if not a warm hug.

Examples:

  • ShellCheck
  • Dependency scanners

7. The Release Workflow — The Star of the Show

Now we get to the juicy part.

This workflow only runs IF:

  • All tests passed
  • AND you're on:
    • main
    • any release/* branch

Here is the full workflow (final version from our conversation), broken down step-by-step so your readers understand every moving part.

8. Step-by-Step Breakdown of the Release Workflow

8.1. Step 1 — Checkout Full Repository

We fetch all history, including tags.

- name: Checkout repository
  uses: actions/checkout@v4
  with:
    fetch-depth: 0

In the logs of your workflow, you should see something like:


Run actions/checkout@v4
Syncing repository: dolpa/dolpa-bash-utils
Getting Git version info
Copying '/Users/runner/.gitconfig' to '/Users/runner/work/_temp/a437752d-e297-49b2-8122-dee56066a155/.gitconfig'
Temporarily overriding HOME='/Users/runner/work/_temp/a437752d-e297-49b2-8122-dee56066a155' before making global git config changes
Adding repository directory to the temporary git global config as a safe directory
/opt/homebrew/bin/git config --global --add safe.directory /Users/runner/work/dolpa-bash-utils/dolpa-bash-utils
Deleting the contents of '/Users/runner/work/dolpa-bash-utils/dolpa-bash-utils'
Initializing the repository
Disabling automatic garbage collection
Setting up auth
Fetching the repository
Determining the checkout info
/opt/homebrew/bin/git sparse-checkout disable
/opt/homebrew/bin/git config --local --unset-all extensions.worktreeConfig
Checking out the ref
/opt/homebrew/bin/git log -1 --format=%H
a94d91e43d64521da41d2bd9b09cbacdd6c9704f

8.2. Step 2 — Determining the Version Number Automatically

We use date-based versioning.

  • On main: vYYYY.MM.DD
  • On release: vYYYY.MM.DD
  • For other branches, you can use: vYYYY.MM.DD.HHMM
      - name: Get library version
        id: version
        run: |
          # new versioning scheme: date-based
          if [[ "${{github.ref }}" == "refs/heads/main" ]]; then
            VERSION="v$(date +'%Y.%m.%d')"
          else
            VERSION="v$(date +'%Y.%m.%d.%H%M')"
          fi

          echo "Determined version: $VERSION"
          echo "VERSION=$VERSION" >> $GITHUB_ENV
          echo "version=$VERSION" >> $GITHUB_OUTPUT
        shell: bash

8.3. Step 3 — Configure Git Identity

GitHub runners do not have a committer identity.

git config user.name "github-actions"
git config user.email "github-actions@github.com"

8.4. Step 4 — Create the Tag (Safely)

This part ensures the workflow does not die if the tag already exists.

if git rev-parse "${VERSION}" >/dev/null 2>&1; then
  echo "Tag exists, skipping"
else
  git tag -a "${VERSION}" -m "Release ${VERSION}"
  git push origin "${VERSION}"
fi

8.5. Step 5 — Build the Changelog

We compare last tag → current commit.

git log --pretty=format:"- %s (%h)" PREV_TAG..HEAD

8.6. Step 6 — Create the ZIP File (Release Asset)

zip -r dist/bash-utils-${VERSION}.zip \
  README.md \
  RELEASE_NOTES.md \
  LICENSE \
  modules \
  bash-utils.sh

8.7. Step 7 — Create GitHub Release + Upload Asset

No second step needed — both upload and release creation happen here.

      - name: Create GitHub Release & Upload Assets
        uses: softprops/action-gh-release@v2
        with:
          tag_name: ${{ steps.version.outputs.version }}
          name: "bash-utils ${{ steps.version.outputs.version }}"
          body_path: RELEASE_NOTES.md
          draft: false
          prerelease: false
          files: dist/*.zip
          overwrite_files: true
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

9. Full Release Workflow (Insertable Code Block)

Here is the final complete version for your blog (same one from our conversation):

Check it on GitHub here.

10. Troubleshooting Section (Because Something Will Break)

A friendly section explaining common issues:

❌ Error: “Write access to the repository not granted”

You’re cloning with the wrong credentials.

❌ Error: “Tag already exists”

We fixed this: workflow no longer fails.

❌ Error: “Requires a tag”

We fixed this by ensuring tag_name is always provided.

❌ Error: “empty ident name”

Configure Git identity inside the workflow.

11. Conclusion — You Now Have a Full CI/CD Pipeline

At this point, your GitHub repository practically maintains itself.

Push code → GitHub tests it → tags it → packages it → and publishes a release.
All you do is sip coffee and look wise.

And if something breaks?

Well… that’s what logs are for. And panic. And maybe a chocolate bar.

Read next

Automating Git Bisect with Scripts or Testing Frameworks

In our previous posts, we’ve explored the fundamental concepts of Git Bisect and how to effectively use it for debugging by identifying the commit that introduced a bug. One of the most powerful features of Git Bisect is its ability to automate the testing process using scripts or testing.

Step-by-Step Guide to Debugging with Git Bisect

Debugging complex codebases can be one of the most challenging tasks in software development, especially when you're trying to pinpoint exactly where a bug was introduced. Git Bisect is an essential tool that simplifies this task by using a binary search algorithm to identify the problematic commit.

Git Bisect: Debugging with Binary Search

Debugging issues in a large codebase can be challenging, especially when you need to identify which specific commit introduced a bug. If you have hundreds or even thousands of commits in your Git history, manually checking each one to locate the problematic change is time-consuming and error-prone.