DEV Community

Cover image for Zero to Published: Building an Automated Release Pipeline with GitHub Actions, Mise, and DevPod
Alan Varghese
Alan Varghese

Posted on

Zero to Published: Building an Automated Release Pipeline with GitHub Actions, Mise, and DevPod

We've all been there: you finish implementing a new feature, run a quick manual check in your terminal, zip up the build directory by hand, navigate to GitHub, click Releases, upload your zip file, and write a release note on the fly.

Then, ten minutes later, a user opens an issue: "The release package is missing a critical config file," or "The build fails on Python 3.12 because of an untracked dependency mismatch."

Manual releases are slow, error-prone, and painful to reproduce. In modern software engineering, every step between writing code and shipping a release should be automated, deterministic, and protected by quality gates.

In this article, I'll walk you through how I built an end-to-end automated release pipeline for a Python project hosted inside a multi-project monorepo (alanvarghese-dev/lab). Weโ€™ll cover:

  1. Deterministic Development Environments using DevPod and Mise.
  2. Quality Gates with Ruff (linting) and Pytest (unit testing).
  3. CI/CD Pipeline Orchestration using GitHub Actions.
  4. Automated Release Packaging & Publishing triggered seamlessly by Git tags.
  5. Real-World Gotchas encountered along the way (and how to solve them).

๐Ÿ—๏ธ Architecture & Monorepo Layout

Before jumping into the code, letโ€™s look at the structure. This project resides as a subproject within a larger DevOps lab repository (alanvarghese-dev/lab). This setup allows multiple DevOps experiments and pipelines to coexist without cluttering separate repositories.

Here is how the repository is structured:

/workspaces/ (Repository Root)
โ”œโ”€โ”€ .github/
โ”‚   โ””โ”€โ”€ workflows/
โ”‚       โ””โ”€โ”€ automated-release.yml      # Root CI/CD workflow
โ”‚
โ””โ”€โ”€ automated-release-pipeline/        # Subproject Directory
    โ”œโ”€โ”€ .devcontainer/
    โ”‚   โ”œโ”€โ”€ Dockerfile                 # DevPod container specification
    โ”‚   โ””โ”€โ”€ .devcontainer.json         # DevContainer build context
    โ”œโ”€โ”€ tests/
    โ”‚   โ””โ”€โ”€ test_app.py                # Automated Pytest suite
    โ”œโ”€โ”€ app.py                         # Core application entrypoint
    โ”œโ”€โ”€ mise.toml                      # Declarative tool & version manager
    โ”œโ”€โ”€ pytest.ini                     # Pytest discovery & module search path
    โ”œโ”€โ”€ .gitignore                     # Clean repository boundaries
    โ””โ”€โ”€ dist/
        โ””โ”€โ”€ automated-release-pipeline.tar.gz  # Generated release artifact
Enter fullscreen mode Exit fullscreen mode

๐Ÿ’ก Monorepo Tip: Because the Git root is at /workspaces while our application lives under /workspaces/automated-release-pipeline/, our GitHub Actions workflow is defined at the root .github/workflows/ directory, while workflow steps target the subproject folder.


๐Ÿ“ฆ Step 1: Isolated Environment with DevPod & Mise

One of the biggest friction points in developer velocity is the classic "works on my machine" problem. To achieve 100% parity between local development and CI runners, we combine DevPod (containerized development) with Mise (modern polyglot tool manager).

1. DevPod Configuration

Using a DevContainer specification based on Ubuntu 24.04, we bake mise directly into the container image:

# .devcontainer/Dockerfile
FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04

# Copy Mise binary directly from official image
COPY --from=jdxcode/mise /usr/local/bin/mise /usr/local/bin/

# Auto-activate Mise in interactive shells
RUN echo 'eval "$(mise activate bash)"' >> /home/vscode/.bashrc && \
    echo 'eval "$(mise activate zsh)"' >> /home/vscode/.zshrc
Enter fullscreen mode Exit fullscreen mode

And configure the DevContainer manifest:

// .devcontainer/devcontainer.json
{
  "name": "automated-release-pipeline",
  "build": {
    "context": "..",
    "dockerfile": "Dockerfile"
  },
  "remoteUser": "vscode"
}
Enter fullscreen mode Exit fullscreen mode

2. Declarative Tooling with mise.toml

Instead of relying on whatever Python version is globally installed on your host OS, mise.toml locks down the exact runtimes, package managers, and CLI tools:

# mise.toml
[tools]
python = "3.12"
pipx = "latest"
"pipx:pytest" = "latest"
"pipx:ruff" = "latest"
Enter fullscreen mode Exit fullscreen mode

With one command:

mise install
Enter fullscreen mode Exit fullscreen mode

