diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000000..718b588040eb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,98 @@ +# Publish prebuilt nodes so a first run does not mean a first build. +# +# Compiling is unavoidable for *vibing* — rewriting the node needs a compiler — +# but it is not needed to *run*. These artifacts let the installer bring a node +# up in seconds and defer the compile until the operator's first decree, when +# the wait finally means something. +# +# Every artifact records the commit it was built from. The installer refuses a +# binary that does not match its source, because a node built from other code is +# not the node the operator is about to start editing. +name: Release + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Existing tag to attach binaries to" + required: true + +permissions: + contents: write + +jobs: + build: + name: ${{ matrix.label }} + runs-on: ${{ matrix.os }} + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + label: macOS (Apple silicon) + slug: macos-arm64 + - os: macos-13 + label: macOS (Intel) + slug: macos-x86_64 + - os: ubuntu-latest + label: Linux (x86_64) + slug: linux-x86_64 + steps: + - uses: actions/checkout@v4 + + - name: Dependencies (macOS) + if: runner.os == 'macOS' + run: brew install cmake ninja boost capnp + + - name: Dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build pkg-config \ + libboost-dev libsqlite3-dev libcapnp-dev capnproto + + - name: Build + run: | + cmake -B build -G Ninja \ + -DBUILD_TESTS=OFF -DBUILD_BENCH=OFF -DBUILD_FUZZ_BINARY=OFF -DBUILD_GUI=OFF + cmake --build build -j"$(getconf _NPROCESSORS_ONLN)" + + # Ship nothing that cannot prove it is a node. + - name: Prove it validates + run: | + set -e + DD=$(mktemp -d) + ./build/bin/bitcoind -datadir="$DD" -chain=regtest -server=1 -listen=0 -daemon + for i in $(seq 1 40); do + ./build/bin/bitcoin-cli -datadir="$DD" -chain=regtest getblockcount >/dev/null 2>&1 && break + sleep 1 + done + ./build/bin/bitcoin-cli -datadir="$DD" -chain=regtest createwallet ci >/dev/null + A=$(./build/bin/bitcoin-cli -datadir="$DD" -chain=regtest -rpcwallet=ci getnewaddress) + ./build/bin/bitcoin-cli -datadir="$DD" -chain=regtest generatetoaddress 101 "$A" >/dev/null + test "$(./build/bin/bitcoin-cli -datadir="$DD" -chain=regtest getblockcount)" = "101" + ./build/bin/bitcoin-cli -datadir="$DD" -chain=regtest stop + + - name: Package + run: | + SHA=$(git rev-parse HEAD) + DIR="vibes-node-${{ matrix.slug }}" + mkdir -p "$DIR" + cp build/bin/bitcoind build/bin/bitcoin-cli "$DIR"/ + # The installer reads this to decide whether the binary matches its source. + printf '%s\n' "$SHA" > "$DIR/COMMIT" + tar -czf "$DIR.tar.gz" "$DIR" + shasum -a 256 "$DIR.tar.gz" > "$DIR.tar.gz.sha256" + ls -lh "$DIR.tar.gz" + + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.tag || github.ref_name }} + files: | + vibes-node-${{ matrix.slug }}.tar.gz + vibes-node-${{ matrix.slug }}.tar.gz.sha256 + fail_on_unmatched_files: true diff --git a/contrib/vibes/bitcoin-vibes b/contrib/vibes/bitcoin-vibes index 5eca9d64f782..a0e163f7e30b 100755 --- a/contrib/vibes/bitcoin-vibes +++ b/contrib/vibes/bitcoin-vibes @@ -19,15 +19,19 @@ import hashlib import hmac import json import os +import platform import queue import random import re import secrets import shlex import shutil +import socket import signal import subprocess +import tarfile import sys +import tempfile import threading import time import urllib.request @@ -461,6 +465,7 @@ class Console: self.git_mutex = threading.Lock() self._engine_cache = None self._discovered = None + self._upstream_cache = None self.node_proc = None self.node_log = None @@ -533,6 +538,7 @@ class Console: "job": self.job.summary() if self.job else None, "vibes": self.vibe_history(), "engine_ready": self.engine_status(), + "update": self.upstream_status(), } def find_claude(self): @@ -910,6 +916,111 @@ class Console: self._engine_cache = (now, state) return state + # ----- staying current with upstream ---------------------------------- + + def upstream_status(self, fetch=False): + """Is there a newer Bitcoin Vibes, and can this node take it? + + A node that has been vibed even once has diverged from origin, so + fast-forward is exactly the case that stops working the moment somebody + uses the product. This reports the real situation instead.""" + if fetch: + self.git("fetch", "origin", self.branch_name(), timeout=120) + self._upstream_cache = None + if self._upstream_cache and time.monotonic() - self._upstream_cache[0] < 60: + return self._upstream_cache[1] + + branch = self.branch_name() + ref = f"origin/{branch}" + code, _, _ = self.git("rev-parse", "--verify", ref) + if code != 0: + state = {"known": False} + else: + _, behind, _ = self.git("rev-list", "--count", f"HEAD..{ref}") + _, ahead, _ = self.git("rev-list", "--count", f"{ref}..HEAD") + behind_n = int(behind.strip() or 0) + ahead_n = int(ahead.strip() or 0) + _, decrees, _ = self.git("rev-list", "--count", "--grep=^vibe:", f"{ref}..HEAD") + state = {"known": True, "behind": behind_n, "ahead": ahead_n, + "decrees": int(decrees.strip() or 0), + "available": behind_n > 0, + "diverged": ahead_n > 0} + self._upstream_cache = (time.monotonic(), state) + return state + + def branch_name(self): + _, out, _ = self.git("rev-parse", "--abbrev-ref", "HEAD") + return out.strip() or "master" + + def update_runner(self, autobuild=True, autorestart=True): + """Bring in new upstream work, replaying the operator's decrees on top.""" + def go(job): + branch = self.branch_name() + job.emit(t="phase", name="update", detail="asking what has changed upstream") + st = self.upstream_status(fetch=True) + if not st.get("known"): + raise RuntimeError(f"there is no origin/{branch} to compare against") + if not st["available"]: + job.emit(t="result", detail="Already current. Upstream has nothing to " + "teach you today.") + return + + n, d = st["behind"], st["decrees"] + job.emit(t="log", line=f"{n} new commit(s) upstream; " + f"{d} decree(s) of your own to replay") + + # A rebase over uncommitted work fails for a reason that has nothing + # to do with conflicts, and blaming upstream for it would send the + # operator hunting the wrong problem. A cancelled decree can leave + # the tree in exactly this state. + dirty = self._changed_files() + if dirty: + job.state = "error" + job.emit(t="phase", name="error", + detail=f"There are {len(dirty)} uncommitted change(s) in the " + f"source — probably a decree that was interrupted. " + f"Nothing was updated. Commit them as a decree, or " + f"discard them, then update again. " + f"({', '.join(dirty[:3])}" + f"{' …' if len(dirty) > 3 else ''})") + return + + if not st["diverged"]: + code, _, err = self.git("merge", "--ff-only", f"origin/{branch}") + if code != 0: + raise RuntimeError(f"could not fast-forward: {err.strip()[:200]}") + job.emit(t="log", line="fast-forwarded cleanly") + else: + # The interesting case, and the one that used to fail silently. + job.emit(t="phase", name="rebase", + detail=f"replaying your {d} decree(s) onto the new upstream") + code, _, err = self.git("rebase", f"origin/{branch}", timeout=300) + if code != 0: + self.git("rebase", "--abort") + job.state = "error" + job.emit(t="phase", name="error", + detail="Upstream has changed the same code your decrees " + "touch, so they cannot be replayed automatically. " + "Nothing was changed — your node is exactly as it " + "was. Resolve it by hand, or issue a decree that " + "reconciles the two. " + f"({err.strip()[:180]})") + return + job.emit(t="log", line="your decrees survived, and now sit on top") + + if autobuild: + if not self._build(job): + return + if not self._smoke_test(job): + return + if autorestart: + self._restart_node(job) + self._upstream_cache = None + job.emit(t="result", + detail=f"Updated. {n} upstream commit(s) absorbed; your decrees " + f"remain law.") + return go + def vibe_history(self): code, out, _ = self.git("log", "--grep=^vibe:", "--format=%H%x1f%ct%x1f%s", "-n", "50") vibes = [] @@ -994,6 +1105,11 @@ class Console: sha = self._commit_vibe(job, prompt, files) if sha and autobuild: ok = self._build(job) + # Compiling proves the code is grammatical, not that it is a + # Bitcoin node. Before this decree becomes the node the operator + # actually runs, make the new binary prove it can still validate. + if ok: + ok = self._smoke_test(job) if ok and autorestart: self._restart_node(job) job.emit(t="result", detail=random.choice(BENEDICTIONS) if sha else @@ -1204,6 +1320,96 @@ class Console: low = max(3, 40 // max(1, jobs)) return low, low * 2 + def _smoke_test(self, job): + """Make the freshly built binary prove it is still a Bitcoin node. + + Runs against a throwaway regtest datadir, never the operator's chain, so + a decree that compiles but cannot validate is caught here rather than by + their peers.""" + bitcoind = self.bin_path("bitcoind") + cli = self.bin_path("bitcoin-cli") + if not (bitcoind and cli): + return True # nothing built to test + job.emit(t="phase", name="verify", + detail="making the new binary prove it can still validate") + + sandbox = Path(tempfile.mkdtemp(prefix="vibes-verify-")) + args = [f"-datadir={sandbox}", "-chain=regtest"] + proc = None + try: + proc = subprocess.Popen( + # -listen=0 keeps it off the network entirely, so no -port is + # needed (and bitcoind rejects -port=0 outright). + [str(bitcoind), *args, "-server=1", "-listen=0", + f"-rpcport={self._free_port()}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + + def rpc(*a): + code, out, _ = run([str(cli), *args, + f"-rpcport={self._verify_rpcport}", *a], timeout=30) + return (out.strip() if code == 0 else None) + + deadline = time.time() + 60 + while time.time() < deadline and rpc("getblockcount") is None: + if proc.poll() is not None: + job.emit(t="log", line="the new binary would not even start") + return self._smoke_failed(job, "it would not start") + time.sleep(0.5) + + checks = [] + rpc("createwallet", "verify") + addr = rpc("-rpcwallet=verify", "getnewaddress") + if not addr: + return self._smoke_failed(job, "it could not make an address") + if rpc("generatetoaddress", "101", addr) is None: + return self._smoke_failed(job, "it could not mine a block") + height = rpc("getblockcount") + checks.append(("mined 101 blocks", height == "101")) + + info = rpc("getblockchaininfo") + checks.append(("chain state readable", bool(info))) + # It must still reject nonsense: an amount above its own ceiling. + bad = run([str(cli), *args, f"-rpcport={self._verify_rpcport}", + "createrawtransaction", "[]", + f'[{{"{addr}":210000000}}]'], timeout=20)[0] + checks.append(("rejects impossible amounts", bad != 0)) + + failed = [name for name, ok in checks if not ok] + for name, ok in checks: + job.emit(t="log", line=f" {'✓' if ok else '✗'} {name}") + if failed: + return self._smoke_failed(job, "; ".join(failed)) + job.emit(t="log", line="the node still validates — proceeding") + return True + except Exception as e: # noqa: BLE001 + job.emit(t="log", line=f"could not run the check ({e}) — proceeding anyway") + return True + finally: + if proc and proc.poll() is None: + run([str(cli), *args, f"-rpcport={self._verify_rpcport}", "stop"], timeout=15) + try: + proc.wait(20) + except subprocess.TimeoutExpired: + proc.kill() + shutil.rmtree(sandbox, ignore_errors=True) + + def _free_port(self): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + self._verify_rpcport = s.getsockname()[1] + return self._verify_rpcport + + @staticmethod + def _smoke_failed(job, why): + job.state = "error" + job.emit(t="phase", name="error", + detail=f"This decree compiles, but the node it produces cannot do its " + f"job: {why}. Your node has NOT been restarted — it is still " + f"running the last binary that worked. Repent this decree, or " + f"issue another that fixes it.") + return False + def build_blocking(self): """First-run build, streamed straight to the terminal (no job/UI yet).""" jobs = str(self.build_jobs()) @@ -1343,6 +1549,20 @@ class Console: job.emit(t="phase", name="setup", detail="preparing your node — this happens once") + # A node in seconds beats a node in twenty minutes. If a published + # binary matches this exact source, start with it and leave the + # compile for the first decree — when the wait has a point. + if self.try_prebuilt(job): + job.emit(t="phase", name="node", detail="waking the node") + if self.start_node(job): + job.emit(t="result", + detail="Your node is ready, Excellency — it was waiting, " + "prebuilt. Your first decree will compile it " + "properly, which takes a few minutes and happens " + "exactly once.") + return + job.emit(t="log", line="the prebuilt node would not start — compiling one") + missing = self.missing_deps() if missing: job.emit(t="phase", name="deps", @@ -1375,6 +1595,63 @@ class Console: detail="Your node is ready, Excellency. It has been waiting.") return go + PREBUILT_SLUGS = {("Darwin", "arm64"): "macos-arm64", + ("Darwin", "x86_64"): "macos-x86_64", + ("Linux", "x86_64"): "linux-x86_64"} + + def try_prebuilt(self, job): + """Fetch a published node, but only one built from this exact commit. + + A binary compiled from different source is not the node the operator is + about to start editing, so a mismatch falls back to compiling rather + than quietly running the wrong thing.""" + if os.environ.get("VIBES_NO_PREBUILT") == "1": + return False + slug = self.PREBUILT_SLUGS.get((platform.system(), platform.machine())) + if not slug: + return False + _, head, _ = self.git("rev-parse", "HEAD") + head = head.strip() + if not head: + return False + + base = os.environ.get( + "VIBES_PREBUILT_URL", + "https://github.com/hotpixelgroup/bitcoin/releases/latest/download") + url = f"{base}/vibes-node-{slug}.tar.gz" + job.emit(t="phase", name="prebuilt", + detail=f"looking for a ready-made node for {slug}") + tmp = Path(tempfile.mkdtemp(prefix="vibes-prebuilt-")) + try: + archive = tmp / "node.tar.gz" + with urllib.request.urlopen(url, timeout=300) as r, open(archive, "wb") as f: + shutil.copyfileobj(r, f) + with tarfile.open(archive) as t: + t.extractall(tmp) # noqa: S202 - our own release + src = tmp / f"vibes-node-{slug}" + built_from = (src / "COMMIT").read_text().strip() + if built_from != head: + job.emit(t="log", + line=f"the published node was built from {built_from[:10]}, " + f"your source is {head[:10]} — compiling instead") + return False + dest = self.repo / "build" / "bin" + dest.mkdir(parents=True, exist_ok=True) + for name in ("bitcoind", "bitcoin-cli"): + shutil.copy2(src / name, dest / name) + (dest / name).chmod(0o755) + if run([str(dest / "bitcoind"), "-version"], timeout=30)[0] != 0: + job.emit(t="log", line="it would not run on this machine — compiling instead") + return False + job.emit(t="log", line="using a ready-made node, verified against your source") + return True + except Exception as e: # noqa: BLE001 + job.emit(t="log", line=f"no ready-made node available ({type(e).__name__}) " + f"— compiling instead") + return False + finally: + shutil.rmtree(tmp, ignore_errors=True) + def missing_deps(self): """Which build tools are absent. Empty means we can compile.""" need = [] @@ -1549,6 +1826,10 @@ class Handler(BaseHTTPRequestHandler): job = c.start_job("revert", sha, c.revert_runner( sha, body.get("autobuild", True), body.get("autorestart", True))) self._job_reply(job) + elif path == "/api/update": + job = c.start_job("update", "", c.update_runner( + body.get("autobuild", True), body.get("autorestart", True))) + self._job_reply(job) elif path == "/api/settings": problems, clean = c.validate_settings(body.get("values") or {}) if problems: diff --git a/contrib/vibes/index.html b/contrib/vibes/index.html index 290b9a7417ca..c090d687ac94 100644 --- a/contrib/vibes/index.html +++ b/contrib/vibes/index.html @@ -104,6 +104,14 @@ .setup-step{margin-top:12px;font-family:var(--mono);font-size:13.5px;color:var(--ink-2)} body.is-setup .tablet,body.is-setup #book-section{opacity:.45;pointer-events:none} + /* ---- upstream has moved ---- */ + .update{display:flex;align-items:center;gap:16px;flex-wrap:wrap; + border:1px solid var(--gold-line);background:var(--marble-3); + border-radius:3px;padding:14px 18px;margin-bottom:20px} + .update-text{flex:1;min-width:220px;color:var(--ink);font-size:16px} + .update-text b{color:var(--gold-ink)} + .update-go{min-height:40px;padding:9px 20px;font-size:13px} + /* ---- the engine notice ---- */ .engine{border:2px solid var(--porphyry);background:#fdf6f4;border-radius:2px; padding:15px 18px;margin-bottom:20px} @@ -382,6 +390,11 @@
starting…
+ +