Free lesson · GenAI Platform Engineering

Compare CI platforms: GitHub Actions vs Tekton vs Dagger

You will build the same CI pipeline on three platforms and compare them. Pipeline: lint (ruff), type-check (mypy), unit tests (pytest), build Docker image, push to Artifact Registry. GitHub Actions: create .github/workflows/ci.yml with jobs for each step, using actions/checkout and docker/build-push-action. Tekton: deploy Tekton Pipelines on GKE (kubectl apply -f tekton-release.yaml). Create a Tekton Pipeline with Tasks: git-clone, lint, test, kaniko-build (builds images inside the cluster without Docker daemon), and push-to-registry. Trigger with a Tekton EventListener that listens for webhook events. Tekton is fully K8s-native — every pipeline step runs as a pod on GKE. Dagger: create a dagger/ci.py using the Dagger Python SDK — define each step as a Python function: lint(), test(), build_image(), push(). Dagger runs identically locally (dagger run python ci.py) and in CI. Dagger uses container-level caching — first run builds everything, subsequent runs only re-execute changed steps. Compare all three: developer experience (YAML vs YAML vs Python code), K8s-native (Tekton runs on GKE, Actions runs on GitHub, Dagger runs anywhere), caching, local debuggability, and vendor lock-in.

Course: DevOps Foundations for GenAI Engineers · Chapter 2 · CI Pipelines with GitHub Actions

Free to read — no subscription required.

Introduction

When you ship a Python service to GKE without a CI workflow that runs lint, type-check, and tests on every push, regressions land in production instead of pull requests — and the cost of a bad merge climbs from minutes to hours. Teams that lean on GitHub-hosted runners hit a different wall: no GPUs, awkward network paths to private registries, and CI minutes billed separately from the cluster they already pay for. This lesson shows how to wire a GitHub Actions workflow that runs ruff, mypy, and pytest on self-hosted GKE runners, so quality gates execute on the same infrastructure that hosts the service. By the end you'll be able to author the workflow YAML, label jobs to land on the right node pool, and orchestrate the three checks so they fail fast on broken code.

Key Terminology

  • GitHub Actions workflow — the YAML file under .github/workflows/ declaring the jobs CI runs on each push or PR; in this lesson it is the unit you author and ship alongside the service.
  • Self-hosted GKE runner — a runner pod managed by the Actions Runner Controller (ARC) inside your GKE cluster; using it instead of ubuntu-latest is what lets jobs reach private Artifact Registry and request GPU node pools.
  • Runner label — the string passed to runs-on: (e.g. gke-cpu, gke-gpu) that maps a job to a runner scale set bound to a specific node pool; an unknown label leaves a job queued indefinitely.
  • Job dependency (needs:) — the keyword that wires jobs into a DAG; needs: [lint, typecheck, test] is what makes the build step gate on the three checks passing.
  • Quality gate — a job whose non-zero exit code blocks the merge; ruff (lint), mypy (type-check), and pytest (tests) are the three gates this workflow enforces.

Concepts

The three-gate workflow shape

A Python CI workflow for a GKE-bound service has three parallel quality jobs — lint, typecheck, test — that all checkout the same source and then fan into a build step. They run in parallel because they share no data; the build step uses needs: to wait for all three to pass before producing the image. This is the shape the code in Code Walkthrough generates.

Loading diagram...

Why GKE runners, not GitHub-hosted

GitHub-hosted runners cannot attach GPUs, cannot route to private Artifact Registry without exposing it, and bill on a separate meter from your cluster. Self-hosted runners on GKE — deployed via the Actions Runner Controller — solve all three: you define runner scale sets bound to specific node pools (CPU-only for lint and type-check, GPU-enabled for inference tests when the suite needs them), the workflow references them by label, and Kubernetes schedules each job onto infrastructure you already pay for.

Runner labels are a typed contract

runs-on: gke-cpu only works if a runner scale set with label gke-cpu is actually registered. Typos like gke-cpus do not fail at parse time — the job just queues forever. The fix is to treat the set of valid labels as a typed allowlist that workflow-generation code validates against, not a free-form string (see Code Walkthrough).

Code Walkthrough

The snippet below ties the three concepts together: it builds a workflow definition with a typed runner-label allowlist (so misconfiguration fails at generation time), wires the three quality gates as parallel jobs, and adds a build job that needs: all three. The companion CIOrchestrator runs the same three commands locally so engineers don't first discover a lint failure inside CI.

