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
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:
1 parent
08bba21dfa
commit
721212ba7e
5 files changed
+311
No files matched your search
@@ -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()
|
||||
@@ -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)
|
||||
@@ -18,3 +18,4 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
- run: python3 .github/time/test_time.py
|
||||
- run: python3 .github/time/test_seed.py
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user