opsscript-gate 0.9.5__tar.gz → 0.9.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/CHANGELOG.md +17 -0
  2. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/PKG-INFO +12 -13
  3. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/README.md +11 -12
  4. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/README.zh-CN.md +6 -4
  5. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/action.yml +27 -0
  6. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/pyproject.toml +1 -1
  7. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/src/opsscript_gate/__init__.py +1 -1
  8. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/src/opsscript_gate/cli.py +84 -4
  9. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/src/opsscript_gate/config.py +42 -2
  10. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/src/opsscript_gate/discovery.py +6 -0
  11. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/src/opsscript_gate/remediation.py +13 -1
  12. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/src/opsscript_gate/runner.py +118 -18
  13. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/tests/test_action_contract.py +2 -2
  14. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/tests/test_audit_coverage.py +355 -0
  15. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/tests/test_onboarding.py +3 -1
  16. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/.gitattributes +0 -0
  17. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  18. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
  19. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  20. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/.github/assets/social-preview.png +0 -0
  21. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/.github/pull_request_template.md +0 -0
  22. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/.github/workflow-templates/opsscript-gate.properties.json +0 -0
  23. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/.github/workflow-templates/opsscript-gate.yml +0 -0
  24. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/.github/workflows/demo.yml +0 -0
  25. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/.github/workflows/deploy-pages.yml +0 -0
  26. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/.github/workflows/release.yml +0 -0
  27. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/.github/workflows/test.yml +0 -0
  28. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/.gitignore +0 -0
  29. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/CITATION.cff +0 -0
  30. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/CONTRIBUTING.md +0 -0
  31. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/LICENSE +0 -0
  32. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/ROADMAP.md +0 -0
  33. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/SECURITY.md +0 -0
  34. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/demo.html +0 -0
  35. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/docs/command-not-found.html +0 -0
  36. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/docs/command-not-found.md +0 -0
  37. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/docs/configuration.md +0 -0
  38. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/docs/gpt-oss.md +0 -0
  39. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/docs/index.html +0 -0
  40. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/docs/installer-smoke-test.html +0 -0
  41. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/docs/installer-smoke-test.md +0 -0
  42. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/docs/llms.txt +0 -0
  43. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/docs/robots.txt +0 -0
  44. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/docs/sitemap.xml +0 -0
  45. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/docs/troubleshooting.md +0 -0
  46. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/examples/alpine-incompatibility/README.md +0 -0
  47. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
  48. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/examples/basic/README.md +0 -0
  49. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/examples/basic/clean_setup.sh +0 -0
  50. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/examples/github-actions/README.md +0 -0
  51. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/examples/github-actions/installer-smoke.yml +0 -0
  52. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/examples/github-actions/workflow.yml +0 -0
  53. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/examples/interactive-hang/README.md +0 -0
  54. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/examples/interactive-hang/prompt_confirm.sh +0 -0
  55. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/index.html +0 -0
  56. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/llms.txt +0 -0
  57. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/src/opsscript_gate/models.py +0 -0
  58. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/src/opsscript_gate/reporter.py +0 -0
  59. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/tests/conftest.py +0 -0
  60. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/tests/fixtures/fail_deps.sh +0 -0
  61. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/tests/fixtures/fail_hang.sh +0 -0
  62. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/tests/fixtures/pass_basic.sh +0 -0
  63. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/tests/test_installer_example.py +0 -0
  64. {opsscript_gate-0.9.5 → opsscript_gate-0.9.7}/tests/test_runner.py +0 -0
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.7] - 2026-11-08
4
+
5
+ ### Added
6
+ - 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.
7
+ - 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`).
8
+ - Enhanced `--workdir` integration to automatically map workspace-relative script paths and retain script `$0` identity for scripts that compute local paths via `dirname "$0"`.
9
+ - Added cross-distro package remediation recommendations for `dash` and `ash` missing interpreters across Debian and Alpine.
10
+
11
+ ## [0.9.6] - 2026-11-08
12
+
13
+ ### Added
14
+ - 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_]*$`).
15
+ - 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"`).
16
+ - Added `env` and `workdir` inputs to the GitHub Composite Action with transparent argument forwarding.
17
+ - Expanded `COMMON_COMMAND_PACKAGES` to provide cross-distro package remediation for standard operations tools (`find`, `xargs`, `grep`, `sed`, `awk`, `diff`, `patch`, `ssh`, `rsync`, `htop`).
18
+ - Added Windows case-insensitive path collision protection in `_output_overwrites_protected` to prevent accidental overwrites of critical project files (e.g. `README.md`).
19
+
3
20
  ## [0.9.5] - 2026-11-08
