ship: build a venture from its own repo on Teahub and land it like seed
ci / test (pull_request) Successful in 35s
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
This commit is contained in:
1 parent
aaeb9417ca
commit
68d0df6232
5 files changed
+223
No files matched your search
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user