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
7 changes: 5 additions & 2 deletions cli/localci/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
from __future__ import annotations

import os
import re
import tempfile
from pathlib import Path

import click
import re

from localci.core.executor import (
ActNotFoundError,
Expand All @@ -29,6 +29,7 @@
from localci.core.queue_builder import QueueBuilder
from localci.core.results import ExecutionSummary
from localci.core.workflow import MatrixEntry, Platform, WorkflowAnalyzer
from localci.core.github_token import resolve_github_token, warn_sentinel_github_token
from localci.core.boost_cache import ensure_boost_cache
from localci.core.ccache_stats import get_ccache_stats
from localci.core.config import resolve_cache_paths
Expand Down Expand Up @@ -156,7 +157,9 @@ def run(
workflow_path = Path(workflow) if workflow else cfg.workflow
project_dir = Path(".").resolve()

gh_token = github_token or os.environ.get("GITHUB_TOKEN") or "local-ci-token"
gh_token = resolve_github_token(github_token)
if not offline:
warn_sentinel_github_token(gh_token)

# ── 1. Parse the workflow ──────────────────────────────────────
try:
Expand Down
6 changes: 4 additions & 2 deletions cli/localci/core/command_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from localci.core.config import CacheConfig, ResolvedCachePaths
from localci.core.executor import ActCommand
from localci.core.github_token import SENTINEL_GITHUB_TOKEN
from localci.core.workflow import MatrixEntry

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -175,9 +176,10 @@ def build(
if resolved_cache_paths.b2_source_host is not None:
env["LOCALCI_B2_SOURCE_DIR"] = resolved_cache_paths.b2_source_container

# Secrets
# Secrets: copy caller-provided secrets; only fill GITHUB_TOKEN when absent
# (setdefault — never override a real token from the orchestrator path).
secrets = {**self.default_secrets}
secrets.setdefault("GITHUB_TOKEN", "local-ci-token")
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
Expand Down
48 changes: 34 additions & 14 deletions cli/localci/core/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import logging
import os
import re
import shutil
import subprocess
import sys
Expand All @@ -23,6 +24,28 @@

logger = logging.getLogger(__name__)

# Substrings (matched case-insensitively) for summarizing failed job output.
_ERROR_EXTRACT_KEYWORDS = (
"error:",
"fatal:",
"failed",
"error[",
"undefined reference",
"no such file",
"cannot find",
"compilation failed",
)

# Public for tests: substring signals for auth/API failures in act output.
# HTTP 4xx status codes use _HTTP_STATUS_PATTERN (word-boundary) to avoid
# false positives such as "4010" or "port 40100".
AUTH_ERROR_EXTRACT_KEYWORDS = (
"unauthorized",
"forbidden",
"rate limit",
)
_HTTP_STATUS_PATTERN = re.compile(r"\b4\d{2}\b")


# =====================================================================
# Enums
Expand Down Expand Up @@ -621,27 +644,24 @@ def _get_log_path(self, matrix_name: str) -> Path:
filename = f"{timestamp}_{safe_name}.log"
return self.logs_dir / filename

@staticmethod
def _line_indicates_error(line: str) -> bool:
"""Return True if *line* looks like a failed-job error summary."""
lower = line.lower()
if any(kw in lower for kw in _ERROR_EXTRACT_KEYWORDS):
return True
if any(kw in lower for kw in AUTH_ERROR_EXTRACT_KEYWORDS):
return True
return _HTTP_STATUS_PATTERN.search(line) is not None

@staticmethod
def _extract_error(output: str, max_lines: int = 10) -> str:
"""Extract error summary from output."""
lines = output.strip().split("\n")

error_lines: list[str] = []
for line in lines:
lower = line.lower()
if any(
kw in lower
for kw in (
"error:",
"fatal:",
"failed",
"error[",
"undefined reference",
"no such file",
"cannot find",
"compilation failed",
)
):
if JobExecutor._line_indicates_error(line):
error_lines.append(line.strip())

if error_lines:
Expand Down
48 changes: 48 additions & 0 deletions cli/localci/core/github_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""GitHub token resolution for act and workflow action downloads.

When no GitHub token is provided, act falls back to a non-functional
placeholder, which produces opaque HTTP 401 errors during action downloads.
This module surfaces that condition as an early, visible warning.
"""

from __future__ import annotations

import os

from localci.utils.output import print_important_warning

SENTINEL_GITHUB_TOKEN = "local-ci-token"


def resolve_github_token(cli_token: str | None) -> str:
"""Return CLI token, ``GITHUB_TOKEN`` env, or the local-ci sentinel."""
if cli_token is not None:
stripped = cli_token.strip()
if stripped:
return stripped
env_token = os.environ.get("GITHUB_TOKEN")
if env_token and env_token.strip():
return env_token.strip()
return SENTINEL_GITHUB_TOKEN


def is_sentinel_github_token(token: str) -> bool:
"""True when *token* is the non-functional placeholder used for local runs."""
return token == SENTINEL_GITHUB_TOKEN


def format_sentinel_github_token_warning() -> str:
"""User-facing warning when falling back to :data:`SENTINEL_GITHUB_TOKEN`."""
return (
"No GitHub token provided; using placeholder token for act. "
"Action downloads may fail with HTTP 401. Set a real token via: "
"export GITHUB_TOKEN=ghp_... , "
"localci run --github-token ghp_... , "
"or use --offline if actions are already cached."
)


def warn_sentinel_github_token(token: str) -> None:
"""Emit a Rich console warning when *token* is :data:`SENTINEL_GITHUB_TOKEN`."""
if is_sentinel_github_token(token):
print_important_warning(format_sentinel_github_token_warning())
24 changes: 21 additions & 3 deletions cli/localci/utils/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from __future__ import annotations

import sys

from rich.console import Console
from rich.panel import Panel
from rich.table import Table
Expand All @@ -28,18 +30,29 @@
}
)

# Module-level console (reconfigured by ``configure_console``).
console = Console(theme=LOCALCI_THEME)
# Module-level consoles (reconfigured by ``configure_console``).
console = Console(theme=LOCALCI_THEME, no_color=False)
# High-severity messages (e.g. missing GitHub token) bypass ``--quiet``.
# Bind to sys.stdout so each print uses the current stream (pytest, CliRunner).
_important_console = Console(
theme=LOCALCI_THEME, file=sys.stdout, no_color=False, quiet=False
)


def configure_console(*, no_color: bool = False, quiet: bool = False) -> None:
"""Reconfigure the global *console* based on CLI flags."""
global console
global console, _important_console
console = Console(
theme=LOCALCI_THEME,
no_color=no_color,
quiet=quiet,
)
_important_console = Console(
theme=LOCALCI_THEME,
file=sys.stdout,
no_color=no_color,
quiet=False,
)


# ---------------------------------------------------------------------------
Expand All @@ -64,6 +77,11 @@ def print_warning(message: str) -> None:
console.print(f"[warning]![/warning] {message}")


def print_important_warning(message: str) -> None:
"""Print a warning that is still shown when ``--quiet`` is set."""
_important_console.print(f"[warning]![/warning] {message}")


def print_info(message: str) -> None:
console.print(f"[info]ℹ[/info] {message}")

Expand Down
Loading
Loading