opsscript-gate 0.9.6__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.6 → opsscript_gate-0.9.7}/CHANGELOG.md +8 -0
  2. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/PKG-INFO +10 -10
  3. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/README.md +9 -9
  4. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/README.zh-CN.md +6 -6
  5. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/action.yml +12 -0
  6. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/pyproject.toml +1 -1
  7. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/__init__.py +1 -1
  8. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/cli.py +16 -1
  9. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/config.py +5 -2
  10. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/discovery.py +6 -0
  11. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/remediation.py +3 -1
  12. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/runner.py +88 -22
  13. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/test_action_contract.py +2 -2
  14. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/test_audit_coverage.py +149 -0
  15. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/test_onboarding.py +1 -1
  16. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.gitattributes +0 -0
  17. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  18. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
  19. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  20. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/assets/social-preview.png +0 -0
  21. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/pull_request_template.md +0 -0
  22. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/workflow-templates/opsscript-gate.properties.json +0 -0
  23. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/workflow-templates/opsscript-gate.yml +0 -0
  24. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/workflows/demo.yml +0 -0
  25. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/workflows/deploy-pages.yml +0 -0
  26. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/workflows/release.yml +0 -0
  27. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/workflows/test.yml +0 -0
  28. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.gitignore +0 -0
  29. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/CITATION.cff +0 -0
  30. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/CONTRIBUTING.md +0 -0
  31. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/LICENSE +0 -0
  32. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/ROADMAP.md +0 -0
  33. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/SECURITY.md +0 -0
  34. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/demo.html +0 -0
  35. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/command-not-found.html +0 -0
  36. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/command-not-found.md +0 -0
  37. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/configuration.md +0 -0
  38. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/gpt-oss.md +0 -0
  39. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/index.html +0 -0
  40. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/installer-smoke-test.html +0 -0
  41. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/installer-smoke-test.md +0 -0
  42. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/llms.txt +0 -0
  43. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/robots.txt +0 -0
  44. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/sitemap.xml +0 -0
  45. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/troubleshooting.md +0 -0
  46. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/alpine-incompatibility/README.md +0 -0
  47. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
  48. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/basic/README.md +0 -0
  49. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/basic/clean_setup.sh +0 -0
  50. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/github-actions/README.md +0 -0
  51. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/github-actions/installer-smoke.yml +0 -0
  52. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/github-actions/workflow.yml +0 -0
  53. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/interactive-hang/README.md +0 -0
  54. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/interactive-hang/prompt_confirm.sh +0 -0
  55. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/index.html +0 -0
  56. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/llms.txt +0 -0
  57. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/models.py +0 -0
  58. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/reporter.py +0 -0
  59. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/conftest.py +0 -0
  60. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/fixtures/fail_deps.sh +0 -0
  61. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/fixtures/fail_hang.sh +0 -0
  62. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/fixtures/pass_basic.sh +0 -0
  63. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/test_installer_example.py +0 -0
  64. {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/test_runner.py +0 -0
@@ -1,5 +1,13 @@
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
+
3
11
  ## [0.9.6] - 2026-11-08
4
12
 
5
13
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opsscript-gate
3
- Version: 0.9.6
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.6
84
+ - uses: Mresyzz/opsscript-gate@v0.9.7
85
85
  with:
86
86
  script-path: install.sh
87
87
  ```
@@ -90,17 +90,17 @@ The result identifies the failing distribution, exit code, line, missing command
90
90
 
91
91
  ---
92
92
 
93
- ## Current release: v0.9.6
93
+ ## Current release: v0.9.7
94
94
 
95
- 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.
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.
96
96
 
97
97
  ```bash
98
98
  opsscript-gate init
99
99
  opsscript-gate doctor
100
100
  opsscript-gate run --dry-run
101
101
  opsscript-gate run --format json --output reports/compatibility.json
102
- # Repeat each distro three times when diagnosing intermittent failures
103
- opsscript-gate run ./install.sh --repeat 3
102
+ # Stop immediately on the first failed check
103
+ opsscript-gate run -x
104
104
  # Pass environment variables and mount repository workspace read-only
105
105
  opsscript-gate run ./install.sh --env APP_ENV=staging --workdir .
106
106
  ```
@@ -147,7 +147,7 @@ jobs:
147
147
  - uses: actions/checkout@v7
148
148
  with:
149
149
  persist-credentials: false
150
- - uses: Mresyzz/opsscript-gate@v0.9.6
150
+ - uses: Mresyzz/opsscript-gate@v0.9.7
151
151
  ```
152
152
 
153
153
  This zero-configuration form discovers shell scripts in the repository. Use
@@ -158,7 +158,7 @@ repository has a single installer. For self-hosted model installers, see the
158
158
  Or test a specific script with custom execution modes:
159
159
 
160
160
  ```yaml
161
- - uses: Mresyzz/opsscript-gate@v0.9.6
161
+ - uses: Mresyzz/opsscript-gate@v0.9.7
162
162
  with:
163
163
  script-path: scripts/install.sh
164
164
  shell: auto
@@ -192,7 +192,7 @@ unrelated scripts:
192
192
  with:
193
193
  persist-credentials: false
194
194
  fetch-depth: 0
195
- - uses: Mresyzz/opsscript-gate@v0.9.6
195
+ - uses: Mresyzz/opsscript-gate@v0.9.7
196
196
  with:
197
197
  changed-since: ${{ github.event.pull_request.base.sha }}
198
198
  preset: minimal
@@ -283,7 +283,7 @@ Use `sarif` when the result should appear in GitHub Code Scanning or another
283
283
  SARIF-compatible viewer. Upload it explicitly with the official upload action:
284
284
 
285
285
  ```yaml
286
- - uses: Mresyzz/opsscript-gate@v0.9.6
286
+ - uses: Mresyzz/opsscript-gate@v0.9.7
287
287
  with:
288
288
  script-path: scripts/install.sh
289
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.6
51
+ - uses: Mresyzz/opsscript-gate@v0.9.7
52
52
  with:
53
53
  script-path: install.sh
54
54
  ```
@@ -57,17 +57,17 @@ The result identifies the failing distribution, exit code, line, missing command
57
57
 
58
58
  ---
59
59
 
60
- ## Current release: v0.9.6
60
+ ## Current release: v0.9.7
61
61
 
62
- 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.
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.
63
63
 
64
64
  ```bash
65
65
  opsscript-gate init
66
66
  opsscript-gate doctor
67
67
  opsscript-gate run --dry-run
68
68
  opsscript-gate run --format json --output reports/compatibility.json
69
- # Repeat each distro three times when diagnosing intermittent failures
70
- opsscript-gate run ./install.sh --repeat 3
69
+ # Stop immediately on the first failed check
70
+ opsscript-gate run -x
71
71
  # Pass environment variables and mount repository workspace read-only
72
72
  opsscript-gate run ./install.sh --env APP_ENV=staging --workdir .
73
73
  ```
@@ -114,7 +114,7 @@ jobs:
114
114
  - uses: actions/checkout@v7
115
115
  with:
116
116
  persist-credentials: false
117
- - uses: Mresyzz/opsscript-gate@v0.9.6
117
+ - uses: Mresyzz/opsscript-gate@v0.9.7
118
118
  ```
119
119
 
120
120
  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
125
125
  Or test a specific script with custom execution modes:
126
126
 
127
127
  ```yaml
128
- - uses: Mresyzz/opsscript-gate@v0.9.6
128
+ - uses: Mresyzz/opsscript-gate@v0.9.7
129
129
  with:
130
130
  script-path: scripts/install.sh
131
131
  shell: auto
@@ -159,7 +159,7 @@ unrelated scripts:
159
159
  with:
160
160
  persist-credentials: false
161
161
  fetch-depth: 0
162
- - uses: Mresyzz/opsscript-gate@v0.9.6
162
+ - uses: Mresyzz/opsscript-gate@v0.9.7
163
163
  with:
164
164
  changed-since: ${{ github.event.pull_request.base.sha }}
165
165
  preset: minimal
@@ -250,7 +250,7 @@ Use `sarif` when the result should appear in GitHub Code Scanning or another
250
250
  SARIF-compatible viewer. Upload it explicitly with the official upload action:
251
251
 
252
252
  ```yaml
253
- - uses: Mresyzz/opsscript-gate@v0.9.6
253
+ - uses: Mresyzz/opsscript-gate@v0.9.7
254
254
  with:
255
255
  script-path: scripts/install.sh
256
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.6
20
+ ## 当前版本 v0.9.7
21
21
 
22
- v0.9.6 是当前已发布版本;如果你要从源码验证当前分支,也可以执行:
22
+ v0.9.7 是当前已发布版本;如果你要从源码验证当前分支,也可以执行:
23
23
 
24
24
  ```bash
25
25
  pip install -e .
@@ -33,11 +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
- # 传递环境变量并以只读方式挂载本地工作空间
37
- opsscript-gate run ./install.sh --env APP_ENV=staging --workdir .
36
+ # 开启失败即停(-x / --fail-fast),传递环境变量并只读挂载工作区
37
+ opsscript-gate run ./install.sh -x --env APP_ENV=staging --workdir .
38
38
  ```
39
39
 
40
- `init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.6`。
40
+ `init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.7`。
41
41
  预览不需要 Docker;真实运行需要 Python 3.10+
42
42
  和可访问的 Linux Docker 引擎。
43
43
  `opsscript-gate doctor` 可以在真实运行前检查 Python 和 Docker。
@@ -76,7 +76,7 @@ Docker daemon 和可选的项目配置。
76
76
  - uses: actions/checkout@v7
77
77
  with:
78
78
  fetch-depth: 0
79
- - uses: Mresyzz/opsscript-gate@v0.9.6
79
+ - uses: Mresyzz/opsscript-gate@v0.9.7
80
80
  with:
81
81
  changed-since: ${{ github.event.pull_request.base.sha }}
82
82
  preset: minimal
@@ -79,6 +79,10 @@ inputs:
79
79
  description: 'Preview the execution plan without Docker'
80
80
  required: false
81
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'
82
86
  output:
83
87
  description: 'Save a report file for upload-artifact or other tooling'
84
88
  required: false
@@ -131,6 +135,7 @@ runs:
131
135
  INPUT_EXCLUDE: ${{ inputs.exclude }}
132
136
  INPUT_MAX_SCRIPTS: ${{ inputs.max-scripts }}
133
137
  INPUT_DRY_RUN: ${{ inputs.dry-run }}
138
+ INPUT_FAIL_FAST: ${{ inputs.fail-fast }}
134
139
  INPUT_OUTPUT: ${{ inputs.output }}
135
140
  INPUT_CHANGED_SINCE: ${{ inputs.changed-since }}
136
141
  run: |
@@ -163,6 +168,13 @@ runs:
163
168
  pattern="${pattern%$'\r'}"
164
169
  [ -z "$pattern" ] || ARGS+=("--exclude" "$pattern")
165
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
166
178
  DRY_RUN_VALUE="$(printf '%s' "$INPUT_DRY_RUN" | tr '[:upper:]' '[:lower:]')"
167
179
  if [ "$DRY_RUN_VALUE" = "true" ]; then
168
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.6"
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.6"
3
+ __version__ = "0.9.7"
4
4
  __all__ = ["__version__"]
@@ -169,6 +169,12 @@ def build_parser() -> argparse.ArgumentParser:
169
169
  default=None,
170
170
  help="Host directory to mount read-only into /workspace as the container working directory",
171
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
+ )
172
178
  run_parser.add_argument(
173
179
  "--changed-since",
174
180
  help="Only test shell scripts changed between this Git revision and HEAD",
@@ -251,6 +257,7 @@ def run_checked_matrix(
251
257
  repeat=args.repeat,
252
258
  env=args.env,
253
259
  workdir=args.workdir,
260
+ fail_fast=getattr(args, "fail_fast", False),
254
261
  )
255
262
  except DockerDaemonError as err:
256
263
  sys.stderr.write(f"Docker Error: {err}\n")
@@ -350,6 +357,8 @@ def main(argv: list[str] | None = None) -> int:
350
357
  explicit = {token.split("=", 1)[0] for token in argv if token.startswith("--")}
351
358
  if any(token.startswith("-j") for token in argv):
352
359
  explicit.add("--jobs")
360
+ if any(token == "-x" or token.startswith("-x") for token in argv):
361
+ explicit.add("--fail-fast")
353
362
  for key in DEFAULTS:
354
363
  if key in config and "--" + key.replace("_", "-") not in explicit:
355
364
  setattr(args, key, config[key])
@@ -446,11 +455,13 @@ def main(argv: list[str] | None = None) -> int:
446
455
  plan = {"dry_run": True, "scripts": target_scripts, "matrix": matrix,
447
456
  "executions": len(target_scripts) * len(matrix) * args.repeat, "shell": args.shell,
448
457
  "network": args.network, "packages": args.packages, "timeout": args.timeout,
449
- "repeat": args.repeat, "env": args.env, "workdir": args.workdir}
458
+ "repeat": args.repeat, "env": args.env, "workdir": args.workdir,
459
+ "fail_fast": args.fail_fast}
450
460
  if changed_selection:
