CI Commands

Overview

cikit provides a command framework for local and CI workflows.

Commands can come from two sources:

  • installed command-provider packages

  • an optional project-local ci_commands package

Command providers are useful for reusable commands shared between projects. Local commands contain project-specific workflow policy, orchestration, or deliberate overrides of provider commands.

Command providers are independent of CI providers such as GitLab CI or Azure DevOps. A command provider supplies Python command modules, while a CI provider describes the environment in which those commands run.

The command layer should remain small. It normally defines CLI arguments, groups workflow steps into CI sections, makes project-specific decisions, and orchestrates reusable cikit macros.

For combining several commands into one local workflow, see Local Pipeline.

Architecture

The command flow is:

cikit
   |
   v
cikit.ci.main
   |
   +-- global arguments
   |
   v
cikit.ci.loader
   |
   +-- discover installed command providers
   +-- discover optional local ci_commands
   +-- resolve command names and overrides
   +-- call register(subparsers)
   |
   v
selected command
   |
   v
run_ci_script()
   |
   +-- logging
   +-- CiRunStatus
   +-- CiRunEnvironment
   +-- top-level error handling
   |
   v
command run()
   |
   +-- CiSection / CiOptionalSection
   +-- cikit macros
   +-- project-specific logic

The resulting command set can be executed locally and inside a supported CI environment.

Command Sources

Installed Command Providers

A command provider is a normal Python package that registers its command package through the cikit.commands entry-point group.

For example:

[project]
name = "company-ci-commands"
version = "0.1.0"

[project.entry-points."cikit.commands"]
company = "company_ci_commands"

A minimal provider repository can use the following structure:

