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 d712213a..04c91061 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,38 @@ 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):** 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 0e2ce782..e6ae3fcd 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,104 @@ 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: 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 + 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("#") + + # 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.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: + # 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. Blanks and comments belong to it only when + # deeper content follows; otherwise they introduce the NEXT key. + while i < len(lines): + 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(lines[i]) - len(lines[i].lstrip())) <= indent_here: + 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 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" + + def autonomy_log_body(src): lines = src.read_text(errors="replace").splitlines() kept = [] @@ -452,6 +576,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..230cf91d 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,274 @@ 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) + + +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. + + 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 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', + ], +) +def test_unscheduled_transform_handles_awkward_yaml(tmp_path, body): + src = tmp_path / "spawn_drift.yml" + src.write_text(body) + 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): + """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."""