451
461
  plan["changed_since"] = args.changed_since
452
462
  env_preview = f"\nEnv: {', '.join(f'{k}={v}' for k, v in args.env.items())}" if args.env else ""
453
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 ""
454
465
  output = json.dumps(plan, indent=2) if args.format == "json" else (
455
466
  "Execution preview (no scripts executed)\n"
456
467
  + "\n".join(f" {path}" for path in target_scripts)
@@ -458,6 +469,7 @@ def main(argv: list[str] | None = None) -> int:
458
469
  + (f"\nPackages: {', '.join(args.packages)}" if args.packages else "")
459
470
  + env_preview
460
471
  + workdir_preview
472
+ + fail_fast_preview
461
473
  + f"\nShell: {args.shell} | Network: {args.network} | Timeout: {args.timeout}s | Repeat: {args.repeat}"
462
474
  )
463
475
  return 0 if publish_output(output, args.output) else 1
@@ -520,6 +532,9 @@ def main(argv: list[str] | None = None) -> int:
520
532
  if args.format not in ("json", "sarif"):
521
533
  emit_github_annotations(report, script_path=script_path)
522
534
 
535
+ if args.fail_fast and not report.all_passed:
536
+ break
537
+
523
538
  total_duration = time.perf_counter() - multi_start
524
539
  all_passed = all(r.all_passed for r in multi_reports.values())
