opsscript-gate 0.4.0__tar.gz → 0.4.1__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.4.0 → opsscript_gate-0.4.1}/CHANGELOG.md +14 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/PKG-INFO +14 -7
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/README.md +13 -6
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/SECURITY.md +6 -4
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/pyproject.toml +1 -1
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/src/opsscript_gate/__init__.py +1 -1
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/src/opsscript_gate/reporter.py +151 -32
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/src/opsscript_gate/runner.py +274 -120
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/tests/test_runner.py +449 -1
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.gitattributes +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/assets/social-preview.png +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/pull_request_template.md +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/workflows/demo.yml +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/workflows/release.yml +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/workflows/test.yml +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.gitignore +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/CONTRIBUTING.md +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/LICENSE +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/ROADMAP.md +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/action.yml +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/docs/promotion/awesome-lists-pr.md +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/docs/promotion/juejin-zhihu-article.md +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/docs/promotion/reddit-devops.md +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/docs/promotion/show-hn.md +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/docs/promotion/v2ex-post.md +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/alpine-incompatibility/README.md +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/basic/README.md +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/basic/clean_setup.sh +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/github-actions/README.md +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/github-actions/workflow.yml +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/interactive-hang/README.md +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/interactive-hang/prompt_confirm.sh +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/src/opsscript_gate/cli.py +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/src/opsscript_gate/discovery.py +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/src/opsscript_gate/models.py +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/src/opsscript_gate/remediation.py +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/tests/fixtures/fail_deps.sh +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/tests/fixtures/fail_hang.sh +0 -0
- {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/tests/fixtures/pass_basic.sh +0 -0
|
@@ -5,6 +5,20 @@ 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.4.1] - 2026-09-19
|
|
9
|
+
|
|
10
|
+
### Fixed & Hardened
|
|
11
|
+
- **Bounded container log capture**: Stream-captures container logs using an immediate rolling byte buffer capped at 256 KiB (`MAX_CAPTURED_LOG_BYTES`) and tail limited to 500 lines (`MAX_LOG_TAIL_LINES`), bounding retained container log data to reduce memory-exhaustion risk from runaway output.
|
|
12
|
+
- **Workflow command neutralization**: Neutralizes untrusted container logs starting with `::` into `[container] ::` to prevent malicious scripts from forging GitHub Actions workflow commands in Runner consoles, while keeping OpsScript Gate's own annotations intact.
|
|
13
|
+
- **Terminal ANSI and control character sanitization**: Strips ANSI escape sequences (CSI, OSC, 2-byte escapes), removes dangerous C0 control characters and DEL, and normalizes CRLF and standalone carriage returns (`\r`) to defeat terminal line overwrite and status spoofing, while preserving printable Unicode.
|
|
14
|
+
- **Context-sensitive Markdown and HTML injection defense**: Implemented dedicated escaping (`escape_inline_code`, `escape_markdown_text`, `escape_markdown_table_cell`, `escape_html_text`, `format_safe_code_fence`) to prevent table cell tearing, HTML container breakout (`</details>`), premature code fence closure, and structural Markdown injection from runtime-derived values.
|
|
15
|
+
- **Bounded streaming script preparation**: Stream-normalizes CRLF in bounded 64 KiB chunks (`SCRIPT_READ_CHUNK_SIZE`), properly handling boundary-split CRLF while preserving lone CR bytes without whole-file memory allocation. Bounded shebang parsing to 4096 bytes (`MAX_SHEBANG_BYTES`).
|
|
16
|
+
- **Parallel matrix script preparation deduplication**: Pre-normalizes scripts once before parallel matrix execution, sharing the prepared file read-only across worker threads and centralizing cleanup to eliminate file locking and race conditions on Windows.
|
|
17
|
+
- **Shebang evaluation refactoring**: Deduplicated shebang resolution across `shebang` and `auto` modes via `_resolve_shell_command`.
|
|
18
|
+
- **Regression test coverage**: Added extensive adversarial and edge-case tests covering oversized streams, boundary splits, workflow spoofing, Markdown injections, and error branches.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
8
22
|
## [0.4.0] - 2026-09-19
|
|
9
23
|
|
|
10
24
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: opsscript-gate
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
@@ -58,7 +58,7 @@ jobs:
|
|
|
58
58
|
runs-on: ubuntu-latest
|
|
59
59
|
steps:
|
|
60
60
|
- uses: actions/checkout@v7
|
|
61
|
-
- uses: Mresyzz/opsscript-gate@v0.4.
|
|
61
|
+
- uses: Mresyzz/opsscript-gate@v0.4.1
|
|
62
62
|
```
|
|
63
63
|
|
|
64
64
|
> **Zero Config**: If `script-path` is omitted, OpsScript Gate automatically discovers shell scripts in your repository and tests them concurrently!
|
|
@@ -66,7 +66,7 @@ jobs:
|
|
|
66
66
|
Or test a specific script with custom execution modes:
|
|
67
67
|
|
|
68
68
|
```yaml
|
|
69
|
-
- uses: Mresyzz/opsscript-gate@v0.4.
|
|
69
|
+
- uses: Mresyzz/opsscript-gate@v0.4.1
|
|
70
70
|
with:
|
|
71
71
|
script-path: scripts/install.sh
|
|
72
72
|
shell: auto
|
|
@@ -212,10 +212,17 @@ OpsScript Gate applies conservative, restricted container defaults when running
|
|
|
212
212
|
5. **Hard Timeout & Container Cleanup**:
|
|
213
213
|
- Enforces configurable timeout (default: 60s). Timed-out containers are sent `SIGKILL` and marked `TIMED_OUT`.
|
|
214
214
|
- Container removal is attempted from a `finally` block in normal, failure, and timeout execution paths.
|
|
215
|
-
6. **
|
|
216
|
-
-
|
|
217
|
-
7. **
|
|
218
|
-
-
|
|
215
|
+
6. **Bounded Output & Memory Protection**:
|
|
216
|
+
- Captures container logs using an immediate rolling byte buffer capped at 256 KiB (`MAX_CAPTURED_LOG_BYTES`) and tail limited to 500 lines (`MAX_LOG_TAIL_LINES`), which bounds retained container log data to reduce memory-exhaustion risk.
|
|
217
|
+
7. **Untrusted Log Neutralization & Terminal Defense**:
|
|
218
|
+
- Neutralizes line-leading workflow commands (`[container] ::`) to prevent forged GitHub Actions annotations in CI runners.
|
|
219
|
+
- Strips ANSI escape sequences and dangerous C0 control characters, and normalizes carriage returns (`\r`) to defeat terminal line overwrite spoofing.
|
|
220
|
+
- Employs context-sensitive escaping (`escape_inline_code`, `escape_markdown_text`, `escape_markdown_table_cell`, `escape_html_text`, `format_safe_code_fence`) to prevent Step Summary layout disruptions.
|
|
221
|
+
8. **Command Injection Defense**:
|
|
222
|
+
- OpsScript Gate's own annotations (`::error`) apply strict percent-encoding for all properties and message bodies, preventing workflow command injection.
|
|
223
|
+
9. **Bounded Streaming CRLF & Shebang Defense**:
|
|
224
|
+
- Stream-normalizes carriage returns in 64 KiB chunks, preserving lone CR bytes and bounds shebang parsing to 4096 bytes without whole-file memory allocation.
|
|
225
|
+
- Pre-normalizes scripts once before parallel matrix runs, sharing a read-only prepared path across worker threads.
|
|
219
226
|
|
|
220
227
|
---
|
|
221
228
|
|
|
@@ -30,7 +30,7 @@ jobs:
|
|
|
30
30
|
runs-on: ubuntu-latest
|
|
31
31
|
steps:
|
|
32
32
|
- uses: actions/checkout@v7
|
|
33
|
-
- uses: Mresyzz/opsscript-gate@v0.4.
|
|
33
|
+
- uses: Mresyzz/opsscript-gate@v0.4.1
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
> **Zero Config**: If `script-path` is omitted, OpsScript Gate automatically discovers shell scripts in your repository and tests them concurrently!
|
|
@@ -38,7 +38,7 @@ jobs:
|
|
|
38
38
|
Or test a specific script with custom execution modes:
|
|
39
39
|
|
|
40
40
|
```yaml
|
|
41
|
-
- uses: Mresyzz/opsscript-gate@v0.4.
|
|
41
|
+
- uses: Mresyzz/opsscript-gate@v0.4.1
|
|
42
42
|
with:
|
|
43
43
|
script-path: scripts/install.sh
|
|
44
44
|
shell: auto
|
|
@@ -184,10 +184,17 @@ OpsScript Gate applies conservative, restricted container defaults when running
|
|
|
184
184
|
5. **Hard Timeout & Container Cleanup**:
|
|
185
185
|
- Enforces configurable timeout (default: 60s). Timed-out containers are sent `SIGKILL` and marked `TIMED_OUT`.
|
|
186
186
|
- Container removal is attempted from a `finally` block in normal, failure, and timeout execution paths.
|
|
187
|
-
6. **
|
|
188
|
-
-
|
|
189
|
-
7. **
|
|
190
|
-
-
|
|
187
|
+
6. **Bounded Output & Memory Protection**:
|
|
188
|
+
- Captures container logs using an immediate rolling byte buffer capped at 256 KiB (`MAX_CAPTURED_LOG_BYTES`) and tail limited to 500 lines (`MAX_LOG_TAIL_LINES`), which bounds retained container log data to reduce memory-exhaustion risk.
|
|
189
|
+
7. **Untrusted Log Neutralization & Terminal Defense**:
|
|
190
|
+
- Neutralizes line-leading workflow commands (`[container] ::`) to prevent forged GitHub Actions annotations in CI runners.
|
|
191
|
+
- Strips ANSI escape sequences and dangerous C0 control characters, and normalizes carriage returns (`\r`) to defeat terminal line overwrite spoofing.
|
|
192
|
+
- Employs context-sensitive escaping (`escape_inline_code`, `escape_markdown_text`, `escape_markdown_table_cell`, `escape_html_text`, `format_safe_code_fence`) to prevent Step Summary layout disruptions.
|
|
193
|
+
8. **Command Injection Defense**:
|
|
194
|
+
- OpsScript Gate's own annotations (`::error`) apply strict percent-encoding for all properties and message bodies, preventing workflow command injection.
|
|
195
|
+
9. **Bounded Streaming CRLF & Shebang Defense**:
|
|
196
|
+
- Stream-normalizes carriage returns in 64 KiB chunks, preserving lone CR bytes and bounds shebang parsing to 4096 bytes without whole-file memory allocation.
|
|
197
|
+
- Pre-normalizes scripts once before parallel matrix runs, sharing a read-only prepared path across worker threads.
|
|
191
198
|
|
|
192
199
|
---
|
|
193
200
|
|
|
@@ -7,8 +7,7 @@ Only the latest release of **OpsScript Gate** receives security updates and bug
|
|
|
7
7
|
| Version | Supported |
|
|
8
8
|
| :--- | :---: |
|
|
9
9
|
| 0.4.x | :white_check_mark: |
|
|
10
|
-
| 0.
|
|
11
|
-
| < 0.3.0 | :x: |
|
|
10
|
+
| < 0.4.0 | :x: |
|
|
12
11
|
|
|
13
12
|
---
|
|
14
13
|
|
|
@@ -32,9 +31,12 @@ Reports will be reviewed as maintainer availability allows.
|
|
|
32
31
|
|
|
33
32
|
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
|
+
OpsScript Gate applies conservative container defaults and hardened I/O boundaries when running scripts:
|
|
36
35
|
- **Restricted Container Defaults**: Containers run with `privileged=False`, `cap_drop=["ALL"]`, and `security_opt=["no-new-privileges:true"]`.
|
|
37
36
|
- **Resource Limits & Isolation**: Enforces memory caps (`--mem-limit`, default 256m), process table caps (`--pids-limit`, default 128), and optional network isolation (`--network none`).
|
|
38
37
|
- **Read-Only Mounting**: Target scripts are mounted read-only (`:ro`). OpsScript Gate does not mount additional host filesystem paths into test containers.
|
|
39
38
|
- **Resource Protection & Hard Timeout**: Containers are subject to hard timeouts (default 60s) with `SIGKILL` termination. Container removal is attempted from a `finally` block in normal, failure, and timeout execution paths.
|
|
40
|
-
- **
|
|
39
|
+
- **Bounded Output & Rolling Buffer**: Container output capture is strictly bounded by tail lines (500) and byte caps (256 KiB) via an immediate rolling byte buffer, bounding retained container log data to reduce memory-exhaustion risk.
|
|
40
|
+
- **Untrusted Log Neutralization**: Container logs neutralize line-leading workflow commands (`[container] ::`), strip terminal ANSI/CSI/OSC sequences and dangerous C0 control characters, and apply context-sensitive Markdown/HTML escaping to protect Step Summaries.
|
|
41
|
+
- **Workflow Command Encoding**: OpsScript Gate's own GitHub Actions annotations (`::error`) apply strict percent-encoding for properties (`%`, `\r`, `\n`, `:`, `,`) and data (`%`, `\r`, `\n`), protecting GitHub Actions workflow-command fields against injection.
|
|
42
|
+
- **Bounded Streaming I/O**: Script preparation and line ending normalization operate in bounded 64 KiB chunks, and shebang line inspection is bounded to 4096 bytes.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "opsscript-gate"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.1"
|
|
8
8
|
description = "Run Linux shell scripts across Debian, Ubuntu and Alpine before release to catch runtime compatibility failures"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -1,10 +1,110 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import html
|
|
3
2
|
import json
|
|
4
3
|
import os
|
|
4
|
+
import re
|
|
5
5
|
import sys
|
|
6
6
|
from opsscript_gate.models import DistroStatus, MultiScriptReport, RunReport, SingleResult
|
|
7
7
|
|
|
8
|
+
# Structure-altering Markdown characters in regular prose:
|
|
9
|
+
# \ ` * _ [ ] ( ) # + - ! > ~ |
|
|
10
|
+
_MARKDOWN_SPECIAL_RE = re.compile(r"([\\`*_{}\[\]()#+\-.!>~|])")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def escape_inline_code(text: str) -> str:
|
|
14
|
+
"""
|
|
15
|
+
Escape text for safe inclusion inside Markdown inline code spans (`...`).
|
|
16
|
+
Replaces backticks with single quotes and strips newlines to prevent code-span breakouts.
|
|
17
|
+
"""
|
|
18
|
+
if not text:
|
|
19
|
+
return ""
|
|
20
|
+
cleaned = text.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")
|
|
21
|
+
return cleaned.replace("`", "'")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def escape_markdown_text(text: str) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Escape structure-altering characters in normal Markdown text.
|
|
27
|
+
Prevents headings, blockquotes, raw HTML, links, and formatting injection
|
|
28
|
+
while preserving standard technical terms like 'non-zero' or 'x86_64'.
|
|
29
|
+
"""
|
|
30
|
+
if not text:
|
|
31
|
+
return ""
|
|
32
|
+
# Neutralize HTML tags and entities
|
|
33
|
+
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
34
|
+
# Escape brackets, parentheses, pipes, backticks, and backslashes to prevent links/code spans
|
|
35
|
+
text = text.replace("\\", "\\\\")
|
|
36
|
+
text = text.replace("`", "\\`").replace("[", "\\[").replace("]", "\\]")
|
|
37
|
+
text = text.replace("(", "\\(").replace(")", "\\)").replace("|", "\\|")
|
|
38
|
+
|
|
39
|
+
# Neutralize line-leading markdown structures (headings, blockquotes, list markers)
|
|
40
|
+
def _neutralize_line_start(m: re.Match) -> str:
|
|
41
|
+
lead, ch = m.group(1), m.group(2)
|
|
42
|
+
if ch in ("#", ">", "-", "+", "*"):
|
|
43
|
+
return f"{lead}\\{ch}"
|
|
44
|
+
return m.group(0)
|
|
45
|
+
|
|
46
|
+
return re.sub(r"^(\s*)([#>+\-*])", _neutralize_line_start, text, flags=re.MULTILINE)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def escape_markdown_table_cell(text: str) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Escape content for safe inclusion in Markdown table cells.
|
|
52
|
+
Replaces newlines with spaces, escapes pipe characters,
|
|
53
|
+
and neutralizes HTML details/summary container tags to prevent breaking summary layout.
|
|
54
|
+
"""
|
|
55
|
+
if not text:
|
|
56
|
+
return ""
|
|
57
|
+
cleaned = text.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")
|
|
58
|
+
# Neutralize details/summary tags that could break surrounding HTML layout
|
|
59
|
+
cleaned = (
|
|
60
|
+
cleaned.replace("</details>", "<\\/details>")
|
|
61
|
+
.replace("<details>", "<\\details>")
|
|
62
|
+
.replace("</summary>", "<\\/summary>")
|
|
63
|
+
.replace("<summary>", "<\\summary>")
|
|
64
|
+
)
|
|
65
|
+
return cleaned.replace("|", "\\|")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def escape_html_text(text: str) -> str:
|
|
69
|
+
"""
|
|
70
|
+
Escape text for safe inclusion in HTML tags and attributes (e.g. <summary>).
|
|
71
|
+
"""
|
|
72
|
+
if not text:
|
|
73
|
+
return ""
|
|
74
|
+
return html.escape(text, quote=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def format_safe_code_fence(content: str, language: str = "text") -> list[str]:
|
|
78
|
+
"""
|
|
79
|
+
Safely enclose content in a markdown code block.
|
|
80
|
+
Dynamically adjusts backtick count to prevent premature closing,
|
|
81
|
+
and neutralizes closing HTML tags (e.g. </details>) that could break surrounding containers.
|
|
82
|
+
"""
|
|
83
|
+
if not content:
|
|
84
|
+
return [f"```{language}", "```"]
|
|
85
|
+
|
|
86
|
+
# Neutralize closing tags if present inside snippet so HTML details/summary container isn't closed
|
|
87
|
+
safe_content = (
|
|
88
|
+
content.replace("</details>", "<\\/details>")
|
|
89
|
+
.replace("</summary>", "<\\/summary>")
|
|
90
|
+
.replace("<details>", "<\\details>")
|
|
91
|
+
.replace("<summary>", "<\\summary>")
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Calculate max consecutive backticks in content
|
|
95
|
+
max_backticks = 3
|
|
96
|
+
current_run = 0
|
|
97
|
+
for ch in safe_content:
|
|
98
|
+
if ch == "`":
|
|
99
|
+
current_run += 1
|
|
100
|
+
if current_run >= max_backticks:
|
|
101
|
+
max_backticks = current_run + 1
|
|
102
|
+
else:
|
|
103
|
+
current_run = 0
|
|
104
|
+
|
|
105
|
+
fence = "`" * max_backticks
|
|
106
|
+
return [f"{fence}{language}", safe_content, fence]
|
|
107
|
+
|
|
8
108
|
|
|
9
109
|
def escape_github_property(value: str) -> str:
|
|
10
110
|
"""
|
|
@@ -203,6 +303,7 @@ def format_markdown_compatibility_card(report: RunReport, script_path: str = "")
|
|
|
203
303
|
"""
|
|
204
304
|
Format an elegant GitHub-flavored Markdown Compatibility Card.
|
|
205
305
|
Suitable for Step Summary and easily copyable into PR / Issue comments.
|
|
306
|
+
Applies context-sensitive escaping for Markdown/HTML output contexts.
|
|
206
307
|
"""
|
|
207
308
|
passed_count = sum(1 for r in report.results if r.status == DistroStatus.PASS)
|
|
208
309
|
total_count = len(report.results)
|
|
@@ -217,7 +318,8 @@ def format_markdown_compatibility_card(report: RunReport, script_path: str = "")
|
|
|
217
318
|
"",
|
|
218
319
|
]
|
|
219
320
|
if script_path:
|
|
220
|
-
|
|
321
|
+
safe_script_path = escape_inline_code(script_path)
|
|
322
|
+
lines.append(f"**Target Script**: `{safe_script_path}` ")
|
|
221
323
|
lines.append(f"**Status**: {badge} ")
|
|
222
324
|
lines.append(f"**Total Duration**: `{report.total_duration:.2f}s`")
|
|
223
325
|
lines.append("")
|
|
@@ -236,24 +338,28 @@ def format_markdown_compatibility_card(report: RunReport, script_path: str = "")
|
|
|
236
338
|
else:
|
|
237
339
|
status_icon = "⚠️ ERROR"
|
|
238
340
|
|
|
341
|
+
distro_cell = escape_markdown_table_cell(f"`{escape_inline_code(r.distro)}`")
|
|
239
342
|
exit_code_str = f"`{r.exit_code}`" if r.exit_code is not None else "`N/A`"
|
|
240
343
|
duration_str = f"`{r.duration:.2f}s`"
|
|
241
344
|
|
|
242
|
-
|
|
243
|
-
|
|
345
|
+
if r.status == DistroStatus.PASS:
|
|
346
|
+
raw_diag = "OK"
|
|
347
|
+
elif r.diagnostic:
|
|
244
348
|
line_str = f" (line {r.diagnostic.line})" if r.diagnostic.line else ""
|
|
245
|
-
clean_cmd = f"`{r.diagnostic.command}`" if r.diagnostic.command else "command"
|
|
246
|
-
|
|
349
|
+
clean_cmd = f"`{escape_inline_code(r.diagnostic.command)}`" if r.diagnostic.command else "command"
|
|
350
|
+
raw_diag = f"⚠️ Missing command: {clean_cmd}{line_str}"
|
|
247
351
|
if r.diagnostic.hint:
|
|
248
|
-
|
|
352
|
+
raw_diag += f"<br>💡 *{r.diagnostic.hint}*"
|
|
249
353
|
elif r.error_message:
|
|
250
|
-
|
|
354
|
+
raw_diag = r.error_message
|
|
251
355
|
elif r.status != DistroStatus.PASS:
|
|
252
|
-
|
|
356
|
+
raw_diag = f"Non-zero exit code: {r.exit_code}"
|
|
357
|
+
else:
|
|
358
|
+
raw_diag = "-"
|
|
253
359
|
|
|
254
|
-
|
|
360
|
+
diag_cell = escape_markdown_table_cell(raw_diag)
|
|
255
361
|
lines.append(
|
|
256
|
-
f"|
|
|
362
|
+
f"| {distro_cell} | {status_icon} | {exit_code_str} | {duration_str} | {diag_cell} |"
|
|
257
363
|
)
|
|
258
364
|
|
|
259
365
|
lines.append("")
|
|
@@ -262,23 +368,33 @@ def format_markdown_compatibility_card(report: RunReport, script_path: str = "")
|
|
|
262
368
|
lines.append("<details>")
|
|
263
369
|
lines.append("<summary>📋 <b>Copyable Markdown (Click to expand & copy to PR / Issue)</b></summary>")
|
|
264
370
|
lines.append("")
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
371
|
+
safe_copy_target = escape_inline_code(script_path or "target")
|
|
372
|
+
copy_lines = [
|
|
373
|
+
f"### 🛡️ OpsScript Gate: {passed_count}/{total_count} Passed (`{safe_copy_target}`)",
|
|
374
|
+
"| Distribution | Status | Time | Details |",
|
|
375
|
+
"| :--- | :---: | :---: | :--- |",
|
|
376
|
+
]
|
|
269
377
|
for r in report.results:
|
|
270
378
|
icon = "✅" if r.status == DistroStatus.PASS else "❌"
|
|
271
|
-
|
|
272
|
-
if r.
|
|
273
|
-
|
|
379
|
+
distro_cell = escape_markdown_table_cell(f"`{escape_inline_code(r.distro)}`")
|
|
380
|
+
if r.status == DistroStatus.PASS:
|
|
381
|
+
raw_detail = "OK"
|
|
382
|
+
elif r.diagnostic:
|
|
383
|
+
safe_cmd = escape_inline_code(r.diagnostic.command or "")
|
|
384
|
+
raw_detail = f"Missing `{safe_cmd}`"
|
|
274
385
|
if r.diagnostic.line:
|
|
275
|
-
|
|
386
|
+
raw_detail += f" (L{r.diagnostic.line})"
|
|
276
387
|
if r.diagnostic.hint:
|
|
277
|
-
|
|
388
|
+
raw_detail += f" — *{r.diagnostic.hint}*"
|
|
278
389
|
elif r.error_message:
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
390
|
+
raw_detail = r.error_message
|
|
391
|
+
else:
|
|
392
|
+
raw_detail = "-"
|
|
393
|
+
detail_cell = escape_markdown_table_cell(raw_detail)
|
|
394
|
+
copy_lines.append(f"| {distro_cell} | {icon} {r.status.value} | `{r.duration:.2f}s` | {detail_cell} |")
|
|
395
|
+
|
|
396
|
+
copy_content = "\n".join(copy_lines)
|
|
397
|
+
lines.extend(format_safe_code_fence(copy_content, language="markdown"))
|
|
282
398
|
lines.append("</details>")
|
|
283
399
|
lines.append("")
|
|
284
400
|
|
|
@@ -287,23 +403,26 @@ def format_markdown_compatibility_card(report: RunReport, script_path: str = "")
|
|
|
287
403
|
if failures:
|
|
288
404
|
lines.append("### 🔍 Failure Diagnostic Logs")
|
|
289
405
|
for r in failures:
|
|
290
|
-
|
|
406
|
+
safe_status = escape_html_text(r.status.value)
|
|
407
|
+
safe_distro = escape_html_text(r.distro)
|
|
408
|
+
lines.append(f"<details><summary><b>[{safe_status}] {safe_distro}</b></summary>")
|
|
291
409
|
lines.append("")
|
|
292
410
|
if r.diagnostic:
|
|
293
|
-
clean_kind = r.diagnostic.kind
|
|
294
|
-
clean_msg = r.diagnostic.message
|
|
411
|
+
clean_kind = escape_inline_code(r.diagnostic.kind)
|
|
412
|
+
clean_msg = escape_inline_code(r.diagnostic.message)
|
|
295
413
|
line_info = f" (line {r.diagnostic.line})" if r.diagnostic.line else ""
|
|
296
414
|
lines.append(f"> **Diagnostic:** `{clean_kind}` — `{clean_msg}`{line_info}")
|
|
297
415
|
if r.diagnostic.hint:
|
|
298
|
-
|
|
416
|
+
safe_hint = escape_markdown_text(r.diagnostic.hint)
|
|
417
|
+
lines.append(f"> 💡 **Recommendation:** {safe_hint}")
|
|
299
418
|
lines.append("")
|
|
300
419
|
if r.error_message:
|
|
301
|
-
|
|
420
|
+
safe_err = escape_markdown_text(r.error_message)
|
|
421
|
+
lines.append(f"> **Error:** {safe_err}")
|
|
302
422
|
lines.append("")
|
|
303
423
|
if r.output_snippet:
|
|
304
|
-
|
|
305
|
-
lines.
|
|
306
|
-
lines.append("```")
|
|
424
|
+
fence_lines = format_safe_code_fence(r.output_snippet, language="text")
|
|
425
|
+
lines.extend(fence_lines)
|
|
307
426
|
else:
|
|
308
427
|
lines.append("*No output captured.*")
|
|
309
428
|
lines.append("</details>")
|