Compare commits
1
Commits
main
...
ship-from-repo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d0df6232 |
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