- Python 100%
|
|
||
|---|---|---|
| debian | ||
| slimerunner | ||
| pyproject.toml | ||
| README.md | ||
| test.py | ||
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, intotarget.job.checkin_artifact(file, apath=..., onsuccess=True, onfailure=True)-- registers a file (apathlib.Path) or raw bytes/string (with an explicitapath) 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.