opsscript-gate 0.6.3__tar.gz → 0.8.0__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 (48) hide show
  1. opsscript_gate-0.6.3/.github/workflow-templates/properties.json → opsscript_gate-0.8.0/.github/workflow-templates/opsscript-gate.properties.json +2 -1
  2. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/.github/workflow-templates/opsscript-gate.yml +1 -1
  3. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/CHANGELOG.md +9 -1
  4. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/CITATION.cff +2 -2
  5. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/PKG-INFO +41 -20
  6. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/README.md +40 -19
  7. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/README.zh-CN.md +12 -5
  8. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/SECURITY.md +2 -2
  9. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/action.yml +7 -1
  10. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/docs/configuration.md +13 -4
  11. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/docs/gpt-oss.md +1 -1
  12. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/examples/github-actions/README.md +1 -1
  13. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/examples/github-actions/workflow.yml +1 -1
  14. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/pyproject.toml +1 -1
  15. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/src/opsscript_gate/__init__.py +1 -1
  16. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/src/opsscript_gate/cli.py +35 -4
  17. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/src/opsscript_gate/config.py +22 -2
  18. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/src/opsscript_gate/reporter.py +120 -0
  19. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/src/opsscript_gate/runner.py +48 -2
  20. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/tests/test_action_contract.py +3 -2
  21. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/tests/test_onboarding.py +17 -1
  22. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/tests/test_runner.py +125 -1
  23. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/.gitattributes +0 -0
  24. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  25. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
  26. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  27. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/.github/assets/social-preview.png +0 -0
  28. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/.github/pull_request_template.md +0 -0
  29. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/.github/workflows/demo.yml +0 -0
  30. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/.github/workflows/release.yml +0 -0
  31. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/.github/workflows/test.yml +0 -0
  32. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/.gitignore +0 -0
  33. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/CONTRIBUTING.md +0 -0
  34. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/LICENSE +0 -0
  35. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/ROADMAP.md +0 -0
  36. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/docs/troubleshooting.md +0 -0
  37. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/examples/alpine-incompatibility/README.md +0 -0
  38. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
  39. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/examples/basic/README.md +0 -0
  40. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/examples/basic/clean_setup.sh +0 -0
  41. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/examples/interactive-hang/README.md +0 -0
  42. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/examples/interactive-hang/prompt_confirm.sh +0 -0
  43. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/src/opsscript_gate/discovery.py +0 -0
  44. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/src/opsscript_gate/models.py +0 -0
  45. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/src/opsscript_gate/remediation.py +0 -0
  46. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/tests/fixtures/fail_deps.sh +0 -0
  47. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/tests/fixtures/fail_hang.sh +0 -0
  48. {opsscript_gate-0.6.3 → opsscript_gate-0.8.0}/tests/fixtures/pass_basic.sh +0 -0
@@ -2,5 +2,6 @@
2
2
  "name": "OpsScript Gate shell compatibility",
3
3
  "description": "Run shell scripts in Debian, Ubuntu and Alpine containers on changes to shell files.",
4
4
  "iconName": "octicon-shield-check",
5
- "categories": ["Continuous integration", "Testing"]
5
+ "categories": ["Continuous integration", "Testing"],
6
+ "filePatterns": [".*\\.sh$", ".*\\.bash$"]
6
7
  }
@@ -25,7 +25,7 @@ jobs:
25
25
  persist-credentials: false
26
26
 
27
27
  - name: Run OpsScript Gate
28
- uses: Mresyzz/opsscript-gate@v0.6.3
28
+ uses: Mresyzz/opsscript-gate@v0.8.0
29
29
  with:
30
30
  shell: auto
31
31
  network: none
@@ -1,9 +1,17 @@
1
1
  # Changelog
2
2
 
3
- ## [0.6.3] - 2026-10-01
3
+ ## [0.8.0] - 2026-10-02
4
+
5
+ ### Added
6
+ - Added opt-in `packages` setup for installing validated package names with
7
+ `apt-get` or `apk` before each container run; package setup requires bridge
8
+ networking and leaves existing offline runs unchanged.
9
+
10
+ ## [0.7.0] - 2026-10-01
4
11
 
5
12
  ### Added
6
13
  - Added `opsscript-gate doctor` to diagnose Python and Docker prerequisites before a local run.
14
+ - Added SARIF 2.1.0 output for location-aware GitHub Code Scanning integration.
7
15
 
8
16
  ### Changed
9
17
  - Expanded package and Marketplace keywords for shell installers, ShellCheck users, portability, and BusyBox.
@@ -5,8 +5,8 @@ type: software
5
5
  authors:
6
6
  - family-names: Mresyzz
7
7
  given-names: Mresy
8
- version: 0.6.3
9
- date-released: 2026-10-01
8
+ version: 0.8.0
9
+ date-released: 2026-10-02
10
10
  repository-code: https://github.com/Mresyzz/opsscript-gate
11
11
  url: https://github.com/Mresyzz/opsscript-gate
12
12
  license: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opsscript-gate
3
- Version: 0.6.3
3
+ Version: 0.8.0
4
4
  Summary: Run Linux shell scripts in Debian, Ubuntu and Alpine containers to catch runtime compatibility failures before merge
5
5
  Project-URL: Homepage, https://mresyzz.github.io/InstallReady/
6
6
  Project-URL: Demo, https://mresyzz.github.io/InstallReady/?repo=Mresyzz%2Fopsscript-gate
@@ -48,15 +48,15 @@ Description-Content-Type: text/markdown
48
48
  It is designed for shell testing, portable shell validation, Bash/POSIX compatibility checks, and cross-distro CI where syntax-only tooling is not enough.
49
49
 
50
50
  If your repository contains an installer, bootstrap script, or shell entrypoint, the fastest path is to