company-ci-commands/
|-- pyproject.toml
`-- src/
    `-- company_ci_commands/
        |-- __init__.py
        |-- init.py
        |-- ruff.py
        |-- test.py
        `-- sphinx.py

The entry point references the package containing the command modules, not an individual command. cikit scans its public top-level modules. Modules whose names start with an underscore are ignored.

Provider-specific helper modules should therefore either use private module names or live outside the scanned command package.

A consuming project installs the provider like any other development dependency:

[dependency-groups]
dev = [
    "cikit",
    "company-ci-commands",
]

Installing a Provider from Git

A provider may live in a separate Git repository. With uv, keep the package in the dependency group and configure its source separately:

[dependency-groups]
dev = [
    "cikit",
    "company-ci-commands",
]

[tool.uv.sources]
company-ci-commands = { git = "ssh://git@git.example.com/ci/company-ci-commands.git", tag = "0.1.0"}

The external repository still needs to expose its command package through the cikit.commands entry-point group:

[project.entry-points."cikit.commands"]
company = "company_ci_commands"

After installing the dependencies:

uv sync --group dev

the provider commands are available automatically:

uv run cikit --help

No additional cikit-specific configuration is required in the consuming project.

The distribution name and import package name may differ. In the example, company-ci-commands is the installed distribution while company_ci_commands is the Python package referenced by the entry point.

Project-Local Commands

A project may additionally provide a root-level package named ci_commands:

project/
|-- ci_commands/
|   |-- __init__.py
|   |-- all_.py
|   `-- project_check.py
|-- src/
|-- tests/
`-- pyproject.toml

The local package is optional. A project can rely entirely on installed command providers.

Local commands are primarily useful for project-specific behavior and orchestration commands such as all. They can also override a command supplied by an installed provider.

Command Discovery and Contract

cikit first discovers all installed packages registered in the cikit.commands entry-point group. It then checks for a root-level ci_commands package in the current working directory.

Every public command module must provide:

COMMAND_NAME = "example"

def register(subparsers):
    ...

COMMAND_NAME must be a non-empty string. It is the logical command name used for conflict detection and local overrides.

register() creates the argparse subparser and assigns the command entry point. The same COMMAND_NAME should be used for the parser:

parser = subparsers.add_parser(COMMAND_NAME)
parser.set_defaults(func=command)

Module Name and Command Name

The Python module name does not have to match the CLI command name.

For example:

ci_commands/all_.py

can declare:

COMMAND_NAME = "all"

and is invoked as:

cikit all

Conflict and Override Rules

Command names must be unique within each discovery layer.

Two installed providers exposing the same COMMAND_NAME are an error:

provider A: test
provider B: test
-> error

A local command may intentionally replace one provider command:

provider: test
local:    test
-> local command wins

Two local modules declaring the same COMMAND_NAME are also an error.

Provider conflicts are detected before local commands are applied. A local override therefore cannot hide a conflict between two installed providers.

Command Module Structure

A typical command module separates the logical command name, workflow logic, shared runtime integration, and CLI registration:

  • COMMAND_NAME

  • run()

  • command()

  • register()

Minimal example:

# SPDX-License-Identifier: GPL-3.0-or-later
"""Example cikit command.

Author:
    Sven Schlender
Date:
    2026-10-09
"""

import argparse

from cikit import CiSection
from cikit.ci import run_ci_script


COMMAND_NAME = "example"


def run(env, status, args: argparse.Namespace):
    """Run command logic.

    Args:
        env: Resolved CI run environment.
        status: Shared CI run status.
        args: Parsed command arguments.
    """
    with CiSection("Example step", status=status):
        ...


def command(args: argparse.Namespace) -> int:
    """Run the example command.

    Args:
        args: Parsed command arguments.

    Returns:
        Process exit code.
    """
    return run_ci_script(
        COMMAND_NAME,
        lambda env, status: run(env, status, args),
        args=args,
    )


def register(subparsers: argparse._SubParsersAction):
    """Register the example command.

    Args:
        subparsers: Argparse subparser registry.
    """
    parser = subparsers.add_parser(
        COMMAND_NAME,
        help="Run example workflow.",
    )
    parser.set_defaults(func=command)

Command-specific arguments are also defined in register():

parser.add_argument(
    "paths",
    nargs="+",
    help="Source paths.",
)

run() contains the actual workflow and normally invokes reusable cikit macros inside CiSection or CiOptionalSection.

command() connects that workflow to the shared runtime through run_ci_script().

register() defines the command-specific CLI and assigns command as the argparse callback.

Global Options

The main cikit command provides two global options:

-v, --verbose
-n, --name <run-name>

They are specified before the selected command:

uv run cikit --verbose ruff src tests

uv run cikit --name unittests test tests/unittests

--verbose enables debug-level logging in the shared command runner.

--name overrides the default logical run name passed to run_ci_script(). Run names identify cikit data and artifact metadata.

Shared Command Runner

run_ci_script() provides the common lifecycle for commands.

It handles:

  • logging setup

  • CiRunStatus creation

  • logical run-name resolution

  • CiRunEnvironment initialization

  • execution of the command callback

  • final run-data output

  • top-level CiToolsError handling

Conceptually:

run_ci_script(default_name, callback, args)
   |
   +-- setup_logging()
   +-- create CiRunStatus
   +-- resolve run name
   +-- init_env(run_name)
   +-- callback(env, status)
   +-- display env.data
   `-- return status.exit_code

Environment Initialization

The runner creates the run environment in a dedicated:

Getting env

section.

The resulting environment provides access to:

  • resolved configuration

  • provider metadata

  • cikit data

  • artifact registration

  • timeline queries

  • command argument expansion

See Environment.

Final Run Data

If the command has stored run data, run_ci_script() prints it in a final:

Result

section.

CI Sections

Commands normally structure their work using CiSection.

Example:

from cikit import CiSection

with CiSection("Running build", status=status):
    build_project(env)

A section:

  • emits provider-specific log grouping where available

  • provides readable local output otherwise

  • measures its duration

  • handles CiRecoverableError

  • updates the shared CiRunStatus

Recoverable Errors

When a CiRecoverableError leaves a normal CiSection:

  • the error is logged

  • its exit code is added to CiRunStatus

  • execution continues after the section

This allows reporting and artifact generation to continue while the final command still returns a non-zero exit code.

Optional Sections

CiOptionalSection is intended for best-effort work.

Example:

from cikit import CiOptionalSection
from cikit.macros import chart_timeline

