diff --git a/CHANGELOG.md b/CHANGELOG.md index 703cf13..bd18ca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.9.7] - 2026-11-08 + +### Added +- Added fast-fail support via CLI `-x` / `--fail-fast`, configuration `"fail_fast": true`, and composite action input `fail-fast` to stop immediately on the first failed check or script. +- Added native shebang parsing and runtime execution support for POSIX lightweight shell interpreters `dash` and `ash` (`/bin/dash`, `/usr/bin/dash`, `/usr/bin/env dash`, `/bin/ash`, `/usr/bin/ash`, `/usr/bin/env ash`). +- Enhanced `--workdir` integration to automatically map workspace-relative script paths and retain script `$0` identity for scripts that compute local paths via `dirname "$0"`. +- Added cross-distro package remediation recommendations for `dash` and `ash` missing interpreters across Debian and Alpine. + ## [0.9.6] - 2026-11-08 ### Added diff --git a/README.md b/README.md index 43412fb..05315a9 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ apt-get --version Run it locally with `opsscript-gate run ./install.sh`, or add the following step to CI: ```yaml -- uses: Mresyzz/opsscript-gate@v0.9.6 +- uses: Mresyzz/opsscript-gate@v0.9.7 with: script-path: install.sh ``` @@ -57,17 +57,17 @@ The result identifies the failing distribution, exit code, line, missing command --- -## Current release: v0.9.6 +## Current release: v0.9.7 -The current release includes safe environment variable passing (`--env`), host workspace context read-only mounting (`--workdir`), expanded package recommendations for standard Linux tools, structured diagnostic properties in SARIF, and composite action outputs. +The current release includes fail-fast execution (`-x` / `--fail-fast`), native `dash` and `ash` shebang support, host workspace context mounting (`--workdir`) with relative script `$0` path preservation, safe environment variable passing (`--env`), and expanded package recommendations. ```bash opsscript-gate init opsscript-gate doctor opsscript-gate run --dry-run opsscript-gate run --format json --output reports/compatibility.json -# Repeat each distro three times when diagnosing intermittent failures -opsscript-gate run ./install.sh --repeat 3 +# Stop immediately on the first failed check +opsscript-gate run -x # Pass environment variables and mount repository workspace read-only opsscript-gate run ./install.sh --env APP_ENV=staging --workdir . ``` @@ -114,7 +114,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: Mresyzz/opsscript-gate@v0.9.6 + - uses: Mresyzz/opsscript-gate@v0.9.7 ``` This zero-configuration form discovers shell scripts in the repository. Use @@ -125,7 +125,7 @@ repository has a single installer. For self-hosted model installers, see the Or test a specific script with custom execution modes: ```yaml - - uses: Mresyzz/opsscript-gate@v0.9.6 + - uses: Mresyzz/opsscript-gate@v0.9.7 with: script-path: scripts/install.sh shell: auto @@ -159,7 +159,7 @@ unrelated scripts: with: persist-credentials: false fetch-depth: 0 - - uses: Mresyzz/opsscript-gate@v0.9.6 + - uses: Mresyzz/opsscript-gate@v0.9.7 with: changed-since: ${{ github.event.pull_request.base.sha }} preset: minimal @@ -250,7 +250,7 @@ Use `sarif` when the result should appear in GitHub Code Scanning or another SARIF-compatible viewer. Upload it explicitly with the official upload action: ```yaml -- uses: Mresyzz/opsscript-gate@v0.9.6 +- uses: Mresyzz/opsscript-gate@v0.9.7 with: script-path: scripts/install.sh format: sarif diff --git a/README.zh-CN.md b/README.zh-CN.md index 0dcaa28..6448a57 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,9 +17,9 @@ Bash 依赖、包管理器假设和交互式阻塞。它与 ShellCheck 互补。 也可以直接打开 [OpsScript Gate 在线演示](https://mresyzz.github.io/opsscript-gate/),先查看 ShellCheck 与运行时验证的差异,再复制 workflow 到自己的仓库。 -## 当前版本 v0.9.6 +## 当前版本 v0.9.7 -v0.9.6 是当前已发布版本;如果你要从源码验证当前分支,也可以执行: +v0.9.7 是当前已发布版本;如果你要从源码验证当前分支,也可以执行: ```bash pip install -e . @@ -33,11 +33,11 @@ opsscript-gate init opsscript-gate doctor opsscript-gate run --dry-run opsscript-gate run --format json --output reports/compatibility.json -# 传递环境变量并以只读方式挂载本地工作空间 -opsscript-gate run ./install.sh --env APP_ENV=staging --workdir . +# 开启失败即停(-x / --fail-fast),传递环境变量并只读挂载工作区 +opsscript-gate run ./install.sh -x --env APP_ENV=staging --workdir . ``` -`init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.6`。 +`init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.7`。 预览不需要 Docker;真实运行需要 Python 3.10+ 和可访问的 Linux Docker 引擎。 `opsscript-gate doctor` 可以在真实运行前检查 Python 和 Docker。 @@ -76,7 +76,7 @@ Docker daemon 和可选的项目配置。 - uses: actions/checkout@v7 with: fetch-depth: 0 -- uses: Mresyzz/opsscript-gate@v0.9.6 +- uses: Mresyzz/opsscript-gate@v0.9.7 with: changed-since: ${{ github.event.pull_request.base.sha }} preset: minimal diff --git a/action.yml b/action.yml index 0c39f49..976d861 100644 --- a/action.yml +++ b/action.yml @@ -79,6 +79,10 @@ inputs: description: 'Preview the execution plan without Docker' required: false default: 'false' + fail-fast: + description: 'Stop immediately on the first failed distribution or script check (default: false)' + required: false + default: 'false' output: description: 'Save a report file for upload-artifact or other tooling' required: false @@ -131,6 +135,7 @@ runs: INPUT_EXCLUDE: ${{ inputs.exclude }} INPUT_MAX_SCRIPTS: ${{ inputs.max-scripts }} INPUT_DRY_RUN: ${{ inputs.dry-run }} + INPUT_FAIL_FAST: ${{ inputs.fail-fast }} INPUT_OUTPUT: ${{ inputs.output }} INPUT_CHANGED_SINCE: ${{ inputs.changed-since }} run: | @@ -163,6 +168,13 @@ runs: pattern="${pattern%$'\r'}" [ -z "$pattern" ] || ARGS+=("--exclude" "$pattern") done <<< "$INPUT_EXCLUDE" + FAIL_FAST_VALUE="$(printf '%s' "$INPUT_FAIL_FAST" | tr '[:upper:]' '[:lower:]')" + if [ "$FAIL_FAST_VALUE" = "true" ]; then + ARGS+=("--fail-fast") + elif [ "$FAIL_FAST_VALUE" != "false" ]; then + echo "fail-fast must be true or false" >&2 + exit 1 + fi DRY_RUN_VALUE="$(printf '%s' "$INPUT_DRY_RUN" | tr '[:upper:]' '[:lower:]')" if [ "$DRY_RUN_VALUE" = "true" ]; then ARGS+=("--dry-run") diff --git a/pyproject.toml b/pyproject.toml index 944fe58..a726dc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "opsscript-gate" -version = "0.9.6" +version = "0.9.7" 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 3dde8bb..b9a2068 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.6" +__version__ = "0.9.7" __all__ = ["__version__"] diff --git a/src/opsscript_gate/cli.py b/src/opsscript_gate/cli.py index 9cf913e..54d995a 100644 --- a/src/opsscript_gate/cli.py +++ b/src/opsscript_gate/cli.py @@ -169,6 +169,12 @@ def build_parser() -> argparse.ArgumentParser: default=None, help="Host directory to mount read-only into /workspace as the container working directory", ) + run_parser.add_argument( + "-x", "--fail-fast", + action="store_true", + default=False, + help="Stop immediately on the first failed distribution or script check", + ) run_parser.add_argument( "--changed-since", help="Only test shell scripts changed between this Git revision and HEAD", @@ -251,6 +257,7 @@ def run_checked_matrix( repeat=args.repeat, env=args.env, workdir=args.workdir, + fail_fast=getattr(args, "fail_fast", False), ) except DockerDaemonError as err: sys.stderr.write(f"Docker Error: {err}\n") @@ -350,6 +357,8 @@ def main(argv: list[str] | None = None) -> int: explicit = {token.split("=", 1)[0] for token in argv if token.startswith("--")} if any(token.startswith("-j") for token in argv): explicit.add("--jobs") + if any(token == "-x" or token.startswith("-x") for token in argv): + explicit.add("--fail-fast") for key in DEFAULTS: if key in config and "--" + key.replace("_", "-") not in explicit: setattr(args, key, config[key]) @@ -446,11 +455,13 @@ def main(argv: list[str] | None = None) -> int: plan = {"dry_run": True, "scripts": target_scripts, "matrix": matrix, "executions": len(target_scripts) * len(matrix) * args.repeat, "shell": args.shell, "network": args.network, "packages": args.packages, "timeout": args.timeout, - "repeat": args.repeat, "env": args.env, "workdir": args.workdir} + "repeat": args.repeat, "env": args.env, "workdir": args.workdir, + "fail_fast": args.fail_fast} if changed_selection: plan["changed_since"] = args.changed_since env_preview = f"\nEnv: {', '.join(f'{k}={v}' for k, v in args.env.items())}" if args.env else "" workdir_preview = f"\nWorkdir: {args.workdir}" if args.workdir else "" + fail_fast_preview = f"\nFail-Fast: {args.fail_fast}" if args.fail_fast else "" output = json.dumps(plan, indent=2) if args.format == "json" else ( "Execution preview (no scripts executed)\n" + "\n".join(f" {path}" for path in target_scripts) @@ -458,6 +469,7 @@ def main(argv: list[str] | None = None) -> int: + (f"\nPackages: {', '.join(args.packages)}" if args.packages else "") + env_preview + workdir_preview + + fail_fast_preview + f"\nShell: {args.shell} | Network: {args.network} | Timeout: {args.timeout}s | Repeat: {args.repeat}" ) return 0 if publish_output(output, args.output) else 1 @@ -520,6 +532,9 @@ def main(argv: list[str] | None = None) -> int: if args.format not in ("json", "sarif"): emit_github_annotations(report, script_path=script_path) + if args.fail_fast and not report.all_passed: + break + total_duration = time.perf_counter() - multi_start all_passed = all(r.all_passed for r in multi_reports.values()) multi_report = MultiScriptReport( diff --git a/src/opsscript_gate/config.py b/src/opsscript_gate/config.py index 9fd7a73..a826a81 100644 --- a/src/opsscript_gate/config.py +++ b/src/opsscript_gate/config.py @@ -17,7 +17,7 @@ "matrix": None, "preset": None, "jobs": None, "timeout": 60, "shell": "posix", "mem_limit": "256m", "pids_limit": 128, "network": "bridge", "packages": [], "exclude": [], "max_scripts": 20, - "repeat": 1, "env": {}, "workdir": None, + "repeat": 1, "env": {}, "workdir": None, "fail_fast": False, } MAX_CONFIG_SIZE_BYTES = 256 * 1024 @@ -130,6 +130,9 @@ def load_config(filename: str | None) -> dict: validate_env(value) elif key == "workdir": validate_workdir(value) + elif key == "fail_fast": + if not isinstance(value, bool): + raise ValueError("fail_fast must be a boolean") elif not isinstance(value, str) or not value.strip(): raise ValueError(f"{key} must be a nonempty string") for key, choices in {"shell": ("posix", "auto", "shebang"), @@ -163,7 +166,7 @@ def init_project() -> list[str]: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: Mresyzz/opsscript-gate@v0.9.6 + - uses: Mresyzz/opsscript-gate@v0.9.7 with: config: .opsscript-gate.json """, diff --git a/src/opsscript_gate/discovery.py b/src/opsscript_gate/discovery.py index 173d2b1..5498660 100644 --- a/src/opsscript_gate/discovery.py +++ b/src/opsscript_gate/discovery.py @@ -34,6 +34,12 @@ "#!/usr/bin/bash", "#!/usr/bin/env sh", "#!/usr/bin/env bash", + "#!/bin/dash", + "#!/usr/bin/dash", + "#!/usr/bin/env dash", + "#!/bin/ash", + "#!/usr/bin/ash", + "#!/usr/bin/env ash", ) MAX_DISCOVERED_SCRIPTS = 20 diff --git a/src/opsscript_gate/remediation.py b/src/opsscript_gate/remediation.py index 3fb1557..a9f4ef1 100644 --- a/src/opsscript_gate/remediation.py +++ b/src/opsscript_gate/remediation.py @@ -89,7 +89,7 @@ def _match_interpreter(distro: str | None, command: str | None, kind: str) -> bo return True if command and (command.startswith("/bin/") or command.startswith("/usr/bin/")): base = _normalize_cmd_basename(command) - if base in ("bash", "sh", "zsh", "python", "python3", "perl"): + if base in ("bash", "sh", "dash", "ash", "zsh", "python", "python3", "perl"): return True return False @@ -133,6 +133,8 @@ def _match_interpreter(distro: str | None, command: str | None, kind: str) -> bo "ssh": {"debian": "openssh-client", "alpine": "openssh-client", "rhel": "openssh-clients"}, "rsync": {"debian": "rsync", "alpine": "rsync", "rhel": "rsync"}, "htop": {"debian": "htop", "alpine": "htop", "rhel": "htop"}, + "dash": {"debian": "dash", "alpine": "dash", "rhel": "dash"}, + "ash": {"debian": "ash", "alpine": "busybox", "rhel": "busybox"}, } diff --git a/src/opsscript_gate/runner.py b/src/opsscript_gate/runner.py index b71eac3..1f1a6b3 100644 --- a/src/opsscript_gate/runner.py +++ b/src/opsscript_gate/runner.py @@ -55,6 +55,12 @@ "/usr/bin/bash": ["/bin/sh", "-c", "/usr/bin/bash /tmp/target_script.sh ShebangParseResult: interpreter="bash", raw_shebang=raw_shebang, ) + elif sub_cmd == "dash": + return ShebangParseResult( + status=ShebangStatus.RECOGNIZED, + command_key="/usr/bin/env dash", + interpreter="dash", + raw_shebang=raw_shebang, + ) + elif sub_cmd == "ash": + return ShebangParseResult( + status=ShebangStatus.RECOGNIZED, + command_key="/usr/bin/env ash", + interpreter="ash", + raw_shebang=raw_shebang, + ) else: return ShebangParseResult( status=ShebangStatus.UNSUPPORTED, raw_shebang=raw_shebang, - error_message=f"Unsupported shebang interpreter: '{clean_shebang}' (supported: sh, bash)", + error_message=f"Unsupported shebang interpreter: '{clean_shebang}' (supported: sh, bash, dash, ash)", ) else: # Reject complex env forms (e.g. env -S bash) or additional arguments @@ -502,7 +522,7 @@ def inspect_shebang(script_path: str) -> ShebangParseResult: error_message=f"Unsupported complex env shebang form: '{clean_shebang}'", ) - # Direct interpreters (e.g. /bin/sh, /bin/bash, /usr/bin/sh, /usr/bin/bash) + # Direct interpreters (e.g. /bin/sh, /bin/bash, /bin/dash, /bin/ash) if cmd in ("/bin/sh", "/usr/bin/sh"): if len(tokens) > 1: return ShebangParseResult( @@ -529,11 +549,37 @@ def inspect_shebang(script_path: str) -> ShebangParseResult: interpreter="bash", raw_shebang=raw_shebang, ) + elif cmd in ("/bin/dash", "/usr/bin/dash"): + if len(tokens) > 1: + return ShebangParseResult( + status=ShebangStatus.UNSUPPORTED, + raw_shebang=raw_shebang, + error_message=f"Unsupported shebang arguments in '{clean_shebang}'", + ) + return ShebangParseResult( + status=ShebangStatus.RECOGNIZED, + command_key=cmd, + interpreter="dash", + raw_shebang=raw_shebang, + ) + elif cmd in ("/bin/ash", "/usr/bin/ash"): + if len(tokens) > 1: + return ShebangParseResult( + status=ShebangStatus.UNSUPPORTED, + raw_shebang=raw_shebang, + error_message=f"Unsupported shebang arguments in '{clean_shebang}'", + ) + return ShebangParseResult( + status=ShebangStatus.RECOGNIZED, + command_key=cmd, + interpreter="ash", + raw_shebang=raw_shebang, + ) else: return ShebangParseResult( status=ShebangStatus.UNSUPPORTED, raw_shebang=raw_shebang, - error_message=f"Unsupported shebang interpreter: '{clean_shebang}' (supported: sh, bash)", + error_message=f"Unsupported shebang interpreter: '{clean_shebang}' (supported: sh, bash, dash, ash)", ) @@ -987,6 +1033,29 @@ def run_on_distro( try: safe_mount_src = normalize_host_path_for_docker(mount_src) + target_bind_path = "/tmp/target_script.sh" + volumes = {} + if workdir is not None: + abs_workdir = os.path.abspath(workdir) + safe_workdir = normalize_host_path_for_docker(abs_workdir) + volumes[safe_workdir] = { + "bind": "/workspace", + "mode": "ro", + } + try: + rel_script = os.path.relpath(abs_script, abs_workdir) + except ValueError: + rel_script = None + if rel_script is not None and not rel_script.startswith("..") and not os.path.isabs(rel_script): + posix_rel = rel_script.replace("\\", "/") + target_bind_path = f"/workspace/{posix_rel}" + command = [part.replace("/tmp/target_script.sh", target_bind_path) for part in command] + + volumes[safe_mount_src] = { + "bind": target_bind_path, + "mode": "ro", + } + try: container_command = _wrap_with_package_setup(command, package_list) except (TypeError, ValueError) as exc: @@ -999,20 +1068,6 @@ def run_on_distro( error_message=str(exc), ) - # Security: Strict unprivileged options and read-only mount - volumes = { - safe_mount_src: { - "bind": "/tmp/target_script.sh", - "mode": "ro", - } - } - if workdir is not None: - safe_workdir = normalize_host_path_for_docker(os.path.abspath(workdir)) - volumes[safe_workdir] = { - "bind": "/workspace", - "mode": "ro", - } - environment = { "DEBIAN_FRONTEND": "noninteractive", "CI": "true", @@ -1220,6 +1275,7 @@ def run_matrix( repeat: int = 1, env: dict[str, str] | None = None, workdir: str | None = None, + fail_fast: bool = False, ) -> RunReport: """ Run the compatibility check across all specified Linux distributions, @@ -1319,7 +1375,7 @@ def run_matrix( def _worker(index: int, distro_name: str) -> tuple[int, SingleResult]: attempts: list[SingleResult] = [] for _ in range(repeat): - attempts.append(run_on_distro( + res_attempt = run_on_distro( client=docker_client, script_path=script_path, distro=distro_name, @@ -1333,9 +1389,12 @@ def _worker(index: int, distro_name: str) -> tuple[int, SingleResult]: prepared_script_path=prepared_script, env=env, workdir=workdir, - )) + ) + attempts.append(res_attempt) + if fail_fast and res_attempt.status != DistroStatus.PASS: + break - if repeat == 1: + if len(attempts) == 1: return index, attempts[0] passed = sum(result.status == DistroStatus.PASS for result in attempts) @@ -1380,6 +1439,8 @@ def _worker(index: int, distro_name: str) -> tuple[int, SingleResult]: for idx, distro_name in enumerate(distro_list): _, res = _worker(idx, distro_name) results[idx] = res + if fail_fast and res.status != DistroStatus.PASS: + break else: with ThreadPoolExecutor(max_workers=effective_jobs) as executor: futures = [ @@ -1389,6 +1450,10 @@ def _worker(index: int, distro_name: str) -> tuple[int, SingleResult]: for fut in as_completed(futures): idx, res = fut.result() results[idx] = res + if fail_fast and res.status != DistroStatus.PASS: + for remaining in futures: + remaining.cancel() + break finally: # Clean up temporary CRLF normalized file exactly once after all matrix workers complete if shared_temp_file is not None: @@ -1399,10 +1464,11 @@ def _worker(index: int, distro_name: str) -> tuple[int, SingleResult]: pass total_duration = time.perf_counter() - start_total - all_passed = all(r.status == DistroStatus.PASS for r in results) if results else True + actual_results = [r for r in results if r is not None] + all_passed = all(r.status == DistroStatus.PASS for r in actual_results) if actual_results else True return RunReport( - results=results, + results=actual_results, total_duration=total_duration, all_passed=all_passed, ) diff --git a/tests/test_action_contract.py b/tests/test_action_contract.py index d1de2d7..a8643dd 100644 --- a/tests/test_action_contract.py +++ b/tests/test_action_contract.py @@ -9,12 +9,12 @@ def test_action_metadata_is_valid_and_inputs_are_wired(): text = (ROOT / "action.yml").read_text(encoding="utf-8") assert "using: 'composite'" in text - for name in ("config", "preset", "exclude", "max-scripts", "dry-run", "output", "changed-since", "packages", "repeat", "env", "workdir"): + for name in ("config", "preset", "exclude", "max-scripts", "dry-run", "output", "changed-since", "packages", "repeat", "env", "workdir", "fail-fast"): assert f" {name}:" in text run_text = text for flag in ("--timeout", "--format", "--shell", "--mem-limit", "--pids-limit", "--network", "--packages", "--matrix", "--jobs", "--config", "--preset", "--repeat", - "--max-scripts", "--output", "--exclude", "--env", "--workdir"): + "--max-scripts", "--output", "--exclude", "--env", "--workdir", "--fail-fast"): assert flag in run_text assert "json, or sarif" in text assert "--changed-since" in run_text diff --git a/tests/test_audit_coverage.py b/tests/test_audit_coverage.py index cf41c9d..5b4dc9f 100644 --- a/tests/test_audit_coverage.py +++ b/tests/test_audit_coverage.py @@ -927,3 +927,152 @@ def test_expanded_remediation_packages(): assert pkg_alp == alp_expected, f"Failed for alpine {cmd}" +# ============================================================================== +# 12. Fail-Fast 快速失败与 dash / ash 原生解释器专项测试 (v0.9.7) +# ============================================================================== + +def test_shebang_dash_and_ash_support(tmp_path): + from opsscript_gate.runner import inspect_shebang, ShebangStatus, _resolve_shell_command, ShellMode + + # 1. 直接 /bin/dash 与 /usr/bin/dash + dash_script = tmp_path / "dash_script" + dash_script.write_text("#!/bin/dash\necho hello\n") + res_dash = inspect_shebang(str(dash_script)) + assert res_dash.status == ShebangStatus.RECOGNIZED + assert res_dash.interpreter == "dash" + assert res_dash.command_key == "/bin/dash" + cmd_dash, err_dash = _resolve_shell_command(ShellMode.SHEBANG, res_dash) + assert err_dash is None + assert "/bin/dash /tmp/target_script.sh