diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09795cd..d61f817 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,7 +128,8 @@ jobs: echo "Tag $TARGET_TAG was released from earlier commit ($TAG_SHA != ${{ github.sha }}). Skipping release." fi else - echo "Target tag $TARGET_TAG does not exist. Creating it and handing publication to the tag workflow." + echo "Target tag $TARGET_TAG does not exist. Creating tag and publishing release." + IS_NEW_RELEASE="true" SHOULD_TAG="true" fi else diff --git a/CHANGELOG.md b/CHANGELOG.md index 52696a9..c47c1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.9.4] - 2026-10-11 + +### Fixed +- Fixed release workflow publishing deadlock where `main` pushes skipped distribution package builds and release creation due to GITHUB_TOKEN tag events not triggering new workflows. +- Unified command path extraction in remediation rules to ensure full-path commands (e.g. `/usr/bin/apt-get`, `/sbin/apk`, `/bin/yum`, `/usr/local/bin/bash`) accurately match package manager and shell hints. +- Added native RHEL/Fedora/CentOS package mappings in `COMMON_COMMAND_PACKAGES` to prevent fallback to invalid Debian package names. +- Added native container package pre-installation support for `dnf`, `microdnf`, and `yum` in `runner._wrap_with_package_setup`. +- Fixed `RunReport` and `MultiScriptReport` `__post_init__` to preserve explicitly passed `all_passed=False` when result collections are empty. +- Supported bare directory exclusions (e.g. `tests` or `tests/`) in `_matches_exclude` to match both directories and all contained files. + ## [0.9.3] - 2026-10-11 ### Added diff --git a/pyproject.toml b/pyproject.toml index 012386c..2e8bd9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "opsscript-gate" -version = "0.9.3" +version = "0.9.4" 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" } diff --git a/src/opsscript_gate/__init__.py b/src/opsscript_gate/__init__.py index 185d856..5385efc 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.9.3" +__version__ = "0.9.4" __all__ = ["__version__"] diff --git a/src/opsscript_gate/config.py b/src/opsscript_gate/config.py index 9089056..94362ee 100644 --- a/src/opsscript_gate/config.py +++ b/src/opsscript_gate/config.py @@ -126,7 +126,7 @@ def init_project() -> list[str]: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: Mresyzz/opsscript-gate@v0.9.3 + - uses: Mresyzz/opsscript-gate@v0.9.4 with: config: .opsscript-gate.json """, diff --git a/src/opsscript_gate/discovery.py b/src/opsscript_gate/discovery.py index 599ae01..173d2b1 100644 --- a/src/opsscript_gate/discovery.py +++ b/src/opsscript_gate/discovery.py @@ -41,7 +41,7 @@ def _matches_exclude(rel_path: str, patterns: list[str] | None) -> bool: - """Match exclusions consistently whether callers use ``foo.sh``, ``./foo.sh``, or ``/foo.sh``.""" + """Match exclusions consistently whether callers use ``foo.sh``, ``./foo.sh``, ``/foo.sh``, or directory names like ``tests``.""" normalized_path = rel_path.replace("\\", "/") while normalized_path.startswith("./"): normalized_path = normalized_path[2:] @@ -51,8 +51,13 @@ def _matches_exclude(rel_path: str, patterns: list[str] | None) -> bool: while normalized_pattern.startswith("./"): normalized_pattern = normalized_pattern[2:] normalized_pattern = normalized_pattern.lstrip("/") + if not normalized_pattern: + continue if fnmatch.fnmatchcase(normalized_path, normalized_pattern): return True + pat = normalized_pattern.rstrip("/") + if normalized_path == pat or normalized_path.startswith(f"{pat}/") or fnmatch.fnmatchcase(normalized_path, f"{pat}/*"): + return True return False diff --git a/src/opsscript_gate/models.py b/src/opsscript_gate/models.py index 81b8643..763c698 100644 --- a/src/opsscript_gate/models.py +++ b/src/opsscript_gate/models.py @@ -113,8 +113,6 @@ class RunReport: def __post_init__(self) -> None: if self.results: self.all_passed = all(r.status == DistroStatus.PASS for r in self.results) - else: - self.all_passed = True def to_dict(self) -> dict[str, Any]: return { @@ -134,8 +132,6 @@ class MultiScriptReport: def __post_init__(self) -> None: if self.reports: self.all_passed = all(r.all_passed for r in self.reports.values()) - else: - self.all_passed = True def to_dict(self) -> dict[str, Any]: return { diff --git a/src/opsscript_gate/remediation.py b/src/opsscript_gate/remediation.py index 7e3d9bb..f646423 100644 --- a/src/opsscript_gate/remediation.py +++ b/src/opsscript_gate/remediation.py @@ -12,30 +12,40 @@ class RemediationRule: hint: str +def _normalize_cmd_basename(command: str | None) -> str: + """Normalize command path and extract clean executable basename.""" + if not command: + return "" + norm = command.lower().replace("\\", "/").split("/")[-1] + if norm.endswith(".exe"): + norm = norm[:-4] + return norm + + 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 command.lower() in ("apt", "apt-get") + return "alpine" in distro.lower() 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 command.lower() == "apk" + return ("debian" in d or "ubuntu" in d) 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 command.lower() in ("yum", "dnf") + return ("debian" in d or "ubuntu" in d) 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 command.lower() in ("yum", "dnf") + return "alpine" in distro.lower() and _normalize_cmd_basename(command) in ("yum", "dnf") def _match_rhel_fedora_apt(distro: str | None, command: str | None, kind: str) -> bool: @@ -43,82 +53,66 @@ def _match_rhel_fedora_apt(distro: str | None, command: str | None, kind: str) - return False d = distro.lower() is_rhel = any(name in d for name in ("fedora", "centos", "rhel", "redhat", "rocky", "alma")) - return is_rhel and command.lower() in ("apt", "apt-get", "apk") - + return is_rhel and _normalize_cmd_basename(command) in ("apt", "apt-get", "apk") def _match_alpine_bash(distro: str | None, command: str | None, kind: str) -> bool: if not distro or not command: return False - return "alpine" in distro.lower() and command.lower() in ("bash", "/bin/bash", "/usr/bin/bash") + return "alpine" in distro.lower() and _normalize_cmd_basename(command) == "bash" def _match_curl(distro: str | None, command: str | None, kind: str) -> bool: - if not command: - return False - normalized = command.lower().replace("\\", "/").split("/")[-1] - if normalized.endswith(".exe"): - normalized = normalized[:-4] - return normalized == "curl" + return _normalize_cmd_basename(command) == "curl" def _match_wget(distro: str | None, command: str | None, kind: str) -> bool: - if not command: - return False - normalized = command.lower().replace("\\", "/").split("/")[-1] - if normalized.endswith(".exe"): - normalized = normalized[:-4] - return normalized == "wget" + return _normalize_cmd_basename(command) == "wget" def _match_sudo(distro: str | None, command: str | None, kind: str) -> bool: - if not command: - return False - normalized = command.lower().replace("\\", "/").split("/")[-1] - if normalized.endswith(".exe"): - normalized = normalized[:-4] - return normalized == "sudo" + return _normalize_cmd_basename(command) == "sudo" def _match_interpreter(distro: str | None, command: str | None, kind: str) -> bool: if kind == "missing_interpreter": return True if command and (command.startswith("/bin/") or command.startswith("/usr/bin/")): - base = command.split("/")[-1] + base = _normalize_cmd_basename(command) if base in ("bash", "sh", "zsh", "python", "python3", "perl"): return True return False # Mapping of common ops CLI command binaries to distro-specific package names. -# Supported distro families: "debian" (Debian/Ubuntu), "alpine" (Alpine Linux). +# Supported distro families: "debian" (Debian/Ubuntu), "alpine" (Alpine Linux), "rhel" (Fedora/CentOS/RHEL). COMMON_COMMAND_PACKAGES: dict[str, dict[str, str]] = { - "curl": {"debian": "curl", "alpine": "curl"}, - "wget": {"debian": "wget", "alpine": "wget"}, - "jq": {"debian": "jq", "alpine": "jq"}, - "git": {"debian": "git", "alpine": "git"}, - "tar": {"debian": "tar", "alpine": "tar"}, - "gzip": {"debian": "gzip", "alpine": "gzip"}, - "bzip2": {"debian": "bzip2", "alpine": "bzip2"}, - "xz": {"debian": "xz-utils", "alpine": "xz"}, - "unzip": {"debian": "unzip", "alpine": "unzip"}, - "zip": {"debian": "zip", "alpine": "zip"}, - "python": {"debian": "python3", "alpine": "python3"}, - "python3": {"debian": "python3", "alpine": "python3"}, - "pip": {"debian": "python3-pip", "alpine": "py3-pip"}, - "pip3": {"debian": "python3-pip", "alpine": "py3-pip"}, - "sudo": {"debian": "sudo", "alpine": "sudo"}, - "bash": {"debian": "bash", "alpine": "bash"}, - "make": {"debian": "build-essential", "alpine": "build-base"}, - "gcc": {"debian": "gcc", "alpine": "gcc"}, - "ca-certificates": {"debian": "ca-certificates", "alpine": "ca-certificates"}, - "openssl": {"debian": "openssl", "alpine": "openssl"}, - "nc": {"debian": "netcat-openbsd", "alpine": "netcat-openbsd"}, - "netcat": {"debian": "netcat-openbsd", "alpine": "netcat-openbsd"}, - "socat": {"debian": "socat", "alpine": "socat"}, - "envsubst": {"debian": "gettext-base", "alpine": "gettext"}, - "crontab": {"debian": "cron", "alpine": "dcron"}, - "ps": {"debian": "procps", "alpine": "procps"}, + "curl": {"debian": "curl", "alpine": "curl", "rhel": "curl"}, + "wget": {"debian": "wget", "alpine": "wget", "rhel": "wget"}, + "jq": {"debian": "jq", "alpine": "jq", "rhel": "jq"}, + "git": {"debian": "git", "alpine": "git", "rhel": "git"}, + "tar": {"debian": "tar", "alpine": "tar", "rhel": "tar"}, + "gzip": {"debian": "gzip", "alpine": "gzip", "rhel": "gzip"}, + "bzip2": {"debian": "bzip2", "alpine": "bzip2", "rhel": "bzip2"}, + "xz": {"debian": "xz-utils", "alpine": "xz", "rhel": "xz"}, + "unzip": {"debian": "unzip", "alpine": "unzip", "rhel": "unzip"}, + "zip": {"debian": "zip", "alpine": "zip", "rhel": "zip"}, + "python": {"debian": "python3", "alpine": "python3", "rhel": "python3"}, + "python3": {"debian": "python3", "alpine": "python3", "rhel": "python3"}, + "pip": {"debian": "python3-pip", "alpine": "py3-pip", "rhel": "python3-pip"}, + "pip3": {"debian": "python3-pip", "alpine": "py3-pip", "rhel": "python3-pip"}, + "sudo": {"debian": "sudo", "alpine": "sudo", "rhel": "sudo"}, + "bash": {"debian": "bash", "alpine": "bash", "rhel": "bash"}, + "make": {"debian": "build-essential", "alpine": "build-base", "rhel": "make"}, + "gcc": {"debian": "gcc", "alpine": "gcc", "rhel": "gcc"}, + "ca-certificates": {"debian": "ca-certificates", "alpine": "ca-certificates", "rhel": "ca-certificates"}, + "openssl": {"debian": "openssl", "alpine": "openssl", "rhel": "openssl"}, + "nc": {"debian": "netcat-openbsd", "alpine": "netcat-openbsd", "rhel": "nmap-ncat"}, + "netcat": {"debian": "netcat-openbsd", "alpine": "netcat-openbsd", "rhel": "nmap-ncat"}, + "socat": {"debian": "socat", "alpine": "socat", "rhel": "socat"}, + "envsubst": {"debian": "gettext-base", "alpine": "gettext", "rhel": "gettext"}, + "crontab": {"debian": "cron", "alpine": "dcron", "rhel": "cronie"}, + "ps": {"debian": "procps", "alpine": "procps", "rhel": "procps-ng"}, } diff --git a/src/opsscript_gate/runner.py b/src/opsscript_gate/runner.py index bfec2d4..664f5c3 100644 --- a/src/opsscript_gate/runner.py +++ b/src/opsscript_gate/runner.py @@ -676,7 +676,13 @@ def _wrap_with_package_setup(command: Sequence[str], packages: Sequence[str]) -> "(rm -rf /tmp/opsscript-apt-archives /var/lib/apt/lists/* || true) || setup_failed; " "elif command -v apk >/dev/null 2>&1; then " f"apk add --no-cache {package_args} || setup_failed; " - f"else echo '{PACKAGE_SETUP_ERROR_MARKER} no supported package manager found (apt-get or apk)' >&2; exit 125; fi" + "elif command -v dnf >/dev/null 2>&1; then " + f"dnf install -y --setopt=install_weak_deps=False {package_args} || setup_failed; " + "elif command -v microdnf >/dev/null 2>&1; then " + f"microdnf install -y {package_args} || setup_failed; " + "elif command -v yum >/dev/null 2>&1; then " + f"yum install -y {package_args} || setup_failed; " + f"else echo '{PACKAGE_SETUP_ERROR_MARKER} no supported package manager found (apt-get, apk, dnf, microdnf, yum)' >&2; exit 125; fi" ) target_command = " ".join(shlex.quote(part) for part in command) return ["/bin/sh", "-c", f"{install} && exec {target_command}"] diff --git a/tests/test_audit_coverage.py b/tests/test_audit_coverage.py index 19cd6d9..93063b1 100644 --- a/tests/test_audit_coverage.py +++ b/tests/test_audit_coverage.py @@ -479,3 +479,99 @@ def test_failure_diagnostic_and_sarif_suggested_package(): props = sarif["runs"][0]["results"][0]["properties"] assert props.get("command") == "jq" assert props.get("suggestedPackage") == "jq" + + +# ============================================================================== +# 7. 缺陷深度修复专项回归测试 +# ============================================================================== + +def test_remediation_matchers_with_absolute_paths(): + from opsscript_gate.remediation import generate_remediation_hint + + # 验证全路径命令(如 /usr/bin/apt-get, /sbin/apk, /bin/yum, /usr/local/bin/bash)均能精准命中 + assert generate_remediation_hint( + distro="alpine:3.20", command="/usr/bin/apt-get", kind="missing_command" + ) == "Alpine normally uses apk instead of apt-get." + + assert generate_remediation_hint( + distro="ubuntu:22.04", command="/sbin/apk", kind="missing_command" + ) == "Debian/Ubuntu normally uses APT (apt-get) instead of apk." + + assert generate_remediation_hint( + distro="debian:12-slim", command="/usr/bin/yum", kind="missing_command" + ) == "Debian/Ubuntu normally uses APT (apt-get) instead of yum or dnf." + + assert generate_remediation_hint( + distro="alpine:3.20", command="/usr/bin/yum", kind="missing_command" + ) == "Alpine normally uses apk instead of yum or dnf." + + assert generate_remediation_hint( + distro="fedora:40", command="/usr/bin/apt-get", kind="missing_command" + ) == "RHEL/Fedora family distributions normally use dnf or yum instead of apt-get or apk." + + assert generate_remediation_hint( + distro="alpine:3.20", command="/usr/local/bin/bash", kind="missing_command" + ) == "Alpine minimal images do not include Bash by default; consider using POSIX sh or installing bash." + + +def test_rhel_package_recommendations(): + from opsscript_gate.remediation import resolve_package_recommendation + + # RHEL 原生包名映射 + pkg_make, cmd_make = resolve_package_recommendation("make", distro="fedora:40") + assert pkg_make == "make" + assert cmd_make == "dnf install -y make" + + pkg_xz, cmd_xz = resolve_package_recommendation("xz", distro="rocky:9") + assert pkg_xz == "xz" + assert cmd_xz == "dnf install -y xz" + + pkg_nc, cmd_nc = resolve_package_recommendation("nc", distro="centos:7") + assert pkg_nc == "nmap-ncat" + assert cmd_nc == "dnf install -y nmap-ncat" + + pkg_cron, cmd_cron = resolve_package_recommendation("crontab", distro="rhel:9") + assert pkg_cron == "cronie" + assert cmd_cron == "dnf install -y cronie" + + # 全路径命令也可以正常识别 + pkg_curl, cmd_curl = resolve_package_recommendation("/usr/bin/curl", distro="fedora:40") + assert pkg_curl == "curl" + assert cmd_curl == "dnf install -y curl" + + +def test_package_setup_rhel_package_managers(): + from opsscript_gate.runner import _wrap_with_package_setup + + wrapped = _wrap_with_package_setup(["/bin/sh", "-c", "echo 1"], ["curl", "jq"]) + cmd_str = " ".join(wrapped) + # 验证生成的预装包装命令已包含 dnf、microdnf、yum 支持 + assert "command -v dnf" in cmd_str + assert "command -v microdnf" in cmd_str + assert "command -v yum" in cmd_str + + +def test_run_report_empty_explicit_all_passed(): + from opsscript_gate.models import RunReport, MultiScriptReport + + # 验证空结果集但显式指定 all_passed=False 时不被篡改为 True + report = RunReport(results=[], all_passed=False) + assert report.all_passed is False + + multi = MultiScriptReport(reports={}, all_passed=False) + assert multi.all_passed is False + + # 默认值依然保持 True + assert RunReport().all_passed is True + assert MultiScriptReport().all_passed is True + + +def test_discovery_matches_exclude_directory(): + from opsscript_gate.discovery import _matches_exclude + + # 验证目录名排除规则能够正确匹配目录本身及目录下所有文件 + assert _matches_exclude("tests/foo.sh", ["tests"]) is True + assert _matches_exclude("tests/sub/bar.sh", ["tests"]) is True + assert _matches_exclude("tests/foo.sh", ["tests/"]) is True + assert _matches_exclude("tests/foo.sh", ["./tests/"]) is True + assert _matches_exclude("other/foo.sh", ["tests"]) is False