seed: land a venture built elsewhere on yolopreneur.lol (#64)
ci / test (push) Successful in 35s
time/worker-run git fetch origin gh-pages 2>&1|tail -1; git show FETCH_HEAD:ventures/dollhouse/app.js | sed -n 140,3

seed: land a venture built elsewhere on yolopreneur.lol (#64)

Actions -> seed -> Run workflow with a slug, the URL(s) of a .tgz whose top folder is the slug, and its sha256
(plus a name and a one-liner for a new venture). seed.py checks the bundle, copies it over ventures/<slug>/ on
gh-pages in one commit, adds or refreshes the venture's line in ventures/ventures.json, and the workflow asks
Teahub pages to pull. Nothing in the bundle runs. test_seed.py runs in CI. Merged at the owner's request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9wtGiGEeq1H5DA3T88PRe
This commit was merged in pull request #64.
This commit is contained in:
teahubxyzandClaude Opus 5.5 committed 2026-10-15 23:56:46 +00:00
1 parent 08bba21dfa
commit 721212ba7e
5 files changed
+311

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Seed a venture on demand: land a site built elsewhere on gh-pages at ventures/<slug>/, which Teahub pages serves at
<slug>.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 <slug>/ (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/<slug>/,
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 <slug>/ 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/<slug>/ 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()
+99
View File
@@ -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"<!doctype html>hi", "hh/plates/a.svg": b"<svg/>", "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"<svg/>"
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)
+1
View File
@@ -18,3 +18,4 @@ jobs:
with:
node-version: 22
- run: python3 .github/time/test_time.py
- run: python3 .github/time/test_seed.py
+59
View File
@@ -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/<slug>/ 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 <slug>.yolopreneur.lol serves it within the minute.
# Nothing in the bundle runs: it is checked (plain files and folders under <slug>/, an index.html) and copied.
on:
workflow_dispatch:
inputs:
slug:
description: "Folder and subdomain: ventures/<slug>/ is served at <slug>.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"
+1
View File
@@ -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/<slug>/`. 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/<slug>/` 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 `<slug>.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 `<slug>.<TIME_VENTURE_DOMAIN>`. 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.