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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## [0.9.5] - 2026-11-08

### Added
- Extended structured failure diagnostics to handle exit code 126 (`permission_denied` and `exec_format_error`) and exit code 137 (`oom_killed`) with targeted remediation suggestions.
- Added native distribution family detection for Amazon Linux (`amazonlinux`, `amzn`) and Oracle Linux (`oracle`, `ol:`) to support RHEL-family package recommendations.
- Added GitHub Composite Action `outputs` for `all-passed` and `report-file`, exporting execution status via `$GITHUB_OUTPUT`.
- Surfaced package installation recommendations and package names directly in Markdown compatibility tables, failure diagnostic log drawers, and terminal detail outputs.
- Added convenient counting properties (`passed_count`, `failed_count`, `total_count`) on `RunReport` and `MultiScriptReport`.

## [0.9.4] - 2026-10-11

### Fixed
Expand Down
18 changes: 17 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ inputs:
required: false
default: ''

outputs:
all-passed:
description: 'Whether all script compatibility checks passed (true or false)'
value: ${{ steps.gate.outputs.all-passed }}
report-file:
description: 'Path to saved report file if output input was set'
value: ${{ inputs.output }}

runs:
using: 'composite'
steps:
Expand All @@ -94,6 +102,7 @@ runs:
pip install "${{ github.action_path }}"

- name: Run compatibility gate
id: gate
shell: bash
env:
INPUT_SCRIPT_PATH: ${{ inputs.script-path }}
Expand Down Expand Up @@ -146,4 +155,11 @@ runs:
echo "dry-run must be true or false" >&2
exit 1
fi
python -m opsscript_gate.cli "${ARGS[@]}"
GATE_STATUS=0
python -m opsscript_gate.cli "${ARGS[@]}" || GATE_STATUS=$?
if [ "$GATE_STATUS" -eq 0 ]; then
echo "all-passed=true" >> "$GITHUB_OUTPUT"
else
echo "all-passed=false" >> "$GITHUB_OUTPUT"
fi
exit "$GATE_STATUS"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "opsscript-gate"
version = "0.9.4"
version = "0.9.5"
description = "Cross-distro shell script compatibility and portability checker for Debian, Ubuntu, and Alpine. Runs real containers to diagnose command not found."
readme = "README.md"
license = { text = "MIT" }
Expand Down
2 changes: 1 addition & 1 deletion src/opsscript_gate/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""OpsScript Gate: Lightweight cross-distro compatibility pre-check for Linux ops scripts."""

__version__ = "0.9.4"
__version__ = "0.9.5"
__all__ = ["__version__"]
2 changes: 1 addition & 1 deletion src/opsscript_gate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def init_project() -> list[str]:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: Mresyzz/opsscript-gate@v0.9.4
- uses: Mresyzz/opsscript-gate@v0.9.5
with:
config: .opsscript-gate.json
""",
Expand Down
24 changes: 24 additions & 0 deletions src/opsscript_gate/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ def __post_init__(self) -> None:
if self.results:
self.all_passed = all(r.status == DistroStatus.PASS for r in self.results)

@property
def passed_count(self) -> int:
return sum(1 for r in self.results if r.status == DistroStatus.PASS)

@property
def failed_count(self) -> int:
return len(self.results) - self.passed_count

@property
def total_count(self) -> int:
return len(self.results)

