Skip to content

Commit d5abbaf

Browse files
authored
Merge pull request #129 from WithAutonomi/chrisoneil/v2-719-add-the-standard-pr-template-across-all-six-crate-repos
chore: add PR template and required CI checks (V2-719, V2-720)
2 parents 5ba61f2 + 12fc816 commit d5abbaf

6 files changed

Lines changed: 445 additions & 0 deletions

File tree

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
## Linear issue
2+
<!-- REQUIRED. Link the issue: an issue key like V2-123, or a linear.app URL.
3+
CI blocks PRs with no linked Linear issue. -->
4+
5+
## Risk tier
6+
<!-- Check exactly one. Boundary question: does this change node behavior, the wire
7+
protocol, stored-data format, payments/economics, or the upgrade mechanism?
8+
If yes -> T2/T3, and it rides the weekly release train.
9+
If no -> T0/T1, and it may ship via the client track.
10+
Propose the tier; a human confirms it at review. -->
11+
- [ ] T0 — docs / tooling / CI / pure UX-output. Repo CI only.
12+
- [ ] T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
13+
- [ ] T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
14+
- [ ] T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.
15+
16+
## Compatibility
17+
<!-- State the impact on each axis, or "none". -->
18+
- Wire:
19+
- Storage:
20+
- API:
21+
22+
## Semver impact
23+
<!-- Check exactly one. This is where the crate's bump level is decided, while the
24+
context is fresh; the train-manifest skill maps it to a per-crate version bump. -->
25+
- [ ] breaking
26+
- [ ] feature
27+
- [ ] fix
28+
29+
## Test evidence
30+
<!-- Per the tier: what was run, at what scale, and the result. Attach or link artifacts. -->
31+
32+
## New dependency
33+
<!-- Any new external dependency? Write "none", or list them. New deps need explicit
34+
human acknowledgement in review. -->
35+
36+
## ADR
37+
<!-- REQUIRED for Tier 2/3 — link the ADR. Write "n/a" for Tier 0/1. -->
38+
39+
## Mitigation / rollback
40+
<!-- One line: how we back this out or limit the blast radius if it misbehaves. -->

