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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
20 changes: 19 additions & 1 deletion broca/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions broca/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 = ('<div class="table-scroll" role="region" aria-label="Evaluation history; scroll for more columns" tabindex="0">'
'<table><thead><tr><th>Assistant / benchmark</th><th>Date / model</th><th>Outcome / score</th><th>Evidence / comparison</th><th>Record</th></tr></thead><tbody>'
Expand Down
100 changes: 100 additions & 0 deletions broca/publication.py
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions docs/publication.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
130 changes: 130 additions & 0 deletions tests/test_publication.py
Original file line number Diff line number Diff line change
@@ -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'}
Loading