Mise downloads and configures Python 3.12, Pytest, and Ruff in an isolated sandbox.


๐Ÿงช Step 2: Application Code & Quality Gates

To validate the release workflow, we have a clean Python application with corresponding unit tests and linter configurations.

1. The Application (app.py)

# app.py
def add(a, b):
    return a + b


if __name__ == "__main__":
    print(f"2 + 3 = {add(2, 3)}")
Enter fullscreen mode Exit fullscreen mode

2. Unit Testing with Pytest (tests/test_app.py)

# tests/test_app.py
from app import add


def test_add():
    assert add(2, 3) == 5


def test_add_negative_numbers():
    assert add(-2, -3) == -5
Enter fullscreen mode Exit fullscreen mode

3. Configuring Pytest (pytest.ini)

When tests reside in a subdirectory (tests/) separate from the root source files, Python's import engine needs to know where to find top-level modules. We declare this cleanly in pytest.ini:

# pytest.ini
[pytest]
testpaths = tests
pythonpath = .
Enter fullscreen mode Exit fullscreen mode

4. Local Verification Commands

Before pushing any commit, we verify our quality gates locally using mise exec:

# 1. Run the application
mise exec -- python app.py

# 2. Run Ruff linter (code quality)
mise exec -- ruff check .

# 3. Run Pytest suite
mise exec -- pytest
Enter fullscreen mode Exit fullscreen mode

โšก Step 3: CI/CD Pipeline Orchestration with GitHub Actions

Now comes the core automation engine. We want GitHub Actions to:

  1. Trigger automatically whenever a release tag (e.g. v*) or commit is pushed.
  2. Spin up Mise and install our pinned toolset.
  3. Enforce Quality Gates (Ruff + Pytest). If either fails, immediately halt the pipeline.
  4. Package the application assets into a clean .tar.gz distribution archive.
  5. Upload the artifact to the GitHub workflow run.
  6. Publish a formal GitHub Release with auto-generated changelogs and the release tarball attached.

The Pipeline Architecture

flowchart TD
    A["Developer pushes Git Tag (e.g., v1.0.0)"] --> B["GitHub Actions Triggered"]
    B --> C["Checkout Repo (actions/checkout@v4)"]
    C --> D["Setup Mise (jdx/mise-action@v3)"]
    D --> E["Install Dependencies (mise install)"]
    E --> F["Lint Gate (mise exec -- ruff check .)"]
    F --> G["Test Gate (mise exec -- pytest)"]
    G --> H["Build Release Archive (dist/automated-release-pipeline.tar.gz)"]
    H --> I["Upload CI Artifact (actions/upload-artifact@v4)"]
    I --> J["Publish GitHub Release (softprops/action-gh-release@v2)"]
    J --> K["๐ŸŽ‰ Release Published with Tarball Attached"]

    F -.->|Linter Error| L["โŒ Halt Pipeline"]
    G -.->|Test Failure| L

The GitHub Actions Workflow File (.github/workflows/automated-release.yml)

name: Automated Release Pipeline

on:
  push:
    branches:
      - main
    tags:
      - 'v*'
  pull_request:
    branches:
      - main

permissions:
  contents: write

jobs:
  validate-and-release:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ./automated-release-pipeline

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Setup Mise
        uses: jdx/mise-action@v3

      - name: Install Tools via Mise
        run: mise install

      - name: Code Quality Check (Ruff)
        run: mise exec -- ruff check .

      - name: Run Test Suite (Pytest)
        run: mise exec -- pytest

      - name: Package Release Tarball
        if: startsWith(github.ref, 'refs/tags/v')
        run: |
          mkdir -p dist
          tar -czvf dist/automated-release-pipeline.tar.gz app.py mise.toml pytest.ini

      - name: Upload Build Artifact to Workflow Run
        if: startsWith(github.ref, 'refs/tags/v')
        uses: actions/upload-artifact@v4
        with:
          name: automated-release-pipeline-dist
          path: automated-release-pipeline/dist/automated-release-pipeline.tar.gz

      - name: Create GitHub Release
        if: startsWith(github.ref, 'refs/tags/v')
        uses: softprops/action-gh-release@v2
        with:
          files: automated-release-pipeline/dist/automated-release-pipeline.tar.gz
          generate_release_notes: true
Enter fullscreen mode Exit fullscreen mode

๐Ÿท๏ธ Step 4: The Tag-Driven Release Flow in Action

With the workflow in place, cutting a new release is completely frictionless. Here is the exact end-to-end developer workflow:

1. Test and Stage Locally

# Verify all quality checks pass
mise exec -- ruff check .
mise exec -- pytest

# Stage and commit your changes
git add .
git commit -m "feat: complete automated release pipeline setup"
git push origin main
Enter fullscreen mode Exit fullscreen mode

