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.
Files changed (57) hide show
  1. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/CHANGELOG.md +9 -0
  2. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/PKG-INFO +7 -7
  3. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/README.md +6 -6
  4. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/pyproject.toml +1 -1
  5. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/__init__.py +1 -1
  6. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/cli.py +21 -5
  7. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/config.py +1 -1
  8. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/discovery.py +12 -2
  9. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/reporter.py +4 -4
  10. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/runner.py +74 -9
  11. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/tests/test_onboarding.py +18 -0
  12. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/tests/test_runner.py +72 -0
  13. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.gitattributes +0 -0
  14. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  15. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
  16. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  17. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/assets/social-preview.png +0 -0
  18. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/pull_request_template.md +0 -0
  19. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/workflow-templates/opsscript-gate.properties.json +0 -0
  20. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/workflow-templates/opsscript-gate.yml +0 -0
  21. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/workflows/demo.yml +0 -0
  22. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/workflows/deploy-pages.yml +0 -0
  23. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/workflows/release.yml +0 -0
  24. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.github/workflows/test.yml +0 -0
  25. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/.gitignore +0 -0
  26. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/CITATION.cff +0 -0
  27. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/CONTRIBUTING.md +0 -0
  28. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/LICENSE +0 -0
  29. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/README.zh-CN.md +0 -0
  30. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/ROADMAP.md +0 -0
  31. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/SECURITY.md +0 -0
  32. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/action.yml +0 -0
  33. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/demo.html +0 -0
  34. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/command-not-found.html +0 -0
  35. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/command-not-found.md +0 -0
  36. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/configuration.md +0 -0
  37. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/gpt-oss.md +0 -0
  38. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/index.html +0 -0
  39. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/llms.txt +0 -0
  40. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/sitemap.xml +0 -0
  41. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/docs/troubleshooting.md +0 -0
  42. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/alpine-incompatibility/README.md +0 -0
  43. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
  44. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/basic/README.md +0 -0
  45. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/basic/clean_setup.sh +0 -0
  46. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/github-actions/README.md +0 -0
  47. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/github-actions/workflow.yml +0 -0
  48. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/interactive-hang/README.md +0 -0
  49. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/examples/interactive-hang/prompt_confirm.sh +0 -0
  50. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/index.html +0 -0
  51. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/llms.txt +0 -0
  52. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/models.py +0 -0
  53. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/src/opsscript_gate/remediation.py +0 -0
  54. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/tests/fixtures/fail_deps.sh +0 -0
  55. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/tests/fixtures/fail_hang.sh +0 -0
  56. {opsscript_gate-0.9.0 → opsscript_gate-0.9.1}/tests/fixtures/pass_basic.sh +0 -0
  57. {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.0
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.0
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.0
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.0
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.0
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.0
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.0
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.0
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.0
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.0
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.0
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.0
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.0
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.0"
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" }
@@ -1,4 +1,4 @@
1
1
  """OpsScript Gate: Lightweight cross-distro compatibility pre-check for Linux ops scripts."""
2
2
 
3
- __version__ = "0.9.0"
3
+ __version__ = "0.9.1"
4
4
  __all__ = ["__version__"]
@@ -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
- checks.append({
230
- "name": "project",
231
- "ok": True,
232
- "detail": f"Found {config_path}" if config_path.is_file() else "No project config (optional)",
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))
@@ -90,7 +90,7 @@ jobs:
90
90
  - uses: actions/checkout@v7
91
91
  with:
92
92
  persist-credentials: false
93
- - uses: Mresyzz/opsscript-gate@v0.9.0
93
+ - uses: Mresyzz/opsscript-gate@v0.9.1
94
94
  with:
95
95
  config: .opsscript-gate.json
96
96
  """,
@@ -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 any(fnmatch.fnmatchcase(rel_path, pattern) for pattern in (exclude or [])):
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 any(fnmatch.fnmatchcase(rel_path, pattern) for pattern in (exclude or [])):
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 exists without loading the entire file into RAM
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
- break
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 'No supported package manager found (apt-get or apk)' >&2; exit 125; fi"
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
- timed_out = True
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
- exit_code = 0 if container.status == "exited" else 1
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