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
|
node-version: 22
|
||||||
- run: python3 .github/time/test_time.py
|
- run: python3 .github/time/test_time.py
|
||||||
- run: python3 .github/time/test_seed.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.
|
- **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.
|
- **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.
|
- **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`.
|
- **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.
|
- **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.
|
- **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