Minimalist gitlab runner harness
Find a file
Maria Matejka b9abde76cf Minor doc updates
Added AI/LLM disclosure, fixed repository links.
2026-10-03 20:59:30 +02:00
debian Minor doc updates 2026-10-03 20:59:30 +02:00
slimerunner Report failure_reason alongside state=failed 2026-10-03 20:25:26 +02:00
pyproject.toml Minor doc updates 2026-10-03 20:59:30 +02:00
README.md Minor doc updates 2026-10-03 20:59:30 +02:00
test.py Add graceful job cancellation support 2026-10-03 19:17:15 +02:00

Slimerunner

A minimalist Python library for writing custom GitLab CI runners.

GitLab's own gitlab-runner binary expects to interpret a job's script: lines itself, inside a shell on whatever executor you've configured. Slimerunner skips that entirely: it implements the Runner REST API directly (jobs/request, log streaming, state updates, artifact download/upload), and leaves what a job actually does up to you, in plain Python, with no shell script in between. That makes it a good fit for runners that aren't really "run arbitrary CI scripts" at all, but a specific, hand-written job (publish a release, update a repository, mirror an artifact somewhere) that happens to be triggered by a GitLab pipeline and reports back through it.

It is not a general-purpose replacement for gitlab-runner -- there's no executor abstraction, no shell interpretation, no support for most of the predefined CI/CD variables beyond what GitLab hands you in the job payload. If you need to run arbitrary .gitlab-ci.yml scripts, use gitlab-runner. If you're writing one specific job's logic in Python and want it triggered by and reporting into GitLab, this is for that.

Installing

pip install .

Requires Python >= 3.10 and requests. A Debian source package is also provided (debian/) via dpkg-buildpackage.

Quick start

A runner needs a Config object with the three values you get back from registering it against a GitLab project (Settings -> CI/CD -> Runners, or the Runners API):

class Config:
    system_id = "s_abcdef123456"
    token = "glrt-..."
    url = "https://gitlab.example.com"   # base URL, no /api/v4

Subclass Runner with the identity it reports to GitLab, then poll for jobs tagged for it:

from slimerunner import Runner, JobCanceled

class MyRunner(Runner):
    name = "my-runner"
    version = "0.1.0"
    revision = "n/a"
    platform = "linux"
    architecture = "amd64"

r = MyRunner(Config)
print(r.verify())  # confirms the token is valid

for job in r.jobs():
    try:
        job.log("Starting work")
        # ... do whatever this job is for ...
        job.checkin_artifact("some output", apath="result.txt")
    except JobCanceled:
        print(f"Job {job.id} was canceled, moving on.")

r.jobs() polls jobs/request (backing off when there's nothing to do), and wraps each job as a context manager: entering it reports running; leaving it reports success/failed (with artifacts attached either way, depending on what you check in) and derives failure_reason for GitLab automatically. You never call job.update_state() for the final state yourself -- just do the work inside the for loop and let the context manager close it out, whether that's by falling through normally, raising, or being canceled.

The Job API

  • job.log(msg) -- appends to the job's trace (shown live in the GitLab UI) and checks for a pending cancellation in the same request (see below).
  • job.download_artifacts(target) -- downloads and extracts every upstream job's artifacts this job declared a dependency on, into target.
  • job.checkin_artifact(file, apath=..., onsuccess=True, onfailure=True) -- registers a file (a pathlib.Path) or raw bytes/string (with an explicit apath) to be bundled into the job's artifact archive when it finishes, optionally only on success or only on failure.
  • job.env -- a dict of the job's CI/CD variables (CI_COMMIT_SHA, CI_COMMIT_TAG, etc.), keyed by name.
  • job.id, job.token, job.job_info, job.git_info, job.dependencies -- the raw fields GitLab sent when handing out the job.

Cancellation

GitLab signals a cancellation asynchronously, via a Job-Status: canceling header on the next log or state-update response -- there's no separate channel to interrupt you mid-computation. Slimerunner surfaces this as a JobCanceled exception, raised the next time you call job.log() or job.update_state() after GitLab reports the job canceling. In other words, every job.log() call is an implicit safe point.

For a block of work that must not be interrupted partway through (e.g. writing a file and checking it in together), wrap it in job.fragile(): a cancellation noticed inside the block is deferred until the block exits cleanly, then raised there instead. Nested fragile() blocks only raise once the outermost one exits, and a real exception raised inside a fragile() block is never masked by a pending cancellation -- it propagates normally, and the cancellation stays pending for the next safe point.

for job in r.jobs():
    try:
        job.log("step one")
        with job.fragile():
            write_file()
            job.checkin_artifact(the_file, apath="out.bin")
        job.log("step two")
    except JobCanceled:
        print(f"Job {job.id} was canceled, moving on.")

Declaring cancellation support also requires telling GitLab about it: RunnerFeatures already sets cancelable and cancel_gracefully to True, so this works out of the box.

Runner features

RunnerFeatures (sent as part of every jobs/request poll) currently declares: artifacts, upload_multiple_artifacts, upload_raw_artifacts, cancelable, cancel_gracefully.

Example

See test.py for a complete, runnable example against a real GitLab instance (it expects a hand-written Config.py alongside it, per the comment at its top).

Licence

GNU GPL 3+

AI / LLM Disclosure

This project has been partially vibecoded by Claude, eye-reviewed and hand-tested.

Contributing

Feel free to send a pull request; for larger updates send an e-mail first.