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
13 changes: 12 additions & 1 deletion cli/localci/core/patch_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

from __future__ import annotations

import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING

from localci.core.config import PATCH_STEP_NAMES, LocalCIConfig

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from localci.core.workflow import MatrixEntry

Expand All @@ -22,7 +25,15 @@ def name(self) -> str:

@abstractmethod
def apply(self, ctx: PatchContext) -> None:
"""Apply this patch in place to ``ctx.lines``."""
"""Apply this patch in place to ``ctx.lines``.

Early exits that do not modify ``ctx.lines`` must call
:meth:`_skip` with an actionable reason.
"""

def _skip(self, reason: str) -> None:
"""Log a warning when this step exits without modifying the workflow."""
logger.warning("Patch step %s skipped: %s", self.name, reason)


@dataclass
Expand Down
106 changes: 66 additions & 40 deletions cli/localci/core/patch_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def name(self) -> str:

def apply(self, ctx: PatchContext) -> None:
if not ctx.job_id or not ctx.container_mount_options:
self._skip("job_id and container_mount_options are required")
return
job_header = re.compile(r"^\s{2}" + re.escape(ctx.job_id) + r"\s*:\s*$")
for i, line in enumerate(ctx.lines):
Expand Down Expand Up @@ -69,8 +70,11 @@ def apply(self, ctx: PatchContext) -> None:
j + 1,
f'{options_indent}options: "{ctx.container_mount_options}"\n',
)
break
break
return
self._skip(f"job {ctx.job_id!r} has no container block")
return
self._skip(f"job {ctx.job_id!r} not found in workflow")
return


class B2SourceCacheStep(PatchStep):
Expand Down Expand Up @@ -100,7 +104,9 @@ def apply(self, ctx: PatchContext) -> None:
f"{ind} fi\n"
f"{ind}fi\n"
)
break
return
self._skip("no cp -rL boost-source boost-root line found")
return


class RestoreCapyTimestampsStep(PatchStep):
Expand All @@ -118,24 +124,28 @@ def apply(self, ctx: PatchContext) -> None:
already_patched = any(
"capy-file-stats" in ctx.lines[j] for j in range(max(0, i - 15), i)
)
if not already_patched:
list_indent = step_match.group(1)
prop_indent = list_indent + " "
body_indent = prop_indent + " "
new_step = [
f"{list_indent}- name: Restore capy source file timestamps\n",
f"{prop_indent}run: |\n",
f'{body_indent}if [ -n "${{LOCALCI_B2_SOURCE_DIR:-}}" ] && [ -f "${{LOCALCI_B2_SOURCE_DIR}}/.capy-file-stats" ]; then\n',
f"{body_indent} while IFS=' ' read -r saved_mtime fhash relpath; do\n",
f'{body_indent} [ -f "capy-root/$relpath" ] || continue\n',
f"{body_indent} curr=$(sha256sum \"capy-root/$relpath\" 2>/dev/null | cut -d' ' -f1)\n",
f'{body_indent} [ "$curr" = "$fhash" ] && touch -d "@$saved_mtime" "capy-root/$relpath" 2>/dev/null || true\n',
f'{body_indent} done < "${{LOCALCI_B2_SOURCE_DIR}}/.capy-file-stats"\n',
f"{body_indent}fi\n",
]
for j, new_line in enumerate(new_step):
ctx.lines.insert(i + j, new_line)
break
if already_patched:
self._skip("restore step already present")
return
list_indent = step_match.group(1)
prop_indent = list_indent + " "
body_indent = prop_indent + " "
new_step = [
f"{list_indent}- name: Restore capy source file timestamps\n",
f"{prop_indent}run: |\n",
f'{body_indent}if [ -n "${{LOCALCI_B2_SOURCE_DIR:-}}" ] && [ -f "${{LOCALCI_B2_SOURCE_DIR}}/.capy-file-stats" ]; then\n',
f"{body_indent} while IFS=' ' read -r saved_mtime fhash relpath; do\n",
f'{body_indent} [ -f "capy-root/$relpath" ] || continue\n',
f"{body_indent} curr=$(sha256sum \"capy-root/$relpath\" 2>/dev/null | cut -d' ' -f1)\n",
f'{body_indent} [ "$curr" = "$fhash" ] && touch -d "@$saved_mtime" "capy-root/$relpath" 2>/dev/null || true\n',
f'{body_indent} done < "${{LOCALCI_B2_SOURCE_DIR}}/.capy-file-stats"\n',
f"{body_indent}fi\n",
]
for j, new_line in enumerate(new_step):
ctx.lines.insert(i + j, new_line)
return
self._skip("Patch Boost step not found")
return


class CapyCopyPreservationStep(PatchStep):
Expand All @@ -161,7 +171,9 @@ def apply(self, ctx: PatchContext) -> None:
f'{ind} done > "${{LOCALCI_B2_SOURCE_DIR}}/.capy-file-stats"\n'
f"{ind}fi\n"
)
break
return
self._skip('no cp -r "$workspace_root" capy copy line found')
return


