Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(gh pr:*)",
"Bash(git:*)",
"Bash(npm:*)",
"Bash(jq:*)",
"Bash(date:*)",
"Bash(./scripts/create-docs-pr.sh:*)",
"mcp__barkme__notify"
]
}
}
40 changes: 35 additions & 5 deletions .github/workflows/fetch-claude-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Fetch Claude Code Docs

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

Expand Down Expand Up @@ -45,6 +45,7 @@ jobs:
- name: Fetch all Anthropic docs
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
JINA_API_KEY: ${{ secrets.JINA_API_KEY }}
run: uv run scripts/fetcher.py

- name: Setup GitHub CLI and Git
Expand All @@ -55,7 +56,11 @@ jobs:
git config --global user.email "claude-yolo@lroole.com"

- name: Claude handles everything
uses: anthropics/claude-code-action@main
# Pinned: @main changed claude_args parsing in June 2026 and silently
# broke 'Bash(gh pr:*)' (split on the space into two invalid rules)
# -> 3 weeks of pushed branches with no PRs. Tool permissions now live
# in .claude/settings.json, which survives arg-parsing changes.
uses: anthropics/claude-code-action@v1.0.168
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
with:
Expand All @@ -64,7 +69,6 @@ jobs:
allowed_bots: "claude-yolo[bot]"
claude_args: |
--model claude-sonnet-4-6
--allowedTools Bash(gh pr:*),Bash(git:*),Bash(npm:*),Bash(jq:*),Bash(date:*),mcp__barkme__notify
--mcp-config '{
"mcpServers": {
"barkme": {
Expand Down Expand Up @@ -178,13 +182,39 @@ jobs:
- Highlight the interesting bits

## BARKME PROTOCOL (nightshift notifications)
Always barkme when creating PR (one simple notification):
Bark AFTER the PR exists, never before:
- Only notify once gh pr create / create-docs-pr.sh returned a real PR URL
- Title: "📦 Claude Code v{version}"
- Body: "{key feature}"
- URL: Link to PR
- URL: the actual PR link (verify it is non-empty before barking)

If push or PR creation fails: bark a failure alert instead
("🚨 docs pipeline: {step} failed - needs human") and exit non-zero.
A notification without a PR behind it is worse than no notification.

Keep it SHORT - day shift will do detailed analysis later.

Remember: You're not a changelog generator. You're a smart filter that understands WHY changes matter to humans using Claude Code.

[>be me >11:45pm Pacific docs checker >Anthropic devs are asleep >catching their fresh updates >day shift will handle the rest]

# Tripwire: a pushed branch without a PR, or changes left uncommitted,
# means the agent step silently failed. Fail loudly instead of lying green.
# (June 2026 incident: 31 orphan branches, zero PRs, all runs green.)
- name: Verify outcome
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
BRANCH=$(git branch --show-current)
if [ -n "$(git status --porcelain)" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't fail intentionally ignored low-signal diffs

This dirty-workspace tripwire conflicts with the prompt above that still tells the agent to “do nothing, exit 0” when the fetch produces only low-signal changes such as blog/research, GitHub mirror content, or .metadata.json. In those scheduled runs the fetcher has already modified the worktree, so an agent that correctly decides no commit/PR is warranted leaves changes behind and this verification step turns the run red even though nothing silently failed. Either have the agent reset ignored changes before exiting or exempt the explicitly ignored paths here.

Useful? React with 👍 / 👎.

echo "::error::Workspace has uncommitted changes after agent step"
git status --short | head -20
exit 1
fi
if [ "$BRANCH" != "main" ] && [ -n "$BRANCH" ]; then
PRS=$(gh pr list --head "$BRANCH" --json number --jq 'length')
if [ "$PRS" = "0" ]; then
echo "::error::Branch $BRANCH pushed but no PR exists - PR creation silently failed"
exit 1
fi
fi
7 changes: 5 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ dist/
# Claude Code
.claude-trace/

# Local development
.claude/
# Local development (root only; keep settings.json tracked: CI reads tool
# permissions from it via settingSources=project. Note: content/github/**
# mirrors still ship .claude/ dirs we currently do not archive.)
/.claude/*
!/.claude/settings.json
worktree/
WIP/
references/
45 changes: 40 additions & 5 deletions scripts/fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,20 @@ async def fetch_text(self, session: aiohttp.ClientSession, url: str) -> str:
r.raise_for_status()
return await r.text()

async def fetch_bytes(self, session: aiohttp.ClientSession, url: str) -> bytes:
async with session.get(url) as r:
async def fetch_bytes(
self, session: aiohttp.ClientSession, url: str,
headers: Optional[Dict] = None,
) -> bytes:
async with session.get(url, headers=headers) as r:
r.raise_for_status()
return await r.read()

def _jina_headers(self) -> Optional[Dict]:
# Without a key, r.jina.ai rate-limits hard (~95% failures at 10
# concurrent). Set JINA_API_KEY to lift blog/support success rates.
key = os.environ.get("JINA_API_KEY")
return {"Authorization": f"Bearer {key}"} if key else None

def extract_sitemap_urls(self, xml: str, must_contain: str = "") -> List[str]:
urls = []
for line in xml.split("\n"):
Expand Down Expand Up @@ -211,7 +220,8 @@ async def download_via_jina(self, session, url, output_subdir, semaphore) -> Dic
self.stats["skipped"] += 1
return {"url": url, "status": "skipped"}
try:
content = await self.fetch_bytes(session, f"https://r.jina.ai/{url}")
content = await self.fetch_bytes(
session, f"https://r.jina.ai/{url}", headers=self._jina_headers())
output_path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(output_path, "wb") as f:
await f.write(content)
Expand All @@ -233,7 +243,8 @@ async def download_support_article(self, session, url, semaphore) -> Dict:
self.stats["skipped"] += 1
return {"url": url, "status": "skipped"}
try:
content = await self.fetch_bytes(session, f"https://r.jina.ai/{url}")
content = await self.fetch_bytes(
session, f"https://r.jina.ai/{url}", headers=self._jina_headers())
output_path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(output_path, "wb") as f:
await f.write(content)
Expand Down Expand Up @@ -342,8 +353,13 @@ async def fetch_all(self):
print("Source: platform.claude.com/sitemap.xml")
xml = await self.fetch_text(session, self.platform_sitemap_url)
urls = self.extract_sitemap_urls(xml, "/docs/en/")
# Terraform provider reference serves no .md variant (404s)
terraform = [u for u in urls if "/api/terraform/" in u]
urls = [u for u in urls if "/api/terraform/" not in u]
counts["platform"] = len(urls)
print(f" {len(urls)} docs")
print(f" {len(urls)} docs"
+ (f" ({len(terraform)} terraform pages skipped, no .md)"
if terraform else ""))
for url in urls:
tasks.append(self.download_doc(session, url, semaphore))

Expand Down Expand Up @@ -413,9 +429,24 @@ async def fetch_all(self):
if tasks:
results = await tqdm_asyncio.gather(*tasks, desc="Fetching", unit="file")
await self._save_metadata(results)
self._print_failures(results)

self._print_summary()

def _print_failures(self, results: List[Dict]):
failed = [r for r in results if r.get("status") == "failed"]
if not failed:
return
by_host = defaultdict(int)
for r in failed:
by_host[r["url"].split("/")[2]] += 1
print("\nFailed by host:")
Comment on lines +346 to +349
for host, n in sorted(by_host.items(), key=lambda kv: -kv[1]):
print(f" {host}: {n}")
print("Sample errors:")
for r in failed[:3]:
print(f" {r['url']}: {str(r.get('error', ''))[:120]}")

async def _fetch_meta(self, session):
print("Meta: NPM manifest + CHANGELOG")
try:
Expand Down Expand Up @@ -446,6 +477,10 @@ async def _save_metadata(self, results: List[Dict]):
"section": self.section or "all",
},
"items": [r for r in results if r.get("status") == "success"],
"failures": [
{"url": r["url"], "error": str(r.get("error", ""))[:200]}
for r in results if r.get("status") == "failed"
],
Comment on lines +386 to +389
"summary": {
"total": self.stats["total"],
"downloaded": self.stats["downloaded"],
Expand Down
Loading