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.6] - 2026-11-08

### Added
- Added safe container environment variable injection via CLI `--env` and configuration `"env": {...}`, with strict POSIX variable name validation (`^[A-Za-z_][A-Za-z0-9_]*$`).
- Added host workspace context read-only mounting via CLI `--workdir` and configuration `"workdir": "..."`, automatically setting `/workspace` as the container working directory while strictly preserving host immutability (`mode: "ro"`).
- Added `env` and `workdir` inputs to the GitHub Composite Action with transparent argument forwarding.
- Expanded `COMMON_COMMAND_PACKAGES` to provide cross-distro package remediation for standard operations tools (`find`, `xargs`, `grep`, `sed`, `awk`, `diff`, `patch`, `ssh`, `rsync`, `htop`).
- Added Windows case-insensitive path collision protection in `_output_overwrites_protected` to prevent accidental overwrites of critical project files (e.g. `README.md`).

## [0.9.5] - 2026-11-08

### Added
Expand Down
19 changes: 9 additions & 10 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.4
- uses: Mresyzz/opsscript-gate@v0.9.6
with:
script-path: install.sh
```
Expand All @@ -57,12 +57,9 @@ The result identifies the failing distribution, exit code, line, missing command

---

## Current release: v0.9.4
## Current release: v0.9.6

The current release includes a project config file, a dry-run plan, presets, exclusions,
saved reports, changed-script selection for pull requests, and repeat runs for detecting
flaky runtime behavior. The project controls
were introduced in v0.5.0; projects on v0.4.1 and earlier do not include them.
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.

```bash
opsscript-gate init
Expand All @@ -71,6 +68,8 @@ 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
# Pass environment variables and mount repository workspace read-only
opsscript-gate run ./install.sh --env APP_ENV=staging --workdir .
```

`init` creates `.opsscript-gate.json` and a GitHub Actions workflow without replacing
Expand Down Expand Up @@ -115,7 +114,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: Mresyzz/opsscript-gate@v0.9.4
- uses: Mresyzz/opsscript-gate@v0.9.6
```

This zero-configuration form discovers shell scripts in the repository. Use
Expand All @@ -126,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.4
- uses: Mresyzz/opsscript-gate@v0.9.6
with:
script-path: scripts/install.sh
shell: auto
Expand Down Expand Up @@ -160,7 +159,7 @@ unrelated scripts:
with:
persist-credentials: false
fetch-depth: 0
- uses: Mresyzz/opsscript-gate@v0.9.4
- uses: Mresyzz/opsscript-gate@v0.9.6
with:
changed-since: ${{ github.event.pull_request.base.sha }}
preset: minimal
Expand Down Expand Up @@ -251,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.4
- uses: Mresyzz/opsscript-gate@v0.9.6
with:
script-path: scripts/install.sh
format: sarif
Expand Down
10 changes: 6 additions & 4 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.4
## 当前版本 v0.9.6

v0.9.4 是当前已发布版本;如果你要从源码验证当前分支,也可以执行:
v0.9.6 是当前已发布版本;如果你要从源码验证当前分支,也可以执行:

```bash
pip install -e .
Expand All @@ -33,9 +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 .
```

`init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.4`。
`init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.6`。
预览不需要 Docker;真实运行需要 Python 3.10+
和可访问的 Linux Docker 引擎。
`opsscript-gate doctor` 可以在真实运行前检查 Python 和 Docker。
Expand Down Expand Up @@ -74,7 +76,7 @@ Docker daemon 和可选的项目配置。
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: Mresyzz/opsscript-gate@v0.9.4
- uses: Mresyzz/opsscript-gate@v0.9.6
with:
changed-since: ${{ github.event.pull_request.base.sha }}
preset: minimal
Expand Down
15 changes: 15 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ inputs:
description: 'Optional comma- or newline-separated packages to install before running (requires network=bridge)'
required: false
default: ''
env:
description: 'Newline-separated list of environment variables to pass into containers (e.g. KEY=VALUE or KEY)'
required: false
default: ''
workdir:
description: 'Host directory to mount read-only into /workspace as the container working directory'
required: false
default: ''

