open-standards/wai/wai-quantum-desktop/release/manifests.py
Transaction Science d41ee743d6 wai-quantum-desktop release: the cask it writes passes brew style
The 0.1.1 cask had three findings in the tap: no blank line between each disk
image's sha256 and its url, and no depends_on :macos. The writer now puts both
in, and manifests_test.py checks them. Written from the 0.1.1 release's own
assets, the cask has no finding, and the Scoop manifest and latest.json are
byte for byte the ones published. (A minimum version is not given: the Intel
build states macOS 10.13, below what Homebrew supports, so brew style calls
it redundant.)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 22:19:02 -05:00

175 lines
6.9 KiB
Python

"""The package managers' entries and the update manifest for a Quantum Studio
desktop release, from the assets attached to it:
python3 release/manifests.py <version> <tag> <forge-api> <owner/repo> <public-url> <out-dir>
Writes into <out-dir>:
wai-quantum-desktop.rb a Homebrew cask (macOS), from the disk images and their SHA-256 files
(wai-quantum-desktop.rb.in);
wai-quantum-desktop.json a Scoop manifest (Windows), from the portable archive and its SHA-256
(wai-quantum-desktop.json.in);
latest.json the update manifest, from the update signatures: written only when
the builds were signed for updates. The website serves it to the app
(its scripts/sync-desktop-update.sh takes it from this release).
A platform whose assets are not attached is left out, and stderr says so; with nothing at
all, it fails. The cask says `auto_updates true` only when the macOS builds were signed for
updates, since only then does the app update itself.
The environment: FORGE_TOKEN (optional) for the API; ASSETS_DIR reads the assets from a
local folder instead of the release (the test does); MACOS_NOTARISED=true leaves out the
cask's caveat for an app signed ad hoc.
"""
import json
import os
import pathlib
import string
import sys
import urllib.request
version, tag, api, repo, public, out = sys.argv[1:7]
here = pathlib.Path(__file__).resolve().parent
token = os.environ.get("FORGE_TOKEN", "")
# A user agent of our own: the public address refuses the library's default one.
headers = {"User-Agent": "wai-quantum-release"}
if token:
headers["Authorization"] = f"token {token}"
# The forge's own address, as the API's: assets are read there, over the private network,
# not through the public address the entries point users to.
web = api.rsplit("/api/v1", 1)[0]
local = os.environ.get("ASSETS_DIR")
notarised = os.environ.get("MACOS_NOTARISED", "") == "true"
def get(url):
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=60) as r:
return r.read()
if local:
names = set(os.listdir(local))
published = None
def read(name):
return pathlib.Path(local, name).read_text()
else:
release = json.loads(get(f"{api}/repos/{repo}/releases/tags/{tag}"))
names = {a["name"] for a in release.get("assets", [])}
published = release.get("published_at") or release.get("created_at")
def read(name):
return get(f"{web}/{repo}/releases/download/{tag}/{name}").decode()
def asset(triple, suffix):
return f"wai-quantum-desktop-{version}-{triple}{suffix}"
def url(name):
return f"{public}/releases/download/{tag}/{name}"
def digest(name):
"""The SHA-256 its .sha256 file states, or None when either is missing."""
if name not in names or name + ".sha256" not in names:
print(f"note: {name} is not attached; left out", file=sys.stderr)
return None
return read(name + ".sha256").split()[0]
def signature(name):
"""Its update signature (the .sig file's text, base64), or None."""
if name not in names:
return None
if name + ".sig" not in names:
print(f"note: {name} has no update signature; no update for it", file=sys.stderr)
return None
return read(name + ".sig").strip()
out_dir = pathlib.Path(out)
out_dir.mkdir(parents=True, exist_ok=True)
wrote = []
# The update manifest: each platform's update, under the names the updater looks for
# ({os}-{arch}-{installer}, then {os}-{arch}).
updates = [
(["darwin-aarch64"], "aarch64-apple-darwin", ".app.tar.gz"),
(["darwin-x86_64"], "x86_64-apple-darwin", ".app.tar.gz"),
(["linux-x86_64", "linux-x86_64-appimage"], "x86_64-unknown-linux-gnu", ".AppImage"),
(["linux-x86_64-deb"], "x86_64-unknown-linux-gnu", ".deb"),
(["linux-x86_64-rpm"], "x86_64-unknown-linux-gnu", ".rpm"),
(["linux-aarch64", "linux-aarch64-appimage"], "aarch64-unknown-linux-gnu", ".AppImage"),
(["linux-aarch64-deb"], "aarch64-unknown-linux-gnu", ".deb"),
(["linux-aarch64-rpm"], "aarch64-unknown-linux-gnu", ".rpm"),
(["windows-x86_64", "windows-x86_64-nsis"], "x86_64-pc-windows-msvc", "-setup.exe"),
]
platforms = {}
for keys, triple, suffix in updates:
name = asset(triple, suffix)
sig = signature(name)
if sig:
for k in keys:
platforms[k] = {"signature": sig, "url": url(name)}
if platforms:
manifest = {"version": version, "notes": f"Quantum Studio {version}: {public}/releases/tag/{tag}"}
if published:
manifest["pub_date"] = published
manifest["platforms"] = dict(sorted(platforms.items()))
(out_dir / "latest.json").write_text(json.dumps(manifest, indent=2) + "\n")
wrote.append(f"the update manifest ({', '.join(sorted(platforms))})")
else:
print("note: no update is signed (the release had no update key), so there is no update manifest", file=sys.stderr)
# The Homebrew cask, for the disk images.
blocks = []
for cpu, triple in (("arm", "aarch64-apple-darwin"), ("intel", "x86_64-apple-darwin")):
name = asset(triple, ".dmg")
sha = digest(name)
if sha:
blocks.append(
f' on_{cpu} do\n sha256 "{sha}"\n\n url "{url(name)}",\n'
f' verified: "{public.split("://", 1)[1]}/"\n end\n'
)
if blocks:
self_updating = any(k.startswith("darwin-") for k in platforms)
caveats = "" if notarised else (
'\n caveats <<~EOS\n'
' Quantum Studio is signed ad hoc and not yet notarised. If macOS blocks its first launch, run:\n'
' xattr -dr com.apple.quarantine "/Applications/Quantum Studio.app"\n'
' EOS\n'
)
cask = string.Template((here / "wai-quantum-desktop.rb.in").read_text()).substitute(
version=version,
arch_blocks="".join(blocks),
auto_updates=" auto_updates true\n" if self_updating else "",
caveats=caveats,
)
(out_dir / "wai-quantum-desktop.rb").write_text(cask)
wrote.append("the Homebrew cask" + (" (the app updates itself)" if self_updating else ""))
# The Scoop manifest, for the portable archive.
win = asset("x86_64-pc-windows-msvc", "")
win_sha = digest(win + ".zip")
if win_sha:
fill = {"version": version, "url": url(win + ".zip"), "hash": win_sha, "extract_dir": win}
def filled(v):
if isinstance(v, str):
return string.Template(v).substitute(fill)
if isinstance(v, list):
return [filled(x) for x in v]
if isinstance(v, dict):
return {k: filled(x) for k, x in v.items()}
return v
scoop = filled(json.loads((here / "wai-quantum-desktop.json.in").read_text()))
(out_dir / "wai-quantum-desktop.json").write_text(json.dumps(scoop, indent=2) + "\n")
wrote.append("the Scoop manifest")
if not wrote:
sys.exit("no installer is attached to the release")
print(f"wrote {', '.join(wrote)} for Quantum Studio {version}")