fix: exclude Squad state from publish sync (#271)

* fix: exclude squad state from publish sync Limit publish-to-main sync to generated data and content paths, rebuild rollups before staging, and fail if any .squad path would be synced. Closes #270 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: assert publish sync excludes squad state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 6, 2026 at 10:13 UTC 7db7bd7aeb0130e7f5de63bfdc37391ce1985b45
3 files changed +77 -27
.github/workflows/sync-publish-to-main.yml
+14 -27
@@ -54,23 +54,9 @@ jobs:
54 content/monthly/ \
55 content/yearly/
56
57 - # OPTIONAL — squad state; tolerant of missing files
58 - git checkout origin/publish -- .squad/run-counter.txt 2>/dev/null || true
59 - git checkout origin/publish -- .squad/decisions.md 2>/dev/null || true
60 -
61 - # Agent history files (per-agent learnings)
62 - git ls-tree -r --name-only origin/publish -- .squad/agents/ 2>/dev/null \
63 - | { grep 'history.md$' || true; } \
64 - | while IFS= read -r hist; do
65 - [ -n "$hist" ] && git checkout origin/publish -- "$hist" 2>/dev/null || true
66 - done
67 -
68 - # Skills (team-level reusable patterns)
69 - git ls-tree -r --name-only origin/publish -- .squad/skills/ 2>/dev/null \
70 - | { grep -E '\.md$' || true; } \
71 - | while IFS= read -r skill; do
72 - [ -n "$skill" ] && git checkout origin/publish -- "$skill" 2>/dev/null || true
73 - done
57 + # Rebuild rollups from the synced analyzed summaries so monthly/yearly
58 + # pages cannot drift from the weekly content in the generated sync PR.
59 + python3 scripts/generate_rollups.py
60
61 # Check if there are any changes
62 if git diff --cached --quiet && git diff --quiet; then
@@ -79,6 +65,12 @@ jobs:
65 fi
66
67 git add -A
68 + if git diff --cached --name-only | grep -E '^\.squad/' >/dev/null; then
69 + echo "::error::Refusing to sync .squad state from publish to main."
70 + git diff --cached --name-only | grep -E '^\.squad/' || true
71 + exit 1
72 + fi
73 +
74 if git diff --cached --quiet; then
75 echo "No changes to sync after staging."
76 exit 0
@@ -86,7 +78,7 @@ jobs:
78
79 git commit -m "sync: publish data → main
80
89 - Automated sync of crawl data, analysis, content, and squad learnings
81 + Automated sync of crawl data, analysis, and content
82 from the publish branch."
83
84 git push -f origin "$SYNC_BRANCH"
@@ -100,9 +92,9 @@ jobs:
92 --base main \
93 --head "$SYNC_BRANCH" \
94 --title "sync: publish data → main" \
103 - --body "Automated sync of generated content subtrees and durable squad learnings from the publish branch to main.
95 + --body "Automated sync of generated content and data subtrees from the publish branch to main.
96
105 - This PR keeps main up-to-date with generated data so that squad learnings and content are accessible from the default branch.
97 + This PR keeps main up-to-date with generated data and content from the crawl/publish pipeline.
98
99 **Synced paths (required — workflow fails if missing on publish):**
100 - \`data/raw/\` — raw crawl data
@@ -110,13 +102,8 @@ jobs:
102 - \`data/metrics/\` — token usage metrics
103 - \`content/weekly/\`, \`content/monthly/\`, \`content/yearly/\` — generated Hugo pages
104
113 - **Synced from .squad/ (optional — tolerant of missing):**
114 - - \`.squad/agents/*/history.md\` — agent learnings
115 - - \`.squad/decisions.md\` — team decisions
116 - - \`.squad/skills/**/*.md\` — reusable patterns
117 - - \`.squad/run-counter.txt\` — run counter
118 -
119 - **Explicitly NOT synced** (session-local noise): \`.squad/log/\`, \`.squad/orchestration-log/\`, \`.squad/decisions/inbox/\`
105 + **Explicitly NOT synced:**
106 + - \`.squad/**\` — Squad memory, decisions, run counters, skills, logs, and agent histories are not authoritative on publish.
107
108 **Notes:**
109 - Publish is authoritative for synced paths. Hand-edits on main to these paths WILL be overwritten.
tests/test_pipeline.py
+31
@@ -293,6 +293,37 @@ class WorkflowConfigTests(unittest.TestCase):
293 self.assertIn("content/monthly/", upload_step["with"]["path"])
294 self.assertIn("content/yearly/", upload_step["with"]["path"])
295
296 + def test_sync_publish_to_main_excludes_squad_state_and_regenerates_rollups(self) -> None:
297 + workflow_path = Path(".github/workflows/sync-publish-to-main.yml")
298 + workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
299 +
300 + sync_job = workflow["jobs"]["sync"]
301 + sync_step = next((s for s in sync_job["steps"] if s.get("name") == "Sync data from publish"), None)
302 + self.assertIsNotNone(sync_step)
303 +
304 + sync_run = sync_step["run"]
305 + for generated_path in (
306 + "data/raw/",
307 + "data/analyzed/",
308 + "data/metrics/",
309 + "content/weekly/",
310 + "content/monthly/",
311 + "content/yearly/",
312 + ):
313 + self.assertIn(generated_path, sync_run)
314 +
315 + self.assertIn("python3 scripts/generate_rollups.py", sync_run)
316 + self.assertLess(sync_run.index("python3 scripts/generate_rollups.py"), sync_run.index("git add -A"))
317 + self.assertIn("Refusing to sync .squad state from publish to main.", sync_run)
318 + self.assertLess(sync_run.index("Refusing to sync .squad"), sync_run.index("git commit -m"))
319 + self.assertIn("**Explicitly NOT synced:**", sync_run)
320 + self.assertIn(".squad/**", sync_run)
321 + self.assertNotIn("git checkout origin/publish -- .squad", sync_run)
322 + self.assertNotIn("git ls-tree -r --name-only origin/publish -- .squad", sync_run)
323 + self.assertNotIn(".squad/decisions.md", sync_run)
324 + self.assertNotIn(".squad/agents/*/history.md", sync_run)
325 + self.assertNotIn("squad learnings", sync_run.lower())
326 +
327 def test_notify_workflow_posts_optional_webhook(self) -> None:
328 workflow_path = Path(".github/workflows/crawl-and-publish.yml")
329 workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
tests/test_sync_publish_workflow.py new
+32
@@ -0,0 +1,32 @@
1 +from pathlib import Path
2 +
3 +
4 +WORKFLOW = Path(".github/workflows/sync-publish-to-main.yml")
5 +
6 +
7 +def test_publish_sync_only_checks_out_generated_content_paths() -> None:
8 + workflow = WORKFLOW.read_text(encoding="utf-8")
9 +
10 + for path in (
11 + "data/raw/",
12 + "data/analyzed/",
13 + "data/metrics/",
14 + "content/weekly/",
15 + "content/monthly/",
16 + "content/yearly/",
17 + ):
18 + assert path in workflow
19 +
20 + assert "git checkout origin/publish -- .squad" not in workflow
21 + assert "git ls-tree -r --name-only origin/publish -- .squad" not in workflow
22 + assert "squad learnings" not in workflow.lower()
23 + assert "python3 scripts/generate_rollups.py" in workflow
24 +
25 +
26 +def test_publish_sync_refuses_staged_squad_changes() -> None:
27 + workflow = WORKFLOW.read_text(encoding="utf-8")
28 +
29 + assert "Refusing to sync .squad state from publish to main" in workflow
30 + assert "git diff --cached --name-only | grep -E '^\\.squad/'" in workflow
31 + assert "data/raw/" in workflow
32 + assert ".squad/**" in workflow