Skip to content

Commit 58be5e6

Browse files
lroolleclaude
andcommitted
fix: unbreak scheduled PR pipeline after action parsing change
Since ~June 14 every scheduled run pushed a docs branch, failed silently on 'gh pr create', barked a notification, and exited green: claude-code-action@main changed claude_args parsing so 'Bash(gh pr:*)' split on the space into two invalid permission rules. Result: 31 orphan branches, zero PRs, three weeks of stale main. - Move tool permissions to .claude/settings.json (JSON survives any arg parsing; settingSources=project already loads it in CI) - Pin claude-code-action to v1.0.168 instead of the moving @main - Add a Verify outcome tripwire: pushed branch without a PR, or uncommitted changes after the agent step, now fail the run - Bark protocol: notify only after a real PR URL exists; bark a failure alert otherwise - fetcher: record failures in .metadata.json (they were discarded), print per-host failure breakdown, support JINA_API_KEY (r.jina.ai rate limiting caused 574 of 731 failures), skip terraform pages that serve no .md variant (the other 157) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent dd4215e commit 58be5e6

4 files changed

Lines changed: 93 additions & 12 deletions

File tree

.claude/settings.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(gh pr:*)",
5+
"Bash(git:*)",
6+
"Bash(npm:*)",
7+
"Bash(jq:*)",
8+
"Bash(date:*)",
9+
"Bash(./scripts/create-docs-pr.sh:*)",
10+
"mcp__barkme__notify"
11+
]
12+
}
13+
}

.github/workflows/fetch-claude-docs.yml

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Fetch Claude Code Docs
22

33
on:
44
schedule:
5-
# Every 6 hours: 00:00, 06:00, 12:00, 18:00 UTC
5+
# Four times daily: 01:00, 06:00, 11:00, 16:00 UTC (aligned with hn-digest)
66
- cron: "0 1,6,11,16 * * *"
77
workflow_dispatch: # Allow manual trigger
88

@@ -45,6 +45,7 @@ jobs:
4545
- name: Fetch all Anthropic docs
4646
env:
4747
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
48+
JINA_API_KEY: ${{ secrets.JINA_API_KEY }}
4849
run: uv run scripts/fetcher.py
4950

