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

6 May 2026 8 min read

Last updated: 8 October 2026

Share:

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).

yaml
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:

python
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 job

Running 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.

python
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()
text
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: False

What 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?

Yes, for models that train within the job time limit, 6 hours on GitHub-hosted runners. Larger jobs usually run on self-hosted runners or a cloud training service, started and checked by the workflow.

How do I fail a GitHub Actions job when model quality is too low?

Make the training or evaluation script exit with a non-zero code when a metric misses its threshold. The step fails, its job fails, and jobs that depend on it do not run.

How do I schedule model retraining with GitHub Actions?

Add a schedule trigger with a cron expression. Schedules run on the default branch, in UTC unless a time zone is set, at most every five minutes, and can be delayed at busy times, so avoid the start of the hour.

How can I check a workflow file before pushing it?

Run a static checker such as actionlint locally or in a pre-commit hook. It catches many errors, such as unknown jobs, invalid cron expressions and contexts used in the wrong place, though not every misspelled input.

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

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.

Explore course