opsscript-gate 0.2.1__tar.gz → 0.3.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 (37) hide show
  1. opsscript_gate-0.3.0/.github/workflows/release.yml +213 -0
  2. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/CHANGELOG.md +10 -0
  3. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/PKG-INFO +29 -20
  4. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/README.md +28 -19
  5. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/SECURITY.md +3 -1
  6. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/github-actions/workflow.yml +2 -1
  7. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/pyproject.toml +1 -1
  8. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/src/opsscript_gate/__init__.py +1 -1
  9. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/src/opsscript_gate/models.py +19 -0
  10. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/src/opsscript_gate/reporter.py +13 -1
  11. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/src/opsscript_gate/runner.py +100 -1
  12. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/tests/test_runner.py +248 -2
  13. opsscript_gate-0.2.1/.github/workflows/release.yml +0 -121
  14. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.gitattributes +0 -0
  15. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  16. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
  17. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  18. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.github/assets/social-preview.png +0 -0
  19. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.github/pull_request_template.md +0 -0
  20. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.github/workflows/demo.yml +0 -0
  21. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.github/workflows/test.yml +0 -0
  22. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.gitignore +0 -0
  23. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/CONTRIBUTING.md +0 -0
  24. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/LICENSE +0 -0
  25. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/ROADMAP.md +0 -0
  26. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/action.yml +0 -0
  27. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/alpine-incompatibility/README.md +0 -0
  28. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
  29. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/basic/README.md +0 -0
  30. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/basic/clean_setup.sh +0 -0
  31. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/github-actions/README.md +0 -0
  32. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/interactive-hang/README.md +0 -0
  33. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/interactive-hang/prompt_confirm.sh +0 -0
  34. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/src/opsscript_gate/cli.py +0 -0
  35. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/tests/fixtures/fail_deps.sh +0 -0
  36. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/tests/fixtures/fail_hang.sh +0 -0
  37. {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/tests/fixtures/pass_basic.sh +0 -0
@@ -0,0 +1,213 @@
1
+ name: Build and Release
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ tags:
8
+ - 'v*'
9
+ workflow_dispatch:
10
+
11
+ concurrency:
12
+ group: opsscript-gate-release
13
+ cancel-in-progress: false
14
+ queue: max
15
+
16
+ permissions:
17
+ contents: read
18
+
19
+ jobs:
20
+ test:
21
+ name: Run Test Suite
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - name: Checkout Code
25
+ uses: actions/checkout@v7
26
+
27
+ - name: Set up Python
28
+ uses: actions/setup-python@v7
29
+ with:
30
+ python-version: '3.12'
31
+
32
+ - name: Install Dependencies
33
+ run: |
34
+ python -m pip install --upgrade pip
35
+ pip install -e .[test]
36
+
37
+ - name: Run Tests
38
+ run: |
39
+ pytest -v -m "not integration"
40
+
41
+ integration:
42
+ name: Docker Integration Tests
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - name: Checkout Code
46
+ uses: actions/checkout@v7
47
+
48
+ - name: Set up Python
49
+ uses: actions/setup-python@v7
50
+ with:
51
+ python-version: '3.12'
52
+
53
+ - name: Install Dependencies
54
+ run: |
55
+ python -m pip install --upgrade pip
56
+ pip install -e .[test]
57
+
58
+ - name: Verify Docker Available
59
+ run: docker version
60
+
61
+ - name: Run Docker Integration Tests
62
+ run: |
63
+ pytest -v -m integration
64
+
65
+ build:
66
+ name: Build Distribution Packages
67
+ needs: [test, integration]
68
+ runs-on: ubuntu-latest
69
+ permissions:
70
+ contents: write
71
+ outputs:
72
+ is_new_release: ${{ steps.release-check.outputs.is_new_release }}
73
+ should_tag: ${{ steps.release-check.outputs.should_tag }}
74
+ release_tag: ${{ steps.release-check.outputs.release_tag }}
75
+ steps:
76
+ - name: Checkout Code
77
+ uses: actions/checkout@v7
78
+ with:
79
+ fetch-depth: 0
80
+
81
+ - name: Set up Python
82
+ uses: actions/setup-python@v7
83
+ with:
84
+ python-version: '3.12'
85
+
86
+ - name: Determine Release Readiness
87
+ id: release-check
88
+ shell: bash
89
+ run: |
90
+ set -euo pipefail
91
+
92
+ TOML_VERSION=$(python -c "import tomllib; f=open('pyproject.toml', 'rb'); print(tomllib.load(f)['project']['version'])")
93
+ INIT_VERSION=$(python -c "import sys; sys.path.insert(0, 'src'); import opsscript_gate; print(opsscript_gate.__version__)")
94
+
95
+ echo "pyproject.toml version: $TOML_VERSION"
96
+ echo "opsscript_gate version: $INIT_VERSION"
97
+
98
+ if [ "$TOML_VERSION" != "$INIT_VERSION" ]; then
99
+ echo "::error::Version mismatch: pyproject.toml ($TOML_VERSION) != __init__.py ($INIT_VERSION)"
100
+ exit 1
101
+ fi
102
+
103
+ TARGET_TAG="v$TOML_VERSION"
104
+ echo "Target release tag: $TARGET_TAG"
105
+ echo "Triggering ref: ${{ github.ref }}"
106
+ echo "Triggering SHA: ${{ github.sha }}"
107
+ echo "Triggering event: ${{ github.event_name }}"
108
+
109
+ IS_NEW_RELEASE="false"
110
+ SHOULD_TAG="false"
111
+
112
+ if [[ "${{ github.ref }}" =~ ^refs/tags/v ]]; then
113
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
114
+ echo "Triggered via explicit Git tag: $GITHUB_REF_NAME (version: $TAG_VERSION)"
115
+ if [ "$TAG_VERSION" != "$TOML_VERSION" ]; then
116
+ echo "::error::Tag mismatch: Git tag $GITHUB_REF_NAME does not match project version $TOML_VERSION"
117
+ exit 1
118
+ fi
119
+ IS_NEW_RELEASE="true"
120
+ SHOULD_TAG="false"
121
+ elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
122
+ if git rev-parse -q --verify "refs/tags/$TARGET_TAG" >/dev/null; then
123
+ TAG_SHA=$(git rev-list -n 1 "$TARGET_TAG")
124
+ echo "Tag $TARGET_TAG already exists, pointing to commit: $TAG_SHA"
125
+ if [ "$TAG_SHA" = "${{ github.sha }}" ]; then
126
+ echo "Tag $TARGET_TAG points to current commit (${{ github.sha }}). Treating as retry/resume of release."
127
+ IS_NEW_RELEASE="true"
128
+ SHOULD_TAG="false"
129
+ else
130
+ echo "Tag $TARGET_TAG was released from earlier commit ($TAG_SHA != ${{ github.sha }}). Skipping release."
131
+ IS_NEW_RELEASE="false"
132
+ SHOULD_TAG="false"
133
+ fi
134
+ else
135
+ echo "Target tag $TARGET_TAG does not exist. Scheduling new release for commit ${{ github.sha }}."
136
+ IS_NEW_RELEASE="true"
137
+ SHOULD_TAG="true"
138
+ fi
139
+ else
140
+ echo "Workflow triggered on unsupported ref (${{ github.ref }}). Release is strictly prohibited."
141
+ IS_NEW_RELEASE="false"
142
+ SHOULD_TAG="false"
143
+ fi
144
+
145
+ echo "is_new_release=$IS_NEW_RELEASE" >> "$GITHUB_OUTPUT"
146
+ echo "should_tag=$SHOULD_TAG" >> "$GITHUB_OUTPUT"
147
+ echo "release_tag=$TARGET_TAG" >> "$GITHUB_OUTPUT"
148
+
149
+ - name: Install build tools
150
+ if: steps.release-check.outputs.is_new_release == 'true'
151
+ run: |
152
+ python -m pip install --upgrade pip
153
+ pip install build twine
154
+
155
+ - name: Build sdist and wheel
156
+ if: steps.release-check.outputs.is_new_release == 'true'
157
+ run: |
158
+ python -m build
159
+
160
+ - name: Verify Package Metadata
161
+ if: steps.release-check.outputs.is_new_release == 'true'
162
+ run: |
163
+ python -m twine check dist/*
164
+
165
+ - name: Upload dist artifacts
166
+ if: steps.release-check.outputs.is_new_release == 'true'
167
+ uses: actions/upload-artifact@v4
168
+ with:
169
+ name: dist
170
+ path: dist/
171
+ retention-days: 7
172
+
173
+ - name: Create and push Git tag
174
+ if: steps.release-check.outputs.is_new_release == 'true' && steps.release-check.outputs.should_tag == 'true'
175
+ shell: bash
176
+ run: |
177
+ TARGET_TAG="${{ steps.release-check.outputs.release_tag }}"
178
+ echo "Creating and pushing Git tag: $TARGET_TAG pointing to commit $GITHUB_SHA"
179
+ git config user.name "github-actions[bot]"
180
+ git config user.email "github-actions[bot]@users.noreply.github.com"
181
+ git tag "$TARGET_TAG" "$GITHUB_SHA"
182
+ git push origin "$TARGET_TAG"
183
+
184
+ - name: Create GitHub Release and Upload Assets
185
+ uses: softprops/action-gh-release@v3
186
+ if: steps.release-check.outputs.is_new_release == 'true'
187
+ with:
188
+ tag_name: ${{ steps.release-check.outputs.release_tag }}
189
+ target_commitish: ${{ github.sha }}
190
+ files: dist/*
191
+ generate_release_notes: true
192
+
193
+ publish-pypi:
194
+ name: Publish to PyPI via OIDC
195
+ needs: [test, build]
196
+ if: needs.build.outputs.is_new_release == 'true'
197
+ runs-on: ubuntu-latest
198
+ environment:
199
+ name: pypi
200
+ url: https://pypi.org/p/opsscript-gate
201
+ permissions:
202
+ id-token: write
203
+ steps:
204
+ - name: Download dist artifacts
205
+ uses: actions/download-artifact@v4
206
+ with:
207
+ name: dist
208
+ path: dist
209
+
210
+ - name: Publish package distributions to PyPI
211
+ uses: pypa/gh-action-pypi-publish@release/v1
212
+ with:
213
+ packages-dir: dist/
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.0] - 2026-09-19
9
+
10
+ ### Added
11
+ - Structured runtime failure diagnostics: detect common "command not found" failures across Linux distributions (Alpine/BusyBox ash, Debian/Ubuntu dash, Bash, and missing interpreter paths).
12
+ - High-confidence dual-signal classification: requires exit code 127 and matching shell output to produce a `missing_command` diagnostic.
13
+ - Additive `diagnostic` field in JSON report format (`SingleResult.to_dict()`) providing structured diagnostic information (backward-compatible for consumers tolerating additional fields).
14
+ - Enhanced terminal table formatting and GitHub Actions Step Summary showing clear failure causes in the summary overview.
15
+
16
+ ---
17
+
8
18
  ## [0.2.1] - 2026-09-19
9
19
 
10
20
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opsscript-gate
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Run Linux shell scripts across Debian, Ubuntu and Alpine before release to catch runtime compatibility failures
5
5
  Project-URL: Homepage, https://github.com/Mresyzz/opsscript-gate
6
6
  Project-URL: Repository, https://github.com/Mresyzz/opsscript-gate
@@ -39,7 +39,7 @@ Description-Content-Type: text/markdown
39
39
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
40
40
  [![Supported Distros](https://img.shields.io/badge/matrix-Debian%20%7C%20Ubuntu%20%7C%20Alpine-orange.svg)](#default-test-matrix)
41
41
 
42
- **OpsScript Gate** is a drop-in runtime compatibility gate for Linux shell scripts. It executes your shell scripts inside isolated Debian, Ubuntu, and Alpine containers before release, catching environment-specific runtime failures that static analysis cannot detect.
42
+ **OpsScript Gate** is a drop-in runtime compatibility gate for Linux shell scripts. It executes your shell scripts inside separate Debian, Ubuntu, and Alpine Docker containers before release, catching environment-specific runtime failures that static analysis cannot detect.
43
43
 
44
44
  <p align="center">
45
45
  <img src="https://raw.githubusercontent.com/Mresyzz/opsscript-gate/main/.github/assets/social-preview.png" alt="OpsScript Gate Terminal Preview" width="800">
@@ -51,27 +51,31 @@ Description-Content-Type: text/markdown
51
51
 
52
52
  ### In GitHub Actions
53
53
 
54
- Add one step to your pull request workflow (`.github/workflows/gate.yml`):
54
+ Here is a complete minimal workflow to drop into `.github/workflows/shell-compat.yml`:
55
55
 
56
56
  ```yaml
57
- - name: Verify Shell Script Portability
58
- uses: Mresyzz/opsscript-gate@v0.2.1
59
- with:
60
- script-path: scripts/setup.sh
61
- # shell: posix # optional: 'posix' (default), 'shebang', or 'auto'
57
+ name: Shell compatibility
58
+ on: [pull_request]
59
+
60
+ jobs:
61
+ shell-compat:
62
+ runs-on: ubuntu-latest
63
+ steps:
64
+ - uses: actions/checkout@v7
65
+ - uses: Mresyzz/opsscript-gate@v0.3.0
66
+ with:
67
+ script-path: scripts/setup.sh
68
+ shell: auto
62
69
  ```
63
70
 
64
71
  ### In Local Terminal (CLI)
65
72
 
66
- Requires Python 3.10+ and a local Docker engine:
73
+ Requires Python 3.10+ and a running local Docker engine:
67
74
 
68
75
  ```bash
69
76
  # Install from PyPI
70
77
  pip install opsscript-gate
71
78
 
72
- # Latest development version
73
- pip install git+https://github.com/Mresyzz/opsscript-gate.git
74
-
75
79
  # Run compatibility gate against your script
76
80
  opsscript-gate run ./scripts/setup.sh
77
81
  ```
@@ -138,9 +142,11 @@ Failed Distributions - Output Snippets (last 15 lines):
138
142
 
139
143
  ## Security Boundaries
140
144
 
141
- OpsScript Gate uses conservative container defaults when running scripts:
145
+ OpsScript Gate is not a security sandbox for untrusted code. Containers may run as the image's default user, and Docker/host-kernel security boundaries still apply.
146
+
147
+ OpsScript Gate applies conservative container defaults when running scripts:
142
148
 
143
- 1. **Unprivileged by Design**:
149
+ 1. **Restricted Container Defaults**:
144
150
  - Containers run with `privileged=False`.
145
151
  - All Linux capabilities are dropped: `cap_drop=["ALL"]`.
146
152
  - Privilege escalation is disabled: `security_opt=["no-new-privileges:true"]`.
@@ -149,7 +155,7 @@ OpsScript Gate uses conservative container defaults when running scripts:
149
155
  - OpsScript Gate does not mount additional host filesystem paths into the test container.
150
156
  3. **Anti-Hang Deadlock Defense**:
151
157
  - Disables TTY and stdin (`stdin_open=False`, `tty=False`).
152
- - Redirects execution: `/bin/sh -c "/bin/sh /tmp/target_script.sh </dev/null"`.
158
+ - Redirects execution with input disconnected: `/bin/sh -c "... /tmp/target_script.sh </dev/null"`.
153
159
  - Injects `DEBIAN_FRONTEND=noninteractive` and `CI=true`.
154
160
  - Any script prompting for user input (`read -p`) fails immediately instead of blocking the CI runner.
155
161
  4. **Timeout & Container Cleanup**:
@@ -157,8 +163,8 @@ OpsScript Gate uses conservative container defaults when running scripts:
157
163
  - Container removal is attempted from a `finally` block during normal Python execution paths, including failures and timeouts.
158
164
  5. **Windows CRLF Defense**:
159
165
  - Automatically detects and normalizes carriage returns (`\r\n` -> `\n`) before container execution, preventing false `\r: command not found` errors.
160
- 6. **POSIX-oriented `/bin/sh` Baseline**:
161
- - Containers invoke `/bin/sh` directly, catching undeclared Bashism syntax (e.g. bash arrays, `[[ ... ]]`) that break in lightweight Alpine environments.
166
+ 6. **Minimal `/bin/sh` Baseline**:
167
+ - In default POSIX mode, containers invoke `/bin/sh` directly, catching undeclared Bashism syntax (e.g. bash arrays, `[[ ... ]]`) that break in lightweight Alpine environments.
162
168
 
163
169
  ---
164
170
 
@@ -169,7 +175,7 @@ OpsScript Gate uses conservative container defaults when running scripts:
169
175
  | `debian:12-slim` | Debian 12 (Bookworm) | Minimal glibc + APT base |
170
176
  | `ubuntu:22.04` | Ubuntu 22.04 LTS (Jammy) | Enterprise long-term support baseline |
171
177
  | `ubuntu:24.04` | Ubuntu 24.04 LTS (Noble) | Modern glibc, updated coreutils & defaults |
172
- | `alpine:3.20` | Alpine Linux 3.20 | Minimal musl libc + BusyBox (strict POSIX test) |
178
+ | `alpine:3.20` | Alpine Linux 3.20 | Minimal musl libc + BusyBox /bin/sh environment |
173
179
 
174
180
  You can customize the matrix at any time via `--matrix` or Action input `matrix`.
175
181
 
@@ -196,7 +202,7 @@ usage: opsscript-gate run [-h] [--matrix MATRIX] [--timeout TIMEOUT]
196
202
 
197
203
  ### Shell Execution Modes (`--shell`)
198
204
 
199
- - **`posix`** (default): Strictly executes with `/bin/sh`, ignoring any script shebang. Ideal for verifying that your script runs in minimal POSIX-compliant environments (e.g. Alpine BusyBox).
205
+ - **`posix`** (default): Strictly executes with `/bin/sh`, ignoring any script shebang. Useful for verifying that your script runs under minimal `/bin/sh` environments, including Alpine BusyBox.
200
206
  - **`shebang`**: Strictly honors the interpreter specified in the script's shebang (`#!/bin/sh`, `#!/bin/bash`, `#!/usr/bin/sh`, `#!/usr/bin/bash`, `#!/usr/bin/env sh`, `#!/usr/bin/env bash`). If the shebang is missing, malformed, or specifies an unsupported interpreter/flag, the check fails immediately with an error before running containers.
201
207
  - **`auto`**: Honors recognized shebangs if present; falls back to `/bin/sh` if no shebang is declared. Scripts with explicit unsupported or malformed shebangs fail immediately with an error (does not silently execute as POSIX).
202
208
 
@@ -219,7 +225,7 @@ Check out the [examples/](examples/) directory for self-contained, runnable scen
219
225
 
220
226
  ## Dogfooding
221
227
 
222
- OpsScript Gate is used in [`Mresyzz/linux-dev-bootstrap`](https://github.com/Mresyzz/linux-dev-bootstrap) to validate its `install.sh` script across the default Debian, Ubuntu, and Alpine matrix in GitHub Actions. That workflow pins `Mresyzz/opsscript-gate@v0.2.0` with `shell: auto`.
228
+ OpsScript Gate is used in [`Mresyzz/linux-dev-bootstrap`](https://github.com/Mresyzz/linux-dev-bootstrap) to validate its `install.sh` script across the default Debian, Ubuntu, and Alpine matrix in GitHub Actions. That workflow pins `Mresyzz/opsscript-gate@v0.2.1` with `shell: auto`.
223
229
 
224
230
  See the downstream workflow: [`.github/workflows/test.yml`](https://github.com/Mresyzz/linux-dev-bootstrap/blob/main/.github/workflows/test.yml).
225
231
 
@@ -235,6 +241,9 @@ git clone https://github.com/Mresyzz/opsscript-gate.git
235
241
  cd opsscript-gate
236
242
  pip install -e .[test]
237
243
 
244
+ # Or install latest unreleased code directly from Git:
245
+ # pip install git+https://github.com/Mresyzz/opsscript-gate.git
246
+
238
247
  # Run unit tests (mocked)
239
248
  pytest -v -m "not integration"
240
249
 
@@ -11,7 +11,7 @@
11
11
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
12
12
  [![Supported Distros](https://img.shields.io/badge/matrix-Debian%20%7C%20Ubuntu%20%7C%20Alpine-orange.svg)](#default-test-matrix)
13
13
 
14
- **OpsScript Gate** is a drop-in runtime compatibility gate for Linux shell scripts. It executes your shell scripts inside isolated Debian, Ubuntu, and Alpine containers before release, catching environment-specific runtime failures that static analysis cannot detect.
14
+ **OpsScript Gate** is a drop-in runtime compatibility gate for Linux shell scripts. It executes your shell scripts inside separate Debian, Ubuntu, and Alpine Docker containers before release, catching environment-specific runtime failures that static analysis cannot detect.
15
15
 
16
16
  <p align="center">
17
17
  <img src="https://raw.githubusercontent.com/Mresyzz/opsscript-gate/main/.github/assets/social-preview.png" alt="OpsScript Gate Terminal Preview" width="800">
@@ -23,27 +23,31 @@
23
23
 
24
24
  ### In GitHub Actions
25
25
 
26
- Add one step to your pull request workflow (`.github/workflows/gate.yml`):
26
+ Here is a complete minimal workflow to drop into `.github/workflows/shell-compat.yml`:
27
27
 
28
28
  ```yaml
29
- - name: Verify Shell Script Portability
30
- uses: Mresyzz/opsscript-gate@v0.2.1
31
- with:
32
- script-path: scripts/setup.sh
33
- # shell: posix # optional: 'posix' (default), 'shebang', or 'auto'
29
+ name: Shell compatibility
30
+ on: [pull_request]
31
+
32
+ jobs:
33
+ shell-compat:
34
+ runs-on: ubuntu-latest
35
+ steps:
36
+ - uses: actions/checkout@v7
37
+ - uses: Mresyzz/opsscript-gate@v0.3.0
38
+ with:
39
+ script-path: scripts/setup.sh
40
+ shell: auto
34
41
  ```
35
42
 
36
43
  ### In Local Terminal (CLI)
37
44
 
38
- Requires Python 3.10+ and a local Docker engine:
45
+ Requires Python 3.10+ and a running local Docker engine:
39
46
 
40
47
  ```bash
41
48
  # Install from PyPI
42
49
  pip install opsscript-gate
43
50
 
44
- # Latest development version
45
- pip install git+https://github.com/Mresyzz/opsscript-gate.git
46
-
47
51
  # Run compatibility gate against your script
48
52
  opsscript-gate run ./scripts/setup.sh
49
53
  ```
@@ -110,9 +114,11 @@ Failed Distributions - Output Snippets (last 15 lines):
110
114
 
111
115
  ## Security Boundaries
112
116
 
113
- OpsScript Gate uses conservative container defaults when running scripts:
117
+ OpsScript Gate is not a security sandbox for untrusted code. Containers may run as the image's default user, and Docker/host-kernel security boundaries still apply.
118
+
119
+ OpsScript Gate applies conservative container defaults when running scripts:
114
120
 
115
- 1. **Unprivileged by Design**:
121
+ 1. **Restricted Container Defaults**:
116
122
  - Containers run with `privileged=False`.
117
123
  - All Linux capabilities are dropped: `cap_drop=["ALL"]`.
118
124
  - Privilege escalation is disabled: `security_opt=["no-new-privileges:true"]`.
@@ -121,7 +127,7 @@ OpsScript Gate uses conservative container defaults when running scripts:
121
127
  - OpsScript Gate does not mount additional host filesystem paths into the test container.
122
128
  3. **Anti-Hang Deadlock Defense**:
123
129
  - Disables TTY and stdin (`stdin_open=False`, `tty=False`).
124
- - Redirects execution: `/bin/sh -c "/bin/sh /tmp/target_script.sh </dev/null"`.
130
+ - Redirects execution with input disconnected: `/bin/sh -c "... /tmp/target_script.sh </dev/null"`.
125
131
  - Injects `DEBIAN_FRONTEND=noninteractive` and `CI=true`.
126
132
  - Any script prompting for user input (`read -p`) fails immediately instead of blocking the CI runner.
127
133
  4. **Timeout & Container Cleanup**:
@@ -129,8 +135,8 @@ OpsScript Gate uses conservative container defaults when running scripts:
129
135
  - Container removal is attempted from a `finally` block during normal Python execution paths, including failures and timeouts.
130
136
  5. **Windows CRLF Defense**:
131
137
  - Automatically detects and normalizes carriage returns (`\r\n` -> `\n`) before container execution, preventing false `\r: command not found` errors.
132
- 6. **POSIX-oriented `/bin/sh` Baseline**:
133
- - Containers invoke `/bin/sh` directly, catching undeclared Bashism syntax (e.g. bash arrays, `[[ ... ]]`) that break in lightweight Alpine environments.
138
+ 6. **Minimal `/bin/sh` Baseline**:
139
+ - In default POSIX mode, containers invoke `/bin/sh` directly, catching undeclared Bashism syntax (e.g. bash arrays, `[[ ... ]]`) that break in lightweight Alpine environments.
134
140
 
135
141
  ---
136
142
 
@@ -141,7 +147,7 @@ OpsScript Gate uses conservative container defaults when running scripts:
141
147
  | `debian:12-slim` | Debian 12 (Bookworm) | Minimal glibc + APT base |
142
148
  | `ubuntu:22.04` | Ubuntu 22.04 LTS (Jammy) | Enterprise long-term support baseline |
143
149
  | `ubuntu:24.04` | Ubuntu 24.04 LTS (Noble) | Modern glibc, updated coreutils & defaults |
144
- | `alpine:3.20` | Alpine Linux 3.20 | Minimal musl libc + BusyBox (strict POSIX test) |
150
+ | `alpine:3.20` | Alpine Linux 3.20 | Minimal musl libc + BusyBox /bin/sh environment |
145
151
 
146
152
  You can customize the matrix at any time via `--matrix` or Action input `matrix`.
147
153
 
@@ -168,7 +174,7 @@ usage: opsscript-gate run [-h] [--matrix MATRIX] [--timeout TIMEOUT]
168
174
 
169
175
  ### Shell Execution Modes (`--shell`)
170
176
 
171
- - **`posix`** (default): Strictly executes with `/bin/sh`, ignoring any script shebang. Ideal for verifying that your script runs in minimal POSIX-compliant environments (e.g. Alpine BusyBox).
177
+ - **`posix`** (default): Strictly executes with `/bin/sh`, ignoring any script shebang. Useful for verifying that your script runs under minimal `/bin/sh` environments, including Alpine BusyBox.
172
178
  - **`shebang`**: Strictly honors the interpreter specified in the script's shebang (`#!/bin/sh`, `#!/bin/bash`, `#!/usr/bin/sh`, `#!/usr/bin/bash`, `#!/usr/bin/env sh`, `#!/usr/bin/env bash`). If the shebang is missing, malformed, or specifies an unsupported interpreter/flag, the check fails immediately with an error before running containers.
173
179
  - **`auto`**: Honors recognized shebangs if present; falls back to `/bin/sh` if no shebang is declared. Scripts with explicit unsupported or malformed shebangs fail immediately with an error (does not silently execute as POSIX).
174
180
 
@@ -191,7 +197,7 @@ Check out the [examples/](examples/) directory for self-contained, runnable scen
191
197
 
192
198
  ## Dogfooding
193
199
 
194
- OpsScript Gate is used in [`Mresyzz/linux-dev-bootstrap`](https://github.com/Mresyzz/linux-dev-bootstrap) to validate its `install.sh` script across the default Debian, Ubuntu, and Alpine matrix in GitHub Actions. That workflow pins `Mresyzz/opsscript-gate@v0.2.0` with `shell: auto`.
200
+ OpsScript Gate is used in [`Mresyzz/linux-dev-bootstrap`](https://github.com/Mresyzz/linux-dev-bootstrap) to validate its `install.sh` script across the default Debian, Ubuntu, and Alpine matrix in GitHub Actions. That workflow pins `Mresyzz/opsscript-gate@v0.2.1` with `shell: auto`.
195
201
 
196
202
  See the downstream workflow: [`.github/workflows/test.yml`](https://github.com/Mresyzz/linux-dev-bootstrap/blob/main/.github/workflows/test.yml).
197
203
 
@@ -207,6 +213,9 @@ git clone https://github.com/Mresyzz/opsscript-gate.git
207
213
  cd opsscript-gate
208
214
  pip install -e .[test]
209
215
 
216
+ # Or install latest unreleased code directly from Git:
217
+ # pip install git+https://github.com/Mresyzz/opsscript-gate.git
218
+
210
219
  # Run unit tests (mocked)
211
220
  pytest -v -m "not integration"
212
221
 
@@ -30,7 +30,9 @@ Reports will be reviewed as maintainer availability allows.
30
30
 
31
31
  ## Runner Security Boundaries
32
32
 
33
+ OpsScript Gate is not a security sandbox for untrusted code. Containers may run as the image's default user, and Docker containers still share the host kernel.
34
+
33
35
  OpsScript Gate applies conservative container defaults when running scripts:
34
- - **Unprivileged Execution**: Containers run with `privileged=False`, `cap_drop=["ALL"]`, and `security_opt=["no-new-privileges:true"]`.
36
+ - **Restricted Container Defaults**: Containers run with `privileged=False`, `cap_drop=["ALL"]`, and `security_opt=["no-new-privileges:true"]`.
35
37
  - **Read-Only Mounting**: Target scripts are mounted read-only (`:ro`). OpsScript Gate does not mount additional host filesystem paths into test containers.
36
38
  - **Resource Protection**: Containers are subject to timeouts and `SIGKILL` termination. Container removal is attempted from a `finally` block during normal Python execution paths, including failures and timeouts.
@@ -17,9 +17,10 @@ jobs:
17
17
  uses: actions/checkout@v7
18
18
 
19
19
  - name: Run OpsScript Gate
20
- uses: Mresyzz/opsscript-gate@v0.1.1
20
+ uses: Mresyzz/opsscript-gate@v0.3.0
21
21
  with:
22
22
  script-path: 'scripts/deploy.sh'
23
+ shell: auto
23
24
  matrix: 'debian:12-slim,ubuntu:22.04,ubuntu:24.04,alpine:3.20'
24
25
  timeout: '60'
25
26
  format: 'table'
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "opsscript-gate"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Run Linux shell scripts across Debian, Ubuntu and Alpine before release to catch runtime compatibility failures"
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.2.1"
3
+ __version__ = "0.3.0"
4
4
  __all__ = ["__version__"]
@@ -20,6 +20,23 @@ class ShellMode(str, Enum):
20
20
  AUTO = "auto"
21
21
 
22
22
 
23
+ @dataclass
24
+ class FailureDiagnostic:
25
+ """Structured diagnostic information for execution failures."""
26
+ kind: str
27
+ message: str
28
+ command: str | None = None
29
+
30
+ def to_dict(self) -> dict[str, Any]:
31
+ d: dict[str, Any] = {
32
+ "kind": self.kind,
33
+ "message": self.message,
34
+ }
35
+ if self.command is not None:
36
+ d["command"] = self.command
37
+ return d
38
+
39
+
23
40
  @dataclass
24
41
  class SingleResult:
25
42
  """Execution result for a single Linux distribution."""
@@ -29,6 +46,7 @@ class SingleResult:
29
46
  duration: float
30
47
  output_snippet: str = ""
31
48
  error_message: str | None = None
49
+ diagnostic: FailureDiagnostic | None = None
32
50
 
33
51
  def to_dict(self) -> dict[str, Any]:
34
52
  return {
@@ -38,6 +56,7 @@ class SingleResult:
38
56
  "duration": round(self.duration, 3),
39
57
  "output_snippet": self.output_snippet,
40
58
  "error_message": self.error_message,
59
+ "diagnostic": self.diagnostic.to_dict() if self.diagnostic is not None else None,
41
60
  }
42
61
 
43
62
 
@@ -16,6 +16,8 @@ def format_terminal_table(report: RunReport) -> str:
16
16
 
17
17
  if r.status == DistroStatus.PASS:
18
18
  detail = "OK"
19
+ elif r.diagnostic:
20
+ detail = r.diagnostic.message
19
21
  elif r.error_message:
20
22
  detail = r.error_message
21
23
  else:
@@ -107,7 +109,12 @@ def format_github_summary(report: RunReport) -> str:
107
109
 
108
110
  exit_code_str = f"`{r.exit_code}`" if r.exit_code is not None else "`N/A`"
109
111
  duration_str = f"`{r.duration:.2f}s`"
110
- msg = r.error_message or "-"
112
+ if r.status == DistroStatus.PASS:
113
+ msg = "OK"
114
+ elif r.diagnostic:
115
+ msg = r.diagnostic.message
116
+ else:
117
+ msg = r.error_message or "-"
111
118
  msg_escaped = msg.replace("|", "\\|")
112
119
 
113
120
  lines.append(
@@ -123,6 +130,11 @@ def format_github_summary(report: RunReport) -> str:
123
130
  for r in failures:
124
131
  lines.append(f"<details><summary><b>[{r.status.value}] {r.distro}</b></summary>")
125
132
  lines.append("")
133
+ if r.diagnostic:
134
+ clean_kind = r.diagnostic.kind.replace("`", "'")
135
+ clean_msg = r.diagnostic.message.replace("`", "'")
136
+ lines.append(f"> **Diagnostic:** `{clean_kind}` — `{clean_msg}`")
137
+ lines.append("")
126
138
  if r.error_message:
127
139
  lines.append(f"> **Error:** {r.error_message}")
128
140
  lines.append("")
@@ -11,7 +11,7 @@ from docker.errors import DockerException, ImageNotFound
11
11
 
12
12
  from dataclasses import dataclass
13
13
  from enum import Enum
14
- from opsscript_gate.models import DistroStatus, RunReport, ShellMode, SingleResult
14
+ from opsscript_gate.models import DistroStatus, FailureDiagnostic, RunReport, ShellMode, SingleResult
15
15
 
16
16
  DEFAULT_MATRIX: list[str] = [
17
17
  "debian:12-slim",
@@ -67,6 +67,103 @@ def sanitize_diagnostic_text(text: str, max_length: int = 200) -> str:
67
67
  return sanitized
68
68
 
69
69
 
70
+ # Conservative regular expression matching recognized shell-origin 'not found' error patterns:
71
+ # Examples:
72
+ # - BusyBox ash: "sh: line 4: apt-get: not found", "sh: curl: not found", "/bin/sh: ...: not found"
73
+ # - Debian dash: "dash: 1: curl: not found", "sh: 1: curl: not found"
74
+ # - Bash: "bash: line 4: foo: command not found", "bash: foo: command not found"
75
+ # - /bin/sh: "/bin/sh: line 1: /usr/bin/bash: not found", "/bin/sh: curl: not found"
76
+ # - script-path + line-number forms: "/tmp/target_script.sh: line 4: curl: not found", "test.sh: 4: curl: not found"
77
+ _MISSING_COMMAND_RE = re.compile(
78
+ r"""
79
+ (?:^|(?<=[\r\n])) # start of line
80
+ \s*
81
+ (?:
82
+ (?:/(?:usr/)?bin/)?(?:sh|bash|dash|ash) # recognized shell interpreter name
83
+ (?::\s*[^:\r\n]+)? # optional script path within shell error
84
+ (?::\s*(?:line\s+\d+|\d+))? # optional line indicator
85
+ |
86
+ [^:\r\n]+?:\s*(?:line\s+\d+|\d+) # script-path + line-number forms
87
+ )
88
+ :\s+ # separator after shell-origin prefix
89
+ (?P<cmd>['"`]?[\w./+-]+['"`]?) # strictly valid command identifier/path
90
+ :\s+ # colon separator
91
+ (?:command\s+not\s+found|not\s+found) # missing command indicator
92
+ \s*(?=$|[\r\n]) # end of line
93
+ """,
94
+ re.IGNORECASE | re.VERBOSE | re.MULTILINE,
95
+ )
96
+
97
+ # Strict whitelist for extracted command token validation (alphanumerics, Unicode words, dots, dashes, slashes):
98
+ _VALID_COMMAND_TOKEN_RE = re.compile(r"^[\w./+-]+$")
99
+
100
+ # Common status, protocol, and message prefixes that must never be classified as missing commands:
101
+ _NON_COMMAND_TOKENS = {
102
+ "status", "error", "warning", "info", "notice", "message",
103
+ "file", "directory", "entry", "key", "value", "user", "record",
104
+ "http", "https", "response", "request", "server", "client",
105
+ }
106
+
107
+
108
+ def extract_failure_diagnostic(
109
+ output: str,
110
+ exit_code: int | None = None,
111
+ ) -> FailureDiagnostic | None:
112
+ """
113
+ Extract high-confidence structured diagnostic from container execution failure.
114
+
115
+ Deliberately conservative (prefer false negatives over false positives):
116
+ - Requires exit_code == 127 as an essential high-confidence signal.
117
+ - Requires matching a well-known shell 'not found' pattern in output.
118
+ - Strictly validates command token syntax (no arbitrary text or markdown injection).
119
+ - Excludes numeric status codes, status/error message prefixes, path-only dots/slashes, and unclassified output.
120
+ - Returns None if not confidently classified as missing_command.
121
+ """
122
+ if exit_code != 127 or not output:
123
+ return None
124
+
125
+ # Strip ANSI escape sequences first
126
+ cleaned_output = _ANSI_ESCAPE_RE.sub("", output)
127
+
128
+ for line in cleaned_output.splitlines():
129
+ line_clean = line.strip()
130
+ if not line_clean:
131
+ continue
132
+
133
+ m = _MISSING_COMMAND_RE.search(line_clean)
134
+ if not m:
135
+ continue
136
+
137
+ raw_cmd = m.group("cmd").strip("'\"`")
138
+ if not raw_cmd or raw_cmd.isdigit():
139
+ continue
140
+
141
+ # Disallow pure dot/slash sequences
142
+ if raw_cmd in (".", "..", "/", "//"):
143
+ continue
144
+
145
+ # Exclude common non-command tokens and protocol prefixes (e.g. Status, Error, HTTP/1.1)
146
+ lowered = raw_cmd.lower()
147
+ if lowered in _NON_COMMAND_TOKENS or lowered.split("/")[0] in _NON_COMMAND_TOKENS:
148
+ continue
149
+
150
+ # Validate against strict conservative grammar
151
+ if not _VALID_COMMAND_TOKEN_RE.match(raw_cmd):
152
+ continue
153
+
154
+ sanitized_cmd = sanitize_diagnostic_text(raw_cmd, max_length=100)
155
+ if not sanitized_cmd or not _VALID_COMMAND_TOKEN_RE.match(sanitized_cmd):
156
+ continue
157
+
158
+ return FailureDiagnostic(
159
+ kind="missing_command",
160
+ command=sanitized_cmd,
161
+ message=f"command not found: {sanitized_cmd}",
162
+ )
163
+
164
+ return None
165
+
166
+
70
167
  class ShebangStatus(str, Enum):
71
168
  """Status of shebang parsing."""
72
169
  RECOGNIZED = "recognized"
@@ -505,6 +602,7 @@ def run_on_distro(
505
602
  error_message=None,
506
603
  )
507
604
  else:
605
+ diagnostic = extract_failure_diagnostic(output, exit_code=exit_code)
508
606
  return SingleResult(
509
607
  distro=distro,
510
608
  status=DistroStatus.FAIL,
@@ -512,6 +610,7 @@ def run_on_distro(
512
610
  duration=duration,
513
611
  output_snippet=extract_snippet(output),
514
612
  error_message=f"Script failed with non-zero exit code: {exit_code}",
613
+ diagnostic=diagnostic,
515
614
  )
516
615
 
517
616
  except Exception as exc:
@@ -9,7 +9,7 @@ import pytest
9
9
  from docker.errors import DockerException
10
10
 
11
11
  from opsscript_gate.cli import build_parser, main, parse_matrix_argument
12
- from opsscript_gate.models import DistroStatus, RunReport, ShellMode, SingleResult
12
+ from opsscript_gate.models import DistroStatus, FailureDiagnostic, RunReport, ShellMode, SingleResult
13
13
  from opsscript_gate.reporter import (
14
14
  format_github_summary,
15
15
  format_json,
@@ -24,6 +24,7 @@ from opsscript_gate.runner import (
24
24
  DockerDaemonError,
25
25
  ShebangParseResult,
26
26
  ShebangStatus,
27
+ extract_failure_diagnostic,
27
28
  extract_snippet,
28
29
  get_docker_client,
29
30
  inspect_shebang,
@@ -368,7 +369,7 @@ def test_cli_version(capsys):
368
369
  main(["--version"])
369
370
  assert excinfo.value.code == 0
370
371
  captured = capsys.readouterr()
371
- assert "0.2.1" in captured.out
372
+ assert "0.3.0" in captured.out
372
373
 
373
374
 
374
375
  def test_cli_format_markdown_and_table(tmp_path, capsys):
@@ -869,6 +870,9 @@ def test_integration_fail_deps_alpine():
869
870
  assert report.all_passed is False
870
871
  assert report.results[0].status == DistroStatus.FAIL
871
872
  assert report.results[0].exit_code == 127
873
+ assert report.results[0].diagnostic is not None
874
+ assert report.results[0].diagnostic.kind == "missing_command"
875
+ assert report.results[0].diagnostic.command == "apt-get"
872
876
 
873
877
 
874
878
  @pytest.mark.integration
@@ -883,3 +887,245 @@ def test_integration_missing_bash_alpine(tmp_path):
883
887
  assert report.results[0].status == DistroStatus.FAIL
884
888
  assert report.results[0].exit_code == 127
885
889
  assert "not found" in (report.results[0].output_snippet or "").lower()
890
+ assert report.results[0].diagnostic is not None
891
+ assert report.results[0].diagnostic.kind == "missing_command"
892
+ assert report.results[0].diagnostic.command == "/bin/bash"
893
+
894
+
895
+ # ==============================================================================
896
+ # 7. Runtime Failure Diagnostics Tests
897
+ # ==============================================================================
898
+
899
+ def test_extract_failure_diagnostic_positive_variants():
900
+ # Alpine-style
901
+ diag1 = extract_failure_diagnostic("/tmp/target_script.sh: line 4: apt-get: not found", exit_code=127)
902
+ assert diag1 is not None
903
+ assert diag1.kind == "missing_command"
904
+ assert diag1.command == "apt-get"
905
+ assert diag1.message == "command not found: apt-get"
906
+
907
+ # Debian/Ubuntu dash style
908
+ diag2 = extract_failure_diagnostic("/tmp/target_script.sh: 4: curl: not found", exit_code=127)
909
+ assert diag2 is not None
910
+ assert diag2.command == "curl"
911
+ assert diag2.message == "command not found: curl"
912
+
913
+ # Bash style with underscores
914
+ diag3 = extract_failure_diagnostic("bash: line 4: foo_bar: command not found", exit_code=127)
915
+ assert diag3 is not None
916
+ assert diag3.command == "foo_bar"
917
+ assert diag3.message == "command not found: foo_bar"
918
+
919
+ # Ash style direct command
920
+ diag4 = extract_failure_diagnostic("sh: apt-get: not found", exit_code=127)
921
+ assert diag4 is not None
922
+ assert diag4.command == "apt-get"
923
+
924
+ # Missing interpreter path
925
+ diag5 = extract_failure_diagnostic("/bin/sh: line 1: /usr/bin/bash: not found", exit_code=127)
926
+ assert diag5 is not None
927
+ assert diag5.command == "/usr/bin/bash"
928
+ assert diag5.message == "command not found: /usr/bin/bash"
929
+
930
+ # Versioned command
931
+ diag6 = extract_failure_diagnostic("sh: python3.12: not found", exit_code=127)
932
+ assert diag6 is not None
933
+ assert diag6.command == "python3.12"
934
+
935
+ # Single-quoted and double-quoted variants
936
+ diag7 = extract_failure_diagnostic("bash: 'my-cmd': command not found", exit_code=127)
937
+ assert diag7 is not None
938
+ assert diag7.command == "my-cmd"
939
+
940
+ diag8 = extract_failure_diagnostic('sh: "my-cmd": not found', exit_code=127)
941
+ assert diag8 is not None
942
+ assert diag8.command == "my-cmd"
943
+
944
+ # Multi-line output selects first missing command
945
+ multiline = "Step 1\nsh: line 2: curl: not found\nsh: line 3: jq: not found\nDone"
946
+ diag9 = extract_failure_diagnostic(multiline, exit_code=127)
947
+ assert diag9 is not None
948
+ assert diag9.command == "curl"
949
+
950
+
951
+ def test_extract_failure_diagnostic_negative_rules():
952
+ # Bare exit 127 with no matching shell output
953
+ assert extract_failure_diagnostic("Process terminated with 127", exit_code=127) is None
954
+ assert extract_failure_diagnostic("", exit_code=127) is None
955
+ assert extract_failure_diagnostic("Unknown fatal error", exit_code=127) is None
956
+
957
+ # Application output with arbitrary prefixes or missing shell-origin format
958
+ assert extract_failure_diagnostic("Error: apt-get: not found", exit_code=127) is None
959
+ assert extract_failure_diagnostic("myapp: plugin: not found", exit_code=127) is None
960
+ assert extract_failure_diagnostic("apt-get: not found", exit_code=127) is None
961
+
962
+ # Matching output but exit code != 127 (e.g. exit 1 or 2)
963
+ assert extract_failure_diagnostic("bash: foo: command not found", exit_code=1) is None
964
+ assert extract_failure_diagnostic("sh: apt-get: not found", exit_code=2) is None
965
+ assert extract_failure_diagnostic("/tmp/script.sh: line 1: curl: not found", exit_code=None) is None
966
+
967
+ # Program merely printing command-not-found-like text
968
+ assert extract_failure_diagnostic("cat: /etc/hosts: File not found", exit_code=127) is None
969
+ assert extract_failure_diagnostic("grep: /path/file: No such file or directory", exit_code=127) is None
970
+ assert extract_failure_diagnostic("Error: database key not found in cache", exit_code=127) is None
971
+
972
+ # HTTP status or numeric tokens
973
+ assert extract_failure_diagnostic("HTTP/1.1 404: not found", exit_code=127) is None
974
+ assert extract_failure_diagnostic("404: not found", exit_code=127) is None
975
+ assert extract_failure_diagnostic("error: 1234: not found", exit_code=127) is None
976
+ assert extract_failure_diagnostic("warning: 0: command not found", exit_code=127) is None
977
+
978
+ # Other common errors that must NOT be misclassified as missing_command
979
+ assert extract_failure_diagnostic("sh: line 10: syntax error: unexpected end of file", exit_code=127) is None
980
+ assert extract_failure_diagnostic("bash: /path/to/script.sh: Permission denied", exit_code=127) is None
981
+ assert extract_failure_diagnostic("Status: not found", exit_code=127) is None
982
+
983
+ # Pure symbols/dots
984
+ assert extract_failure_diagnostic(": not found", exit_code=127) is None
985
+ assert extract_failure_diagnostic("..: not found", exit_code=127) is None
986
+ assert extract_failure_diagnostic("/: not found", exit_code=127) is None
987
+ assert extract_failure_diagnostic("//: not found", exit_code=127) is None
988
+
989
+
990
+ def test_extract_failure_diagnostic_sanitization_and_unicode():
991
+ # ANSI escape sequences stripped before matching
992
+ ansi_text = "\x1b[31mbash: curl: command not found\x1b[0m"
993
+ diag = extract_failure_diagnostic(ansi_text, exit_code=127)
994
+ assert diag is not None
995
+ assert diag.command == "curl"
996
+ assert diag.message == "command not found: curl"
997
+
998
+ # Unicode command identifier
999
+ unicode_text = "sh: line 1: 测试工具: not found"
1000
+ diag_u = extract_failure_diagnostic(unicode_text, exit_code=127)
1001
+ assert diag_u is not None
1002
+ assert diag_u.command == "测试工具"
1003
+ assert diag_u.message == "command not found: 测试工具"
1004
+
1005
+ # Excessive command name length truncated safely with ellipsis
1006
+ long_cmd = "a" * 150
1007
+ long_text = f"sh: {long_cmd}: not found"
1008
+ diag_long = extract_failure_diagnostic(long_text, exit_code=127)
1009
+ assert diag_long is not None
1010
+ assert len(diag_long.command) == 100
1011
+ assert diag_long.command.endswith("...")
1012
+
1013
+
1014
+ def test_run_on_distro_with_diagnostic_mock(tmp_path):
1015
+ script_file = tmp_path / "test_missing.sh"
1016
+ script_file.write_text("#!/bin/sh\napt-get update\n", encoding="utf-8")
1017
+
1018
+ mock_client = mock.MagicMock()
1019
+ mock_container = mock.MagicMock()
1020
+ mock_container.status = "exited"
1021
+ mock_container.attrs = {"State": {"ExitCode": 127}}
1022
+ mock_container.logs.return_value = b"sh: line 2: apt-get: not found\n"
1023
+ mock_client.containers.create.return_value = mock_container
1024
+
1025
+ # Positive: exit 127 + apt-get not found -> sets structured diagnostic
1026
+ res_pos = run_on_distro(mock_client, str(script_file), "alpine:3.20")
1027
+ assert res_pos.status == DistroStatus.FAIL
1028
+ assert res_pos.exit_code == 127
1029
+ assert res_pos.error_message == "Script failed with non-zero exit code: 127"
1030
+ assert res_pos.diagnostic is not None
1031
+ assert res_pos.diagnostic.kind == "missing_command"
1032
+ assert res_pos.diagnostic.command == "apt-get"
1033
+ assert res_pos.diagnostic.message == "command not found: apt-get"
1034
+
1035
+ # Negative 1: exit 127 but no missing command in output -> diagnostic is None
1036
+ mock_container.logs.return_value = b"An unexpected error occurred.\n"
1037
+ res_neg1 = run_on_distro(mock_client, str(script_file), "alpine:3.20")
1038
+ assert res_neg1.status == DistroStatus.FAIL
1039
+ assert res_neg1.exit_code == 127
1040
+ assert res_neg1.diagnostic is None
1041
+ assert res_neg1.error_message == "Script failed with non-zero exit code: 127"
1042
+
1043
+ # Negative 2: exit 1 with 'command not found' output -> diagnostic is None (must be exit 127)
1044
+ mock_container.attrs = {"State": {"ExitCode": 1}}
1045
+ mock_container.logs.return_value = b"sh: line 2: apt-get: not found\n"
1046
+ res_neg2 = run_on_distro(mock_client, str(script_file), "alpine:3.20")
1047
+ assert res_neg2.status == DistroStatus.FAIL
1048
+ assert res_neg2.exit_code == 1
1049
+ assert res_neg2.diagnostic is None
1050
+ assert res_neg2.error_message == "Script failed with non-zero exit code: 1"
1051
+
1052
+
1053
+ def test_diagnostic_serialization_backward_compatibility():
1054
+ # With diagnostic
1055
+ res_with_diag = SingleResult(
1056
+ distro="alpine:3.20",
1057
+ status=DistroStatus.FAIL,
1058
+ exit_code=127,
1059
+ duration=0.456,
1060
+ output_snippet="apt-get: not found",
1061
+ error_message="Script failed with non-zero exit code: 127",
1062
+ diagnostic=FailureDiagnostic(
1063
+ kind="missing_command",
1064
+ command="apt-get",
1065
+ message="command not found: apt-get",
1066
+ ),
1067
+ )
1068
+ d = res_with_diag.to_dict()
1069
+ assert d["distro"] == "alpine:3.20"
1070
+ assert d["status"] == "FAIL"
1071
+ assert d["exit_code"] == 127
1072
+ assert d["duration"] == 0.456
1073
+ assert d["output_snippet"] == "apt-get: not found"
1074
+ assert d["error_message"] == "Script failed with non-zero exit code: 127"
1075
+ assert d["diagnostic"] == {
1076
+ "kind": "missing_command",
1077
+ "command": "apt-get",
1078
+ "message": "command not found: apt-get",
1079
+ }
1080
+
1081
+ # Without diagnostic
1082
+ res_without_diag = SingleResult(
1083
+ distro="debian:12-slim",
1084
+ status=DistroStatus.PASS,
1085
+ exit_code=0,
1086
+ duration=1.0,
1087
+ )
1088
+ d2 = res_without_diag.to_dict()
1089
+ assert d2["diagnostic"] is None
1090
+
1091
+
1092
+ def test_reporter_rendering_with_diagnostic():
1093
+ res1 = SingleResult(
1094
+ distro="debian:12-slim",
1095
+ status=DistroStatus.PASS,
1096
+ exit_code=0,
1097
+ duration=0.5,
1098
+ )
1099
+ res2 = SingleResult(
1100
+ distro="alpine:3.20",
1101
+ status=DistroStatus.FAIL,
1102
+ exit_code=127,
1103
+ duration=0.2,
1104
+ output_snippet="apt-get: not found",
1105
+ error_message="Script failed with non-zero exit code: 127",
1106
+ diagnostic=FailureDiagnostic(
1107
+ kind="missing_command",
1108
+ command="apt-get",
1109
+ message="command not found: apt-get",
1110
+ ),
1111
+ )
1112
+ res3 = SingleResult(
1113
+ distro="ubuntu:22.04",
1114
+ status=DistroStatus.FAIL,
1115
+ exit_code=1,
1116
+ duration=0.3,
1117
+ error_message="Script failed with non-zero exit code: 1",
1118
+ )
1119
+ report = RunReport(results=[res1, res2, res3], total_duration=1.0)
1120
+
1121
+ # 1. Terminal Table
1122
+ table_output = format_terminal_table(report)
1123
+ assert "command not found: apt-get" in table_output
1124
+ assert "Script failed with non-zero exit code: 1" in table_output
1125
+ assert "OK" in table_output
1126
+
1127
+ # 2. GitHub Summary
1128
+ gh_output = format_github_summary(report)
1129
+ assert "command not found: apt-get" in gh_output
1130
+ assert "> **Diagnostic:** `missing_command` — `command not found: apt-get`" in gh_output
1131
+ assert "> **Error:** Script failed with non-zero exit code: 127" in gh_output
@@ -1,121 +0,0 @@
1
- name: Build and Release
2
-
3
- on:
4
- push:
5
- tags:
6
- - 'v*'
7
- workflow_dispatch:
8
-
9
- permissions:
10
- contents: read
11
-
12
- jobs:
13
- test:
14
- name: Run Test Suite
15
- runs-on: ubuntu-latest
16
- steps:
17
- - name: Checkout Code
18
- uses: actions/checkout@v7
19
-
20
- - name: Set up Python
21
- uses: actions/setup-python@v7
22
- with:
23
- python-version: '3.12'
24
-
25
- - name: Install Dependencies
26
- run: |
27
- python -m pip install --upgrade pip
28
- pip install -e .[test]
29
-
30
- - name: Run Tests
31
- run: |
32
- pytest -v -m "not integration"
33
-
34
- build:
35
- name: Build Distribution Packages
36
- needs: test
37
- runs-on: ubuntu-latest
38
- permissions:
39
- contents: write
40
- steps:
41
- - name: Checkout Code
42
- uses: actions/checkout@v7
43
-
44
- - name: Set up Python
45
- uses: actions/setup-python@v7
46
- with:
47
- python-version: '3.12'
48
-
49
- - name: Verify Version Consistency
50
- shell: bash
51
- run: |
52
- TOML_VERSION=$(python -c "import tomllib; f=open('pyproject.toml', 'rb'); print(tomllib.load(f)['project']['version'])")
53
- INIT_VERSION=$(python -c "import sys; sys.path.insert(0, 'src'); import opsscript_gate; print(opsscript_gate.__version__)")
54
-
55
- echo "pyproject.toml version: $TOML_VERSION"
56
- echo "opsscript_gate version: $INIT_VERSION"
57
-
58
- if [ "$TOML_VERSION" != "$INIT_VERSION" ]; then
59
- echo "::error::Version mismatch: pyproject.toml ($TOML_VERSION) != __init__.py ($INIT_VERSION)"
60
- exit 1
61
- fi
62
-
63
- if [[ "${{ github.ref }}" =~ ^refs/tags/v ]]; then
64
- TAG_VERSION="${GITHUB_REF_NAME#v}"
65
- echo "Git tag version: $TAG_VERSION"
66
- if [ "$TAG_VERSION" != "$TOML_VERSION" ]; then
67
- echo "::error::Tag mismatch: Git tag v$TAG_VERSION does not match project version $TOML_VERSION"
68
- exit 1
69
- fi
70
- fi
71
-
72
- echo "Version consistency verified: $TOML_VERSION"
73
-
74
- - name: Install build tools
75
- run: |
76
- python -m pip install --upgrade pip
77
- pip install build twine
78
-
79
- - name: Build sdist and wheel
80
- run: |
81
- python -m build
82
-
83
- - name: Verify Package Metadata
84
- run: |
85
- python -m twine check dist/*
86
-
87
- - name: Upload dist artifacts
88
- uses: actions/upload-artifact@v4
89
- with:
90
- name: dist
91
- path: dist/
92
- retention-days: 7
93
-
94
- - name: Create GitHub Release and Upload Assets
95
- uses: softprops/action-gh-release@v2
96
- if: startsWith(github.ref, 'refs/tags/v')
97
- with:
98
- files: dist/*
99
- generate_release_notes: true
100
-
101
- publish-pypi:
102
- name: Publish to PyPI via OIDC
103
- needs: [test, build]
104
- if: startsWith(github.ref, 'refs/tags/v')
105
- runs-on: ubuntu-latest
106
- environment:
107
- name: pypi
108
- url: https://pypi.org/p/opsscript-gate
109
- permissions:
110
- id-token: write
111
- steps:
112
- - name: Download dist artifacts
113
- uses: actions/download-artifact@v4
114
- with:
115
- name: dist
116
- path: dist
117
-
118
- - name: Publish package distributions to PyPI
119
- uses: pypa/gh-action-pypi-publish@release/v1
120
- with:
121
- packages-dir: dist/
File without changes