.github/scripts/check_pr.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
#!/usr/bin/env python3
2+
"""Release-process PR checks.
3+
4+
Two modes, each wired to its own required status check:
5+
6+
check_pr.py linear V2-720 — require a linked Linear issue on every PR
7+
(main + rc-*).
8+
check_pr.py template V2-719 — require the standard PR template to be present
9+
and fully filled (enforced on PRs targeting main; a
10+
no-op that passes on rc-* so hotfix/regression PRs are
11+
not forced to carry the full template).
12+
13+
PR fields are read from the environment (set by the workflow from the event
14+
payload): PR_TITLE, PR_BODY, PR_BRANCH, PR_BASE.
15+
16+
This file is duplicated verbatim across the six crate repos; keep them
17+
identical when updating.
18+
"""
19+
import os
20+
import re
21+
import sys
22+
23+
# Known Linear team keys (the issue-key prefixes). A Linear reference is a URL
24+
# under linear.app OR an issue key with one of these prefixes — this is what
25+
# keeps unrelated technical tokens like "UTF-8" / "SHA-256" / "RFC-123" from
26+
# satisfying the gate. Add a new team's key here when a team is created.
27+
LINEAR_TEAM_PREFIXES = ("V2", "AUTO", "REL", "INFRA", "QA")
28+
29+
# Case-insensitive so it also matches Linear-generated branch names, which are
30+
# lower-cased (e.g. chrisoneil/v2-720-...).
31+
LINEAR_KEY = re.compile(
32+
r"\b(?:" + "|".join(LINEAR_TEAM_PREFIXES) + r")-[0-9]+\b", re.IGNORECASE
33+
)
34+
# A real Linear issue URL: linear.app/<workspace>/issue/<KEY>[/<slug>]. Constrained
35+
# to the /issue/<key> path so generic pages (linear.app/changelog,
36+
# linear.app/not-an-issue) do not count as a linked issue.
37+
LINEAR_URL = re.compile(
38+
r"linear\.app/[^/\s]+/issue/[A-Za-z][A-Za-z0-9]*-[0-9]+", re.IGNORECASE
39+
)
40+
41+
# Canonical section headings, exactly as they appear in the template, keyed by
42+
# their lower-cased form. Used so failure messages name the real heading.
43+
CANONICAL_HEADINGS = {
44+
"linear issue": "Linear issue",
45+
"risk tier": "Risk tier",
46+
"compatibility": "Compatibility",
47+
"semver impact": "Semver impact",
48+
"test evidence": "Test evidence",
49+
"new dependency": "New dependency",
50+
"adr": "ADR",
51+
"mitigation / rollback": "Mitigation / rollback",
52+
}
53+
54+
55+
def env(name):
56+
return os.environ.get(name, "") or ""
57+
58+
59+
def fail(msg):
60+
print(msg)
61+
sys.exit(1)
62+
63+
64+
def ok(msg):
65+
print(msg)
66+
sys.exit(0)
67+
68+
69+
def strip_comments(text):
70+
"""Remove HTML comments so template scaffolding and its examples (e.g. the
71+
'V2-123' hint in the Linear-issue comment) never count as real content."""
72+
return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
73+
74+
75+
def linear_ref(*parts):
76+
"""Return the first Linear URL/key found in the given (comment-free) parts."""
77+
haystack = "\n".join(parts)
78+
m = LINEAR_URL.search(haystack) or LINEAR_KEY.search(haystack)
79+
return m.group(0) if m else None
80+
81+
82+
def sections(body):
83+
"""Split a markdown body into {heading-lowercased: text-until-next-##}."""
84+
result, current, buf = {}, None, []
85+
for line in body.splitlines():
86+
m = re.match(r"^\s*##\s+(.*?)\s*$", line)
87+
if m:
88+
if current is not None:
89+
result[current] = "\n".join(buf)
90+
current, buf = m.group(1).strip().lower(), []
91+
elif current is not None:
92+
buf.append(line)
93+
if current is not None:
94+
result[current] = "\n".join(buf)
95+
return result
96+
97+
98+
def check_linear():
99+
# Strip comments from the body so the template's own example does not count.
100+
ref = linear_ref(env("PR_TITLE"), strip_comments(env("PR_BODY")), env("PR_BRANCH"))
101+
if ref:
102+
ok(f"✅ Linear reference found: {ref}")
103+
fail(
104+
"❌ No linked Linear issue found.\n\n"
105+
"Every PR must reference a Linear issue — an issue key ("
106+
+ " / ".join(f"{p}-123" for p in LINEAR_TEAM_PREFIXES)
107+
+ "), or a linear.app/<workspace>/issue/<key> URL — in the PR title, body,\n"
108+
"or branch name. Add it to the '## Linear issue' section and update the PR."
109+
)
110+
111+
112+
def check_template():
113+
base = env("PR_BASE")
114+
if base and base != "main":
115+
ok(f"✅ pr-template not enforced on base '{base}' (main only).")
116+
117+
body = env("PR_BODY")
118+
secs = sections(body)
119+
errors = []
120+
121+
# The Risk tier / Semver impact headings are the sentinels that the template
122+
# is actually in use.
123+
if "risk tier" not in secs or "semver impact" not in secs:
124+
fail(
125+
"❌ PR template not detected.\n\n"
126+
"Your PR description must use .github/PULL_REQUEST_TEMPLATE.md (the\n"
127+
"'## Risk tier' and '## Semver impact' sections are missing). Copy the\n"
128+
"template into the PR body and fill every field."
129+
)
130+
131+
for heading in CANONICAL_HEADINGS:
132+
if heading not in secs:
133+
errors.append(f"missing section: ## {CANONICAL_HEADINGS[heading]}")
134+
135+
# Exactly one Risk tier box checked.
136+
tiers = re.findall(
137+
r"^\s*-\s*\[[xX]\]\s*(T[0-3])\b", secs.get("risk tier", ""), re.MULTILINE
138+
)
139+
if len(tiers) != 1:
140+
errors.append(
141+
f"Risk tier: check exactly one box (found {len(tiers)} checked)"
142+
)
143+
tier = tiers[0] if len(tiers) == 1 else None
144+
145+
# Exactly one Semver impact box checked.
146+
semver = re.findall(
147+
r"^\s*-\s*\[[xX]\]\s*(breaking|feature|fix)\b",
148+
secs.get("semver impact", ""),
149+
re.MULTILINE | re.IGNORECASE,
150+
)
151+
if len(semver) != 1:
152+
errors.append(
153+
f"Semver impact: check exactly one box (found {len(semver)} checked)"
154+
)
155+
156+
# Free-text sections that must not be empty.
157+
for heading in ("test evidence", "new dependency", "mitigation / rollback"):
158+
if heading in secs and not strip_comments(secs[heading]).strip():
159+
errors.append(f"'## {CANONICAL_HEADINGS[heading]}' is empty")
160+
161+
# Compatibility: every axis must carry a value (use 'none' where N/A).
162+
# Use [ \t] rather than \s after the colon so an empty axis cannot "borrow"
163+
# the next line's content across a newline.
164+
comp = strip_comments(secs.get("compatibility", ""))
165+
for axis in ("Wire", "Storage", "API"):
166+
if not re.search(rf"^[ \t]*-[ \t]*{axis}[ \t]*:[ \t]*\S", comp, re.MULTILINE):
167+
errors.append(
168+
f"'## Compatibility': fill in {axis} (use 'none' if not applicable)"
169+
)
170+
171+
# Linear reference present in its own section (comments stripped).
172+
if "linear issue" in secs and not linear_ref(strip_comments(secs["linear issue"])):
173+
errors.append("'## Linear issue': add the issue key or a linear.app link")
174+
175+
# ADR must be filled explicitly: 'n/a' for T0/T1, a link for T2/T3.
176+
adr = strip_comments(secs.get("adr", "")).strip()
177+
if not adr:
178+
errors.append(
179+
"'## ADR' is empty: write 'n/a' for Tier 0/1, or an ADR link for Tier 2/3"
180+
)
181+
elif tier in ("T2", "T3") and not re.search(r"https?://", adr):
182+
errors.append(f"ADR is required for {tier}: add an ADR link in '## ADR'")
183+
184+
if errors:
185+
fail(
186+
"❌ PR template incomplete:\n"
187+
+ "\n".join(f" - {e}" for e in errors)
188+
+ "\n\nFill in .github/PULL_REQUEST_TEMPLATE.md completely and update the PR."
189+
)
190+
ok(f"✅ PR template complete (tier {tier}).")
191+
192+
193+
def main():
194+
mode = sys.argv[1] if len(sys.argv) > 1 else ""
195+
if mode == "linear":
196+
check_linear()
197+
elif mode == "template":
198+
check_template()
199+
else:
200+
fail(f"usage: check_pr.py [linear|template] (got: {mode!r})")
201+
202+
203+
if __name__ == "__main__":
204+
main()

