diff --git a/README.md b/README.md index 60ad723..274375e 100644 --- a/README.md +++ b/README.md @@ -100,3 +100,6 @@ repository suffix; membership and order come from Brain’s board registry. Use the [local setup guide](docs/setup.md) to preview and create empty user-owned data, resume interrupted setup and validate a local board/feed. Publication and Scientist connection follow as explicit steps. + +Prepare a [standalone publication bundle](docs/publication.md) for an independent +Pages repository, then validate its real feed through Scientist before connecting. diff --git a/broca/__main__.py b/broca/__main__.py index dc1f93f..b81a1ec 100644 --- a/broca/__main__.py +++ b/broca/__main__.py @@ -19,6 +19,14 @@ def main(argv=None): setup.add_argument("--pages-url", required=True) setup.add_argument("--brain", type=Path, required=True) setup.add_argument("--apply", action="store_true") + publication = sub.add_parser("publication", help="prepare a static Pages bundle") + publication.add_argument("--repos", type=Path, required=True) + publication.add_argument("--repo", required=True) + publication.add_argument("--pages-url", required=True) + publication.add_argument("--brain", type=Path, required=True) + publication.add_argument("--output", type=Path, required=True) + publication.add_argument("--branch", default="main") + publication.add_argument("--apply", action="store_true") add = sub.add_parser("ingest") add.add_argument("record", type=Path) collect = sub.add_parser("refresh") @@ -41,6 +49,16 @@ def main(argv=None): print(("Created: " if args.apply else "Would create: ") + (", ".join(missing) or "nothing")) print("Local setup only; publication and Scientist connection remain separate.") return 0 + if args.command == "publication": + from .publication import prepare, apply + prepared = prepare(args.root, args.repos, args.repo, args.pages_url, + args.brain, args.output, args.branch) + if args.apply: + apply(prepared) + print(("Created: " if args.apply else "Would create: ") + str(prepared[0])) + print("\n".join(prepared[1])) + print("Review rendered content before publishing; no remote changes made.") + return 0 names = config(args.root)["assistants"] if args.command == "check": records = load_records(args.root, names) @@ -63,7 +81,7 @@ def main(argv=None): raise ValueError("HTML and state output paths must differ") from .state import build state = build(args.root, args.brain, args.repository, args.pages_url) - page = render(args.root, args.brain) + page = render(args.root, args.brain, repository=args.repository or "PyAutoLabs/PyAutoBroca") args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(page) if state is not None: diff --git a/broca/board.py b/broca/board.py index bc14c6f..216cd5e 100644 --- a/broca/board.py +++ b/broca/board.py @@ -41,7 +41,9 @@ def latest_benchmark(runs): key=lambda r: {"passed": 0, "unscored": 1, "failed": 2}[r["status"]], default=None) -def render(root, brain): +def render(root, brain, repository="PyAutoLabs/PyAutoBroca"): + if not isinstance(repository, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("invalid board repository identity") theme = theme_from(brain) cfg = config(root) board_base = cfg.get("board_base_url") @@ -119,7 +121,7 @@ def render(root, brain): {"href": "#history", "label": "Evaluations", "count": len(history)}, {"href": "#maintenance", "label": "Maintenance"}] panel = theme.orchestration_panel("broca-checkin", "Check in with Broca", "", prompt, - organ="broca", work_links=[{"label": "PyAutoBroca", "href": "https://github.com/PyAutoLabs/PyAutoBroca"}], + organ="broca", work_links=[{"label": repository.split("/")[-1], "href": "https://github.com/" + repository}], refreshed_at=snapshot.get("refreshed_at"), refresh_url=None) table = ('
' '' diff --git a/broca/publication.py b/broca/publication.py new file mode 100644 index 0000000..872b01f --- /dev/null +++ b/broca/publication.py @@ -0,0 +1,100 @@ +"""Prepare a static, reviewable Pages bundle without publishing anything.""" +import json +from pathlib import Path +import re + +import yaml + +from .board import render +from .collect import check_receipts, config, load_snapshot +from .records import load_records +from .setup import plain_path +from .state import build, pages_directory, shared_contract + + +def workflow(repository, pages, branch): + # The shared contract was checked during rendering. At publication, check + # artifact membership and identity again without installing the framework. + check = f'''python - <<'PYTHON' +import json, os +from pathlib import Path +site = Path("site") +if site.is_symlink() or not site.is_dir(): + raise SystemExit("site must be a real directory") +if sorted(p.name for p in site.iterdir()) != ["index.html", "state.json"]: + raise SystemExit("site must contain only index.html and state.json") +if any(p.is_symlink() or not p.is_file() for p in site.iterdir()): + raise SystemExit("site entries must be regular files") +state = json.loads((site / "state.json").read_text()) +if os.environ["GITHUB_REPOSITORY"] != {repository!r}: + raise SystemExit("publishing repository differs from configured identity") +if (state.get("organ"), state.get("repo"), state.get("pages_url")) != ( + "broca", {repository.split('/')[-1]!r}, {pages!r}): + raise SystemExit("feed identity differs from configured publication") +PYTHON''' + return yaml.safe_dump({ + "name": "Publish Broca snapshot", + "on": {"push": {"branches": [branch]}, "pull_request": None, "workflow_dispatch": None}, + "permissions": {"contents": "read"}, + "concurrency": {"group": "pages-${{ github.event_name == 'pull_request' && github.ref || 'production' }}", + "cancel-in-progress": False}, + "jobs": { + "build": {"runs-on": "ubuntu-latest", "steps": [ + {"uses": "actions/checkout@v4"}, + {"uses": "actions/setup-python@v5", "with": {"python-version": "3.12"}}, + {"name": "Validate publication files and identity", "run": check}, + {"uses": "actions/upload-pages-artifact@v3", "with": {"path": "site"}}]}, + "deploy": { + "if": f"github.ref == 'refs/heads/{branch}' && github.event_name != 'pull_request'", + "needs": "build", "runs-on": "ubuntu-latest", + "permissions": {"pages": "write", "id-token": "write"}, + "environment": {"name": "github-pages", "url": "${{ steps.deployment.outputs.page_url }}"}, + "steps": [{"name": "Deploy reviewed snapshot", "id": "deployment", + "uses": "actions/deploy-pages@v4"}]}}}, sort_keys=False) + + +def prepare(root, repos, repo, pages_url, brain, output, branch="main"): + root = Path(root).resolve() + output = plain_path(output) + if output.exists() or not output.parent.is_dir(): + raise ValueError("publication destination must be new, with an existing parent") + if output == root or root in output.parents: + raise ValueError("publication destination must be outside the private data directory") + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", branch): + raise ValueError("publication branch must contain only letters, digits, hyphens or underscores") + try: + catalogue = yaml.safe_load(Path(repos).read_text()) + except yaml.YAMLError as exc: + raise ValueError("invalid canonical repository map") from exc + entries = catalogue.get("repos") if isinstance(catalogue, dict) else None + row = entries.get(repo) if isinstance(entries, dict) else None + repository = row.get("github") if isinstance(row, dict) else None + if not isinstance(repository, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("repo must identify a canonical owner/repository in the supplied map") + pages = pages_directory(pages_url) + cfg = config(root) + check_receipts(root) + load_snapshot(root) + load_records(root, cfg["assistants"]) + state = build(root, brain, repository, pages) + errors = shared_contract(brain).validate_state(state) + if errors: + raise ValueError(f"invalid publication feed: {errors}") + page = render(root, brain, repository=repository) + return output, { + "site/index.html": page, + "site/state.json": json.dumps(state, indent=2) + "\n", + ".github/workflows/pages.yml": workflow(repository, pages, branch), + } + + +def apply(prepared): + output, files = prepared + plain_path(output) + output.mkdir() # exclusive: never overwrite an existing publication checkout + for name, content in files.items(): + path = output / name + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("x") as stream: + stream.write(content) + return output diff --git a/docs/publication.md b/docs/publication.md new file mode 100644 index 0000000..5dc70e4 --- /dev/null +++ b/docs/publication.md @@ -0,0 +1,76 @@ +# Prepare an independent Pages publication + +Start from [local setup](setup.md). The Broca data directory stays local; a new +publication directory holds a static snapshot and a minimal deployment workflow. +No framework or private setup files are copied into that directory. + +From the Broca framework checkout, preview: + +```sh +python -m broca --root /path/to/my-guides publication \ + --repos /path/to/Mind/repos.yaml --repo my-guides \ + --pages-url https://research.example/guides/ --brain /path/to/PyAutoBrain \ + --output /path/to/new-publication --branch main +``` + +The command validates saved owner evidence, renders the board and validates its +shared feed. It lists the destination and files without writing. Repeat with +`--apply` to create exactly: + +- `site/index.html` +- `site/state.json` +- `.github/workflows/pages.yml` + +The destination must be new, outside the data directory, with an existing parent. +Symlink destinations and existing directories are refused. The supported default +branch names contain letters, digits, hyphens or underscores; `main` is the default. +If writing is interrupted, inspect the partial bundle and generate into another +new directory. Existing publication checkouts are never overwritten by this command. + +The feed and board work link use the canonical repository identity selected from +your Mind map. The Pages location is explicit; supplying it does not prove ownership +or configure a custom domain. Empty evidence remains unknown. Rendering does not +collect new evidence or advance its observation timestamps. + +## Review, then deploy + +Inspect both HTML and JSON before publishing. The file allowlist excludes local +setup files, paths and raw source records as files; it is not a privacy scanner. +Rendered content can include saved prompts, record descriptions, resource links +and other owner-supplied text. Review its visibility and intended audience. + +Choose the destination repository matching the canonical identity. With explicit +publication approval, copy the reviewed bundle into that repository, commit it on +its chosen default branch and enable GitHub Pages using Actions. Resolve any +existing files or workflows manually. Custom-domain configuration and repository +creation are separate operator steps; this command performs no network or git +operations and changes no access settings. + +The generated workflow checks that `site/` contains exactly two regular files, +checks the feed identity against the configured destination and current repository, +and uploads only `site/`. It runs validation on pull requests, and deploys only on +the selected branch for a push or manual dispatch. A dispatch on another branch +cannot deploy. The workflow reads only its own publication checkout: no Brain or +Broca installation, collection, evaluation or schedule is involved. + +For updates, render another bundle from current saved evidence, review its diff, +and replace the two site files through the publication repository's normal review +process. Repeated rendering does not make old evidence fresh. Configuration or +branch changes also require reviewing the generated workflow diff. + +## Connect and resume + +After successful deployment, inspect the real HTTPS `state.json` under the configured +Pages directory. Use Scientist's existing `connect_organ.py` preview/apply with +`--organ broca`, your canonical map/key and that Pages directory. The independent +Scientist becomes connected only after its validator accepts the real feed. + +For a fresh conversation, provide your canonical map, local Broca data directory, +publication repository and existing Mind task. Ask the assistant to inspect those +before rendering or proposing an update. The private setup handoff remains local; +do not upload it to help deployment. Missing or stale evidence after connection +remains evidence trouble for the connected organ. + +Local bundle validation is not proof of a live deployment. No live independent +endpoint, pause control or full activation workflow is established by generating +this snapshot. diff --git a/docs/setup.md b/docs/setup.md index b30e676..7d6054a 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -55,8 +55,9 @@ Project source and research records stay in their existing homes. ## Connect it to Scientist -Publish only the reviewed HTML and feed from `site/` through a separately approved -publication task. Local setup neither checks remote ownership nor creates a +Use the [publication bundle guide](publication.md) to prepare reviewed HTML, feed +and a minimal Pages workflow for an independent destination. Publish through a +separately approved publication task. Local setup neither checks remote ownership nor creates a repository, publishes content, schedules upkeep or evaluates assistants. When the real HTTPS feed is available, use Scientist's existing diff --git a/tests/test_publication.py b/tests/test_publication.py new file mode 100644 index 0000000..50871d7 --- /dev/null +++ b/tests/test_publication.py @@ -0,0 +1,130 @@ +"""Exercise the emitted bundle and the workflow's actual artifact guard.""" +import json +import os +from pathlib import Path +import subprocess + +import pytest +import yaml + +from broca.__main__ import main +from broca.publication import apply, prepare +from broca.state import shared_contract, UNKNOWN_TIME + +BRAIN = Path(__file__).resolve().parents[2] / "PyAutoBrain" + + +@pytest.fixture +def instance(tmp_path): + root = tmp_path / 'data' + repos = tmp_path / 'repos.yaml' + repos.write_text('repos:\n guides:\n github: Research/IndependentGuides\n') + assert main(['--root', str(root), 'setup', '--repos', str(repos), '--repo', 'guides', + '--pages-url', 'https://research.example/guides/', '--brain', str(BRAIN), '--apply']) == 0 + (root / 'private.txt').write_text('PRIVATE-MARKER') + return root, repos, tmp_path / 'publication' + + +def command(instance): + root, repos, output = instance + return ['--root', str(root), 'publication', '--repos', str(repos), '--repo', 'guides', + '--pages-url', 'https://research.example/guides/', '--brain', str(BRAIN), + '--output', str(output), '--branch', 'trunk'] + + +def snapshot(root): + return {str(p.relative_to(root)): p.read_bytes() for p in root.rglob('*') if p.is_file()} + + +def run_guard(output, repository='Research/IndependentGuides'): + workflow = yaml.safe_load((output / '.github/workflows/pages.yml').read_text()) + step = next(s for s in workflow['jobs']['build']['steps'] if 'run' in s) + return subprocess.run(['bash', '-c', step['run']], cwd=output, + env={**os.environ, 'GITHUB_REPOSITORY': repository}, capture_output=True, text=True) + + +def test_bundle_preserves_source_and_uses_independent_identity(instance): + root, _, output = instance + before = snapshot(root) + assert main(command(instance)) == 0 + assert not output.exists() + assert main(command(instance) + ['--apply']) == 0 + assert snapshot(root) == before + files = snapshot(output) + assert set(files) == {'site/index.html', 'site/state.json', '.github/workflows/pages.yml'} + assert all(b'PRIVATE-MARKER' not in b and str(root).encode() not in b for b in files.values()) + page = files['site/index.html'].decode() + assert 'https://github.com/Research/IndependentGuides' in page + assert 'https://github.com/PyAutoLabs/PyAutoBroca' not in page + state = json.loads(files['site/state.json']) + assert shared_contract(BRAIN).validate_state(state) == [] + assert state['repo'] == 'IndependentGuides' + assert state['updated'] == UNKNOWN_TIME and state['status'] == 'grey' + assert run_guard(output).returncode == 0 + assert run_guard(output, 'Research/Wrong').returncode != 0 + assert main(command(instance) + ['--apply']) == 2 + assert snapshot(output) == files + + +def test_workflow_has_narrow_upload_and_deployment_guard(instance): + assert main(command(instance) + ['--apply']) == 0 + output = instance[2] + wf = yaml.safe_load((output / '.github/workflows/pages.yml').read_text()) + assert wf['on']['push']['branches'] == ['trunk'] + assert set(wf['on']) == {'push', 'pull_request', 'workflow_dispatch'} + assert wf['permissions'] == {'contents': 'read'} + build = wf['jobs']['build'] + assert build['steps'][-1]['with']['path'] == 'site' + assert build['steps'][0] == {'uses': 'actions/checkout@v4'} + deploy = wf['jobs']['deploy'] + assert deploy['needs'] == 'build' + assert deploy['if'] == "github.ref == 'refs/heads/trunk' && github.event_name != 'pull_request'" + assert deploy['permissions'] == {'pages': 'write', 'id-token': 'write'} + + +@pytest.mark.parametrize('tamper', ['extra', 'symlink', 'directory', 'identity', 'missing']) +def test_generated_guard_rejects_bad_artifacts(instance, tamper): + assert main(command(instance) + ['--apply']) == 0 + output = instance[2]; site = output / 'site' + if tamper == 'extra': + (site / 'private.txt').write_text('private') + elif tamper in ('symlink', 'directory', 'missing'): + (site / 'index.html').unlink() + if tamper == 'symlink': + (site / 'index.html').symlink_to(instance[0] / 'private.txt') + elif tamper == 'directory': + (site / 'index.html').mkdir() + else: + state = json.loads((site / 'state.json').read_text()); state['repo'] = 'Wrong' + (site / 'state.json').write_text(json.dumps(state)) + assert run_guard(output).returncode != 0 + + +@pytest.mark.parametrize('bad', ['repo', 'url', 'branch', 'evidence', 'symlink', 'inside', 'map']) +def test_invalid_inputs_write_nothing(instance, bad, tmp_path): + root, repos, output = instance; argv = command(instance) + if bad in ('repo', 'url', 'branch'): + flag, value = {'repo': ('--repo', 'missing'), 'url': ('--pages-url', 'http://bad'), + 'branch': ('--branch', "main' || true")}[bad] + argv[argv.index(flag)+1] = value + elif bad == 'evidence': + (root / 'latest.json').write_text('{"receipt":"invalid"}') + elif bad == 'symlink': + output.symlink_to(tmp_path / 'missing') + elif bad == 'inside': + argv[argv.index('--output')+1] = str(root / 'publication') + else: + repos.write_text('repos: []') + before = snapshot(root) + assert main(argv + ['--apply']) == 2 + assert snapshot(root) == before + assert not output.exists() + + +def test_racing_destination_is_preserved(instance): + root, repos, output = instance + prepared = prepare(root, repos, 'guides', 'https://research.example/guides/', BRAIN, output) + output.mkdir(); (output / 'keep').write_text('existing') + with pytest.raises(FileExistsError): + apply(prepared) + assert snapshot(output) == {'keep': b'existing'}
Assistant / benchmarkDate / modelOutcome / scoreEvidence / comparisonRecord