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

Handling and Resolving Conflicts During Rebasing

Rebasing is a common technique to keep a clean and linear history. However, one of the challenges developers face while performing a rebase is dealing with merge conflicts. During a rebase, conflicts may arise if the changes in your branch overlap or contradict the changes in the branch

🪐 Publishing the HHGTTG Bash Module on GitHub

I packaged my small Bash module, cleaned the code, added structure, and finally published it on GitHub. In this post, I walk through preparing a project for public release, choosing a layout, documenting it properly, and getting it ready for future automation.