opsscript-gate 0.2.0__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.
- opsscript_gate-0.3.0/.github/workflows/release.yml +213 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/CHANGELOG.md +19 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/PKG-INFO +36 -19
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/README.md +35 -18
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/SECURITY.md +3 -1
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/examples/github-actions/workflow.yml +2 -1
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/pyproject.toml +1 -1
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/src/opsscript_gate/__init__.py +1 -1
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/src/opsscript_gate/models.py +19 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/src/opsscript_gate/reporter.py +13 -1
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/src/opsscript_gate/runner.py +117 -4
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/tests/test_runner.py +304 -8
- opsscript_gate-0.2.0/.github/workflows/release.yml +0 -121
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/.gitattributes +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/.github/assets/social-preview.png +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/.github/pull_request_template.md +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/.github/workflows/demo.yml +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/.github/workflows/test.yml +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/.gitignore +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/CONTRIBUTING.md +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/LICENSE +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/ROADMAP.md +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/action.yml +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/examples/alpine-incompatibility/README.md +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/examples/basic/README.md +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/examples/basic/clean_setup.sh +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/examples/github-actions/README.md +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/examples/interactive-hang/README.md +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/examples/interactive-hang/prompt_confirm.sh +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/src/opsscript_gate/cli.py +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/tests/fixtures/fail_deps.sh +0 -0
- {opsscript_gate-0.2.0 → opsscript_gate-0.3.0}/tests/fixtures/fail_hang.sh +0 -0
- {opsscript_gate-0.2.0 → 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,25 @@ 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
|
+
|
|
18
|
+
## [0.2.1] - 2026-09-19
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- Strip complete ANSI CSI and OSC escape sequences from diagnostic text instead of leaving escape-sequence residue.
|
|
22
|
+
- Ensure diagnostic `max_length` includes the trailing ellipsis when truncation occurs.
|
|
23
|
+
- Add regression coverage for ANSI sanitization and truncation edge cases.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
8
27
|
## [0.2.0] - 2026-09-18
|
|
9
28
|
|
|
10
29
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: opsscript-gate
|
|
3
|
-
Version: 0.
|
|
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
|
[](https://opensource.org/licenses/MIT)
|
|
40
40
|
[](#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
|
|
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
|
-
|
|
54
|
+
Here is a complete minimal workflow to drop into `.github/workflows/shell-compat.yml`:
|
|
55
55
|
|
|
56
56
|
```yaml
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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. **
|
|
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 "
|
|
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. **
|
|
161
|
-
-
|
|
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
|
|
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.
|
|
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
|
|
|
@@ -217,6 +223,14 @@ Check out the [examples/](examples/) directory for self-contained, runnable scen
|
|
|
217
223
|
|
|
218
224
|
---
|
|
219
225
|
|
|
226
|
+
## Dogfooding
|
|
227
|
+
|
|
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`.
|
|
229
|
+
|
|
230
|
+
See the downstream workflow: [`.github/workflows/test.yml`](https://github.com/Mresyzz/linux-dev-bootstrap/blob/main/.github/workflows/test.yml).
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
220
234
|
## Development & Testing
|
|
221
235
|
|
|
222
236
|
The test suite uses Docker SDK mocking to ensure fast unit tests without needing a local daemon:
|
|
@@ -227,6 +241,9 @@ git clone https://github.com/Mresyzz/opsscript-gate.git
|
|
|
227
241
|
cd opsscript-gate
|
|
228
242
|
pip install -e .[test]
|
|
229
243
|
|
|
244
|
+
# Or install latest unreleased code directly from Git:
|
|
245
|
+
# pip install git+https://github.com/Mresyzz/opsscript-gate.git
|
|
246
|
+
|
|
230
247
|
# Run unit tests (mocked)
|
|
231
248
|
pytest -v -m "not integration"
|
|
232
249
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
[](https://opensource.org/licenses/MIT)
|
|
12
12
|
[](#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
|
|
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
|
-
|
|
26
|
+
Here is a complete minimal workflow to drop into `.github/workflows/shell-compat.yml`:
|
|
27
27
|
|
|
28
28
|
```yaml
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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. **
|
|
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 "
|
|
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. **
|
|
133
|
-
-
|
|
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
|
|
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.
|
|
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
|
|
|
@@ -189,6 +195,14 @@ Check out the [examples/](examples/) directory for self-contained, runnable scen
|
|
|
189
195
|
|
|
190
196
|
---
|
|
191
197
|
|
|
198
|
+
## Dogfooding
|
|
199
|
+
|
|
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`.
|
|
201
|
+
|
|
202
|
+
See the downstream workflow: [`.github/workflows/test.yml`](https://github.com/Mresyzz/linux-dev-bootstrap/blob/main/.github/workflows/test.yml).
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
192
206
|
## Development & Testing
|
|
193
207
|
|
|
194
208
|
The test suite uses Docker SDK mocking to ensure fast unit tests without needing a local daemon:
|
|
@@ -199,6 +213,9 @@ git clone https://github.com/Mresyzz/opsscript-gate.git
|
|
|
199
213
|
cd opsscript-gate
|
|
200
214
|
pip install -e .[test]
|
|
201
215
|
|
|
216
|
+
# Or install latest unreleased code directly from Git:
|
|
217
|
+
# pip install git+https://github.com/Mresyzz/opsscript-gate.git
|
|
218
|
+
|
|
202
219
|
# Run unit tests (mocked)
|
|
203
220
|
pytest -v -m "not integration"
|
|
204
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
|
-
- **
|
|
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.
|
|
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.
|
|
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" }
|
|
@@ -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
|
-
|
|
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("")
|