opsscript-gate 0.7.0__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.7.0/.github/workflow-templates/properties.json → opsscript_gate-0.8.0/.github/workflow-templates/opsscript-gate.properties.json +2 -1
  2. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/.github/workflow-templates/opsscript-gate.yml +1 -1
  3. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/CHANGELOG.md +5 -2
  4. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/CITATION.cff +2 -2
  5. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/PKG-INFO +10 -7
  6. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/README.md +9 -6
  7. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/README.zh-CN.md +8 -5
  8. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/SECURITY.md +2 -2
  9. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/action.yml +6 -0
  10. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/docs/configuration.md +13 -4
  11. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/docs/gpt-oss.md +1 -1
  12. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/examples/github-actions/README.md +1 -1
  13. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/examples/github-actions/workflow.yml +1 -1
  14. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/pyproject.toml +1 -1
  15. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/src/opsscript_gate/__init__.py +1 -1
  16. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/src/opsscript_gate/cli.py +27 -2
  17. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/src/opsscript_gate/config.py +22 -2
  18. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/src/opsscript_gate/runner.py +48 -2
  19. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/tests/test_action_contract.py +2 -2
  20. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/tests/test_onboarding.py +17 -1
  21. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/tests/test_runner.py +75 -1
  22. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/.gitattributes +0 -0
  23. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  24. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
  25. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  26. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/.github/assets/social-preview.png +0 -0
  27. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/.github/pull_request_template.md +0 -0
  28. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/.github/workflows/demo.yml +0 -0
  29. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/.github/workflows/release.yml +0 -0
  30. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/.github/workflows/test.yml +0 -0
  31. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/.gitignore +0 -0
  32. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/CONTRIBUTING.md +0 -0
  33. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/LICENSE +0 -0
  34. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/ROADMAP.md +0 -0
  35. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/docs/troubleshooting.md +0 -0
  36. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/examples/alpine-incompatibility/README.md +0 -0
  37. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
  38. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/examples/basic/README.md +0 -0
  39. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/examples/basic/clean_setup.sh +0 -0
  40. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/examples/interactive-hang/README.md +0 -0
  41. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/examples/interactive-hang/prompt_confirm.sh +0 -0
  42. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/src/opsscript_gate/discovery.py +0 -0
  43. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/src/opsscript_gate/models.py +0 -0
  44. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/src/opsscript_gate/remediation.py +0 -0
  45. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/src/opsscript_gate/reporter.py +0 -0
  46. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/tests/fixtures/fail_deps.sh +0 -0
  47. {opsscript_gate-0.7.0 → opsscript_gate-0.8.0}/tests/fixtures/fail_hang.sh +0 -0
  48. {opsscript_gate-0.7.0 → 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.7.0
28
+ uses: Mresyzz/opsscript-gate@v0.8.0
29
29
  with:
30
30
  shell: auto
31
31
  network: none
@@ -1,14 +1,17 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.8.0] - 2026-10-02
4
4
 
5
5
  ### Added
6
- - Added SARIF 2.1.0 output for location-aware GitHub Code Scanning integration.
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.
7
9
 
8
10
  ## [0.7.0] - 2026-10-01
9
11
 
10
12
  ### Added
11
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.
12
15
 
13
16
  ### Changed
