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)