51
- copy the [zero-configuration workflow](.github/workflow-templates/opsscript-gate.yml). It only runs when
52
- shell files change, uses offline containers by default, and uploads a report that can be attached to a PR.
53
- No account, API key, or service signup is required.
51
+ open the [InstallReady scanner](https://mresyzz.github.io/InstallReady/), enter `owner/repository`, and
52
+ choose **Add workflow on GitHub**. It generates a reviewable workflow without an account, API key, or
53
+ service signup. You can also copy the maintained [zero-configuration workflow](.github/workflow-templates/opsscript-gate.yml).
54
54
 
55
55
  Try the browser scanner: [InstallReady](https://mresyzz.github.io/InstallReady/?repo=Mresyzz%2Fopsscript-gate) checks this repository without executing code or requiring a login.
56
56
 
57
57
  ---
58
58
 
59
- ## Current release: v0.6.3
59
+ ## Current release: v0.8.0
60
60
 
61
61
  The current release includes a project config file, a dry-run plan, presets, exclusions,
62
62
  saved reports, and changed-script selection for pull requests. The project controls
@@ -77,7 +77,10 @@ Use `opsscript-gate doctor` to check Python and Docker before a real run.
77
77
  Useful for standalone installers, container entrypoints and release scripts that
78
78
  must work on both GNU/Linux and Alpine/BusyBox. Each script runs independently;
79
79
  repository files, sibling scripts and project dependencies are **not** mounted.
80
- See [configuration and migration](docs/configuration.md) and
80
+ If an installer needs a small, explicit set of tools such as `curl`, pass
81
+ `packages` in the config or Action input; the runner installs them with the
82
+ image's native package manager before testing. Package setup is opt-in and
83
+ requires bridge networking. See [configuration and migration](docs/configuration.md) and
81
84
  [why a shell script works on Ubuntu but fails on Alpine](docs/troubleshooting.md).
82
85
 
83
86
  ## Quickstart
@@ -86,8 +89,9 @@ See [configuration and migration](docs/configuration.md) and
86
89
 
87
90
  Add this workflow to `.github/workflows/shell-compat.yml`:
88
91
 
89
- You can also select **OpsScript Gate shell compatibility** from GitHub's workflow templates, or copy the
90
- maintained [starter workflow](.github/workflow-templates/opsscript-gate.yml).
92
+ The repository includes a maintained [workflow template](.github/workflow-templates/opsscript-gate.yml).
93
+ For a one-click setup in another public repository, use the InstallReady scanner above; it opens GitHub's
94
+ workflow editor with the generated file ready to review.
91
95
 
92
96
  You can copy the maintained example from [`examples/github-actions/workflow.yml`](examples/github-actions/workflow.yml)
93
97
  and change the `script-path`, or use the minimal workflow below.
@@ -101,7 +105,9 @@ jobs:
101
105
  runs-on: ubuntu-latest
102
106
  steps:
103
107
  - uses: actions/checkout@v7
104
- - uses: Mresyzz/opsscript-gate@v0.6.3
108
+ with:
109
+ persist-credentials: false
110
+ - uses: Mresyzz/opsscript-gate@v0.8.0
105
111
  ```
106
112
 
107
113
  This zero-configuration form discovers shell scripts in the repository. Use
@@ -112,7 +118,7 @@ repository has a single installer. For self-hosted model installers, see the
112
118
  Or test a specific script with custom execution modes:
113
119
 
114
120
  ```yaml
115
- - uses: Mresyzz/opsscript-gate@v0.6.3
121
+ - uses: Mresyzz/opsscript-gate@v0.8.0
116
122
  with:
117
123
  script-path: scripts/install.sh
118
124
  shell: auto
@@ -142,8 +148,9 @@ unrelated scripts:
142
148
  ```yaml
143
149
  - uses: actions/checkout@v7
144
150
  with:
151
+ persist-credentials: false
145
152
  fetch-depth: 0
146
- - uses: Mresyzz/opsscript-gate@v0.6.3
153
+ - uses: Mresyzz/opsscript-gate@v0.8.0
147
154
  with:
148
155
  changed-since: ${{ github.event.pull_request.base.sha }}
149
156
  preset: minimal
@@ -220,6 +227,28 @@ The action also writes a markdown matrix to `$GITHUB_STEP_SUMMARY`:
220
227
  </details>
221
228
  ```
222
229
 
230
+ ### SARIF for Code Scanning
231
+
232
+ Use `sarif` when the result should appear in GitHub Code Scanning or another
233
+ SARIF-compatible viewer. Upload it explicitly with the official upload action:
234
+
235
+ ```yaml
236
+ - uses: Mresyzz/opsscript-gate@v0.8.0
237
+ with:
238
+ script-path: scripts/install.sh
239
+ format: sarif
240
+ output: reports/opsscript-gate.sarif
241
+
242
+ - uses: github/codeql-action/upload-sarif@v3
243
+ if: always()
244
+ with:
245
+ sarif_file: reports/opsscript-gate.sarif
246
+ ```
247
+
248
+ Each runtime failure becomes a location-aware result with the distribution,
249
+ exit code, command, and diagnostic line. A passing matrix produces a valid SARIF
250
+ file with no findings.
251
+
223
252
  ---
224
253
 
225
254
  ## Example: runtime behavior that static analysis misses
@@ -376,7 +405,7 @@ usage: opsscript-gate run [-h] [--matrix MATRIX] [-j JOBS] [--timeout TIMEOUT]
376
405
  | `-j, --jobs` | Integer | `min(2, size)` | Number of concurrent container jobs |
377
406
  | `--matrix` | String | `debian:12-slim,ubuntu:22.04,ubuntu:24.04,alpine:3.20` | Comma-separated list of Docker images |
378
407
  | `--timeout` | Integer | `60` | Hard timeout per container in seconds |
379
- | `--format` | Choice | `table` | Output format: `table`, `markdown`, or `json` |
408
+ | `--format` | Choice | `table` | Output format: `table`, `markdown`, `json`, or `sarif` |
380
409
  | `--shell` | Choice | `posix` | Execution mode: `posix` (default), `shebang`, or `auto` |
381
410
  | `--mem-limit` | String | `256m` | Memory limit per container (e.g. `256m`, `512m`) |
382
411
  | `--pids-limit`| Integer | `128` | Maximum number of processes per container |
@@ -413,14 +442,6 @@ usage: opsscript-gate run [-h] [--matrix MATRIX] [-j JOBS] [--timeout TIMEOUT]
413
442
  Explicit CLI options override project settings. Explicit script paths bypass discovery
414
443
  and its exclusions. See [complete configuration semantics](docs/configuration.md).
415
444
 
416
- ## Downstream example
417
-
418
- [`Mresyzz/linux-dev-bootstrap`](https://github.com/Mresyzz/linux-dev-bootstrap) contains a workflow that validates `install.sh` across the default Debian, Ubuntu, and Alpine matrix.
419
-
420
- See the downstream workflow: [`.github/workflows/test.yml`](https://github.com/Mresyzz/linux-dev-bootstrap/blob/main/.github/workflows/test.yml).
421
-
422
- ---
423
-
424
445
  ## 🧪 Development & Testing
425
446
 
426
447
  ```bash
@@ -18,15 +18,15 @@
18
18
  It is designed for shell testing, portable shell validation, Bash/POSIX compatibility checks, and cross-distro CI where syntax-only tooling is not enough.
19
19
 
20
20
  If your repository contains an installer, bootstrap script, or shell entrypoint, the fastest path is to
21
- copy the [zero-configuration workflow](.github/workflow-templates/opsscript-gate.yml). It only runs when
22
- shell files change, uses offline containers by default, and uploads a report that can be attached to a PR.
23
- No account, API key, or service signup is required.
21
+ open the [InstallReady scanner](https://mresyzz.github.io/InstallReady/), enter `owner/repository`, and
22
+ choose **Add workflow on GitHub**. It generates a reviewable workflow without an account, API key, or
23
+ service signup. You can also copy the maintained [zero-configuration workflow](.github/workflow-templates/opsscript-gate.yml).
24
24
 
25
25
  Try the browser scanner: [InstallReady](https://mresyzz.github.io/InstallReady/?repo=Mresyzz%2Fopsscript-gate) checks this repository without executing code or requiring a login.
26
26
 
27
27
  ---
28
28
 
29
- ## Current release: v0.6.3
29
+ ## Current release: v0.8.0
30
30
 
31
31
  The current release includes a project config file, a dry-run plan, presets, exclusions,
32
32
  saved reports, and changed-script selection for pull requests. The project controls
@@ -47,7 +47,10 @@ Use `opsscript-gate doctor` to check Python and Docker before a real run.
47
47
  Useful for standalone installers, container entrypoints and release scripts that
48
48
  must work on both GNU/Linux and Alpine/BusyBox. Each script runs independently;
49
49
  repository files, sibling scripts and project dependencies are **not** mounted.
50
- See [configuration and migration](docs/configuration.md) and
50
+ If an installer needs a small, explicit set of tools such as `curl`, pass
51
+ `packages` in the config or Action input; the runner installs them with the
52
+ image's native package manager before testing. Package setup is opt-in and
53
+ requires bridge networking. See [configuration and migration](docs/configuration.md) and
51
54
  [why a shell script works on Ubuntu but fails on Alpine](docs/troubleshooting.md).
52
55
 
53
56
  ## Quickstart
@@ -56,8 +59,9 @@ See [configuration and migration](docs/configuration.md) and
56
59
 
57
60
  Add this workflow to `.github/workflows/shell-compat.yml`:
58
61
 
59
- You can also select **OpsScript Gate shell compatibility** from GitHub's workflow templates, or copy the
60
- maintained [starter workflow](.github/workflow-templates/opsscript-gate.yml).
62
+ The repository includes a maintained [workflow template](.github/workflow-templates/opsscript-gate.yml).
63
+ For a one-click setup in another public repository, use the InstallReady scanner above; it opens GitHub's
64
+ workflow editor with the generated file ready to review.
61
65
 
62
66
  You can copy the maintained example from [`examples/github-actions/workflow.yml`](examples/github-actions/workflow.yml)
63
67
  and change the `script-path`, or use the minimal workflow below.
@@ -71,7 +75,9 @@ jobs:
71
75
  runs-on: ubuntu-latest
72
76
  steps:
73
77
  - uses: actions/checkout@v7
74
- - uses: Mresyzz/opsscript-gate@v0.6.3
78
+ with:
79
+ persist-credentials: false
80
+ - uses: Mresyzz/opsscript-gate@v0.8.0
75
81
  ```
76
82
 
77
83
  This zero-configuration form discovers shell scripts in the repository. Use
@@ -82,7 +88,7 @@ repository has a single installer. For self-hosted model installers, see the
82
88
  Or test a specific script with custom execution modes:
83
89
 
84
90
  ```yaml
85
- - uses: Mresyzz/opsscript-gate@v0.6.3
91
+ - uses: Mresyzz/opsscript-gate@v0.8.0
86
92
  with:
87
93
  script-path: scripts/install.sh
88
94
  shell: auto
@@ -112,8 +118,9 @@ unrelated scripts:
112
118
  ```yaml
113
119
  - uses: actions/checkout@v7
114
120
  with:
121
+ persist-credentials: false
115
122
  fetch-depth: 0
116
- - uses: Mresyzz/opsscript-gate@v0.6.3
123
+ - uses: Mresyzz/opsscript-gate@v0.8.0
117
124
  with:
118
125
  changed-since: ${{ github.event.pull_request.base.sha }}
119
126
  preset: minimal
@@ -190,6 +197,28 @@ The action also writes a markdown matrix to `$GITHUB_STEP_SUMMARY`:
190
197
  </details>
191
198
  ```
192
199
 
200
+ ### SARIF for Code Scanning
201
+
202
+ Use `sarif` when the result should appear in GitHub Code Scanning or another
203
+ SARIF-compatible viewer. Upload it explicitly with the official upload action:
204
+
205
+ ```yaml
206
+ - uses: Mresyzz/opsscript-gate@v0.8.0
207
+ with:
208
+ script-path: scripts/install.sh
209
+ format: sarif
210
+ output: reports/opsscript-gate.sarif
211
+
212
+ - uses: github/codeql-action/upload-sarif@v3
213
+ if: always()
214
+ with:
215
+ sarif_file: reports/opsscript-gate.sarif
216
+ ```
217
+
218
+ Each runtime failure becomes a location-aware result with the distribution,
219
+ exit code, command, and diagnostic line. A passing matrix produces a valid SARIF
220
+ file with no findings.
221
+
193
222
  ---
194
223
 
195
224
  ## Example: runtime behavior that static analysis misses
@@ -346,7 +375,7 @@ usage: opsscript-gate run [-h] [--matrix MATRIX] [-j JOBS] [--timeout TIMEOUT]
346
375
  | `-j, --jobs` | Integer | `min(2, size)` | Number of concurrent container jobs |
347
376
  | `--matrix` | String | `debian:12-slim,ubuntu:22.04,ubuntu:24.04,alpine:3.20` | Comma-separated list of Docker images |
348
377
  | `--timeout` | Integer | `60` | Hard timeout per container in seconds |
349
- | `--format` | Choice | `table` | Output format: `table`, `markdown`, or `json` |
378
+ | `--format` | Choice | `table` | Output format: `table`, `markdown`, `json`, or `sarif` |
350
379
  | `--shell` | Choice | `posix` | Execution mode: `posix` (default), `shebang`, or `auto` |
351
380
  | `--mem-limit` | String | `256m` | Memory limit per container (e.g. `256m`, `512m`) |
352
381
  | `--pids-limit`| Integer | `128` | Maximum number of processes per container |
@@ -383,14 +412,6 @@ usage: opsscript-gate run [-h] [--matrix MATRIX] [-j JOBS] [--timeout TIMEOUT]
383
412
  Explicit CLI options override project settings. Explicit script paths bypass discovery
384
413
  and its exclusions. See [complete configuration semantics](docs/configuration.md).
385
414
 
386
- ## Downstream example
387
-
388
- [`Mresyzz/linux-dev-bootstrap`](https://github.com/Mresyzz/linux-dev-bootstrap) contains a workflow that validates `install.sh` across the default Debian, Ubuntu, and Alpine matrix.
389
-
390
- See the downstream workflow: [`.github/workflows/test.yml`](https://github.com/Mresyzz/linux-dev-bootstrap/blob/main/.github/workflows/test.yml).
391
-
392
- ---
393
-
394
415
  ## 🧪 Development & Testing
395
416
 
396
417
  ```bash
@@ -10,9 +10,9 @@ Bash 依赖、包管理器假设和交互式阻塞。它与 ShellCheck 互补。
10
10
 
11
11
  也可以直接打开 [InstallReady 在线扫描器](https://mresyzz.github.io/InstallReady/?repo=Mresyzz%2Fopsscript-gate),无需登录即可检查公开仓库。
12
12
 
13
- ## 当前版本 v0.6.3
13
+ ## 当前版本 v0.8.0
14
14
 
15
- v0.6.3 已作为正式版本发布;如果你要从源码验证当前分支,也可以执行:
15
+ v0.8.0 已作为正式版本发布;如果你要从源码验证当前分支,也可以执行:
16
16
 
17
17
  ```bash
18
18
  pip install -e .
@@ -28,14 +28,21 @@ opsscript-gate run --dry-run
28
28
  opsscript-gate run --format json --output reports/compatibility.json
29
29
  ```
30
30
 
31
- `init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.6.3`。
31
+ `init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.8.0`。
32
32
  预览不需要 Docker;真实运行需要 Python 3.10+
33
33
  和可访问的 Linux Docker 引擎。
34
34
  `opsscript-gate doctor` 可以在真实运行前检查 Python 和 Docker。
35
35
 
36
+ 需要接入 GitHub Code Scanning 时,将 `--format sarif --output reports/opsscript-gate.sarif`
37
+ 传给运行命令,再用 `github/codeql-action/upload-sarif@v3` 上传报告。每个失败发行版
38
+ 都会保留脚本路径、诊断行号、命令和退出码;全部通过时仍会生成合法的空 SARIF 报告。
39
+
36
40
  生成的配置默认排除 `tests/*`、`examples/*`,使用 Debian + Alpine,尊重
37
41
  脚本 shebang,并关闭网络。请根据实际脚本审核配置;需要下载文件时启用
38
- `--network bridge`。保留现有无配置行为:四个发行版、POSIX shell、bridge 网络。
42
+ `--network bridge`。安装器如果需要 `curl`、证书或其他明确工具,可以配置
43
+ `packages`;运行器会用镜像自带的 `apt-get` 或 `apk` 安装它们。包安装是可选的,
44
+ 只接受包名,不接受 Shell 命令,并且必须使用 `bridge` 网络。保留现有无配置行为:
45
+ 四个发行版、POSIX shell、bridge 网络。
39
46
 
40
47
  ## 本轮改进
41
48
 
@@ -58,7 +65,7 @@ Docker daemon 和可选的项目配置。
58
65
  - uses: actions/checkout@v7
59
66
  with:
60
67
  fetch-depth: 0
61
- - uses: Mresyzz/opsscript-gate@v0.6.3
68
+ - uses: Mresyzz/opsscript-gate@v0.8.0
62
69
  with:
63
70
  changed-since: ${{ github.event.pull_request.base.sha }}
64
71
  preset: minimal
@@ -6,8 +6,8 @@ Only the latest release of **OpsScript Gate** receives security updates and bug
6
6
 
7
7
  | Version | Supported |
8
8
  | :--- | :---: |
9
- | 0.4.x | :white_check_mark: |
10
- | < 0.4.0 | :x: |
9
+ | 0.8.x | :white_check_mark: |
10
+ | < 0.8.0 | :x: |
11
11
 
12
12
  ---
13
13
 
@@ -23,7 +23,7 @@ inputs:
23
23
  required: false
24
24
  default: ''
25
25
  format:
26
- description: 'Output report format: table, markdown, or json'
26
+ description: 'Output report format: table, markdown, json, or sarif'
27
27
  required: false
28
28
  default: ''
29
29
  shell:
@@ -42,6 +42,10 @@ inputs:
42
42
  description: "Container network mode: 'bridge' or 'none' (default: bridge)"
43
43
  required: false
44
44
  default: ''
45
+ packages:
46
+ description: 'Optional comma- or newline-separated packages to install before running (requires network=bridge)'
47
+ required: false
48
+ default: ''
45
49
 
46
50
  config:
47
51
  description: 'Path to project JSON configuration'
@@ -97,6 +101,7 @@ runs:
97
101
  INPUT_MEM_LIMIT: ${{ inputs.mem-limit }}
98
102
  INPUT_PIDS_LIMIT: ${{ inputs.pids-limit }}
99
103
  INPUT_NETWORK: ${{ inputs.network }}
104
+ INPUT_PACKAGES: ${{ inputs.packages }}
100
105
  INPUT_CONFIG: ${{ inputs.config }}
101
106
  INPUT_PRESET: ${{ inputs.preset }}
102
107
  INPUT_EXCLUDE: ${{ inputs.exclude }}
@@ -116,6 +121,7 @@ runs:
116
121
  [ -z "$INPUT_MEM_LIMIT" ] || ARGS+=(--mem-limit "$INPUT_MEM_LIMIT")
117
122
  [ -z "$INPUT_PIDS_LIMIT" ] || ARGS+=(--pids-limit "$INPUT_PIDS_LIMIT")
118
123
  [ -z "$INPUT_NETWORK" ] || ARGS+=(--network "$INPUT_NETWORK")
124
+ [ -z "$INPUT_PACKAGES" ] || ARGS+=(--packages "$INPUT_PACKAGES")
119
125
  [ -z "$INPUT_MATRIX" ] || ARGS+=(--matrix "$INPUT_MATRIX")
120
126
  [ -z "$INPUT_JOBS" ] || ARGS+=(--jobs "$INPUT_JOBS")
121
127
  [ -z "$INPUT_CONFIG" ] || ARGS+=(--config "$INPUT_CONFIG")
@@ -12,7 +12,7 @@ opsscript-gate run --dry-run
12
12
  ```
13
13
 
14
14
  `init` refuses to overwrite either `.opsscript-gate.json` or
15
- `.github/workflows/opsscript-gate.yml`. The generated workflow uses the v0.6.3 tag.
15
+ `.github/workflows/opsscript-gate.yml`. The generated workflow uses the v0.8.0 tag.
16
16
 
17
17
  `doctor` checks Python and Docker connectivity without executing repository scripts.
18
18
  Use `opsscript-gate doctor --format json` when collecting a support report.
@@ -34,6 +34,7 @@ directory, not the config file's parent.
34
34
  "preset": "minimal",
35
35
  "shell": "auto",
36
36
  "network": "none",
37
+ "packages": [],
37
38
  "timeout": 30,
38
39
  "jobs": 2,
39
40
  "mem_limit": "256m",
@@ -46,7 +47,11 @@ directory, not the config file's parent.
46
47
  These are all supported keys, plus `matrix`, a comma-separated image string used
47
48
  instead of `preset`. Unknown keys and incorrect value types are errors. Numeric
48
49
  limits must be positive integers. `shell` accepts `posix`, `auto`, `shebang`;
49
- `network` accepts `none`, `bridge`.
50
+ `network` accepts `none`, `bridge`. `packages` is an optional list of package
51
+ names to install inside each test container before the target script runs. The
52
+ runner uses `apt-get` for Debian/Ubuntu images and `apk` for Alpine images. It
53
+ accepts package names only, never shell commands, and requires `network: bridge`.
54
+ The default is an empty list, so existing networking settings and execution remain unchanged.
50
55
 
51
56
  | Preset | Images |
52
57
  | --- | --- |
@@ -99,7 +104,7 @@ For pull requests, set `changed-since` to the base commit and fetch full Git his
99
104
  - uses: actions/checkout@v7
100
105
  with:
101
106
  fetch-depth: 0
102
- - uses: Mresyzz/opsscript-gate@v0.6.3
107
+ - uses: Mresyzz/opsscript-gate@v0.8.0
103
108
  with:
104
109
  changed-since: ${{ github.event.pull_request.base.sha }}
105
110
  preset: minimal
@@ -109,11 +114,15 @@ The Action passes when no changed shell scripts are selected. This keeps unrelat
109
114
  documentation or application changes from starting container jobs.
110
115
 
111
116
  ```yaml
112
- - uses: Mresyzz/opsscript-gate@v0.6.3
117
+ - uses: Mresyzz/opsscript-gate@v0.8.0
113
118
  with:
114
119
  config: .opsscript-gate.json
115
120
  format: json
116
121
  output: reports/compatibility.json
122
+ network: bridge
123
+ packages: |
124
+ curl
125
+ ca-certificates
117
126
  exclude: |
118
127
  tests/*
119
128
  examples/*
@@ -33,7 +33,7 @@ jobs:
33
33
  fetch-depth: 0
34
34
  persist-credentials: false
35
35
 
36
- - uses: Mresyzz/opsscript-gate@v0.6.3
36
+ - uses: Mresyzz/opsscript-gate@v0.8.0
37
37
  with:
38
38
  changed-since: ${{ github.event.pull_request.base.sha }}
39
39
  preset: minimal
@@ -2,4 +2,4 @@
2
2
 
3
3
  This example shows a GitHub Actions workflow for checking a shell script on pull requests.
4
4
 
5
- Copy `workflow.yml` into your repository at `.github/workflows/script-gate.yml` and point `script-path` to the script you want to validate. The example uses the current v0.6.3 Action release.
5
+ Copy `workflow.yml` into your repository at `.github/workflows/script-gate.yml` and point `script-path` to the script you want to validate. The example uses the current v0.8.0 Action release.
@@ -19,7 +19,7 @@ jobs:
19
19
  persist-credentials: false
20
20
 
21
21
  - name: Run OpsScript Gate
22
- uses: Mresyzz/opsscript-gate@v0.6.3
22
+ uses: Mresyzz/opsscript-gate@v0.8.0
23
23
  with:
24
24
  script-path: 'scripts/deploy.sh'
25
25
  shell: auto
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "opsscript-gate"
7
- version = "0.6.3"
7
+ version = "0.8.0"
8
8
  description = "Run Linux shell scripts in Debian, Ubuntu and Alpine containers to catch runtime compatibility failures before merge"
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.6.3"
3
+ __version__ = "0.8.0"
4
4
  __all__ = ["__version__"]
@@ -9,14 +9,16 @@ from pathlib import Path
9
9
 
10
10
  from opsscript_gate import __version__
11
11
  from opsscript_gate.discovery import discover_changed_scripts, discover_scripts
12
- from opsscript_gate.config import DEFAULTS, PRESETS, init_project, load_config
12
+ from opsscript_gate.config import DEFAULTS, PRESETS, init_project, load_config, validate_packages
13
13
  from opsscript_gate.models import MultiScriptReport, RunReport
14
14
  from opsscript_gate.reporter import (
15
15
  emit_github_annotations,
16
16
  format_github_summary,
17
17
  format_json,
18
+ format_multi_sarif,
18
19
  format_multi_github_summary,
19
20
  format_multi_terminal_table,
21
+ format_sarif,
20
22
  format_terminal_table,
21
23
  write_github_step_summary,
22
24
  )
@@ -91,9 +93,9 @@ def build_parser() -> argparse.ArgumentParser:
91
93
  )
92
94
  run_parser.add_argument(
93
95
  "--format",
94
- choices=["table", "markdown", "json"],
96
+ choices=["table", "markdown", "json", "sarif"],
95
97
  default="table",
96
- help="Output report format: 'table' (default ASCII), 'markdown', or 'json'",
98
+ help="Output report format: 'table' (default ASCII), 'markdown', 'json', or 'sarif'",
97
99
  )
98
100
  run_parser.add_argument(
99
101
  "--shell",
@@ -119,6 +121,12 @@ def build_parser() -> argparse.ArgumentParser:
119
121
  default="bridge",
120
122
  help="Network mode for containers: 'bridge' or 'none' (default: bridge)",
121
123
  )
124
+ run_parser.add_argument(
125
+ "--packages",
126
+ action="append",
127
+ default=None,
128
+ help="Optional comma-separated packages to install in each container before running (repeatable; requires network=bridge)",
129
+ )
122
130
  run_parser.add_argument(
123
131
  "--changed-since",
124
132
  help="Only test shell scripts changed between this Git revision and HEAD",
@@ -141,6 +149,19 @@ def parse_matrix_argument(matrix_raw: str | None) -> list[str]:
141
149
  return images if images else DEFAULT_MATRIX
142
150
 
143
151
 
152
+ def parse_packages_argument(packages_raw: list[str] | None) -> list[str]:
153
+ """Parse repeated comma/newline-separated package arguments."""
154
+ if not packages_raw:
155
+ return []
156
+ packages: list[str] = []
157
+ for raw in packages_raw:
158
+ for package in raw.replace("\r", "\n").replace(",", "\n").split("\n"):
159
+ name = package.strip()
160
+ if name:
161
+ packages.append(name)
162
+ return validate_packages(packages)
163
+
164
+
144
165
  def main(argv: list[str] | None = None) -> int:
145
166
  """Main CLI entrypoint."""
146
167
  parser = build_parser()
@@ -213,6 +234,9 @@ def main(argv: list[str] | None = None) -> int:
213
234
  raise ValueError(f"{key.replace('_', '-')} must be positive")
214
235
  if args.network not in ("bridge", "none"):
215
236
  raise ValueError("network must be bridge or none")
237
+ args.packages = parse_packages_argument(args.packages)
238
+ if args.packages and args.network == "none":
239
+ raise ValueError("packages require network=bridge")
216
240
  if args.matrix is not None and not any(x.strip() for x in args.matrix.split(",")):
217
241
  raise ValueError("matrix must contain at least one image")
218
242
  except ValueError as err:
@@ -261,13 +285,14 @@ def main(argv: list[str] | None = None) -> int:
261
285
  if args.dry_run:
262
286
  plan = {"dry_run": True, "scripts": target_scripts, "matrix": matrix,
263
287
  "executions": len(target_scripts) * len(matrix), "shell": args.shell,
264
- "network": args.network, "timeout": args.timeout}
288
+ "network": args.network, "packages": args.packages, "timeout": args.timeout}
265
289
  if changed_selection:
266
290
  plan["changed_since"] = args.changed_since
267
291
  output = json.dumps(plan, indent=2) if args.format == "json" else (
268
292
  "Execution preview (no scripts executed)\n"
269
293
  + "\n".join(f" {path}" for path in target_scripts)
270
294
  + f"\nImages: {', '.join(matrix)}\nContainer executions: {plan['executions']}"
295
+ + (f"\nPackages: {', '.join(args.packages)}" if args.packages else "")
271
296
  + f"\nShell: {args.shell} | Network: {args.network} | Timeout: {args.timeout}s"
272
297
  )
273
298
  return 0 if publish_output(output, args.output) else 1
@@ -290,6 +315,7 @@ def main(argv: list[str] | None = None) -> int:
290
315
  mem_limit=args.mem_limit,
291
316
  pids_limit=args.pids_limit,
292
317
  network=args.network,
318
+ packages=args.packages,
293
319
  )
294
320
  except DockerDaemonError as err:
295
321
  sys.stderr.write(f"Docker Error: {err}\n")
@@ -301,6 +327,8 @@ def main(argv: list[str] | None = None) -> int:
301
327
  # Format report output
302
328
  if args.format == "json":
303
329
  output = format_json(report)
330
+ elif args.format == "sarif":
331
+ output = format_sarif(report, script_path=script_path)
304
332
  elif args.format == "markdown":
305
333
  output = format_github_summary(report, script_path=script_path)
306
334
  else:
@@ -332,6 +360,7 @@ def main(argv: list[str] | None = None) -> int:
332
360
  mem_limit=args.mem_limit,
333
361
  pids_limit=args.pids_limit,
334
362
  network=args.network,
363
+ packages=args.packages,
335
364
  )
336
365
  except DockerDaemonError as err:
337
366
  sys.stderr.write(f"Docker Error: {err}\n")
@@ -353,6 +382,8 @@ def main(argv: list[str] | None = None) -> int:
353
382
 
354
383
  if args.format == "json":
355
384
  output = format_json(multi_report)
385
+ elif args.format == "sarif":
386
+ output = format_multi_sarif(multi_report)
356
387
  elif args.format == "markdown":
357
388
  output = format_multi_github_summary(multi_report)
358
389
  else:
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ import re
5
6
  from pathlib import Path
6
7
 
7
8
  CONFIG_NAME = ".opsscript-gate.json"
@@ -12,9 +13,26 @@ PRESETS = {
12
13
  DEFAULTS = {
13
14
  "matrix": None, "preset": None, "jobs": None, "timeout": 60,
14
15
  "shell": "posix", "mem_limit": "256m", "pids_limit": 128,
15
- "network": "bridge", "exclude": [], "max_scripts": 20,
16
+ "network": "bridge", "packages": [], "exclude": [], "max_scripts": 20,
16
17
  }
17
18
 
19
+ PACKAGE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+._:-]*$")
20
+
21
+
22
+ def validate_packages(value: object) -> list[str]:
23
+ """Validate and normalize package names used for optional container setup."""
24
+ if not isinstance(value, list) or not all(isinstance(p, str) for p in value):
25
+ raise ValueError("packages must be a list of package names")
26
+ packages: list[str] = []
27
+ for package in value:
28
+ name = package.strip()
29
+ if not name or not PACKAGE_NAME_RE.fullmatch(name):
30
+ raise ValueError(
31
+ "packages must contain nonempty package names using only letters, numbers, '+', '.', '_', ':', or '-'")
32
+ if name not in packages:
33
+ packages.append(name)
34
+ return packages
35
+
18
36
 
19
37
  def load_config(filename: str | None) -> dict:
20
38
  path = Path(filename or CONFIG_NAME)
@@ -36,6 +54,8 @@ def load_config(filename: str | None) -> dict:
36
54
  elif key == "exclude":
37
55
  if not isinstance(value, list) or not all(isinstance(p, str) and p for p in value):
38
56
  raise ValueError("exclude must be a list of nonempty glob strings")
57
+ elif key == "packages":
58
+ validate_packages(value)
39
59
  elif not isinstance(value, str) or not value.strip():
40
60
  raise ValueError(f"{key} must be a nonempty string")
41
61
  for key, choices in {"shell": ("posix", "auto", "shebang"),
@@ -63,7 +83,7 @@ jobs:
63
83
  - uses: actions/checkout@v7
64
84
  with:
65
85
  persist-credentials: false
66
- - uses: Mresyzz/opsscript-gate@v0.6.3
86
+ - uses: Mresyzz/opsscript-gate@v0.8.0
67
87
  with:
68
88
  config: .opsscript-gate.json
69
89
  """,
@@ -3,6 +3,7 @@ import json
3
3
  import os
4
4
  import re
5
5
  import sys
6
+ from opsscript_gate import __version__
6
7
  from opsscript_gate.models import DistroStatus, MultiScriptReport, RunReport, SingleResult
7
8
 
8
9
  # Structure-altering Markdown characters in regular prose:
@@ -463,6 +464,125 @@ def format_json(report: RunReport | MultiScriptReport) -> str:
463
464
  return json.dumps(report.to_dict(), indent=2, ensure_ascii=False)
464
465
 
465
466
 
467
+ _SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
468
+ _SARIF_RULES = {
469
+ DistroStatus.FAIL: ("OSG001", "Runtime compatibility failure"),
470
+ DistroStatus.TIMED_OUT: ("OSG002", "Runtime compatibility check timed out"),
471
+ DistroStatus.ERROR: ("OSG003", "Runtime compatibility check error"),
472
+ }
473
+
474
+
475
+ def _sarif_path(script_path: str) -> str:
476
+ """Return a repository-relative SARIF artifact path."""
477
+ clean = (script_path or "script.sh").replace("\\", "/")
478
+ while clean.startswith("./"):
479
+ clean = clean[2:]
480
+ return clean or "script.sh"
481
+
482
+
483
+ def _sarif_message(result: SingleResult) -> str:
484
+ parts = [f"[{result.distro}] {result.status.value}"]
485
+ if result.diagnostic:
486
+ parts.append(result.diagnostic.message)
487
+ if result.diagnostic.hint:
488
+ parts.append(result.diagnostic.hint)
489
+ elif result.error_message:
490
+ parts.append(result.error_message)
491
+ elif result.exit_code is not None:
492
+ parts.append(f"Execution exited with code {result.exit_code}.")
493
+ return " — ".join(parts)
494
+
495
+
496
+ def _sarif_result(result: SingleResult, script_path: str) -> dict[str, object] | None:
497
+ if result.status == DistroStatus.PASS:
498
+ return None
499
+
500
+ rule_id, rule_name = _SARIF_RULES[result.status]
501
+ physical_location: dict[str, object] = {
502
+ "artifactLocation": {
503
+ "uri": _sarif_path(script_path),
504
+ },
505
+ }
506
+ if result.diagnostic and result.diagnostic.line and result.diagnostic.line > 0:
507
+ physical_location["region"] = {"startLine": result.diagnostic.line}
508
+
509
+ properties: dict[str, object] = {
510
+ "distribution": result.distro,
511
+ "status": result.status.value,
512
+ "durationSeconds": round(result.duration, 3),
513
+ }
514
+ if result.exit_code is not None:
515
+ properties["exitCode"] = result.exit_code
516
+ if result.diagnostic and result.diagnostic.command:
517
+ properties["command"] = result.diagnostic.command
518
+
519
+ return {
520
+ "ruleId": rule_id,
521
+ "level": "error",
522
+ "message": {"text": _sarif_message(result)},
523
+ "locations": [{"physicalLocation": physical_location}],
524
+ "properties": properties,
525
+ "help": {
526
+ "text": rule_name,
527
+ "uri": "https://github.com/Mresyzz/opsscript-gate#ci-output",
528
+ },
529
+ }
530
+
531
+
532
+ def _sarif_run(results: list[dict[str, object]]) -> dict[str, object]:
533
+ return {
534
+ "tool": {
535
+ "driver": {
536
+ "name": "OpsScript Gate",
537
+ "version": __version__,
538
+ "informationUri": "https://github.com/Mresyzz/opsscript-gate",
539
+ "rules": [
540
+ {
541
+ "id": rule_id,
542
+ "name": rule_name,
543
+ "shortDescription": {"text": rule_name},
544
+ "helpUri": "https://github.com/Mresyzz/opsscript-gate#ci-output",
545
+ }
546
+ for rule_id, rule_name in _SARIF_RULES.values()
547
+ ],
548
+ },
549
+ },
550
+ "results": results,
551
+ }
552
+
553
+
554
+ def format_sarif(report: RunReport, script_path: str = "") -> str:
555
+ """Format runtime failures as SARIF 2.1.0 for GitHub Code Scanning."""
556
+ results = [
557
+ item
558
+ for result in report.results
559
+ if (item := _sarif_result(result, script_path)) is not None
560
+ ]
561
+ payload = {
562
+ "$schema": _SARIF_SCHEMA,
563
+ "version": "2.1.0",
564
+ "runs": [_sarif_run(results)],
565
+ }
566
+ return json.dumps(payload, indent=2, ensure_ascii=False)
567
+
568
+
569
+ def format_multi_sarif(report: MultiScriptReport) -> str:
570
+ """Format multi-script runtime failures as one SARIF 2.1.0 run."""
571
+ results: list[dict[str, object]] = []
572
+ for script_path, script_report in report.reports.items():
573
+ results.extend(
574
+ item
575
+ for result in script_report.results
576
+ if (item := _sarif_result(result, script_path)) is not None
577
+ )
578
+ payload = {
579
+ "$schema": _SARIF_SCHEMA,
580
+ "version": "2.1.0",
581
+ "runs": [_sarif_run(results)],
582
+ }
583
+ return json.dumps(payload, indent=2, ensure_ascii=False)
584
+
585
+
466
586
  def write_github_step_summary(
467
587
  report_or_content: RunReport | MultiScriptReport | str,
468
588
  script_path: str = "",
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import re
5
+ import shlex
5
6
  import tempfile
6
7
  import time
7
8
  from typing import Any, Sequence
@@ -12,6 +13,7 @@ from docker.errors import DockerException, ImageNotFound
12
13
  from concurrent.futures import ThreadPoolExecutor, as_completed
13
14
  from dataclasses import dataclass
14
15
  from enum import Enum
16
+ from opsscript_gate.config import PACKAGE_NAME_RE
15
17
  from opsscript_gate.models import DistroStatus, FailureDiagnostic, RunReport, ShellMode, SingleResult
16
18
  from opsscript_gate.remediation import generate_remediation_hint
17
19
 
@@ -574,6 +576,32 @@ def _resolve_shell_command(
574
576
  return None, f"Unsupported shell mode: {mode}"
575
577
 
576
578
 
579
+ def _wrap_with_package_setup(command: Sequence[str], packages: Sequence[str]) -> list[str]:
580
+ """Preinstall explicit packages with the image's native package manager."""
581
+ if not packages:
582
+ return list(command)
583
+
584
+ if any(not isinstance(package, str) or not PACKAGE_NAME_RE.fullmatch(package) for package in packages):
585
+ raise ValueError("Invalid package name")
586
+
587
+ package_args = " ".join(shlex.quote(package) for package in packages)
588
+ install = (
589
+ "if command -v apt-get >/dev/null 2>&1; then "
590
+ "mkdir -p /tmp/opsscript-apt-archives/partial && "
591
+ "apt-get -o APT::Sandbox::User=root "
592
+ "-o Dir::Cache::archives=/tmp/opsscript-apt-archives/ update && "
593
+ f"apt-get -o APT::Sandbox::User=root "
594
+ "-o Dir::Cache::archives=/tmp/opsscript-apt-archives/ "
595
+ f"install -y --no-install-recommends {package_args} && "
596
+ "rm -rf /tmp/opsscript-apt-archives /var/lib/apt/lists/*; "
597
+ "elif command -v apk >/dev/null 2>&1; then "
598
+ f"apk add --no-cache {package_args}; "
599
+ "else echo 'No supported package manager found (apt-get or apk)' >&2; exit 125; fi"
600
+ )
601
+ target_command = " ".join(shlex.quote(part) for part in command)
602
+ return ["/bin/sh", "-c", f"{install} && exec {target_command}"]
603
+
604
+
577
605
  def run_on_distro(
578
606
  client: docker.DockerClient,
579
607
  script_path: str,
@@ -585,12 +613,24 @@ def run_on_distro(
585
613
  mem_limit: str = "256m",
586
614
  pids_limit: int = 128,
587
615
  network: str = "bridge",
616
+ packages: Sequence[str] | None = None,
588
617
  prepared_script_path: str | None = None,
589
618
  ) -> SingleResult:
590
619
  """
591
620
  Run a target script inside an unprivileged, non-interactive container.
592
621
  Supports posix, shebang, and auto execution modes with strict resource limits.
593
622
  """
623
+ package_list = list(packages or [])
624
+ if package_list and network == "none":
625
+ return SingleResult(
626
+ distro=distro,
627
+ status=DistroStatus.ERROR,
628
+ exit_code=None,
629
+ duration=0.0,
630
+ output_snippet="",
631
+ error_message="Package setup requires network mode 'bridge'",
632
+ )
633
+
594
634
  try:
595
635
  mode = ShellMode(shell_mode)
596
636
  except ValueError:
@@ -647,6 +687,7 @@ def run_on_distro(
647
687
  )
648
688
 
649
689
  safe_mount_src = normalize_host_path_for_docker(mount_src)
690
+ container_command = _wrap_with_package_setup(command, package_list)
650
691
 
651
692
  # Security: Strict unprivileged options and read-only mount
652
693
  volumes = {
@@ -667,7 +708,7 @@ def run_on_distro(
667
708
  try:
668
709
  container = client.containers.create(
669
710
  image=distro,
670
- command=command,
711
+ command=container_command,
671
712
  volumes=volumes,
672
713
  environment=environment,
673
714
  stdin_open=False,
@@ -684,7 +725,7 @@ def run_on_distro(
684
725
  client.images.pull(distro)
685
726
  container = client.containers.create(
686
727
  image=distro,
687
- command=command,
728
+ command=container_command,
688
729
  volumes=volumes,
689
730
  environment=environment,
690
731
  stdin_open=False,
@@ -799,6 +840,7 @@ def run_matrix(
799
840
  mem_limit: str = "256m",
800
841
  pids_limit: int = 128,
801
842
  network: str = "bridge",
843
+ packages: Sequence[str] | None = None,
802
844
  ) -> RunReport:
803
845
  """
804
846
  Run the compatibility check across all specified Linux distributions,
@@ -817,6 +859,9 @@ def run_matrix(
817
859
  parsed_shebang = inspect_shebang(abs_script)
818
860
 
819
861
  distro_list = list(matrix) if matrix else DEFAULT_MATRIX
862
+ package_list = list(packages or [])
863
+ if package_list and network == "none":
864
+ raise ValueError("Package setup requires network mode 'bridge'")
820
865
  docker_client = client or get_docker_client()
821
866
 
822
867
  effective_jobs = jobs if (jobs is not None and jobs > 0) else min(2, len(distro_list))
@@ -859,6 +904,7 @@ def run_matrix(
859
904
  mem_limit=mem_limit,
860
905
  pids_limit=pids_limit,
861
906
  network=network,
907
+ packages=package_list,
862
908
  prepared_script_path=prepared_script,
863
909
  )
864
910
  return index, res
@@ -7,13 +7,14 @@ ROOT = Path(__file__).parents[1]
7
7
  def test_action_metadata_is_valid_and_inputs_are_wired():
8
8
  text = (ROOT / "action.yml").read_text(encoding="utf-8")
9
9
  assert "using: 'composite'" in text
10
- for name in ("config", "preset", "exclude", "max-scripts", "dry-run", "output", "changed-since"):
10
+ for name in ("config", "preset", "exclude", "max-scripts", "dry-run", "output", "changed-since", "packages"):
11
11
  assert f" {name}:" in text
12
12
  run_text = text
13
13
  for flag in ("--timeout", "--format", "--shell", "--mem-limit", "--pids-limit",
14
- "--network", "--matrix", "--jobs", "--config", "--preset",
14
+ "--network", "--packages", "--matrix", "--jobs", "--config", "--preset",
15
15
  "--max-scripts", "--output", "--exclude"):
16
16
  assert flag in run_text
17
+ assert "json, or sarif" in text
17
18
  assert "--changed-since" in run_text
18
19
 
19
20
 
@@ -69,7 +69,8 @@ def test_init_existing_workflow_leaves_config_absent(project):
69
69
 
70
70
  @pytest.mark.parametrize("config", [[], {"typo": 1}, {"timeout": True},
71
71
  {"jobs": 0}, {"exclude": "tests/*"}, {"shell": "fish"}, {"network": "host"},
72
- {"preset": "unknown"}, {"matrix": "alpine", "preset": "minimal"}])
72
+ {"preset": "unknown"}, {"matrix": "alpine", "preset": "minimal"},
73
+ {"packages": "curl"}, {"packages": ["curl; touch pwned"]}])
73
74
  def test_invalid_config(project, config):
74
75
  Path(CONFIG_NAME).write_text(json.dumps(config))
75
76
  with patch("opsscript_gate.cli.run_matrix") as run:
@@ -88,6 +89,21 @@ def test_config_cli_precedence_and_saved_plan(project, capsys):
88
89
  assert plan["timeout"] == 9
89
90
 
90
91
 
92
+ def test_packages_are_forwarded_and_visible_in_dry_run(project, capsys):
93
+ assert main([
94
+ "run", "install.sh", "--dry-run", "--format", "json",
95
+ "--packages", "curl,ca-certificates", "--packages", "tar",
96
+ ]) == 0
97
+ plan = json.loads(capsys.readouterr().out)
98
+ assert plan["packages"] == ["curl", "ca-certificates", "tar"]
99
+
100
+
101
+ def test_packages_reject_offline_network(project):
102
+ with patch("opsscript_gate.cli.run_matrix") as run:
103
+ assert main(["run", "install.sh", "--packages", "curl", "--network", "none"]) == 1
104
+ run.assert_not_called()
105
+
106
+
91
107
  def test_preset_overrides_config_matrix(project, capsys):
92
108
  Path(CONFIG_NAME).write_text('{"matrix": "custom:1"}')
93
109
  assert main(["run", "--dry-run", "--preset", "ubuntu", "--format", "json"]) == 0
@@ -8,7 +8,7 @@ from unittest import mock
8
8
  import pytest
9
9
  from docker.errors import DockerException
10
10
 
11
- from opsscript_gate.cli import build_parser, main, parse_matrix_argument
11
+ from opsscript_gate.cli import build_parser, main, parse_matrix_argument, parse_packages_argument
12
12
  from opsscript_gate.discovery import discover_scripts, is_shell_script
13
13
  from opsscript_gate.models import DistroStatus, FailureDiagnostic, MultiScriptReport, RunReport, ShellMode, SingleResult
14
14
  from opsscript_gate.remediation import generate_remediation_hint, REMEDIATION_RULES
@@ -19,8 +19,10 @@ from opsscript_gate.reporter import (
19
19
  format_github_annotations,
20
20
  format_github_summary,
21
21
  format_json,
22
+ format_multi_sarif,
22
23
  format_multi_github_summary,
23
24
  format_multi_terminal_table,
25
+ format_sarif,
24
26
  format_terminal_table,
25
27
  write_github_step_summary,
26
28
  )
@@ -41,6 +43,7 @@ from opsscript_gate.runner import (
41
43
  run_matrix,
42
44
  run_on_distro,
43
45
  sanitize_diagnostic_text,
46
+ _wrap_with_package_setup,
44
47
  )
45
48
 
46
49
 
@@ -195,6 +198,50 @@ def test_run_on_distro_pass_mock(tmp_path):
195
198
  mock_container.remove.assert_called_once_with(force=True)
196
199
 
197
200
 
201
+ def test_run_on_distro_package_setup_wraps_command(tmp_path):
202
+ script_file = tmp_path / "test.sh"
203
+ script_file.write_text("#!/bin/sh\necho OK\n", encoding="utf-8")
204
+
205
+ mock_client = mock.MagicMock()
206
+ mock_container = mock.MagicMock()
207
+ mock_container.status = "exited"
208
+ mock_container.attrs = {"State": {"ExitCode": 0}}
209
+ mock_container.logs.return_value = b"OK\n"
210
+ mock_client.containers.create.return_value = mock_container
211
+
212
+ result = run_on_distro(
213
+ mock_client,
214
+ str(script_file),
215
+ "alpine:3.20",
216
+ packages=["curl", "ca-certificates"],
217
+ network="bridge",
218
+ )
219
+
220
+ assert result.status == DistroStatus.PASS
221
+ command = mock_client.containers.create.call_args.kwargs["command"]
222
+ assert command[:2] == ["/bin/sh", "-c"]
223
+ assert "Dir::Cache::archives=/tmp/opsscript-apt-archives/" in command[2]
224
+ assert "apk add --no-cache curl ca-certificates" in command[2]
225
+ assert "exec /bin/sh -c" in command[2]
226
+
227
+
228
+ def test_package_setup_requires_network(tmp_path):
229
+ script_file = tmp_path / "test.sh"
230
+ script_file.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
231
+ result = run_on_distro(
232
+ mock.MagicMock(), str(script_file), "alpine:3.20", packages=["curl"], network="none"
233
+ )
234
+ assert result.status == DistroStatus.ERROR
235
+ assert "requires network" in (result.error_message or "")
236
+
237
+
238
+ def test_package_setup_quotes_and_validates_names():
239
+ wrapped = _wrap_with_package_setup(["/bin/sh", "-c", "echo ok"], ["ca-certificates"])
240
+ assert "ca-certificates" in wrapped[2]
241
+ with pytest.raises(ValueError):
242
+ _wrap_with_package_setup(["/bin/sh"], ["curl;touch /tmp/pwned"])
243
+
244
+
198
245
  def test_run_on_distro_fail_mock(tmp_path):
199
246
  script_file = tmp_path / "test_fail.sh"
200
247
  script_file.write_text("#!/bin/sh\napt-get: not found\nexit 127\n", encoding="utf-8")
@@ -300,6 +347,54 @@ def test_reporter_json():
300
347
  assert data["results"][0]["status"] == "PASS"
301
348
 
302
349
 
350
+ def test_reporter_sarif_contains_failure_location_and_metadata():
351
+ result = SingleResult(
352
+ "alpine:3.20",
353
+ DistroStatus.FAIL,
354
+ 127,
355
+ 0.42,
356
+ diagnostic=FailureDiagnostic(
357
+ kind="missing_command",
358
+ message="apt-get: command not found",
359
+ command="apt-get",
360
+ line=4,
361
+ distro="alpine:3.20",
362
+ hint="Use apk instead of apt-get.",
363
+ ),
364
+ )
365
+ report = RunReport([SingleResult("debian:12-slim", DistroStatus.PASS, 0, 0.2), result])
366
+
367
+ payload = json.loads(format_sarif(report, script_path="./scripts/install.sh"))
368
+ assert payload["$schema"].endswith("sarif-2.1.0.json")
369
+ assert payload["version"] == "2.1.0"
370
+ finding = payload["runs"][0]["results"][0]
371
+ assert finding["ruleId"] == "OSG001"
372
+ assert finding["level"] == "error"
373
+ assert finding["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] == "scripts/install.sh"
374
+ assert finding["locations"][0]["physicalLocation"]["region"]["startLine"] == 4
375
+ assert finding["properties"]["distribution"] == "alpine:3.20"
376
+ assert finding["properties"]["command"] == "apt-get"
377
+
378
+
379
+ def test_reporter_multi_sarif_keeps_script_paths():
380
+ report = MultiScriptReport(
381
+ reports={
382
+ "install.sh": RunReport([SingleResult("alpine:3.20", DistroStatus.FAIL, 1, 0.1)]),
383
+ "scripts/setup.sh": RunReport([SingleResult("debian:12-slim", DistroStatus.TIMED_OUT, None, 5.0)]),
384
+ },
385
+ total_duration=5.1,
386
+ )
387
+
388
+ payload = json.loads(format_multi_sarif(report))
389
+ results = payload["runs"][0]["results"]
390
+ assert len(results) == 2
391
+ paths = {
392
+ result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
393
+ for result in results
394
+ }
395
+ assert paths == {"install.sh", "scripts/setup.sh"}
396
+
397
+
303
398
  def test_write_github_step_summary(tmp_path):
304
399
  summary_file = tmp_path / "step_summary.md"
305
400
  r1 = SingleResult("debian:12-slim", DistroStatus.PASS, 0, 1.0)
@@ -322,6 +417,14 @@ def test_parse_matrix_argument():
322
417
  assert parse_matrix_argument("debian:12-slim, alpine:3.20") == ["debian:12-slim", "alpine:3.20"]
323
418
 
324
419
 
420
+ def test_parse_packages_argument():
421
+ assert parse_packages_argument(["curl, ca-certificates", "tar"]) == [
422
+ "curl", "ca-certificates", "tar"
423
+ ]
424
+ with pytest.raises(ValueError, match="package"):
425
+ parse_packages_argument(["curl && touch pwned"])
426
+
427
+
325
428
  def test_cli_help(capsys):
326
429
  parser = build_parser()
327
430
  with pytest.raises(SystemExit):
@@ -871,6 +974,27 @@ def test_integration_pass_basic():
871
974
  assert report.results[0].status == DistroStatus.PASS
872
975
 
873
976
 
977
+ @pytest.mark.integration
978
+ @pytest.mark.skipif(not is_docker_daemon_available(), reason="Docker daemon is not running or accessible")
979
+ @pytest.mark.parametrize("image", DEFAULT_MATRIX)
980
+ def test_integration_package_setup(tmp_path, image):
981
+ script = tmp_path / "needs-curl.sh"
982
+ script.write_text(
983
+ "#!/usr/bin/env bash\nset -e\ncurl --version >/dev/null\ntar --version >/dev/null\n"
984
+ "test -s /etc/ssl/certs/ca-certificates.crt\n", encoding="utf-8"
985
+ )
986
+ report = run_matrix(
987
+ str(script),
988
+ matrix=[image],
989
+ packages=["bash", "curl", "ca-certificates", "tar"],
990
+ shell_mode="auto",
991
+ network="bridge",
992
+ timeout=120,
993
+ )
994
+ assert report.all_passed is True, report.to_dict()
995
+ assert report.results[0].status == DistroStatus.PASS
996
+
997
+
874
998
  @pytest.mark.integration
875
999
  @pytest.mark.skipif(not is_docker_daemon_available(), reason="Docker daemon is not running or accessible")
876
1000
  def test_integration_fail_deps_alpine():
File without changes