14
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.7.0
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.7.0
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
@@ -56,7 +56,7 @@ Try the browser scanner: [InstallReady](https://mresyzz.github.io/InstallReady/?
56
56
 
57
57
  ---
58
58
 
59
- ## Current release: v0.7.0
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
@@ -104,7 +107,7 @@ jobs:
104
107
  - uses: actions/checkout@v7
105
108
  with:
106
109
  persist-credentials: false
107
- - uses: Mresyzz/opsscript-gate@v0.7.0
110
+ - uses: Mresyzz/opsscript-gate@v0.8.0
108
111
  ```
109
112
 
110
113
  This zero-configuration form discovers shell scripts in the repository. Use
@@ -115,7 +118,7 @@ repository has a single installer. For self-hosted model installers, see the
115
118
  Or test a specific script with custom execution modes:
116
119
 
117
120
  ```yaml
118
- - uses: Mresyzz/opsscript-gate@v0.7.0
121
+ - uses: Mresyzz/opsscript-gate@v0.8.0
119
122
  with:
120
123
  script-path: scripts/install.sh
121
124
  shell: auto
@@ -147,7 +150,7 @@ unrelated scripts:
147
150
  with:
148
151
  persist-credentials: false
149
152
  fetch-depth: 0
150
- - uses: Mresyzz/opsscript-gate@v0.7.0
153
+ - uses: Mresyzz/opsscript-gate@v0.8.0
151
154
  with:
152
155
  changed-since: ${{ github.event.pull_request.base.sha }}
153
156
  preset: minimal
@@ -230,7 +233,7 @@ Use `sarif` when the result should appear in GitHub Code Scanning or another
230
233
  SARIF-compatible viewer. Upload it explicitly with the official upload action:
231
234
 
232
235
  ```yaml
233
- - uses: Mresyzz/opsscript-gate@v0.7.0
236
+ - uses: Mresyzz/opsscript-gate@v0.8.0
234
237
  with:
235
238
  script-path: scripts/install.sh
236
239
  format: sarif
@@ -26,7 +26,7 @@ Try the browser scanner: [InstallReady](https://mresyzz.github.io/InstallReady/?
26
26
 
27
27
  ---
28
28
 
29
- ## Current release: v0.7.0
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
@@ -74,7 +77,7 @@ jobs:
74
77
  - uses: actions/checkout@v7
75
78
  with:
76
79
  persist-credentials: false
77
- - uses: Mresyzz/opsscript-gate@v0.7.0
80
+ - uses: Mresyzz/opsscript-gate@v0.8.0
78
81
  ```
79
82
 
80
83
  This zero-configuration form discovers shell scripts in the repository. Use
@@ -85,7 +88,7 @@ repository has a single installer. For self-hosted model installers, see the
85
88
  Or test a specific script with custom execution modes:
86
89
 
87
90
  ```yaml
88
- - uses: Mresyzz/opsscript-gate@v0.7.0
91
+ - uses: Mresyzz/opsscript-gate@v0.8.0
89
92
  with:
90
93
  script-path: scripts/install.sh
91
94
  shell: auto
@@ -117,7 +120,7 @@ unrelated scripts:
117
120
  with:
118
121
  persist-credentials: false
119
122
  fetch-depth: 0
120
- - uses: Mresyzz/opsscript-gate@v0.7.0
123
+ - uses: Mresyzz/opsscript-gate@v0.8.0
121
124
  with:
122
125
  changed-since: ${{ github.event.pull_request.base.sha }}
123
126
  preset: minimal
@@ -200,7 +203,7 @@ Use `sarif` when the result should appear in GitHub Code Scanning or another
200
203
  SARIF-compatible viewer. Upload it explicitly with the official upload action:
201
204
 
202
205
  ```yaml
203
- - uses: Mresyzz/opsscript-gate@v0.7.0
206
+ - uses: Mresyzz/opsscript-gate@v0.8.0
204
207
  with:
205
208
  script-path: scripts/install.sh
206
209
  format: sarif
@@ -10,9 +10,9 @@ Bash 依赖、包管理器假设和交互式阻塞。它与 ShellCheck 互补。
10
10
 
11
11
  也可以直接打开 [InstallReady 在线扫描器](https://mresyzz.github.io/InstallReady/?repo=Mresyzz%2Fopsscript-gate),无需登录即可检查公开仓库。
12
12
 
13
- ## 当前版本 v0.7.0
13
+ ## 当前版本 v0.8.0
14
14
 
15
- v0.7.0 已作为正式版本发布;如果你要从源码验证当前分支,也可以执行:
15
+ v0.8.0 已作为正式版本发布;如果你要从源码验证当前分支,也可以执行:
16
16
 
17
17
  ```bash
18
18
  pip install -e .
@@ -28,7 +28,7 @@ opsscript-gate run --dry-run
28
28
  opsscript-gate run --format json --output reports/compatibility.json
29
29
  ```
30
30
 
31
- `init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.7.0`。
31
+ `init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.8.0`。
32
32
  预览不需要 Docker;真实运行需要 Python 3.10+
33
33
  和可访问的 Linux Docker 引擎。
34
34
  `opsscript-gate doctor` 可以在真实运行前检查 Python 和 Docker。
@@ -39,7 +39,10 @@ opsscript-gate run --format json --output reports/compatibility.json
39
39
 
40
40
  生成的配置默认排除 `tests/*`、`examples/*`,使用 Debian + Alpine,尊重
41
41
  脚本 shebang,并关闭网络。请根据实际脚本审核配置;需要下载文件时启用
42
- `--network bridge`。保留现有无配置行为:四个发行版、POSIX shell、bridge 网络。
42
+ `--network bridge`。安装器如果需要 `curl`、证书或其他明确工具,可以配置
43
+ `packages`;运行器会用镜像自带的 `apt-get` 或 `apk` 安装它们。包安装是可选的,
44
+ 只接受包名,不接受 Shell 命令,并且必须使用 `bridge` 网络。保留现有无配置行为:
45
+ 四个发行版、POSIX shell、bridge 网络。
43
46
 
44
47
  ## 本轮改进
45
48
 
@@ -62,7 +65,7 @@ Docker daemon 和可选的项目配置。
62
65
  - uses: actions/checkout@v7
63
66
  with:
64
67
  fetch-depth: 0
65
- - uses: Mresyzz/opsscript-gate@v0.7.0
68
+ - uses: Mresyzz/opsscript-gate@v0.8.0
66
69
  with:
67
70
  changed-since: ${{ github.event.pull_request.base.sha }}
68
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
 
@@ -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.7.0 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.7.0
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.7.0
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.7.0
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.7.0 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.7.0
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.7.0"
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.7.0"
3
+ __version__ = "0.8.0"
4
4
  __all__ = ["__version__"]
@@ -9,7 +9,7 @@ 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,
@@ -121,6 +121,12 @@ def build_parser() -> argparse.ArgumentParser:
121
121
  default="bridge",
122
122
  help="Network mode for containers: 'bridge' or 'none' (default: bridge)",
123
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
+ )
124
130
  run_parser.add_argument(
125
131
  "--changed-since",
126
132
  help="Only test shell scripts changed between this Git revision and HEAD",
@@ -143,6 +149,19 @@ def parse_matrix_argument(matrix_raw: str | None) -> list[str]:
143
149
  return images if images else DEFAULT_MATRIX
144
150
 
145
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
+
146
165
  def main(argv: list[str] | None = None) -> int:
147
166
  """Main CLI entrypoint."""
148
167
  parser = build_parser()
@@ -215,6 +234,9 @@ def main(argv: list[str] | None = None) -> int:
215
234
  raise ValueError(f"{key.replace('_', '-')} must be positive")
216
235
  if args.network not in ("bridge", "none"):
217
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")
218
240
  if args.matrix is not None and not any(x.strip() for x in args.matrix.split(",")):
219
241
  raise ValueError("matrix must contain at least one image")
220
242
  except ValueError as err:
@@ -263,13 +285,14 @@ def main(argv: list[str] | None = None) -> int:
263
285
  if args.dry_run:
264
286
  plan = {"dry_run": True, "scripts": target_scripts, "matrix": matrix,
265
287
  "executions": len(target_scripts) * len(matrix), "shell": args.shell,
266
- "network": args.network, "timeout": args.timeout}
288
+ "network": args.network, "packages": args.packages, "timeout": args.timeout}
267
289
  if changed_selection:
268
290
  plan["changed_since"] = args.changed_since
269
291
  output = json.dumps(plan, indent=2) if args.format == "json" else (
270
292
  "Execution preview (no scripts executed)\n"
271
293
  + "\n".join(f" {path}" for path in target_scripts)
272
294
  + f"\nImages: {', '.join(matrix)}\nContainer executions: {plan['executions']}"
295
+ + (f"\nPackages: {', '.join(args.packages)}" if args.packages else "")
273
296
  + f"\nShell: {args.shell} | Network: {args.network} | Timeout: {args.timeout}s"
274
297
  )
275
298
  return 0 if publish_output(output, args.output) else 1
@@ -292,6 +315,7 @@ def main(argv: list[str] | None = None) -> int:
292
315
  mem_limit=args.mem_limit,
293
316
  pids_limit=args.pids_limit,
294
317
  network=args.network,
318
+ packages=args.packages,
295
319
  )