525
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, "env": {}, "workdir": None,
20
+ "repeat": 1, "env": {}, "workdir": None, "fail_fast": False,
21
21
  }
22
22
 
23
23
  MAX_CONFIG_SIZE_BYTES = 256 * 1024
@@ -130,6 +130,9 @@ def load_config(filename: str | None) -> dict:
130
130
  validate_env(value)
131
131
  elif key == "workdir":
132
132
  validate_workdir(value)
133
+ elif key == "fail_fast":
134
+ if not isinstance(value, bool):
135
+ raise ValueError("fail_fast must be a boolean")
133
136
  elif not isinstance(value, str) or not value.strip():
134
137
  raise ValueError(f"{key} must be a nonempty string")
135
138
  for key, choices in {"shell": ("posix", "auto", "shebang"),
@@ -163,7 +166,7 @@ jobs:
163
166
  - uses: actions/checkout@v7
164
167
  with:
165
168
  persist-credentials: false
166
- - uses: Mresyzz/opsscript-gate@v0.9.6
169
+ - uses: Mresyzz/opsscript-gate@v0.9.7
167
170
  with:
168
171
  config: .opsscript-gate.json
169
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
 
@@ -133,6 +133,8 @@ COMMON_COMMAND_PACKAGES: dict[str, dict[str, str]] = {
133
133
  "ssh": {"debian": "openssh-client", "alpine": "openssh-client", "rhel": "openssh-clients"},
134
134
  "rsync": {"debian": "rsync", "alpine": "rsync", "rhel": "rsync"},
135
135
  "htop": {"debian": "htop", "alpine": "htop", "rhel": "htop"},
136
+ "dash": {"debian": "dash", "alpine": "dash", "rhel": "dash"},
137
+ "ash": {"debian": "ash", "alpine": "busybox", "rhel": "busybox"},
136
138
  }
137
139
 
138
140
 
@@ -55,6 +55,12 @@ SUPPORTED_SHEBANG_COMMANDS: dict[str, list[str]] = {
55
55
  "/usr/bin/bash": ["/bin/sh", "-c", "/usr/bin/bash /tmp/target_script.sh </dev/null"],
56
56
  "/usr/bin/env sh": ["/bin/sh", "-c", "/usr/bin/env sh /tmp/target_script.sh </dev/null"],
57
57
  "/usr/bin/env bash": ["/bin/sh", "-c", "/usr/bin/env bash /tmp/target_script.sh </dev/null"],
58
+ "/bin/dash": ["/bin/sh", "-c", "/bin/dash /tmp/target_script.sh </dev/null"],
59
+ "/usr/bin/dash": ["/bin/sh", "-c", "/usr/bin/dash /tmp/target_script.sh </dev/null"],
60
+ "/usr/bin/env dash": ["/bin/sh", "-c", "/usr/bin/env dash /tmp/target_script.sh </dev/null"],
61
+ "/bin/ash": ["/bin/sh", "-c", "/bin/ash /tmp/target_script.sh </dev/null"],
62
+ "/usr/bin/ash": ["/bin/sh", "-c", "/usr/bin/ash /tmp/target_script.sh </dev/null"],
63
+ "/usr/bin/env ash": ["/bin/sh", "-c", "/usr/bin/env ash /tmp/target_script.sh </dev/null"],
58
64
  }
59
65
 
60
66
  DEFAULT_POSIX_COMMAND: list[str] = ["/bin/sh", "-c", "/bin/sh /tmp/target_script.sh </dev/null"]
@@ -488,11 +494,25 @@ def inspect_shebang(script_path: str) -> ShebangParseResult:
488
494
  interpreter="bash",
489
495
  raw_shebang=raw_shebang,
490
496
  )
