From 0b62e0199589a9e3bc7a94aa19194c83f4a01670 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Tue, 4 Aug 2026 19:14:24 +0100 Subject: [PATCH 1/3] fix: decide .github per file so the template stops shipping broken automation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MIND_RULES` mapped `.github/*` to KEEP_SUB, which only replaces PyAutoLabs with YOURORG. Everything else passed through verbatim, so the public template arrived carrying instance automation that cannot run in the org it was spawned for — 13 failing runs in PyAutoMind-template. Owner substitution does not help: YOURORG is a literal placeholder, so the template's own spawn_drift run failed with `repository 'https://github.com/YOURORG/PyAutoMind/' not found`. Anything that clones or queries a sibling repo is broken on arrival, secrets or not. Adopts the fresh-repo invariant (spec rule 9): a workflow shipped into a template must be able to succeed on a freshly-spawned repo with no secrets and no sibling repos. Only lifecycle_drift.yml clears it — empirically the one green workflow in the template's history. - 9a lifecycle_drift.yml KEEP (self-contained, no owner reference at all) - 9b spawn_drift.yml SPECIAL:unscheduled — kept, `schedule:` stripped so it never auto-fails on an org with no *-template repos; dispatch + PR remain. Fails loudly if the trigger ever disappears upstream, so the rule cannot rot into a silent no-op. - 9c morning_status, morning_health, arxiv_papers, .github/scripts/** DROP - 9d everything else NO catch-all — UNMATCHED by design 9d is the part that matters most. A catch-all is fail-OPEN: a workflow added to Mind later rides it into the template with whatever schedule and secrets it has. The tripwire test caught exactly that against an earlier draft of this change that kept the fallback, so the fallback is gone — a new .github file now fails the run and gets an explicit human decision, like every other new file class. MEMORY_RULES gets the same treatment. validate.yml is self-contained and still ships (byte-identical, so no Memory drift), but its catch-all is closed too — leaving one fail-open door while shutting the other is a half-fix. Refs #121 Co-Authored-By: Claude Opus 5 --- docs/pyautobrain/spawn_spec.md | 18 ++- scripts/spawn.py | 84 +++++++++++++- tests/test_spawn_template_contract.py | 155 ++++++++++++++++++++++++++ 3 files changed, 253 insertions(+), 4 deletions(-) diff --git a/docs/pyautobrain/spawn_spec.md b/docs/pyautobrain/spawn_spec.md index d712213a..3a801bce 100644 --- a/docs/pyautobrain/spawn_spec.md +++ b/docs/pyautobrain/spawn_spec.md @@ -43,7 +43,11 @@ 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`) + instance root docs (`dashboard.md`, `overview.md`) + legacy pre-migration prompt dirs (`autolens/`) | DROP (lifecycle records + instance content) | | 8 | `skills/**`, `policy/**` | KEEP verbatim (`OWNERSHIP.md`, `create_issue/` are generic; `policy/` is org-agnostic safety text) | -| 9 | `.github/**` | KEEP verbatim EXCEPT workflows that reference live secrets/repos beyond the org placeholder — those SUBSTITUTE `PyAutoLabs` → `YOURORG` and keep | +| 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 | +| 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` | SPECIAL → keep with the `schedule:` trigger **stripped**. The generator machinery is generic and worth shipping, but a fresh org has no published `*-template` repos, so a weekly run would fail until it does. `pull_request` + `workflow_dispatch` remain; a comment says to re-add the schedule once templates are published | +| 9c | `.github/workflows/{morning_status,morning_health,arxiv_papers}.yml`, `.github/scripts/**` | DROP — instance automation. 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 | +| 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 | ### PyAutoMemory → PyAutoMemory-template @@ -58,7 +62,17 @@ deliberately, never silently shipped into a template. | 5 | `index.md` | SUBSTITUTE → skeleton: intro line + a table with the single `wiki/example/` row + "add sub-wikis following the same schema" | | 6 | `reading-queue.md` | EMPTY → header + section-format comment | | 7 | `README.md` | GENERATE → a template README asset held inside spawn (text surgery on the live README is brittle across edits; the asset keeps `--check` round-trips stable) | -| 8 | `.github/**` | KEEP with owner substitution; `logo.png` (instance branding) | DROP. The old legacy-family DROP rules (root `*.bib`, PDFs, `CTI/` etc.) are retired — those files are gone from the live repo and PyAutoMemory's structure lint (`make validate-structure`, in CI) prevents their return at the source | +| 8 | `.github/workflows/validate.yml` | KEEP with owner substitution — self-contained (no schedule, no secrets, no sibling repos), so it clears the fresh-repo invariant. As in the Mind table's rule 9d there is **no `.github/**` catch-all**: a new Memory workflow is UNMATCHED and needs an explicit decision. `logo.png` (instance branding) | DROP. The old legacy-family DROP rules (root `*.bib`, PDFs, `CTI/` etc.) are retired — those files are gone from the live repo and PyAutoMemory's structure lint (`make validate-structure`, in CI) prevents their return at the source | + +**Fresh-repo invariant (hard rule, rule 9):** a workflow shipped into a +template must be able to **succeed on a freshly-spawned repo with no secrets +and no sibling repos**. A workflow that cannot is not "configuration the new +owner will finish" — it is a scheduled job that fails on their repo and emails +them, forever, for work they never asked for. When in doubt DROP: a spawned org +that later builds the same organs can copy a workflow across deliberately, but +it cannot easily discover why an inherited one keeps failing. The implementation +must include a test asserting no shipped workflow carries a `secrets.` +reference, a `YOURORG/` cross-repo reference, or a `schedule:` trigger. **Privacy invariant (hard rule):** no live wiki page, bibliography entry, reading-queue line, prompt, or registry entry may ever appear in a template diff --git a/scripts/spawn.py b/scripts/spawn.py index 0e2ce782..67a1736c 100644 --- a/scripts/spawn.py +++ b/scripts/spawn.py @@ -71,7 +71,29 @@ # Instance root docs: ("dashboard.md", "DROP"), ("overview.md", "DROP"), ("skills/*", "KEEP"), ("policy/*", "KEEP"), - (".github/*", "KEEP_SUB"), + # .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 + # and no sibling repos. Owner substitution alone does NOT achieve that — + # YOURORG is a literal placeholder, and the template's own spawn_drift run + # failed with `repository 'https://github.com/YOURORG/PyAutoMind/' not + # found`. Anything cloning or querying a sibling repo is broken on arrival. + # + # Ordered before the .github/scripts DROP and each other; first match wins. + (".github/workflows/lifecycle_drift.yml", "KEEP"), # 9a: self-contained + (".github/workflows/spawn_drift.yml", "SPECIAL:unscheduled"), # 9b + # 9c — instance automation: sibling repo lists, organ-specific workflow + # names, org secrets, strong-lensing vocabulary. Every one of the 13 failing + # runs in the published template came from these. + (".github/workflows/morning_status.yml", "DROP"), + (".github/workflows/morning_health.yml", "DROP"), + (".github/workflows/arxiv_papers.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 + # and secrets it has — the exact defect this rule exists to fix. With no + # fallback, a new .github file is UNMATCHED, spawn fails, and a human adds + # an explicit rule 9 entry. Same doctrine as every other new file class: + # "extend the spec's tables, then mirror here", never classify ad hoc. # Agent-discovery symlinks are install artifacts (recreated by the # PyAutoBrain installer), not source content — drop them from the template. (".claude/*", "DROP"), (".codex/*", "DROP"), @@ -87,7 +109,11 @@ # Same org-wide pointer docs as MIND_RULES — owner substitution. ("AI_POLICY.md", "KEEP_SUB"), ("CONTRIBUTING.md", "KEEP_SUB"), ("bibliography/*", "EMPTY"), - (".github/*", "KEEP_SUB"), + # Same fail-closed discipline as MIND_RULES (spec rule 9d). validate.yml is + # self-contained — no schedule, no secrets, no sibling repos — so it clears + # the fresh-repo invariant and ships. No catch-all: a new Memory workflow is + # UNMATCHED and gets an explicit decision. + (".github/workflows/validate.yml", "KEEP_SUB"), # The shared wiki schema is template content; the sub-wikis are instance # content (the generator stamps an empty wiki/example/ instead). ("wiki/CLAUDE.md", "KEEP"), @@ -408,6 +434,58 @@ def empty_body(src, rel=None): return header + "\n\n\n" +def unscheduled_workflow_body(src): + """Drop a workflow's `schedule:` trigger (spec rule 9b). + + `spawn_drift.yml` is generic machinery worth shipping, but its scheduled run + clones `/*-template` repos a freshly-spawned org does not have yet — + so on a schedule it would fail weekly and email the new owner. Stripping the + trigger keeps the capability (`workflow_dispatch`, `pull_request`) without + the noise. + + Structural, not textual: the `schedule:` key and its indented block go, and + everything else is preserved byte for byte. + """ + lines = substitute_owner(src.read_text(errors="replace")).splitlines() + out, i, dropped = [], 0, False + while i < len(lines): + line = lines[i] + stripped = line.lstrip() + if stripped.startswith("schedule:") and not stripped.startswith("#"): + indent = len(line) - len(stripped) + out.append(" " * indent + "# schedule: removed by spawn — a fresh org has") + out.append(" " * indent + "# no published *-template repos yet, so the run") + out.append(" " * indent + "# would fail until it does. Re-add once you publish.") + i += 1 + # Consume the block: deeper-indented lines, plus blanks inside it. + while i < len(lines): + nxt = lines[i] + if not nxt.strip(): + # A blank only belongs to the block if more block follows. + j = i + while j < len(lines) and not lines[j].strip(): + j += 1 + if j < len(lines) and (len(lines[j]) - len(lines[j].lstrip())) > indent: + i = j + continue + break + if (len(nxt) - len(nxt.lstrip())) <= indent: + break + i += 1 + dropped = True + continue + out.append(line) + i += 1 + if not dropped: + # The trigger this rule exists to remove is gone — the rule is now + # silently a no-op, which is how a guard rots. Fail loudly instead. + raise SystemExit( + f"spawn: {src.name} has no 'schedule:' trigger to strip.\n" + f" SPECIAL:unscheduled is now a no-op — re-check spawn_spec.md rule 9b." + ) + return "\n".join(out) + "\n" + + def autonomy_log_body(src): lines = src.read_text(errors="replace").splitlines() kept = [] @@ -452,6 +530,8 @@ def generate_mind(mind_root, out_dir): dest.write_text(substitute_owner(src.read_text(errors="replace"))) elif action == "EMPTY": dest.write_text(empty_body(src, rel)) + elif action == "SPECIAL:unscheduled": + dest.write_text(unscheduled_workflow_body(src)) elif action == "SPECIAL:autonomy_log": dest.write_text(autonomy_log_body(src)) elif action == "SPECIAL:body_map": diff --git a/tests/test_spawn_template_contract.py b/tests/test_spawn_template_contract.py index e7b29272..2a91e430 100644 --- a/tests/test_spawn_template_contract.py +++ b/tests/test_spawn_template_contract.py @@ -9,13 +9,25 @@ That is exactly what happened on 2026-08-04: sync `51f5ae58` at 17:28:51Z, bot commit `79864dde` at 17:29:12Z, and the very next dispatch failed on `only in published: complete/index.md`. + +The same file also pins the **fresh-repo invariant** (spec rule 9, issue #121): +a workflow shipped into a template must be able to succeed on a freshly-spawned +repo with no secrets and no sibling repos. The published template had 13 failing +runs from inherited instance automation, and owner substitution does not help — +`YOURORG` is a literal placeholder, so its own `spawn_drift` run failed +`repository 'https://github.com/YOURORG/PyAutoMind/' not found`. + +Both halves are the same idea: the template is a live repo with running +workflows, so spawn owns what those workflows do on arrival. """ import importlib.util +import re import subprocess from pathlib import Path import pytest +import yaml SPAWN_PY = Path(__file__).resolve().parents[1] / "scripts" / "spawn.py" @@ -62,6 +74,149 @@ def _fake_repo(root, files): "scripts/lifecycle.py": STUB_LIFECYCLE, } +# A .github mirroring the real one: two self-contained/generic workflows and +# three pieces of instance automation (sibling repo lists, organ workflow +# names, org secrets, domain vocabulary). +GITHUB_FILES = { + ".github/workflows/lifecycle_drift.yml": ( + "name: Lifecycle Drift\non:\n push:\n branches: [main]\n" + "jobs:\n drift:\n runs-on: ubuntu-latest\n steps:\n" + " - uses: actions/checkout@v4\n" + " - run: python3 scripts/lifecycle.py check\n" + ), + ".github/workflows/spawn_drift.yml": ( + "name: Spawn Drift\non:\n" + " schedule:\n - cron: \"17 6 * * 1\"\n" + " pull_request:\n paths:\n - \"scripts/spawn.py\"\n" + " workflow_dispatch:\n\n" + "jobs:\n drift:\n runs-on: ubuntu-latest\n steps:\n" + " - run: git clone https://github.com/PyAutoLabs/PyAutoMind\n" + ), + ".github/workflows/morning_status.yml": ( + "name: digest\non:\n schedule:\n - cron: \"0 6 * * *\"\n" + "jobs:\n d:\n runs-on: ubuntu-latest\n steps:\n" + " - run: echo PyAutoLabs/PyAutoFit\n" + ), + ".github/workflows/morning_health.yml": ( + "name: health\non:\n schedule:\n - cron: \"0 7 * * *\"\n" + "jobs:\n h:\n runs-on: ubuntu-latest\n steps:\n" + " - run: gh api repos/PyAutoLabs/PyAutoHeart/actions/workflows/x.yml\n" + ), + ".github/workflows/arxiv_papers.yml": ( + "name: papers\non:\n schedule:\n - cron: \"0 8 * * *\"\n" + "jobs:\n p:\n runs-on: ubuntu-latest\n steps:\n" + " - env:\n HOOK: ${{ secrets.PYAUTO_PAPERS_WEBHOOK_URL }}\n" + " run: echo x\n" + ), + ".github/scripts/arxiv_fetch.py": "QUERY = 'strong lensing OR lensed quasar'\n", +} + +DROPPED_GITHUB = [ + ".github/workflows/morning_status.yml", + ".github/workflows/morning_health.yml", + ".github/workflows/arxiv_papers.yml", + ".github/scripts/arxiv_fetch.py", +] + + +@pytest.fixture +def mind_with_github(tmp_path): + mind = tmp_path / "PyAutoMind" + _fake_repo(mind, {**MINIMAL_MIND, **GITHUB_FILES}) + out = tmp_path / "out" + spawn.generate_mind(mind, out) + return out + + +def _shipped_workflows(out): + d = out / ".github" / "workflows" + return sorted(d.glob("*.yml")) if d.exists() else [] + + +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: + assert not (mind_with_github / rel).exists(), f"{rel} shipped into the template" + + +def test_generic_workflows_are_still_shipped(mind_with_github): + """Guard the other direction — rule 9 must not over-drop.""" + names = {p.name for p in _shipped_workflows(mind_with_github)} + assert names == {"lifecycle_drift.yml", "spawn_drift.yml"}, names + + +def test_no_shipped_workflow_runs_on_a_schedule(mind_with_github): + """The fresh-repo invariant's teeth. + + A scheduled job that cannot succeed on a fresh org fails weekly and emails + the new owner forever. Nothing shipped may auto-run. + """ + for wf in _shipped_workflows(mind_with_github): + spec = yaml.safe_load(wf.read_text()) + triggers = spec[True] if True in spec else spec.get("on", {}) + assert "schedule" not in (triggers or {}), f"{wf.name} still auto-runs" + + +def test_no_shipped_workflow_needs_a_configured_secret(mind_with_github): + """`GITHUB_TOKEN` is auto-provided by Actions; anything else is org setup + a freshly-spawned repo does not have.""" + for wf in _shipped_workflows(mind_with_github): + for ref in re.findall(r"secrets\.([A-Za-z_][A-Za-z0-9_]*)", wf.read_text()): + assert ref == "GITHUB_TOKEN", f"{wf.name} needs configured secret {ref}" + + +def test_a_new_mind_workflow_is_a_human_decision(tmp_path): + """`.github` has NO catch-all rule, deliberately. + + A catch-all is fail-open: a workflow added to Mind later would ride it into + the template carrying whatever schedule and secrets it has — the exact + defect rule 9 exists to fix. This test wrote itself: an earlier draft kept + a `.github/*` KEEP_SUB fallback and this case caught the schedule sailing + straight through. + + Unmatched means spawn fails and a human adds an explicit rule 9 entry. + """ + mind = tmp_path / "PyAutoMind" + files = {**MINIMAL_MIND, **GITHUB_FILES} + files[".github/workflows/brand_new_thing.yml"] = ( + "name: new\non:\n schedule:\n - cron: \"0 9 * * *\"\n" + "jobs:\n n:\n runs-on: ubuntu-latest\n steps:\n" + " - env:\n K: ${{ secrets.SOME_ORG_SECRET }}\n" + " run: echo x\n" + ) + _fake_repo(mind, files) + out = tmp_path / "out" + + warns = spawn.generate_mind(mind, out) + + assert ".github/workflows/brand_new_thing.yml" in warns, ( + "a new .github file was classified silently — it must be UNMATCHED" + ) + assert not (out / ".github" / "workflows" / "brand_new_thing.yml").exists() + + +def test_unscheduled_transform_fails_loudly_if_it_becomes_a_noop(tmp_path): + """If spawn_drift ever loses its schedule upstream, the rule silently stops + doing anything — that is how a guard rots. It must fail instead.""" + src = tmp_path / "spawn_drift.yml" + src.write_text("name: x\non:\n workflow_dispatch:\njobs: {}\n") + with pytest.raises(SystemExit): + spawn.unscheduled_workflow_body(src) + + +def test_unscheduled_transform_preserves_everything_else(tmp_path): + """Structural strip: only the schedule block goes.""" + src = tmp_path / "spawn_drift.yml" + src.write_text(GITHUB_FILES[".github/workflows/spawn_drift.yml"]) + + spec = yaml.safe_load(spawn.unscheduled_workflow_body(src)) + triggers = spec[True] if True in spec else spec["on"] + + assert "schedule" not in triggers + assert "workflow_dispatch" in triggers + assert triggers["pull_request"]["paths"] == ["scripts/spawn.py"] + assert list(spec["jobs"]) == ["drift"] + def test_spawn_stamps_the_templates_complete_index(tmp_path): """spawn must run the GENERATED tree's own lifecycle.py, not the live one.""" From 110b804f5ca52538adfd0be5e788619998123389 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Tue, 4 Aug 2026 19:16:24 +0100 Subject: [PATCH 2/3] fix: scope the schedule strip to the top-level on: mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first draft matched any line starting with `schedule:`, so a `schedule:` line inside a `run: |` shell block was rewritten into comments — silently mangling the script. Found by stress-testing the transform against awkward YAML rather than by the happy-path test, which passed throughout. The strip now tracks the top-level `on:` mapping and only removes a direct child of it. Flow style (`on: {schedule: ...}`) still fails loudly rather than being guessed at, as does a workflow with no schedule at all. Tests cover the run-block false positive, quoted `on` keys (YAML 1.1 coerces bare `on` to True, so some repos quote it), comments inside the block, and CRLF. Refs #121 Co-Authored-By: Claude Opus 5 --- scripts/spawn.py | 20 ++++++++- tests/test_spawn_template_contract.py | 59 +++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/scripts/spawn.py b/scripts/spawn.py index 67a1736c..19064201 100644 --- a/scripts/spawn.py +++ b/scripts/spawn.py @@ -448,11 +448,27 @@ def unscheduled_workflow_body(src): """ lines = substitute_owner(src.read_text(errors="replace")).splitlines() out, i, dropped = [], 0, False + in_on_block = False while i < len(lines): line = lines[i] stripped = line.lstrip() - if stripped.startswith("schedule:") and not stripped.startswith("#"): - indent = len(line) - len(stripped) + indent_here = len(line) - len(stripped) + + # Track the top-level `on:` mapping. Scoping the strip to it matters: + # a purely line-based match also rewrites a `schedule:` line inside a + # `run: |` shell block, silently mangling the script. + if indent_here == 0 and stripped.strip(): + key = stripped.split(":", 1)[0].strip().strip("\"'") + if key == "on" and not stripped.startswith("#"): + # Flow style (`on: {schedule: ...}`) has its value inline; this + # line-based transform cannot safely edit it, so fall through + # to the loud failure below rather than guess. + in_on_block = not stripped.split(":", 1)[1].strip() + else: + in_on_block = False + + if in_on_block and stripped.startswith("schedule:") and not stripped.startswith("#"): + indent = indent_here out.append(" " * indent + "# schedule: removed by spawn — a fresh org has") out.append(" " * indent + "# no published *-template repos yet, so the run") out.append(" " * indent + "# would fail until it does. Re-add once you publish.") diff --git a/tests/test_spawn_template_contract.py b/tests/test_spawn_template_contract.py index 2a91e430..c37eacb9 100644 --- a/tests/test_spawn_template_contract.py +++ b/tests/test_spawn_template_contract.py @@ -204,6 +204,65 @@ def test_unscheduled_transform_fails_loudly_if_it_becomes_a_noop(tmp_path): spawn.unscheduled_workflow_body(src) +def test_unscheduled_transform_only_touches_the_on_mapping(tmp_path): + """A `schedule:` line inside a `run: |` block is shell, not a trigger. + + The first draft matched any line starting with `schedule:` and rewrote that + shell line into comments — silently mangling the script. The strip is scoped + to the top-level `on:` mapping. + """ + src = tmp_path / "spawn_drift.yml" + src.write_text( + "name: x\non:\n schedule:\n - cron: \"0 6 * * *\"\n workflow_dispatch:\n" + "jobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: |\n" + " schedule: not a trigger\n echo done\n" + ) + + out = spawn.unscheduled_workflow_body(src) + + assert "schedule: not a trigger" in out, "the run block was corrupted" + assert "echo done" in out + spec = yaml.safe_load(out) + triggers = spec[True] if True in spec else spec["on"] + assert "schedule" not in triggers and "workflow_dispatch" in triggers + + +@pytest.mark.parametrize( + "body", + [ + # Flow style — a line-based transform cannot safely edit it. + 'name: x\non: {schedule: [{cron: "0 6 * * *"}]}\njobs: {}\n', + # No schedule at all — the rule would be a silent no-op. + "name: x\non:\n workflow_dispatch:\njobs: {}\n", + ], +) +def test_unscheduled_transform_fails_rather_than_guessing(tmp_path, body): + src = tmp_path / "spawn_drift.yml" + src.write_text(body) + with pytest.raises(SystemExit): + spawn.unscheduled_workflow_body(src) + + +@pytest.mark.parametrize( + "body", + [ + # Quoted `on` key — YAML 1.1 turns bare `on` into True, so some repos quote it. + 'name: x\n"on":\n schedule:\n - cron: "0 6 * * *"\n workflow_dispatch:\njobs: {}\n', + # Comment inside the schedule block. + 'name: x\non:\n schedule:\n # nightly\n - cron: "0 6 * * *"\n workflow_dispatch:\njobs: {}\n', + # CRLF line endings. + 'name: x\r\non:\r\n schedule:\r\n - cron: "0 6"\r\n workflow_dispatch:\r\njobs: {}\r\n', + ], +) +def test_unscheduled_transform_handles_awkward_yaml(tmp_path, body): + src = tmp_path / "spawn_drift.yml" + src.write_text(body) + spec = yaml.safe_load(spawn.unscheduled_workflow_body(src)) + triggers = spec[True] if True in spec else spec["on"] + assert "schedule" not in triggers + assert "workflow_dispatch" in triggers + + def test_unscheduled_transform_preserves_everything_else(tmp_path): """Structural strip: only the schedule block goes.""" src = tmp_path / "spawn_drift.yml" From 38c8f47960e990e9f4163916e5f2eb23538139ed Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Tue, 4 Aug 2026 19:24:09 +0100 Subject: [PATCH 3/3] fix: address independent review of the .github partition rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review findings, all verified against the real transform before fixing. 1. `pip install pytest` only, but the contract tests import PyYAML. setup-python gives a clean interpreter, so this would have failed at COLLECTION on the next CI run — the whole suite, not one test. Declares pyyaml. 2. `unscheduled_workflow_body()` deleted `on.workflow_call.inputs.schedule`, a legitimate input rather than a trigger: it matched `schedule:` at any depth under `on:`. Now matches only a DIRECT child of the top-level `on:` mapping. 3. Same function emitted INVALID YAML when a comment sat at the same indent as `schedule:` — block consumption stopped early and orphaned the `- cron` line. Blanks and comments are now consumed only when deeper content follows, so a comment introducing the NEXT key still travels with that key. 4. Memory's fail-closed rule was untested — every workflow fixture drove generate_mind. Adds test_memory_github_is_also_fail_closed, control-tested by reverting the rule to a catch-all and confirming it fails. 5. The docstring claimed byte-for-byte preservation, which is false: splitlines discards line terminators and the rejoin normalises CRLF to LF. Says so now. Also corrects the spec, which overclaimed. It said shipped workflows "must be able to succeed" and mandated a test asserting no `YOURORG/` reference — but rule 9b deliberately ships spawn_drift.yml, which clones `/*-template` and so fails on manual dispatch until the org publishes templates. The rule being protected is "nothing fails UNATTENDED", not "every human-invoked path succeeds"; asserting no `YOURORG/` would forbid shipping the generator at all. The invariant now states both conditions precisely and records what it does not require, so the next reader does not "fix" the gap by breaking rule 9b. Generated output is byte-identical to before these fixes — the shipped .github tree and spawn_drift triggers/jobs are unchanged. Refs #121 Co-Authored-By: Claude Opus 5 --- .github/workflows/spawn_drift.yml | 5 +- docs/pyautobrain/spawn_spec.md | 39 +++++++++--- scripts/spawn.py | 90 ++++++++++++++++++--------- tests/test_spawn_template_contract.py | 70 ++++++++++++++++++++- 4 files changed, 162 insertions(+), 42 deletions(-) diff --git a/.github/workflows/spawn_drift.yml b/.github/workflows/spawn_drift.yml index 89bb7dd1..70d6ff36 100644 --- a/.github/workflows/spawn_drift.yml +++ b/.github/workflows/spawn_drift.yml @@ -32,7 +32,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install pytest + # pyyaml: the contract tests parse generated workflows. Declared here + # because setup-python gives a clean interpreter — relying on the runner + # image having PyYAML would fail collection, not just a test. + - run: pip install pytest pyyaml - name: spawn privacy invariant + template contract # Whole dir, not one file: a suite named in the workflow by filename # silently stops covering anything added beside it. diff --git a/docs/pyautobrain/spawn_spec.md b/docs/pyautobrain/spawn_spec.md index 3a801bce..04c91061 100644 --- a/docs/pyautobrain/spawn_spec.md +++ b/docs/pyautobrain/spawn_spec.md @@ -64,15 +64,36 @@ deliberately, never silently shipped into a template. | 7 | `README.md` | GENERATE → a template README asset held inside spawn (text surgery on the live README is brittle across edits; the asset keeps `--check` round-trips stable) | | 8 | `.github/workflows/validate.yml` | KEEP with owner substitution — self-contained (no schedule, no secrets, no sibling repos), so it clears the fresh-repo invariant. As in the Mind table's rule 9d there is **no `.github/**` catch-all**: a new Memory workflow is UNMATCHED and needs an explicit decision. `logo.png` (instance branding) | DROP. The old legacy-family DROP rules (root `*.bib`, PDFs, `CTI/` etc.) are retired — those files are gone from the live repo and PyAutoMemory's structure lint (`make validate-structure`, in CI) prevents their return at the source | -**Fresh-repo invariant (hard rule, rule 9):** a workflow shipped into a -template must be able to **succeed on a freshly-spawned repo with no secrets -and no sibling repos**. A workflow that cannot is not "configuration the new -owner will finish" — it is a scheduled job that fails on their repo and emails -them, forever, for work they never asked for. When in doubt DROP: a spawned org -that later builds the same organs can copy a workflow across deliberately, but -it cannot easily discover why an inherited one keeps failing. The implementation -must include a test asserting no shipped workflow carries a `secrets.` -reference, a `YOURORG/` cross-repo reference, or a `schedule:` trigger. +**Fresh-repo invariant (hard rule, rule 9):** no workflow shipped into a +template may **fail unattended** on a freshly-spawned repo. A job that runs on +its own and cannot succeed is not "configuration the new owner will finish" — +it fails on their repo and emails them, forever, for work they never asked for. +That is what produced the published template's 13 failing runs. + +Precisely, a shipped workflow must satisfy **both**: + +1. **No unattended trigger it cannot satisfy.** No `schedule:`, and nothing + else that fires without a human, unless it succeeds with no secrets and no + sibling repos. `lifecycle_drift.yml` keeps its `push:` trigger because it + genuinely does succeed (rule 9a). +2. **No configured secret.** `secrets.GITHUB_TOKEN` is auto-provided by Actions + and allowed; anything the org must create is not. + +Deliberately **not** required: that every human-invoked path succeeds. +`spawn_drift.yml` ships under rule 9b with its schedule stripped but still +clones `/*-template`, so a manual `workflow_dispatch` in an org that has +not published templates yet will fail. That is a human asking for it, with the +generated comment explaining why — not an inherited job failing on its own. Do +not "fix" this by asserting no `YOURORG/` reference: that would forbid shipping +the generator machinery at all. + +When in doubt DROP. A spawned org that later builds the same organs can copy a +workflow across deliberately; it cannot easily discover why an inherited one +keeps failing. + +The implementation must include tests asserting (1) and (2) over every shipped +workflow, and that a NEW `.github` file is UNMATCHED rather than classified by +a catch-all (rule 9d). **Privacy invariant (hard rule):** no live wiki page, bibliography entry, reading-queue line, prompt, or registry entry may ever appear in a template diff --git a/scripts/spawn.py b/scripts/spawn.py index 19064201..e6ae3fcd 100644 --- a/scripts/spawn.py +++ b/scripts/spawn.py @@ -443,49 +443,79 @@ def unscheduled_workflow_body(src): trigger keeps the capability (`workflow_dispatch`, `pull_request`) without the noise. - Structural, not textual: the `schedule:` key and its indented block go, and - everything else is preserved byte for byte. + Structural, not textual: only the `schedule:` key that is a DIRECT CHILD of + the top-level `on:` mapping is removed, together with its block. Every other + line is passed through unchanged, except that line endings are normalised to + `\\n` (`splitlines()` discards the originals). + + Three near-misses this scoping exists to avoid, all found by testing rather + than by reading: + * a `schedule:` line inside a `run: |` shell block is script text, not a + trigger — a bare line match rewrote it into comments; + * `on.workflow_call.inputs.schedule` is a legitimate input, not a trigger, + so depth matters, not just "somewhere under `on:`"; + * a comment sitting at the SAME indent as `schedule:` used to end block + consumption early, orphaning the `- cron` line and emitting invalid YAML. + + Anything this cannot handle confidently (flow style, no schedule at all) + raises instead of guessing. """ + def substantive(idx): + """Next line that is neither blank nor a comment, or None.""" + while idx < len(lines): + s = lines[idx].strip() + if s and not s.startswith("#"): + return idx + idx += 1 + return None + lines = substitute_owner(src.read_text(errors="replace")).splitlines() out, i, dropped = [], 0, False - in_on_block = False + on_child_indent = None # set once we know the `on:` block's child depth while i < len(lines): line = lines[i] stripped = line.lstrip() indent_here = len(line) - len(stripped) + is_blank_or_comment = (not stripped) or stripped.startswith("#") - # Track the top-level `on:` mapping. Scoping the strip to it matters: - # a purely line-based match also rewrites a `schedule:` line inside a - # `run: |` shell block, silently mangling the script. - if indent_here == 0 and stripped.strip(): + # Enter/leave the top-level `on:` mapping. + if indent_here == 0 and not is_blank_or_comment: key = stripped.split(":", 1)[0].strip().strip("\"'") - if key == "on" and not stripped.startswith("#"): - # Flow style (`on: {schedule: ...}`) has its value inline; this - # line-based transform cannot safely edit it, so fall through - # to the loud failure below rather than guess. - in_on_block = not stripped.split(":", 1)[1].strip() + if key == "on" and not stripped.split(":", 1)[1].strip(): + nxt = substantive(i + 1) + on_child_indent = ( + len(lines[nxt]) - len(lines[nxt].lstrip()) if nxt is not None else None + ) else: - in_on_block = False - - if in_on_block and stripped.startswith("schedule:") and not stripped.startswith("#"): - indent = indent_here - out.append(" " * indent + "# schedule: removed by spawn — a fresh org has") - out.append(" " * indent + "# no published *-template repos yet, so the run") - out.append(" " * indent + "# would fail until it does. Re-add once you publish.") + # Any other top-level key ends the block. Flow-style `on: {...}` + # lands here too and never sets a child indent, so it reaches + # the loud failure below rather than being edited blind. + on_child_indent = None + + if ( + on_child_indent is not None + and indent_here == on_child_indent # DIRECT child of `on:` + and stripped.startswith("schedule:") + and not stripped.startswith("#") + ): + pad = " " * indent_here + out.append(pad + "# schedule: removed by spawn — a fresh org has") + out.append(pad + "# no published *-template repos yet, so the run") + out.append(pad + "# would fail until it does. Re-add once you publish.") i += 1 - # Consume the block: deeper-indented lines, plus blanks inside it. + # Consume the block. Blanks and comments belong to it only when + # deeper content follows; otherwise they introduce the NEXT key. while i < len(lines): - nxt = lines[i] - if not nxt.strip(): - # A blank only belongs to the block if more block follows. - j = i - while j < len(lines) and not lines[j].strip(): - j += 1 - if j < len(lines) and (len(lines[j]) - len(lines[j].lstrip())) > indent: - i = j + s = lines[i].strip() + if not s or s.startswith("#"): + nxt = substantive(i) + if nxt is not None and ( + len(lines[nxt]) - len(lines[nxt].lstrip()) + ) > indent_here: + i += 1 continue break - if (len(nxt) - len(nxt.lstrip())) <= indent: + if (len(lines[i]) - len(lines[i].lstrip())) <= indent_here: break i += 1 dropped = True @@ -496,7 +526,7 @@ def unscheduled_workflow_body(src): # The trigger this rule exists to remove is gone — the rule is now # silently a no-op, which is how a guard rots. Fail loudly instead. raise SystemExit( - f"spawn: {src.name} has no 'schedule:' trigger to strip.\n" + f"spawn: {src.name} has no top-level 'on: schedule:' trigger to strip.\n" f" SPECIAL:unscheduled is now a no-op — re-check spawn_spec.md rule 9b." ) return "\n".join(out) + "\n" diff --git a/tests/test_spawn_template_contract.py b/tests/test_spawn_template_contract.py index c37eacb9..230cf91d 100644 --- a/tests/test_spawn_template_contract.py +++ b/tests/test_spawn_template_contract.py @@ -204,6 +204,43 @@ def test_unscheduled_transform_fails_loudly_if_it_becomes_a_noop(tmp_path): spawn.unscheduled_workflow_body(src) +MINIMAL_MEMORY = { + "README.md": "# Mem\n", "AGENTS.md": "# A\n", "CLAUDE.md": "# C\n", + "LICENSE": "MIT\n", ".gitignore": "tmp/\n", "Makefile": "all:\n", + "AI_POLICY.md": "p\n", "CONTRIBUTING.md": "c\n", + "index.md": "# Index\n", "reading-queue.md": "# Reading queue\n", + "bibliography/README.md": "# Bib\n", + "wiki/CLAUDE.md": "# schema\n", + ".github/workflows/validate.yml": ( + "name: validate\non:\n push:\n branches: [main]\n" + "jobs:\n v:\n runs-on: ubuntu-latest\n steps:\n - run: make validate\n" + ), +} + + +def test_memory_github_is_also_fail_closed(tmp_path): + """MEMORY_RULES has no `.github` catch-all either. + + Closing one fail-open door and leaving the other is a half-fix, and every + other workflow test here drives generate_mind — so without this, reverting + Memory's rule to a catch-all would go unnoticed. + """ + mem = tmp_path / "PyAutoMemory" + files = dict(MINIMAL_MEMORY) + files[".github/workflows/some_new_memory_job.yml"] = ( + 'name: new\non:\n schedule:\n - cron: "0 9 * * *"\njobs: {}\n' + ) + _fake_repo(mem, files) + out = tmp_path / "out" + + warns = spawn.generate_memory(mem, out) + + assert ".github/workflows/some_new_memory_job.yml" in warns + assert not (out / ".github" / "workflows" / "some_new_memory_job.yml").exists() + # …and the known-good one still ships. + assert (out / ".github" / "workflows" / "validate.yml").exists() + + def test_unscheduled_transform_only_touches_the_on_mapping(tmp_path): """A `schedule:` line inside a `run: |` block is shell, not a trigger. @@ -248,8 +285,13 @@ def test_unscheduled_transform_fails_rather_than_guessing(tmp_path, body): [ # Quoted `on` key — YAML 1.1 turns bare `on` into True, so some repos quote it. 'name: x\n"on":\n schedule:\n - cron: "0 6 * * *"\n workflow_dispatch:\njobs: {}\n', - # Comment inside the schedule block. + # Comment nested inside the schedule block. 'name: x\non:\n schedule:\n # nightly\n - cron: "0 6 * * *"\n workflow_dispatch:\njobs: {}\n', + # Comment at the SAME indent as `schedule:` — used to end block + # consumption early, orphaning `- cron` and emitting invalid YAML. + 'name: x\non:\n schedule:\n # nightly\n - cron: "0 6 * * *"\n workflow_dispatch:\njobs: {}\n', + # Comment introducing the NEXT key must survive with that key. + 'name: x\non:\n schedule:\n - cron: "0 6 * * *"\n # manual only\n workflow_dispatch:\njobs: {}\n', # CRLF line endings. 'name: x\r\non:\r\n schedule:\r\n - cron: "0 6"\r\n workflow_dispatch:\r\njobs: {}\r\n', ], @@ -257,10 +299,34 @@ def test_unscheduled_transform_fails_rather_than_guessing(tmp_path, body): def test_unscheduled_transform_handles_awkward_yaml(tmp_path, body): src = tmp_path / "spawn_drift.yml" src.write_text(body) - spec = yaml.safe_load(spawn.unscheduled_workflow_body(src)) + out = spawn.unscheduled_workflow_body(src) + spec = yaml.safe_load(out) # must not raise — invalid YAML is the failure triggers = spec[True] if True in spec else spec["on"] assert "schedule" not in triggers assert "workflow_dispatch" in triggers + assert "- cron" not in out, "orphaned cron entry left behind" + + +def test_unscheduled_transform_keeps_a_workflow_call_schedule_input(tmp_path): + """`on.workflow_call.inputs.schedule` is an input, not a trigger. + + Depth matters, not merely "somewhere under `on:`" — only a DIRECT child of + the top-level `on:` mapping is a trigger. + """ + src = tmp_path / "spawn_drift.yml" + src.write_text( + 'name: x\non:\n schedule:\n - cron: "0 6 * * *"\n' + " workflow_call:\n inputs:\n schedule:\n type: string\njobs: {}\n" + ) + + out = spawn.unscheduled_workflow_body(src) + + spec = yaml.safe_load(out) + triggers = spec[True] if True in spec else spec["on"] + assert "schedule" not in triggers, "the trigger should be gone" + assert triggers["workflow_call"]["inputs"]["schedule"]["type"] == "string", ( + "the workflow_call input was deleted along with the trigger" + ) def test_unscheduled_transform_preserves_everything_else(tmp_path):