4
21
 
5
22
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opsscript-gate
3
- Version: 0.9.5
3
+ Version: 0.9.7
4
4
  Summary: Cross-distro shell script compatibility and portability checker for Debian, Ubuntu, and Alpine. Runs real containers to diagnose command not found.
5
5
  Project-URL: Homepage, https://mresyzz.github.io/opsscript-gate/
6
6
  Project-URL: Demo, https://mresyzz.github.io/opsscript-gate/
@@ -81,7 +81,7 @@ apt-get --version
81
81
  Run it locally with `opsscript-gate run ./install.sh`, or add the following step to CI:
82
82
 
83
83
  ```yaml
84
- - uses: Mresyzz/opsscript-gate@v0.9.4
84
+ - uses: Mresyzz/opsscript-gate@v0.9.7
85
85
  with:
86
86
  script-path: install.sh
87
87
  ```
@@ -90,20 +90,19 @@ The result identifies the failing distribution, exit code, line, missing command
90
90
 
91
91
  ---
92
92
 
93
- ## Current release: v0.9.4
93
+ ## Current release: v0.9.7
94
94
 
95
- The current release includes a project config file, a dry-run plan, presets, exclusions,
96
- saved reports, changed-script selection for pull requests, and repeat runs for detecting
97
- flaky runtime behavior. The project controls
98
- were introduced in v0.5.0; projects on v0.4.1 and earlier do not include them.
95
+ 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.
99
96
 
100
97
  ```bash
101
98
  opsscript-gate init
102
99
  opsscript-gate doctor
103
100
  opsscript-gate run --dry-run
104
101
  opsscript-gate run --format json --output reports/compatibility.json
105
- # Repeat each distro three times when diagnosing intermittent failures
106
- opsscript-gate run ./install.sh --repeat 3
102
+ # Stop immediately on the first failed check
103
+ opsscript-gate run -x
104
+ # Pass environment variables and mount repository workspace read-only
105
+ opsscript-gate run ./install.sh --env APP_ENV=staging --workdir .
107
106
  ```
108
107
 
109
108
  `init` creates `.opsscript-gate.json` and a GitHub Actions workflow without replacing
@@ -148,7 +147,7 @@ jobs:
148
147
  - uses: actions/checkout@v7
149
148
  with:
150
149
  persist-credentials: false
151
- - uses: Mresyzz/opsscript-gate@v0.9.4
150
+ - uses: Mresyzz/opsscript-gate@v0.9.7
152
151
  ```
153
152
 
154
153
  This zero-configuration form discovers shell scripts in the repository. Use
@@ -159,7 +158,7 @@ repository has a single installer. For self-hosted model installers, see the
159
158
  Or test a specific script with custom execution modes:
160
159
 
161
160
  ```yaml
162
- - uses: Mresyzz/opsscript-gate@v0.9.4
161
+ - uses: Mresyzz/opsscript-gate@v0.9.7
163
162
  with:
164
163
  script-path: scripts/install.sh
165
164
  shell: auto
@@ -193,7 +192,7 @@ unrelated scripts:
193
192
  with:
194
193
  persist-credentials: false
195
194
  fetch-depth: 0
196
- - uses: Mresyzz/opsscript-gate@v0.9.4
195
+ - uses: Mresyzz/opsscript-gate@v0.9.7
197
196
  with:
198
197
  changed-since: ${{ github.event.pull_request.base.sha }}
199
198
  preset: minimal
@@ -284,7 +283,7 @@ Use `sarif` when the result should appear in GitHub Code Scanning or another
284
283
  SARIF-compatible viewer. Upload it explicitly with the official upload action:
285
284
 
