opsscript-gate 0.9.0__tar.gz → 0.9.1__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.0 → opsscript_gate-0.9.1}/CHANGELOG.md +9 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/PKG-INFO +7 -7
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/README.md +6 -6
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/pyproject.toml +1 -1
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/__init__.py +1 -1
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/cli.py +21 -5
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/config.py +1 -1
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/discovery.py +12 -2
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/reporter.py +4 -4
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/runner.py +74 -9
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/tests/test_onboarding.py +18 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/tests/test_runner.py +72 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.gitattributes +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/assets/social-preview.png +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/pull_request_template.md +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/workflow-templates/opsscript-gate.properties.json +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/workflow-templates/opsscript-gate.yml +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/workflows/demo.yml +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/workflows/deploy-pages.yml +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/workflows/release.yml +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/workflows/test.yml +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.gitignore +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/CITATION.cff +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/CONTRIBUTING.md +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/LICENSE +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/README.zh-CN.md +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/ROADMAP.md +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/SECURITY.md +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/action.yml +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/demo.html +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/command-not-found.html +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/command-not-found.md +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/configuration.md +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/gpt-oss.md +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/index.html +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/llms.txt +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/sitemap.xml +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/troubleshooting.md +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/alpine-incompatibility/README.md +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/basic/README.md +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/basic/clean_setup.sh +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/github-actions/README.md +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/github-actions/workflow.yml +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/interactive-hang/README.md +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/interactive-hang/prompt_confirm.sh +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/index.html +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/llms.txt +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/models.py +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/remediation.py +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/tests/fixtures/fail_deps.sh +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/tests/fixtures/fail_hang.sh +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/tests/fixtures/pass_basic.sh +0 -0
- {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/tests/test_action_contract.py +0 -0
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.1] - 2026-10-03
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Normalized discovery exclusions so top-level paths match with or without `./`.
|
|
7
|
+
- Made SARIF output conform to the 2.1.0 result schema.
|
|
8
|
+
- Failed closed when Docker omitted an exit code and separated package setup errors from script failures.
|
|
9
|
+
- Validated project configuration in `doctor` and handled UTF-8 BOM scripts.
|
|
10
|
+
- Distinguished Docker wait API errors from genuine execution timeouts.
|
|
11
|
+
|
|
3
12
|
## [0.9.0] - 2026-10-03
|
|
4
13
|
|
|
5
14
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: opsscript-gate
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Cross-distro shell script compatibility checker for runtime testing in Debian, Ubuntu and Alpine
|
|
5
5
|
Project-URL: Homepage, https://mresyzz.github.io/opsscript-gate/
|
|
6
6
|
Project-URL: Demo, https://mresyzz.github.io/opsscript-gate/
|
|
@@ -79,7 +79,7 @@ apt-get --version
|
|
|
79
79
|
Run it locally with `opsscript-gate run ./install.sh`, or add the following step to CI:
|
|
80
80
|
|
|
81
81
|
```yaml
|
|
82
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
82
|
+
- uses: Mresyzz/opsscript-gate@v0.9.1
|
|
83
83
|
with:
|
|
84
84
|
script-path: install.sh
|
|
85
85
|
```
|
|
@@ -88,7 +88,7 @@ The result identifies the failing distribution, exit code, line, missing command
|
|
|
88
88
|
|
|
89
89
|
---
|
|
90
90
|
|
|
91
|
-
## Current release: v0.9.
|
|
91
|
+
## Current release: v0.9.1
|
|
92
92
|
|
|
93
93
|
The current release includes a project config file, a dry-run plan, presets, exclusions,
|
|
94
94
|
saved reports, changed-script selection for pull requests, and repeat runs for detecting
|
|
@@ -142,7 +142,7 @@ jobs:
|
|
|
142
142
|
- uses: actions/checkout@v7
|
|
143
143
|
with:
|
|
144
144
|
persist-credentials: false
|
|
145
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
145
|
+
- uses: Mresyzz/opsscript-gate@v0.9.1
|
|
146
146
|
```
|
|
147
147
|
|
|
148
148
|
This zero-configuration form discovers shell scripts in the repository. Use
|
|
@@ -153,7 +153,7 @@ repository has a single installer. For self-hosted model installers, see the
|
|
|
153
153
|
Or test a specific script with custom execution modes:
|
|
154
154
|
|
|
155
155
|
```yaml
|
|
156
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
156
|
+
- uses: Mresyzz/opsscript-gate@v0.9.1
|
|
157
157
|
with:
|
|
158
158
|
script-path: scripts/install.sh
|
|
159
159
|
shell: auto
|
|
@@ -187,7 +187,7 @@ unrelated scripts:
|
|
|
187
187
|
with:
|
|
188
188
|
persist-credentials: false
|
|
189
189
|
fetch-depth: 0
|
|
190
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
190
|
+
- uses: Mresyzz/opsscript-gate@v0.9.1
|
|
191
191
|
with:
|
|
192
192
|
changed-since: ${{ github.event.pull_request.base.sha }}
|
|
193
193
|
preset: minimal
|
|
@@ -278,7 +278,7 @@ Use `sarif` when the result should appear in GitHub Code Scanning or another
|
|
|
278
278
|
SARIF-compatible viewer. Upload it explicitly with the official upload action:
|
|
279
279
|
|
|
280
280
|
```yaml
|
|
281
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
281
|
+
- uses: Mresyzz/opsscript-gate@v0.9.1
|
|
282
282
|
with:
|
|
283
283
|
script-path: scripts/install.sh
|
|
284
284
|
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.1
|
|
52
52
|
with:
|
|
53
53
|
script-path: install.sh
|
|
54
54
|
```
|
|
@@ -57,7 +57,7 @@ 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.1
|
|
61
61
|
|
|
62
62
|
The current release includes a project config file, a dry-run plan, presets, exclusions,
|
|
63
63
|
saved reports, changed-script selection for pull requests, and repeat runs for detecting
|
|
@@ -111,7 +111,7 @@ jobs:
|
|
|
111
111
|
- uses: actions/checkout@v7
|
|
112
112
|
with:
|
|
113
113
|
persist-credentials: false
|
|
114
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
114
|
+
- uses: Mresyzz/opsscript-gate@v0.9.1
|
|
115
115
|
```
|
|
116
116
|
|
|
117
117
|
This zero-configuration form discovers shell scripts in the repository. Use
|
|
@@ -122,7 +122,7 @@ repository has a single installer. For self-hosted model installers, see the
|
|
|
122
122
|
Or test a specific script with custom execution modes:
|
|
123
123
|
|
|
124
124
|
```yaml
|
|
125
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
125
|
+
- uses: Mresyzz/opsscript-gate@v0.9.1
|
|
126
126
|
with:
|
|
127
127
|
script-path: scripts/install.sh
|
|
128
128
|
shell: auto
|
|
@@ -156,7 +156,7 @@ unrelated scripts:
|
|
|
156
156
|
with:
|
|
157
157
|
persist-credentials: false
|
|
158
158
|
fetch-depth: 0
|
|
159
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
159
|
+
- uses: Mresyzz/opsscript-gate@v0.9.1
|
|
160
160
|
with:
|
|
161
161
|
changed-since: ${{ github.event.pull_request.base.sha }}
|
|
162
162
|
preset: minimal
|
|
@@ -247,7 +247,7 @@ Use `sarif` when the result should appear in GitHub Code Scanning or another
|
|
|
247
247
|
SARIF-compatible viewer. Upload it explicitly with the official upload action:
|
|
248
248
|
|
|
249
249
|
```yaml
|
|
250
|
-
- uses: Mresyzz/opsscript-gate@v0.9.
|
|
250
|
+
- uses: Mresyzz/opsscript-gate@v0.9.1
|
|
251
251
|
with:
|
|
252
252
|
script-path: scripts/install.sh
|
|
253
253
|
format: sarif
|
|
@@ -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.1"
|
|
8
8
|
description = "Cross-distro shell script compatibility checker for runtime testing in Debian, Ubuntu and Alpine"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -226,11 +226,27 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
226
226
|
checks.append({"name": "docker", "ok": False, "detail": str(err)})
|
|
227
227
|
|
|
228
228
|
config_path = Path(".opsscript-gate.json")
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
229
|
+
if not config_path.is_file():
|
|
230
|
+
checks.append({
|
|
231
|
+
"name": "project",
|
|
232
|
+
"ok": True,
|
|
233
|
+
"detail": "No project config (optional)",
|
|
234
|
+
})
|
|
235
|
+
else:
|
|
236
|
+
try:
|
|
237
|
+
load_config(str(config_path))
|
|
238
|
+
except (OSError, ValueError) as err:
|
|
239
|
+
checks.append({
|
|
240
|
+
"name": "project",
|
|
241
|
+
"ok": False,
|
|
242
|
+
"detail": f"Invalid {config_path}: {err}",
|
|
243
|
+
})
|
|
244
|
+
else:
|
|
245
|
+
checks.append({
|
|
246
|
+
"name": "project",
|
|
247
|
+
"ok": True,
|
|
248
|
+
"detail": f"Found and validated {config_path}",
|
|
249
|
+
})
|
|
234
250
|
passed = all(check["ok"] for check in checks)
|
|
235
251
|
if args.format == "json":
|
|
236
252
|
print(json.dumps({"status": "PASS" if passed else "FAIL", "checks": checks}, indent=2))
|
|
@@ -40,6 +40,16 @@ MAX_DISCOVERED_SCRIPTS = 20
|
|
|
40
40
|
MAX_SCRIPT_SIZE_BYTES = 1024 * 1024 # 1 MB
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def _matches_exclude(rel_path: str, patterns: list[str] | None) -> bool:
|
|
44
|
+
"""Match exclusions consistently whether callers use ``foo.sh`` or ``./foo.sh``."""
|
|
45
|
+
normalized_path = rel_path[2:] if rel_path.startswith("./") else rel_path
|
|
46
|
+
for pattern in patterns or []:
|
|
47
|
+
normalized_pattern = pattern[2:] if pattern.startswith("./") else pattern
|
|
48
|
+
if fnmatch.fnmatchcase(normalized_path, normalized_pattern):
|
|
49
|
+
return True
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
43
53
|
def is_shell_script(file_path: Path, max_size_bytes: int = MAX_SCRIPT_SIZE_BYTES) -> bool:
|
|
44
54
|
"""
|
|
45
55
|
Check if a file is a candidate shell script:
|
|
@@ -101,7 +111,7 @@ def discover_scripts(
|
|
|
101
111
|
if is_shell_script(file_path, max_size_bytes=max_size_bytes):
|
|
102
112
|
try:
|
|
103
113
|
rel_path = file_path.relative_to(root_path).as_posix()
|
|
104
|
-
if
|
|
114
|
+
if _matches_exclude(rel_path, exclude):
|
|
105
115
|
continue
|
|
106
116
|
# Prepend ./ if top-level for standard script path conventions
|
|
107
117
|
if not rel_path.startswith("./") and "/" not in rel_path:
|
|
@@ -168,7 +178,7 @@ def discover_changed_scripts(
|
|
|
168
178
|
rel_path = resolved.relative_to(root_path).as_posix()
|
|
169
179
|
except (OSError, ValueError):
|
|
170
180
|
continue
|
|
171
|
-
if
|
|
181
|
+
if _matches_exclude(rel_path, exclude):
|
|
172
182
|
continue
|
|
173
183
|
if not is_shell_script(candidate, max_size_bytes=max_size_bytes):
|
|
174
184
|
continue
|
|
@@ -533,10 +533,6 @@ def _sarif_result(result: SingleResult, script_path: str) -> dict[str, object] |
|
|
|
533
533
|
"message": {"text": _sarif_message(result)},
|
|
534
534
|
"locations": [{"physicalLocation": physical_location}],
|
|
535
535
|
"properties": properties,
|
|
536
|
-
"help": {
|
|
537
|
-
"text": rule_name,
|
|
538
|
-
"uri": "https://github.com/Mresyzz/opsscript-gate#ci-output",
|
|
539
|
-
},
|
|
540
536
|
}
|
|
541
537
|
|
|
542
538
|
|
|
@@ -552,6 +548,10 @@ def _sarif_run(results: list[dict[str, object]]) -> dict[str, object]:
|
|
|
552
548
|
"id": rule_id,
|
|
553
549
|
"name": rule_name,
|
|
554
550
|
"shortDescription": {"text": rule_name},
|
|
551
|
+
"help": {
|
|
552
|
+
"text": rule_name,
|
|
553
|
+
"uri": "https://github.com/Mresyzz/opsscript-gate#ci-output",
|
|
554
|
+
},
|
|
555
555
|
"helpUri": "https://github.com/Mresyzz/opsscript-gate#ci-output",
|
|
556
556
|
}
|
|
557
557
|
for rule_id, rule_name in _SARIF_RULES.values()
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
|
+
import socket
|
|
5
6
|
import shlex
|
|
6
7
|
import tempfile
|
|
7
8
|
import time
|
|
@@ -59,6 +60,17 @@ _DANGEROUS_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
|
59
60
|
# e.g. "::error file=fake.sh::forged" -> "[container] ::error file=fake.sh::forged"
|
|
60
61
|
_WORKFLOW_COMMAND_LINE_RE = re.compile(r"^(::)", re.MULTILINE)
|
|
61
62
|
|
|
63
|
+
UTF8_BOM = b"\xef\xbb\xbf"
|
|
64
|
+
PACKAGE_SETUP_ERROR_MARKER = "OPSSCRIPT_GATE_SETUP_ERROR:"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_wait_timeout_error(exc: Exception) -> bool:
|
|
68
|
+
"""Return whether a Docker wait exception represents an actual timeout."""
|
|
69
|
+
if isinstance(exc, (TimeoutError, socket.timeout)):
|
|
70
|
+
return True
|
|
71
|
+
name = exc.__class__.__name__.lower()
|
|
72
|
+
return name in {"readtimeout", "readtimeouterror", "sockettimeout"}
|
|
73
|
+
|
|
62
74
|
|
|
63
75
|
def sanitize_log_output(text: str) -> str:
|
|
64
76
|
"""
|
|
@@ -258,6 +270,9 @@ def inspect_shebang(script_path: str) -> ShebangParseResult:
|
|
|
258
270
|
error_message=f"Failed to read script to parse shebang: {exc}",
|
|
259
271
|
)
|
|
260
272
|
|
|
273
|
+
if first_line_bytes.startswith(UTF8_BOM):
|
|
274
|
+
first_line_bytes = first_line_bytes[len(UTF8_BOM):]
|
|
275
|
+
|
|
261
276
|
raw_content = first_line_bytes.rstrip(b"\r\n")
|
|
262
277
|
if len(first_line_bytes) > MAX_SHEBANG_BYTES and not first_line_bytes.endswith((b"\n", b"\r")):
|
|
263
278
|
return ShebangParseResult(
|
|
@@ -429,20 +444,28 @@ def prepare_script(
|
|
|
429
444
|
if not os.path.isfile(abs_path):
|
|
430
445
|
return abs_path, None
|
|
431
446
|
|
|
432
|
-
# First pass: stream-check if any CRLF
|
|
447
|
+
# First pass: stream-check if any CRLF or a leading UTF-8 BOM exists without
|
|
448
|
+
# loading the entire file into RAM.
|
|
433
449
|
has_crlf = False
|
|
450
|
+
has_bom = False
|
|
451
|
+
chunk_size = max(3, chunk_size)
|
|
434
452
|
with open(abs_path, "rb") as f:
|
|
435
453
|
prev_byte = b""
|
|
454
|
+
first_chunk = True
|
|
436
455
|
while True:
|
|
437
456
|
chunk = f.read(chunk_size)
|
|
438
457
|
if not chunk:
|
|
439
458
|
break
|
|
459
|
+
if first_chunk:
|
|
460
|
+
has_bom = chunk.startswith(UTF8_BOM)
|
|
461
|
+
first_chunk = False
|
|
440
462
|
if b"\r\n" in chunk or (prev_byte == b"\r" and chunk.startswith(b"\n")):
|
|
441
463
|
has_crlf = True
|
|
442
|
-
|
|
464
|
+
if has_bom:
|
|
465
|
+
break
|
|
443
466
|
prev_byte = chunk[-1:]
|
|
444
467
|
|
|
445
|
-
if not has_crlf:
|
|
468
|
+
if not has_crlf and not has_bom:
|
|
446
469
|
return abs_path, None
|
|
447
470
|
|
|
448
471
|
# Stream-normalize CRLF into temporary file in fixed-size chunks
|
|
@@ -450,6 +473,7 @@ def prepare_script(
|
|
|
450
473
|
try:
|
|
451
474
|
with open(abs_path, "rb") as src:
|
|
452
475
|
carry_cr = False
|
|
476
|
+
first_chunk = True
|
|
453
477
|
while True:
|
|
454
478
|
chunk = src.read(chunk_size)
|
|
455
479
|
if not chunk:
|
|
@@ -457,6 +481,11 @@ def prepare_script(
|
|
|
457
481
|
temp_file.write(b"\r")
|
|
458
482
|
break
|
|
459
483
|
|
|
484
|
+
if first_chunk:
|
|
485
|
+
if chunk.startswith(UTF8_BOM):
|
|
486
|
+
chunk = chunk[len(UTF8_BOM):]
|
|
487
|
+
first_chunk = False
|
|
488
|
+
|
|
460
489
|
if carry_cr:
|
|
461
490
|
if chunk.startswith(b"\n"):
|
|
462
491
|
# CRLF split across boundary -> write single LF
|
|
@@ -586,6 +615,9 @@ def _wrap_with_package_setup(command: Sequence[str], packages: Sequence[str]) ->
|
|
|
586
615
|
|
|
587
616
|
package_args = " ".join(shlex.quote(package) for package in packages)
|
|
588
617
|
install = (
|
|
618
|
+
"setup_failed() { code=$?; "
|
|
619
|
+
f"echo '{PACKAGE_SETUP_ERROR_MARKER} package installation failed (exit code' \"$code\" ')' >&2; "
|
|
620
|
+
"exit \"$code\"; }; "
|
|
589
621
|
"if command -v apt-get >/dev/null 2>&1; then "
|
|
590
622
|
"mkdir -p /tmp/opsscript-apt-archives/partial && "
|
|
591
623
|
"apt-get -o APT::Sandbox::User=root "
|
|
@@ -599,10 +631,10 @@ def _wrap_with_package_setup(command: Sequence[str], packages: Sequence[str]) ->
|
|
|
599
631
|
"-o APT::Update::Post-Invoke-Success::= "
|
|
600
632
|
"-o Dir::Cache::archives=/tmp/opsscript-apt-archives/ "
|
|
601
633
|
f"install -y --no-install-recommends {package_args} && "
|
|
602
|
-
"(rm -rf /tmp/opsscript-apt-archives /var/lib/apt/lists/* || true); "
|
|
634
|
+
"(rm -rf /tmp/opsscript-apt-archives /var/lib/apt/lists/* || true) || setup_failed; "
|
|
603
635
|
"elif command -v apk >/dev/null 2>&1; then "
|
|
604
|
-
f"apk add --no-cache {package_args}; "
|
|
605
|
-
"else echo '
|
|
636
|
+
f"apk add --no-cache {package_args} || setup_failed; "
|
|
637
|
+
f"else echo '{PACKAGE_SETUP_ERROR_MARKER} no supported package manager found (apt-get or apk)' >&2; exit 125; fi"
|
|
606
638
|
)
|
|
607
639
|
target_command = " ".join(shlex.quote(part) for part in command)
|
|
608
640
|
return ["/bin/sh", "-c", f"{install} && exec {target_command}"]
|
|
@@ -751,14 +783,30 @@ def run_on_distro(
|
|
|
751
783
|
# Test doubles and older clients that do not return a status dict fall back
|
|
752
784
|
# to the bounded polling path below.
|
|
753
785
|
timed_out = False
|
|
786
|
+
wait_error: Exception | None = None
|
|
754
787
|
wait_result = None
|
|
755
788
|
wait_fn = getattr(container, "wait", None)
|
|
756
789
|
if callable(wait_fn):
|
|
757
790
|
try:
|
|
758
791
|
wait_result = wait_fn(timeout=max(0.01, float(timeout)))
|
|
759
|
-
except Exception:
|
|
760
|
-
|
|
792
|
+
except Exception as exc:
|
|
793
|
+
if _is_wait_timeout_error(exc):
|
|
794
|
+
timed_out = True
|
|
795
|
+
else:
|
|
796
|
+
wait_error = exc
|
|
761
797
|
if not isinstance(wait_result, dict) and not timed_out:
|
|
798
|
+
if wait_error is not None:
|
|
799
|
+
duration = time.perf_counter() - start_time
|
|
800
|
+
raw_logs = collect_container_logs(container)
|
|
801
|
+
output = sanitize_log_output(raw_logs)
|
|
802
|
+
return SingleResult(
|
|
803
|
+
distro=distro,
|
|
804
|
+
status=DistroStatus.ERROR,
|
|
805
|
+
exit_code=None,
|
|
806
|
+
duration=duration,
|
|
807
|
+
output_snippet=extract_snippet(output),
|
|
808
|
+
error_message=f"Container wait failed: {wait_error}",
|
|
809
|
+
)
|
|
762
810
|
while True:
|
|
763
811
|
elapsed = time.perf_counter() - start_time
|
|
764
812
|
if elapsed >= timeout:
|
|
@@ -799,7 +847,24 @@ def run_on_distro(
|
|
|
799
847
|
exit_code = state.get("ExitCode")
|
|
800
848
|
|
|
801
849
|
if exit_code is None:
|
|
802
|
-
|
|
850
|
+
return SingleResult(
|
|
851
|
+
distro=distro,
|
|
852
|
+
status=DistroStatus.ERROR,
|
|
853
|
+
exit_code=None,
|
|
854
|
+
duration=duration,
|
|
855
|
+
output_snippet=extract_snippet(output),
|
|
856
|
+
error_message="Container exited without a Docker exit code; execution result is unknown",
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
if PACKAGE_SETUP_ERROR_MARKER in output:
|
|
860
|
+
return SingleResult(
|
|
861
|
+
distro=distro,
|
|
862
|
+
status=DistroStatus.ERROR,
|
|
863
|
+
exit_code=exit_code,
|
|
864
|
+
duration=duration,
|
|
865
|
+
output_snippet=extract_snippet(output),
|
|
866
|
+
error_message="Container package setup failed before the script ran",
|
|
867
|
+
)
|
|
803
868
|
|
|
804
869
|
if exit_code == 0:
|
|
805
870
|
return SingleResult(
|
|
@@ -57,6 +57,16 @@ def test_doctor_json_reports_docker_failure(project, capsys):
|
|
|
57
57
|
assert next(check for check in report["checks"] if check["name"] == "docker")["ok"] is False
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
def test_doctor_rejects_invalid_project_config(project, capsys):
|
|
61
|
+
Path(CONFIG_NAME).write_text('{"timeout":')
|
|
62
|
+
with patch("opsscript_gate.cli.get_docker_client"):
|
|
63
|
+
assert main(["doctor", "--format", "json"]) == 1
|
|
64
|
+
report = json.loads(capsys.readouterr().out)
|
|
65
|
+
check = next(item for item in report["checks"] if item["name"] == "project")
|
|
66
|
+
assert check["ok"] is False
|
|
67
|
+
assert "Invalid" in check["detail"]
|
|
68
|
+
|
|
69
|
+
|
|
60
70
|
def test_init_existing_workflow_leaves_config_absent(project):
|
|
61
71
|
workflow = project / ".github/workflows/opsscript-gate.yml"
|
|
62
72
|
workflow.parent.mkdir(parents=True)
|
|
@@ -127,6 +137,7 @@ def test_discovery_limit_and_exclusions(project):
|
|
|
127
137
|
with pytest.raises(ValueError, match="no scripts were executed"):
|
|
128
138
|
discover_scripts(max_scripts=1)
|
|
129
139
|
assert discover_scripts(max_scripts=1, exclude=["tests/*"]) == ["./install.sh"]
|
|
140
|
+
assert discover_scripts(max_scripts=1, exclude=["./install.sh", "tests/*"]) == []
|
|
130
141
|
with patch("opsscript_gate.cli.run_matrix") as run:
|
|
131
142
|
assert main(["run", "--max-scripts", "1"]) == 1
|
|
132
143
|
run.assert_not_called()
|
|
@@ -158,6 +169,13 @@ def test_changed_discovery_uses_git_paths_and_filters_candidates(tmp_path):
|
|
|
158
169
|
assert "origin/main...HEAD" in run.call_args.args[0]
|
|
159
170
|
|
|
160
171
|
|
|
172
|
+
def test_changed_discovery_matches_dot_slash_exclusions(tmp_path):
|
|
173
|
+
(tmp_path / "install.sh").write_text("#!/bin/sh\necho ok\n")
|
|
174
|
+
completed = type("Completed", (), {"stdout": b"install.sh\0", "stderr": b""})()
|
|
175
|
+
with patch("opsscript_gate.discovery.subprocess.run", return_value=completed):
|
|
176
|
+
assert discover_changed_scripts(str(tmp_path), exclude=["./install.sh"]) == []
|
|
177
|
+
|
|
178
|
+
|
|
161
179
|
@pytest.mark.parametrize("revision", ["", "--output=bad", "origin/main bad"])
|
|
162
180
|
def test_changed_discovery_rejects_invalid_revision(tmp_path, revision):
|
|
163
181
|
with pytest.raises(ValueError, match="changed-since"):
|
|
@@ -226,6 +226,29 @@ def test_run_on_distro_package_setup_wraps_command(tmp_path):
|
|
|
226
226
|
assert "APT::Update::Post-Invoke-Success::=" in command[2]
|
|
227
227
|
assert "apk add --no-cache curl ca-certificates" in command[2]
|
|
228
228
|
assert "exec /bin/sh -c" in command[2]
|
|
229
|
+
assert "OPSSCRIPT_GATE_SETUP_ERROR:" in command[2]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_run_on_distro_package_setup_failure_is_error(tmp_path):
|
|
233
|
+
script_file = tmp_path / "test.sh"
|
|
234
|
+
script_file.write_text("#!/bin/sh\necho should-not-run\n", encoding="utf-8")
|
|
235
|
+
|
|
236
|
+
mock_client = mock.MagicMock()
|
|
237
|
+
mock_container = mock.MagicMock()
|
|
238
|
+
mock_container.status = "exited"
|
|
239
|
+
mock_container.attrs = {"State": {"ExitCode": 125}}
|
|
240
|
+
mock_container.logs.return_value = (
|
|
241
|
+
b"OPSSCRIPT_GATE_SETUP_ERROR: no supported package manager found (apt-get or apk)\n"
|
|
242
|
+
)
|
|
243
|
+
mock_client.containers.create.return_value = mock_container
|
|
244
|
+
|
|
245
|
+
result = run_on_distro(
|
|
246
|
+
mock_client, str(script_file), "scratch:latest", packages=["curl"], network="bridge"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
assert result.status == DistroStatus.ERROR
|
|
250
|
+
assert result.exit_code == 125
|
|
251
|
+
assert "package setup failed" in (result.error_message or "")
|
|
229
252
|
|
|
230
253
|
|
|
231
254
|
def test_package_setup_requires_network(tmp_path):
|
|
@@ -266,6 +289,42 @@ def test_run_on_distro_fail_mock(tmp_path):
|
|
|
266
289
|
mock_container.remove.assert_called_once_with(force=True)
|
|
267
290
|
|
|
268
291
|
|
|
292
|
+
def test_run_on_distro_missing_exit_code_fails_closed(tmp_path):
|
|
293
|
+
script_file = tmp_path / "unknown.sh"
|
|
294
|
+
script_file.write_text("#!/bin/sh\necho unknown\n", encoding="utf-8")
|
|
295
|
+
|
|
296
|
+
mock_client = mock.MagicMock()
|
|
297
|
+
mock_container = mock.MagicMock()
|
|
298
|
+
mock_container.status = "exited"
|
|
299
|
+
mock_container.attrs = {"State": {}}
|
|
300
|
+
mock_container.logs.return_value = b"unknown\n"
|
|
301
|
+
mock_client.containers.create.return_value = mock_container
|
|
302
|
+
|
|
303
|
+
result = run_on_distro(mock_client, str(script_file), "debian:12-slim")
|
|
304
|
+
|
|
305
|
+
assert result.status == DistroStatus.ERROR
|
|
306
|
+
assert result.exit_code is None
|
|
307
|
+
assert "without a Docker exit code" in (result.error_message or "")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_run_on_distro_wait_api_error_is_not_timeout(tmp_path):
|
|
311
|
+
script_file = tmp_path / "wait-error.sh"
|
|
312
|
+
script_file.write_text("#!/bin/sh\necho wait\n", encoding="utf-8")
|
|
313
|
+
|
|
314
|
+
mock_client = mock.MagicMock()
|
|
315
|
+
mock_container = mock.MagicMock()
|
|
316
|
+
mock_container.wait.side_effect = RuntimeError("Docker daemon disconnected")
|
|
317
|
+
mock_container.logs.return_value = b"wait failed\n"
|
|
318
|
+
mock_client.containers.create.return_value = mock_container
|
|
319
|
+
|
|
320
|
+
result = run_on_distro(mock_client, str(script_file), "debian:12-slim")
|
|
321
|
+
|
|
322
|
+
assert result.status == DistroStatus.ERROR
|
|
323
|
+
assert result.status != DistroStatus.TIMED_OUT
|
|
324
|
+
assert "wait failed" in (result.error_message or "")
|
|
325
|
+
mock_container.kill.assert_not_called()
|
|
326
|
+
|
|
327
|
+
|
|
269
328
|
def test_run_on_distro_timeout_kill_mock(tmp_path):
|
|
270
329
|
script_file = tmp_path / "hang.sh"
|
|
271
330
|
script_file.write_text("#!/bin/sh\nsleep 100\n", encoding="utf-8")
|
|
@@ -400,6 +459,8 @@ def test_reporter_sarif_contains_failure_location_and_metadata():
|
|
|
400
459
|
assert finding["locations"][0]["physicalLocation"]["region"]["startLine"] == 4
|
|
401
460
|
assert finding["properties"]["distribution"] == "alpine:3.20"
|
|
402
461
|
assert finding["properties"]["command"] == "apt-get"
|
|
462
|
+
assert "help" not in finding
|
|
463
|
+
assert payload["runs"][0]["tool"]["driver"]["rules"][0]["help"]["uri"].endswith("#ci-output")
|
|
403
464
|
|
|
404
465
|
|
|
405
466
|
def test_reporter_multi_sarif_keeps_script_paths():
|
|
@@ -597,6 +658,17 @@ def test_inspect_shebang_matrix(tmp_path):
|
|
|
597
658
|
assert res_crlf.status == ShebangStatus.RECOGNIZED
|
|
598
659
|
assert res_crlf.interpreter == "bash"
|
|
599
660
|
|
|
661
|
+
bom_file = tmp_path / "bom.sh"
|
|
662
|
+
bom_file.write_bytes(b"\xef\xbb\xbf#!/bin/sh\necho hi\r\n")
|
|
663
|
+
res_bom = inspect_shebang(str(bom_file))
|
|
664
|
+
assert res_bom.status == ShebangStatus.RECOGNIZED
|
|
665
|
+
prepared_bom, temp_bom = prepare_script(str(bom_file))
|
|
666
|
+
try:
|
|
667
|
+
assert open(prepared_bom, "rb").read().startswith(b"#!/bin/sh\n")
|
|
668
|
+
finally:
|
|
669
|
+
if temp_bom is not None:
|
|
670
|
+
temp_bom.close()
|
|
671
|
+
|
|
600
672
|
# 3. Leading whitespace before #! must be treated as missing
|
|
601
673
|
lead_space = tmp_path / "lead_space.sh"
|
|
602
674
|
lead_space.write_text(" #!/bin/sh\necho hi\n", encoding="utf-8")
|
|
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.0 → opsscript_gate-0.9.1}/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
|