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.
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/CHANGELOG.md +8 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/PKG-INFO +10 -10
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/README.md +9 -9
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/README.zh-CN.md +6 -6
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/action.yml +12 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/pyproject.toml +1 -1
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/__init__.py +1 -1
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/cli.py +16 -1
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/config.py +5 -2
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/discovery.py +6 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/remediation.py +3 -1
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/runner.py +88 -22
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/test_action_contract.py +2 -2
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/test_audit_coverage.py +149 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/test_onboarding.py +1 -1
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.gitattributes +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/assets/social-preview.png +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/pull_request_template.md +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/workflow-templates/opsscript-gate.properties.json +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/workflow-templates/opsscript-gate.yml +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/workflows/demo.yml +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/workflows/deploy-pages.yml +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/workflows/release.yml +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.github/workflows/test.yml +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/.gitignore +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/CITATION.cff +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/CONTRIBUTING.md +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/LICENSE +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/ROADMAP.md +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/SECURITY.md +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/demo.html +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/command-not-found.html +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/command-not-found.md +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/configuration.md +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/gpt-oss.md +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/index.html +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/installer-smoke-test.html +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/installer-smoke-test.md +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/llms.txt +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/robots.txt +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/sitemap.xml +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/docs/troubleshooting.md +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/alpine-incompatibility/README.md +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/basic/README.md +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/basic/clean_setup.sh +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/github-actions/README.md +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/github-actions/installer-smoke.yml +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/github-actions/workflow.yml +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/interactive-hang/README.md +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/interactive-hang/prompt_confirm.sh +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/index.html +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/llms.txt +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/models.py +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/src/opsscript_gate/reporter.py +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/conftest.py +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/fixtures/fail_deps.sh +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/fixtures/fail_hang.sh +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/fixtures/pass_basic.sh +0 -0
- {opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/tests/test_installer_example.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
93
|
+
## Current release: v0.9.7
|
|
94
94
|
|
|
95
|
-
The current release includes
|
|
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
|
-
#
|
|
103
|
-
opsscript-gate run
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
60
|
+
## Current release: v0.9.7
|
|
61
61
|
|
|
62
|
-
The current release includes
|
|
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
|
-
#
|
|
70
|
-
opsscript-gate run
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
20
|
+
## 当前版本 v0.9.7
|
|
21
21
|
|
|
22
|
-
v0.9.
|
|
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.
|
|
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.
|
|
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.
|
|
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" }
|
|
@@ -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.
|
|
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, /
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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=
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{opsscript_gate-0.9.6 → opsscript_gate-0.9.7}/examples/alpine-incompatibility/apt_dependency.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|