497
+ elif sub_cmd == "dash":
498
+ return ShebangParseResult(
499
+ status=ShebangStatus.RECOGNIZED,
500
+ command_key="/usr/bin/env dash",
501
+ interpreter="dash",
502
+ raw_shebang=raw_shebang,
503
+ )
504
+ elif sub_cmd == "ash":
505
+ return ShebangParseResult(
506
+ status=ShebangStatus.RECOGNIZED,
507
+ command_key="/usr/bin/env ash",
508
+ interpreter="ash",
509
+ raw_shebang=raw_shebang,
510
+ )
491
511
  else:
492
512
  return ShebangParseResult(
493
513
  status=ShebangStatus.UNSUPPORTED,
494
514
  raw_shebang=raw_shebang,
495
- error_message=f"Unsupported shebang interpreter: '{clean_shebang}' (supported: sh, bash)",
515
+ error_message=f"Unsupported shebang interpreter: '{clean_shebang}' (supported: sh, bash, dash, ash)",
496
516
  )
497
517
  else:
498
518
  # Reject complex env forms (e.g. env -S bash) or additional arguments
@@ -502,7 +522,7 @@ def inspect_shebang(script_path: str) -> ShebangParseResult:
502
522
  error_message=f"Unsupported complex env shebang form: '{clean_shebang}'",
