diff --git a/.github/time/ship.py b/.github/time/ship.py new file mode 100644 index 0000000..b57f2c8 --- /dev/null +++ b/.github/time/ship.py @@ -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 / 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() diff --git a/.github/time/test_ship.py b/.github/time/test_ship.py new file mode 100644 index 0000000..83b7011 --- /dev/null +++ b/.github/time/test_ship.py @@ -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"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"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) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73f735d..a9e17ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/ship.yml b/.github/workflows/ship.yml new file mode 100644 index 0000000..dfbd802 --- /dev/null +++ b/.github/workflows/ship.yml @@ -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// on gh-pages, its line in ventures/ventures.json, served at .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/.tgz with / 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// is served at .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" diff --git a/README.md b/README.md index 317860f..88d9871 100644 --- a/README.md +++ b/README.md @@ -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//`. 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. +- **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/.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 `.`. 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.