opsscript-gate 0.9.5__tar.gz → 0.9.6__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 (64) hide show
  1. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/CHANGELOG.md +9 -0
  2. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/PKG-INFO +10 -11
  3. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/README.md +9 -10
  4. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/README.zh-CN.md +6 -4
  5. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/action.yml +15 -0
  6. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/pyproject.toml +1 -1
  7. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/__init__.py +1 -1
  8. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/cli.py +69 -4
  9. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/config.py +39 -2
  10. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/remediation.py +10 -0
  11. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/runner.py +37 -3
  12. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/test_action_contract.py +2 -2
  13. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/test_audit_coverage.py +206 -0
  14. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/test_onboarding.py +3 -1
  15. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.gitattributes +0 -0
  16. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  17. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
  18. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  19. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/assets/social-preview.png +0 -0
  20. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/pull_request_template.md +0 -0
  21. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/workflow-templates/opsscript-gate.properties.json +0 -0
  22. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/workflow-templates/opsscript-gate.yml +0 -0
  23. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/workflows/demo.yml +0 -0
  24. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/workflows/deploy-pages.yml +0 -0
  25. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/workflows/release.yml +0 -0
  26. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.github/workflows/test.yml +0 -0
  27. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/.gitignore +0 -0
  28. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/CITATION.cff +0 -0
  29. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/CONTRIBUTING.md +0 -0
  30. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/LICENSE +0 -0
  31. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/ROADMAP.md +0 -0
  32. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/SECURITY.md +0 -0
  33. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/demo.html +0 -0
  34. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/command-not-found.html +0 -0
  35. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/command-not-found.md +0 -0
  36. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/configuration.md +0 -0
  37. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/gpt-oss.md +0 -0
  38. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/index.html +0 -0
  39. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/installer-smoke-test.html +0 -0
  40. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/installer-smoke-test.md +0 -0
  41. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/llms.txt +0 -0
  42. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/robots.txt +0 -0
  43. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/sitemap.xml +0 -0
  44. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/docs/troubleshooting.md +0 -0
  45. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/alpine-incompatibility/README.md +0 -0
  46. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
  47. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/basic/README.md +0 -0
  48. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/basic/clean_setup.sh +0 -0
  49. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/github-actions/README.md +0 -0
  50. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/github-actions/installer-smoke.yml +0 -0
  51. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/github-actions/workflow.yml +0 -0
  52. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/interactive-hang/README.md +0 -0
  53. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/examples/interactive-hang/prompt_confirm.sh +0 -0
  54. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/index.html +0 -0
  55. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/llms.txt +0 -0
  56. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/discovery.py +0 -0
  57. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/models.py +0 -0
  58. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/src/opsscript_gate/reporter.py +0 -0
  59. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/conftest.py +0 -0
  60. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/fixtures/fail_deps.sh +0 -0
  61. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/fixtures/fail_hang.sh +0 -0
  62. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/fixtures/pass_basic.sh +0 -0
  63. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/test_installer_example.py +0 -0
  64. {opsscript_gate-0.9.5 → opsscript_gate-0.9.6}/tests/test_runner.py +0 -0
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.6] - 2026-11-08
4
+
5
+ ### Added
6
+ - Added safe container environment variable injection via CLI `--env` and configuration `"env": {...}`, with strict POSIX variable name validation (`^[A-Za-z_][A-Za-z0-9_]*$`).
7
+ - Added host workspace context read-only mounting via CLI `--workdir` and configuration `"workdir": "..."`, automatically setting `/workspace` as the container working directory while strictly preserving host immutability (`mode: "ro"`).
8
+ - Added `env` and `workdir` inputs to the GitHub Composite Action with transparent argument forwarding.
9
+ - Expanded `COMMON_COMMAND_PACKAGES` to provide cross-distro package remediation for standard operations tools (`find`, `xargs`, `grep`, `sed`, `awk`, `diff`, `patch`, `ssh`, `rsync`, `htop`).
10
+ - Added Windows case-insensitive path collision protection in `_output_overwrites_protected` to prevent accidental overwrites of critical project files (e.g. `README.md`).
11
+
3
12
  ## [0.9.5] - 2026-11-08
4
13
 
5
14
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opsscript-gate
3
- Version: 0.9.5
3
+ Version: 0.9.6
4
4
  Summary: Cross-distro shell script compatibility and portability checker for Debian, Ubuntu, and Alpine. Runs real containers to diagnose command not found.
5
5
  Project-URL: Homepage, https://mresyzz.github.io/opsscript-gate/
6
6
  Project-URL: Demo, https://mresyzz.github.io/opsscript-gate/
@@ -81,7 +81,7 @@ apt-get --version
81
81
  Run it locally with `opsscript-gate run ./install.sh`, or add the following step to CI:
82
82
 
83
83
  ```yaml
84
- - uses: Mresyzz/opsscript-gate@v0.9.4
84
+ - uses: Mresyzz/opsscript-gate@v0.9.6
85
85
  with:
86
86
  script-path: install.sh
87
87
  ```
@@ -90,12 +90,9 @@ The result identifies the failing distribution, exit code, line, missing command
90
90
 
91
91
  ---
92
92
 
93
- ## Current release: v0.9.4
93
+ ## Current release: v0.9.6
94
94
 
95
- The current release includes a project config file, a dry-run plan, presets, exclusions,
96
- saved reports, changed-script selection for pull requests, and repeat runs for detecting
97
- flaky runtime behavior. The project controls
98
- were introduced in v0.5.0; projects on v0.4.1 and earlier do not include them.
95
+ The current release includes safe environment variable passing (`--env`), host workspace context read-only mounting (`--workdir`), expanded package recommendations for standard Linux tools, structured diagnostic properties in SARIF, and composite action outputs.
99
96
 
