From 41e1593a5d672ffdcb70b1260bf2dfe501427e4b Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Mon, 1 Jun 2026 15:03:59 -0400 Subject: [PATCH 1/8] feat: token sentinel warning and error keyword extraction --- cli/localci/cli/run.py | 9 ++- cli/localci/core/command_builder.py | 3 +- cli/localci/core/executor.py | 30 +++++---- cli/localci/core/github_token.py | 33 +++++++++ cli/tests/test_executor.py | 12 +++- cli/tests/test_github_token.py | 100 ++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 cli/localci/core/github_token.py create mode 100644 cli/tests/test_github_token.py diff --git a/cli/localci/cli/run.py b/cli/localci/cli/run.py index f58ab30..c84239f 100644 --- a/cli/localci/cli/run.py +++ b/cli/localci/cli/run.py @@ -29,6 +29,11 @@ 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 ( + format_sentinel_github_token_warning, + is_sentinel_github_token, + resolve_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 @@ -156,7 +161,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 is_sentinel_github_token(gh_token): + print_warning(format_sentinel_github_token_warning()) # ── 1. Parse the workflow ────────────────────────────────────── try: diff --git a/cli/localci/core/command_builder.py b/cli/localci/core/command_builder.py index 1b2c207..86228a8 100644 --- a/cli/localci/core/command_builder.py +++ b/cli/localci/core/command_builder.py @@ -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__) @@ -177,7 +178,7 @@ def build( # Secrets 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 diff --git a/cli/localci/core/executor.py b/cli/localci/core/executor.py index 20d246c..4a2dbdd 100644 --- a/cli/localci/core/executor.py +++ b/cli/localci/core/executor.py @@ -23,6 +23,22 @@ 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", + "401", + "unauthorized", + "forbidden", + "rate limit", +) + # ===================================================================== # Enums @@ -629,19 +645,7 @@ def _extract_error(output: str, max_lines: int = 10) -> str: 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 any(kw in lower for kw in _ERROR_EXTRACT_KEYWORDS): error_lines.append(line.strip()) if error_lines: diff --git a/cli/localci/core/github_token.py b/cli/localci/core/github_token.py new file mode 100644 index 0000000..3291bdb --- /dev/null +++ b/cli/localci/core/github_token.py @@ -0,0 +1,33 @@ +"""GitHub token resolution for act and workflow action downloads.""" + +from __future__ import annotations + +import os + +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: + return cli_token + env_token = os.environ.get("GITHUB_TOKEN") + if env_token: + return env_token + 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." + ) diff --git a/cli/tests/test_executor.py b/cli/tests/test_executor.py index fea1c6f..ac54b6b 100644 --- a/cli/tests/test_executor.py +++ b/cli/tests/test_executor.py @@ -609,6 +609,14 @@ def test_extract_error_fallback(self, mock_which): assert "line 2" in extracted assert "line 3" in extracted + @patch("shutil.which") + def test_extract_error_http_401(self, mock_which): + mock_which.return_value = "/usr/bin/act" + executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + output = "fetching action\nHTTP 401: Bad credentials\nend" + extracted = executor._extract_error(output) + assert "401" in extracted + def test_cleanup_temp_files(self, tmp_path): event = tmp_path / "event.json" event.write_text("{}") @@ -674,6 +682,8 @@ def test_extra_env(self, tmp_path): assert cmd.env["BOOST_ROOT"] == "/opt/boost" def test_default_secrets(self, tmp_path): + from localci.core.github_token import SENTINEL_GITHUB_TOKEN + wf = tmp_path / "ci.yml" wf.write_text("name: CI") @@ -681,7 +691,7 @@ def test_default_secrets(self, tmp_path): entry = _make_entry() cmd = builder.build(entry) - assert "GITHUB_TOKEN" in cmd.secrets + assert cmd.secrets["GITHUB_TOKEN"] == SENTINEL_GITHUB_TOKEN def test_custom_default_secrets(self, tmp_path): wf = tmp_path / "ci.yml" diff --git a/cli/tests/test_github_token.py b/cli/tests/test_github_token.py new file mode 100644 index 0000000..6b8bf37 --- /dev/null +++ b/cli/tests/test_github_token.py @@ -0,0 +1,100 @@ +"""Tests for GitHub token resolution and sentinel warnings.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from localci.cli.main import cli +from localci.core.executor import JobExecutor +from localci.core.github_token import ( + SENTINEL_GITHUB_TOKEN, + format_sentinel_github_token_warning, + is_sentinel_github_token, + resolve_github_token, +) + +runner = CliRunner() +SAMPLE_WORKFLOW = str(Path(__file__).parent / "fixtures" / "sample_workflow.yml") + + +class TestResolveGithubToken: + def test_cli_token_takes_precedence(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_TOKEN", "env-token") + assert resolve_github_token("cli-token") == "cli-token" + + def test_env_token_when_no_cli(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_TOKEN", "env-token") + assert resolve_github_token(None) == "env-token" + + def test_sentinel_when_unset(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + assert resolve_github_token(None) == SENTINEL_GITHUB_TOKEN + + def test_is_sentinel_detects_placeholder(self) -> None: + assert is_sentinel_github_token(SENTINEL_GITHUB_TOKEN) + assert not is_sentinel_github_token("ghp_real") + + def test_warning_message_documents_remediation(self) -> None: + msg = format_sentinel_github_token_warning() + assert "GITHUB_TOKEN" in msg + assert "--github-token" in msg + assert "--offline" in msg + assert "401" in msg + + +class TestExtractErrorAuthKeywords: + @pytest.mark.parametrize( + "line", + [ + "Error: HTTP 401 Unauthorized", + "authentication required: unauthorized", + "403 Forbidden: resource not accessible", + "API rate limit exceeded for user", + ], + ) + def test_extract_error_matches_auth_keywords(self, line: str) -> None: + output = textwrap.dedent(f"""\ + Downloading action + {line} + cleanup + """) + extracted = JobExecutor._extract_error(output) + assert line.strip() in extracted + + +class TestRunSentinelWarning: + def test_dry_run_warns_when_no_token( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + result = runner.invoke( + cli, + ["run", "--workflow", SAMPLE_WORKFLOW, "--dry-run", "--platform", "linux"], + ) + assert result.exit_code == 0, result.output + assert "No GitHub token provided" in result.output + assert "GITHUB_TOKEN" in result.output + + def test_dry_run_no_warning_with_cli_token( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + result = runner.invoke( + cli, + [ + "run", + "--workflow", + SAMPLE_WORKFLOW, + "--dry-run", + "--platform", + "linux", + "--github-token", + "ghp_test_token", + ], + ) + assert result.exit_code == 0, result.output + assert "No GitHub token provided" not in result.output From 0dfa3af7115cfa3a8e735cf51926d1845726b9f4 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Mon, 1 Jun 2026 16:09:16 -0400 Subject: [PATCH 2/8] update: adjustment --- cli/localci/cli/run.py | 10 +++------- cli/localci/core/executor.py | 11 +++++++---- cli/localci/core/github_token.py | 15 ++++++++++++++- cli/tests/test_github_token.py | 21 +++++++++++++++++++++ 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/cli/localci/cli/run.py b/cli/localci/cli/run.py index c84239f..a292e9d 100644 --- a/cli/localci/cli/run.py +++ b/cli/localci/cli/run.py @@ -29,11 +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 ( - format_sentinel_github_token_warning, - is_sentinel_github_token, - resolve_github_token, -) +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 @@ -162,8 +158,6 @@ def run( project_dir = Path(".").resolve() gh_token = resolve_github_token(github_token) - if is_sentinel_github_token(gh_token): - print_warning(format_sentinel_github_token_warning()) # ── 1. Parse the workflow ────────────────────────────────────── try: @@ -278,6 +272,7 @@ def run( # ── 4. Dry-run mode ─────────────────────────────────────────── if dry_run: + warn_sentinel_github_token(gh_token) _print_execution_plan(queue, workflow_path, effective_timeout) return @@ -309,6 +304,7 @@ def run( orch_config.max_parallel = effective_parallel orch_config.job_timeout = effective_timeout orch_config.keep_containers = effective_keep_containers + warn_sentinel_github_token(gh_token) orch_config.default_secrets = {"GITHUB_TOKEN": gh_token} # Match feature/cache-main-install behavior: noninteractive apt so "Install packages" never hangs orch_config.default_env = {"DEBIAN_FRONTEND": "noninteractive"} diff --git a/cli/localci/core/executor.py b/cli/localci/core/executor.py index 4a2dbdd..adb7f1b 100644 --- a/cli/localci/core/executor.py +++ b/cli/localci/core/executor.py @@ -24,6 +24,12 @@ logger = logging.getLogger(__name__) # Substrings (matched case-insensitively) for summarizing failed job output. +_AUTH_ERROR_EXTRACT_KEYWORDS = ( + "401", + "unauthorized", + "forbidden", + "rate limit", +) _ERROR_EXTRACT_KEYWORDS = ( "error:", "fatal:", @@ -33,10 +39,7 @@ "no such file", "cannot find", "compilation failed", - "401", - "unauthorized", - "forbidden", - "rate limit", + *_AUTH_ERROR_EXTRACT_KEYWORDS, ) diff --git a/cli/localci/core/github_token.py b/cli/localci/core/github_token.py index 3291bdb..e586d6f 100644 --- a/cli/localci/core/github_token.py +++ b/cli/localci/core/github_token.py @@ -1,9 +1,16 @@ -"""GitHub token resolution for act and workflow action downloads.""" +"""GitHub token resolution for act and workflow action downloads. + +Part of the token slice of the Silent Failure Chain (T9 defaults + T10 error +extraction): missing tokens previously produced opaque act 401s with no +upfront warning. +""" from __future__ import annotations import os +from localci.utils.output import print_warning + SENTINEL_GITHUB_TOKEN = "local-ci-token" @@ -31,3 +38,9 @@ def format_sentinel_github_token_warning() -> str: "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_warning(format_sentinel_github_token_warning()) diff --git a/cli/tests/test_github_token.py b/cli/tests/test_github_token.py index 6b8bf37..aac0314 100644 --- a/cli/tests/test_github_token.py +++ b/cli/tests/test_github_token.py @@ -15,7 +15,9 @@ format_sentinel_github_token_warning, is_sentinel_github_token, resolve_github_token, + warn_sentinel_github_token, ) +from localci.core.executor import _AUTH_ERROR_EXTRACT_KEYWORDS runner = CliRunner() SAMPLE_WORKFLOW = str(Path(__file__).parent / "fixtures" / "sample_workflow.yml") @@ -45,6 +47,25 @@ def test_warning_message_documents_remediation(self) -> None: assert "--offline" in msg assert "401" in msg + def test_warn_sentinel_emits_rich_warning(self, capsys: pytest.CaptureFixture[str]) -> None: + warn_sentinel_github_token(SENTINEL_GITHUB_TOKEN) + out = capsys.readouterr().out + assert "No GitHub token provided" in out + + def test_warn_sentinel_skips_real_token(self, capsys: pytest.CaptureFixture[str]) -> None: + warn_sentinel_github_token("ghp_real") + assert "No GitHub token provided" not in capsys.readouterr().out + + +class TestAuthErrorExtractKeywords: + def test_auth_keywords_registered(self) -> None: + assert _AUTH_ERROR_EXTRACT_KEYWORDS == ( + "401", + "unauthorized", + "forbidden", + "rate limit", + ) + class TestExtractErrorAuthKeywords: @pytest.mark.parametrize( From a64d9560e9212d61db445d517d01cdd02f6e8b77 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Mon, 1 Jun 2026 23:54:06 -0400 Subject: [PATCH 3/8] fix: self-review findings --- cli/localci/cli/run.py | 3 +- cli/localci/core/executor.py | 5 ++- cli/localci/core/github_token.py | 14 +++--- cli/localci/utils/output.py | 15 ++++++- cli/tests/test_executor.py | 8 ++++ cli/tests/test_github_token.py | 75 ++++++++++++++++++++++++++++++-- 6 files changed, 106 insertions(+), 14 deletions(-) diff --git a/cli/localci/cli/run.py b/cli/localci/cli/run.py index a292e9d..fb7f271 100644 --- a/cli/localci/cli/run.py +++ b/cli/localci/cli/run.py @@ -158,6 +158,7 @@ def run( project_dir = Path(".").resolve() gh_token = resolve_github_token(github_token) + warn_sentinel_github_token(gh_token) # ── 1. Parse the workflow ────────────────────────────────────── try: @@ -272,7 +273,6 @@ def run( # ── 4. Dry-run mode ─────────────────────────────────────────── if dry_run: - warn_sentinel_github_token(gh_token) _print_execution_plan(queue, workflow_path, effective_timeout) return @@ -304,7 +304,6 @@ def run( orch_config.max_parallel = effective_parallel orch_config.job_timeout = effective_timeout orch_config.keep_containers = effective_keep_containers - warn_sentinel_github_token(gh_token) orch_config.default_secrets = {"GITHUB_TOKEN": gh_token} # Match feature/cache-main-install behavior: noninteractive apt so "Install packages" never hangs orch_config.default_env = {"DEBIAN_FRONTEND": "noninteractive"} diff --git a/cli/localci/core/executor.py b/cli/localci/core/executor.py index adb7f1b..3c843f3 100644 --- a/cli/localci/core/executor.py +++ b/cli/localci/core/executor.py @@ -24,8 +24,9 @@ logger = logging.getLogger(__name__) # Substrings (matched case-insensitively) for summarizing failed job output. -_AUTH_ERROR_EXTRACT_KEYWORDS = ( +AUTH_ERROR_EXTRACT_KEYWORDS = ( "401", + "403", "unauthorized", "forbidden", "rate limit", @@ -39,7 +40,7 @@ "no such file", "cannot find", "compilation failed", - *_AUTH_ERROR_EXTRACT_KEYWORDS, + *AUTH_ERROR_EXTRACT_KEYWORDS, ) diff --git a/cli/localci/core/github_token.py b/cli/localci/core/github_token.py index e586d6f..2b77283 100644 --- a/cli/localci/core/github_token.py +++ b/cli/localci/core/github_token.py @@ -9,18 +9,20 @@ import os -from localci.utils.output import print_warning +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: - return cli_token + if cli_token is not None: + stripped = cli_token.strip() + if stripped: + return stripped env_token = os.environ.get("GITHUB_TOKEN") - if env_token: - return env_token + if env_token and env_token.strip(): + return env_token.strip() return SENTINEL_GITHUB_TOKEN @@ -43,4 +45,4 @@ def format_sentinel_github_token_warning() -> str: 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_warning(format_sentinel_github_token_warning()) + print_important_warning(format_sentinel_github_token_warning()) diff --git a/cli/localci/utils/output.py b/cli/localci/utils/output.py index f358d44..ea56ed3 100644 --- a/cli/localci/utils/output.py +++ b/cli/localci/utils/output.py @@ -31,15 +31,23 @@ # Module-level console (reconfigured by ``configure_console``). console = Console(theme=LOCALCI_THEME) +# High-severity messages (e.g. missing GitHub token) bypass ``--quiet``. +_important_console = Console(theme=LOCALCI_THEME) + 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, + no_color=no_color, + quiet=False, + ) # --------------------------------------------------------------------------- @@ -64,6 +72,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}") diff --git a/cli/tests/test_executor.py b/cli/tests/test_executor.py index ac54b6b..8da42c7 100644 --- a/cli/tests/test_executor.py +++ b/cli/tests/test_executor.py @@ -617,6 +617,14 @@ def test_extract_error_http_401(self, mock_which): extracted = executor._extract_error(output) assert "401" in extracted + @patch("shutil.which") + def test_extract_error_http_403(self, mock_which): + mock_which.return_value = "/usr/bin/act" + executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + output = "fetching action\nreceived HTTP status: 403\nend" + extracted = executor._extract_error(output) + assert "403" in extracted + def test_cleanup_temp_files(self, tmp_path): event = tmp_path / "event.json" event.write_text("{}") diff --git a/cli/tests/test_github_token.py b/cli/tests/test_github_token.py index aac0314..63e1f7b 100644 --- a/cli/tests/test_github_token.py +++ b/cli/tests/test_github_token.py @@ -9,7 +9,7 @@ from click.testing import CliRunner from localci.cli.main import cli -from localci.core.executor import JobExecutor +from localci.core.executor import AUTH_ERROR_EXTRACT_KEYWORDS, JobExecutor from localci.core.github_token import ( SENTINEL_GITHUB_TOKEN, format_sentinel_github_token_warning, @@ -17,7 +17,7 @@ resolve_github_token, warn_sentinel_github_token, ) -from localci.core.executor import _AUTH_ERROR_EXTRACT_KEYWORDS +from localci.utils.output import configure_console runner = CliRunner() SAMPLE_WORKFLOW = str(Path(__file__).parent / "fixtures" / "sample_workflow.yml") @@ -36,6 +36,22 @@ def test_sentinel_when_unset(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("GITHUB_TOKEN", raising=False) assert resolve_github_token(None) == SENTINEL_GITHUB_TOKEN + def test_blank_cli_token_falls_back_to_sentinel( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + assert resolve_github_token(" ") == SENTINEL_GITHUB_TOKEN + + def test_blank_env_token_falls_back_to_sentinel( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("GITHUB_TOKEN", " ") + assert resolve_github_token(None) == SENTINEL_GITHUB_TOKEN + + def test_env_token_is_stripped(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_TOKEN", " env-token ") + assert resolve_github_token(None) == "env-token" + def test_is_sentinel_detects_placeholder(self) -> None: assert is_sentinel_github_token(SENTINEL_GITHUB_TOKEN) assert not is_sentinel_github_token("ghp_real") @@ -50,8 +66,19 @@ def test_warning_message_documents_remediation(self) -> None: def test_warn_sentinel_emits_rich_warning(self, capsys: pytest.CaptureFixture[str]) -> None: warn_sentinel_github_token(SENTINEL_GITHUB_TOKEN) out = capsys.readouterr().out + assert out.lstrip().startswith("!") assert "No GitHub token provided" in out + def test_warn_sentinel_visible_when_console_quiet( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + configure_console(quiet=True) + warn_sentinel_github_token(SENTINEL_GITHUB_TOKEN) + out = capsys.readouterr().out + assert out.lstrip().startswith("!") + assert "No GitHub token provided" in out + configure_console(quiet=False) + def test_warn_sentinel_skips_real_token(self, capsys: pytest.CaptureFixture[str]) -> None: warn_sentinel_github_token("ghp_real") assert "No GitHub token provided" not in capsys.readouterr().out @@ -59,8 +86,9 @@ def test_warn_sentinel_skips_real_token(self, capsys: pytest.CaptureFixture[str] class TestAuthErrorExtractKeywords: def test_auth_keywords_registered(self) -> None: - assert _AUTH_ERROR_EXTRACT_KEYWORDS == ( + assert AUTH_ERROR_EXTRACT_KEYWORDS == ( "401", + "403", "unauthorized", "forbidden", "rate limit", @@ -74,6 +102,7 @@ class TestExtractErrorAuthKeywords: "Error: HTTP 401 Unauthorized", "authentication required: unauthorized", "403 Forbidden: resource not accessible", + "received HTTP status: 403", "API rate limit exceeded for user", ], ) @@ -100,6 +129,25 @@ def test_dry_run_warns_when_no_token( assert "No GitHub token provided" in result.output assert "GITHUB_TOKEN" in result.output + def test_dry_run_warns_when_no_token_and_quiet( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + result = runner.invoke( + cli, + [ + "-q", + "run", + "--workflow", + SAMPLE_WORKFLOW, + "--dry-run", + "--platform", + "linux", + ], + ) + assert result.exit_code == 0, result.output + assert "No GitHub token provided" in result.output + def test_dry_run_no_warning_with_cli_token( self, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -119,3 +167,24 @@ def test_dry_run_no_warning_with_cli_token( ) assert result.exit_code == 0, result.output assert "No GitHub token provided" not in result.output + + def test_early_exit_still_warns_when_no_jobs_match( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + result = runner.invoke( + cli, + [ + "run", + "--workflow", + SAMPLE_WORKFLOW, + "--dry-run", + "--platform", + "linux", + "--job", + "nonexistent-job-xyz", + ], + ) + assert result.exit_code == 0, result.output + assert "No GitHub token provided" in result.output + assert "No jobs match" in result.output From bc4cddefef26cf62a3572c89f889f494316990c1 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Tue, 2 Jun 2026 00:51:38 -0400 Subject: [PATCH 4/8] fix: Skip the sentinel warning when --offline is already enabled. --- cli/localci/cli/run.py | 3 ++- cli/tests/test_github_token.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/cli/localci/cli/run.py b/cli/localci/cli/run.py index fb7f271..1aceb82 100644 --- a/cli/localci/cli/run.py +++ b/cli/localci/cli/run.py @@ -158,7 +158,8 @@ def run( project_dir = Path(".").resolve() gh_token = resolve_github_token(github_token) - warn_sentinel_github_token(gh_token) + if not offline: + warn_sentinel_github_token(gh_token) # ── 1. Parse the workflow ────────────────────────────────────── try: diff --git a/cli/tests/test_github_token.py b/cli/tests/test_github_token.py index 63e1f7b..91408f3 100644 --- a/cli/tests/test_github_token.py +++ b/cli/tests/test_github_token.py @@ -148,6 +148,25 @@ def test_dry_run_warns_when_no_token_and_quiet( assert result.exit_code == 0, result.output assert "No GitHub token provided" in result.output + def test_dry_run_no_warning_when_offline( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + result = runner.invoke( + cli, + [ + "run", + "--workflow", + SAMPLE_WORKFLOW, + "--dry-run", + "--platform", + "linux", + "--offline", + ], + ) + assert result.exit_code == 0, result.output + assert "No GitHub token provided" not in result.output + def test_dry_run_no_warning_with_cli_token( self, monkeypatch: pytest.MonkeyPatch ) -> None: From 907edd893ebcb20534bd9248cda6d95bd39276cd Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Tue, 2 Jun 2026 04:02:28 -0400 Subject: [PATCH 5/8] fix: findings during full-review --- cli/localci/cli/run.py | 2 +- cli/localci/core/command_builder.py | 3 ++- cli/localci/core/executor.py | 32 ++++++++++++++++++++--------- cli/localci/core/github_token.py | 6 +++--- cli/localci/utils/output.py | 13 ++++++++---- cli/tests/test_executor.py | 9 ++++++++ cli/tests/test_github_token.py | 26 ----------------------- 7 files changed, 46 insertions(+), 45 deletions(-) diff --git a/cli/localci/cli/run.py b/cli/localci/cli/run.py index 1aceb82..470a0b0 100644 --- a/cli/localci/cli/run.py +++ b/cli/localci/cli/run.py @@ -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, diff --git a/cli/localci/core/command_builder.py b/cli/localci/core/command_builder.py index 86228a8..085743a 100644 --- a/cli/localci/core/command_builder.py +++ b/cli/localci/core/command_builder.py @@ -176,7 +176,8 @@ 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", SENTINEL_GITHUB_TOKEN) diff --git a/cli/localci/core/executor.py b/cli/localci/core/executor.py index 3c843f3..1cbed47 100644 --- a/cli/localci/core/executor.py +++ b/cli/localci/core/executor.py @@ -9,6 +9,7 @@ import logging import os +import re import shutil import subprocess import sys @@ -24,13 +25,6 @@ logger = logging.getLogger(__name__) # Substrings (matched case-insensitively) for summarizing failed job output. -AUTH_ERROR_EXTRACT_KEYWORDS = ( - "401", - "403", - "unauthorized", - "forbidden", - "rate limit", -) _ERROR_EXTRACT_KEYWORDS = ( "error:", "fatal:", @@ -40,9 +34,18 @@ "no such file", "cannot find", "compilation failed", - *AUTH_ERROR_EXTRACT_KEYWORDS, ) +# 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 @@ -641,6 +644,16 @@ 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.""" @@ -648,8 +661,7 @@ def _extract_error(output: str, max_lines: int = 10) -> str: error_lines: list[str] = [] for line in lines: - lower = line.lower() - if any(kw in lower for kw in _ERROR_EXTRACT_KEYWORDS): + if JobExecutor._line_indicates_error(line): error_lines.append(line.strip()) if error_lines: diff --git a/cli/localci/core/github_token.py b/cli/localci/core/github_token.py index 2b77283..8fd7472 100644 --- a/cli/localci/core/github_token.py +++ b/cli/localci/core/github_token.py @@ -1,8 +1,8 @@ """GitHub token resolution for act and workflow action downloads. -Part of the token slice of the Silent Failure Chain (T9 defaults + T10 error -extraction): missing tokens previously produced opaque act 401s with no -upfront warning. +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 diff --git a/cli/localci/utils/output.py b/cli/localci/utils/output.py index ea56ed3..8d3cf04 100644 --- a/cli/localci/utils/output.py +++ b/cli/localci/utils/output.py @@ -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 @@ -28,11 +30,13 @@ } ) -# 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``. -_important_console = Console(theme=LOCALCI_THEME) +# 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: @@ -45,6 +49,7 @@ def configure_console(*, no_color: bool = False, quiet: bool = False) -> None: ) _important_console = Console( theme=LOCALCI_THEME, + file=sys.stdout, no_color=no_color, quiet=False, ) diff --git a/cli/tests/test_executor.py b/cli/tests/test_executor.py index 8da42c7..a6f0e53 100644 --- a/cli/tests/test_executor.py +++ b/cli/tests/test_executor.py @@ -625,6 +625,15 @@ def test_extract_error_http_403(self, mock_which): extracted = executor._extract_error(output) assert "403" in extracted + @patch("shutil.which") + def test_extract_error_ignores_401_false_positive(self, mock_which): + mock_which.return_value = "/usr/bin/act" + executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + output = "progress: fetched 4010 bytes from cache\nall done" + extracted = executor._extract_error(output, max_lines=2) + assert "4010" in extracted + assert "401" not in extracted.split() + def test_cleanup_temp_files(self, tmp_path): event = tmp_path / "event.json" event.write_text("{}") diff --git a/cli/tests/test_github_token.py b/cli/tests/test_github_token.py index 91408f3..bd9706e 100644 --- a/cli/tests/test_github_token.py +++ b/cli/tests/test_github_token.py @@ -15,10 +15,7 @@ format_sentinel_github_token_warning, is_sentinel_github_token, resolve_github_token, - warn_sentinel_github_token, ) -from localci.utils.output import configure_console - runner = CliRunner() SAMPLE_WORKFLOW = str(Path(__file__).parent / "fixtures" / "sample_workflow.yml") @@ -63,32 +60,9 @@ def test_warning_message_documents_remediation(self) -> None: assert "--offline" in msg assert "401" in msg - def test_warn_sentinel_emits_rich_warning(self, capsys: pytest.CaptureFixture[str]) -> None: - warn_sentinel_github_token(SENTINEL_GITHUB_TOKEN) - out = capsys.readouterr().out - assert out.lstrip().startswith("!") - assert "No GitHub token provided" in out - - def test_warn_sentinel_visible_when_console_quiet( - self, capsys: pytest.CaptureFixture[str] - ) -> None: - configure_console(quiet=True) - warn_sentinel_github_token(SENTINEL_GITHUB_TOKEN) - out = capsys.readouterr().out - assert out.lstrip().startswith("!") - assert "No GitHub token provided" in out - configure_console(quiet=False) - - def test_warn_sentinel_skips_real_token(self, capsys: pytest.CaptureFixture[str]) -> None: - warn_sentinel_github_token("ghp_real") - assert "No GitHub token provided" not in capsys.readouterr().out - - class TestAuthErrorExtractKeywords: def test_auth_keywords_registered(self) -> None: assert AUTH_ERROR_EXTRACT_KEYWORDS == ( - "401", - "403", "unauthorized", "forbidden", "rate limit", From 8bf2f50ff77c61557b3adf48d1ffca777573c3ac Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Tue, 2 Jun 2026 06:46:26 -0400 Subject: [PATCH 6/8] fix: Test may not distinguish correct vs. incorrect implementations. --- cli/tests/test_executor.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/cli/tests/test_executor.py b/cli/tests/test_executor.py index a6f0e53..64d4650 100644 --- a/cli/tests/test_executor.py +++ b/cli/tests/test_executor.py @@ -629,10 +629,18 @@ def test_extract_error_http_403(self, mock_which): def test_extract_error_ignores_401_false_positive(self, mock_which): mock_which.return_value = "/usr/bin/act" executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) - output = "progress: fetched 4010 bytes from cache\nall done" + # 4010 line must not be in the last two lines so max_lines=2 fallback + # differs from wrongly treating "401" inside "4010" as an error line. + output = ( + "progress: fetched 4010 bytes from cache\n" + "middle: still running\n" + "all done\n" + "finished ok" + ) extracted = executor._extract_error(output, max_lines=2) - assert "4010" in extracted - assert "401" not in extracted.split() + assert "4010" not in extracted + assert "all done" in extracted + assert "finished ok" in extracted def test_cleanup_temp_files(self, tmp_path): event = tmp_path / "event.json" From c954792b46daf2ebb7c048e8719bec18ab3a4816 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Tue, 2 Jun 2026 11:27:35 -0400 Subject: [PATCH 7/8] fix: remove hardcoded `/tmp/localci-test` from tests --- cli/tests/test_executor.py | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/cli/tests/test_executor.py b/cli/tests/test_executor.py index 64d4650..63fcd2c 100644 --- a/cli/tests/test_executor.py +++ b/cli/tests/test_executor.py @@ -455,16 +455,21 @@ def test_default_status_is_pending(self): class TestJobExecutor: """Test executor with mocked subprocess.""" + @pytest.fixture(autouse=True) + def _logs_dir(self, tmp_path: Path) -> None: + self.logs_dir = tmp_path / "logs" + self.logs_dir.mkdir(parents=True, exist_ok=True) + @patch("shutil.which") def test_has_act_true(self, mock_which): mock_which.return_value = "/usr/bin/act" - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) assert executor.has_act is True @patch("shutil.which") def test_has_act_false(self, mock_which): mock_which.return_value = None - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) assert executor.has_act is False @patch("sys.platform", "win32") @@ -479,7 +484,7 @@ def which_side_effect(name): return None mock_which.side_effect = which_side_effect - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) assert executor.has_act is True assert executor._act_path == "C:\\ProgramData\\chocolatey\\bin\\act-cli.exe" @@ -490,14 +495,14 @@ def test_check_act_success(self, mock_which, mock_run): mock_run.return_value = MagicMock( returncode=0, stdout="act version 0.2.68" ) - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) version = executor.check_act() assert "0.2.68" in version @patch("shutil.which") def test_check_act_not_installed(self, mock_which): mock_which.return_value = None - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) with pytest.raises(ActNotFoundError, match="act is not installed"): executor.check_act() @@ -506,7 +511,7 @@ def test_check_act_not_installed(self, mock_which): def test_check_docker_success(self, mock_which, mock_run): mock_which.side_effect = lambda name: f"/usr/bin/{name}" mock_run.return_value = MagicMock(returncode=0) - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) # Should not raise executor.check_docker() @@ -515,7 +520,7 @@ def test_check_docker_not_installed(self, mock_which): mock_which.side_effect = ( lambda name: "/usr/bin/act" if name == "act" else None ) - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) with pytest.raises( DockerNotAvailableError, match="not installed" ): @@ -526,14 +531,14 @@ def test_check_docker_not_installed(self, mock_which): def test_check_docker_not_running(self, mock_which, mock_run): mock_which.side_effect = lambda name: f"/usr/bin/{name}" mock_run.return_value = MagicMock(returncode=1) - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) with pytest.raises(DockerNotAvailableError, match="not running"): executor.check_docker() @patch("shutil.which") def test_run_dryrun(self, mock_which): mock_which.side_effect = lambda name: f"/usr/bin/{name}" - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) cmd = ActCommand( workflow_file=Path("ci.yml"), @@ -555,7 +560,7 @@ def test_run_dryrun(self, mock_which): @patch("shutil.which") def test_run_preflight_act_fails(self, mock_which): mock_which.return_value = None - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) cmd = ActCommand( workflow_file=Path("ci.yml"), @@ -570,7 +575,7 @@ def test_run_preflight_docker_fails(self, mock_which): mock_which.side_effect = ( lambda name: "/usr/bin/act" if name == "act" else None ) - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) cmd = ActCommand( workflow_file=Path("ci.yml"), @@ -588,7 +593,7 @@ def test_run_preflight_docker_fails(self, mock_which): @patch("shutil.which") def test_extract_error_finds_keywords(self, mock_which): mock_which.return_value = "/usr/bin/act" - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) output = textwrap.dedent("""\ Step 1: setup Step 2: build @@ -603,7 +608,7 @@ def test_extract_error_finds_keywords(self, mock_which): @patch("shutil.which") def test_extract_error_fallback(self, mock_which): mock_which.return_value = "/usr/bin/act" - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) output = "line 1\nline 2\nline 3" extracted = executor._extract_error(output, max_lines=2) assert "line 2" in extracted @@ -612,7 +617,7 @@ def test_extract_error_fallback(self, mock_which): @patch("shutil.which") def test_extract_error_http_401(self, mock_which): mock_which.return_value = "/usr/bin/act" - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) output = "fetching action\nHTTP 401: Bad credentials\nend" extracted = executor._extract_error(output) assert "401" in extracted @@ -620,7 +625,7 @@ def test_extract_error_http_401(self, mock_which): @patch("shutil.which") def test_extract_error_http_403(self, mock_which): mock_which.return_value = "/usr/bin/act" - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) output = "fetching action\nreceived HTTP status: 403\nend" extracted = executor._extract_error(output) assert "403" in extracted @@ -628,7 +633,7 @@ def test_extract_error_http_403(self, mock_which): @patch("shutil.which") def test_extract_error_ignores_401_false_positive(self, mock_which): mock_which.return_value = "/usr/bin/act" - executor = JobExecutor(logs_dir=Path("/tmp/localci-test")) + executor = JobExecutor(logs_dir=self.logs_dir) # 4010 line must not be in the last two lines so max_lines=2 fallback # differs from wrongly treating "401" inside "4010" as an error line. output = ( From c0d4f6fed45608cebcd7ffcb99ad4f4b9ecfe323 Mon Sep 17 00:00:00 2001 From: bradjin8 Date: Tue, 2 Jun 2026 11:49:44 -0400 Subject: [PATCH 8/8] fix: Strengthen the auth-extraction assertions. --- cli/tests/test_executor.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/cli/tests/test_executor.py b/cli/tests/test_executor.py index 63fcd2c..9f47681 100644 --- a/cli/tests/test_executor.py +++ b/cli/tests/test_executor.py @@ -618,17 +618,34 @@ def test_extract_error_fallback(self, mock_which): def test_extract_error_http_401(self, mock_which): mock_which.return_value = "/usr/bin/act" executor = JobExecutor(logs_dir=self.logs_dir) - output = "fetching action\nHTTP 401: Bad credentials\nend" - extracted = executor._extract_error(output) + # Auth line not in last two lines so max_lines=2 fallback cannot pass alone. + output = ( + "setup: resolving action\n" + "HTTP 401: unauthorized - Bad credentials\n" + "middle: post-download\n" + "all done\n" + "finished ok" + ) + extracted = executor._extract_error(output, max_lines=2) assert "401" in extracted + assert "unauthorized" in extracted.lower() + assert "finished ok" not in extracted @patch("shutil.which") def test_extract_error_http_403(self, mock_which): mock_which.return_value = "/usr/bin/act" executor = JobExecutor(logs_dir=self.logs_dir) - output = "fetching action\nreceived HTTP status: 403\nend" - extracted = executor._extract_error(output) + output = ( + "setup: resolving action\n" + "received HTTP status: 403 forbidden for this resource\n" + "middle: post-download\n" + "all done\n" + "finished ok" + ) + extracted = executor._extract_error(output, max_lines=2) assert "403" in extracted + assert "forbidden" in extracted.lower() + assert "finished ok" not in extracted @patch("shutil.which") def test_extract_error_ignores_401_false_positive(self, mock_which):