Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
16 changes: 11 additions & 5 deletions .github/workflows/spawn_drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,18 @@ name: Spawn Drift
on:
schedule:
- cron: "17 6 * * 1" # weekly, Monday 06:17 UTC
# No `paths:` filter, deliberately (2026-08-25). It used to watch the
# generator — spawn.py, tests/, the spec, this file — which is the wrong
# input set: the partition's other input is the SOURCE TREE, so a PR adding
# a file that matches no rule is exactly what needs checking and exactly
# what the filter excluded. firewall_gate.yml, pages_dashboard.yml and
# dashboard.html all landed that way and went unnoticed until the weekly
# cron failed on 2026-08-24.
#
# Cheap, because the `drift` job below is skipped on pull_request: a PR runs
# only the hermetic `privacy` job (pytest, synthetic inputs, no clones, no
# secrets, no writes).
pull_request:
paths:
- "scripts/spawn.py"
- "tests/**"
- "docs/pyautobrain/spawn_spec.md"
- ".github/workflows/spawn_drift.yml"
workflow_dispatch:

# One sync at a time: the proposal branch is shared, so concurrent runs would
Expand Down
5 changes: 3 additions & 2 deletions docs/pyautobrain/spawn_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ deliberately, never silently shipped into a template.
| 6c | `complete/index.md` | GENERATE → stamped by running the **generated tree's own** `scripts/lifecycle.py index --apply` after the tree is written (`lifecycle.py` resolves its root from `__file__`, and rule 1 already KEEPs it). The live `index.md` is still DROPped by rule 7 — this is a fresh empty-archive index, not a copy. Required because the template ships `lifecycle_drift.yml`, whose self-heal (PyAutoMind#116) regenerates this file on every push to the template's own `main`: if spawn did not produce it, each sync would be followed by a bot commit creating a file the next `--check` reports as drift, forever. Do NOT hold the text as a constant here — `lifecycle.py` owns the index format, and a second copy would drift from it |
| 7 | `active/ complete/ z_features/ z_vault/ autoprompt/` + instance reference docs (`docs/**` now holds only reference material like `spawn_spec.md`) + legacy pre-migration prompt dirs (`autolens/`) | DROP (lifecycle records + instance content; the former `overview.md` instance root doc was deleted outright in #248) |
| 7b | `dashboard.md` | EMPTY (rule 5's mechanism, `EMPTY_TITLES` entry) rather than rule 7's DROP: `README.md` ships verbatim under rule 3 and links this page from its top, so dropping it hands every spawned org a broken front-page link. Like every other rule-5 file the generated body is a title plus the schema-pointer comment — a Mind with no tasks has nothing else to truthfully say, and the page fills itself the first time the new org runs `pyauto-brain intake --apply dashboard`. The live page's content is instance state and never travels |
| 7c | `dashboard.html` | DROP — the Pages twin of `dashboard.md`, written by the same `pyauto-brain intake --apply dashboard` run. Unlike 7b there is no broken-link argument to answer: no shipped file links it, and its publisher (`pages_dashboard.yml`) is dropped by rule 9c, so a fresh org has nothing that reads the page until it regenerates the pair itself |
| 8 | `skills/**`, `policy/**` | KEEP verbatim (`OWNERSHIP.md`, `create_issue/` are generic; `policy/` is org-agnostic safety text) |
| 9 | `.github/**` | **Per file, by the succeed-on-a-fresh-repo test below.** Not a blanket rule: owner substitution alone does NOT make a workflow work, because `YOURORG` is a literal placeholder — the template's own `spawn_drift` run failed `repository 'https://github.com/YOURORG/PyAutoMind/' not found`. See rules 9a–9c |
| 9 | `.github/**` | **Per file, by the succeed-on-a-fresh-repo test below.** Not a blanket rule: owner substitution alone does NOT make a workflow work, because `YOURORG` is a literal placeholder — the template's own `spawn_drift` run failed `repository 'https://github.com/YOURORG/PyAutoMind/' not found`. See rules 9a–9d |
| 9a | `.github/workflows/lifecycle_drift.yml` | KEEP verbatim — operates only on its own repo (checkout + local scripts) and contains no owner reference at all, so it needs no substitution and succeeds unmodified in a fresh org. Empirically the one green workflow in the template's run history |
| 9b | `.github/workflows/spawn_drift.yml` | DROP — was "keep with the `schedule:` stripped", revised in #125. The self-heal added there makes this workflow depend on `secrets.PAT_PYAUTOLABS` AND on published `*-template` repos, neither of which a freshly-spawned org has, so **every** path in it is unrunnable there and the secret reference alone breaks the no-configured-secret condition. "When in doubt DROP" applies: an org that later publishes templates can adopt this workflow deliberately, having read it. The template still ships `scripts/spawn.py` + `tests/`, so the generator and its guards travel; only the org-coupled automation does not |
| 9c | `.github/workflows/{dashboard_refresh,registry_reconcile,morning_status,morning_health,arxiv_papers}.yml`, `.github/scripts/**` | DROP — instance automation. `dashboard_refresh.yml` checks out `PyAutoLabs/PyAutoBrain` (the dashboard renderer lives with the intake conductor, not in Mind), so it fails on checkout in any org that has no such sibling — and owner substitution only turns that into the literal `YOURORG/PyAutoBrain`. The rest They hardcode sibling repo lists, organ-specific workflow names (`PyAutoHeart`/`PyAutoBrain`/`PyAutoHands`), org secrets (`PYAUTO_PAPERS_WEBHOOK_URL`, `CLAUDE_CODE_OAUTH_TOKEN`) and, in `arxiv_fetch.py`, strong-lensing search vocabulary plus dated incident notes. All 13 failing runs in the published template came from these |
| 9c | `.github/workflows/{dashboard_refresh,registry_reconcile,morning_status,morning_health,arxiv_papers,firewall_gate,pages_dashboard}.yml`, `.github/scripts/**` | DROP — instance automation. `dashboard_refresh.yml` checks out `PyAutoLabs/PyAutoBrain` (the dashboard renderer lives with the intake conductor, not in Mind), so it fails on checkout in any org that has no such sibling — and owner substitution only turns that into the literal `YOURORG/PyAutoBrain`. The rest hardcode sibling repo lists, organ-specific workflow names (`PyAutoHeart`/`PyAutoBrain`/`PyAutoHands`), org secrets (`PYAUTO_PAPERS_WEBHOOK_URL`, `CLAUDE_CODE_OAUTH_TOKEN`) and, in `arxiv_fetch.py`, strong-lensing search vocabulary plus dated incident notes. All 13 failing runs in the published template came from these. Two later additions join them (2026-08, first caught by the 2026-08-24 drift run): `firewall_gate.yml` checks out `PyAutoLabs/{PyAutoBrain,PyAutoHeart,PyAutoHands}` by name — `dashboard_refresh.yml`'s failure mode three times over; and `pages_dashboard.yml` needs a GitHub Pages site the default token cannot create on a fresh repo (the Hands lesson already recorded for Memory's `knowledge_board.yml`) and takes `pages: write` + `id-token: write` |
| 9d | any other `.github/**` | **No catch-all rule — UNMATCHED by design.** A fallback here is fail-*open*: a workflow added to Mind later would ride it into the template carrying whatever schedule and secrets it has, which is precisely the defect 9a–9c fix. A new `.github` file must fail the run and get an explicit entry above, like every other new file class |
| 10 | `.claude/**`, `.codex/**` | DROP — agent-discovery symlinks are install artifacts recreated by the PyAutoBrain installer, not source content |

Expand Down
17 changes: 17 additions & 0 deletions scripts/spawn.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@
# front-page link. The emptied page carries the regenerate command, which
# is the whole of what a fresh Mind can truthfully say.
("dashboard.md", "EMPTY"),
# `dashboard.html` is the Pages twin of dashboard.md, generated by the same
# `pyauto-brain intake --apply dashboard` run. DROP rather than EMPTY: no
# shipped file links it (README links neither page directly), and its
# publisher — pages_dashboard.yml — is dropped by rule 9c, so a fresh org
# has nothing that reads it until it regenerates the pair itself.
("dashboard.html", "DROP"),
("skills/*", "KEEP"), ("policy/*", "KEEP"),
# .github is decided PER FILE by the spec's fresh-repo invariant (rule 9):
# a shipped workflow must succeed on a freshly-spawned repo with no secrets
Expand Down Expand Up @@ -125,6 +131,17 @@
(".github/workflows/morning_status.yml", "DROP"),
(".github/workflows/morning_health.yml", "DROP"),
(".github/workflows/arxiv_papers.yml", "DROP"),
# 9c also: the tenant-firewall gate checks out three sibling organ repos by
# name (PyAutoBrain/PyAutoHeart/PyAutoHands). A fresh org has none of them,
# and owner substitution only turns those into YOURORG/... placeholders —
# the same failure mode as dashboard_refresh.yml.
(".github/workflows/firewall_gate.yml", "DROP"),
# 9c also: the Pages publisher. It needs a GitHub Pages site the default
# token cannot create on a fresh repo (the Hands lesson, already recorded
# for Memory's knowledge_board.yml) and takes pages:write + id-token:write,
# so it is unrunnable on arrival. scripts/ and the renderer still travel;
# an adopter re-adds the publisher deliberately.
(".github/workflows/pages_dashboard.yml", "DROP"),
(".github/scripts/*", "DROP"),
# NO `.github/*` catch-all, deliberately. A catch-all is fail-OPEN: a new
# Mind workflow would ride it into the template carrying whatever schedule
Expand Down
48 changes: 48 additions & 0 deletions tests/test_spawn_template_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ def _real(name):
" - env:\n HOOK: ${{ secrets.PYAUTO_PAPERS_WEBHOOK_URL }}\n"
" run: echo x\n"
),
".github/workflows/firewall_gate.yml": _real("firewall_gate.yml"),
".github/workflows/pages_dashboard.yml": _real("pages_dashboard.yml"),
".github/scripts/arxiv_fetch.py": "QUERY = 'strong lensing OR lensed quasar'\n",
}