config:
description: 'Path to project JSON configuration'
Expand Down Expand Up @@ -116,6 +124,8 @@ runs:
INPUT_PIDS_LIMIT: ${{ inputs.pids-limit }}
INPUT_NETWORK: ${{ inputs.network }}
INPUT_PACKAGES: ${{ inputs.packages }}
INPUT_ENV: ${{ inputs.env }}
INPUT_WORKDIR: ${{ inputs.workdir }}
INPUT_CONFIG: ${{ inputs.config }}
INPUT_PRESET: ${{ inputs.preset }}
INPUT_EXCLUDE: ${{ inputs.exclude }}
Expand All @@ -137,13 +147,18 @@ runs:
[ -z "$INPUT_PIDS_LIMIT" ] || ARGS+=(--pids-limit "$INPUT_PIDS_LIMIT")
[ -z "$INPUT_NETWORK" ] || ARGS+=(--network "$INPUT_NETWORK")
[ -z "$INPUT_PACKAGES" ] || ARGS+=(--packages "$INPUT_PACKAGES")
[ -z "$INPUT_WORKDIR" ] || ARGS+=(--workdir "$INPUT_WORKDIR")
[ -z "$INPUT_MATRIX" ] || ARGS+=(--matrix "$INPUT_MATRIX")
[ -z "$INPUT_JOBS" ] || ARGS+=(--jobs "$INPUT_JOBS")
[ -z "$INPUT_CONFIG" ] || ARGS+=(--config "$INPUT_CONFIG")
[ -z "$INPUT_PRESET" ] || ARGS+=(--preset "$INPUT_PRESET")
[ -z "$INPUT_MAX_SCRIPTS" ] || ARGS+=(--max-scripts "$INPUT_MAX_SCRIPTS")
[ -z "$INPUT_OUTPUT" ] || ARGS+=(--output "$INPUT_OUTPUT")
[ -z "$INPUT_CHANGED_SINCE" ] || ARGS+=(--changed-since "$INPUT_CHANGED_SINCE")
while IFS= read -r env_var; do
env_var="${env_var%$'\r'}"
[ -z "$env_var" ] || ARGS+=("--env" "$env_var")
done <<< "$INPUT_ENV"
while IFS= read -r pattern; do
pattern="${pattern%$'\r'}"
[ -z "$pattern" ] || ARGS+=("--exclude" "$pattern")
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.5"
version = "0.9.6"
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.5"
__version__ = "0.9.6"
__all__ = ["__version__"]
73 changes: 69 additions & 4 deletions src/opsscript_gate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,20 @@

from opsscript_gate import __version__
from opsscript_gate.discovery import discover_changed_scripts, discover_scripts
from opsscript_gate.config import DEFAULTS, MAX_MATRIX_IMAGES, MAX_REPEAT_ATTEMPTS, MAX_TIMEOUT_SECONDS, PRESETS, init_project, load_config, validate_memory_limit, validate_packages
from opsscript_gate.config import (
DEFAULTS,
ENV_KEY_RE,
MAX_MATRIX_IMAGES,
MAX_REPEAT_ATTEMPTS,
MAX_TIMEOUT_SECONDS,
PRESETS,
init_project,
load_config,
validate_env,
validate_memory_limit,
validate_packages,
validate_workdir,
)
from opsscript_gate.models import MultiScriptReport, RunReport
from opsscript_gate.reporter import (
emit_github_annotations,
Expand Down Expand Up @@ -144,6 +157,18 @@ def build_parser() -> argparse.ArgumentParser:
default=None,
help="Optional comma-separated packages to install in each container before running (repeatable; requires network=bridge)",
)
run_parser.add_argument(
"--env",
action="append",
default=None,
help="Environment variable to pass into containers in KEY=VALUE or KEY format (repeatable)",
)
run_parser.add_argument(
"--workdir",
type=str,
default=None,
help="Host directory to mount read-only into /workspace as the container working directory",
)
run_parser.add_argument(
"--changed-since",
help="Only test shell scripts changed between this Git revision and HEAD",
Expand Down Expand Up @@ -181,6 +206,33 @@ def parse_packages_argument(packages_raw: list[str] | None) -> list[str]:
return validate_packages(packages)


def parse_env_arguments(
env_args: list[str] | None,
base_env: dict[str, str] | None = None,
) -> dict[str, str]:
"""Parse repeated --env arguments, resolving KEY=VALUE or KEY from host env."""
merged: dict[str, str] = dict(base_env or {})
if not env_args:
return validate_env(merged) if merged else {}
for item in env_args:
for entry in item.splitlines():
entry = entry.strip()
if not entry:
continue
if "=" in entry:
key, val = entry.split("=", 1)
else:
key = entry
val = os.environ.get(key, "")
key = key.strip()
if not ENV_KEY_RE.fullmatch(key):
raise ValueError(
f"Invalid environment variable name: '{key}'. Must be a valid POSIX identifier (^[A-Za-z_][A-Za-z0-9_]*$)"
)
merged[key] = val
return validate_env(merged)


def run_checked_matrix(
args: argparse.Namespace, script_path: str, matrix: list[str]
) -> RunReport | None:
Expand All @@ -197,6 +249,8 @@ def run_checked_matrix(
network=args.network,
packages=args.packages,
repeat=args.repeat,
env=args.env,
workdir=args.workdir,
)
except DockerDaemonError as err:
sys.stderr.write(f"Docker Error: {err}\n")
Expand All @@ -207,8 +261,11 @@ def run_checked_matrix(

def _output_overwrites_protected(output_path: Path, protected: list[Path]) -> bool:
"""Reject paths that resolve to or share an inode with a protected file."""
if output_path in protected:
return True
for p in protected:
if output_path == p:
return True
if os.name == "nt" and str(output_path).lower() == str(p).lower():
return True
if not output_path.exists():
return False
for target in protected:
Expand Down Expand Up @@ -316,6 +373,10 @@ def main(argv: list[str] | None = None) -> int:
args.packages = parse_packages_argument(args.packages)
if args.packages and args.network == "none":
raise ValueError("packages require network=bridge")
base_env = config.get("env") if isinstance(config.get("env"), dict) else {}
args.env = parse_env_arguments(args.env, base_env=base_env)
if args.workdir:
args.workdir = validate_workdir(args.workdir)
if args.matrix is not None and not any(x.strip() for x in args.matrix.split(",")):
raise ValueError("matrix must contain at least one image")
except ValueError as err:
Expand Down Expand Up @@ -385,14 +446,18 @@ 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}
"repeat": args.repeat, "env": args.env, "workdir": args.workdir}
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 ""
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
+ 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
41 changes: 39 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,
"repeat": 1, "env": {}, "workdir": None,
}

