GitHub Actions for ML: Automating Workflows
GitHub Actions can test, train and gate a model on every change. This guide writes a complete workflow for a real concrete-strength model, runs its test and training steps locally, and lints it with actionlint, which caught four of five planted mistakes and missed one that only a careful review would find.
GloryTecks Team
Training & Placement Faculty
Last updated: 8 October 2026
GitHub Actions runs workflows when things happen in a repository: a pull request opens, code lands on the main branch, a schedule fires. For a machine learning project that means every change can be checked, every model trained and measured against a quality bar, and every release gated, without anyone remembering to do it.
This guide builds a complete workflow for a small but real model, runs its steps locally, and checks the workflow file itself with a linter.
How a workflow is put together
A workflow is a YAML file under .github/workflows. Its triggers say when it runs. Its jobs run on fresh virtual machines, called runners, in parallel unless one job needs another. Each job is a list of steps, either shell commands or reusable actions. A step that exits with a non-zero code fails its job, and that simple rule is how a quality gate works.
The workflow
The project predicts the compressive strength of concrete from its ingredients and age, using the UCI dataset of 1,030 mixes, under CC BY 4.0 (UCI: Concrete Compressive Strength).
name: train-and-check
on:
pull_request:
paths: ["features.py", "train.py", "requirements.txt", "data/**"]
push:
branches: [main]
schedule:
- cron: "17 3 * * 1" # Mondays 03:17 UTC, away from the busy top of the hour
workflow_dispatch:
permissions:
contents: read
concurrency:
group: train-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.11"
cache: pip
- run: pip install -r requirements.txt
- run: python -m pytest -q
train:
needs: test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.11"
cache: pip
- run: pip install -r requirements.txt
- run: python train.py --min-r2 0.85
- uses: actions/upload-artifact@v7
with:
name: model
path: |
model.joblib
metrics.json
publish:
needs: train
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # can require a reviewer's approval before this job runs
steps:
- uses: actions/download-artifact@v8
with:
name: model
- run: python -c "import json; print(json.load(open('metrics.json')))"
- run: echo "Upload model.joblib to the model registry here"
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}What each part does:
- Triggers. Pull requests that touch code or data, pushes to main, a weekly schedule and manual runs. Schedules use cron in UTC by default and can take an IANA time zone. They run only on the default branch, at most every five minutes, can be delayed at busy times such as the start of each hour, and in public repositories they stop after 60 days without activity (GitHub Docs: events). Hence the odd minute in the schedule.
- Concurrency. A newer run on the same branch cancels an older one, so stale training jobs do not pile up.
- Caching. setup-python can cache pip, pipenv or poetry downloads, but only when asked; caching is off by default (GitHub: setup-python).
- Jobs in order. Tests run first; training runs only if they pass; publishing runs only on main.
- Artefacts. The trained model and its metrics are uploaded, and the publish job downloads exactly that model.
- An approval step. The production environment can require up to six reviewers, one of whom must approve. On Free, Pro and Team plans, required reviewers are available only for public repositories (GitHub Docs: environments).
- Secrets. The registry token is read from repository secrets. Secrets other than GITHUB_TOKEN are not passed to runs triggered from forks (GitHub Docs: secrets).
The quality gate lives in the training script, as an exit code:
import argparse
import json
import sys
import joblib
import pandas as pd
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.metrics import mean_absolute_error, r2_score
from sklearn.model_selection import train_test_split
from features import add_features
parser = argparse.ArgumentParser()
parser.add_argument("--min-r2", type=float, required=True) # the quality gate, set by the workflow
args = parser.parse_args()
mix = add_features(pd.read_csv("data/concrete.csv")) # UCI Concrete Compressive Strength (CC BY 4.0)
train, test = train_test_split(mix, test_size=0.2, random_state=0)
features = [c for c in mix.columns if c != "strength_mpa"]
model = HistGradientBoostingRegressor(random_state=0).fit(train[features], train.strength_mpa)
predicted = model.predict(test[features])
metrics = {"rows": len(mix), "r2": round(r2_score(test.strength_mpa, predicted), 3),
"mae_mpa": round(mean_absolute_error(test.strength_mpa, predicted), 2)}
json.dump(metrics, open("metrics.json", "w"), indent=2)
joblib.dump(model, "model.joblib")
print(json.dumps(metrics))
if metrics["r2"] < args.min_r2:
sys.exit(f"R2 {metrics['r2']} is below the required {args.min_r2}") # a non-zero exit fails the jobRunning the steps and linting the file
A workflow file is code, and mistakes in it surface only after a push and a wait. actionlint is a static checker for workflow files (actionlint). The script below runs the workflow's own test and training commands locally, then lints the workflow and a copy with five planted mistakes.
import re
import subprocess
import sys
from pathlib import Path
def run(*command):
result = subprocess.run(command, capture_output=True, text=True)
return result.returncode, (result.stdout + result.stderr).strip()
code, out = run("actionlint", ".github/workflows/train.yml")
print(f"actionlint, workflow as written: {out or 'no problems'} (exit {code})")
code, out = run(sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider")
passed = re.search(r"\d+ passed", out).group()
print(f"pytest: {passed} (exit {code})")
for gate in ("0.85", "0.95"): # the same command the train job runs
code, out = run(sys.executable, "train.py", "--min-r2", gate)
print(f"train.py --min-r2 {gate}: {out.splitlines()[-1]} (exit {code})")
workflow = Path(".github/workflows/train.yml").read_text()
MISTAKES = [('needs: test\n runs-on: ubuntu-latest', 'needs: tests\n runs-on: ubuntu-lates'), # two typos
('cron: "17 3 * * 1"', 'cron: "17 3 * * 8"'), # no day 8
("if: github.ref == 'refs/heads/main'", "if: secrets.REGISTRY_TOKEN != ''"), # wrong context
('python-version: "3.11"', 'python_version: "3.11"')] # misspelled input
for old, new in MISTAKES:
workflow = workflow.replace(old, new, 1)
Path("broken.yml").write_text(workflow)
code, out = run("actionlint", "-oneline", "broken.yml")
print(f"\nactionlint, the same workflow with five mistakes (exit {code}):")
for line in out.splitlines():
location, message = line.split(": ", 1)
rule = re.search(r"\[[a-z-]+\]$", message).group()
print(f" line {location.split(':')[1]}: {message.split('. available')[0].removesuffix(rule).strip()} {rule}")
print(f" problems reported: {len(out.splitlines())}; misspelled python_version input reported: {'python_version' in out}")
Path("broken.yml").unlink()actionlint, workflow as written: no problems (exit 0)
pytest: 2 passed (exit 0)
train.py --min-r2 0.85: {"rows": 1030, "r2": 0.941, "mae_mpa": 2.75} (exit 0)
train.py --min-r2 0.95: R2 0.941 is below the required 0.95 (exit 1)
actionlint, the same workflow with five mistakes (exit 1):
line 9: invalid CRON format "17 3 * * 8" in schedule event: end of range (8) above maximum (6): 8 [events]
line 31: job "train" needs job "tests" which does not exist in this workflow [job-needs]
line 33: label "ubuntu-lates" is unknown [runner-label]
line 52: context "secrets" is not allowed here [expression]
problems reported: 4; misspelled python_version input reported: FalseWhat the output shows
- The workflow passed the linter, and its steps worked: two tests passed, and the model reached an R² of 0.941 with a mean error of 2.75 MPa.
- The gate does its job. With a bar of 0.85 the training step exited 0; with a bar of 0.95 it exited 1, which would fail the train job and stop the publish job from running.
- The linter caught four of five mistakes: an impossible cron day, a dependency on a job that does not exist, a misspelled runner label, and secrets used in a job condition where that context is not available.
- It missed one. The misspelled python_version input went unreported, apparently because this actionlint release does not yet describe version 7 of setup-python. Linters only know what they have been taught; reviews and a first real run still matter.
Limits worth knowing
Jobs on GitHub-hosted runners can run for up to 6 hours, and on self-hosted runners up to 5 days (GitHub Docs: limits). Heavy training belongs on dedicated machines or a cloud training service, with the workflow starting the job and checking its result. For GPUs or private data, self-hosted runners are the usual answer.
For the pipeline design behind this workflow, see CI/CD for machine learning. For handling the registry token and other credentials, see secrets management for ML pipelines. For writing the tests the first job runs, see testing machine learning code and data.
Common mistakes
- Training on every push instead of only when code or data change.
- Scheduling exactly on the hour, when delays are most likely.
- Quality checks that print warnings instead of failing the job.
- Secrets that forked pull requests are expected to use.
- Workflow changes merged without linting.
Can GitHub Actions train machine learning models?
How do I fail a GitHub Actions job when model quality is too low?
How do I schedule model retraining with GitHub Actions?
How can I check a workflow file before pushing it?
Git, GitHub and branching are covered in the Version Control and Collaboration module of the MLOps course, and automated testing and deployment in its CI/CD for Machine Learning module.
Sources
- Events that trigger workflows (GitHub Docs) — GitHub
- actions/setup-python (GitHub) — GitHub
- Using secrets in GitHub Actions (GitHub Docs) — GitHub
- Managing environments for deployment (GitHub Docs) — GitHub
- Actions limits (GitHub Docs) — GitHub
- actionlint (GitHub) — rhysd
- Concrete Compressive Strength dataset (UCI Machine Learning Repository) — UCI Machine Learning Repository
GloryTecks Team
Training & Placement Faculty
The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.
Want to master MLOps?
Join GloryTecks’ expert-led MLOps training in Hyderabad with real-time projects and 100% placement support.
Related articles
MLOps Roadmap 2026: Skills, Tools and Career Path
MLOps sits between data science, software engineering and operations, which makes it hard to know where to start. This roadmap orders the skills from foundations to production operations, gives each stage a project that proves the skill, and explains what the MLOps engineer role actually involves.
MLflow Tutorial: Tracking Experiments and Models
MLflow records what produced each model so you never have to remember. This tutorial trains three models on real bike-sharing data, logs every run, compares them by querying the tracking store, registers the best one, points a champion alias at it and loads it back by that alias, all on a laptop.
Docker for Machine Learning: A Practical Guide
How to containerise a machine-learning project so it runs the same everywhere: pinning the exact library versions a saved model depends on, a Dockerfile ordered for fast rebuilds, keeping data out of images, multi-stage builds, non-root users and GPU access.