Skip to content

Commit 93335dd

Browse files
committed
Add workflow for pana score
1 parent 2476af3 commit 93335dd

File tree

4 files changed

+294
-0
lines changed

4 files changed

+294
-0
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
name: Package Score
2+
description: |
3+
Run pana (pub.dev package analyzer) to compute the package score,
4+
print a readable report to the workflow log, and upload an HTML
5+
report as a workflow artifact.
6+
7+
inputs:
8+
working-directory:
9+
description: The working directory of the package (relative to repo root)
10+
required: true
11+
package-name:
12+
description: The name of the package being scored
13+
required: true
14+
exit-code-threshold:
15+
description: |
16+
Fail the step if (maxPoints - grantedPoints) exceeds this value.
17+
Set to -1 to never fail. Default 160 means score must be >= 0/160.
18+
required: false
19+
default: "160"
20+
21+
runs:
22+
using: "composite"
23+
steps:
24+
- name: Install pana
25+
shell: bash
26+
run: dart pub global activate pana
27+
28+
- name: Run pana
29+
id: pana
30+
shell: bash
31+
working-directory: ${{ inputs.working-directory }}
32+
run: |
33+
# Run pana with threshold; capture markdown output and exit code.
34+
set +e
35+
if [ "${{ inputs.exit-code-threshold }}" != "-1" ]; then
36+
dart pub global run pana \
37+
--exit-code-threshold ${{ inputs.exit-code-threshold }} \
38+
--no-warning . > "$RUNNER_TEMP/pana_score.md" 2>&1
39+
else
40+
dart pub global run pana \
41+
--no-warning . > "$RUNNER_TEMP/pana_score.md" 2>&1
42+
fi
43+
PANA_EXIT=$?
44+
set -e
45+
echo "pana_exit=$PANA_EXIT" >> "$GITHUB_OUTPUT"
46+
echo "score_md=$RUNNER_TEMP/pana_score.md" >> "$GITHUB_OUTPUT"
47+
48+
- name: Render score report
49+
shell: bash
50+
run: |
51+
python3 "$GITHUB_ACTION_PATH/render_score.py" \
52+
"${{ steps.pana.outputs.score_md }}" \
53+
--html "$RUNNER_TEMP/score_report.html"
54+
55+
- name: Upload HTML score report
56+
if: always()
57+
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # 7.0.0
58+
with:
59+
name: score_report-${{ inputs.package-name }}-${{ github.run_id }}
60+
path: ${{ runner.temp }}/score_report.html
61+
62+
- name: Enforce score threshold
63+
if: steps.pana.outputs.pana_exit != '0'
64+
shell: bash
65+
run: |
66+
echo "::error::Pana score is below the required threshold (exit-code-threshold: ${{ inputs.exit-code-threshold }})"
67+
exit ${{ steps.pana.outputs.pana_exit }}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
#!/usr/bin/env python3
2+
"""Render pana score output as a readable ASCII terminal report and generate an HTML report."""
3+
import html
4+
import re
5+
import sys
6+
import textwrap
7+
8+
9+
def _bar_ascii(label, got, total):
10+
pct = got / total if total else 0
11+
filled = int(pct * 20)
12+
return f" {label:<45} [{'█' * filled + '░' * (20 - filled)}] {got}/{total}"
13+
14+
15+
def _bar_html(label, got, total):
16+
pct = got / total if total else 0
17+
pct_int = int(pct * 100)
18+
color = "#4caf50" if pct >= 0.8 else "#ff9800" if pct >= 0.5 else "#f44336"
19+
return (
20+
f'<div class="bar-row">'
21+
f'<span class="bar-label">{html.escape(label)}</span>'
22+
f'<div class="bar-track"><div class="bar-fill" style="width:{pct_int}%;background:{color};"></div></div>'
23+
f'<span class="bar-score">{got}/{total}</span>'
24+
f'</div>'
25+
)
26+
27+
28+
def render_ascii(text):
29+
"""Render score markdown as ASCII terminal output."""
30+
W = 72
31+
lines = []
32+
33+
def bar(label, got, total):
34+
return _bar_ascii(label, got, total)
35+
36+
lines.append("=" * W)
37+
lines.append(" PACKAGE SCORE REPORT".center(W))
38+
lines.append("=" * W)
39+
40+
m = re.search(r'Points:\s*(\d+)\s*/\s*(\d+)', text)
41+
if m:
42+
got, total = int(m.group(1)), int(m.group(2))
43+
lines.append(f"\n Total: {got} / {total} points")
44+
lines.append(bar("Overall", got, total))
45+
lines.append("")
46+
47+
for sm in re.finditer(r'^## (✓|✗)\s+(.+?)\s*\((\d+)\s*/\s*(\d+)\)', text, re.M):
48+
icon = "✓" if sm.group(1) == "✓" else "✗"
49+
name, got, total = sm.group(2), int(sm.group(3)), int(sm.group(4))
50+
lines.append("-" * W)
51+
lines.append(f" {icon} {name}")
52+
lines.append(bar(name, got, total))
53+
54+
sec_start = sm.end()
55+
sec_end = text.find("\n## ", sec_start)
56+
if sec_end == -1:
57+
sec_end = len(text)
58+
section = text[sec_start:sec_end]
59+
60+
for sub in re.finditer(r'^### \[(.)\]\s+(\d+)/(\d+) points:\s+(.+)', section, re.M):
61+
mark = sub.group(1)
62+
sg, st, label = int(sub.group(2)), int(sub.group(3)), re.sub(r'\*\*([^*]+)\*\*', r'\1', sub.group(4))
63+
sym = {"*": "✓", "x": "✗", "~": "~"}.get(mark, mark)
64+
lines.append(f" {sym} {sg:>2}/{st:<2} {label}")
65+
66+
if "platform" in name.lower():
67+
if re.search(r'\*\*WASM-ready:\*\*', section):
68+
lines.append(f" ✓ WASM compatible")
69+
elif re.search(r'not WASM-compatible', section):
70+
lines.append(f" ✗ WASM not compatible (partial Web score)")
71+
72+
lines.append("-" * W)
73+
74+
issues = re.findall(
75+
r'The constraint `([^`]+)` on (\w+) does not support the stable version `([^`]+)`', text
76+
)
77+
if issues:
78+
lines.append(f"\n ⚠ Dependency issues ({len(issues)}):")
79+
for constraint, pkg, ver in issues:
80+
lines.append(f" • {pkg}: {constraint} doesn't support {ver}")
81+
82+
lines.append("")
83+
return "\n".join(lines)
84+
85+
86+
def render_html(text):
87+
"""Render score markdown as an HTML report."""
88+
parts = []
89+
parts.append(textwrap.dedent("""\
90+
<!DOCTYPE html>
91+
<html lang="en">
92+
<head>
93+
<meta charset="utf-8">
94+
<meta name="viewport" content="width=device-width, initial-scale=1">
95+
<title>Package Score Report</title>
96+
<style>
97+
* { box-sizing: border-box; margin: 0; padding: 0; }
98+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
99+
background: #f5f5f5; color: #333; padding: 2rem; max-width: 900px; margin: 0 auto; }
100+
h1 { text-align: center; margin-bottom: 0.5rem; }
101+
.total { text-align: center; font-size: 1.4rem; margin-bottom: 1.5rem; color: #555; }
102+
.section { background: #fff; border-radius: 8px; padding: 1.2rem 1.5rem;
103+
margin-bottom: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
104+
.section-header { display: flex; align-items: center; gap: 0.5rem;
105+
font-size: 1.1rem; font-weight: 600; margin-bottom: 0.8rem; }
106+
.icon-pass { color: #4caf50; } .icon-fail { color: #f44336; }
107+
.bar-row { display: flex; align-items: center; gap: 0.8rem; margin: 0.4rem 0; }
108+
.bar-label { flex: 1; font-size: 0.9rem; }
109+
.bar-track { width: 200px; height: 14px; background: #e0e0e0; border-radius: 7px; overflow: hidden; }
110+
.bar-fill { height: 100%; border-radius: 7px; transition: width 0.3s; }
111+
.bar-score { font-size: 0.9rem; font-weight: 600; min-width: 50px; }
112+
.sub-item { padding: 0.2rem 0 0.2rem 1.5rem; font-size: 0.9rem; }
113+
.sub-item .icon-pass, .sub-item .icon-fail { font-weight: bold; }
114+
.details { margin-top: 0.5rem; padding: 0.8rem; background: #fafafa;
115+
border-radius: 4px; font-size: 0.85rem; border-left: 3px solid #ddd; }
116+
.dep-issues { background: #fff3e0; border-left-color: #ff9800; }
117+
footer { text-align: center; margin-top: 2rem; font-size: 0.8rem; color: #999; }
118+
</style>
119+
</head>
120+
<body>
121+
<h1>📊 Package Score Report</h1>
122+
"""))
123+
124+
# Total score
125+
m = re.search(r'Points:\s*(\d+)\s*/\s*(\d+)', text)
126+
if m:
127+
got, total = int(m.group(1)), int(m.group(2))
128+
parts.append(f'<div class="total">{got} / {total} points</div>')
129+
parts.append(_bar_html("Overall", got, total))
130+
131+
# Sections
132+
for sm in re.finditer(r'^## (✓|✗)\s+(.+?)\s*\((\d+)\s*/\s*(\d+)\)', text, re.M):
133+
passed = sm.group(1) == "✓"
134+
icon_cls = "icon-pass" if passed else "icon-fail"
135+
icon_char = "✓" if passed else "✗"
136+
name, got, total = sm.group(2), int(sm.group(3)), int(sm.group(4))
137+
138+
parts.append('<div class="section">')
139+
parts.append(f'<div class="section-header"><span class="{icon_cls}">{icon_char}</span> {html.escape(name)}</div>')
140+
parts.append(_bar_html(name, got, total))
141+
142+
sec_start = sm.end()
143+
sec_end = text.find("\n## ", sec_start)
144+
if sec_end == -1:
145+
sec_end = len(text)
146+
section = text[sec_start:sec_end]
147+
148+
for sub in re.finditer(r'^### \[(.)\]\s+(\d+)/(\d+) points:\s+(.+)', section, re.M):
149+
mark = sub.group(1)
150+
sg, st = int(sub.group(2)), int(sub.group(3))
151+
label = re.sub(r'\*\*([^*]+)\*\*', r'\1', sub.group(4))
152+
sub_passed = mark == "*"
153+
si_cls = "icon-pass" if sub_passed else "icon-fail"
154+
si_char = "✓" if sub_passed else "✗"
155+
parts.append(f'<div class="sub-item"><span class="{si_cls}">{si_char}</span> {sg}/{st}{html.escape(label)}</div>')
156+
157+
# WASM status
158+
if "platform" in name.lower():
159+
if re.search(r'\*\*WASM-ready:\*\*', section):
160+
parts.append('<div class="sub-item"><span class="icon-pass">✓</span> WASM compatible</div>')
161+
elif re.search(r'not WASM-compatible', section):
162+
parts.append('<div class="sub-item"><span class="icon-fail">✗</span> WASM not compatible</div>')
163+
164+
# Extract detail blocks (content within <details>)
165+
for detail in re.finditer(r'<details>\s*<summary>\s*(.*?)\s*</summary>\s*(.*?)\s*</details>', section, re.S):
166+
summary_text = re.sub(r'<[^>]+>', '', detail.group(1)).strip()
167+
detail_body = re.sub(r'<[^>]+>', '', detail.group(2)).strip()
168+
if summary_text or detail_body:
169+
parts.append(f'<div class="details"><strong>{html.escape(summary_text)}</strong><br>{html.escape(detail_body)}</div>')
170+
171+
parts.append('</div>')
172+
173+
# Dependency issues
174+
issues = re.findall(
175+
r'The constraint `([^`]+)` on (\w+) does not support the stable version `([^`]+)`', text
176+
)
177+
if issues:
178+
parts.append('<div class="section">')
179+
parts.append(f'<div class="section-header"><span class="icon-fail">⚠</span> Dependency issues ({len(issues)})</div>')
180+
for constraint, pkg, ver in issues:
181+
parts.append(f'<div class="sub-item dep-issues">• {html.escape(pkg)}: <code>{html.escape(constraint)}</code> doesn\'t support {html.escape(ver)}</div>')
182+
parts.append('</div>')
183+
184+
parts.append('<footer>Generated by pana — <a href="https://pub.dev/packages/pana">pub.dev/packages/pana</a></footer>')
185+
parts.append('</body></html>')
186+
return "\n".join(parts)
187+
188+
189+
def main():
190+
if len(sys.argv) < 2:
191+
print("Usage: render_score.py <score.md> [--html <output.html>]", file=sys.stderr)
192+
sys.exit(1)
193+
194+
score_path = sys.argv[1]
195+
text = open(score_path).read()
196+
197+
# Always print ASCII to stdout
198+
print(render_ascii(text))
199+
200+
# Generate HTML if --html flag is given
201+
if "--html" in sys.argv:
202+
idx = sys.argv.index("--html")
203+
if idx + 1 < len(sys.argv):
204+
html_path = sys.argv[idx + 1]
205+
else:
206+
html_path = "score_report.html"
207+
with open(html_path, "w") as f:
208+
f.write(render_html(text))
209+
print(f"HTML report written to {html_path}")
210+
211+
212+
if __name__ == "__main__":
213+
main()

