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.
- opsscript_gate-0.3.0/.github/workflows/release.yml +213 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/CHANGELOG.md +10 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/PKG-INFO +29 -20
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/README.md +28 -19
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/SECURITY.md +3 -1
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/github-actions/workflow.yml +2 -1
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/pyproject.toml +1 -1
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/src/opsscript_gate/__init__.py +1 -1
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/src/opsscript_gate/models.py +19 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/src/opsscript_gate/reporter.py +13 -1
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/src/opsscript_gate/runner.py +100 -1
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/tests/test_runner.py +248 -2
- opsscript_gate-0.2.1/.github/workflows/release.yml +0 -121
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.gitattributes +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.github/assets/social-preview.png +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.github/pull_request_template.md +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.github/workflows/demo.yml +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.github/workflows/test.yml +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/.gitignore +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/CONTRIBUTING.md +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/LICENSE +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/ROADMAP.md +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/action.yml +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/alpine-incompatibility/README.md +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/basic/README.md +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/basic/clean_setup.sh +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/github-actions/README.md +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/interactive-hang/README.md +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/interactive-hang/prompt_confirm.sh +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/src/opsscript_gate/cli.py +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/tests/fixtures/fail_deps.sh +0 -0
- {opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/tests/fixtures/fail_hang.sh +0 -0
- {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.
|
|
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
|
|
|
@@ -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.
|
|
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
|
[](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
|
|
|
@@ -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.
|
|
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
|
-
- **
|
|
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("")
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{opsscript_gate-0.2.1 → opsscript_gate-0.3.0}/examples/alpine-incompatibility/apt_dependency.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|