2. Cut and Push an Annotated Release Tag

# Create an annotated Git tag
git tag -a v1.0.0 -m "Release v1.0.0"

# Explicitly push the tag reference
git push origin refs/tags/v1.0.0
Enter fullscreen mode Exit fullscreen mode

3. The Result

Once pushed:

  • GitHub Actions detects refs/tags/v1.0.0.
  • Tools are provisioned via jdx/mise-action.
  • Ruff and Pytest execute in seconds.
  • dist/automated-release-pipeline.tar.gz is built and uploaded.
  • A GitHub Release v1.0.0 is published with the tarball attached and release notes automatically generated from commit history!

๐Ÿ› Troubleshooting & War Stories

Real engineering is never without bumps. Here are three common issues encountered during this project and how to fix them:

1. Pytest ModuleNotFoundError: No module named 'app'

  • Problem: Running pytest from the root directory failed to import app.py inside tests/test_app.py.
  • Root Cause: By default, pytest does not always add the current working directory to sys.path.
  • Solution: Create a pytest.ini file in the project root containing:
  [pytest]
  testpaths = tests
  pythonpath = .
Enter fullscreen mode Exit fullscreen mode

2. Git Tag Push Error: src refspec v1.0.0 does not match any

  • Problem: Running git push origin v1.0.0 failed with a refspec mismatch error.
  • Root Cause: The tag had not been created as an annotated tag or was ambiguous in local refs.
  • Solution: Verify the local tag exists with git tag, create an annotated tag with git tag -a v1.0.0 -m "Release v1.0.0", and push explicitly using the full reference path:
  git push origin refs/tags/v1.0.0
Enter fullscreen mode Exit fullscreen mode

3. Monorepo Path Context in GitHub Actions

  • Problem: Actions failed when looking for mise.toml at the repository root.
  • Root Cause: The pipeline is inside /workspaces/automated-release-pipeline/, but GitHub Actions defaults to the root of the Git repo.
  • Solution: Set defaults.run.working-directory: ./automated-release-pipeline in the GitHub Actions job configuration so that every run step executes in the correct subdirectory.

๐Ÿ”ฎ What's Next? (Future Roadmap)

While this pipeline is rock solid for our Python application, here are great next-level enhancements for enterprise production systems:

  • [ ] Docker Image Builds & Container Registry: Package the application into a minimal Docker container and push to GitHub Container Registry (GHCR).
  • [ ] Automated Semantic Versioning: Incorporate tools like semantic-release or commitizen to parse Conventional Commits and auto-bump versions without manual tagging.
  • [ ] Security Scanning (SAST): Add tools like Bandit and Trivy to scan dependencies and source code for CVEs during CI.
  • [ ] Deployment Environments: Add deployment stages (Dev -> Staging -> Prod) with approval gates using GitHub Environments.

๐Ÿง  Key Takeaways

A release pipeline is much more than a simple build script. A true production-ready release pipeline connects:

$$\text{Source Control} \longrightarrow \text{Tool Management} \longrightarrow \text{Quality Gates} \longrightarrow \text{Artifact Packaging} \longrightarrow \text{Distribution}$$

By combining DevPod for containerized dev environments, Mise for unified tooling, Pytest & Ruff for zero-defect quality gates, and GitHub Actions for tag-driven releases, we eliminated manual errors and turned releases into a one-command celebration. ๐ŸŽ‰


๐Ÿ’ฌ How do you manage releases in your projects? Are you using Mise, ASDF, or Docker for tool versioning? Let's discuss in the comments below!

Top comments (1)

Collapse
 
raknaos profile image
Baptiste Le Bouquin

Tag-triggered releases are the part I'd defend hardest from your list. The moment "the release" becomes "whatever zip someone built on their laptop at 11pm", you get exactly the class of issue you opened with โ€” missing config file, works-on-my-machine. Everything deriving from a git tag is the only setup where I stopped having to think about which build is canonical.

Two things that bit me on similar pipelines. First: build artifacts in CI, never locally โ€” a tar.gz produced on a dev machine carries whatever toolchain drift that machine has accumulated, and that's precisely what mise is supposed to pin, so it's worth double-checking the CI runner resolves the exact same mise.toml instead of a loose approximation. Second: pin your actions by commit SHA, not by tag โ€” a @v4 tag that moves under you is a supply-chain surprise you don't want to discover mid-release.

One question, since I spotted dist/automated-release-pipeline.tar.gz in your repo tree: is that committed on purpose, or a leftover? If it's committed you'll eventually get merge conflicts on binary churn โ€” if it's a local build that snuck in, a .gitignore entry plus the CI artifact step makes the pipeline the single source of truth.