Skip to content

Commit 2aebae7

Browse files
zhangning21GUIDINGLI
authored andcommitted
ci: Support pull request dependencies via Depends-On.
Allow pull requests targeting master to declare same- and cross-repository dependencies. Parse declarations with a tested Python helper, apply exact dependency commits before the existing build matrix, and rerun heavy CI only when an edited description changes the dependency state. Keep fork builds read-only and use a trusted workflow_run to validate artifacts and post per-build dependency results. Keep the apps workflow consistent with the implementation already merged in apache/nuttx. Assisted-by: Kiro:gpt-5.6-sol Signed-off-by: zhangning21 <zhangning21@xiaomi.com>
1 parent f2e9924 commit 2aebae7

4 files changed

Lines changed: 912 additions & 1 deletion

File tree

.github/scripts/depends_on.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
#!/usr/bin/env python3
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
#
14+
"""Parse ``depends-on:`` declarations from a NuttX pull request body.
15+
16+
Supported forms are a single reference, an inline list, or multiple declaration
17+
lines. References may use ``owner/repo/pull/N`` or a full GitHub URL. Bullet-list
18+
continuations are not supported.
19+
20+
Declarations must start a line with at most three spaces and must be outside
21+
Markdown code blocks. Repositories are restricted to ``NUTTX_REPO`` and
22+
``APPS_REPO``; PR numbers are positive JavaScript-safe integers.
23+
24+
CLI:
25+
python3 depends_on.py # print result JSON
26+
python3 depends_on.py --print-state # print state used by the edit gate
27+
python3 depends_on.py --github-output # write workflow outputs and report
28+
"""
29+
30+
from __future__ import annotations
31+
32+
import json
33+
import os
34+
import re
35+
import sys
36+
37+
# Limit PR numbers to JavaScript's exact integer range because the comment
38+
# workflow reads the report. The 19-digit regex limit also avoids huge int()
39+
# conversions; it is not a GitHub business limit.
40+
_MAX_SAFE_PR_NUMBER = 9007199254740991 # 2**53 - 1
41+
_TOKEN_RE = re.compile(
42+
r"^(?:https://github\.com/)?"
43+
r"(?P<repo>[A-Za-z0-9._-]+/[A-Za-z0-9._-]+)/pull/(?P<num>[1-9][0-9]{0,18})$"
44+
)
45+
46+
# CommonMark treats four leading spaces or a tab as indented code. Anchoring
47+
# also excludes prose and prefixes such as "not-depends-on:".
48+
_MARKER_RE = re.compile(r"^ {0,3}depends-on:[ \t]*(?P<rest>.*)$", re.IGNORECASE)
49+
50+
# CommonMark fences may be indented by at most three spaces.
51+
_FENCE_RE = re.compile(r"^ {0,3}(?:```|~~~)")
52+
53+
54+
def allowed_repos_from_env():
55+
return (
56+
os.environ.get("NUTTX_REPO", "apache/nuttx"),
57+
os.environ.get("APPS_REPO", "apache/nuttx-apps"),
58+
)
59+
60+
61+
def _split_tokens(text):
62+
# GitHub Markdown does not treat Unicode separators as declaration
63+
# boundaries, so split tokens on ASCII separators only.
64+
return [t for t in re.split(r"[ \t\r\n\[\],]+", text.strip()) if t]
65+
66+
67+
def _lines(body):
68+
"""Split on newline forms rendered by GitHub Markdown."""
69+
return (body or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
70+
71+
72+
def has_declaration(body):
73+
"""True if the body has a line-anchored depends-on: outside code fences."""
74+
in_fence = False
75+
for ln in _lines(body):
76+
if _FENCE_RE.match(ln):
77+
in_fence = not in_fence
78+
continue
79+
if not in_fence and _MARKER_RE.match(ln):
80+
return True
81+
return False
82+
83+
84+
def _declared_tokens(body):
85+
"""Yield tokens from single-line declarations outside code fences."""
86+
in_fence = False
87+
for ln in _lines(body):
88+
if _FENCE_RE.match(ln):
89+
in_fence = not in_fence
90+
continue
91+
if in_fence:
92+
continue
93+
m = _MARKER_RE.match(ln)
94+
if m:
95+
for t in _split_tokens(m.group("rest")):
96+
yield t
97+
98+
99+
def parse_dependencies(body, allowed_repos=None, pr_number=None, head_sha=None):
100+
"""Return the structured dependency result for ``body``."""
101+
if allowed_repos is None:
102+
allowed_repos = allowed_repos_from_env()
103+
104+
deps = []
105+
warnings = []
106+
seen = set()
107+
for t in _declared_tokens(body):
108+
m = _TOKEN_RE.match(t)
109+
if not m:
110+
continue # stray text on a declaration line; ignore
111+
repo = m.group("repo")
112+
num = int(m.group("num"))
113+
if num > _MAX_SAFE_PR_NUMBER:
114+
continue # beyond JS safe integer; comment workflow can't handle it
115+
if repo not in allowed_repos:
116+
warnings.append("Ignoring unsupported dependency repo: " + repo)
117+
continue
118+
key = (repo, num)
119+
if key not in seen:
120+
seen.add(key)
121+
deps.append({"repo": repo, "number": num})
122+
123+
has_decl = not deps and has_declaration(body)
124+
if has_decl:
125+
warnings.append(
126+
"Found a 'depends-on:' line but no valid dependency was parsed. "
127+
"Declare dependencies on the same line as 'depends-on:', e.g. "
128+
"depends-on: [{}/pull/<N> {}/pull/<M>]".format(*allowed_repos)
129+
)
130+
131+
return {
132+
"version": 1,
133+
"pr_number": pr_number,
134+
"head_sha": head_sha,
135+
"status": "ok" if deps else "invalid" if has_decl else "none",
136+
"dependencies": deps,
137+
"warnings": warnings,
138+
}
139+
140+
141+
def dep_ref(dep):
142+
return "{}/pull/{}".format(dep["repo"], dep["number"])
143+
144+
145+
def _int_or_none(value):
146+
try:
147+
return int(value)
148+
except (TypeError, ValueError):
149+
return None
150+
151+
152+
def main(argv):
153+
body = os.environ.get("PR_BODY", "")
154+
result = parse_dependencies(
155+
body,
156+
pr_number=_int_or_none(os.environ.get("PR_NUMBER")),
157+
head_sha=os.environ.get("HEAD_SHA") or None,
158+
)
159+
refs = [dep_ref(d) for d in result["dependencies"]]
160+
161+
if "--print-state" in argv:
162+
print(result["status"])
163+
for r in refs:
164+
print(r)
165+
return 0
166+
167+
if "--github-output" in argv:
168+
for w in result["warnings"]:
169+
print("::warning::" + w)
170+
out_lines = [
171+
"depends_on=" + " ".join(refs),
172+
"status=" + result["status"],
173+
]
174+
gh_out = os.environ.get("GITHUB_OUTPUT")
175+
if gh_out:
176+
with open(gh_out, "a", encoding="utf-8") as f:
177+
f.write("\n".join(out_lines) + "\n")
178+
else:
179+
print("\n".join(out_lines))
180+
# Write the report only when a depends-on declaration is present
181+
# (status ok/invalid). status=none means there is nothing to report and
182+
# nothing to comment; existing (historical) comments are left untouched.
183+
report_path = os.environ.get("REPORT_PATH")
184+
if report_path and result["status"] != "none":
185+
parent = os.path.dirname(report_path)
186+
if parent:
187+
os.makedirs(parent, exist_ok=True)
188+
with open(report_path, "w", encoding="utf-8") as f:
189+
json.dump(result, f)
190+
return 0
191+
192+
print(json.dumps(result))
193+
return 0
194+
195+
196+
if __name__ == "__main__":
197+
raise SystemExit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)