503
523
  )
504
524
 
505
- # Direct interpreters (e.g. /bin/sh, /bin/bash, /usr/bin/sh, /usr/bin/bash)
525
+ # Direct interpreters (e.g. /bin/sh, /bin/bash, /bin/dash, /bin/ash)
506
526
  if cmd in ("/bin/sh", "/usr/bin/sh"):
507
527
  if len(tokens) > 1:
508
528
  return ShebangParseResult(
@@ -529,11 +549,37 @@ def inspect_shebang(script_path: str) -> ShebangParseResult:
529
549
  interpreter="bash",
530
550
  raw_shebang=raw_shebang,
531
551
  )
552
+ elif cmd in ("/bin/dash", "/usr/bin/dash"):
553
+ if len(tokens) > 1:
554
+ return ShebangParseResult(
555
+ status=ShebangStatus.UNSUPPORTED,
556
+ raw_shebang=raw_shebang,
557
+ error_message=f"Unsupported shebang arguments in '{clean_shebang}'",
558
+ )
559
+ return ShebangParseResult(
560
+ status=ShebangStatus.RECOGNIZED,
561
+ command_key=cmd,
562
+ interpreter="dash",
563
+ raw_shebang=raw_shebang,
564
+ )
565
+ elif cmd in ("/bin/ash", "/usr/bin/ash"):
566
+ if len(tokens) > 1:
567
+ return ShebangParseResult(
568
+ status=ShebangStatus.UNSUPPORTED,
569
+ raw_shebang=raw_shebang,
570
+ error_message=f"Unsupported shebang arguments in '{clean_shebang}'",
571
+ )
572
+ return ShebangParseResult(
573
+ status=ShebangStatus.RECOGNIZED,
574
+ command_key=cmd,
575
+ interpreter="ash",
576
+ raw_shebang=raw_shebang,
577
+ )
532
578
  else:
533
579
  return ShebangParseResult(
534
580
  status=ShebangStatus.UNSUPPORTED,
535
581
  raw_shebang=raw_shebang,
536
- error_message=f"Unsupported shebang interpreter: '{clean_shebang}' (supported: sh, bash)",
582
+ error_message=f"Unsupported shebang interpreter: '{clean_shebang}' (supported: sh, bash, dash, ash)",
537
583
  )
538
584
 
539
585
 
