diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86826b2..8d70c57 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,8 @@ name: Build and Release on: push: + branches: + - main tags: - 'v*' workflow_dispatch: @@ -37,16 +39,22 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + outputs: + is_new_release: ${{ steps.version-check.outputs.is_new_release }} + release_tag: ${{ steps.version-check.outputs.release_tag }} steps: - name: Checkout Code uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v7 with: python-version: '3.12' - - name: Verify Version Consistency + - name: Verify Version and Release Readiness + id: version-check shell: bash run: | TOML_VERSION=$(python -c "import tomllib; f=open('pyproject.toml', 'rb'); print(tomllib.load(f)['project']['version'])") @@ -60,6 +68,9 @@ jobs: exit 1 fi + TARGET_TAG="v$TOML_VERSION" + echo "Target release tag: $TARGET_TAG" + if [[ "${{ github.ref }}" =~ ^refs/tags/v ]]; then TAG_VERSION="${GITHUB_REF_NAME#v}" echo "Git tag version: $TAG_VERSION" @@ -67,24 +78,52 @@ jobs: echo "::error::Tag mismatch: Git tag v$TAG_VERSION does not match project version $TOML_VERSION" exit 1 fi + echo "is_new_release=true" >> "$GITHUB_OUTPUT" + echo "release_tag=$TARGET_TAG" >> "$GITHUB_OUTPUT" + elif [ "${{ github.ref }}" = "refs/heads/main" ]; then + if git rev-parse -q --verify "refs/tags/$TARGET_TAG" >/dev/null; then + echo "Tag $TARGET_TAG already exists in git history. Skipping automated release." + echo "is_new_release=false" >> "$GITHUB_OUTPUT" + echo "release_tag=$TARGET_TAG" >> "$GITHUB_OUTPUT" + else + echo "New version detected on main ($TARGET_TAG does not exist yet). Proceeding with automated release." + echo "is_new_release=true" >> "$GITHUB_OUTPUT" + echo "release_tag=$TARGET_TAG" >> "$GITHUB_OUTPUT" + fi + else + echo "is_new_release=true" >> "$GITHUB_OUTPUT" + echo "release_tag=$TARGET_TAG" >> "$GITHUB_OUTPUT" fi - echo "Version consistency verified: $TOML_VERSION" + - name: Create and push Git tag + if: steps.version-check.outputs.is_new_release == 'true' && github.ref == 'refs/heads/main' + shell: bash + run: | + TARGET_TAG="${{ steps.version-check.outputs.release_tag }}" + echo "Creating and pushing Git tag: $TARGET_TAG" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "$TARGET_TAG" + git push origin "$TARGET_TAG" - name: Install build tools + if: steps.version-check.outputs.is_new_release == 'true' run: | python -m pip install --upgrade pip pip install build twine - name: Build sdist and wheel + if: steps.version-check.outputs.is_new_release == 'true' run: | python -m build - name: Verify Package Metadata + if: steps.version-check.outputs.is_new_release == 'true' run: | python -m twine check dist/* - name: Upload dist artifacts + if: steps.version-check.outputs.is_new_release == 'true' uses: actions/upload-artifact@v4 with: name: dist @@ -93,15 +132,16 @@ jobs: - name: Create GitHub Release and Upload Assets uses: softprops/action-gh-release@v2 - if: startsWith(github.ref, 'refs/tags/v') + if: steps.version-check.outputs.is_new_release == 'true' with: + tag_name: ${{ steps.version-check.outputs.release_tag }} files: dist/* generate_release_notes: true publish-pypi: name: Publish to PyPI via OIDC needs: [test, build] - if: startsWith(github.ref, 'refs/tags/v') + if: needs.build.outputs.is_new_release == 'true' runs-on: ubuntu-latest environment: name: pypi diff --git a/CHANGELOG.md b/CHANGELOG.md index a28170d..2f9a208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-09-19 + +### Added +- Structured runtime failure diagnostics: detect common "command not found" failures across Linux distributions (Alpine/BusyBox ash, Debian/Ubuntu dash, Bash, and missing interpreter paths). +- High-confidence dual-signal classification: requires exit code 127 and matching shell output to produce a `missing_command` diagnostic. +- Additive `diagnostic` field in JSON report format (`SingleResult.to_dict()`) providing structured diagnostic information (backward-compatible for consumers tolerating additional fields). +- Enhanced terminal table formatting and GitHub Actions Step Summary showing clear failure causes in the summary overview. +- Automated release on merge to main when project version is bumped. + +--- + ## [0.2.1] - 2026-09-19 ### Fixed diff --git a/README.md b/README.md index 0216247..641738a 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: Mresyzz/opsscript-gate@v0.2.1 + - uses: Mresyzz/opsscript-gate@v0.3.0 with: script-path: scripts/setup.sh shell: auto diff --git a/examples/github-actions/workflow.yml b/examples/github-actions/workflow.yml index 754891f..5cc7927 100644 --- a/examples/github-actions/workflow.yml +++ b/examples/github-actions/workflow.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v7 - name: Run OpsScript Gate - uses: Mresyzz/opsscript-gate@v0.2.1 + uses: Mresyzz/opsscript-gate@v0.3.0 with: script-path: 'scripts/deploy.sh' shell: auto diff --git a/pyproject.toml b/pyproject.toml index af1b4e0..b6787a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "opsscript-gate" -version = "0.2.1" +version = "0.3.0" description = "Run Linux shell scripts across Debian, Ubuntu and Alpine before release to catch runtime compatibility failures" readme = "README.md" license = { text = "MIT" } diff --git a/src/opsscript_gate/__init__.py b/src/opsscript_gate/__init__.py index 8c4d82c..8b7b1e1 100644 --- a/src/opsscript_gate/__init__.py +++ b/src/opsscript_gate/__init__.py @@ -1,4 +1,4 @@ """OpsScript Gate: Lightweight cross-distro compatibility pre-check for Linux ops scripts.""" -__version__ = "0.2.1" +__version__ = "0.3.0" __all__ = ["__version__"] diff --git a/src/opsscript_gate/models.py b/src/opsscript_gate/models.py index 51107e8..498ca6c 100644 --- a/src/opsscript_gate/models.py +++ b/src/opsscript_gate/models.py @@ -20,6 +20,23 @@ class ShellMode(str, Enum): AUTO = "auto" +@dataclass +class FailureDiagnostic: + """Structured diagnostic information for execution failures.""" + kind: str + message: str + command: str | None = None + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + "kind": self.kind, + "message": self.message, + } + if self.command is not None: + d["command"] = self.command + return d + + @dataclass class SingleResult: """Execution result for a single Linux distribution.""" @@ -29,6 +46,7 @@ class SingleResult: duration: float output_snippet: str = "" error_message: str | None = None + diagnostic: FailureDiagnostic | None = None def to_dict(self) -> dict[str, Any]: return { @@ -38,6 +56,7 @@ def to_dict(self) -> dict[str, Any]: "duration": round(self.duration, 3), "output_snippet": self.output_snippet, "error_message": self.error_message, + "diagnostic": self.diagnostic.to_dict() if self.diagnostic is not None else None, } diff --git a/src/opsscript_gate/reporter.py b/src/opsscript_gate/reporter.py index 6407e40..66d6444 100644 --- a/src/opsscript_gate/reporter.py +++ b/src/opsscript_gate/reporter.py @@ -16,6 +16,8 @@ def format_terminal_table(report: RunReport) -> str: if r.status == DistroStatus.PASS: detail = "OK" + elif r.diagnostic: + detail = r.diagnostic.message elif r.error_message: detail = r.error_message else: @@ -107,7 +109,12 @@ def format_github_summary(report: RunReport) -> str: exit_code_str = f"`{r.exit_code}`" if r.exit_code is not None else "`N/A`" duration_str = f"`{r.duration:.2f}s`" - msg = r.error_message or "-" + if r.status == DistroStatus.PASS: + msg = "OK" + elif r.diagnostic: + msg = r.diagnostic.message + else: + msg = r.error_message or "-" msg_escaped = msg.replace("|", "\\|") lines.append( @@ -123,6 +130,11 @@ def format_github_summary(report: RunReport) -> str: for r in failures: lines.append(f"
[{r.status.value}] {r.distro}") lines.append("") + if r.diagnostic: + clean_kind = r.diagnostic.kind.replace("`", "'") + clean_msg = r.diagnostic.message.replace("`", "'") + lines.append(f"> **Diagnostic:** `{clean_kind}` — `{clean_msg}`") + lines.append("") if r.error_message: lines.append(f"> **Error:** {r.error_message}") lines.append("") diff --git a/src/opsscript_gate/runner.py b/src/opsscript_gate/runner.py index 1832744..b0ed333 100644 --- a/src/opsscript_gate/runner.py +++ b/src/opsscript_gate/runner.py @@ -11,7 +11,7 @@ from dataclasses import dataclass from enum import Enum -from opsscript_gate.models import DistroStatus, RunReport, ShellMode, SingleResult +from opsscript_gate.models import DistroStatus, FailureDiagnostic, RunReport, ShellMode, SingleResult DEFAULT_MATRIX: list[str] = [ "debian:12-slim", @@ -67,6 +67,94 @@ def sanitize_diagnostic_text(text: str, max_length: int = 200) -> str: return sanitized +# Conservative regular expression matching common shell 'not found' error patterns: +# Examples: +# - "apt-get: not found" +# - "bash: foo: command not found" +# - "/tmp/target_script.sh: line 4: curl: not found" +# - "/bin/sh: line 1: /usr/bin/bash: not found" +_MISSING_COMMAND_RE = re.compile( + r""" + (?:^|(?<=[\r\n])) # start of line + (?:\s*[^:\r\n]+:\s*)* # optional prefixes like 'sh: ', 'line 4: ', 'script.sh: 2: ' + (?P['"`]?[\w./+-]+['"`]?) # strictly valid command identifier/path + :\s+ # colon separator + (?:command\s+not\s+found|not\s+found) # missing command indicator + \s*(?=$|[\r\n]) # end of line + """, + re.IGNORECASE | re.VERBOSE | re.MULTILINE, +) + +# Strict whitelist for extracted command token validation (alphanumerics, Unicode words, dots, dashes, slashes): +_VALID_COMMAND_TOKEN_RE = re.compile(r"^[\w./+-]+$") + +# Common status, protocol, and message prefixes that must never be classified as missing commands: +_NON_COMMAND_TOKENS = { + "status", "error", "warning", "info", "notice", "message", + "file", "directory", "entry", "key", "value", "user", "record", + "http", "https", "response", "request", "server", "client", +} + + +def extract_failure_diagnostic( + output: str, + exit_code: int | None = None, +) -> FailureDiagnostic | None: + """ + Extract high-confidence structured diagnostic from container execution failure. + + Deliberately conservative (prefer false negatives over false positives): + - Requires exit_code == 127 as an essential high-confidence signal. + - Requires matching a well-known shell 'not found' pattern in output. + - Strictly validates command token syntax (no arbitrary text or markdown injection). + - Excludes numeric status codes, status/error message prefixes, path-only dots/slashes, and unclassified output. + - Returns None if not confidently classified as missing_command. + """ + if exit_code != 127 or not output: + return None + + # Strip ANSI escape sequences first + cleaned_output = _ANSI_ESCAPE_RE.sub("", output) + + for line in cleaned_output.splitlines(): + line_clean = line.strip() + if not line_clean: + continue + + m = _MISSING_COMMAND_RE.search(line_clean) + if not m: + continue + + raw_cmd = m.group("cmd").strip("'\"`") + if not raw_cmd or raw_cmd.isdigit(): + continue + + # Disallow pure dot/slash sequences + if raw_cmd in (".", "..", "/", "//"): + continue + + # Exclude common non-command tokens and protocol prefixes (e.g. Status, Error, HTTP/1.1) + lowered = raw_cmd.lower() + if lowered in _NON_COMMAND_TOKENS or lowered.split("/")[0] in _NON_COMMAND_TOKENS: + continue + + # Validate against strict conservative grammar + if not _VALID_COMMAND_TOKEN_RE.match(raw_cmd): + continue + + sanitized_cmd = sanitize_diagnostic_text(raw_cmd, max_length=100) + if not sanitized_cmd or not _VALID_COMMAND_TOKEN_RE.match(sanitized_cmd): + continue + + return FailureDiagnostic( + kind="missing_command", + command=sanitized_cmd, + message=f"command not found: {sanitized_cmd}", + ) + + return None + + class ShebangStatus(str, Enum): """Status of shebang parsing.""" RECOGNIZED = "recognized" @@ -505,6 +593,7 @@ def run_on_distro( error_message=None, ) else: + diagnostic = extract_failure_diagnostic(output, exit_code=exit_code) return SingleResult( distro=distro, status=DistroStatus.FAIL, @@ -512,6 +601,7 @@ def run_on_distro( duration=duration, output_snippet=extract_snippet(output), error_message=f"Script failed with non-zero exit code: {exit_code}", + diagnostic=diagnostic, ) except Exception as exc: diff --git a/tests/test_runner.py b/tests/test_runner.py index 4c60406..9cd21a1 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -9,7 +9,7 @@ from docker.errors import DockerException from opsscript_gate.cli import build_parser, main, parse_matrix_argument -from opsscript_gate.models import DistroStatus, RunReport, ShellMode, SingleResult +from opsscript_gate.models import DistroStatus, FailureDiagnostic, RunReport, ShellMode, SingleResult from opsscript_gate.reporter import ( format_github_summary, format_json, @@ -24,6 +24,7 @@ DockerDaemonError, ShebangParseResult, ShebangStatus, + extract_failure_diagnostic, extract_snippet, get_docker_client, inspect_shebang, @@ -368,7 +369,7 @@ def test_cli_version(capsys): main(["--version"]) assert excinfo.value.code == 0 captured = capsys.readouterr() - assert "0.2.1" in captured.out + assert "0.3.0" in captured.out def test_cli_format_markdown_and_table(tmp_path, capsys): @@ -883,3 +884,237 @@ def test_integration_missing_bash_alpine(tmp_path): assert report.results[0].status == DistroStatus.FAIL assert report.results[0].exit_code == 127 assert "not found" in (report.results[0].output_snippet or "").lower() + + +# ============================================================================== +# 7. Runtime Failure Diagnostics Tests +# ============================================================================== + +def test_extract_failure_diagnostic_positive_variants(): + # Alpine-style + diag1 = extract_failure_diagnostic("/tmp/target_script.sh: line 4: apt-get: not found", exit_code=127) + assert diag1 is not None + assert diag1.kind == "missing_command" + assert diag1.command == "apt-get" + assert diag1.message == "command not found: apt-get" + + # Debian/Ubuntu dash style + diag2 = extract_failure_diagnostic("/tmp/target_script.sh: 4: curl: not found", exit_code=127) + assert diag2 is not None + assert diag2.command == "curl" + assert diag2.message == "command not found: curl" + + # Bash style with underscores + diag3 = extract_failure_diagnostic("bash: line 4: foo_bar: command not found", exit_code=127) + assert diag3 is not None + assert diag3.command == "foo_bar" + assert diag3.message == "command not found: foo_bar" + + # Direct short command + diag4 = extract_failure_diagnostic("apt-get: not found", exit_code=127) + assert diag4 is not None + assert diag4.command == "apt-get" + + # Missing interpreter path + diag5 = extract_failure_diagnostic("/bin/sh: line 1: /usr/bin/bash: not found", exit_code=127) + assert diag5 is not None + assert diag5.command == "/usr/bin/bash" + assert diag5.message == "command not found: /usr/bin/bash" + + # Versioned command + diag6 = extract_failure_diagnostic("sh: python3.12: not found", exit_code=127) + assert diag6 is not None + assert diag6.command == "python3.12" + + # Single-quoted and double-quoted variants + diag7 = extract_failure_diagnostic("bash: 'my-cmd': command not found", exit_code=127) + assert diag7 is not None + assert diag7.command == "my-cmd" + + diag8 = extract_failure_diagnostic('sh: "my-cmd": not found', exit_code=127) + assert diag8 is not None + assert diag8.command == "my-cmd" + + # Multi-line output selects first missing command + multiline = "Step 1\nsh: line 2: curl: not found\nsh: line 3: jq: not found\nDone" + diag9 = extract_failure_diagnostic(multiline, exit_code=127) + assert diag9 is not None + assert diag9.command == "curl" + + +def test_extract_failure_diagnostic_negative_rules(): + # Bare exit 127 with no matching shell output + assert extract_failure_diagnostic("Process terminated with 127", exit_code=127) is None + assert extract_failure_diagnostic("", exit_code=127) is None + assert extract_failure_diagnostic("Unknown fatal error", exit_code=127) is None + + # Matching output but exit code != 127 (e.g. exit 1 or 2) + assert extract_failure_diagnostic("bash: foo: command not found", exit_code=1) is None + assert extract_failure_diagnostic("apt-get: not found", exit_code=2) is None + assert extract_failure_diagnostic("/tmp/script.sh: curl: not found", exit_code=None) is None + + # Program merely printing command-not-found-like text + assert extract_failure_diagnostic("cat: /etc/hosts: File not found", exit_code=127) is None + assert extract_failure_diagnostic("grep: /path/file: No such file or directory", exit_code=127) is None + assert extract_failure_diagnostic("Error: database key not found in cache", exit_code=127) is None + + # HTTP status or numeric tokens + assert extract_failure_diagnostic("HTTP/1.1 404: not found", exit_code=127) is None + assert extract_failure_diagnostic("404: not found", exit_code=127) is None + assert extract_failure_diagnostic("error: 1234: not found", exit_code=127) is None + assert extract_failure_diagnostic("warning: 0: command not found", exit_code=127) is None + + # Other common errors that must NOT be misclassified as missing_command + assert extract_failure_diagnostic("sh: line 10: syntax error: unexpected end of file", exit_code=127) is None + assert extract_failure_diagnostic("bash: /path/to/script.sh: Permission denied", exit_code=127) is None + assert extract_failure_diagnostic("Status: not found", exit_code=127) is None + + # Pure symbols/dots + assert extract_failure_diagnostic(": not found", exit_code=127) is None + assert extract_failure_diagnostic("..: not found", exit_code=127) is None + assert extract_failure_diagnostic("/: not found", exit_code=127) is None + assert extract_failure_diagnostic("//: not found", exit_code=127) is None + + +def test_extract_failure_diagnostic_sanitization_and_unicode(): + # ANSI escape sequences stripped before matching + ansi_text = "\x1b[31mbash: curl: command not found\x1b[0m" + diag = extract_failure_diagnostic(ansi_text, exit_code=127) + assert diag is not None + assert diag.command == "curl" + assert diag.message == "command not found: curl" + + # Unicode command identifier + unicode_text = "sh: line 1: 测试工具: not found" + diag_u = extract_failure_diagnostic(unicode_text, exit_code=127) + assert diag_u is not None + assert diag_u.command == "测试工具" + assert diag_u.message == "command not found: 测试工具" + + # Excessive command name length truncated safely with ellipsis + long_cmd = "a" * 150 + long_text = f"sh: {long_cmd}: not found" + diag_long = extract_failure_diagnostic(long_text, exit_code=127) + assert diag_long is not None + assert len(diag_long.command) == 100 + assert diag_long.command.endswith("...") + + +def test_run_on_distro_with_diagnostic_mock(tmp_path): + script_file = tmp_path / "test_missing.sh" + script_file.write_text("#!/bin/sh\napt-get update\n", encoding="utf-8") + + mock_client = mock.MagicMock() + mock_container = mock.MagicMock() + mock_container.status = "exited" + mock_container.attrs = {"State": {"ExitCode": 127}} + mock_container.logs.return_value = b"sh: line 2: apt-get: not found\n" + mock_client.containers.create.return_value = mock_container + + # Positive: exit 127 + apt-get not found -> sets structured diagnostic + res_pos = run_on_distro(mock_client, str(script_file), "alpine:3.20") + assert res_pos.status == DistroStatus.FAIL + assert res_pos.exit_code == 127 + assert res_pos.error_message == "Script failed with non-zero exit code: 127" + assert res_pos.diagnostic is not None + assert res_pos.diagnostic.kind == "missing_command" + assert res_pos.diagnostic.command == "apt-get" + assert res_pos.diagnostic.message == "command not found: apt-get" + + # Negative 1: exit 127 but no missing command in output -> diagnostic is None + mock_container.logs.return_value = b"An unexpected error occurred.\n" + res_neg1 = run_on_distro(mock_client, str(script_file), "alpine:3.20") + assert res_neg1.status == DistroStatus.FAIL + assert res_neg1.exit_code == 127 + assert res_neg1.diagnostic is None + assert res_neg1.error_message == "Script failed with non-zero exit code: 127" + + # Negative 2: exit 1 with 'command not found' output -> diagnostic is None (must be exit 127) + mock_container.attrs = {"State": {"ExitCode": 1}} + mock_container.logs.return_value = b"sh: line 2: apt-get: not found\n" + res_neg2 = run_on_distro(mock_client, str(script_file), "alpine:3.20") + assert res_neg2.status == DistroStatus.FAIL + assert res_neg2.exit_code == 1 + assert res_neg2.diagnostic is None + assert res_neg2.error_message == "Script failed with non-zero exit code: 1" + + +def test_diagnostic_serialization_backward_compatibility(): + # With diagnostic + res_with_diag = SingleResult( + distro="alpine:3.20", + status=DistroStatus.FAIL, + exit_code=127, + duration=0.456, + output_snippet="apt-get: not found", + error_message="Script failed with non-zero exit code: 127", + diagnostic=FailureDiagnostic( + kind="missing_command", + command="apt-get", + message="command not found: apt-get", + ), + ) + d = res_with_diag.to_dict() + assert d["distro"] == "alpine:3.20" + assert d["status"] == "FAIL" + assert d["exit_code"] == 127 + assert d["duration"] == 0.456 + assert d["output_snippet"] == "apt-get: not found" + assert d["error_message"] == "Script failed with non-zero exit code: 127" + assert d["diagnostic"] == { + "kind": "missing_command", + "command": "apt-get", + "message": "command not found: apt-get", + } + + # Without diagnostic + res_without_diag = SingleResult( + distro="debian:12-slim", + status=DistroStatus.PASS, + exit_code=0, + duration=1.0, + ) + d2 = res_without_diag.to_dict() + assert d2["diagnostic"] is None + + +def test_reporter_rendering_with_diagnostic(): + res1 = SingleResult( + distro="debian:12-slim", + status=DistroStatus.PASS, + exit_code=0, + duration=0.5, + ) + res2 = SingleResult( + distro="alpine:3.20", + status=DistroStatus.FAIL, + exit_code=127, + duration=0.2, + output_snippet="apt-get: not found", + error_message="Script failed with non-zero exit code: 127", + diagnostic=FailureDiagnostic( + kind="missing_command", + command="apt-get", + message="command not found: apt-get", + ), + ) + res3 = SingleResult( + distro="ubuntu:22.04", + status=DistroStatus.FAIL, + exit_code=1, + duration=0.3, + error_message="Script failed with non-zero exit code: 1", + ) + report = RunReport(results=[res1, res2, res3], total_duration=1.0) + + # 1. Terminal Table + table_output = format_terminal_table(report) + assert "command not found: apt-get" in table_output + assert "Script failed with non-zero exit code: 1" in table_output + assert "OK" in table_output + + # 2. GitHub Summary + gh_output = format_github_summary(report) + assert "command not found: apt-get" in gh_output + assert "> **Diagnostic:** `missing_command` — `command not found: apt-get`" in gh_output + assert "> **Error:** Script failed with non-zero exit code: 127" in gh_output