Code snippetpython
1import subprocess 2from dataclasses import dataclass, field 3 4ALLOWED_RUNNERS = {"gke-cpu", "gke-gpu", "gke-highmem", "ubuntu-latest"} 5 6STAGES = { 7 "lint": ["python", "-m", "ruff", "check", "src/", "--output-format=json"], 8 "typecheck": ["python", "-m", "mypy", "src/", "--no-error-summary"], 9 "test": ["python", "-m", "pytest", "tests/", "-v", "--tb=short"], 10} 11 12@dataclass 13class Job: 14 name: str 15 runner: str 16 run: str 17 needs: list[str] = field(default_factory=list) 18 19@dataclass 20class WorkflowBuilder: 21 name: str 22 jobs: dict[str, Job] = field(default_factory=dict) 23 24 def add_job(self, job: Job) -> None: 25 if job.runner not in ALLOWED_RUNNERS: 26 raise ValueError( 27 f"job '{job.name}' uses runner '{job.runner}'; " 28 f"allowed: {sorted(ALLOWED_RUNNERS)}" 29 ) 30 self.jobs[job.name] = job 31 32 def to_dict(self) -> dict: 33 return { 34 "name": self.name, 35 "on": ["push", "pull_request"], 36 "jobs": { 37 j.name: { 38 "runs-on": j.runner, 39 **({"needs": j.needs} if j.needs else {}), 40 "steps": [ 41 {"uses": "actions/checkout@v4"}, 42 {"name": j.name, "run": j.run}, 43 ], 44 } 45 for j in self.jobs.values() 46 }, 47 } 48 49class CIOrchestrator: 50 def run_all(self, timeout: int = 300) -> dict: 51 results: dict = {} 52 for name, cmd in STAGES.items(): 53 proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) 54 results[name] = {"passed": proc.returncode == 0, "returncode": proc.returncode} 55 results["passed"] = all(v["passed"] for v in results.values() if isinstance(v, dict)) 56 return results 57 58def build_workflow() -> dict: 59 wf = WorkflowBuilder(name="ci") 60 for stage, cmd in STAGES.items(): 61 wf.add_job(Job(name=stage, runner="gke-cpu", run=" ".join(cmd))) 62 wf.add_job(Job( 63 name="build", 64 runner="gke-cpu", 65 run="kaniko --dockerfile=Dockerfile --destination=$IMAGE", 66 needs=list(STAGES.keys()), 67 )) 68 return wf.to_dict()

ALLOWED_RUNNERS is the single source of truth for valid runs-on labels, and WorkflowBuilder.add_job raises immediately on an unknown one — a typo fails before any YAML is written. STAGES defines the three commands once so both the workflow generator and the local CIOrchestrator invoke them identically; the flags ruff, mypy, and pytest see in CI are the same ones an engineer runs at their desk. build_workflow assembles the three quality jobs as peers and wires the build job to needs: all three, producing the DAG drawn in the diagram above.

You'll know it works when python -c "import yaml, mod; print(yaml.dump(mod.build_workflow()))" emits a workflow whose build job lists [lint, typecheck, test] under needs:, CIOrchestrator().run_all() returns {"passed": True, ...} on a clean checkout, and a deliberate ruff violation flips it to False with the offending stage's passed: False.

Do's and Don'ts

Do's

  1. Do pin every runner label to an ALLOWED_RUNNERS allowlist — a typo in runs-on: queues the job indefinitely instead of failing loudly.
  2. Do run ruff, mypy, and pytest as three parallel jobs that fan into build — sequential gates lengthen feedback time with no correctness benefit since the three checks share no state.
  3. Do invoke the same commands locally that CI runs — engineers should never first discover a lint failure inside a GitHub Actions log.

Don'ts

  1. Don't default to ubuntu-latest for GKE-bound services — you lose access to private Artifact Registry and GPU node pools and pay on a separate meter from the cluster.
  2. Don't let the build job start without needs: [lint, typecheck, test] — without the gate, broken code produces deployable images.
  3. Don't hard-code workflow YAML across repos — generate it from a typed builder so the runner-label contract is checked in one place.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Platform Engineering subscription.

From · cancel anytime

More free lessons in DevOps Foundations for GenAI Engineers

All free lessons in GenAI Platform Engineering