sunnyware / scripts /ship.py
shamiur's picture
Part 157b: archive legacy smokes + UTF-8 hardening
67a57f6
Raw History Blame Contribute Delete
17.2 kB
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Shamiur Rashid Sunny
# SPDX-License-Identifier: AGPL-3.0-only
"""Part 157: one-shot ship helper for the Sunnyware project.
Usage:
python scripts/ship.py <part_number> "<short description>" [options]
Options:
--body TEXT Detailed body for HANDOFF.md (defaults to desc)
--wait N Seconds to wait for HF rebuild (default: 150)
--verify-role R Role to use for live verify (default: general)
--verify-input T Input for live verify (default: Reply with PONG.)
--skip STEPS Comma-separated steps to skip.
Valid: smokes,gate,docs,commit,gh,hf,verify
--dry-run Show what would happen without touching git
--no-color Disable ANSI colors
Steps performed:
1. smokes - run every scripts/smoke_test_part*.py
2. gate - scripts/pre_push_check.py
3. docs - append HANDOFF + WORKFLOW + bump AGENTS footer
4. commit - git add + commit with the assembled message
5. gh - git push origin main
6. hf - ./push_hf.sh
7. verify - wait for HF, GET /health/ready, fire live request
Never pushes broken code: if any smoke or the gate fails, we abort
before touching git.
"""
import argparse
import datetime
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
# Part 157b: force UTF-8 stdout on Windows (cp1252 can't encode
# box-drawing chars, emoji, or the ANSI color codes we use).
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
ROOT = Path.cwd()
SCRIPTS = ROOT / "scripts"
SRC = ROOT / "src" / "sunnyware"
BASE_URL = "https://shamiur-sunnyware.hf.space"
# Part 157c: legacy smokes that had hard-coded counts before
# newer parts changed them (e.g. roles went 9 -> 10 when the
# auditor role was added in Part 142a). ship.py warns on these
# but does not abort. A future cleanup part will update them.
LEGACY_FAILING_SMOKES = set() # Part 157b: legacy archived
USE_COLOR = True
def c(txt, code):
if not USE_COLOR:
return txt
return f"\033[{code}m{txt}\033[0m"
def info(msg):
print(f" {msg}")
def ok(step, msg):
print(f"{c('[OK]', '32')} [{step}] {msg}")
def fail(step, msg, exit_code=1):
print(f"{c('[FAIL]', '31')} [{step}] {msg}")
sys.exit(exit_code)
def hdr(title):
line = "=" * 70
print("")
print(c(line, "36"))
print(c(f" {title}", "36"))
print(c(line, "36"))
# ─────────────────────────────────────────────────────────────
# STEP 1 — run smokes
# ─────────────────────────────────────────────────────────────
def run_smokes():
hdr("STEP 1 — smokes")
files = sorted(SCRIPTS.glob("smoke_test_part*.py"))
def num_key(p):
m = re.search(r"part(\d+)", p.name)
return int(m.group(1)) if m else 0
files.sort(key=num_key)
failed = []
for f in files:
try:
# Part 157b: force UTF-8 so smokes that print non-cp1252
# chars (em-dash, box-drawing) don't spuriously fail on
# Windows when ship.py captures their output.
_env = os.environ.copy()
_env["PYTHONIOENCODING"] = "utf-8"
_env["PYTHONUTF8"] = "1"
r = subprocess.run(
[sys.executable, str(f)],
capture_output=True, text=True, timeout=180,
env=_env, encoding="utf-8", errors="replace",
)
except subprocess.TimeoutExpired:
info(f"{c('[FAIL]', '31')} {f.name:36s} TIMEOUT")
failed.append(f.name)
continue
lines = [ln for ln in (r.stdout or "").strip().splitlines() if ln.strip()]
tail = lines[-1] if lines else "(no output)"
passed = r.returncode == 0
is_legacy = f.name in LEGACY_FAILING_SMOKES
if passed:
marker = c("[OK]", "32")
elif is_legacy:
marker = c("[WARN]", "33") # yellow
tail = "(legacy allowlist) " + tail
else:
marker = c("[FAIL]", "31")
info(f"{marker} {f.name:36s} {tail}")
if not passed and not is_legacy:
failed.append(f.name)
if failed:
fail("1", f"{len(failed)} smoke(s) failed: {', '.join(failed)}")
ok("1", f"{len(files)} smoke(s) passed")
# ─────────────────────────────────────────────────────────────
# STEP 2 — pre-push gate
# ─────────────────────────────────────────────────────────────
def run_gate():
hdr("STEP 2 — pre-push gate")
r = subprocess.run(
[sys.executable, str(SCRIPTS / "pre_push_check.py")],
capture_output=True, text=True,
)
for line in (r.stdout or "").splitlines():
if "SUMMARY" in line or "RESULT" in line:
info(line.strip())
if r.returncode != 0:
fail("2", "pre-push gate blocked")
ok("2", "gate passed")
# ─────────────────────────────────────────────────────────────
# STEP 3 — docs sync
# ─────────────────────────────────────────────────────────────
def update_docs(part, desc, body, dry_run=False):
hdr("STEP 3 — docs sync")
# HANDOFF.md
p = ROOT / "HANDOFF.md"
t = p.read_text(encoding="utf-8")
marker = f"### Part {part} - {desc}"
if marker in t:
ok("3", f"HANDOFF already has Part {part}")
else:
when = datetime.date.today().isoformat()
entry = f"\n\n{marker}\n{body}\n"
if not dry_run:
p.write_text(t.rstrip() + entry, encoding="utf-8")
ok("3", f"HANDOFF extended (Part {part})")
# WORKFLOW.md
p = ROOT / "WORKFLOW.md"
t = p.read_text(encoding="utf-8")
if f"| {part} " in t:
ok("3", f"WORKFLOW already has Part {part}")
else:
when = datetime.date.today().isoformat()
block = (
f"\n\n## {when} - Part {part}\n\n"
f"| Part | Feature |\n"
f"|------|---------|\n"
f"| {part} | {desc} |\n\n"
f"{body}\n"
)
if not dry_run:
p.write_text(t.rstrip() + block, encoding="utf-8")
ok("3", f"WORKFLOW extended (Part {part})")
# AGENTS.md footer
p = ROOT / "AGENTS.md"
t = p.read_text(encoding="utf-8")
lines = t.splitlines()
footer_idx = None
for i, ln in enumerate(lines):
if ln.startswith("**Last update:**"):
footer_idx = i
break
new_footer = f"**Last update:** {datetime.date.today().isoformat()} (Part {part} - {desc})"
if footer_idx is None:
if not dry_run:
p.write_text(t.rstrip() + "\n\n" + new_footer + "\n", encoding="utf-8")
ok("3", "AGENTS footer appended")
elif lines[footer_idx] == new_footer:
ok("3", "AGENTS footer already current")
else:
lines[footer_idx] = new_footer
if not dry_run:
p.write_text("\n".join(lines) + "\n", encoding="utf-8")
ok("3", "AGENTS footer bumped")
# ─────────────────────────────────────────────────────────────
# STEP 4 — commit
# ─────────────────────────────────────────────────────────────
def do_commit(part, desc, body, dry_run=False):
hdr("STEP 4 — commit")
# files to add: source tree + scripts + docs (safe glob)
to_add = [
"src/",
"scripts/",
"HANDOFF.md",
"WORKFLOW.md",
"AGENTS.md",
"RESUME.md",
]
if dry_run:
ok("4", "dry-run: skipped git add")
else:
subprocess.run(["git", "add"] + to_add, check=False)
status = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True,
)
dirty = [ln for ln in (status.stdout or "").splitlines() if ln.strip()]
if not dirty:
ok("4", "nothing to commit (tree clean)")
return None
msg = f"Part {part}: {desc}\n\n{body}\n"
mf = ROOT / f".commit_msg_part{part}.txt"
mf.write_text(msg, encoding="utf-8")
if dry_run:
ok("4", f"dry-run: would commit {len(dirty)} file(s)")
mf.unlink()
return None
r = subprocess.run(
["git", "commit", "-F", str(mf)],
capture_output=True, text=True,
)
try:
mf.unlink()
except Exception:
pass
if r.returncode != 0:
info((r.stderr or r.stdout).strip()[-400:])
fail("4", "git commit failed")
head_line = (r.stdout or "").strip().splitlines()[0]
info(head_line)
# extract short hash
m = re.search(r"\[main ([0-9a-f]+)\]", head_line)
head_hash = m.group(1) if m else ""
ok("4", f"commit created ({head_hash})")
return head_hash
# ─────────────────────────────────────────────────────────────
# STEP 5 — push GitHub
# ─────────────────────────────────────────────────────────────
def push_github(dry_run=False):
hdr("STEP 5 — push GitHub")
if dry_run:
ok("5", "dry-run: skipped")
return
r = subprocess.run(
["git", "push", "origin", "main"],
capture_output=True, text=True,
)
if r.returncode != 0:
info((r.stderr or "")[-300:])
fail("5", "git push origin failed")
for line in (r.stdout or "").strip().splitlines()[-2:]:
info(line)
ok("5", "GitHub push OK")
# ─────────────────────────────────────────────────────────────
# STEP 6 — push HF
# ─────────────────────────────────────────────────────────────
def push_hf(dry_run=False):
hdr("STEP 6 — push HF")
if dry_run:
ok("6", "dry-run: skipped")
return
r = subprocess.run(
["bash", "push_hf.sh"],
capture_output=True, text=True, timeout=200,
)
if r.returncode != 0:
info((r.stderr or "")[-300:])
fail("6", "push_hf.sh failed")
for line in (r.stdout or "").strip().splitlines()[-2:]:
info(line)
ok("6", "HF push OK")
# ─────────────────────────────────────────────────────────────
# STEP 7 — wait + verify
# ─────────────────────────────────────────────────────────────
def http_get(path, timeout=20):
try:
with urllib.request.urlopen(BASE_URL + path, timeout=timeout) as r:
return r.status, r.read()
except urllib.error.HTTPError as e:
return e.code, b""
except Exception:
return 0, b""
def http_post_json(path, payload, timeout=120):
req = urllib.request.Request(
BASE_URL + path,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read().decode())
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read().decode())
except Exception:
return e.code, {}
except Exception as e:
return 0, {"error": f"{type(e).__name__}: {e}"}
def verify(role, user_input, wait_s, dry_run=False):
hdr("STEP 7 — wait + live verify")
if dry_run:
ok("7", "dry-run: skipped")
return
if wait_s > 0:
info(f"waiting {wait_s}s for HF rebuild...")
time.sleep(wait_s)
# poll with GET (not HEAD — /health/ready only accepts GET)
up = False
for i in range(1, 16):
code, _ = http_get("/health/ready", timeout=15)
if code == 200:
info(f"HF up at attempt {i}")
up = True
break
info(f"attempt {i}: HTTP {code}, waiting 15s")
time.sleep(15)
if not up:
fail("7", "HF did not come back up in ~3 min")
info("")
info(f"-- live {role} test --")
code, d = http_post_json("/api/agent/run", {
"input": user_input,
"session_id": f"ship-part-{int(time.time())}",
"role": role,
}, timeout=240)
if code != 200:
info(f" HTTP {code}: {json.dumps(d)[:300]}")
fail("7", "live verify HTTP not 200")
r = d.get("result") or {}
content = (r.get("content") or "").strip()
err = d.get("detail") or d.get("error")
steps = r.get("steps") or []
tools_used = [s.get("tool") for s in steps]
info(f" provider: {r.get('provider')}")
info(f" steps: {len(steps)}")
info(f" tools: {tools_used}")
info(f" error: {err!r}")
info(f" content: {content[:200]!r}")
if err:
fail("7", "live verify returned an error")
ok("7", "live verify OK")
# ─────────────────────────────────────────────────────────────
# main
# ─────────────────────────────────────────────────────────────
def main():
global USE_COLOR
ap = argparse.ArgumentParser(
prog="ship.py",
description="One-shot ship helper for Sunnyware.",
)
ap.add_argument("part", help="Part number, e.g. 157 or 157b")
ap.add_argument("desc", help="Short description (one line)")
ap.add_argument("--body", default=None,
help="Detailed body for HANDOFF.md (defaults to desc)")
ap.add_argument("--wait", type=int, default=150,
help="Seconds to wait for HF rebuild (default: 150)")
ap.add_argument("--verify-role", default="general",
help="Role for live verify (default: general)")
ap.add_argument("--verify-input", default="Reply with the single word PONG.",
help="Input for live verify")
ap.add_argument("--skip", default="",
help="Comma-separated steps to skip: "
"smokes,gate,docs,commit,gh,hf,verify")
ap.add_argument("--dry-run", action="store_true",
help="Do everything except modify git / HF")
ap.add_argument("--no-color", action="store_true")
args = ap.parse_args()
if args.no_color:
USE_COLOR = False
skip = {s.strip().lower() for s in args.skip.split(",") if s.strip()}
body = args.body if args.body is not None else args.desc
print("=" * 70)
print(f"ship.py — Part {args.part}: {args.desc}")
print(f"skip={sorted(skip) if skip else '(none)'} dry_run={args.dry_run}")
print("=" * 70)
# 1. smokes (before touching git)
if "smokes" in skip:
info("[1] skipped")
else:
run_smokes()
# 2. gate
if "gate" in skip:
info("[2] skipped")
else:
run_gate()
# 3. docs
if "docs" in skip:
info("[3] skipped")
else:
update_docs(args.part, args.desc, body, dry_run=args.dry_run)
# 4. commit
head_hash = None
if "commit" in skip:
info("[4] skipped")
else:
head_hash = do_commit(args.part, args.desc, body, dry_run=args.dry_run)
# 5. gh push
if "gh" in skip:
info("[5] skipped")
else:
push_github(dry_run=args.dry_run)
# 6. hf push
if "hf" in skip:
info("[6] skipped")
else:
push_hf(dry_run=args.dry_run)
# 7. verify
if "verify" in skip:
info("[7] skipped")
else:
verify(args.verify_role, args.verify_input, args.wait,
dry_run=args.dry_run)
print("")
print("=" * 70)
print(f"[DONE] Part {args.part} shipped"
+ (f" (HEAD {head_hash})" if head_hash else ""))
print("=" * 70)
if __name__ == "__main__":
main()