@@ -987,6 +1033,29 @@ def run_on_distro(
987
1033
 
988
1034
  try:
989
1035
  safe_mount_src = normalize_host_path_for_docker(mount_src)
1036
+ target_bind_path = "/tmp/target_script.sh"
1037
+ volumes = {}
1038
+ if workdir is not None:
1039
+ abs_workdir = os.path.abspath(workdir)
1040
+ safe_workdir = normalize_host_path_for_docker(abs_workdir)
1041
+ volumes[safe_workdir] = {
1042
+ "bind": "/workspace",
1043
+ "mode": "ro",
1044
+ }
1045
+ try:
1046
+ rel_script = os.path.relpath(abs_script, abs_workdir)
1047
+ except ValueError:
1048
+ rel_script = None
1049
+ if rel_script is not None and not rel_script.startswith("..") and not os.path.isabs(rel_script):
1050
+ posix_rel = rel_script.replace("\\", "/")
1051
+ target_bind_path = f"/workspace/{posix_rel}"
1052
+ command = [part.replace("/tmp/target_script.sh", target_bind_path) for part in command]
1053
+
1054
+ volumes[safe_mount_src] = {
1055
+ "bind": target_bind_path,
1056
+ "mode": "ro",
1057
+ }
1058
+
990
1059
  try:
991
1060
  container_command = _wrap_with_package_setup(command, package_list)
992
1061
  except (TypeError, ValueError) as exc:
@@ -999,20 +1068,6 @@ def run_on_distro(
999
1068
  error_message=str(exc),
1000
1069
  )
1001
1070
 
1002
- # Security: Strict unprivileged options and read-only mount
1003
- volumes = {
1004
- safe_mount_src: {
1005
- "bind": "/tmp/target_script.sh",
1006
- "mode": "ro",
1007
- }
1008
- }
1009
- if workdir is not None:
1010
- safe_workdir = normalize_host_path_for_docker(os.path.abspath(workdir))
1011
- volumes[safe_workdir] = {
1012
- "bind": "/workspace",
1013
- "mode": "ro",
1014
- }
1015
-
1016
1071
  environment = {
1017
1072
  "DEBIAN_FRONTEND": "noninteractive",
1018
1073
  "CI": "true",
@@ -1220,6 +1275,7 @@ def run_matrix(
1220
1275
  repeat: int = 1,
1221
1276
  env: dict[str, str] | None = None,
1222
1277
  workdir: str | None = None,
1278
+ fail_fast: bool = False,
1223
1279
  ) -> RunReport:
1224
1280
  """
1225
1281
  Run the compatibility check across all specified Linux distributions,
@@ -1319,7 +1375,7 @@ def run_matrix(
1319
1375
  def _worker(index: int, distro_name: str) -> tuple[int, SingleResult]:
1320
1376
  attempts: list[SingleResult] = []
1321
1377
  for _ in range(repeat):
1322
- attempts.append(run_on_distro(
1378
+ res_attempt = run_on_distro(
1323
1379
  client=docker_client,
1324
1380
  script_path=script_path,
1325
1381
  distro=distro_name,
@@ -1333,9 +1389,12 @@ def run_matrix(
1333
1389
  prepared_script_path=prepared_script,
1334
1390
  env=env,
1335
1391
  workdir=workdir,
1336
- ))
1392
+ )
1393
+ attempts.append(res_attempt)
1394
+ if fail_fast and res_attempt.status != DistroStatus.PASS:
1395
+ break
1337
1396
 
1338
- if repeat == 1:
1397
+ if len(attempts) == 1:
1339
1398
  return index, attempts[0]
1340
1399
 
1341
1400
  passed = sum(result.status == DistroStatus.PASS for result in attempts)
@@ -1380,6 +1439,8 @@ def run_matrix(
1380
1439
  for idx, distro_name in enumerate(distro_list):
1381
1440
  _, res = _worker(idx, distro_name)
1382
1441
  results[idx] = res
1442
+ if fail_fast and res.status != DistroStatus.PASS:
1443
+ break
1383
1444
  else:
1384
1445
  with ThreadPoolExecutor(max_workers=effective_jobs) as executor:
1385
1446
  futures = [
@@ -1389,6 +1450,10 @@ def run_matrix(
1389
1450
  for fut in as_completed(futures):
1390
1451
  idx, res = fut.result()
1391
1452
  results[idx] = res
1453
+ if fail_fast and res.status != DistroStatus.PASS:
1454
+ for remaining in futures:
1455
+ remaining.cancel()
1456
+ break
1392
1457
  finally:
1393
1458
  # Clean up temporary CRLF normalized file exactly once after all matrix workers complete
1394
1459
  if shared_temp_file is not None:
@@ -1399,10 +1464,11 @@ def run_matrix(
1399
1464
  pass
1400
1465
 
1401
1466
  total_duration = time.perf_counter() - start_total
1402
- all_passed = all(r.status == DistroStatus.PASS for r in results) if results else True
1467
+ actual_results = [r for r in results if r is not None]
1468
+ all_passed = all(r.status == DistroStatus.PASS for r in actual_results) if actual_results else True
1403
1469
 
1404
1470
  return RunReport(
1405
- results=results,
1471
+ results=actual_results,
1406
1472
  total_duration=total_duration,
1407
1473
  all_passed=all_passed,
1408
1474
  )
@@ -9,12 +9,12 @@ ROOT = Path(__file__).parents[1]
9
9
  def test_action_metadata_is_valid_and_inputs_are_wired():
10
10
  text = (ROOT / "action.yml").read_text(encoding="utf-8")
11
11
  assert "using: 'composite'" in text
12
- for name in ("config", "preset", "exclude", "max-scripts", "dry-run", "output", "changed-since", "packages", "repeat", "env", "workdir"):
12
+ for name in ("config", "preset", "exclude", "max-scripts", "dry-run", "output", "changed-since", "packages", "repeat", "env", "workdir", "fail-fast"):
13
13
  assert f" {name}:" in text
14
14
  run_text = text
15
15
  for flag in ("--timeout", "--format", "--shell", "--mem-limit", "--pids-limit",
16
16
  "--network", "--packages", "--matrix", "--jobs", "--config", "--preset", "--repeat",
17
- "--max-scripts", "--output", "--exclude", "--env", "--workdir"):
17
+ "--max-scripts", "--output", "--exclude", "--env", "--workdir", "--fail-fast"):
18
18
  assert flag in run_text
19
19
  assert "json, or sarif" in text
20
20
  assert "--changed-since" in run_text
@@ -927,3 +927,152 @@ def test_expanded_remediation_packages():
927
927
  assert pkg_alp == alp_expected, f"Failed for alpine {cmd}"
928
928
 
929
929
 
930
+ # ==============================================================================
931
+ # 12. Fail-Fast 快速失败与 dash / ash 原生解释器专项测试 (v0.9.7)
932
+ # ==============================================================================
933
+
934
+ def test_shebang_dash_and_ash_support(tmp_path):
935
+ from opsscript_gate.runner import inspect_shebang, ShebangStatus, _resolve_shell_command, ShellMode
936
+
937
+ # 1. 直接 /bin/dash 与 /usr/bin/dash
938
+ dash_script = tmp_path / "dash_script"
939
+ dash_script.write_text("#!/bin/dash\necho hello\n")
940
+ res_dash = inspect_shebang(str(dash_script))
941
+ assert res_dash.status == ShebangStatus.RECOGNIZED
942
+ assert res_dash.interpreter == "dash"
943
+ assert res_dash.command_key == "/bin/dash"
944
+ cmd_dash, err_dash = _resolve_shell_command(ShellMode.SHEBANG, res_dash)
945
+ assert err_dash is None
946
+ assert "/bin/dash /tmp/target_script.sh </dev/null" in cmd_dash[2]
947
+
948
+ # 2. 直接 /bin/ash 与 /usr/bin/env ash
949
+ ash_script = tmp_path / "ash_script"
950
+ ash_script.write_text("#!/usr/bin/env ash\necho hello\n")
951
+ res_ash = inspect_shebang(str(ash_script))
952
+ assert res_ash.status == ShebangStatus.RECOGNIZED
953
+ assert res_ash.interpreter == "ash"
954
+ assert res_ash.command_key == "/usr/bin/env ash"
955
+ cmd_ash, err_ash = _resolve_shell_command(ShellMode.SHEBANG, res_ash)
956
+ assert err_ash is None
957
+ assert "/usr/bin/env ash /tmp/target_script.sh </dev/null" in cmd_ash[2]
958
+
959
+
960
+ def test_workdir_relative_script_mount_and_command(tmp_path):
961
+ from opsscript_gate.runner import run_on_distro
962
+ from unittest.mock import MagicMock
963
+
964
+ ws = tmp_path / "repo_root"
965
+ ws.mkdir()
966
+ sub_dir = ws / "scripts" / "nested"
967
+ sub_dir.mkdir(parents=True)
968
+ script_file = sub_dir / "setup.sh"
969
+ script_file.write_text("#!/bin/sh\necho test\n")
970
+
971
+ mock_client = MagicMock()
972
+ mock_container = MagicMock()
973
+ mock_container.wait.return_value = {"StatusCode": 0}
974
+ mock_container.logs.return_value = b"test\n"
975
+ mock_container.attrs = {"State": {"ExitCode": 0}}
976
+ mock_client.containers.create.return_value = mock_container
977
+
978
+ res = run_on_distro(
979
+ client=mock_client,
980
+ script_path=str(script_file),
981
+ distro="debian:12-slim",
982
+ workdir=str(ws),
983
+ )
984
+ assert res.status.value == "PASS"
985
+
986
+ create_call = mock_client.containers.create.call_args[1]
987
+ # 容器命令中的脚本路径应自动映射至 /workspace/scripts/nested/setup.sh
988
+ cmd_str = create_call["command"][2]
989
+ assert "/workspace/scripts/nested/setup.sh" in cmd_str
990
+
991
+ # 验证 volume bind 路径亦为 /workspace/scripts/nested/setup.sh
992
+ bind_targets = [v["bind"] for v in create_call["volumes"].values()]
993
+ assert "/workspace" in bind_targets
994
+ assert "/workspace/scripts/nested/setup.sh" in bind_targets
995
+
996
+
997
+ def test_fail_fast_multi_script(tmp_path, capsys):
998
+ from opsscript_gate.cli import main
999
+ from unittest.mock import patch
1000
+ from opsscript_gate.models import RunReport, SingleResult, DistroStatus
1001
+
1002
+ s1 = tmp_path / "a_fail.sh"
1003
+ s1.write_text("#!/bin/sh\nexit 1\n")
1004
+ s2 = tmp_path / "b_pass.sh"
1005
+ s2.write_text("#!/bin/sh\nexit 0\n")
1006
+
1007
+ executed_scripts = []
1008
+
1009
+ def fake_run_checked_matrix(args, script_path, matrix):
1010
+ executed_scripts.append(script_path)
1011
+ if "a_fail" in script_path:
1012
+ return RunReport([SingleResult("debian:12-slim", DistroStatus.FAIL, 1, 0.1)])
1013
+ return RunReport([SingleResult("debian:12-slim", DistroStatus.PASS, 0, 0.1)])
1014
+
1015
+ with patch("opsscript_gate.cli.discover_scripts", return_value=[str(s1), str(s2)]):
1016
+ with patch("opsscript_gate.cli.run_checked_matrix", side_effect=fake_run_checked_matrix):
1017
+ # 开启 fail-fast:第 1 个脚本失败后,第 2 个脚本不应被执行
1018
+ code = main(["run", "--fail-fast"])
1019
+ assert code == 1
1020
+ assert len(executed_scripts) == 1
1021
+ assert executed_scripts[0] == str(s1)
1022
+
1023
+
1024
+ def test_fail_fast_matrix_repeat(tmp_path):
1025
+ from opsscript_gate.runner import run_matrix
1026
+ from unittest.mock import MagicMock
1027
+
1028
+ script = tmp_path / "test.sh"
1029
+ script.write_text("#!/bin/sh\n")
1030
+
1031
+ mock_client = MagicMock()
1032
+ mock_container = MagicMock()
1033
+ # 模拟第一次执行就失败
1034
+ mock_container.wait.return_value = {"StatusCode": 1}
1035
+ mock_container.logs.return_value = b"failed\n"
1036
+ mock_container.attrs = {"State": {"ExitCode": 1}}
1037
+ mock_client.containers.create.return_value = mock_container
1038
+
1039
+ # 设置 repeat=5, jobs=1, fail_fast=True
1040
+ report = run_matrix(
1041
+ script_path=str(script),
1042
+ matrix=["debian:12-slim"],
1043
+ client=mock_client,
1044
+ repeat=5,
1045
+ jobs=1,
1046
+ fail_fast=True,
1047
+ )
1048
+ assert report.all_passed is False
1049
+ # fail_fast 生效:containers.create 仅被调用 1 次,而非 5 次
1050
+ assert mock_client.containers.create.call_count == 1
1051
+
1052
+
1053
+ def test_config_fail_fast_setting(tmp_path, capsys):
1054
+ from opsscript_gate.cli import main
1055
+ import json
1056
+
1057
+ script = tmp_path / "script.sh"
1058
+ script.write_text("#!/bin/sh\n")
1059
+
1060
+ cfg_file = tmp_path / ".opsscript-gate.json"
1061
+ cfg_file.write_text(json.dumps({
1062
+ "fail_fast": True,
1063
+ }))
1064
+
1065
+ code = main([
1066
+ "run",
1067
+ str(script),
1068
+ "--config", str(cfg_file),
1069
+ "--dry-run",
1070
+ "--format", "json",
1071
+ ])
1072
+ assert code == 0
1073
+ captured = capsys.readouterr().out
1074
+ plan = json.loads(captured)
1075
+ assert plan["fail_fast"] is True
1076
+
1077
+
1078
+
@@ -83,7 +83,7 @@ def test_init_existing_workflow_leaves_config_absent(project):
83
83
  {"matrix": ","}, {"mem_limit": "0"}, {"mem_limit": "unlimited"}, {"repeat": 21}, {"timeout": 3601},
84
84
  {"packages": "curl"}, {"packages": ["curl; touch pwned"]},
85
85
  {"env": "not_a_dict"}, {"env": {"1_BAD": "val"}}, {"env": {"OK": 123}},
86
- {"workdir": 123}, {"workdir": ""}])
86
+ {"workdir": 123}, {"workdir": ""}, {"fail_fast": "yes"}, {"fail_fast": 1}])
87
87
  def test_invalid_config(project, config):
88
88
  Path(CONFIG_NAME).write_text(json.dumps(config))
89
89
  with patch("opsscript_gate.cli.run_matrix") as run:
File without changes
File without changes
File without changes