Expand All @@ -125,6 +127,13 @@ def _real(name):
".github/workflows/morning_status.yml",
".github/workflows/morning_health.yml",
".github/workflows/arxiv_papers.yml",
# rule 9c: checks out three sibling organ repos by name — dashboard_refresh's
# failure mode three times over. Added 2026-08, first caught by the
# 2026-08-24 spawn_drift run as UNMATCHED.
".github/workflows/firewall_gate.yml",
# rule 9c: needs a GitHub Pages site the default token cannot create on a
# fresh repo, and takes pages:write + id-token:write.
".github/workflows/pages_dashboard.yml",
".github/scripts/arxiv_fetch.py",
]

Expand All @@ -143,6 +152,45 @@ def _shipped_workflows(out):
return sorted(d.glob("*.yml")) if d.exists() else []


def test_no_tracked_file_is_unmatched_by_mind_rules():
"""Every file in the LIVE Mind tree must have an explicit MIND_RULES entry.

Every other test here builds a synthetic tree, so it only covers the file
classes somebody remembered to add to the fixture. That is how
`firewall_gate.yml`, `pages_dashboard.yml` and `dashboard.html` reached
main unclassified and sat there until the 2026-08-24 weekly drift run
failed on them: nothing at PR time ever looked at the real file list.

This reads the real tracked files instead, so a new file class fails the
PR that adds it rather than the next Monday cron. It is the same condition
the drift job reports as UNMATCHED / exit 2, minus the clones — the `drift`
job is skipped on pull_request, so this hermetic check is the only
PR-time guard there is.

MEMORY_RULES cannot be checked from here (PyAutoMemory is not a sibling in
this checkout); it stays covered by the weekly run.
"""
repo = Path(__file__).resolve().parents[1]
proc = subprocess.run(
["git", "-C", str(repo), "ls-files", "-z"],
capture_output=True, text=True,
)
if proc.returncode != 0:
pytest.skip("not a git checkout")
tracked = [f for f in proc.stdout.split("\0") if f]
assert tracked, "git ls-files returned nothing — wrong root?"

unmatched = [
f for f in tracked if spawn.match_rule(Path(f), spawn.MIND_RULES)[1] is None
]
assert not unmatched, (
"these tracked files match no MIND_RULES entry, so spawn cannot decide "
"whether they travel into the template. Extend the spec's tables "
"(docs/pyautobrain/spawn_spec.md), then mirror the decision into "
f"MIND_RULES: {unmatched}"
)


def test_instance_automation_is_not_shipped(mind_with_github):
"""The 13 failing runs in the published template all came from these."""
for rel in DROPPED_GITHUB:
Expand Down
Loading