def to_dict(self) -> dict[str, Any]:
return {
"results": [r.to_dict() for r in self.results],
Expand All @@ -133,6 +145,18 @@ def __post_init__(self) -> None:
if self.reports:
self.all_passed = all(r.all_passed for r in self.reports.values())

@property
def passed_scripts_count(self) -> int:
return sum(1 for r in self.reports.values() if r.all_passed)

@property
def failed_scripts_count(self) -> int:
return len(self.reports) - self.passed_scripts_count

@property
def total_scripts_count(self) -> int:
return len(self.reports)

def to_dict(self) -> dict[str, Any]:
return {
"reports": {script: r.to_dict() for script, r in self.reports.items()},
Expand Down
69 changes: 45 additions & 24 deletions src/opsscript_gate/remediation.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,38 +22,48 @@ def _normalize_cmd_basename(command: str | None) -> str:
return norm


def detect_distro_family(distro: str | None) -> str:
"""Identify the distribution family for package manager commands."""
if not distro:
return "unknown"
d = distro.lower()
if "alpine" in d:
return "alpine"
if any(name in d for name in ("debian", "ubuntu", "mint")):
return "debian"
if any(name in d for name in ("fedora", "centos", "rhel", "redhat", "rocky", "alma", "amazonlinux", "amzn", "oracle", "ol:")):
return "rhel"
return "unknown"


def _match_alpine_apt(distro: str | None, command: str | None, kind: str) -> bool:
if not distro or not command:
return False
return "alpine" in distro.lower() and _normalize_cmd_basename(command) in ("apt", "apt-get")
return detect_distro_family(distro) == "alpine" and _normalize_cmd_basename(command) in ("apt", "apt-get")


def _match_debian_ubuntu_apk(distro: str | None, command: str | None, kind: str) -> bool:
if not distro or not command:
return False
d = distro.lower()
return ("debian" in d or "ubuntu" in d) and _normalize_cmd_basename(command) == "apk"
return detect_distro_family(distro) == "debian" and _normalize_cmd_basename(command) == "apk"


def _match_debian_ubuntu_yum(distro: str | None, command: str | None, kind: str) -> bool:
if not distro or not command:
return False
d = distro.lower()
return ("debian" in d or "ubuntu" in d) and _normalize_cmd_basename(command) in ("yum", "dnf")
return detect_distro_family(distro) == "debian" and _normalize_cmd_basename(command) in ("yum", "dnf")


def _match_alpine_yum(distro: str | None, command: str | None, kind: str) -> bool:
if not distro or not command:
return False
return "alpine" in distro.lower() and _normalize_cmd_basename(command) in ("yum", "dnf")
return detect_distro_family(distro) == "alpine" and _normalize_cmd_basename(command) in ("yum", "dnf")


def _match_rhel_fedora_apt(distro: str | None, command: str | None, kind: str) -> bool:
if not distro or not command:
return False
d = distro.lower()
is_rhel = any(name in d for name in ("fedora", "centos", "rhel", "redhat", "rocky", "alma"))
return is_rhel and _normalize_cmd_basename(command) in ("apt", "apt-get", "apk")
return detect_distro_family(distro) == "rhel" and _normalize_cmd_basename(command) in ("apt", "apt-get", "apk")


def _match_alpine_bash(distro: str | None, command: str | None, kind: str) -> bool:
Expand Down Expand Up @@ -116,18 +126,16 @@ def _match_interpreter(distro: str | None, command: str | None, kind: str) -> bo
}


def detect_distro_family(distro: str | None) -> str:
"""Identify the distribution family for package manager commands."""
if not distro:
return "unknown"
d = distro.lower()
if "alpine" in d:
return "alpine"
if any(name in d for name in ("debian", "ubuntu", "mint")):
return "debian"
if any(name in d for name in ("fedora", "centos", "rhel", "redhat", "rocky", "alma")):
return "rhel"
return "unknown"
def _match_permission_denied(distro: str | None, command: str | None, kind: str) -> bool:
return kind == "permission_denied"


def _match_exec_format(distro: str | None, command: str | None, kind: str) -> bool:
return kind == "exec_format_error"


def _match_oom_killed(distro: str | None, command: str | None, kind: str) -> bool:
return kind == "oom_killed"


def resolve_package_recommendation(
Expand All @@ -140,9 +148,7 @@ def resolve_package_recommendation(
"""
if not command:
return None, None
norm_cmd = command.lower().replace("\\", "/").split("/")[-1]
if norm_cmd.endswith(".exe"):
norm_cmd = norm_cmd[:-4]
norm_cmd = _normalize_cmd_basename(command)

packages = COMMON_COMMAND_PACKAGES.get(norm_cmd)
if not packages:
Expand All @@ -169,6 +175,21 @@ def resolve_package_recommendation(


REMEDIATION_RULES: list[RemediationRule] = [
RemediationRule(
rule_id="permission_denied",
matcher=_match_permission_denied,
hint="Target script or binary lacks executable permissions; consider running 'chmod +x <file>' or executing via an explicit interpreter (e.g. 'sh script.sh').",
),
RemediationRule(
rule_id="exec_format_error",
matcher=_match_exec_format,
hint="Binary format is incompatible with the container architecture or C runtime (e.g. glibc binary on Alpine musl); consider using native packages or statically linked binaries.",
),
RemediationRule(
rule_id="oom_killed",
matcher=_match_oom_killed,
hint="Container exceeded configured memory limit and was killed by OOM killer; consider optimizing memory usage or increasing --mem-limit.",
),
RemediationRule(
rule_id="alpine_apt",
matcher=_match_alpine_apt,
Expand Down
24 changes: 20 additions & 4 deletions src/opsscript_gate/reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,10 @@ def format_terminal_table(report: RunReport, script_path: str = "") -> str:
line_suffix = f" (line {r.diagnostic.line})" if r.diagnostic.line else ""
detail = f"{sanitize_display_text(r.diagnostic.message)}{line_suffix}"
if r.diagnostic.hint:
hints.append((safe_distro, sanitize_display_text(r.diagnostic.hint)))
hint_text = sanitize_display_text(r.diagnostic.hint)
if r.diagnostic.suggested_package:
hint_text += f" (suggested package: {sanitize_display_text(r.diagnostic.suggested_package)})"
hints.append((safe_distro, hint_text))
elif r.error_message:
detail = sanitize_display_text(r.error_message)
else:
Expand Down Expand Up @@ -403,9 +406,18 @@ def format_markdown_compatibility_card(report: RunReport, script_path: str = "")
elif r.diagnostic:
line_str = f" (line {r.diagnostic.line})" if r.diagnostic.line else ""
clean_cmd = _table_code_span(r.diagnostic.command) if r.diagnostic.command else "command"
diag_cell = f"⚠️ Missing command: {clean_cmd}{line_str}"
if r.diagnostic.kind == "permission_denied":
diag_cell = f"Permission denied: {clean_cmd}{line_str}"
elif r.diagnostic.kind == "exec_format_error":
diag_cell = f"Exec format error: {clean_cmd}{line_str}"
elif r.diagnostic.kind == "oom_killed":
diag_cell = "OOM Killed (memory limit exceeded)"
else:
diag_cell = f"Missing command: {clean_cmd}{line_str}"
if r.diagnostic.suggested_package:
diag_cell += f"<br>Package: `{escape_markdown_table_cell(r.diagnostic.suggested_package)}`"
if r.diagnostic.hint:
diag_cell += f"<br>💡 *{escape_markdown_table_cell(r.diagnostic.hint)}*"
diag_cell += f"<br>*{escape_markdown_table_cell(r.diagnostic.hint)}*"
elif r.error_message:
diag_cell = escape_markdown_table_cell(r.error_message)
elif r.status != DistroStatus.PASS:
Expand Down Expand Up @@ -466,9 +478,12 @@ def format_markdown_compatibility_card(report: RunReport, script_path: str = "")
clean_msg = escape_inline_code(r.diagnostic.message)
line_info = f" (line {r.diagnostic.line})" if r.diagnostic.line else ""
lines.append(f"> **Diagnostic:** `{clean_kind}` — `{clean_msg}`{line_info}")
if r.diagnostic.suggested_package:
clean_pkg = escape_inline_code(r.diagnostic.suggested_package)
lines.append(f"> **Suggested Package:** `{clean_pkg}`")
if r.diagnostic.hint:
safe_hint = escape_markdown_text(r.diagnostic.hint)
lines.append(f"> 💡 **Recommendation:** {safe_hint}")
lines.append(f"> **Recommendation:** {safe_hint}")
lines.append("")
if r.error_message:
safe_err = escape_markdown_text(r.error_message)
Expand Down Expand Up @@ -583,6 +598,7 @@ def _sarif_result(result: SingleResult, script_path: str) -> dict[str, object] |
if result.exit_code is not None:
properties["exitCode"] = result.exit_code
if result.diagnostic:
properties["diagnosticKind"] = result.diagnostic.kind
if result.diagnostic.command:
properties["command"] = sanitize_display_text(result.diagnostic.command)
if result.diagnostic.suggested_package:
Expand Down
Loading
Loading