100
97
  ```bash
101
98
  opsscript-gate init
@@ -104,6 +101,8 @@ opsscript-gate run --dry-run
104
101
  opsscript-gate run --format json --output reports/compatibility.json
105
102
  # Repeat each distro three times when diagnosing intermittent failures
106
103
  opsscript-gate run ./install.sh --repeat 3
104
+ # Pass environment variables and mount repository workspace read-only
105
+ opsscript-gate run ./install.sh --env APP_ENV=staging --workdir .
107
106
  ```
108
107
 
109
108
  `init` creates `.opsscript-gate.json` and a GitHub Actions workflow without replacing
@@ -148,7 +147,7 @@ jobs:
148
147
  - uses: actions/checkout@v7
149
148
  with:
150
149
  persist-credentials: false
151
- - uses: Mresyzz/opsscript-gate@v0.9.4
150
+ - uses: Mresyzz/opsscript-gate@v0.9.6
152
151
  ```
153
152
 
154
153
  This zero-configuration form discovers shell scripts in the repository. Use
@@ -159,7 +158,7 @@ repository has a single installer. For self-hosted model installers, see the
159
158
  Or test a specific script with custom execution modes:
160
159
 
161
160
  ```yaml
162
- - uses: Mresyzz/opsscript-gate@v0.9.4
161
+ - uses: Mresyzz/opsscript-gate@v0.9.6
163
162
  with:
164
163
  script-path: scripts/install.sh
165
164
  shell: auto
@@ -193,7 +192,7 @@ unrelated scripts:
193
192
  with:
194
193
  persist-credentials: false
195
194
  fetch-depth: 0
196
- - uses: Mresyzz/opsscript-gate@v0.9.4
195
+ - uses: Mresyzz/opsscript-gate@v0.9.6
197
196
  with:
198
197
  changed-since: ${{ github.event.pull_request.base.sha }}
199
198
  preset: minimal
@@ -284,7 +283,7 @@ Use `sarif` when the result should appear in GitHub Code Scanning or another
284
283
  SARIF-compatible viewer. Upload it explicitly with the official upload action:
285
284
 
286
285
  ```yaml
287
- - uses: Mresyzz/opsscript-gate@v0.9.4
286
+ - uses: Mresyzz/opsscript-gate@v0.9.6
288
287
  with:
289
288
  script-path: scripts/install.sh
290
289
  format: sarif
@@ -48,7 +48,7 @@ apt-get --version
48
48
  Run it locally with `opsscript-gate run ./install.sh`, or add the following step to CI:
49
49
 
50
50
  ```yaml
51
- - uses: Mresyzz/opsscript-gate@v0.9.4
51
+ - uses: Mresyzz/opsscript-gate@v0.9.6
52
52
  with:
53
53
  script-path: install.sh
54
54
  ```
@@ -57,12 +57,9 @@ The result identifies the failing distribution, exit code, line, missing command
57
57
 
58
58
  ---
59
59
 
60
- ## Current release: v0.9.4
60
+ ## Current release: v0.9.6
61
61
 
62
- The current release includes a project config file, a dry-run plan, presets, exclusions,
63
- saved reports, changed-script selection for pull requests, and repeat runs for detecting
64
- flaky runtime behavior. The project controls
65
- were introduced in v0.5.0; projects on v0.4.1 and earlier do not include them.
62
+ The current release includes safe environment variable passing (`--env`), host workspace context read-only mounting (`--workdir`), expanded package recommendations for standard Linux tools, structured diagnostic properties in SARIF, and composite action outputs.
66
63
 
67
64
  ```bash
68
65
  opsscript-gate init
@@ -71,6 +68,8 @@ opsscript-gate run --dry-run
71
68
  opsscript-gate run --format json --output reports/compatibility.json
72
69
  # Repeat each distro three times when diagnosing intermittent failures
73
70
  opsscript-gate run ./install.sh --repeat 3
71
+ # Pass environment variables and mount repository workspace read-only
72
+ opsscript-gate run ./install.sh --env APP_ENV=staging --workdir .
74
73
  ```
75
74
 
76
75
  `init` creates `.opsscript-gate.json` and a GitHub Actions workflow without replacing
@@ -115,7 +114,7 @@ jobs:
115
114
  - uses: actions/checkout@v7
116
115
  with:
117
116
  persist-credentials: false
118
- - uses: Mresyzz/opsscript-gate@v0.9.4
117
+ - uses: Mresyzz/opsscript-gate@v0.9.6
119
118
  ```
120
119
 
121
120
  This zero-configuration form discovers shell scripts in the repository. Use
@@ -126,7 +125,7 @@ repository has a single installer. For self-hosted model installers, see the
126
125
  Or test a specific script with custom execution modes:
127
126
 
128
127
  ```yaml
129
- - uses: Mresyzz/opsscript-gate@v0.9.4
128
+ - uses: Mresyzz/opsscript-gate@v0.9.6
130
129
  with:
131
130
  script-path: scripts/install.sh
132
131
  shell: auto
@@ -160,7 +159,7 @@ unrelated scripts:
160
159
  with:
161
160
  persist-credentials: false
162
161
  fetch-depth: 0
163
- - uses: Mresyzz/opsscript-gate@v0.9.4
162
+ - uses: Mresyzz/opsscript-gate@v0.9.6
164
163
  with:
165
164
  changed-since: ${{ github.event.pull_request.base.sha }}
166
165
  preset: minimal
@@ -251,7 +250,7 @@ Use `sarif` when the result should appear in GitHub Code Scanning or another
251
250
  SARIF-compatible viewer. Upload it explicitly with the official upload action:
252
251
 
253
252
  ```yaml