class B2BootstrapSkipStep(PatchStep):
Expand All @@ -183,32 +195,42 @@ def apply(self, ctx: PatchContext) -> None:
"Skip b2 bootstrap" in ctx.lines[j]
for j in range(max(0, step_start - 15), step_start)
)
if not already_patched:
list_indent, prop_indent, body_indent = _step_insert_indents(
ctx.lines, step_start
)
new_step = [
f"{list_indent}- name: Skip b2 bootstrap (b2 binary cached)\n",
f"{prop_indent}run: |\n",
f'{body_indent}if [ -n "${{LOCALCI_B2_SOURCE_DIR:-}}" ] && [ -f "${{LOCALCI_B2_SOURCE_DIR}}/b2" ]; then\n',
f"{body_indent} printf '#!/bin/sh\\necho \"b2 binary cached, skipping bootstrap.\"\\n' > boost-root/bootstrap.sh\n",
f"{body_indent} chmod +x boost-root/bootstrap.sh\n",
f"{body_indent}fi\n",
]
for j, new_line in enumerate(new_step):
ctx.lines.insert(step_start + j, new_line)
break
if already_patched:
self._skip("skip b2 bootstrap step already present")
return
list_indent, prop_indent, body_indent = _step_insert_indents(
ctx.lines, step_start
)
new_step = [
f"{list_indent}- name: Skip b2 bootstrap (b2 binary cached)\n",
f"{prop_indent}run: |\n",
f'{body_indent}if [ -n "${{LOCALCI_B2_SOURCE_DIR:-}}" ] && [ -f "${{LOCALCI_B2_SOURCE_DIR}}/b2" ]; then\n',
f"{body_indent} printf '#!/bin/sh\\necho \"b2 binary cached, skipping bootstrap.\"\\n' > boost-root/bootstrap.sh\n",
f"{body_indent} chmod +x boost-root/bootstrap.sh\n",
f"{body_indent}fi\n",
]
for j, new_line in enumerate(new_step):
ctx.lines.insert(step_start + j, new_line)
return
self._skip("no b2-workflow uses step found")
return


class ImageSubstitutionStep(PatchStep):
"""Replace matrix container image with the locally-built image tag."""
"""Replace matrix container image with the locally-built image tag.

Raises ValueError if the matrix entry name is not found (unexpected workflow
structure). Skips with a warning if the container field is absent from the
entry block (valid but unsupported layout).
"""

@property
def name(self) -> str:
return "image_substitution"

def apply(self, ctx: PatchContext) -> None:
if not ctx.image_tag:
self._skip("image_tag not provided")
return
name_escaped = re.escape(ctx.entry.name)
name_pattern = re.compile(r'name:\s*["\']?' + name_escaped + r'["\']?\s*$')
Expand Down Expand Up @@ -255,7 +277,9 @@ def apply(self, ctx: PatchContext) -> None:
mo = container_pattern.match(ctx.lines[i])
if mo:
ctx.lines[i] = f'{mo.group(1)}container: "{ctx.image_tag}"\n'
break
return
self._skip("container field not found in matrix entry block")
return


class CodecovSkipStep(PatchStep):
Expand All @@ -280,7 +304,9 @@ def apply(self, ctx: PatchContext) -> None:
f"{indent}{act_check}{rest}; "
f'else echo "Skipping Codecov upload (running under act)."; fi\n'
)
break
return
self._skip("no Codecov upload step found")
return


PATCH_STEP_REGISTRY: dict[str, type[PatchStep]] = {
Expand Down
13 changes: 13 additions & 0 deletions cli/tests/fixtures/patcher/container_image_no_container.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: Container Image No Container Field
on:
push:
jobs:
build:
strategy:
matrix:
include:
- name: "GCC 15: C++20"
runs-on: ubuntu-latest
runs-on: ${{ matrix.runs-on }}
steps:
- run: echo build
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
name: Container Mount No Container Block
on:
push:
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo build
24 changes: 23 additions & 1 deletion cli/tests/test_patch_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

from __future__ import annotations

import logging
from pathlib import Path

import pytest

from localci.cli.run.patcher import _write_patched_workflow
from localci.core.config import LocalCIConfig, PatchesConfig
from localci.core.patch_pipeline import PatchPipeline
from localci.core.patch_pipeline import PatchContext, PatchPipeline
from localci.core.patch_steps import ContainerMountsStep
from localci.core.workflow import (
BuildSystem,
BuildVariant,
Expand Down Expand Up @@ -192,3 +194,23 @@ def test_patches_config_rejects_enabled_step_missing_from_order() -> None:
"image_substitution",
],
)


def test_patch_step_skip_emits_warning(sample_entry: MatrixEntry, caplog) -> None:
"""Enabled patch step with incomplete context logs a skip warning."""
ctx = PatchContext(
lines=["jobs:\n", " build:\n", " runs-on: ubuntu-latest\n"],
entry=sample_entry,
config=LocalCIConfig(),
job_id=None,
container_mount_options=None,
)
with caplog.at_level(logging.WARNING, logger="localci.core.patch_pipeline"):
ContainerMountsStep().apply(ctx)

assert any(
r.levelno == logging.WARNING
and "container_mounts" in r.message
and "job_id and container_mount_options are required" in r.message
for r in caplog.records
)
Loading
Loading