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_commandspackage
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_NAMErun()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.
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
CiRecoverableErrorupdates the shared
CiRunStatus
Recoverable Errors
When a CiRecoverableError leaves a normal CiSection:
the error is logged
its exit code is added to
CiRunStatusexecution 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.commoncikit.macros.ruffcikit.macros.pylintcikit.macros.pytestcikit.macros.coveragecikit.macros.cmakecikit.macros.platformiocikit.macros.bundlecikit.macros.reportscikit.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.
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.