Compare commits

...
Author SHA1 Message Date
teahubxyzandClaude Opus 5.5 68d0df6232 ship: build a venture from its own repo on Teahub and land it like seed
ci / test (pull_request) Successful in 35s
seed lands a bundle built somewhere else, which meant building Havre on the Mac and carrying the bundle over.
ship.yml clones the venture's source repo (private is fine: the time bot's token clones it, for that step only),
runs its tools/build.sh on the runner (installing Chromium for the screenshots when the runner has no Chrome), and
ship.py lands the bundle the build left with seed's own checks and commit. Nothing is built or carried outside the
factory. test_ship.py runs in ci.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sekpYMwTXuMdza1isMQA1
2026-10-16 03:47:20 +00:00
5 changed files with 223 additions and 0 deletions

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Ship a venture built in this run: land the bundle its build left the way seed.py lands a downloaded one.
Run by .github/workflows/ship.yml once it has cloned the venture's source repo and run its tools/build.sh. The bundle
never travelled, so its own sha256 is the check; everything else is seed's (paths, file types, size, an index.html,
the ledger line, one commit to gh-pages). The environment:
SLUG the folder and the subdomain
BUNDLE_FILE the .tgz the build left, with <slug>/ at the top
SOURCE what was built (owner/repo@commit), for the log
NAME, ONE_LINER the ledger line, as for seed
python3 ship.py PAGES_DIR # PAGES_DIR: a checkout of gh-pages
"""
import hashlib
import importlib.util
import os
import sys
import tarfile
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)
def read(path):
"""the bundle the build left, within seed's size limit"""
p = Path(path or "")
if not path or not p.is_file():
raise seed.SeedError(f"no bundle at {path!r}")
if p.stat().st_size > seed.MAX_BYTES:
raise seed.SeedError(f"bundle is over {seed.MAX_BYTES // 2**20} MB")
return p.read_bytes()
def main():
pages = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
slug = os.environ.get("SLUG", "").strip()
try:
if not seed.SLUG.fullmatch(slug):
raise seed.SeedError(f"bad slug: {slug!r}")
data = read(os.environ.get("BUNDLE_FILE", "").strip())
sha = seed.land(pages, slug, data, hashlib.sha256(data).hexdigest(), os.environ.get("NAME", "").strip(),
os.environ.get("ONE_LINER", "").strip())
except (seed.SeedError, OSError, tarfile.TarError) as e:
print(f"::error::ship {slug}: {e}")
sys.exit(1)
pushed = seed.commit(pages, slug, sha)
source = os.environ.get("SOURCE", "").strip() or "this run"
print(f"{'landed' if pushed else 'unchanged'}: ventures/{slug}/ built from {source} ({len(data)} bytes, "
f"sha256 {sha[:12]}) -> " + seed.SITE.format(slug=slug))
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Tests for ship.py: a bundle built in the run lands like a seeded one; a missing or oversized one never does."""
import hashlib
import importlib.util
import io
import json
import os
import subprocess
import sys
import tarfile
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location("ship", HERE / "ship.py")
ship = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ship)
def tgz(files):
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))
return buf.getvalue()
def refused(fn, *a):
try:
fn(*a)
except ship.seed.SeedError as e:
return str(e)
raise AssertionError("expected SeedError")
def test_lands_the_bundle_the_build_left():
d = Path(tempfile.mkdtemp())
b = tgz({"hv/index.html": b"<!doctype html>harbor", "hv/shots/desktop.png": b"png"})
(d / "hv.tgz").write_bytes(b)
assert ship.read(str(d / "hv.tgz")) == b
pages = d / "pages"
(pages / "ventures").mkdir(parents=True)
(pages / "ventures" / "ventures.json").write_text(json.dumps({"ventures": []}, indent=1), encoding="utf-8")
ship.seed.land(pages, "hv", b, hashlib.sha256(b).hexdigest(), "Havre", "A harbor.", today="2026-10-16")
assert (pages / "ventures/hv/index.html").read_bytes() == b"<!doctype html>harbor"
line = json.loads((pages / "ventures/ventures.json").read_text())["ventures"][0]
assert line["slug"] == "hv" and line["shot"] == "hv/shots/desktop.png"
def test_refuses_a_missing_or_oversized_bundle():
d = Path(tempfile.mkdtemp())
assert "no bundle" in refused(ship.read, str(d / "none.tgz"))
assert "no bundle" in refused(ship.read, "")
big = d / "big.tgz"
with open(big, "wb") as f:
f.truncate(ship.seed.MAX_BYTES + 1) # sparse: nothing that size is really written
assert "over" in refused(ship.read, str(big))
def test_a_bad_slug_stops_before_anything_is_read():
env = dict(os.environ, SLUG="../etc", BUNDLE_FILE="/etc/passwd")
r = subprocess.run([sys.executable, str(HERE / "ship.py"), tempfile.mkdtemp()], env=env, capture_output=True,
text=True)
assert r.returncode == 1 and "bad slug" in r.stdout, r.stdout
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)} ship tests passed")
sys.exit(0)
+1
View File
@@ -19,3 +19,4 @@ jobs:
node-version: 22
- run: python3 .github/time/test_time.py
- run: python3 .github/time/test_seed.py
- run: python3 .github/time/test_ship.py
+90
View File
@@ -0,0 +1,90 @@
name: ship
# Ship a venture whose source lives in its own repo on Teahub: clone it, build it here, and land the result the way
# seed does (ventures/<slug>/ on gh-pages, its line in ventures/ventures.json, served at <slug>.yolopreneur.lol).
# Actions -> ship -> Run workflow. Nothing is built or carried outside the factory.
# The source repo can be private: the `time` bot's token clones it, for that one step, and isn't kept. Its build is
# `tools/build.sh OUT`, which leaves OUT/<slug>.tgz with <slug>/ at the top; Chrome for its screenshots comes from
# CHROME (Playwright's Chromium is installed when the runner has none), with CHROME_FLAGS for running as root.
on:
workflow_dispatch:
inputs:
repo:
description: "The venture's source repo on Teahub, owner/repo"
required: true
type: string
slug:
description: "Folder and subdomain: ventures/<slug>/ is served at <slug>.yolopreneur.lol"
required: true
type: string
ref:
description: "Branch, tag or commit to build (empty: the repo's default branch)"
required: false
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 and seed write gh-pages too: take turns
cancel-in-progress: false
jobs:
ship:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
with:
repository: ${{ inputs.repo }}
ref: ${{ inputs.ref }}
token: ${{ secrets.TIME_TOKEN }}
path: src
persist-credentials: false # the build that runs next must not find the token in .git/config
- uses: actions/setup-node@v4
with:
node-version: 22
- name: find Chrome, or install Playwright's Chromium
run: |
set -eu
for c in google-chrome google-chrome-stable chromium chromium-browser; do
if command -v "$c" >/dev/null 2>&1; then
echo "CHROME=$(command -v "$c")" >> "$GITHUB_ENV"; echo "using $(command -v "$c")"; exit 0
fi
done
echo "no Chrome on this runner: installing Playwright's Chromium"
npx -y playwright@1.55.0 install --with-deps chromium > /tmp/pw.log 2>&1 || { tail -20 /tmp/pw.log; exit 1; }
c=$(find "$HOME/.cache/ms-playwright" -type f -name chrome -path '*chrome-linux*' | head -1)
[ -n "$c" ] && [ -x "$c" ] || { echo "::error::no Chromium after the install"; exit 1; }
echo "CHROME=$c" >> "$GITHUB_ENV"; echo "using $c"
- name: build
working-directory: src
env: # inputs reach the scripts through the environment, never interpolated into them
REPO: ${{ inputs.repo }}
CHROME_FLAGS: "--no-sandbox --disable-dev-shm-usage --disable-gpu"
run: |
set -eu
sh tools/build.sh "$GITHUB_WORKSPACE/out"
echo "SOURCE=$REPO@$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
- 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
- name: land
env:
SLUG: ${{ inputs.slug }}
NAME: ${{ inputs.name }}
ONE_LINER: ${{ inputs.one_liner }}
run: BUNDLE_FILE="$GITHUB_WORKSPACE/out/$SLUG.tgz" python3 engine/.github/time/ship.py pages
- 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
@@ -9,6 +9,7 @@ A Gitea-native agent factory, running on Teahub prod (teahub.xyz). The Gitea rep
- **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.
- **Ship** (`ship.yml`, manual): seed for a venture whose source lives in its own repo on Teahub, so nothing is built or carried outside the factory. Give it the repo (private is fine: the `time` bot's token clones it), the slug, and a name and a one-liner for a new venture. It runs the repo's `tools/build.sh OUT`, which leaves `OUT/<slug>.tgz`, on the runner (installing Chromium for screenshots when the runner has no Chrome), then `ship.py` lands that bundle the way seed does.
- **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.