.github/scripts/test_check_pr.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env python3
2+
"""Test matrix for check_pr.py — runs the real checker as a subprocess against a
3+
set of PR fields and asserts the pass/fail outcome. No third-party deps; run with
4+
`python3 .github/scripts/test_check_pr.py` (also executed by the pr-checks
5+
workflow's self-test job).
6+
7+
This file is duplicated verbatim across the six crate repos; keep it identical.
8+
"""
9+
import os
10+
import subprocess
11+
import sys
12+
13+
HERE = os.path.dirname(os.path.abspath(__file__))
14+
CHECKER = os.path.join(HERE, "check_pr.py")
15+
16+
VALID_BODY = """\
17+
## Linear issue
18+
- https://linear.app/autonominetwork/issue/V2-719/add-the-standard-pr-template
19+
20+
## Risk tier
21+
- [x] T0 — docs / tooling / CI.
22+
23+
## Compatibility
24+
- Wire: none
25+
- Storage: none
26+
- API: none
27+
28+
## Semver impact
29+
- [x] fix
30+
31+
## Test evidence
32+
Ran the checker test matrix; all cases pass.
33+
34+
## New dependency
35+
none
36+
37+
## ADR
38+
n/a
39+
40+
## Mitigation / rollback
41+
Revert the PR.
42+
"""
43+
44+
45+
def body_without(section_swap):
46+
"""Return VALID_BODY with a section's content replaced (old -> new)."""
47+
old, new = section_swap
48+
assert old in VALID_BODY, old
49+
return VALID_BODY.replace(old, new)
50+
51+
52+
# Unfilled template: the only Linear-looking token is the example in a comment.
53+
UNFILLED = """\
54+
## Linear issue
55+
<!-- REQUIRED. Link the issue: an issue key like V2-123, or a linear.app URL. -->
56+
## Risk tier
57+
- [ ] T0
58+
## Semver impact
59+
- [ ] fix
60+
"""
61+
62+
# (name, mode, env, expected_exit)
63+
CASES = [
64+
# --- linear-link: rejections ---
65+
("linear: UTF-8 only", "linear", {"PR_TITLE": "fix UTF-8", "PR_BRANCH": "fix/utf8"}, 1),
66+
("linear: SHA-256 / RFC-123", "linear", {"PR_TITLE": "SHA-256 RFC-123", "PR_BRANCH": "x"}, 1),
67+
("linear: linear.app/changelog", "linear", {"PR_BODY": "see https://linear.app/changelog", "PR_BRANCH": "x"}, 1),
68+
("linear: linear.app/not-an-issue", "linear", {"PR_BODY": "https://linear.app/not-an-issue", "PR_BRANCH": "x"}, 1),
69+
("linear: unfilled template (example in comment)", "linear", {"PR_BODY": UNFILLED, "PR_BRANCH": "x"}, 1),
70+
# --- linear-link: acceptances ---
71+
("linear: issue URL in body", "linear", {"PR_BODY": "https://linear.app/autonominetwork/issue/V2-719/foo"}, 0),
72+
("linear: key in branch (lowercased)", "linear", {"PR_BRANCH": "chrisoneil/v2-720-ci-check"}, 0),
73+
("linear: key in title", "linear", {"PR_TITLE": "AUTO-42 do the thing", "PR_BRANCH": "x"}, 0),
74+
# --- pr-template: acceptances ---
75+
("template: valid T0 body", "template", {"PR_BASE": "main", "PR_BODY": VALID_BODY}, 0),
76+
("template: rc-* base is a no-op pass", "template", {"PR_BASE": "rc-2025.10", "PR_BODY": "anything"}, 0),
77+
("template: T2 with ADR link", "template", {"PR_BASE": "main", "PR_BODY": body_without(
78+
("- [x] T0 — docs / tooling / CI.", "- [x] T2 — behavioural.")).replace(
79+
"n/a", "https://github.com/x/adr/0001.md")}, 0),
80+
# --- pr-template: rejections ---
81+
("template: not the template", "template", {"PR_BASE": "main", "PR_BODY": "freeform text"}, 1),
82+
("template: only Wire axis filled", "template", {"PR_BASE": "main", "PR_BODY": body_without(
83+
("- Storage: none\n- API: none", "- Storage:\n- API:"))}, 1),
84+
("template: empty ADR (T0)", "template", {"PR_BASE": "main", "PR_BODY": body_without(("n/a", ""))}, 1),
85+
("template: T2 with ADR n/a (no link)", "template", {"PR_BASE": "main", "PR_BODY": body_without(
86+
("- [x] T0 — docs / tooling / CI.", "- [x] T2 — behavioural."))}, 1),
87+
("template: no tier checked", "template", {"PR_BASE": "main", "PR_BODY": body_without(
88+
("- [x] T0 — docs / tooling / CI.", "- [ ] T0 — docs / tooling / CI."))}, 1),
89+
("template: two tiers checked", "template", {"PR_BASE": "main", "PR_BODY": body_without(
90+
("- [x] T0 — docs / tooling / CI.", "- [x] T0 a\n- [x] T2 b"))}, 1),
91+
]
92+
93+
94+
def run(mode, env):
95+
full = dict(os.environ)
96+
for k in ("PR_TITLE", "PR_BODY", "PR_BRANCH", "PR_BASE"):
97+
full.pop(k, None)
98+
full.update(env)
99+
return subprocess.run(
100+
[sys.executable, CHECKER, mode], env=full, capture_output=True, text=True
101+
).returncode
102+
103+
104+
def main():
105+
failures = 0
106+
for name, mode, env, expected in CASES:
107+
got = run(mode, env)
108+
status = "ok" if got == expected else "FAIL"
109+
if got != expected:
110+
failures += 1
111+
print(f"[{status}] {name} (mode={mode}, expected={expected}, got={got})")
112+
print(f"\n{len(CASES) - failures}/{len(CASES)} cases passed")
113+
sys.exit(1 if failures else 0)
114+
115+
116+
if __name__ == "__main__":
117+
main()

0 commit comments

Comments
 (0)