Skip to content

Commit 461b822

Browse files
authored
Merge pull request #5 from PennockTech/claude/split-ci-workflow-iKwid
Refactor CI/CD workflows with smart change detection
2 parents 65b8711 + bac95b5 commit 461b822

File tree

7 files changed

+610
-61
lines changed

7 files changed

+610
-61
lines changed

.github/scripts/detect-changes.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
#!/usr/bin/env python3
2+
"""Detect which CI job categories need to run based on changed files.
3+
4+
For pull requests, compares against the PR base SHA.
5+
For pushes to main, finds the last successful CI run that is an ancestor of
6+
the current commit, ensuring force-pushes don't mask broken state.
7+
8+
Outputs GitHub Actions outputs: go, goreleaser, markdown, workflows
9+
Each is 'true' or 'false'.
10+
"""
11+
12+
import json
13+
import os
14+
import subprocess
15+
import sys
16+
17+
18+
def run(cmd, *, check=True, capture=True):
19+
"""Run a command, return stdout stripped."""
20+
r = subprocess.run(
21+
cmd,
22+
capture_output=capture,
23+
text=True,
24+
check=check,
25+
)
26+
return r.stdout.strip() if capture else ""
27+
28+
29+
def gh_api(endpoint):
30+
"""Call the GitHub API via gh CLI, return parsed JSON."""
31+
result = subprocess.run(
32+
["gh", "api", endpoint],
33+
capture_output=True,
34+
text=True,
35+
check=True,
36+
)
37+
return json.loads(result.stdout)
38+
39+
40+
def is_ancestor(ancestor, descendant):
41+
"""Check if ancestor is an ancestor of descendant in git history."""
42+
r = subprocess.run(
43+
["git", "merge-base", "--is-ancestor", ancestor, descendant],
44+
capture_output=True,
45+
)
46+
return r.returncode == 0
47+
48+
49+
def find_last_green_ancestor(repo, workflow_file, head_sha):
50+
"""Find the most recent successful CI run whose SHA is an ancestor of HEAD.
51+
52+
Walks backwards through successful runs (up to 10 pages) to find one
53+
whose head_sha is a git ancestor of the current commit. This handles
54+
force-pushes correctly: if main was reset, the old "green" commit is
55+
no longer an ancestor so we skip it.
56+
"""
57+
for page in range(1, 11):
58+
endpoint = (
59+
f"repos/{repo}/actions/workflows/{workflow_file}"
60+
f"/runs?branch=main&status=success&per_page=10&page={page}"
61+
)
62+
try:
63+
data = gh_api(endpoint)
64+
except subprocess.CalledProcessError:
65+
print("::warning::Failed to query GitHub API for last successful run", file=sys.stderr)
66+
return None
67+
68+
runs = data.get("workflow_runs", [])
69+
if not runs:
70+
break
71+
72+
for run_info in runs:
73+
candidate = run_info["head_sha"]
74+
if is_ancestor(candidate, head_sha):
75+
return candidate
76+
77+
return None
78+
79+
80+
def categorize_files(changed_files):
81+
"""Categorize changed files into job categories."""
82+
categories = {
83+
"go": False,
84+
"goreleaser": False,
85+
"markdown": False,
86+
"workflows": False,
87+
}
88+
89+
for f in changed_files:
90+
if f.endswith(".go") or f in ("go.mod", "go.sum"):
91+
categories["go"] = True
92+
if f == "Taskfile.yml":
93+
# Taskfile affects all build/test commands
94+
categories["go"] = True
95+
categories["goreleaser"] = True
96+
if f == "goreleaser.yaml":
97+
categories["goreleaser"] = True
98+
# goreleaser builds Go, so test Go too
99+
categories["go"] = True
100+
if f.endswith(".md"):
101+
categories["markdown"] = True
102+
if f.startswith(".github/"):
103+
categories["workflows"] = True
104+
105+
return categories
106+
107+
108+
def get_changed_files(base_sha, head_sha):
109+
"""Get list of files changed between two commits."""
110+
try:
111+
output = run(["git", "diff", "--name-only", f"{base_sha}...{head_sha}"])
112+
return [f for f in output.splitlines() if f]
113+
except subprocess.CalledProcessError:
114+
# If three-dot diff fails (e.g., shallow clone), try two-dot
115+
try:
116+
output = run(["git", "diff", "--name-only", f"{base_sha}..{head_sha}"])
117+
return [f for f in output.splitlines() if f]
118+
except subprocess.CalledProcessError:
119+
return None
120+
121+
122+
def apply_force_overrides(categories):
123+
"""Apply workflow_dispatch force overrides (additive only).
124+
125+
FORCE_ALL=true enables every category.
126+
FORCE_<CATEGORY>=true enables that specific category.
127+
These can never disable a check that change detection enabled.
128+
"""
129+
if os.environ.get("FORCE_ALL", "").lower() == "true":
130+
print("Force override: all checks enabled", file=sys.stderr)
131+
for k in categories:
132+
categories[k] = True
133+
return
134+
135+
force_map = {
136+
"FORCE_GO": "go",
137+
"FORCE_GORELEASER": "goreleaser",
138+
"FORCE_MARKDOWN": "markdown",
139+
"FORCE_WORKFLOWS": "workflows",
140+
}
141+
for env_var, category in force_map.items():
142+
if os.environ.get(env_var, "").lower() == "true":
143+
if not categories[category]:
144+
print(f"Force override: {category} enabled", file=sys.stderr)
145+
categories[category] = True
146+
147+
148+
def write_outputs(categories):
149+
"""Write category flags to GITHUB_OUTPUT."""
150+
output_file = os.environ.get("GITHUB_OUTPUT")
151+
if output_file:
152+
with open(output_file, "a") as f:
153+
for key, value in categories.items():
154+
f.write(f"{key}={'true' if value else 'false'}\n")
155+
# Always print to stderr for logging
156+
for key, value in categories.items():
157+
print(f" {key}: {value}", file=sys.stderr)
158+
159+
160+
def main():
161+
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
162+
repo = os.environ.get("GITHUB_REPOSITORY", "")
163+
head_sha = os.environ.get("GITHUB_SHA", "")
164+
165+
# Workflow filename for API queries (must match the actual file)
166+
workflow_file = "ci.yaml"
167+
168+
print(f"Event: {event_name}", file=sys.stderr)
169+
print(f"Repository: {repo}", file=sys.stderr)
170+
print(f"HEAD SHA: {head_sha}", file=sys.stderr)
171+
172+
base_sha = None
173+
174+
if event_name == "pull_request":
175+
# For PRs, use the base branch SHA
176+
event_path = os.environ.get("GITHUB_EVENT_PATH", "")
177+
if event_path:
178+
with open(event_path) as f:
179+
event_data = json.load(f)
180+
base_sha = event_data.get("pull_request", {}).get("base", {}).get("sha")
181+
print(f"PR base SHA: {base_sha}", file=sys.stderr)
182+
183+
elif event_name == "push":
184+
# For pushes to main, find the last green ancestor
185+
print("Finding last successful CI run that is an ancestor...", file=sys.stderr)
186+
base_sha = find_last_green_ancestor(repo, workflow_file, head_sha)
187+
if base_sha:
188+
print(f"Last green ancestor: {base_sha}", file=sys.stderr)
189+
else:
190+
print("No green ancestor found", file=sys.stderr)
191+
192+
# Determine categories from change detection
193+
if not base_sha:
194+
print("No base SHA available -- running all checks", file=sys.stderr)
195+
categories = {k: True for k in ("go", "goreleaser", "markdown", "workflows")}
196+
else:
197+
changed_files = get_changed_files(base_sha, head_sha)
198+
if changed_files is None:
199+
print("::warning::git diff failed -- running all checks", file=sys.stderr)
200+
categories = {k: True for k in ("go", "goreleaser", "markdown", "workflows")}
201+
elif not changed_files:
202+
print("No files changed", file=sys.stderr)
203+
categories = {k: False for k in ("go", "goreleaser", "markdown", "workflows")}
204+
else:
205+
print(f"Changed files ({len(changed_files)}):", file=sys.stderr)
206+
for f in changed_files:
207+
print(f" {f}", file=sys.stderr)
208+
categories = categorize_files(changed_files)
209+
210+
# Apply workflow_dispatch force overrides (additive only)
211+
apply_force_overrides(categories)
212+
213+
write_outputs(categories)
214+
215+
216+
if __name__ == "__main__":
217+
main()

0 commit comments

Comments
 (0)