286
285
  ```yaml
287
- - uses: Mresyzz/opsscript-gate@v0.9.4
286
+ - uses: Mresyzz/opsscript-gate@v0.9.7
288
287
  with:
289
288
  script-path: scripts/install.sh
290
289
  format: sarif
@@ -48,7 +48,7 @@ apt-get --version
48
48
  Run it locally with `opsscript-gate run ./install.sh`, or add the following step to CI:
49
49
 
50
50
  ```yaml
51
- - uses: Mresyzz/opsscript-gate@v0.9.4
51
+ - uses: Mresyzz/opsscript-gate@v0.9.7
52
52
  with:
53
53
  script-path: install.sh
54
54
  ```
@@ -57,20 +57,19 @@ The result identifies the failing distribution, exit code, line, missing command
57
57
 
58
58
  ---
59
59
 
60
- ## Current release: v0.9.4
60
+ ## Current release: v0.9.7
61
61
 
62
- The current release includes a project config file, a dry-run plan, presets, exclusions,
63
- saved reports, changed-script selection for pull requests, and repeat runs for detecting
64
- flaky runtime behavior. The project controls
65
- were introduced in v0.5.0; projects on v0.4.1 and earlier do not include them.
62
+ 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.
66
63
 
67
64
  ```bash
68
65
  opsscript-gate init
69
66
  opsscript-gate doctor
70
67
  opsscript-gate run --dry-run
71
68
  opsscript-gate run --format json --output reports/compatibility.json
72
- # Repeat each distro three times when diagnosing intermittent failures
73
- opsscript-gate run ./install.sh --repeat 3
69
+ # Stop immediately on the first failed check
70
+ opsscript-gate run -x
71
+ # Pass environment variables and mount repository workspace read-only
72
+ opsscript-gate run ./install.sh --env APP_ENV=staging --workdir .
74
73
  ```
75
74
 
76
75
  `init` creates `.opsscript-gate.json` and a GitHub Actions workflow without replacing
@@ -115,7 +114,7 @@ jobs:
115
114
  - uses: actions/checkout@v7
116
115
  with:
117
116
  persist-credentials: false
118
- - uses: Mresyzz/opsscript-gate@v0.9.4
117
+ - uses: Mresyzz/opsscript-gate@v0.9.7
119
118
  ```
120
119
 
121
120
  This zero-configuration form discovers shell scripts in the repository. Use
@@ -126,7 +125,7 @@ repository has a single installer. For self-hosted model installers, see the
126
125
  Or test a specific script with custom execution modes:
127
126
 
128
127
  ```yaml
129
- - uses: Mresyzz/opsscript-gate@v0.9.4
128
+ - uses: Mresyzz/opsscript-gate@v0.9.7
130
129
  with:
131
130
  script-path: scripts/install.sh
132
131
  shell: auto
@@ -160,7 +159,7 @@ unrelated scripts:
160
159
  with:
161
160
  persist-credentials: false
162
161
  fetch-depth: 0
163
- - uses: Mresyzz/opsscript-gate@v0.9.4
162
+ - uses: Mresyzz/opsscript-gate@v0.9.7
164
163
  with:
165
164
  changed-since: ${{ github.event.pull_request.base.sha }}
166
165
  preset: minimal
@@ -251,7 +250,7 @@ Use `sarif` when the result should appear in GitHub Code Scanning or another
251
250
  SARIF-compatible viewer. Upload it explicitly with the official upload action:
252
251
 
253
252
  ```yaml
254
- - uses: Mresyzz/opsscript-gate@v0.9.4
253
+ - uses: Mresyzz/opsscript-gate@v0.9.7
255
254
  with:
256
255
  script-path: scripts/install.sh
257
256
  format: sarif
@@ -17,9 +17,9 @@ Bash 依赖、包管理器假设和交互式阻塞。它与 ShellCheck 互补。
17
17
 
