diff --git a/.github/time/seed.py b/.github/time/seed.py new file mode 100644 index 0000000..17be1a4 --- /dev/null +++ b/.github/time/seed.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Seed a venture on demand: land a site built elsewhere on gh-pages at ventures//, which Teahub pages serves at +.yolopreneur.lol, and keep the venture's line in the ledger (ventures/ventures.json). + +Run by .github/workflows/seed.yml (Actions -> seed -> Run workflow). Its inputs arrive in the environment: + + SLUG the folder and the subdomain + BUNDLE one or more https URLs, space-separated. Downloaded in order and joined, they make one .tgz whose + top-level folder is / (a big bundle can come as parts, e.g. Boring blob download links) + SHA sha256 of the joined .tgz: nothing lands unless it matches + NAME, ONE_LINER the ledger line; a venture already in the ledger keeps whatever is left empty + +Nothing in the bundle runs. It is checked (paths, file types, size, an index.html) and copied over ventures//, +replacing what was there, in one commit pushed to gh-pages by the Actions identity. + + python3 seed.py PAGES_DIR # PAGES_DIR: a checkout of gh-pages +""" +import datetime as dt +import hashlib +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import urllib.request +from pathlib import Path + +MAX_BYTES = 64 * 1024 * 1024 +SLUG = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,40}[a-z0-9])?") +RESERVED = {"index.html", "ventures.json", "backlog.json", "shots", "assets", "www"} +SITE = "https://{slug}.yolopreneur.lol/" + + +class SeedError(Exception): + pass + + +def fetch(urls): + """the bundle: every URL in order, joined""" + data = bytearray() + for u in urls: + if not u.startswith("https://"): + raise SeedError(f"not an https URL: {u[:60]}") + with urllib.request.urlopen(u, timeout=120) as r: + data += r.read(MAX_BYTES + 1 - len(data)) + if len(data) > MAX_BYTES: + raise SeedError(f"bundle is over {MAX_BYTES // 2**20} MB") + return bytes(data) + + +def unpack(data, slug, into): + """write the bundle's / tree under `into`, refusing anything that isn't a plain file or folder inside it""" + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf: + members = tf.getmembers() + for m in members: + parts = Path(m.name).parts + if m.name.startswith("/") or ".." in parts: + raise SeedError(f"unsafe path in bundle: {m.name}") + if not (m.isfile() or m.isdir()): + raise SeedError(f"not a plain file or folder: {m.name}") + if not parts or parts[0] != slug: + raise SeedError(f"everything in the bundle must sit under {slug}/: {m.name}") + for m in members: + target = into / m.name + if m.isdir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(tf.extractfile(m).read()) + if not (into / slug / "index.html").is_file(): + raise SeedError(f"the bundle has no {slug}/index.html") + + +def ledger_line(data, slug, name, one_liner, today): + """the venture's line in the ledger, added (at the top) or refreshed; raises before anything is written""" + vs = data.setdefault("ventures", []) + v = next((v for v in vs if v.get("slug") == slug), None) + if v is None: + if not name or not one_liner: + raise SeedError("a new venture needs a name and a one-liner for the ledger") + v = {"slug": slug, "name": name, "one_liner": one_liner, "url": SITE.format(slug=slug), + "pages": SITE.format(slug=slug), "launched": today, "signups": 0, "source": "yolopreneur"} + vs.insert(0, v) + else: + if name: + v["name"] = name + if one_liner: + v["one_liner"] = one_liner + return v + + +def land(pages, slug, data, want, name="", one_liner="", today=None): + """check the bundle and put it at ventures// in the gh-pages checkout `pages`; returns the bundle's sha256. + Every check runs before anything is written, so a refused bundle leaves the checkout as it was.""" + if not SLUG.fullmatch(slug or "") or slug in RESERVED: + raise SeedError(f"bad slug: {slug!r}") + if not re.fullmatch(r"[0-9a-f]{64}", (want or "").lower()): + raise SeedError("sha256 must be 64 hex characters") + got = hashlib.sha256(data).hexdigest() + if got != want.lower(): + raise SeedError(f"sha256 mismatch: the bundle is {got}") + path = pages / "ventures" / "ventures.json" + book = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {"ventures": []} + line = ledger_line(book, slug, name, one_liner, today or dt.date.today().isoformat()) + with tempfile.TemporaryDirectory() as tmp: + unpack(data, slug, Path(tmp)) + dest = pages / "ventures" / slug + shutil.rmtree(dest, ignore_errors=True) + shutil.copytree(Path(tmp) / slug, dest) + if (dest / "shots" / "desktop.png").is_file(): + line["shot"] = f"{slug}/shots/desktop.png" # the card on the yolopreneur.lol index + path.write_text(json.dumps(book, indent=1), encoding="utf-8") # the ledger's own layout: indent 1, ASCII + return got + + +def commit(pages, slug, sha): + git = ["git", "-C", str(pages), "-c", "user.name=github-actions[bot]", + "-c", "user.email=github-actions[bot]@users.noreply.teahub.xyz"] + subprocess.run(git + ["add", "--all", f"ventures/{slug}", "ventures/ventures.json"], check=True) + if subprocess.run(git + ["diff", "--cached", "--quiet"]).returncode == 0: + print("nothing changed: ventures/" + slug + " is already this bundle") + return False + subprocess.run(git + ["commit", "-q", "-m", f"ventures: seed {slug} (bundle {sha[:12]})"], check=True) + if subprocess.run(git + ["push", "-q", "origin", "HEAD:gh-pages"]).returncode != 0: + # gh-pages moved under us (the venture loop writes there too): replay this one commit on top, push again + subprocess.run(git + ["pull", "-q", "--rebase", "origin", "gh-pages"], check=True) + subprocess.run(git + ["push", "-q", "origin", "HEAD:gh-pages"], check=True) + return True + + +def main(): + pages = Path(sys.argv[1] if len(sys.argv) > 1 else ".") + slug = os.environ.get("SLUG", "").strip() + try: + data = fetch(os.environ.get("BUNDLE", "").split()) + sha = land(pages, slug, data, os.environ.get("SHA", "").strip(), os.environ.get("NAME", "").strip(), + os.environ.get("ONE_LINER", "").strip()) + except (SeedError, OSError, tarfile.TarError) as e: + print(f"::error::seed {slug}: {e}") + sys.exit(1) + pushed = commit(pages, slug, sha) + print(f"{'landed' if pushed else 'unchanged'}: ventures/{slug}/ ({len(data)} bytes, sha256 {sha[:12]}) -> " + + SITE.format(slug=slug)) + + +if __name__ == "__main__": + main() diff --git a/.github/time/test_seed.py b/.github/time/test_seed.py new file mode 100644 index 0000000..6f680b3 --- /dev/null +++ b/.github/time/test_seed.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Tests for seed.py: a bundle lands only when it is what it says it is, and the ledger keeps its layout.""" +import hashlib +import importlib.util +import io +import json +import sys +import tarfile +import tempfile +from pathlib import Path + +spec = importlib.util.spec_from_file_location("seed", Path(__file__).with_name("seed.py")) +seed = importlib.util.module_from_spec(spec) +spec.loader.exec_module(seed) + +LEDGER = {"ventures": [{"slug": "mdecks", "name": ".mdeck", "one_liner": "Slides in plain text — fast.", + "launched": "2026-10-12", "signups": 0}], "refreshed": "2026-10-14"} + + +def tgz(files, links=()): + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + for name, body in files.items(): + ti = tarfile.TarInfo(name) + ti.size = len(body) + tf.addfile(ti, io.BytesIO(body)) + for name, target in links: + ti = tarfile.TarInfo(name) + ti.type, ti.linkname = tarfile.SYMTYPE, target + tf.addfile(ti) + return buf.getvalue() + + +def pages_dir(): + d = Path(tempfile.mkdtemp()) + (d / "ventures").mkdir() + (d / "ventures" / "ventures.json").write_text(json.dumps(LEDGER, indent=1), encoding="utf-8") + return d + + +def refused(fn, *a, **kw): + try: + fn(*a, **kw) + except seed.SeedError as e: + return str(e) + raise AssertionError("expected SeedError") + + +def sha(b): + return hashlib.sha256(b).hexdigest() + + +def test_lands_and_lists(): + p = pages_dir() + b = tgz({"hh/index.html": b"hi", "hh/plates/a.svg": b"", "hh/shots/desktop.png": b"png"}) + assert seed.land(p, "hh", b, sha(b), "House Hand", "A drawing hand.", today="2026-10-15") == sha(b) + assert (p / "ventures/hh/plates/a.svg").read_bytes() == b"" + text = (p / "ventures/ventures.json").read_text() + assert text == json.dumps(json.loads(text), indent=1) # same layout as the file it came from + assert "\\u2014" in text # ASCII escapes kept, like the original + first = json.loads(text)["ventures"][0] + assert first["slug"] == "hh" and first["launched"] == "2026-10-15" and first["shot"] == "hh/shots/desktop.png" + assert first["url"] == "https://hh.yolopreneur.lol/" and json.loads(text)["refreshed"] == "2026-10-14" + + +def test_replaces_and_keeps_the_ledger_line(): + p = pages_dir() + b1 = tgz({"hh/index.html": b"one", "hh/old.txt": b"stale"}) + seed.land(p, "hh", b1, sha(b1), "House Hand", "First line.", today="2026-10-15") + b2 = tgz({"hh/index.html": b"two"}) + seed.land(p, "hh", b2, sha(b2), today="2026-10-20") # no name or line given: keep them + assert not (p / "ventures/hh/old.txt").exists() and (p / "ventures/hh/index.html").read_bytes() == b"two" + vs = [v for v in json.loads((p / "ventures/ventures.json").read_text())["ventures"] if v["slug"] == "hh"] + assert len(vs) == 1 and vs[0]["one_liner"] == "First line." and vs[0]["launched"] == "2026-10-15" + + +def test_refuses(): + p = pages_dir() + good = tgz({"hh/index.html": b"x"}) + assert "mismatch" in refused(seed.land, p, "hh", good, "0" * 64, "n", "o") + assert "bad slug" in refused(seed.land, p, "../etc", good, sha(good), "n", "o") + assert "bad slug" in refused(seed.land, p, "ventures.json", good, sha(good), "n", "o") + for bad, why in ((tgz({"hh/../../x": b"x", "hh/index.html": b"x"}), "unsafe"), + (tgz({"hh/index.html": b"x"}, links=[("hh/link", "/etc/passwd")]), "plain file"), + (tgz({"other/index.html": b"x"}), "under hh/"), + (tgz({"hh/readme.txt": b"x"}), "no hh/index.html")): + assert why in refused(seed.land, p, "hh", bad, sha(bad), "n", "o"), why + assert "needs a name" in refused(seed.land, p, "hh", good, sha(good)) + assert not (p / "ventures/hh").exists() # nothing half-landed + assert "https" in refused(seed.fetch, ["http://example.com/x.tgz"]) + + +if __name__ == "__main__": + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for t in tests: + t() + print("ok", t.__name__) + print(f"{len(tests)} seed tests passed") + sys.exit(0) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e4cc7a..73f735d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,3 +18,4 @@ jobs: with: node-version: 22 - run: python3 .github/time/test_time.py + - run: python3 .github/time/test_seed.py diff --git a/.github/workflows/seed.yml b/.github/workflows/seed.yml new file mode 100644 index 0000000..00f2709 --- /dev/null +++ b/.github/workflows/seed.yml @@ -0,0 +1,59 @@ +name: seed +# Seed a venture on demand: land a site that was built somewhere else (by hand, on the Mac, by another agent) on +# yolopreneur.lol. Actions -> seed -> Run workflow, with the inputs below. +# The bundle is a .tgz whose top-level folder is the slug. .github/time/seed.py checks it against its sha256, copies +# it over ventures// on gh-pages in one commit, adds or refreshes the venture's line in ventures/ventures.json, +# and the last step asks Teahub pages to pull, so .yolopreneur.lol serves it within the minute. +# Nothing in the bundle runs: it is checked (plain files and folders under /, an index.html) and copied. +on: + workflow_dispatch: + inputs: + slug: + description: "Folder and subdomain: ventures// is served at .yolopreneur.lol" + required: true + type: string + bundle: + description: "https URL of the .tgz, or of its parts, space-separated and in order" + required: true + type: string + sha256: + description: "sha256 of the whole .tgz" + required: true + type: string + name: + description: "Name in the ledger (needed for a new venture)" + required: false + type: string + one_liner: + description: "One line in the ledger (needed for a new venture)" + required: false + type: string +permissions: + contents: write +concurrency: + group: venture # the venture loop writes gh-pages too: take turns + cancel-in-progress: false +jobs: + seed: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + with: + sparse-checkout: .github/time + path: engine + persist-credentials: false + - uses: actions/checkout@v5 + with: + ref: gh-pages + path: pages + fetch-depth: 1 + - run: python3 engine/.github/time/seed.py pages + env: # inputs reach the script through the environment, never interpolated into it + SLUG: ${{ inputs.slug }} + BUNDLE: ${{ inputs.bundle }} + SHA: ${{ inputs.sha256 }} + NAME: ${{ inputs.name }} + ONE_LINER: ${{ inputs.one_liner }} + - name: tell Teahub pages to pull + run: curl -fsS -X POST "https://yolopreneur.lol/_hook/${GITHUB_REPOSITORY}" || echo "pages pulls by itself within five minutes" diff --git a/README.md b/README.md index d19cb2d..317860f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A Gitea-native agent factory, running on Teahub prod (teahub.xyz). The Gitea rep - **Hands-off** (`TIME_AUTOMERGE=on`): label an issue `spec` and Time splits it into cards that run one at a time. A card's PR merges itself once the reviewer approves and checks are green, then the next card starts. Objections, red checks and protected paths (workflows, deploy, secrets) go to a human. - **Venture loop** (`venture.yml`, every 20 min once `TIME_VENTURE=on`): one venture card a day, or one right away from Actions → venture → Run workflow → "Start a new venture now". It scouts the repos that gained the most stars this week; seats pitch; the board votes (Borda, quorum of two); every board seat writes a launch plan (positioning, first users, pricing, channels, 7-day success and kill lines) and one seat synthesizes them into `LAUNCH.md`; the worker builds the product as the page on `gh-pages` (the tool fills the first screen under a slim header with the one signup button; pricing, limits and the why sit below); deterministic checks and headless Chrome (phone and desktop) gate it, with two fix rounds before an andon; GitHub Pages ships it at `/ventures//`. Signups go through an issue form (`.github/ISSUE_TEMPLATE/early-access.yml`), each one thanked on its issue; after 7 days the count of distinct people decides: build v2, kill, or watch a week more. The studio index lives at `/ventures/`. Launch posts are drafts for a human; nothing is posted outside the repo. - **Batches** (`TIME_VENTURE_BATCH=3`, or Run workflow → "batch"): one scout pass, a field of pitches, and the board's top k become venture cards in one milestone, built side by side and launched together. A held child gets one batch retry before it's counted dead; a retro compares the cohort once every child is measured. +- **Seed** (`seed.yml`, manual): land a venture that was built somewhere else. Give it a slug, the URL of a .tgz whose top folder is that slug (or of its parts, in order) and the .tgz's sha256, plus a name and a one-liner for a new venture. `seed.py` checks the bundle, copies it to `ventures//` on `gh-pages` in one commit, adds or refreshes its line in `ventures/ventures.json`, and asks Teahub pages to pull, so it is live at `.yolopreneur.lol` within the minute. Nothing in the bundle runs. - **Backends** (optional, `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`): each venture also gets its own Cloudflare Worker, D1 database and Workers AI, serving its Pages folder at `.`. The page talks to it through `time.js` (share links that save a creation, a rate-limited small model, and accounts with passkeys: no passwords, no email); every call degrades gracefully, so products still work on Pages alone. Code: `.github/time/cloudflare.py` and `cloudflare/worker.js`. - **Doctor** (`doctor.yml`, manual): runs the tests and checks label, settings, keys and today's spend. - **Update** (`update.yml`, weekly or manual): opens a PR with the latest engine from teahubxyz/time, so every project stays on one Time instead of drifting copies.