fuzzyworld-tools / files / bin / deploy-tools
deploy-tools
92625227 2026-11-04 Pavel Safronov | 1 | #!/usr/bin/env python3 |
258a9da8 2026-11-06 Pavel Safronov | 2 | """Deploy this repo's targets, only when what a target ships has changed. |
92625227 2026-11-04 Pavel Safronov | 3 | |
| 4 | bin/deploy-tools [server] deploy the server (install + republish all) if server/ changed | |
258a9da8 2026-11-06 Pavel Safronov | 5 | bin/deploy-tools all every target |
92625227 2026-11-04 Pavel Safronov | 6 | bin/deploy-tools runner vscode named targets |
| 7 | bin/deploy-tools status what is deployed, and what is pending | |
| 8 | --force deploy even if unchanged | |
| 9 | ||
258a9da8 2026-11-06 Pavel Safronov | 10 | The targets are declared in package.json, under "fuzzyworld": {"deploy": {<target>: {"paths": [folders it ships], |
| 11 | "command": [script, args...], "description": "...", "perMachine": true?}}}. | |
| 12 | ||
| 13 | A ledger on the server (~/fw/state/deployed.json on $FW_HOST, default `dh`) records, per "<repo>/<target>", the | |
92625227 2026-11-04 Pavel Safronov | 14 | commit and the content hash of the folders it ships. "Changed" means that content hash differs, so it covers |
258a9da8 2026-11-06 Pavel Safronov | 15 | uncommitted edits too, and committing the very files you already deployed does not redeploy. The ledger is |
| 16 | written only after a target's script succeeds. A `perMachine` target (the laptop runner) is installed per Mac, | |
| 17 | so its ledger key ends in @<hostname>. Gitignored files inside a shipped folder are not hashed (and are never | |
| 18 | "changes" here) even though rsync may upload them. `fw deploys --json` on the server reads the same ledger | |
| 19 | for `fw admin`. | |
92625227 2026-11-04 Pavel Safronov | 20 | """ |
| 21 | import json | |
| 22 | import os | |
258a9da8 2026-11-06 Pavel Safronov | 23 | import posixpath |
| 24 | import re | |
92625227 2026-11-04 Pavel Safronov | 25 | import socket |
| 26 | import subprocess | |
| 27 | import sys | |
| 28 | import tempfile | |
| 29 | import time | |
| 30 | ||
| 31 | ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| 32 | HOST = os.environ.get("FW_HOST", "dh") | |
| 33 | LEDGER = "fw/state/deployed.json" # on HOST, relative to its home | |
258a9da8 2026-11-06 Pavel Safronov | 34 | NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*\Z") # as server/deploys.py: it drops targets that fail this |
| 35 | PER_MACHINE = set() # targets whose ledger key carries the hostname | |
| 36 | ||
| 37 | ||
| 38 | def clean_path(p): | |
| 39 | """A folder inside the repo, written plainly; as server/deploys.py clean_path (the two must agree).""" | |
ab35fc22 2026-11-07 Pavel Safronov | 40 | return (isinstance(p, str) and p != "" and posixpath.normpath(p) == p and not p.startswith(("/", "../")) and p != ".." |
| 41 | and not any(c in p for c in ":*?[]\\")) | |
258a9da8 2026-11-06 Pavel Safronov | 42 | |
| 43 | ||
| 44 | def declared_targets(): | |
| 45 | """(repo name on the server, {target: (paths, command)}) from package.json. `server` should include the republish, | |
| 46 | because pages are generated by the installed gen.py: new generator code only shows once they are regenerated.""" | |
| 47 | with open(os.path.join(ROOT, "package.json")) as f: | |
| 48 | pkg = json.load(f) | |
| 49 | found = {} | |
| 50 | for name, t in ((pkg.get("fuzzyworld") or {}).get("deploy") or {}).items(): | |
ab35fc22 2026-11-07 Pavel Safronov | 51 | paths, cmd = (t.get("paths"), t.get("command")) if isinstance(t, dict) else (None, None) |
258a9da8 2026-11-06 Pavel Safronov | 52 | ok = (NAME_RE.match(name) and isinstance(paths, list) and paths and all(clean_path(p) for p in paths) |
ab35fc22 2026-11-07 Pavel Safronov | 53 | and isinstance(cmd, list) and cmd and all(isinstance(c, str) for c in cmd) and clean_path(cmd[0])) |
258a9da8 2026-11-06 Pavel Safronov | 54 | if not ok: |
| 55 | raise SystemExit(f'package.json: fuzzyworld.deploy.{name} needs a plain name, "paths" (folders inside the repo, ' | |
| 56 | 'written plainly: no "./", trailing "/" or "..") and "command" (a list)') | |
| 57 | found[name] = (paths, cmd) | |
| 58 | if t.get("perMachine"): | |
| 59 | PER_MACHINE.add(name) | |
| 60 | repo = (pkg.get("fuzzyworld") or {}).get("repo") or pkg.get("name") or os.path.basename(ROOT) | |
| 61 | if not NAME_RE.match(str(repo)): | |
| 62 | raise SystemExit('package.json: set "fuzzyworld": {"repo": "<name of the repo on the server>"} (the package name is not usable)') | |
| 63 | return repo, found | |
| 64 | ||
| 65 | ||
| 66 | REPO, TARGETS = declared_targets() | |
92625227 2026-11-04 Pavel Safronov | 67 | |
| 68 | ||
| 69 | def git(*args, env=None): | |
| 70 | return subprocess.run(["git", "-C", ROOT, *args], check=True, capture_output=True, text=True, env=env).stdout.strip() | |
| 71 | ||
| 72 | ||
| 73 | def content_hash(paths): | |
| 74 | """Hash of the working-tree content of `paths` (untracked files included, ignored ones not). | |
| 75 | Equals the committed tree hash once the same content is committed.""" | |
| 76 | index = os.path.join(git("rev-parse", "--absolute-git-dir"), "index") | |
| 77 | with tempfile.TemporaryDirectory() as tmp: | |
| 78 | copy = os.path.join(tmp, "index") | |
| 79 | if os.path.exists(index): | |
| 80 | with open(index, "rb") as src, open(copy, "wb") as dst: | |
| 81 | dst.write(src.read()) | |
| 82 | env = {**os.environ, "GIT_INDEX_FILE": copy} | |
| 83 | try: | |
| 84 | git("add", "-A", "--", *paths, env=env) | |
| 85 | tree = git("write-tree", env=env) | |
| 86 | return ",".join(git("rev-parse", f"{tree}:{p}", env=env) for p in paths) | |
| 87 | except subprocess.CalledProcessError: | |
| 88 | raise SystemExit(f"nothing to ship: {', '.join(paths)} is missing or holds only ignored files") | |
| 89 | ||
| 90 | ||
| 91 | def load(): | |
| 92 | """The ledger from the server; {} if it does not exist yet. An unreachable server is an error.""" | |
| 93 | r = subprocess.run(["ssh", HOST, f"cat {LEDGER} 2>/dev/null || true"], capture_output=True, text=True) | |
| 94 | if r.returncode != 0: | |
| 95 | raise SystemExit(f"cannot reach {HOST}: {r.stderr.strip()}") | |
| 96 | if not r.stdout.strip(): | |
| 97 | return {} | |
| 98 | try: | |
ab35fc22 2026-11-07 Pavel Safronov | 99 | ledger = json.loads(r.stdout) |
92625227 2026-11-04 Pavel Safronov | 100 | except ValueError: |
| 101 | raise SystemExit(f"{HOST}:{LEDGER} is not valid JSON; fix or delete it (saving now would wipe the other targets' records)") | |
ab35fc22 2026-11-07 Pavel Safronov | 102 | if not isinstance(ledger, dict): |
| 103 | raise SystemExit(f"{HOST}:{LEDGER} is not a JSON object; fix or delete it (saving now would wipe the other targets' records)") | |
| 104 | return ledger | |
| 105 | ||
| 106 | ||
| 107 | def migrate(ledger): | |
| 108 | """Before Q111 the keys were "<target>" (and "laptop-runner@<host>"); now "<repo>/<target>". Rename each legacy | |
| 109 | key of a declared target in memory, unless the new key exists; the next save writes the result.""" | |
| 110 | for name in TARGETS: | |
| 111 | old = name + (f"@{socket.gethostname()}" if name in PER_MACHINE else "") | |
| 112 | if old in ledger and key(name) not in ledger: | |
| 113 | ledger[key(name)] = ledger.pop(old) | |
| 114 | return ledger | |
92625227 2026-11-04 Pavel Safronov | 115 | |
| 116 | ||
| 117 | def save(ledger): | |
| 118 | cmd = f"mkdir -p ~/fw/state && cat > {LEDGER}.tmp && mv {LEDGER}.tmp {LEDGER}" | |
| 119 | r = subprocess.run(["ssh", HOST, cmd], input=json.dumps(ledger, indent=2) + "\n", text=True) | |
| 120 | if r.returncode != 0: | |
| 121 | raise SystemExit("the deploy succeeded but the ledger could not be written; the next run will redeploy") | |
| 122 | ||
| 123 | ||
| 124 | def key(name): | |
258a9da8 2026-11-06 Pavel Safronov | 125 | """Ledger key "<repo>/<target>"; a per-machine target (the laptop runner) also carries this Mac's hostname.""" |
| 126 | return f"{REPO}/{name}" + (f"@{socket.gethostname()}" if name in PER_MACHINE else "") | |
92625227 2026-11-04 Pavel Safronov | 127 | |
| 128 | ||
| 129 | def main(argv): | |
| 130 | force = "--force" in argv | |
| 131 | args = [a for a in argv[1:] if a != "--force"] or ["server"] | |
| 132 | names = list(TARGETS) if args == ["all"] else args | |
| 133 | if args != ["status"]: | |
| 134 | for n in names: | |
| 135 | if n not in TARGETS: | |
| 136 | raise SystemExit(f"unknown target {n!r}; targets: {', '.join(TARGETS)}, all, status") | |
ab35fc22 2026-11-07 Pavel Safronov | 137 | ledger = migrate(load()) |
92625227 2026-11-04 Pavel Safronov | 138 | if args == ["status"]: |
| 139 | head = git("rev-parse", "--short", "HEAD") | |
| 140 | for name, (paths, _) in TARGETS.items(): | |
| 141 | rec = ledger.get(key(name)) | |
ab35fc22 2026-11-07 Pavel Safronov | 142 | rec = rec if isinstance(rec, dict) else None |
92625227 2026-11-04 Pavel Safronov | 143 | state = ("never deployed" if not rec else |
ab35fc22 2026-11-07 Pavel Safronov | 144 | "up to date" if rec.get("hash") == content_hash(paths) else "PENDING (changed since deploy)") |
| 145 | when = f' commit {rec.get("commit", "?")}, {rec.get("at", "?")}' if rec else "" | |
92625227 2026-11-04 Pavel Safronov | 146 | print(f"{name:14} {state}{when}") |
| 147 | print(f"(HEAD is {head}; uncommitted edits count as changes)") | |
| 148 | return 0 | |
| 149 | for name in names: | |
| 150 | paths, cmd = TARGETS[name] | |
| 151 | h = content_hash(paths) | |
ab35fc22 2026-11-07 Pavel Safronov | 152 | rec = ledger.get(key(name)) |
| 153 | rec = rec if isinstance(rec, dict) else {} | |
| 154 | if not force and rec.get("hash") == h: | |
| 155 | print(f"{name}: already deployed (commit {rec.get('commit', '?')}), nothing to do. --force to redo.") | |
92625227 2026-11-04 Pavel Safronov | 156 | continue |
| 157 | print(f"{name}: deploying ({' '.join(cmd)})") | |
| 158 | if subprocess.run([os.path.join(ROOT, cmd[0]), *cmd[1:]], cwd=ROOT).returncode != 0: | |
| 159 | print(f"{name}: FAILED, ledger not updated", file=sys.stderr) | |
| 160 | return 1 | |
| 161 | dirty = bool(git("status", "--porcelain", "--", *paths)) | |
| 162 | ledger[key(name)] = {"hash": h, "commit": git("rev-parse", "--short", "HEAD") + ("+uncommitted" if dirty else ""), | |
ab35fc22 2026-11-07 Pavel Safronov | 163 | "at": time.strftime("%Y-%m-%d %H:%M")} # laptop time |
92625227 2026-11-04 Pavel Safronov | 164 | save(ledger) |
| 165 | return 0 | |
| 166 | ||
| 167 | ||
| 168 | if __name__ == "__main__": | |
| 169 | sys.exit(main(sys.argv)) |