18
18
  也可以直接打开 [OpsScript Gate 在线演示](https://mresyzz.github.io/opsscript-gate/),先查看 ShellCheck 与运行时验证的差异,再复制 workflow 到自己的仓库。
19
19
 
20
- ## 当前版本 v0.9.4
20
+ ## 当前版本 v0.9.7
21
21
 
22
- v0.9.4 是当前已发布版本;如果你要从源码验证当前分支,也可以执行:
22
+ v0.9.7 是当前已发布版本;如果你要从源码验证当前分支,也可以执行:
23
23
 
24
24
  ```bash
25
25
  pip install -e .
@@ -33,9 +33,11 @@ opsscript-gate init
33
33
  opsscript-gate doctor
34
34
  opsscript-gate run --dry-run
35
35
  opsscript-gate run --format json --output reports/compatibility.json
36
+ # 开启失败即停(-x / --fail-fast),传递环境变量并只读挂载工作区
37
+ opsscript-gate run ./install.sh -x --env APP_ENV=staging --workdir .
36
38
  ```
37
39
 
38
- `init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.4`。
40
+ `init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.7`。
39
41
  预览不需要 Docker;真实运行需要 Python 3.10+
40
42
  和可访问的 Linux Docker 引擎。
41
43
  `opsscript-gate doctor` 可以在真实运行前检查 Python 和 Docker。
@@ -74,7 +76,7 @@ Docker daemon 和可选的项目配置。
74
76
  - uses: actions/checkout@v7
75
77
  with:
76
78
  fetch-depth: 0
77
- - uses: Mresyzz/opsscript-gate@v0.9.4
79
+ - uses: Mresyzz/opsscript-gate@v0.9.7
78
80
  with:
79
81
  changed-since: ${{ github.event.pull_request.base.sha }}
80
82
  preset: minimal
@@ -50,6 +50,14 @@ inputs:
50
50
  description: 'Optional comma- or newline-separated packages to install before running (requires network=bridge)'
51
51
  required: false
52
52
  default: ''
53
+ env:
54
+ description: 'Newline-separated list of environment variables to pass into containers (e.g. KEY=VALUE or KEY)'
55
+ required: false
56
+ default: ''
57
+ workdir:
58
+ description: 'Host directory to mount read-only into /workspace as the container working directory'
59
+ required: false
60
+ default: ''
53
61
 
54
62
  config:
55
63
  description: 'Path to project JSON configuration'
@@ -71,6 +79,10 @@ inputs:
71
79
  description: 'Preview the execution plan without Docker'
72
80
  required: false
73
81
  default: 'false'
82
+ fail-fast:
83
+ description: 'Stop immediately on the first failed distribution or script check (default: false)'
84
+ required: false
85
+ default: 'false'
74
86
  output:
75
87
  description: 'Save a report file for upload-artifact or other tooling'
76
88
  required: false
@@ -116,11 +128,14 @@ runs:
116
128
  INPUT_PIDS_LIMIT: ${{ inputs.pids-limit }}
117
129
  INPUT_NETWORK: ${{ inputs.network }}
118
130
  INPUT_PACKAGES: ${{ inputs.packages }}
131
+ INPUT_ENV: ${{ inputs.env }}
132
+ INPUT_WORKDIR: ${{ inputs.workdir }}
119
133
  INPUT_CONFIG: ${{ inputs.config }}
120
134
  INPUT_PRESET: ${{ inputs.preset }}
121
135
  INPUT_EXCLUDE: ${{ inputs.exclude }}
122
136
  INPUT_MAX_SCRIPTS: ${{ inputs.max-scripts }}
123
137
  INPUT_DRY_RUN: ${{ inputs.dry-run }}
138
+ INPUT_FAIL_FAST: ${{ inputs.fail-fast }}
124
139
  INPUT_OUTPUT: ${{ inputs.output }}
125
140
  INPUT_CHANGED_SINCE: ${{ inputs.changed-since }}
126
141
  run: |
@@ -137,6 +152,7 @@ runs:
137
152
  [ -z "$INPUT_PIDS_LIMIT" ] || ARGS+=(--pids-limit "$INPUT_PIDS_LIMIT")
138
153
  [ -z "$INPUT_NETWORK" ] || ARGS+=(--network "$INPUT_NETWORK")
139
154
  [ -z "$INPUT_PACKAGES" ] || ARGS+=(--packages "$INPUT_PACKAGES")
155
+ [ -z "$INPUT_WORKDIR" ] || ARGS+=(--workdir "$INPUT_WORKDIR")
140
156
  [ -z "$INPUT_MATRIX" ] || ARGS+=(--matrix "$INPUT_MATRIX")
141
157
  [ -z "$INPUT_JOBS" ] || ARGS+=(--jobs "$INPUT_JOBS")
142
158
  [ -z "$INPUT_CONFIG" ] || ARGS+=(--config "$INPUT_CONFIG")
@@ -144,10 +160,21 @@ runs:
144
160
  [ -z "$INPUT_MAX_SCRIPTS" ] || ARGS+=(--max-scripts "$INPUT_MAX_SCRIPTS")
145
161
  [ -z "$INPUT_OUTPUT" ] || ARGS+=(--output "$INPUT_OUTPUT")
146
162
  [ -z "$INPUT_CHANGED_SINCE" ] || ARGS+=(--changed-since "$INPUT_CHANGED_SINCE")
163
+ while IFS= read -r env_var; do
164
+ env_var="${env_var%$'\r'}"
165
+ [ -z "$env_var" ] || ARGS+=("--env" "$env_var")
166
+ done <<< "$INPUT_ENV"
147
167
  while IFS= read -r pattern; do
148
168
  pattern="${pattern%$'\r'}"
149
169
  [ -z "$pattern" ] || ARGS+=("--exclude" "$pattern")
150
170
  done <<< "$INPUT_EXCLUDE"
171
+ FAIL_FAST_VALUE="$(printf '%s' "$INPUT_FAIL_FAST" | tr '[:upper:]' '[:lower:]')"
172
+ if [ "$FAIL_FAST_VALUE" = "true" ]; then
173
+ ARGS+=("--fail-fast")
174
+ elif [ "$FAIL_FAST_VALUE" != "false" ]; then
175
+ echo "fail-fast must be true or false" >&2
176
+ exit 1
177
+ fi
151
178
  DRY_RUN_VALUE="$(printf '%s' "$INPUT_DRY_RUN" | tr '[:upper:]' '[:lower:]')"
152
179
  if [ "$DRY_RUN_VALUE" = "true" ]; then
153
180
  ARGS+=("--dry-run")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "opsscript-gate"
7
- version = "0.9.5"
7
+ version = "0.9.7"
8
8
  description = "Cross-distro shell script compatibility and portability checker for Debian, Ubuntu, and Alpine. Runs real containers to diagnose command not found."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,4 +1,4 @@
1
1
  """OpsScript Gate: Lightweight cross-distro compatibility pre-check for Linux ops scripts."""
2
2
 
3
- __version__ = "0.9.5"
3
+ __version__ = "0.9.7"
4
4
  __all__ = ["__version__"]
@@ -9,7 +9,20 @@ from pathlib import Path
9
9
 
10
10
  from opsscript_gate import __version__
11
11
  from opsscript_gate.discovery import discover_changed_scripts, discover_scripts
12
- 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
12
+ from opsscript_gate.config import (
13
+ DEFAULTS,
14
+ ENV_KEY_RE,
15
+ MAX_MATRIX_IMAGES,
16
+ MAX_REPEAT_ATTEMPTS,
17
+ MAX_TIMEOUT_SECONDS,
18
+ PRESETS,
19
+ init_project,
20
+ load_config,
21
+ validate_env,
22
+ validate_memory_limit,
23
+ validate_packages,
24
+ validate_workdir,
25
+ )
13
26
  from opsscript_gate.models import MultiScriptReport, RunReport
14
27
  from opsscript_gate.reporter import (
15
28
  emit_github_annotations,
@@ -144,6 +157,24 @@ def build_parser() -> argparse.ArgumentParser:
144
157
  default=None,
145
158
  help="Optional comma-separated packages to install in each container before running (repeatable; requires network=bridge)",
146
159
  )
160
+ run_parser.add_argument(
161
+ "--env",
162
+ action="append",
163
+ default=None,
164
+ help="Environment variable to pass into containers in KEY=VALUE or KEY format (repeatable)",
165
+ )
166
+ run_parser.add_argument(
167
+ "--workdir",
168
+ type=str,
169
+ default=None,
170
+ help="Host directory to mount read-only into /workspace as the container working directory",
171
+ )
172
+ run_parser.add_argument(
173
+ "-x", "--fail-fast",
174
+ action="store_true",
175
+ default=False,
176
+ help="Stop immediately on the first failed distribution or script check",
177
+ )
147
178
  run_parser.add_argument(
148
179
  "--changed-since",
149
180
  help="Only test shell scripts changed between this Git revision and HEAD",
@@ -181,6 +212,33 @@ def parse_packages_argument(packages_raw: list[str] | None) -> list[str]:
181
212
  return validate_packages(packages)
182
213
 
183
214
 
215
+ def parse_env_arguments(
216
+ env_args: list[str] | None,
217
+ base_env: dict[str, str] | None = None,
218
+ ) -> dict[str, str]:
219
+ """Parse repeated --env arguments, resolving KEY=VALUE or KEY from host env."""
220
+ merged: dict[str, str] = dict(base_env or {})
221
+ if not env_args:
222
+ return validate_env(merged) if merged else {}
223
+ for item in env_args:
224
+ for entry in item.splitlines():
225
+ entry = entry.strip()
226
+ if not entry:
227
+ continue
228
+ if "=" in entry:
229
+ key, val = entry.split("=", 1)
230
+ else:
231
+ key = entry
232
+ val = os.environ.get(key, "")
233
+ key = key.strip()
234
+ if not ENV_KEY_RE.fullmatch(key):
235
+ raise ValueError(
236
+ f"Invalid environment variable name: '{key}'. Must be a valid POSIX identifier (^[A-Za-z_][A-Za-z0-9_]*$)"
237
+ )
238
+ merged[key] = val
239
+ return validate_env(merged)
240
+
241
+
184
242
  def run_checked_matrix(
185
243
  args: argparse.Namespace, script_path: str, matrix: list[str]
186
244
  ) -> RunReport | None:
@@ -197,6 +255,9 @@ def run_checked_matrix(
197
255
  network=args.network,
198
256
  packages=args.packages,
199
257
  repeat=args.repeat,
258
+ env=args.env,
259
+ workdir=args.workdir,
260
+ fail_fast=getattr(args, "fail_fast", False),
200
261
  )
201
262
  except DockerDaemonError as err:
202
263
  sys.stderr.write(f"Docker Error: {err}\n")
@@ -207,8 +268,11 @@ def run_checked_matrix(
207
268
 
208
269
  def _output_overwrites_protected(output_path: Path, protected: list[Path]) -> bool:
209
270
  """Reject paths that resolve to or share an inode with a protected file."""
210
- if output_path in protected:
211
- return True
271
+ for p in protected:
272
+ if output_path == p:
273
+ return True
274
+ if os.name == "nt" and str(output_path).lower() == str(p).lower():
275
+ return True
212
276
  if not output_path.exists():
213
277
  return False
214
278
  for target in protected:
@@ -293,6 +357,8 @@ def main(argv: list[str] | None = None) -> int:
293
357
  explicit = {token.split("=", 1)[0] for token in argv if token.startswith("--")}
294
358
  if any(token.startswith("-j") for token in argv):
295
359
  explicit.add("--jobs")
360
+ if any(token == "-x" or token.startswith("-x") for token in argv):
361
+ explicit.add("--fail-fast")
296
362
  for key in DEFAULTS:
297
363
  if key in config and "--" + key.replace("_", "-") not in explicit:
298
364
  setattr(args, key, config[key])
@@ -316,6 +382,10 @@ def main(argv: list[str] | None = None) -> int:
316
382
  args.packages = parse_packages_argument(args.packages)
317
383
  if args.packages and args.network == "none":
318
384
  raise ValueError("packages require network=bridge")
385
+ base_env = config.get("env") if isinstance(config.get("env"), dict) else {}
386
+ args.env = parse_env_arguments(args.env, base_env=base_env)
387
+ if args.workdir:
388
+ args.workdir = validate_workdir(args.workdir)
319
389
  if args.matrix is not None and not any(x.strip() for x in args.matrix.split(",")):
320
390
  raise ValueError("matrix must contain at least one image")
321
391
  except ValueError as err:
@@ -385,14 +455,21 @@ def main(argv: list[str] | None = None) -> int:
385
455
  plan = {"dry_run": True, "scripts": target_scripts, "matrix": matrix,
386
456
  "executions": len(target_scripts) * len(matrix) * args.repeat, "shell": args.shell,
387
457
  "network": args.network, "packages": args.packages, "timeout": args.timeout,
388
- "repeat": args.repeat}
458
+ "repeat": args.repeat, "env": args.env, "workdir": args.workdir,
459
+ "fail_fast": args.fail_fast}
389
460
  if changed_selection:
390
461
  plan["changed_since"] = args.changed_since
462
+ env_preview = f"\nEnv: {', '.join(f'{k}={v}' for k, v in args.env.items())}" if args.env else ""
463
+ workdir_preview = f"\nWorkdir: {args.workdir}" if args.workdir else ""
464
+ fail_fast_preview = f"\nFail-Fast: {args.fail_fast}" if args.fail_fast else ""
391
465
  output = json.dumps(plan, indent=2) if args.format == "json" else (
392
466
  "Execution preview (no scripts executed)\n"
393
467
  + "\n".join(f" {path}" for path in target_scripts)
394
468
  + f"\nImages: {', '.join(matrix)}\nContainer executions: {plan['executions']}"
395
469
  + (f"\nPackages: {', '.join(args.packages)}" if args.packages else "")
470
+ + env_preview
471
+ + workdir_preview
472
+ + fail_fast_preview
396
473
  + f"\nShell: {args.shell} | Network: {args.network} | Timeout: {args.timeout}s | Repeat: {args.repeat}"
397
474
  )
398
475
  return 0 if publish_output(output, args.output) else 1
@@ -455,6 +532,9 @@ def main(argv: list[str] | None = None) -> int:
455
532
  if args.format not in ("json", "sarif"):
456
533
  emit_github_annotations(report, script_path=script_path)
457
534
 
535
+ if args.fail_fast and not report.all_passed:
536
+ break
537
+
458
538
  total_duration = time.perf_counter() - multi_start
459
539
  all_passed = all(r.all_passed for r in multi_reports.values())
460
540
  multi_report = MultiScriptReport(
@@ -17,7 +17,7 @@ DEFAULTS = {
17
17
  "matrix": None, "preset": None, "jobs": None, "timeout": 60,
18
18
  "shell": "posix", "mem_limit": "256m", "pids_limit": 128,
19
19
  "network": "bridge", "packages": [], "exclude": [], "max_scripts": 20,
20
- "repeat": 1,
20
+ "repeat": 1, "env": {}, "workdir": None, "fail_fast": False,
21
21
  }
22
22
 
23
23
  MAX_CONFIG_SIZE_BYTES = 256 * 1024
@@ -26,6 +26,39 @@ MAX_TIMEOUT_SECONDS = 3600
26
26
  MAX_MATRIX_IMAGES = 32
27
27
 
28
28
  PACKAGE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+._:-]*$")
29
+ ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
30
+
31
+
32
+ def validate_env(value: object) -> dict[str, str]:
33
+ """Validate and normalize environment variables specified in configuration or CLI."""
34
+ if not isinstance(value, dict):
35
+ raise ValueError("env must be a JSON object mapping variable names to string values")
36
+ normalized: dict[str, str] = {}
37
+ for k, v in value.items():
38
+ if not isinstance(k, str) or not ENV_KEY_RE.fullmatch(k):
39
+ raise ValueError(
40
+ f"Invalid environment variable name '{k}'. Must be a valid POSIX identifier (^[A-Za-z_][A-Za-z0-9_]*$)"
41
+ )
42
+ if not isinstance(v, str):
43
+ raise ValueError(
44
+ f"Environment variable '{k}' value must be a string, got {type(v).__name__}"
45
+ )
46
+ normalized[k] = v
47
+ return normalized
48
+
49
+
50
+ def validate_workdir(value: object) -> str:
51
+ """Validate that the workspace directory exists, is a directory, and is not root."""
52
+ if not isinstance(value, str) or not value.strip():
53
+ raise ValueError("workdir must be a nonempty path string")
54
+ path = Path(value.strip()).resolve()
55
+ if not path.exists():
56
+ raise ValueError(f"workdir directory does not exist: {value}")
57
+ if not path.is_dir():
58
+ raise ValueError(f"workdir is not a directory: {value}")
59
+ if path == path.parent:
60
+ raise ValueError(f"workdir must not be the root directory: {value}")
61
+ return str(path)
29
62
 
30
63
 
31
64
  def validate_memory_limit(value: object) -> str:
@@ -93,6 +126,13 @@ def load_config(filename: str | None) -> dict:
93
126
  validate_packages(value)
94
127
  elif key == "mem_limit":
95
128
  validate_memory_limit(value)
129
+ elif key == "env":
130
+ validate_env(value)
131
+ elif key == "workdir":
132
+ validate_workdir(value)
133
+ elif key == "fail_fast":
134
+ if not isinstance(value, bool):
135
+ raise ValueError("fail_fast must be a boolean")
96
136
  elif not isinstance(value, str) or not value.strip():
97
137
  raise ValueError(f"{key} must be a nonempty string")
98
138
  for key, choices in {"shell": ("posix", "auto", "shebang"),
@@ -126,7 +166,7 @@ jobs:
126
166
  - uses: actions/checkout@v7
127
167
  with:
128
168
  persist-credentials: false
129
- - uses: Mresyzz/opsscript-gate@v0.9.5
169
+ - uses: Mresyzz/opsscript-gate@v0.9.7
130
170
  with:
131
171
  config: .opsscript-gate.json
132
172
  """,
@@ -34,6 +34,12 @@ SHELL_SHEBANG_PREFIXES = (
34
34
  "#!/usr/bin/bash",
35
35
  "#!/usr/bin/env sh",
36
36
  "#!/usr/bin/env bash",
37
+ "#!/bin/dash",
38
+ "#!/usr/bin/dash",
39
+ "#!/usr/bin/env dash",
40
+ "#!/bin/ash",
41
+ "#!/usr/bin/ash",
42
+ "#!/usr/bin/env ash",
37
43
  )
38
44
 
39
45
  MAX_DISCOVERED_SCRIPTS = 20
@@ -89,7 +89,7 @@ def _match_interpreter(distro: str | None, command: str | None, kind: str) -> bo
89
89
  return True
90
90
  if command and (command.startswith("/bin/") or command.startswith("/usr/bin/")):
91
91
  base = _normalize_cmd_basename(command)
92
- if base in ("bash", "sh", "zsh", "python", "python3", "perl"):
92
+ if base in ("bash", "sh", "dash", "ash", "zsh", "python", "python3", "perl"):
93
93
  return True
94
94
  return False
95
95
 
@@ -123,6 +123,18 @@ COMMON_COMMAND_PACKAGES: dict[str, dict[str, str]] = {
123
123
  "envsubst": {"debian": "gettext-base", "alpine": "gettext", "rhel": "gettext"},
124
124
  "crontab": {"debian": "cron", "alpine": "dcron", "rhel": "cronie"},
125
125
  "ps": {"debian": "procps", "alpine": "procps", "rhel": "procps-ng"},
126
+ "find": {"debian": "findutils", "alpine": "findutils", "rhel": "findutils"},
127
+ "xargs": {"debian": "findutils", "alpine": "findutils", "rhel": "findutils"},
128
+ "grep": {"debian": "grep", "alpine": "grep", "rhel": "grep"},
129
+ "sed": {"debian": "sed", "alpine": "sed", "rhel": "sed"},
130
+ "awk": {"debian": "gawk", "alpine": "gawk", "rhel": "gawk"},
131
+ "diff": {"debian": "diffutils", "alpine": "diffutils", "rhel": "diffutils"},
132
+ "patch": {"debian": "patch", "alpine": "patch", "rhel": "patch"},
133
+ "ssh": {"debian": "openssh-client", "alpine": "openssh-client", "rhel": "openssh-clients"},
134
+ "rsync": {"debian": "rsync", "alpine": "rsync", "rhel": "rsync"},
135
+ "htop": {"debian": "htop", "alpine": "htop", "rhel": "htop"},
136
+ "dash": {"debian": "dash", "alpine": "dash", "rhel": "dash"},
137
+ "ash": {"debian": "ash", "alpine": "busybox", "rhel": "busybox"},
126
138
  }
127
139
 
128
140