Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ local-ci-test-system/

## Documentation

- **[User Guide](cli/Usage%20Guide.md)** — Installation, configuration, commands, troubleshooting
- **[User Guide](cli/Usage%20Guide.md)** — Installation, configuration, commands, troubleshooting (includes [workflow patch pipeline](cli/Usage%20Guide.md#workflow-patch-pipeline) and plugin registration)
- **[Design Guide](docs/Design%20Guide.md)** — Architecture and caching
- **[Performance and Caching](docs/Performance%20and%20Caching.md)** — Cache layout and config
- **[Preparation and Plan](docs/Preparation%20and%20Plan.md)** — Implementation plan and issue breakdown
Expand Down
98 changes: 98 additions & 0 deletions cli/Usage Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- [Configuration](#configuration)
- [Creating a Config File](#creating-a-config-file)
- [Config File Reference](#config-file-reference)
- [Workflow patch pipeline](#workflow-patch-pipeline)
- [Viewing and Editing Config](#viewing-and-editing-config)
- [Commands](#commands)
- [Global Options](#global-options)
Expand Down Expand Up @@ -310,8 +311,105 @@ There is no CLI flag for `stop_on_first_failure`; set it in `.localci.yml` or wi
| `priorities` | Override execution order (lower number runs first) |
| `images` | Where Docker images are stored, auto-build, cleanup |
| `cache` | ccache, Boost dependency, and CMake config caching |
| `patches` | Enable/disable workflow patch steps; project literals; plugin steps |
| `project` | Repository name and native image prefix for `act` command building |
| `execution` | Timeouts, container cleanup, failure behaviour |

#### Workflow patch pipeline

Before each job, Local CI copies your workflow YAML and applies a **patch
pipeline** (text-line transforms, not a YAML round-trip) so jobs run under
[act](https://github.com/nektos/act) with local images, cache bind mounts, and
other local-CI adaptations.

**Built-in steps** (enable/disable each under `patches:`):

| Step | Applies to |
|------|------------|
| `container_mounts` | Any workflow with a job `container:` block |
| `image_substitution` | Matrix jobs using a local Docker image |
| `codecov_skip` | Workflows that upload coverage via Codecov |
| `b2_source_cache`, `restore_capy_timestamps`, `capy_copy_preservation`, `b2_bootstrap_skip` | C++/B2 workflows whose shell/YAML literals match `patches.project` (Boost.Capy defaults) |

**Project literals (`patches.project`)** — retarget the B2-oriented steps for
your workflow without forking Local CI. Defaults match Boost.Capy; override when
your workflow uses different directory names, step titles, or copy commands:

```yaml
patches:
# Optional: turn off steps that do not apply
b2_source_cache: true
restore_capy_timestamps: true
capy_copy_preservation: true
b2_bootstrap_skip: true

project:
boost_source_copy_command: "cp -rL dep-source dep-root"
boost_root_dir: dep-root
patch_dependency_step_name: "Patch Dependency"
project_source_dir: mylib-root
restore_timestamps_step_title: "Restore mylib source file timestamps"
file_stats_basename: .mylib-file-stats
workspace_libs_copy_marker: 'cp -r "$workspace_root"'
workspace_libs_copy_dest: "vendor/$pkg"
b2_workflow_action_marker: build-workflow
cached_module_libs_path: vendor/mylib
```

**Act event payload and images (`project`)** — override hard-coded Boost.Capy
assumptions for the simulated GitHub event and x86 image handling:

```yaml
project:
repo_full_name: acme/my-cpp-lib
native_image_prefix: myproj- # x86 jobs skip linux/386 when image tag starts with this
```

**Custom patch steps (plugins)** — publish a step class and register it via the
`localci.patch_steps` entry-point group (see `localci.core.patch_registry`).
Enable it in `.localci.yml` with `patches.extra_steps`; enabled plugins append
after the built-in steps unless you set an explicit `patches.order`.

Minimal plugin package:

```python
# my_localci_patches/steps.py
from localci.core.patch_pipeline import PatchContext, PatchStep

class MyPatchStep(PatchStep):
@property
def name(self) -> str:
return "my_patch"

def apply(self, ctx: PatchContext) -> None:
ctx.lines.insert(0, "# patched by my_patch\n")
```

```toml
# pyproject.toml (install the package in the same environment as localci)
[project.entry-points."localci.patch_steps"]
my_patch = "my_localci_patches.steps:MyPatchStep"
```

```yaml
# .localci.yml
patches:
container_mounts: false
b2_source_cache: false
restore_capy_timestamps: false
capy_copy_preservation: false
b2_bootstrap_skip: false
image_substitution: false
codecov_skip: false
extra_steps:
my_patch: true
# Optional: explicit order (required if a plugin must run before a built-in step)
# order:
# - my_patch
# - container_mounts
# - image_substitution
```

#### Build caching (Phase 2)

When `cache.enabled` is true, Local CI bind-mounts host cache directories into
Expand Down
19 changes: 13 additions & 6 deletions cli/localci/core/command_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
import tempfile
from pathlib import Path

from localci.core.config import CacheConfig, ResolvedCachePaths
from localci.core.config import (
DEFAULT_NATIVE_IMAGE_PREFIX,
DEFAULT_REPO_FULL_NAME,
CacheConfig,
ResolvedCachePaths,
)
from localci.core.executor import ActCommand
from localci.core.github_token import SENTINEL_GITHUB_TOKEN
from localci.core.workflow import MatrixEntry
Expand Down Expand Up @@ -52,7 +57,8 @@ def __init__(
workflow_file: Path,
project_dir: Path = Path("."),
job_id: str = "build",
repo_full_name: str = "cppalliance/capy",
repo_full_name: str | None = None,
native_image_prefix: str | None = None,
default_env: dict[str, str] | None = None,
default_secrets: dict[str, str] | None = None,
offline: bool = False,
Expand All @@ -61,7 +67,8 @@ def __init__(
self.workflow_file = workflow_file
self.project_dir = project_dir
self.job_id = job_id
self.repo_full_name = repo_full_name
self.repo_full_name = repo_full_name or DEFAULT_REPO_FULL_NAME
self.native_image_prefix = native_image_prefix or DEFAULT_NATIVE_IMAGE_PREFIX
self.default_env = default_env or {}
self.default_secrets = default_secrets or {}
self.offline = offline
Expand Down Expand Up @@ -183,11 +190,11 @@ def build(
secrets.setdefault("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)

# Architecture: request linux/386 only when using a generic image
# (e.g. ubuntu:24.04). Our capy x86 image is amd64 with multilib, so
# we must not request 386 when using it or the daemon will reject.
# (e.g. ubuntu:24.04). Project-specific native images (e.g. capy x86)
# are amd64 with multilib, so we must not request 386 when using them.
container_arch: str | None = None
if entry.architecture == "x86" and (
not image_tag or not str(image_tag).startswith("capy-")
not image_tag or not str(image_tag).startswith(self.native_image_prefix)
):
container_arch = "linux/386"

Expand Down
76 changes: 74 additions & 2 deletions cli/localci/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ class ExecutionConfig(BaseModel):
stop_on_first_failure: bool = False


# Known workflow patch steps (order matches default pipeline sequence).
# Known built-in workflow patch steps (order matches default pipeline sequence).
PATCH_STEP_NAMES: tuple[str, ...] = (
"container_mounts",
"b2_source_cache",
Expand All @@ -196,6 +196,24 @@ class ExecutionConfig(BaseModel):
)


class PatchProjectConfig(BaseModel):
"""Project-specific literals for C++/Boost-oriented patch steps.

Defaults preserve Boost.Capy workflow behaviour. Override for other projects.
"""

boost_source_copy_command: str = "cp -rL boost-source boost-root"
boost_root_dir: str = "boost-root"
patch_dependency_step_name: str = "Patch Boost"
project_source_dir: str = "capy-root"
restore_timestamps_step_title: str = "Restore capy source file timestamps"
file_stats_basename: str = ".capy-file-stats"
workspace_libs_copy_marker: str = 'cp -r "$workspace_root"'
workspace_libs_copy_dest: str = "libs/$module"
b2_workflow_action_marker: str = "b2-workflow"
cached_module_libs_path: str = "libs/capy"


class PatchesConfig(BaseModel):
"""Workflow patch pipeline settings (enable/disable individual patch types)."""

Expand All @@ -207,6 +225,29 @@ class PatchesConfig(BaseModel):
image_substitution: bool = True
codecov_skip: bool = True
order: list[str] | None = None
project: PatchProjectConfig = Field(default_factory=PatchProjectConfig)
extra_steps: dict[str, bool] = Field(default_factory=dict)

@field_validator("extra_steps")
@classmethod
def validate_extra_steps(cls, v: dict[str, bool]) -> dict[str, bool]:
overlap = set(v.keys()) & set(PATCH_STEP_NAMES)
if overlap:
raise ValueError(
f"extra_steps must not duplicate built-in patch steps: "
f"{sorted(overlap)}"
)
from localci.core.patch_registry import get_patch_step_registry

builtin = set(PATCH_STEP_NAMES)
registered = set(get_patch_step_registry().keys()) - builtin
unknown = set(v.keys()) - registered
if unknown:
raise ValueError(
f"Unknown extra patch steps (register via localci.patch_steps "
f"entry points): {sorted(unknown)}"
)
return v

@field_validator("order")
@classmethod
Expand All @@ -219,7 +260,9 @@ def validate_order(cls, v: list[str] | None) -> list[str] | None:
)
if len(v) != len(set(v)):
raise ValueError("Duplicate step names in 'order'")
unknown = set(v) - set(PATCH_STEP_NAMES)
from localci.core.patch_registry import get_patch_step_registry

unknown = set(v) - set(get_patch_step_registry().keys())
if unknown:
raise ValueError(f"Unknown patch steps: {sorted(unknown)}")
return v
Expand All @@ -229,13 +272,41 @@ def validate_order_completeness(self) -> PatchesConfig:
if self.order is None:
return self
enabled = {n for n in PATCH_STEP_NAMES if getattr(self, n, True)}
enabled |= {n for n, on in self.extra_steps.items() if on}
missing = enabled - set(self.order)
if missing:
raise ValueError(
f"Patch steps are enabled but missing from 'order': {sorted(missing)}"
)
return self

def is_step_enabled(self, name: str) -> bool:
"""Return whether patch step *name* is enabled in this config."""
if name in PATCH_STEP_NAMES:
return bool(getattr(self, name, True))
return bool(self.extra_steps.get(name, False))

def resolved_order(self) -> list[str]:
"""Return pipeline order: explicit ``order`` or built-ins + enabled plugins."""
if self.order is not None:
return self.order
order = list(PATCH_STEP_NAMES)
order.extend(
name for name in sorted(self.extra_steps) if self.extra_steps[name]
)
return order


DEFAULT_REPO_FULL_NAME = "cppalliance/capy"
DEFAULT_NATIVE_IMAGE_PREFIX = "capy-"


class ProjectConfig(BaseModel):
"""Project identity and image conventions for act command building."""

repo_full_name: str = DEFAULT_REPO_FULL_NAME
native_image_prefix: str = DEFAULT_NATIVE_IMAGE_PREFIX


# ---------------------------------------------------------------------------
# Root configuration model
Expand Down Expand Up @@ -267,6 +338,7 @@ def expand_workflow(cls, v: Path) -> Path:
logging: LoggingConfig = Field(default_factory=LoggingConfig)
execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
patches: PatchesConfig = Field(default_factory=PatchesConfig)
project: ProjectConfig = Field(default_factory=ProjectConfig)


# ---------------------------------------------------------------------------
Expand Down
15 changes: 14 additions & 1 deletion cli/localci/core/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@

from localci.core.cmake_cache import compute_cmake_input_digest
from localci.core.command_builder import ActCommandBuilder
from localci.core.config import resolve_cache_paths
from localci.core.config import (
DEFAULT_NATIVE_IMAGE_PREFIX,
DEFAULT_REPO_FULL_NAME,
resolve_cache_paths,
)
from localci.core.executor import JobExecutor, JobResult, JobStatus
from localci.core.models import JobEvent, JobEventType, QueuedJob
from localci.core.queue import PriorityJobQueue
Expand Down Expand Up @@ -65,6 +69,8 @@ class OrchestratorConfig:
dispatch_interval: float = 0.1
default_secrets: dict[str, str] | None = None
default_env: dict[str, str] | None = None
repo_full_name: str = DEFAULT_REPO_FULL_NAME
native_image_prefix: str = DEFAULT_NATIVE_IMAGE_PREFIX
image_registry_path: Path | None = None
verbose: bool = False
offline: bool = False
Expand All @@ -80,6 +86,7 @@ def from_config(cls, config: LocalCIConfig) -> OrchestratorConfig:
disk_gb = float(getattr(rl, "disk_min_free_gb", 10.0))
images = getattr(config, "images", None)
registry_path = getattr(images, "registry", None) if images else None
project = getattr(config, "project", None)
return cls(
max_parallel=getattr(config.parallel, "max_jobs", 8),
cpu_threshold=float(cpu),
Expand All @@ -90,6 +97,10 @@ def from_config(cls, config: LocalCIConfig) -> OrchestratorConfig:
stop_on_first_failure=getattr(
config.execution, "stop_on_first_failure", False
),
repo_full_name=getattr(project, "repo_full_name", DEFAULT_REPO_FULL_NAME),
native_image_prefix=getattr(
project, "native_image_prefix", DEFAULT_NATIVE_IMAGE_PREFIX
),
image_registry_path=registry_path,
auto_build=getattr(images, "auto_build", True) if images else True,
)
Expand Down Expand Up @@ -434,6 +445,8 @@ def _execute_job(self, job: QueuedJob) -> JobResult:
workflow_file=self.workflow_file,
project_dir=self.project_dir,
job_id=job.job_id,
repo_full_name=self.config.repo_full_name,
native_image_prefix=self.config.native_image_prefix,
default_secrets=self.config.default_secrets or {},
default_env=self.config.default_env or {},
offline=self.config.offline,
Expand Down
13 changes: 7 additions & 6 deletions cli/localci/core/patch_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING

from localci.core.config import PATCH_STEP_NAMES, LocalCIConfig
from localci.core.config import LocalCIConfig

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -66,19 +66,20 @@ def steps(self) -> list[PatchStep]:
@classmethod
def from_config(cls, config: LocalCIConfig) -> PatchPipeline:
"""Build a pipeline from ``.localci.yml`` patch settings."""
from localci.core.patch_steps import PATCH_STEP_REGISTRY
from localci.core.patch_registry import get_patch_step_registry

patches = config.patches
order = patches.order or list(PATCH_STEP_NAMES)
order = patches.resolved_order()
registry = get_patch_step_registry()
steps: list[PatchStep] = []
for name in order:
if not getattr(patches, name, True):
if not patches.is_step_enabled(name):
continue
step_cls = PATCH_STEP_REGISTRY.get(name)
step_cls = registry.get(name)
if step_cls is None:
raise ValueError(
f"Patch step {name!r} is enabled but not registered; "
"check PATCH_STEP_NAMES / PATCH_STEP_REGISTRY alignment"
"check PATCH_STEP_NAMES / patch step registry alignment"
)
steps.append(step_cls())
return cls(steps)
Expand Down
Loading
Loading