with CiOptionalSection("Create timeline chart", status=status):
    timeline = chart_timeline(
        env,
        keys=["warnings"],
        title="Warnings",
        limit=20,
    )
    timeline.create(env, "warnings")

A recoverable error inside an optional section is logged but does not affect the final run status.

Typical uses include optional timelines and secondary reports.

Run Status

CiRunStatus stores the highest exit code reported by normal CI sections.

This allows a command to continue producing useful outputs after a recoverable failure and still return the appropriate final status.

At the end of run_ci_script(), status.exit_code becomes the command result.

Top-Level Errors

A CiToolsError that escapes the command callback is handled by run_ci_script().

Its message is written to stderr and its exit code is returned. Command modules therefore normally do not need their own outer try/except CiToolsError wrapper.

Using cikit Macros

Commands should orchestrate reusable macros rather than reimplement tool integration.

Common macro modules include:

  • cikit.macros.common

  • cikit.macros.ruff

  • cikit.macros.pylint

  • cikit.macros.pytest

  • cikit.macros.coverage

  • cikit.macros.cmake

  • cikit.macros.platformio

  • cikit.macros.bundle

  • cikit.macros.reports

  • cikit.macros.sphinx

Timeline creation is exported directly from cikit.macros:

from cikit.macros import chart_timeline

The command decides how these reusable pieces are combined.

Example: Ruff Command

A lint command can combine generic Ruff support with project-specific workflow decisions:

import argparse

from cikit import CiOptionalSection, CiSection
from cikit.ci import run_ci_script
from cikit.env import CIProvider
from cikit.macros import chart_timeline, ruff


COMMAND_NAME = "ruff"


def run(env, status, args: argparse.Namespace):
    with CiSection("Running ruff", status=status):
        ruff.ruff(env, args.paths)

    if env.provider == CIProvider.GITLAB:
        with CiSection(
            "Generating GitLab code quality report",
            status=status,
        ):
            ruff.gitlab_code_quality(env, args.paths)

    with CiOptionalSection("Create timeline chart", status=status):
        timeline = chart_timeline(
            env,
            keys=["violations"],
            title="Ruff violations",
            limit=20,
        )
        timeline.create(env, "ruff")

    with CiSection("Generating ruff badge", status=status):
        ruff.badge(env)

    with CiSection("Generating Ruff report", status=status):
        ruff.write_rst(env)


def command(args: argparse.Namespace) -> int:
    return run_ci_script(
        COMMAND_NAME,
        lambda env, status: run(env, status, args),
        args=args,
    )


def register(subparsers: argparse._SubParsersAction):
    parser = subparsers.add_parser(COMMAND_NAME, help="Run Ruff checks.")
    parser.add_argument("paths", nargs="+", help="Paths passed to Ruff.")
    parser.set_defaults(func=command)

The command controls the workflow while the Ruff macro implements reusable tool behavior.

Provider-Specific Decisions

Commands may contain CI-provider-specific decisions when the generated output is inherently provider-specific.

For example:

if env.provider == CIProvider.GITLAB:
    ruff.gitlab_code_quality(env, args.paths)

Generic tool execution should remain provider-independent. CI-provider metadata should normally be consumed through the environment rather than by reading provider-specific operating system variables directly.

See Providers.

Reports and Artifacts

Reports consume run data and already registered artifacts. They should therefore be generated after their producers.

Typical order:

tool execution
    |
run data
    |
artifact registration
    |
optional timeline
    |
report generation

For build commands this is particularly important because BUILD, BUNDLE, LOG, and TIMELINE artifacts must exist before the generic build report is written.

See Builds and Artifacts.

Design Guidelines

Keep command modules small. Their responsibilities should normally be limited to:

  • defining CLI arguments

  • choosing the logical run name

  • opening meaningful CI sections

  • making project-specific decisions

  • invoking reusable macros

  • registering project deliverables

  • generating reports after their inputs exist

Reusable behavior shared between projects belongs in cikit macros or a command provider. Project-specific orchestration can remain in local ci_commands.

See Also