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