.github/workflows/dart_vm.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ jobs:
106106
run: dart analyze --fatal-infos --fatal-warnings .
107107
working-directory: ${{ inputs.working-directory }}
108108

109+
- name: Package Score
110+
if: "always() && steps.bootstrap.conclusion == 'success' && matrix.sdk == 'stable'"
111+
uses: ./.github/composite_actions/package_score
112+
with:
113+
working-directory: ${{ inputs.working-directory }}
114+
package-name: ${{ inputs.package-name }}
115+
109116
- name: Run Tests (stable)
110117
if: "always() && steps.bootstrap.conclusion == 'success' && steps.testCheck.outputs.hasTests == 'true' && matrix.sdk == 'stable'"
111118
run: dart test

.github/workflows/flutter_vm.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ jobs:
8888
run: flutter analyze --fatal-infos --fatal-warnings .
8989
working-directory: ${{ inputs.working-directory }}
9090

91+
- name: Package Score
92+
if: "always() && steps.bootstrap.conclusion == 'success' && matrix.channel == 'stable' && matrix.flutter-version == 'any'"
93+
uses: ./.github/composite_actions/package_score
94+
with:
95+
working-directory: ${{ inputs.working-directory }}
96+
package-name: ${{ inputs.package-name }}
97+
9198
- name: Run Tests
9299
id: testJob
93100
if: "always() && steps.bootstrap.conclusion == 'success' && steps.testCheck.outputs.hasTests == 'true'"

0 commit comments

Comments
 (0)