254
- - uses: Mresyzz/opsscript-gate@v0.9.4
253
+ - uses: Mresyzz/opsscript-gate@v0.9.6
255
254
  with:
256
255
  script-path: scripts/install.sh
257
256
  format: sarif
@@ -17,9 +17,9 @@ Bash 依赖、包管理器假设和交互式阻塞。它与 ShellCheck 互补。
17
17
 
18
18
  也可以直接打开 [OpsScript Gate 在线演示](https://mresyzz.github.io/opsscript-gate/),先查看 ShellCheck 与运行时验证的差异,再复制 workflow 到自己的仓库。
19
19
 
20
- ## 当前版本 v0.9.4
20
+ ## 当前版本 v0.9.6
21
21
 
22
- v0.9.4 是当前已发布版本;如果你要从源码验证当前分支,也可以执行:
22
+ v0.9.6 是当前已发布版本;如果你要从源码验证当前分支,也可以执行:
23
23
 
24
24
  ```bash
25
25
  pip install -e .
@@ -33,9 +33,11 @@ opsscript-gate init
33
33
  opsscript-gate doctor
34
34
  opsscript-gate run --dry-run
35
35
  opsscript-gate run --format json --output reports/compatibility.json
36
+ # 传递环境变量并以只读方式挂载本地工作空间
37
+ opsscript-gate run ./install.sh --env APP_ENV=staging --workdir .
36
38
  ```
37
39
 
38
- `init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.4`。
40
+ `init` 生成配置和 GitHub 工作流,不覆盖已有文件。工作流引用 `v0.9.6`。
39
41
  预览不需要 Docker;真实运行需要 Python 3.10+
40
42
  和可访问的 Linux Docker 引擎。
41
43
  `opsscript-gate doctor` 可以在真实运行前检查 Python 和 Docker。
@@ -74,7 +76,7 @@ Docker daemon 和可选的项目配置。
74
76
  - uses: actions/checkout@v7
75
77
  with:
76
78
  fetch-depth: 0
77
- - uses: Mresyzz/opsscript-gate@v0.9.4
79
+ - uses: Mresyzz/opsscript-gate@v0.9.6
78
80
  with:
79
81
  changed-since: ${{ github.event.pull_request.base.sha }}
80
82
  preset: minimal
@@ -50,6 +50,14 @@ inputs:
50
50
  description: 'Optional comma- or newline-separated packages to install before running (requires network=bridge)'
51
51
  required: false
52
52
  default: ''
53
+ env:
54
+ description: 'Newline-separated list of environment variables to pass into containers (e.g. KEY=VALUE or KEY)'
55
+ required: false
56
+ default: ''
57
+ workdir:
58
+ description: 'Host directory to mount read-only into /workspace as the container working directory'
59
+ required: false
60
+ default: ''
53
61
 
54
62
  config:
55
63
  description: 'Path to project JSON configuration'
@@ -116,6 +124,8 @@ runs:
116
124
  INPUT_PIDS_LIMIT: ${{ inputs.pids-limit }}
117
125
  INPUT_NETWORK: ${{ inputs.network }}
118
126
  INPUT_PACKAGES: ${{ inputs.packages }}
127
+ INPUT_ENV: ${{ inputs.env }}
128
+ INPUT_WORKDIR: ${{ inputs.workdir }}
119
129
  INPUT_CONFIG: ${{ inputs.config }}
120
130
  INPUT_PRESET: ${{ inputs.preset }}
121
131
  INPUT_EXCLUDE: ${{ inputs.exclude }}
@@ -137,6 +147,7 @@ runs:
137
147
  [ -z "$INPUT_PIDS_LIMIT" ] || ARGS+=(--pids-limit "$INPUT_PIDS_LIMIT")
138
148
  [ -z "$INPUT_NETWORK" ] || ARGS+=(--network "$INPUT_NETWORK")
139
149
  [ -z "$INPUT_PACKAGES" ] || ARGS+=(--packages "$INPUT_PACKAGES")
150
+ [ -z "$INPUT_WORKDIR" ] || ARGS+=(--workdir "$INPUT_WORKDIR")
140
151
  [ -z "$INPUT_MATRIX" ] || ARGS+=(--matrix "$INPUT_MATRIX")
141
152
  [ -z "$INPUT_JOBS" ] || ARGS+=(--jobs "$INPUT_JOBS")
142
153
  [ -z "$INPUT_CONFIG" ] || ARGS+=(--config "$INPUT_CONFIG")
@@ -144,6 +155,10 @@ runs:
144
155
  [ -z "$INPUT_MAX_SCRIPTS" ] || ARGS+=(--max-scripts "$INPUT_MAX_SCRIPTS")
145
156
  [ -z "$INPUT_OUTPUT" ] || ARGS+=(--output "$INPUT_OUTPUT")
146
157
  [ -z "$INPUT_CHANGED_SINCE" ] || ARGS+=(--changed-since "$INPUT_CHANGED_SINCE")
158
+ while IFS= read -r env_var; do
159
+ env_var="${env_var%$'\r'}"
160
+ [ -z "$env_var" ] || ARGS+=("--env" "$env_var")
161
+ done <<< "$INPUT_ENV"
147
162
  while IFS= read -r pattern; do
148
163
  pattern="${pattern%$'\r'}"
149
164
  [ -z "$pattern" ] || ARGS+=("--exclude" "$pattern")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "opsscript-gate"
7
- version = "0.9.5"
7
+ version = "0.9.6"
8
8
  description = "Cross-distro shell script compatibility and portability checker for Debian, Ubuntu, and Alpine. Runs real containers to diagnose command not found."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,4 +1,4 @@
1
1
  """OpsScript Gate: Lightweight cross-distro compatibility pre-check for Linux ops scripts."""
2
2
 
3
- __version__ = "0.9.5"
3
+ __version__ = "0.9.6"
4
4
  __all__ = ["__version__"]
@@ -9,7 +9,20 @@ 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, MAX_MATRIX_IMAGES, MAX_REPEAT_ATTEMPTS, MAX_TIMEOUT_SECONDS, PRESETS, init_project, load_config, validate_memory_limit, validate_packages
12
+ from opsscript_gate.config import (
13
+ DEFAULTS,
14
+ ENV_KEY_RE,
15
+ MAX_MATRIX_IMAGES,
16
+ MAX_REPEAT_ATTEMPTS,
17
+ MAX_TIMEOUT_SECONDS,
18
+ PRESETS,
19
+ init_project,
20
+ load_config,
21
+ validate_env,
22
+ validate_memory_limit,
23
+ validate_packages,
24
+ validate_workdir,
25
+ )
13
26
  from opsscript_gate.models import MultiScriptReport, RunReport
14
27
  from opsscript_gate.reporter import (
15
28
  emit_github_annotations,
@@ -144,6 +157,18 @@ def build_parser() -> argparse.ArgumentParser:
144
157
  default=None,
145
158
  help="Optional comma-separated packages to install in each container before running (repeatable; requires network=bridge)",
146
159
  )
160
+ run_parser.add_argument(
161
+ "--env",
162
+ action="append",
163
+ default=None,
164
+ help="Environment variable to pass into containers in KEY=VALUE or KEY format (repeatable)",
165
+ )
166
+ run_parser.add_argument(
167
+ "--workdir",
168
+ type=str,
169
+ default=None,
170
+ help="Host directory to mount read-only into /workspace as the container working directory",
171
+ )
147
172
  run_parser.add_argument(
148
173
  "--changed-since",
149
174
  help="Only test shell scripts changed between this Git revision and HEAD",
@@ -181,6 +206,33 @@ def parse_packages_argument(packages_raw: list[str] | None) -> list[str]:
181
206
  return validate_packages(packages)
182
207
 
183
208
 
209
+ def parse_env_arguments(
210
+ env_args: list[str] | None,
211
+ base_env: dict[str, str] | None = None,
212
+ ) -> dict[str, str]:
213
+ """Parse repeated --env arguments, resolving KEY=VALUE or KEY from host env."""
214
+ merged: dict[str, str] = dict(base_env or {})
215
+ if not env_args:
216
+ return validate_env(merged) if merged else {}
217
+ for item in env_args:
218
+ for entry in item.splitlines():
219
+ entry = entry.strip()
220
+ if not entry:
221
+ continue
222
+ if "=" in entry:
223
+ key, val = entry.split("=", 1)
224
+ else:
225
+ key = entry
226
+ val = os.environ.get(key, "")
227
+ key = key.strip()
228
+ if not ENV_KEY_RE.fullmatch(key):
229
+ raise ValueError(
230
+ f"Invalid environment variable name: '{key}'. Must be a valid POSIX identifier (^[A-Za-z_][A-Za-z0-9_]*$)"
231
+ )
232
+ merged[key] = val
233
+ return validate_env(merged)
234
+
235
+
184
236
  def run_checked_matrix(
185
237
  args: argparse.Namespace, script_path: str, matrix: list[str]
186
238
  ) -> RunReport | None:
@@ -197,6 +249,8 @@ def run_checked_matrix(
197
249
  network=args.network,
198
250
  packages=args.packages,
199
251
  repeat=args.repeat,
252
+ env=args.env,
253
+ workdir=args.workdir,
200
254
  )
201
255
  except DockerDaemonError as err:
202
256
  sys.stderr.write(f"Docker Error: {err}\n")
@@ -207,8 +261,11 @@ def run_checked_matrix(
207
261
 
208
262
  def _output_overwrites_protected(output_path: Path, protected: list[Path]) -> bool:
209
263
  """Reject paths that resolve to or share an inode with a protected file."""
210
- if output_path in protected:
211
- return True
264
+ for p in protected:
265
+ if output_path == p:
266
+ return True
267
+ if os.name == "nt" and str(output_path).lower() == str(p).lower():
268
+ return True
212
269
  if not output_path.exists():
213
270
  return False
214
271
  for target in protected:
@@ -316,6 +373,10 @@ def main(argv: list[str] | None = None) -> int:
316
373
  args.packages = parse_packages_argument(args.packages)
317
374
  if args.packages and args.network == "none":
318
375
  raise ValueError("packages require network=bridge")
376
+ base_env = config.get("env") if isinstance(config.get("env"), dict) else {}
377
+ args.env = parse_env_arguments(args.env, base_env=base_env)
378
+ if args.workdir:
379
+ args.workdir = validate_workdir(args.workdir)
319
380
  if args.matrix is not None and not any(x.strip() for x in args.matrix.split(",")):
320
381
  raise ValueError("matrix must contain at least one image")
321
382
  except ValueError as err:
@@ -385,14 +446,18 @@ def main(argv: list[str] | None = None) -> int:
385
446
  plan = {"dry_run": True, "scripts": target_scripts, "matrix": matrix,
386
447
  "executions": len(target_scripts) * len(matrix) * args.repeat, "shell": args.shell,
387
448
  "network": args.network, "packages": args.packages, "timeout": args.timeout,
388
- "repeat": args.repeat}
449
+ "repeat": args.repeat, "env": args.env, "workdir": args.workdir}
389
450
  if changed_selection:
390
451
  plan["changed_since"] = args.changed_since
452
+ env_preview = f"\nEnv: {', '.join(f'{k}={v}' for k, v in args.env.items())}" if args.env else ""
453
+ workdir_preview = f"\nWorkdir: {args.workdir}" if args.workdir else ""
391
454
  output = json.dumps(plan, indent=2) if args.format == "json" else (
392
455
  "Execution preview (no scripts executed)\n"
393
456
  + "\n".join(f" {path}" for path in target_scripts)
394
457
  + f"\nImages: {', '.join(matrix)}\nContainer executions: {plan['executions']}"
395
458
  + (f"\nPackages: {', '.join(args.packages)}" if args.packages else "")
459
+ + env_preview
460
+ + workdir_preview
396
461
  + f"\nShell: {args.shell} | Network: {args.network} | Timeout: {args.timeout}s | Repeat: {args.repeat}"
397
462
  )
398
463
  return 0 if publish_output(output, args.output) else 1
@@ -17,7 +17,7 @@ DEFAULTS = {
17
17
  "matrix": None, "preset": None, "jobs": None, "timeout": 60,
18
18
  "shell": "posix", "mem_limit": "256m", "pids_limit": 128,
19
19
  "network": "bridge", "packages": [], "exclude": [], "max_scripts": 20,
20
- "repeat": 1,
20
+ "repeat": 1, "env": {}, "workdir": None,
21
21
  }
22
22
 
23
23
  MAX_CONFIG_SIZE_BYTES = 256 * 1024
@@ -26,6 +26,39 @@ MAX_TIMEOUT_SECONDS = 3600
26
26
  MAX_MATRIX_IMAGES = 32
27
27
 
28
28
  PACKAGE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9+._:-]*$")
29
+ ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
30
+
31
+
32
+ def validate_env(value: object) -> dict[str, str]:
33
+ """Validate and normalize environment variables specified in configuration or CLI."""
34
+ if not isinstance(value, dict):
35
+ raise ValueError("env must be a JSON object mapping variable names to string values")
36
+ normalized: dict[str, str] = {}
37
+ for k, v in value.items():
38
+ if not isinstance(k, str) or not ENV_KEY_RE.fullmatch(k):
39
+ raise ValueError(
40
+ f"Invalid environment variable name '{k}'. Must be a valid POSIX identifier (^[A-Za-z_][A-Za-z0-9_]*$)"
41
+ )
42
+ if not isinstance(v, str):
43
+ raise ValueError(
44
+ f"Environment variable '{k}' value must be a string, got {type(v).__name__}"
45
+ )
46
+ normalized[k] = v
47
+ return normalized
48
+
49
+
50
+ def validate_workdir(value: object) -> str:
51
+ """Validate that the workspace directory exists, is a directory, and is not root."""
52
+ if not isinstance(value, str) or not value.strip():
53
+ raise ValueError("workdir must be a nonempty path string")
54
+ path = Path(value.strip()).resolve()
55
+ if not path.exists():
56
+ raise ValueError(f"workdir directory does not exist: {value}")
57
+ if not path.is_dir():
58
+ raise ValueError(f"workdir is not a directory: {value}")
59
+ if path == path.parent:
60
+ raise ValueError(f"workdir must not be the root directory: {value}")
61
+ return str(path)
29
62
 
30
63
 
31
64
  def validate_memory_limit(value: object) -> str:
@@ -93,6 +126,10 @@ def load_config(filename: str | None) -> dict:
93
126
  validate_packages(value)
94
127
  elif key == "mem_limit":
95
128
  validate_memory_limit(value)
129
+ elif key == "env":
130
+ validate_env(value)
131
+ elif key == "workdir":
132
+ validate_workdir(value)
96
133
  elif not isinstance(value, str) or not value.strip():
97
134
  raise ValueError(f"{key} must be a nonempty string")
98
135
  for key, choices in {"shell": ("posix", "auto", "shebang"),
@@ -126,7 +163,7 @@ jobs:
126
163
  - uses: actions/checkout@v7
127
164
  with:
128
165
  persist-credentials: false
129
- - uses: Mresyzz/opsscript-gate@v0.9.5
166
+ - uses: Mresyzz/opsscript-gate@v0.9.6
130
167
  with:
131
168
  config: .opsscript-gate.json
132
169
  """,
@@ -123,6 +123,16 @@ COMMON_COMMAND_PACKAGES: dict[str, dict[str, str]] = {
123
123
  "envsubst": {"debian": "gettext-base", "alpine": "gettext", "rhel": "gettext"},
124
124
  "crontab": {"debian": "cron", "alpine": "dcron", "rhel": "cronie"},
125
125
  "ps": {"debian": "procps", "alpine": "procps", "rhel": "procps-ng"},
126
+ "find": {"debian": "findutils", "alpine": "findutils", "rhel": "findutils"},
127
+ "xargs": {"debian": "findutils", "alpine": "findutils", "rhel": "findutils"},
128
+ "grep": {"debian": "grep", "alpine": "grep", "rhel": "grep"},
129
+ "sed": {"debian": "sed", "alpine": "sed", "rhel": "sed"},
130
+ "awk": {"debian": "gawk", "alpine": "gawk", "rhel": "gawk"},
131
+ "diff": {"debian": "diffutils", "alpine": "diffutils", "rhel": "diffutils"},
132
+ "patch": {"debian": "patch", "alpine": "patch", "rhel": "patch"},
133
+ "ssh": {"debian": "openssh-client", "alpine": "openssh-client", "rhel": "openssh-clients"},
134
+ "rsync": {"debian": "rsync", "alpine": "rsync", "rhel": "rsync"},
135
+ "htop": {"debian": "htop", "alpine": "htop", "rhel": "htop"},
126
136
  }
127
137
 
128
138
 
@@ -16,7 +16,17 @@ from docker.errors import DockerException, ImageNotFound
16
16
  from concurrent.futures import ThreadPoolExecutor, as_completed
17
17
  from dataclasses import dataclass
18
18
  from enum import Enum
19
- from opsscript_gate.config import MAX_MATRIX_IMAGES, MAX_REPEAT_ATTEMPTS, MAX_TIMEOUT_SECONDS, PACKAGE_NAME_RE, validate_memory_limit, validate_packages
19
+ from opsscript_gate.config import (
20
+ ENV_KEY_RE,
21
+ MAX_MATRIX_IMAGES,
22
+ MAX_REPEAT_ATTEMPTS,
23
+ MAX_TIMEOUT_SECONDS,
24
+ PACKAGE_NAME_RE,
25
+ validate_env,
26
+ validate_memory_limit,
27
+ validate_packages,
28
+ validate_workdir,
29
+ )
20
30
  from opsscript_gate.models import DistroStatus, FailureDiagnostic, RunReport, ShellMode, SingleResult
21
31
  from opsscript_gate.remediation import generate_remediation_hint, resolve_package_recommendation
22
32
 
@@ -804,6 +814,8 @@ def _runtime_option_error(
804
814
  pids_limit: int,
805
815
  network: str,
806
816
  packages: Sequence[str],
817
+ env: dict[str, str] | None = None,
818
+ workdir: str | None = None,
807
819
  ) -> str | None:
808
820
  """Validate execution options shared by the public runner entry points."""
809
821
  if isinstance(packages, (str, bytes)):
@@ -823,6 +835,10 @@ def _runtime_option_error(
823
835
  try:
824
836
  validate_memory_limit(mem_limit)
825
837
  validate_packages(list(packages))
838
+ if env is not None:
839
+ validate_env(env)
840
+ if workdir is not None:
841
+ validate_workdir(workdir)
826
842
  except (TypeError, ValueError) as exc:
827
843
  return str(exc)
828
844
  return None
@@ -859,6 +875,8 @@ def run_on_distro(
859
875
  network: str = "bridge",
860
876
  packages: Sequence[str] | None = None,
861
877
  prepared_script_path: str | None = None,
878
+ env: dict[str, str] | None = None,
879
+ workdir: str | None = None,
862
880
  ) -> SingleResult:
863
881
  """
864
882
  Run a target script inside an unprivileged, non-interactive container.
@@ -874,7 +892,7 @@ def run_on_distro(
874
892
  error_message="packages must be a list of package names",
875
893
  )
876
894
  package_list = list(packages or [])
877
- option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list)
895
+ option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list, env=env, workdir=workdir)
878
896
  if option_error:
879
897
  return SingleResult(
880
898
  distro=distro,
@@ -988,10 +1006,20 @@ def run_on_distro(
988
1006
  "mode": "ro",
989
1007
  }
990
1008
  }
1009
+ if workdir is not None:
1010
+ safe_workdir = normalize_host_path_for_docker(os.path.abspath(workdir))
1011
+ volumes[safe_workdir] = {
1012
+ "bind": "/workspace",
1013
+ "mode": "ro",
1014
+ }
1015
+
991
1016
  environment = {
992
1017
  "DEBIAN_FRONTEND": "noninteractive",
993
1018
  "CI": "true",
994
1019
  }
1020
+ if env:
1021
+ environment.update(env)
1022
+
995
1023
  create_kwargs = {
996
1024
  "image": distro,
997
1025
  "command": container_command,
@@ -1014,6 +1042,8 @@ def run_on_distro(
1014
1042
  ),
1015
1043
  "detach": True,
1016
1044
  }
1045
+ if workdir is not None:
1046
+ create_kwargs["working_dir"] = "/workspace"
1017
1047
  try:
1018
1048
  container = client.containers.create(**create_kwargs)
1019
1049
  except ImageNotFound:
@@ -1188,6 +1218,8 @@ def run_matrix(
1188
1218
  network: str = "bridge",
1189
1219
  packages: Sequence[str] | None = None,
1190
1220
  repeat: int = 1,
1221
+ env: dict[str, str] | None = None,
1222
+ workdir: str | None = None,
1191
1223
  ) -> RunReport:
1192
1224
  """
1193
1225
  Run the compatibility check across all specified Linux distributions,
@@ -1247,7 +1279,7 @@ def run_matrix(
1247
1279
  if isinstance(packages, (str, bytes)):
1248
1280
  raise ValueError("packages must be a list of package names")
1249
1281
  package_list = list(packages or [])
1250
- option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list)
1282
+ option_error = _runtime_option_error(timeout, mem_limit, pids_limit, network, package_list, env=env, workdir=workdir)
1251
1283
  if option_error:
1252
1284
  raise ValueError(option_error)
1253
1285
  package_list = validate_packages(package_list)
@@ -1299,6 +1331,8 @@ def run_matrix(
1299
1331
  network=network,
1300
1332
  packages=package_list,
1301
1333
  prepared_script_path=prepared_script,
1334
+ env=env,
1335
+ workdir=workdir,
1302
1336
  ))
1303
1337
 
1304
1338
  if repeat == 1:
@@ -9,12 +9,12 @@ ROOT = Path(__file__).parents[1]
9
9
  def test_action_metadata_is_valid_and_inputs_are_wired():
10
10
  text = (ROOT / "action.yml").read_text(encoding="utf-8")
11
11
  assert "using: 'composite'" in text
12
- for name in ("config", "preset", "exclude", "max-scripts", "dry-run", "output", "changed-since", "packages", "repeat"):
12
+ for name in ("config", "preset", "exclude", "max-scripts", "dry-run", "output", "changed-since", "packages", "repeat", "env", "workdir"):
13
13
  assert f" {name}:" in text
14
14
  run_text = text
15
15
  for flag in ("--timeout", "--format", "--shell", "--mem-limit", "--pids-limit",
16
16
  "--network", "--packages", "--matrix", "--jobs", "--config", "--preset", "--repeat",
17
- "--max-scripts", "--output", "--exclude"):
17
+ "--max-scripts", "--output", "--exclude", "--env", "--workdir"):
18
18
  assert flag in run_text
19
19
  assert "json, or sarif" in text
20
20
  assert "--changed-since" in run_text
@@ -721,3 +721,209 @@ def test_reporter_rendering_suggested_package_and_new_diagnostics():
721
721
  props = sarif["runs"][0]["results"][0]["properties"]
722
722
  assert props["diagnosticKind"] == "permission_denied"
723
723
 
724
+
725
+ # ==============================================================================
726
+ # 11. 环境变量安全注入与工作空间目录挂载专项测试 (v0.9.6)
727
+ # ==============================================================================
728
+
729
+ def test_validate_env_valid_and_invalid():
730
+ from opsscript_gate.config import validate_env
731
+
732
+ # 合法情况
733
+ assert validate_env({"FOO": "bar", "_BAZ_123": "val"}) == {"FOO": "bar", "_BAZ_123": "val"}
734
+ assert validate_env({}) == {}
735
+
736
+ # 非字典拒绝
737
+ with pytest.raises(ValueError, match="env must be a JSON object"):
738
+ validate_env(["FOO=bar"])
739
+
740
+ # 非法 key 命名拒绝
741
+ with pytest.raises(ValueError, match="Invalid environment variable name '123FOO'"):
742
+ validate_env({"123FOO": "bar"})
743
+
744
+ with pytest.raises(ValueError, match="Invalid environment variable name 'FOO-BAR'"):
745
+ validate_env({"FOO-BAR": "bar"})
746
+
747
+ # 非字符串 value 拒绝
748
+ with pytest.raises(ValueError, match="Environment variable 'FOO' value must be a string"):
749
+ validate_env({"FOO": 123})
750
+
751
+
752
+ def test_validate_workdir_valid_and_invalid(tmp_path):
753
+ from opsscript_gate.config import validate_workdir
754
+
755
+ # 合法目录
756
+ sub_dir = tmp_path / "my_workspace"
757
+ sub_dir.mkdir()
758
+ assert validate_workdir(str(sub_dir)) == str(sub_dir.resolve())
759
+
760
+ # 不存在目录拒绝
761
+ with pytest.raises(ValueError, match="workdir directory does not exist"):
762
+ validate_workdir(str(tmp_path / "non_existent"))
763
+
764
+ # 指向普通文件拒绝
765
+ file_path = tmp_path / "regular.sh"
766
+ file_path.write_text("#!/bin/sh\n")
767
+ with pytest.raises(ValueError, match="workdir is not a directory"):
768
+ validate_workdir(str(file_path))
769
+
770
+ # 空或非字符串拒绝
771
+ with pytest.raises(ValueError, match="workdir must be a nonempty path string"):
772
+ validate_workdir("")
773
+ with pytest.raises(ValueError, match="workdir must be a nonempty path string"):
774
+ validate_workdir(123)
775
+
776
+
777
+ def test_config_env_and_workdir_support(tmp_path):
778
+ from opsscript_gate.config import load_config
779
+ import json
780
+
781
+ ws = tmp_path / "ws"
782
+ ws.mkdir()
783
+ cfg_file = tmp_path / ".opsscript-gate.json"
784
+ cfg_file.write_text(json.dumps({
785
+ "env": {"APP_PORT": "8080", "APP_ENV": "staging"},
786
+ "workdir": str(ws),
787
+ }))
788
+
789
+ loaded = load_config(str(cfg_file))
790
+ assert loaded["env"] == {"APP_PORT": "8080", "APP_ENV": "staging"}
791
+ assert loaded["workdir"] == str(ws.resolve())
792
+
793
+
794
+ def test_parse_env_arguments_cli(monkeypatch):
795
+ from opsscript_gate.cli import parse_env_arguments
796
+
797
+ monkeypatch.setenv("EXISTING_VAR", "host_value")
798
+
799
+ # KEY=VALUE 格式
800
+ res = parse_env_arguments(["FOO=123", "BAR=abc=def"])
801
+ assert res == {"FOO": "123", "BAR": "abc=def"}
802
+
803
+ # 单独 KEY(从宿主机环境变量获取)
804
+ res_host = parse_env_arguments(["EXISTING_VAR", "NON_EXISTING"])
805
+ assert res_host["EXISTING_VAR"] == "host_value"
806
+ assert res_host["NON_EXISTING"] == ""
807
+
808
+ # 多行输入
809
+ res_multiline = parse_env_arguments(["A=1\nB=2\n\nC=3"])
810
+ assert res_multiline == {"A": "1", "B": "2", "C": "3"}
811
+
812
+ # 优先级合并:命令行覆盖 base_env
813
+ res_merge = parse_env_arguments(["A=cli_val", "B=2"], base_env={"A": "base_val", "C": "3"})
814
+ assert res_merge == {"A": "cli_val", "B": "2", "C": "3"}
815
+
816
+ # 非法名称抛错
817
+ with pytest.raises(ValueError, match="Invalid environment variable name: 'BAD-NAME'"):
818
+ parse_env_arguments(["BAD-NAME=1"])
819
+
820
+
821
+ def test_cli_output_overwrites_protected_case_insensitive(tmp_path, monkeypatch):
822
+ from opsscript_gate.cli import _output_overwrites_protected
823
+ from pathlib import Path
824
+
825
+ file_a = tmp_path / "README.md"
826
+ file_a.write_text("hello")
827
+
828
+ # 同一路径
829
+ assert _output_overwrites_protected(file_a, [file_a]) is True
830
+
831
+ # 验证在 Windows 平台下大小写不敏感碰撞保护
832
+ monkeypatch.setattr("os.name", "nt")
833
+ file_lower = tmp_path / "readme.md"
834
+ assert _output_overwrites_protected(file_lower, [file_a]) is True
835
+
836
+ # 未受保护的文件
837
+ file_other = tmp_path / "other.txt"
838
+ assert _output_overwrites_protected(file_other, [file_a]) is False
839
+
840
+
841
+ def test_run_matrix_with_env_and_workdir_mock(tmp_path):
842
+ from opsscript_gate.runner import run_matrix
843
+ from unittest.mock import MagicMock
844
+
845
+ script = tmp_path / "test.sh"
846
+ script.write_text("#!/bin/sh\necho ok\n")
847
+
848
+ ws = tmp_path / "my_workdir"
849
+ ws.mkdir()
850
+
851
+ mock_client = MagicMock()
852
+ mock_container = MagicMock()
853
+ mock_container.wait.return_value = {"StatusCode": 0}
854
+ mock_container.logs.return_value = b"ok\n"
855
+ mock_container.attrs = {"State": {"ExitCode": 0}}
856
+ mock_client.containers.create.return_value = mock_container
857
+
858
+ report = run_matrix(
859
+ script_path=str(script),
860
+ matrix=["debian:12-slim"],
861
+ client=mock_client,
862
+ env={"TEST_KEY": "test_val"},
863
+ workdir=str(ws),
864
+ )
865
+
866
+ assert report.all_passed is True
867
+ create_call = mock_client.containers.create.call_args[1]
868
+
869
+ # 验证环境变量合并
870
+ assert create_call["environment"]["TEST_KEY"] == "test_val"
871
+ assert create_call["environment"]["CI"] == "true"
872
+ assert create_call["environment"]["DEBIAN_FRONTEND"] == "noninteractive"
873
+
874
+ # 验证工作区只读挂载与工作目录配置
875
+ assert create_call["working_dir"] == "/workspace"
876
+ found_ws_mount = False
877
+ for host_vol, cfg in create_call["volumes"].items():
878
+ if cfg["bind"] == "/workspace":
879
+ assert cfg["mode"] == "ro"
880
+ found_ws_mount = True
881
+ assert found_ws_mount is True
882
+
883
+
884
+ def test_cli_dry_run_displays_env_and_workdir(tmp_path, capsys):
885
+ from opsscript_gate.cli import main
886
+ import json
887
+
888
+ script = tmp_path / "script.sh"
889
+ script.write_text("#!/bin/sh\n")
890
+
891
+ ws = tmp_path / "workspace"
892
+ ws.mkdir()
893
+
894
+ code = main([
895
+ "run",
896
+ str(script),
897
+ "--dry-run",
898
+ "--format", "json",
899
+ "--env", "VAR_A=1",
900
+ "--workdir", str(ws),
901
+ ])
902
+ assert code == 0
903
+ captured = capsys.readouterr().out
904
+ plan = json.loads(captured)
905
+ assert plan["env"] == {"VAR_A": "1"}
906
+ assert plan["workdir"] == str(ws.resolve())
907
+
908
+
909
+ def test_expanded_remediation_packages():
910
+ from opsscript_gate.remediation import resolve_package_recommendation
911
+
912
+ for cmd, deb_expected, alp_expected in [
913
+ ("find", "findutils", "findutils"),
914
+ ("xargs", "findutils", "findutils"),
915
+ ("grep", "grep", "grep"),
916
+ ("sed", "sed", "sed"),
917
+ ("awk", "gawk", "gawk"),
918
+ ("diff", "diffutils", "diffutils"),
919
+ ("patch", "patch", "patch"),
920
+ ("ssh", "openssh-client", "openssh-client"),
921
+ ("rsync", "rsync", "rsync"),
922
+ ("htop", "htop", "htop"),
923
+ ]:
924
+ pkg_deb, _ = resolve_package_recommendation(cmd, "debian:12-slim")
925
+ assert pkg_deb == deb_expected, f"Failed for debian {cmd}"
926
+ pkg_alp, _ = resolve_package_recommendation(cmd, "alpine:3.20")
927
+ assert pkg_alp == alp_expected, f"Failed for alpine {cmd}"
928
+
929
+
@@ -81,7 +81,9 @@ def test_init_existing_workflow_leaves_config_absent(project):
81
81
  {"jobs": 0}, {"exclude": "tests/*"}, {"shell": "fish"}, {"network": "host"},
82
82
  {"preset": "unknown"}, {"matrix": "alpine", "preset": "minimal"},
83
83
  {"matrix": ","}, {"mem_limit": "0"}, {"mem_limit": "unlimited"}, {"repeat": 21}, {"timeout": 3601},
84
- {"packages": "curl"}, {"packages": ["curl; touch pwned"]}])
84
+ {"packages": "curl"}, {"packages": ["curl; touch pwned"]},
85
+ {"env": "not_a_dict"}, {"env": {"1_BAD": "val"}}, {"env": {"OK": 123}},
86
+ {"workdir": 123}, {"workdir": ""}])
85
87
  def test_invalid_config(project, config):
86
88
  Path(CONFIG_NAME).write_text(json.dumps(config))
87
89
  with patch("opsscript_gate.cli.run_matrix") as run:
File without changes
File without changes
File without changes