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]>
175 lines
6.9 KiB
Python
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}")
|