5051
- name: Setup GitHub CLI and Git
@@ -55,7 +56,11 @@ jobs:
5556
git config --global user.email "claude-yolo@lroole.com"
5657
5758
- name: Claude handles everything
58-
uses: anthropics/claude-code-action@main
59+
# Pinned: @main changed claude_args parsing in June 2026 and silently
60+
# broke 'Bash(gh pr:*)' (split on the space into two invalid rules)
61+
# -> 3 weeks of pushed branches with no PRs. Tool permissions now live
62+
# in .claude/settings.json, which survives arg-parsing changes.
63+
uses: anthropics/claude-code-action@v1.0.168
5964
env:
6065
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
6166
with:
@@ -64,7 +69,6 @@ jobs:
6469
allowed_bots: "claude-yolo[bot]"
6570
claude_args: |
6671
--model claude-sonnet-4-6
67-
--allowedTools Bash(gh pr:*),Bash(git:*),Bash(npm:*),Bash(jq:*),Bash(date:*),mcp__barkme__notify
6872
--mcp-config '{
6973
"mcpServers": {
7074
"barkme": {
@@ -178,13 +182,39 @@ jobs:
178182
- Highlight the interesting bits
179183
180184
## BARKME PROTOCOL (nightshift notifications)
181-
Always barkme when creating PR (one simple notification):
185+
Bark AFTER the PR exists, never before:
186+
- Only notify once gh pr create / create-docs-pr.sh returned a real PR URL
182187
- Title: "📦 Claude Code v{version}"
183188
- Body: "{key feature}"
184-
- URL: Link to PR
189+
- URL: the actual PR link (verify it is non-empty before barking)
190+
191+
If push or PR creation fails: bark a failure alert instead
192+
("🚨 docs pipeline: {step} failed - needs human") and exit non-zero.
193+
A notification without a PR behind it is worse than no notification.
185194
186195
Keep it SHORT - day shift will do detailed analysis later.
187196
188197
Remember: You're not a changelog generator. You're a smart filter that understands WHY changes matter to humans using Claude Code.
189198
190199
[>be me >11:45pm Pacific docs checker >Anthropic devs are asleep >catching their fresh updates >day shift will handle the rest]
200+
201+
# Tripwire: a pushed branch without a PR, or changes left uncommitted,
202+
# means the agent step silently failed. Fail loudly instead of lying green.
203+
# (June 2026 incident: 31 orphan branches, zero PRs, all runs green.)
204+
- name: Verify outcome
205+
env:
206+
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
207+
run: |
208+
BRANCH=$(git branch --show-current)
209+
if [ -n "$(git status --porcelain)" ]; then
210+
echo "::error::Workspace has uncommitted changes after agent step"
211+
git status --short | head -20
212+
exit 1
213+
fi
214+
if [ "$BRANCH" != "main" ] && [ -n "$BRANCH" ]; then
215+
PRS=$(gh pr list --head "$BRANCH" --json number --jq 'length')
216+
if [ "$PRS" = "0" ]; then
217+
echo "::error::Branch $BRANCH pushed but no PR exists - PR creation silently failed"
218+
exit 1
219+
fi
220+
fi

.gitignore

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ dist/
1515
# Claude Code
1616
.claude-trace/
1717

18-
# Local development
19-
.claude/
18+
# Local development (root only; keep settings.json tracked: CI reads tool
19+
# permissions from it via settingSources=project. Note: content/github/**
20+
# mirrors still ship .claude/ dirs we currently do not archive.)
21+
/.claude/*
22+
!/.claude/settings.json
2023
worktree/
2124
WIP/
2225
references/

scripts/fetcher.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,20 @@ async def fetch_text(self, session: aiohttp.ClientSession, url: str) -> str:
107107
r.raise_for_status()
108108
return await r.text()
109109

110-
async def fetch_bytes(self, session: aiohttp.ClientSession, url: str) -> bytes:
111-
async with session.get(url) as r:
110+
async def fetch_bytes(
111+
self, session: aiohttp.ClientSession, url: str,
112+
headers: Optional[Dict] = None,
113+
) -> bytes:
114+
async with session.get(url, headers=headers) as r:
112115
r.raise_for_status()
113116
return await r.read()
114117

118+
def _jina_headers(self) -> Optional[Dict]:
119+
# Without a key, r.jina.ai rate-limits hard (~95% failures at 10
120+
# concurrent). Set JINA_API_KEY to lift blog/support success rates.
121+
key = os.environ.get("JINA_API_KEY")
122+
return {"Authorization": f"Bearer {key}"} if key else None
123+
115124
def extract_sitemap_urls(self, xml: str, must_contain: str = "") -> List[str]:
116125
urls = []
117126
for line in xml.split("\n"):
@@ -211,7 +220,8 @@ async def download_via_jina(self, session, url, output_subdir, semaphore) -> Dic
211220
self.stats["skipped"] += 1
212221
return {"url": url, "status": "skipped"}
213222
try:
214-
content = await self.fetch_bytes(session, f"https://r.jina.ai/{url}")
223+
content = await self.fetch_bytes(
224+
session, f"https://r.jina.ai/{url}", headers=self._jina_headers())
215225
output_path.parent.mkdir(parents=True, exist_ok=True)
216226
async with aiofiles.open(output_path, "wb") as f:
217227
await f.write(content)
@@ -233,7 +243,8 @@ async def download_support_article(self, session, url, semaphore) -> Dict:
233243
self.stats["skipped"] += 1
234244
return {"url": url, "status": "skipped"}
235245
try:
236-
content = await self.fetch_bytes(session, f"https://r.jina.ai/{url}")
246+
content = await self.fetch_bytes(
247+
session, f"https://r.jina.ai/{url}", headers=self._jina_headers())
237248
output_path.parent.mkdir(parents=True, exist_ok=True)
238249
async with aiofiles.open(output_path, "wb") as f:
239250
await f.write(content)
@@ -342,8 +353,13 @@ async def fetch_all(self):
342353
print("Source: platform.claude.com/sitemap.xml")
343354
xml = await self.fetch_text(session, self.platform_sitemap_url)
344355
urls = self.extract_sitemap_urls(xml, "/docs/en/")
356+
# Terraform provider reference serves no .md variant (404s)
357+
terraform = [u for u in urls if "/api/terraform/" in u]
358+
urls = [u for u in urls if "/api/terraform/" not in u]
345359
counts["platform"] = len(urls)
346-
print(f" {len(urls)} docs")
360+
print(f" {len(urls)} docs"
361+
+ (f" ({len(terraform)} terraform pages skipped, no .md)"
362+
if terraform else ""))
347363
for url in urls:
348364
tasks.append(self.download_doc(session, url, semaphore))
349365

@@ -413,9 +429,24 @@ async def fetch_all(self):
413429
if tasks:
414430
results = await tqdm_asyncio.gather(*tasks, desc="Fetching", unit="file")
415431
await self._save_metadata(results)
432+
self._print_failures(results)
416433

417434
self._print_summary()
418435

436+
def _print_failures(self, results: List[Dict]):
437+
failed = [r for r in results if r.get("status") == "failed"]
438+
if not failed:
439+
return
440+
by_host = defaultdict(int)
441+
for r in failed:
442+
by_host[r["url"].split("/")[2]] += 1
443+
print("\nFailed by host:")
444+
for host, n in sorted(by_host.items(), key=lambda kv: -kv[1]):
445+
print(f" {host}: {n}")
446+
print("Sample errors:")
447+
for r in failed[:3]:
448+
print(f" {r['url']}: {str(r.get('error', ''))[:120]}")
449+
419450
async def _fetch_meta(self, session):
420451
print("Meta: NPM manifest + CHANGELOG")
421452
try:
@@ -446,6 +477,10 @@ async def _save_metadata(self, results: List[Dict]):
446477
"section": self.section or "all",
447478
},
448479
"items": [r for r in results if r.get("status") == "success"],
480+
"failures": [
481+
{"url": r["url"], "error": str(r.get("error", ""))[:200]}
482+
for r in results if r.get("status") == "failed"
483+
],
449484
"summary": {
450485
"total": self.stats["total"],
451486
"downloaded": self.stats["downloaded"],

0 commit comments

Comments
 (0)