// <<@file update_release.py>>=
import argparse
import base64
import hashlib
import json
import os
import re
import shutil
import subprocess
import time
import urllib.error
import urllib.request
from pathlib import Path
PACKAGING = Path(__file__).parent
REPO_ROOT = PACKAGING.parent
MAINTAINER = "Gianni Ferrarotti <gianni.ferrarotti@gmail.com>"
DESCRIPTION = "Bidirectional literate programming toolchain (noweb, macros, source tracing)"
HOMEPAGE = "https://github.com/giannifer7/weaveback"
RELEASES = f"{HOMEPAGE}/releases/download"
REPO = "giannifer7/weaveback"
API = "https://api.github.com"
NEEDED_ASSETS = [
"weaveback-x86_64-linux.tar.gz",
"weaveback-macro-musl",
"weaveback-tangle-musl",
"weaveback-docgen-musl",
"wb-tangle-musl",
"wb-query-musl",
"wb-serve-musl",
"wb-mcp-musl",
]
NEEDED_ASSET_PATTERNS = [
r"^weaveback_agent-.*-cp314-cp314-manylinux.*_x86_64\.whl$",
r"^weaveback_agent-.*-cp314-cp314-musllinux.*_x86_64\.whl$",
r"^weaveback_agent-.*-cp314-cp314-win_amd64\.whl$",
]
def gh_token() -> str:
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
if token:
return token
result = subprocess.run(["gh", "auth", "token"], capture_output=True, text=True)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
raise SystemExit(
"No GitHub token found. Set GH_TOKEN/GITHUB_TOKEN or run 'gh auth login'."
)
def api_get(path: str, token: str) -> dict:
req = urllib.request.Request(
f"{API}{path}",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read())
def download_asset(url: str, token: str) -> bytes:
req = urllib.request.Request(
url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/octet-stream",
},
)
with urllib.request.urlopen(req, timeout=120) as r:
return r.read()
def wait_for_release(version: str, token: str, timeout: int = 1800, poll: int = 20) -> dict:
tag = f"v{version}"
deadline = time.monotonic() + timeout
print(f"Waiting for GitHub release {tag} assets", end="", flush=True)
while time.monotonic() < deadline:
try:
data = api_get(f"/repos/{REPO}/releases/tags/{tag}", token)
names = {a["name"] for a in data.get("assets", [])}
if (
all(a in names for a in NEEDED_ASSETS)
and all(any(re.match(pattern, name) for name in names) for pattern in NEEDED_ASSET_PATTERNS)
):
print(" ready.")
return data
except (urllib.error.URLError, TimeoutError):
pass print(".", end="", flush=True)
time.sleep(poll)
raise SystemExit(f"\nTimed out after {timeout}s waiting for release assets.")
def fetch_assets(release: dict, token: str) -> dict[str, bytes]:
by_name = {a["name"]: a["url"] for a in release["assets"]}
result = {}
for name in NEEDED_ASSETS:
print(f" Downloading {name}...")
result[name] = download_asset(by_name[name], token)
return result
def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def sha256_sri(data: bytes) -> str:
return "sha256-" + base64.b64encode(hashlib.sha256(data).digest()).decode()
def pkgbuild(version: str, tarball_sha256: str) -> str:
source = f"{RELEASES}/v${{pkgver}}/weaveback-x86_64-linux.tar.gz"
return f"""\
# Maintainer: {MAINTAINER}
#
# AUR package for weaveback — bidirectional literate programming toolchain.
# Installs the split CLI plus supporting tools from the pre-built x86_64
# tarball on the GitHub release.
#
# Regenerate after each release:
# python packaging/update_release.py <version>
pkgname=weaveback-bin
pkgver={version}
pkgrel=1
pkgdesc="{DESCRIPTION}"
url="{HOMEPAGE}"
license=('0BSD' 'MIT' 'Apache-2.0')
arch=('x86_64')
provides=('weaveback' 'wb-tangle' 'wb-query' 'wb-serve' 'wb-mcp')
conflicts=('weaveback' 'weaveback-git')
depends=('gcc-libs' 'glibc')
options=('!debug')
source=("weaveback-x86_64-linux.tar.gz::{source}")
sha256sums=('{tarball_sha256}')
package() {{
install -Dm755 weaveback-macro -t "${{pkgdir}}/usr/bin"
install -Dm755 weaveback-tangle -t "${{pkgdir}}/usr/bin"
install -Dm755 weaveback-docgen -t "${{pkgdir}}/usr/bin"
install -Dm755 wb-tangle -t "${{pkgdir}}/usr/bin"
install -Dm755 wb-query -t "${{pkgdir}}/usr/bin"
install -Dm755 wb-serve -t "${{pkgdir}}/usr/bin"
install -Dm755 wb-mcp -t "${{pkgdir}}/usr/bin"
}}
"""
def flake(version: str, tarball_sha256: str, sri: dict) -> str:
base = f"{RELEASES}/v${{version}}"
return f"""\
{{
description = "{DESCRIPTION}";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = {{ self, nixpkgs }}:
let
lib = nixpkgs.lib;
version = "{version}";
base = "{base}";
# Pre-built musl binaries are x86_64-linux only.
# They package the split CLI and supporting tools.
#
# The PyO3 extension is intentionally *not* exposed here as a pre-built
# Nix package because it is Python-ABI- and platform-specific: for that
# side we want wheels or a source build inside a dev shell, not a single
# "universal musl" artifact.
#
# The devShell works on all common systems and includes the Python build
# and lint tools needed for python/weaveback-agent and crates/weaveback-py.
devSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forEachDevSystem = f: lib.genAttrs devSystems (s: f nixpkgs.legacyPackages.${{s}});
linuxPkgs = nixpkgs.legacyPackages.x86_64-linux;
releaseBin = {{ pname, sha256 }}: linuxPkgs.stdenv.mkDerivation {{
inherit pname version;
src = linuxPkgs.fetchurl {{ url = "${{base}}/${{pname}}-musl"; inherit sha256; }};
dontUnpack = true;
installPhase = "install -Dm755 $src $out/bin/${{pname}}";
}};
cliBundle = linuxPkgs.stdenv.mkDerivation {{
pname = "weaveback-cli";
inherit version;
src = linuxPkgs.fetchurl {{ url = "${{base}}/weaveback-x86_64-linux.tar.gz"; sha256 = "{tarball_sha256}"; }};
dontUnpack = false;
installPhase = ''
install -Dm755 wb-tangle $out/bin/wb-tangle
install -Dm755 wb-query $out/bin/wb-query
install -Dm755 wb-serve $out/bin/wb-serve
install -Dm755 wb-mcp $out/bin/wb-mcp
install -Dm755 weaveback-macro $out/bin/weaveback-macro
install -Dm755 weaveback-tangle $out/bin/weaveback-tangle
install -Dm755 weaveback-docgen $out/bin/weaveback-docgen
'';
}};
in {{
packages.x86_64-linux = {{
default = cliBundle;
weaveback-macro = releaseBin {{ pname = "weaveback-macro"; sha256 = "{sri['weaveback-macro-musl']}"; }};
weaveback-tangle = releaseBin {{ pname = "weaveback-tangle"; sha256 = "{sri['weaveback-tangle-musl']}"; }};
weaveback-docgen = releaseBin {{ pname = "weaveback-docgen"; sha256 = "{sri['weaveback-docgen-musl']}"; }};
wb-tangle = releaseBin {{ pname = "wb-tangle"; sha256 = "{sri['wb-tangle-musl']}"; }};
wb-query = releaseBin {{ pname = "wb-query"; sha256 = "{sri['wb-query-musl']}"; }};
wb-serve = releaseBin {{ pname = "wb-serve"; sha256 = "{sri['wb-serve-musl']}"; }};
wb-mcp = releaseBin {{ pname = "wb-mcp"; sha256 = "{sri['wb-mcp-musl']}"; }};
}};
# Full documentation + development toolchain.
# Usage: nix develop
devShells = forEachDevSystem (pkgs: {{
default = pkgs.mkShell {{
buildInputs = with pkgs; [
just # task runner
plantuml # UML diagrams via --plantuml-jar (brings JDK)
nodejs # TypeScript bundle for the serve UI
python3 # packaging scripts and Python project runtime
uv # Python package / tool runner
maturin # PyO3 build frontend
ruff # Python formatter / linter
mypy # Python static typing
pylint # Python lint baseline
git
];
shellHook = ''
echo ""
echo "weaveback dev shell — available recipes:"
echo " just tangle regenerate source files from .adoc"
echo " just docs render HTML documentation"
echo " just serve live-reload server with inline editor"
echo " just test run all tests"
echo " just py-check build + lint + test the Python agent bridge"
if [ -f pyproject.toml ]; then
echo " syncing Python project with uv..."
if ! uv sync --project . --all-groups; then
echo " warning: uv sync failed; continuing with the shell environment"
fi
fi
echo ""
'';
}};
}});
}};
}}
"""
def read_cargo_version() -> str:
text = (REPO_ROOT / "Cargo.toml").read_text()
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
if not m:
raise SystemExit("Could not read version from Cargo.toml")
return m.group(1)
def run(args: list, cwd: Path) -> None:
subprocess.run(args, cwd=cwd, check=True)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("version", nargs="?",
help="Release version (default: read from Cargo.toml)")
parser.add_argument("--tag", action="store_true",
help="Commit Cargo.lock, push the git tag, then wait for CI")
parser.add_argument("--retag", action="store_true",
help="Delete existing tag, then do --tag (re-triggers CI)")
parser.add_argument("--dry-run", action="store_true",
help="Write files but skip all git/AUR steps")
args = parser.parse_args()
version = (args.version.lstrip("v") if args.version else read_cargo_version())
aur_dir = REPO_ROOT.parent / "aur-weaveback-bin"
token = gh_token()
print(f"Releasing v{version}...")
if args.retag:
tag = f"v{version}"
subprocess.run(["git", "push", "--delete", "origin", tag], cwd=REPO_ROOT)
subprocess.run(["git", "tag", "-d", tag], cwd=REPO_ROOT)
args.tag = True
if args.tag:
run(["cargo", "build"], cwd=REPO_ROOT) run(["git", "add", "project/project.adoc", "Cargo.toml", "Cargo.lock"], cwd=REPO_ROOT)
result = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=REPO_ROOT)
if result.returncode != 0:
run(["git", "commit", "-m", f"chore: release v{version}"], cwd=REPO_ROOT)
run(["git", "push", "origin", "main"], cwd=REPO_ROOT)
print(f"Tagging v{version}...")
run(["git", "tag", "-a", f"v{version}", "-m", f"v{version}"], cwd=REPO_ROOT)
run(["git", "push", "origin", f"v{version}"], cwd=REPO_ROOT)
release = wait_for_release(version, token)
assets = fetch_assets(release, token)
tarball = assets["weaveback-x86_64-linux.tar.gz"]
sri = {name: sha256_sri(data) for name, data in assets.items() if name != "weaveback-x86_64-linux.tar.gz"}
(aur_dir / "PKGBUILD").write_text(pkgbuild(version, sha256_hex(tarball)))
print(" Written aur-weaveback-bin/PKGBUILD")
(REPO_ROOT / "flake.nix").write_text(flake(version, sha256_sri(tarball), sri))
print(" Written flake.nix")
if args.dry_run:
print("\nDry run — skipping git and AUR steps.")
return
print("\nCommitting weaveback repo...")
run(["git", "add", "flake.nix"], cwd=REPO_ROOT)
has_changes = subprocess.run(
["git", "diff", "--cached", "--quiet"], cwd=REPO_ROOT
).returncode != 0
if has_changes:
run(["git", "commit", "-m", f"chore: release v{version}"], cwd=REPO_ROOT)
run(["git", "push", "origin", "main"], cwd=REPO_ROOT)
else:
print(" Nothing changed — skipping commit and push.")
print("\nUpdating AUR package...")
srcinfo = subprocess.run(
["makepkg", "--printsrcinfo"],
cwd=aur_dir, check=True, capture_output=True, text=True,
).stdout
(aur_dir / ".SRCINFO").write_text(srcinfo)
run(["git", "add", "PKGBUILD", ".SRCINFO"], cwd=aur_dir)
has_aur_changes = subprocess.run(
["git", "diff", "--cached", "--quiet"], cwd=aur_dir
).returncode != 0
if has_aur_changes:
run(["git", "commit", "-m", f"Release {version}"], cwd=aur_dir)
run(["git", "push"], cwd=aur_dir)
else:
print(" AUR unchanged — skipping commit and push.")
print(f"\nDone. Released v{version}.")
if __name__ == "__main__":
main()
// @