296
320
  except DockerDaemonError as err:
297
321
  sys.stderr.write(f"Docker Error: {err}\n")
@@ -336,6 +360,7 @@ def main(argv: list[str] | None = None) -> int:
336
360
  mem_limit=args.mem_limit,
337
361
  pids_limit=args.pids_limit,
338
362
  network=args.network,
363
+ packages=args.packages,
339
364
  )
340
365
  except DockerDaemonError as err:
341
366
  sys.stderr.write(f"Docker Error: {err}\n")
@@ -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.7.0
86
+ - uses: Mresyzz/opsscript-gate@v0.8.0
67
87
  with:
68
88
  config: .opsscript-gate.json
69
89
  """,
@@ -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,11 +7,11 @@ 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
17
  assert "json, or sarif" in text
@@ -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
@@ -43,6 +43,7 @@ from opsscript_gate.runner import (
43
43
  run_matrix,
44
44
  run_on_distro,
45
45
  sanitize_diagnostic_text,
46
+ _wrap_with_package_setup,
46
47
  )
47
48
 
48
49
 
@@ -197,6 +198,50 @@ def test_run_on_distro_pass_mock(tmp_path):
197
198
  mock_container.remove.assert_called_once_with(force=True)
198
199
 
199
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
+
200
245
  def test_run_on_distro_fail_mock(tmp_path):
201
246
  script_file = tmp_path / "test_fail.sh"
202
247
  script_file.write_text("#!/bin/sh\napt-get: not found\nexit 127\n", encoding="utf-8")
@@ -372,6 +417,14 @@ def test_parse_matrix_argument():
372
417
  assert parse_matrix_argument("debian:12-slim, alpine:3.20") == ["debian:12-slim", "alpine:3.20"]
373
418
 
374
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
+
375
428
  def test_cli_help(capsys):
376
429
  parser = build_parser()
377
430
  with pytest.raises(SystemExit):
@@ -921,6 +974,27 @@ def test_integration_pass_basic():
921
974
  assert report.results[0].status == DistroStatus.PASS
922
975
 
923
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
+
924
998
  @pytest.mark.integration
925
999
  @pytest.mark.skipif(not is_docker_daemon_available(), reason="Docker daemon is not running or accessible")
926
1000
  def test_integration_fail_deps_alpine():
File without changes