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

Managing Tags in Large Projects and Automated Pipelines

Git tags are very useful for marking milestones, version, and other important points in your code. When managing large projects, tagging can help in maintaining an organized workflow. Integrating tags into automated pipelines can help streamline your development and deployment in software releases.

Annotated vs. Lightweight Tags in Git: Which One Should You Use?

Git tags are crucial for marking important points in your repository's history, such as version releases. But not all tags in Git are created equal. There are two types of tags you can use: annotated and lightweight tags. Understanding the differences between these tags is vital.

Using Git Tags to Mark Version Releases

Git tags are an essential part of software versioning and release management. They allow you to mark specific points in your repository's history as important milestones, such as a version release. Unlike branches, which continue to evolve over time, tags are immutable and serve as fixed pointers.