Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ name: Build and Release

on:
push:
branches:
- main
tags:
- 'v*'
workflow_dispatch:
Expand Down Expand Up @@ -37,16 +39,22 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
is_new_release: ${{ steps.version-check.outputs.is_new_release }}
release_tag: ${{ steps.version-check.outputs.release_tag }}
steps:
- name: Checkout Code
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.12'

- name: Verify Version Consistency
- name: Verify Version and Release Readiness
id: version-check
shell: bash
run: |
TOML_VERSION=$(python -c "import tomllib; f=open('pyproject.toml', 'rb'); print(tomllib.load(f)['project']['version'])")
Expand All @@ -60,31 +68,62 @@ jobs:
exit 1
fi

TARGET_TAG="v$TOML_VERSION"
echo "Target release tag: $TARGET_TAG"

if [[ "${{ github.ref }}" =~ ^refs/tags/v ]]; then
TAG_VERSION="${GITHUB_REF_NAME#v}"
echo "Git tag version: $TAG_VERSION"
if [ "$TAG_VERSION" != "$TOML_VERSION" ]; then
echo "::error::Tag mismatch: Git tag v$TAG_VERSION does not match project version $TOML_VERSION"
exit 1
fi
echo "is_new_release=true" >> "$GITHUB_OUTPUT"
echo "release_tag=$TARGET_TAG" >> "$GITHUB_OUTPUT"
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
if git rev-parse -q --verify "refs/tags/$TARGET_TAG" >/dev/null; then
echo "Tag $TARGET_TAG already exists in git history. Skipping automated release."
echo "is_new_release=false" >> "$GITHUB_OUTPUT"
echo "release_tag=$TARGET_TAG" >> "$GITHUB_OUTPUT"
else
echo "New version detected on main ($TARGET_TAG does not exist yet). Proceeding with automated release."
echo "is_new_release=true" >> "$GITHUB_OUTPUT"
echo "release_tag=$TARGET_TAG" >> "$GITHUB_OUTPUT"
fi
else
echo "is_new_release=true" >> "$GITHUB_OUTPUT"
echo "release_tag=$TARGET_TAG" >> "$GITHUB_OUTPUT"
fi

echo "Version consistency verified: $TOML_VERSION"
- name: Create and push Git tag
if: steps.version-check.outputs.is_new_release == 'true' && github.ref == 'refs/heads/main'
shell: bash
run: |
TARGET_TAG="${{ steps.version-check.outputs.release_tag }}"
echo "Creating and pushing Git tag: $TARGET_TAG"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$TARGET_TAG"
git push origin "$TARGET_TAG"

- name: Install build tools
if: steps.version-check.outputs.is_new_release == 'true'
run: |
python -m pip install --upgrade pip
pip install build twine

- name: Build sdist and wheel
if: steps.version-check.outputs.is_new_release == 'true'
run: |
python -m build

