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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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 .
```
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand All @@ -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。
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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")
Expand Down
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.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" }
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.6"
__version__ = "0.9.7"
__all__ = ["__version__"]
17 changes: 16 additions & 1 deletion src/opsscript_gate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -446,18 +455,21 @@ 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)
+ f"\nImages: {', '.join(matrix)}\nContainer executions: {plan['executions']}"
+ (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
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 5 additions & 2 deletions src/opsscript_gate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
""",
Expand Down
6 changes: 6 additions & 0 deletions src/opsscript_gate/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/opsscript_gate/remediation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"},
}


Expand Down
Loading
Loading