MAX_CONFIG_SIZE_BYTES = 256 * 1024
Expand All @@ -26,6 +26,39 @@
MAX_MATRIX_IMAGES = 32

PACKAGE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+._:-]*$")
ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")


def validate_env(value: object) -> dict[str, str]:
"""Validate and normalize environment variables specified in configuration or CLI."""
if not isinstance(value, dict):
raise ValueError("env must be a JSON object mapping variable names to string values")
normalized: dict[str, str] = {}
for k, v in value.items():
if not isinstance(k, str) or not ENV_KEY_RE.fullmatch(k):
raise ValueError(
f"Invalid environment variable name '{k}'. Must be a valid POSIX identifier (^[A-Za-z_][A-Za-z0-9_]*$)"
)
if not isinstance(v, str):
raise ValueError(
f"Environment variable '{k}' value must be a string, got {type(v).__name__}"
)
normalized[k] = v
return normalized


def validate_workdir(value: object) -> str:
"""Validate that the workspace directory exists, is a directory, and is not root."""
if not isinstance(value, str) or not value.strip():
raise ValueError("workdir must be a nonempty path string")
path = Path(value.strip()).resolve()
if not path.exists():
raise ValueError(f"workdir directory does not exist: {value}")
if not path.is_dir():
raise ValueError(f"workdir is not a directory: {value}")
if path == path.parent:
raise ValueError(f"workdir must not be the root directory: {value}")
return str(path)


def validate_memory_limit(value: object) -> str:
Expand Down Expand Up @@ -93,6 +126,10 @@ def load_config(filename: str | None) -> dict:
validate_packages(value)
elif key == "mem_limit":
validate_memory_limit(value)
elif key == "env":
validate_env(value)
elif key == "workdir":
validate_workdir(value)
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 @@ -126,7 +163,7 @@ def init_project() -> list[str]:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: Mresyzz/opsscript-gate@v0.9.5
- uses: Mresyzz/opsscript-gate@v0.9.6
with:
config: .opsscript-gate.json
""",
Expand Down
10 changes: 10 additions & 0 deletions src/opsscript_gate/remediation.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ def _match_interpreter(distro: str | None, command: str | None, kind: str) -> bo
"envsubst": {"debian": "gettext-base", "alpine": "gettext", "rhel": "gettext"},
"crontab": {"debian": "cron", "alpine": "dcron", "rhel": "cronie"},
"ps": {"debian": "procps", "alpine": "procps", "rhel": "procps-ng"},
"find": {"debian": "findutils", "alpine": "findutils", "rhel": "findutils"},
"xargs": {"debian": "findutils", "alpine": "findutils", "rhel": "findutils"},
"grep": {"debian": "grep", "alpine": "grep", "rhel": "grep"},
"sed": {"debian": "sed", "alpine": "sed", "rhel": "sed"},
"awk": {"debian": "gawk", "alpine": "gawk", "rhel": "gawk"},
"diff": {"debian": "diffutils", "alpine": "diffutils", "rhel": "diffutils"},
"patch": {"debian": "patch", "alpine": "patch", "rhel": "patch"},
"ssh": {"debian": "openssh-client", "alpine": "openssh-client", "rhel": "openssh-clients"},
"rsync": {"debian": "rsync", "alpine": "rsync", "rhel": "rsync"},
"htop": {"debian": "htop", "alpine": "htop", "rhel": "htop"},
}


Expand Down
Loading
Loading