- name: Verify Package Metadata
if: steps.version-check.outputs.is_new_release == 'true'
run: |
python -m twine check dist/*

- name: Upload dist artifacts
if: steps.version-check.outputs.is_new_release == 'true'
uses: actions/upload-artifact@v4
with:
name: dist
Expand All @@ -93,15 +132,16 @@ jobs:

- name: Create GitHub Release and Upload Assets
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/v')
if: steps.version-check.outputs.is_new_release == 'true'
with:
tag_name: ${{ steps.version-check.outputs.release_tag }}
files: dist/*
generate_release_notes: true

publish-pypi:
name: Publish to PyPI via OIDC
needs: [test, build]
if: startsWith(github.ref, 'refs/tags/v')
if: needs.build.outputs.is_new_release == 'true'
runs-on: ubuntu-latest
environment:
name: pypi
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.3.0] - 2026-09-19

### Added
- Structured runtime failure diagnostics: detect common "command not found" failures across Linux distributions (Alpine/BusyBox ash, Debian/Ubuntu dash, Bash, and missing interpreter paths).
- High-confidence dual-signal classification: requires exit code 127 and matching shell output to produce a `missing_command` diagnostic.
- Additive `diagnostic` field in JSON report format (`SingleResult.to_dict()`) providing structured diagnostic information (backward-compatible for consumers tolerating additional fields).
- Enhanced terminal table formatting and GitHub Actions Step Summary showing clear failure causes in the summary overview.
- Automated release on merge to main when project version is bumped.

---

## [0.2.1] - 2026-09-19

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: Mresyzz/opsscript-gate@v0.2.1
- uses: Mresyzz/opsscript-gate@v0.3.0
with:
script-path: scripts/setup.sh
shell: auto
Expand Down
2 changes: 1 addition & 1 deletion examples/github-actions/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
uses: actions/checkout@v7

- name: Run OpsScript Gate
uses: Mresyzz/opsscript-gate@v0.2.1
uses: Mresyzz/opsscript-gate@v0.3.0
with:
script-path: 'scripts/deploy.sh'
shell: auto
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "opsscript-gate"
version = "0.2.1"
version = "0.3.0"
description = "Run Linux shell scripts across Debian, Ubuntu and Alpine before release to catch runtime compatibility failures"
readme = "README.md"
license = { text = "MIT" }
Expand Down
2 changes: 1 addition & 1 deletion src/opsscript_gate/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""OpsScript Gate: Lightweight cross-distro compatibility pre-check for Linux ops scripts."""

__version__ = "0.2.1"
__version__ = "0.3.0"
__all__ = ["__version__"]
19 changes: 19 additions & 0 deletions src/opsscript_gate/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ class ShellMode(str, Enum):
AUTO = "auto"


@dataclass
class FailureDiagnostic:
"""Structured diagnostic information for execution failures."""
kind: str
message: str
command: str | None = None

def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {
"kind": self.kind,
"message": self.message,
}
if self.command is not None:
d["command"] = self.command
return d


@dataclass
class SingleResult:
"""Execution result for a single Linux distribution."""
Expand All @@ -29,6 +46,7 @@ class SingleResult:
duration: float
output_snippet: str = ""
error_message: str | None = None
diagnostic: FailureDiagnostic | None = None

def to_dict(self) -> dict[str, Any]:
return {
Expand All @@ -38,6 +56,7 @@ def to_dict(self) -> dict[str, Any]:
"duration": round(self.duration, 3),
"output_snippet": self.output_snippet,
"error_message": self.error_message,
"diagnostic": self.diagnostic.to_dict() if self.diagnostic is not None else None,
}


Expand Down
14 changes: 13 additions & 1 deletion src/opsscript_gate/reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ def format_terminal_table(report: RunReport) -> str:

if r.status == DistroStatus.PASS:
detail = "OK"
elif r.diagnostic:
detail = r.diagnostic.message
elif r.error_message:
detail = r.error_message
else:
Expand Down Expand Up @@ -107,7 +109,12 @@ def format_github_summary(report: RunReport) -> str:

exit_code_str = f"`{r.exit_code}`" if r.exit_code is not None else "`N/A`"
duration_str = f"`{r.duration:.2f}s`"
msg = r.error_message or "-"
if r.status == DistroStatus.PASS:
msg = "OK"
elif r.diagnostic:
msg = r.diagnostic.message
else:
msg = r.error_message or "-"
msg_escaped = msg.replace("|", "\\|")

lines.append(
Expand All @@ -123,6 +130,11 @@ def format_github_summary(report: RunReport) -> str:
for r in failures:
lines.append(f"<details><summary><b>[{r.status.value}] {r.distro}</b></summary>")
lines.append("")
if r.diagnostic:
clean_kind = r.diagnostic.kind.replace("`", "'")
clean_msg = r.diagnostic.message.replace("`", "'")
lines.append(f"> **Diagnostic:** `{clean_kind}` — `{clean_msg}`")
lines.append("")
if r.error_message:
lines.append(f"> **Error:** {r.error_message}")
lines.append("")
Expand Down
92 changes: 91 additions & 1 deletion src/opsscript_gate/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from dataclasses import dataclass
from enum import Enum
from opsscript_gate.models import DistroStatus, RunReport, ShellMode, SingleResult
from opsscript_gate.models import DistroStatus, FailureDiagnostic, RunReport, ShellMode, SingleResult

DEFAULT_MATRIX: list[str] = [
"debian:12-slim",
Expand Down Expand Up @@ -67,6 +67,94 @@ def sanitize_diagnostic_text(text: str, max_length: int = 200) -> str:
return sanitized


# Conservative regular expression matching common shell 'not found' error patterns:
# Examples:
# - "apt-get: not found"
# - "bash: foo: command not found"
# - "/tmp/target_script.sh: line 4: curl: not found"
# - "/bin/sh: line 1: /usr/bin/bash: not found"
_MISSING_COMMAND_RE = re.compile(
r"""
(?:^|(?<=[\r\n])) # start of line
(?:\s*[^:\r\n]+:\s*)* # optional prefixes like 'sh: ', 'line 4: ', 'script.sh: 2: '
(?P<cmd>['"`]?[\w./+-]+['"`]?) # strictly valid command identifier/path
:\s+ # colon separator
(?:command\s+not\s+found|not\s+found) # missing command indicator
\s*(?=$|[\r\n]) # end of line
""",
re.IGNORECASE | re.VERBOSE | re.MULTILINE,
)

# Strict whitelist for extracted command token validation (alphanumerics, Unicode words, dots, dashes, slashes):
_VALID_COMMAND_TOKEN_RE = re.compile(r"^[\w./+-]+$")

# Common status, protocol, and message prefixes that must never be classified as missing commands:
_NON_COMMAND_TOKENS = {
"status", "error", "warning", "info", "notice", "message",
"file", "directory", "entry", "key", "value", "user", "record",
"http", "https", "response", "request", "server", "client",
}


def extract_failure_diagnostic(
output: str,
exit_code: int | None = None,
) -> FailureDiagnostic | None:
"""
Extract high-confidence structured diagnostic from container execution failure.

Deliberately conservative (prefer false negatives over false positives):
- Requires exit_code == 127 as an essential high-confidence signal.
- Requires matching a well-known shell 'not found' pattern in output.
- Strictly validates command token syntax (no arbitrary text or markdown injection).
- Excludes numeric status codes, status/error message prefixes, path-only dots/slashes, and unclassified output.
- Returns None if not confidently classified as missing_command.
"""
if exit_code != 127 or not output:
return None

# Strip ANSI escape sequences first
cleaned_output = _ANSI_ESCAPE_RE.sub("", output)

for line in cleaned_output.splitlines():
line_clean = line.strip()
if not line_clean:
continue

m = _MISSING_COMMAND_RE.search(line_clean)
if not m:
continue

raw_cmd = m.group("cmd").strip("'\"`")
if not raw_cmd or raw_cmd.isdigit():
continue

# Disallow pure dot/slash sequences
if raw_cmd in (".", "..", "/", "//"):
continue

# Exclude common non-command tokens and protocol prefixes (e.g. Status, Error, HTTP/1.1)
lowered = raw_cmd.lower()
if lowered in _NON_COMMAND_TOKENS or lowered.split("/")[0] in _NON_COMMAND_TOKENS:
continue

# Validate against strict conservative grammar
if not _VALID_COMMAND_TOKEN_RE.match(raw_cmd):
continue

sanitized_cmd = sanitize_diagnostic_text(raw_cmd, max_length=100)
if not sanitized_cmd or not _VALID_COMMAND_TOKEN_RE.match(sanitized_cmd):
continue

return FailureDiagnostic(
kind="missing_command",
command=sanitized_cmd,
message=f"command not found: {sanitized_cmd}",
)

return None


class ShebangStatus(str, Enum):
"""Status of shebang parsing."""
RECOGNIZED = "recognized"
Expand Down Expand Up @@ -505,13 +593,15 @@ def run_on_distro(
error_message=None,
)
else:
diagnostic = extract_failure_diagnostic(output, exit_code=exit_code)
return SingleResult(
distro=distro,
status=DistroStatus.FAIL,
exit_code=exit_code,
duration=duration,
output_snippet=extract_snippet(output),
error_message=f"Script failed with non-zero exit code: {exit_code}",
diagnostic=diagnostic,
)

except Exception as exc:
Expand Down
Loading
Loading