restructure
Check and Windows preview / check (patina) (push) Successful in 2m20s
Check and Windows preview / check (rivet) (push) Successful in 1m38s
Check and Windows preview / previews (push) Successful in 1m6s
Check and Windows preview / windows (push) Successful in 1m50s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 7s
Independent plugin release / build (push) Successful in 2m21s
Check and Windows preview / check (patina) (push) Successful in 2m20s
Check and Windows preview / check (rivet) (push) Successful in 1m38s
Check and Windows preview / previews (push) Successful in 1m6s
Check and Windows preview / windows (push) Successful in 1m50s
Independent plugin release / publish (push) Successful in 8s
Independent plugin release / plan (push) Successful in 7s
Independent plugin release / build (push) Successful in 2m21s
This commit is contained in:
1 parent
3c5b05fdec
commit
15853e61ac
87 files changed
+2065
-1198
No files matched your search
@@ -12,8 +12,8 @@ import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
parser = argparse.ArgumentParser(description='Measure relative level and attack/texture changes in local FLAC references. Requires ffmpeg, numpy, and matplotlib. Does not transcribe notes or infer emotion.')
|
||||
parser.add_argument('folder', nargs='?', default='.')
|
||||
parser.add_argument('--output', default='docs/reference-analysis')
|
||||
parser.add_argument('folder', nargs='?', default='references/devath')
|
||||
parser.add_argument('--output', default='docs/rivet/research/measurements')
|
||||
args = parser.parse_args()
|
||||
root = Path(args.folder)
|
||||
out = Path(args.output)
|
||||
|
||||
+24
-19
@@ -1,48 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build and package CLAP without modifying a DAW's plugin directories."""
|
||||
"""Build a catalogued CLAP plugin without modifying a DAW installation."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
catalog = json.loads((root / "plugins/catalog.json").read_text())
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--plugin", choices=["motif-storyboard", "resonance-compressor"], default="motif-storyboard", help="Plugin to package")
|
||||
parser.add_argument("--plugin", choices=catalog, default="rivet", help="Plugin to package")
|
||||
parser.add_argument("--target", help="Rust target triple; defaults to rustc's host")
|
||||
parser.add_argument("--standalone", action="store_true", help="Also build the standalone preview app")
|
||||
parser.add_argument("--standalone", action="store_true", help="Also build Rivet's standalone preview")
|
||||
args = parser.parse_args()
|
||||
product = catalog[args.plugin]
|
||||
target = args.target
|
||||
if target is None:
|
||||
info = subprocess.check_output(["rustc", "-vV"], text=True)
|
||||
target = next(line.removeprefix("host: ") for line in info.splitlines() if line.startswith("host: "))
|
||||
if "windows" not in target and "linux" not in target:
|
||||
parser.error("This milestone packages Windows and Linux only.")
|
||||
if args.standalone and args.plugin != "motif-storyboard":
|
||||
parser.error("Standalone is available only for Motif Storyboard.")
|
||||
manifest = root / "plugins" / args.plugin / "Cargo.toml"
|
||||
parser.error("Packaging supports Windows releases and native Linux development builds.")
|
||||
if args.standalone and "standalone" not in product:
|
||||
parser.error(f"{product['name']} has no standalone application.")
|
||||
manifest = root / product["manifest"]
|
||||
cmd = ["cargo", "build", "--manifest-path", str(manifest), "--release", "--locked", "--target", target, "--lib"]
|
||||
if args.standalone:
|
||||
cmd += ["--features", "standalone", "--bin", "motif-storyboard"]
|
||||
cmd += ["--features", "standalone", "--bin", product["standalone"]]
|
||||
subprocess.run(cmd, cwd=root, check=True)
|
||||
target_root = Path(os.environ.get("CARGO_TARGET_DIR", root / "plugins" / args.plugin / "target"))
|
||||
target_root = Path(os.environ.get("CARGO_TARGET_DIR", manifest.parent / "target"))
|
||||
if not target_root.is_absolute():
|
||||
target_root = root / target_root
|
||||
build = target_root / target / "release"
|
||||
destination = root / "dist" / target
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
stem = args.plugin.replace("-", "_")
|
||||
stem = product["library"]
|
||||
library = build / (f"{stem}.dll" if "windows" in target else f"lib{stem}.so")
|
||||
display = "Motif Storyboard" if args.plugin == "motif-storyboard" else "Resonance Compressor"
|
||||
plugin = destination / f"{display}.clap"
|
||||
plugin = destination / f"{product['name']}.clap"
|
||||
shutil.copy2(library, plugin)
|
||||
shutil.copy2(root / "LICENSE", destination)
|
||||
guide = "fl-studio.md" if args.plugin == "motif-storyboard" else "resonance-compressor.md"
|
||||
shutil.copy2(root / "docs" / guide, destination / f"{args.plugin}-setup.md")
|
||||
if args.plugin == "resonance-compressor":
|
||||
shutil.copy2(root / "crates/resonance-ui/assets/OFL.txt", destination / "Roboto-OFL.txt")
|
||||
shutil.copy2(root / "docs/resonance-editor.png", destination / "resonance-editor.png")
|
||||
for name in ["LICENSE", "README.md", "docs/development.md", "docs/releases.md"]:
|
||||
output = destination / name
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(root / name, output)
|
||||
shutil.copytree(root / product["documentation"], destination / product["documentation"], dirs_exist_ok=True)
|
||||
for source, name in product.get("licenses", {}).items():
|
||||
output = destination / name
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(root / source, output)
|
||||
if args.standalone:
|
||||
exe = "motif-storyboard.exe" if "windows" in target else "motif-storyboard"
|
||||
exe = product["standalone"] + (".exe" if "windows" in target else "")
|
||||
shutil.copy2(build / exe, destination)
|
||||
print(f"Packaged {plugin}")
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check one product without compiling the other product's engine or wrapper."""
|
||||
import argparse
|
||||
import subprocess
|
||||
from plugin_catalog import ROOT, catalog
|
||||
|
||||
products = catalog()
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--plugin", required=True, choices=products)
|
||||
args = parser.parse_args()
|
||||
product = products[args.plugin]
|
||||
packages = [part for name in product["packages"] for part in ["-p", name]]
|
||||
commands = [
|
||||
["cargo", "fmt", *packages, "--", "--check"],
|
||||
["cargo", "test", *packages, "--locked"],
|
||||
["cargo", "clippy", *packages, "--all-targets", "--locked", "--", "-D", "warnings"],
|
||||
["cargo", "fmt", "--manifest-path", product["manifest"], "--", "--check"],
|
||||
["cargo", "test", "--manifest-path", product["manifest"], "--locked"],
|
||||
["cargo", "clippy", "--manifest-path", product["manifest"], "--all-targets", "--locked", "--", "-D", "warnings"],
|
||||
]
|
||||
for command in commands:
|
||||
subprocess.run(command, cwd=ROOT, check=True)
|
||||
+59
-21
@@ -1,29 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate and archive the two Windows CLAP deliverables; no Linux output."""
|
||||
"""Package a combined preview bundle or an independently versioned plugin."""
|
||||
import argparse
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import zipfile
|
||||
from plugin_catalog import ROOT, archive_name, catalog, version
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
folder = root / "dist/x86_64-pc-windows-gnu"
|
||||
names = [
|
||||
"Motif Storyboard.clap", "Resonance Compressor.clap", "LICENSE",
|
||||
"motif-storyboard-setup.md", "resonance-compressor-setup.md", "Roboto-OFL.txt", "resonance-editor.png",
|
||||
]
|
||||
for name in names[:2]:
|
||||
data = (folder / name).read_bytes()
|
||||
if data[:2] != b"MZ":
|
||||
raise SystemExit(f"{name}: expected Windows PE binary")
|
||||
def pe_check(name, data):
|
||||
if len(data) < 64 or data[:2] != b"MZ":
|
||||
raise ValueError(f"{name}: expected Windows PE binary")
|
||||
offset = struct.unpack_from("<I", data, 0x3C)[0]
|
||||
if data[offset:offset + 4] != b"PE\0\0" or struct.unpack_from("<H", data, offset + 4)[0] != 0x8664:
|
||||
raise SystemExit(f"{name}: expected x86-64 Windows DLL")
|
||||
if offset + 24 > len(data) or data[offset:offset+4] != b"PE\0\0" or struct.unpack_from("<H", data, offset+4)[0] != 0x8664:
|
||||
raise ValueError(f"{name}: expected x86-64 Windows DLL")
|
||||
if b"clap_entry\0" not in data:
|
||||
raise SystemExit(f"{name}: CLAP entry export missing")
|
||||
checksums = "".join(f"{hashlib.sha256((folder / name).read_bytes()).hexdigest()} {name}\n" for name in names)
|
||||
(folder / "SHA256SUMS").write_text(checksums, encoding="utf-8")
|
||||
archive = root / "dist/fl-plugins-windows-x86_64.zip"
|
||||
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as output:
|
||||
for name in names + ["SHA256SUMS"]:
|
||||
output.write(folder / name, arcname=name)
|
||||
print(f"Packaged {archive}")
|
||||
raise ValueError(f"{name}: CLAP entry export missing")
|
||||
|
||||
def package(target="x86_64-pc-windows-gnu", plugin=None, root=ROOT):
|
||||
products = catalog(root)
|
||||
selected = {plugin: products[plugin]} if plugin else products
|
||||
folder = root / "dist" / target
|
||||
names = ["LICENSE", "docs/development.md", "docs/releases.md"]
|
||||
for product in selected.values():
|
||||
name = f"{product['name']}.clap"
|
||||
pe_check(name, (folder / name).read_bytes())
|
||||
names.append(name)
|
||||
names.extend(p.relative_to(root).as_posix() for p in (root / product["documentation"]).rglob("*") if p.is_file())
|
||||
names.extend(product.get("licenses", {}).values())
|
||||
payload = {name: (folder / name).read_bytes() for name in sorted(set(names))}
|
||||
if plugin:
|
||||
product = selected[plugin]
|
||||
release_version = version(plugin, root)
|
||||
filename = archive_name(plugin, release_version)
|
||||
payload["README.md"] = (
|
||||
f"# {product['name']} {release_version}\n\n{product['description']}.\n\n"
|
||||
f"Copy {product['name']}.clap into C:\\Program Files\\Common Files\\CLAP\\ "
|
||||
"and rescan plugins in FL Studio 2024.1 or newer.\n\n"
|
||||
f"See [{product['name']} guide]({product['documentation']}/README.md).\n"
|
||||
).encode()
|
||||
else:
|
||||
filename = "rivet-patina-windows-x86_64.zip"
|
||||
payload["README.md"] = (folder / "README.md").read_bytes()
|
||||
checksums = "".join(f"{hashlib.sha256(data).hexdigest()} {name}\n" for name, data in sorted(payload.items()))
|
||||
payload["SHA256SUMS"] = checksums.encode()
|
||||
archive = root / "dist" / filename
|
||||
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as output:
|
||||
for name, data in sorted(payload.items()):
|
||||
info = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
output.writestr(info, data)
|
||||
# Stable ZIP metadata makes repeated packaging of identical files reproducible.
|
||||
sidecar = archive.with_suffix(".zip.sha256")
|
||||
sidecar.write_text(f"{hashlib.sha256(archive.read_bytes()).hexdigest()} {archive.name}\n", encoding="utf-8")
|
||||
print(f"Packaged {archive}")
|
||||
return archive
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--plugin", choices=catalog(), help="Omit for a combined development bundle")
|
||||
parser.add_argument("--target", default="x86_64-pc-windows-gnu", choices=["x86_64-pc-windows-gnu", "x86_64-pc-windows-msvc"])
|
||||
args = parser.parse_args()
|
||||
package(args.target, args.plugin)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Shared product metadata and independent release versions (Python 3.11+)."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def catalog(root=ROOT):
|
||||
return json.loads((root / "plugins/catalog.json").read_text())
|
||||
|
||||
|
||||
def version(plugin, root=ROOT):
|
||||
product = catalog(root)[plugin]
|
||||
return tomllib.loads((root / product["manifest"]).read_text())["package"]["version"]
|
||||
|
||||
|
||||
def tag_version(plugin, tag, root=ROOT):
|
||||
match = re.fullmatch(re.escape(plugin) + r"-v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)", tag)
|
||||
if not match or match[1] != version(plugin, root):
|
||||
raise ValueError(f"Expected {plugin}-v{version(plugin, root)}, got {tag!r}")
|
||||
return match[1]
|
||||
|
||||
|
||||
def archive_name(plugin, release_version):
|
||||
return f"{plugin}-windows-x86_64-{release_version}.zip"
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate independent product tags and publish Windows assets to Gitea."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
import zipfile
|
||||
from plugin_catalog import ROOT, archive_name, catalog, tag_version
|
||||
|
||||
class SafeRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, request, fp, code, msg, headers, newurl):
|
||||
redirect = super().redirect_request(request, fp, code, msg, headers, newurl)
|
||||
if redirect and urllib.parse.urlsplit(request.full_url)[:2] != urllib.parse.urlsplit(newurl)[:2]:
|
||||
redirect.remove_header("Authorization")
|
||||
return redirect
|
||||
|
||||
class Gitea:
|
||||
def __init__(self, server, repository, token):
|
||||
parts = repository.split("/")
|
||||
if len(parts) != 2 or not all(parts):
|
||||
raise ValueError("GITEA_REPOSITORY must be owner/repository")
|
||||
self.server = server.rstrip("/")
|
||||
self.base = self.server + "/api/v1/repos/" + "/".join(urllib.parse.quote(p, safe="") for p in parts)
|
||||
self.token = token
|
||||
self.opener = urllib.request.build_opener(SafeRedirect())
|
||||
|
||||
def fetch(self, method, url, data=None, content_type=None, missing=False):
|
||||
headers = {}
|
||||
if urllib.parse.urlsplit(url)[:2] == urllib.parse.urlsplit(self.server)[:2]:
|
||||
headers["Authorization"] = "token " + self.token
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with self.opener.open(request, timeout=120) as response:
|
||||
return response.read()
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code == 404 and missing:
|
||||
return None
|
||||
raise RuntimeError(f"Gitea {method}: HTTP {error.code}") from None
|
||||
|
||||
def request(self, method, path, data=None, missing=False):
|
||||
raw = self.fetch(method, self.base + path,
|
||||
json.dumps(data).encode() if data is not None else None,
|
||||
"application/json" if data is not None else None, missing)
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
def download(self, url):
|
||||
return self.fetch("GET", url)
|
||||
|
||||
def upload(self, release_id, path):
|
||||
boundary = "rivet-patina-" + uuid.uuid4().hex
|
||||
body = (
|
||||
f"--{boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{path.name}\"\r\n"
|
||||
"Content-Type: application/octet-stream\r\n\r\n"
|
||||
).encode() + path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
|
||||
url = self.base + f"/releases/{release_id}/assets?name=" + urllib.parse.quote(path.name, safe="")
|
||||
return json.loads(self.fetch("POST", url, body, f"multipart/form-data; boundary={boundary}"))
|
||||
|
||||
def release_notes(plugin, release_version, root=ROOT):
|
||||
notes = (root / "docs" / plugin / "CHANGELOG.md").read_text()
|
||||
match = re.search(r"^## " + re.escape(release_version) + r"\s*\n(.*?)(?=^## |\Z)", notes, re.M | re.S)
|
||||
if not match:
|
||||
raise ValueError(f"Add a {release_version} section to docs/{plugin}/CHANGELOG.md")
|
||||
return match[1].strip()
|
||||
|
||||
def plan(tag, root=ROOT):
|
||||
tag = tag.removeprefix("refs/tags/")
|
||||
plugin = next((p for p in catalog(root) if tag.startswith(p + "-v")), None)
|
||||
if plugin is None:
|
||||
raise ValueError("Use a rivet-v<version> or patina-v<version> tag")
|
||||
release_version = tag_version(plugin, tag, root)
|
||||
release_notes(plugin, release_version, root)
|
||||
return {"plugin": plugin, "version": release_version, "tag": tag,
|
||||
"archive": archive_name(plugin, release_version)}
|
||||
|
||||
def publish(plugin, tag, client, root=ROOT):
|
||||
metadata = plan(tag, root)
|
||||
if metadata["plugin"] != plugin:
|
||||
raise ValueError("Selected plugin does not match the release tag")
|
||||
tag = metadata["tag"]
|
||||
product = catalog(root)[plugin]
|
||||
archive = root / "dist" / metadata["archive"]
|
||||
sidecar = archive.with_suffix(".zip.sha256")
|
||||
expected = f"{hashlib.sha256(archive.read_bytes()).hexdigest()} {archive.name}\n"
|
||||
if sidecar.read_text() != expected:
|
||||
raise ValueError("Archive checksum does not match its sidecar")
|
||||
with zipfile.ZipFile(archive) as package:
|
||||
if package.testzip() is not None or [n for n in package.namelist() if n.endswith(".clap")] != [f"{product['name']}.clap"]:
|
||||
raise ValueError("Release ZIP must contain exactly the selected plugin")
|
||||
sums = package.read("SHA256SUMS").decode().splitlines()
|
||||
for line in sums:
|
||||
digest, name = line.split(" ", 1)
|
||||
if hashlib.sha256(package.read(name)).hexdigest() != digest:
|
||||
raise ValueError("Internal package checksum mismatch")
|
||||
escaped = urllib.parse.quote(tag, safe="")
|
||||
# Do not let CreateRelease silently manufacture a missing repository tag.
|
||||
if client.request("GET", "/tags/" + escaped, missing=True) is None:
|
||||
raise ValueError("The release tag must already exist in Gitea")
|
||||
release = client.request("GET", "/releases/tags/" + escaped, missing=True)
|
||||
if release is None:
|
||||
release = client.request("POST", "/releases", {
|
||||
"name": f"{product['name']} {metadata['version']}", "tag_name": tag,
|
||||
"body": release_notes(plugin, metadata["version"], root),
|
||||
"draft": True, "prerelease": "-" in metadata["version"],
|
||||
})
|
||||
assets = release.get("assets", [])
|
||||
for path in [archive, sidecar]:
|
||||
existing = next((asset for asset in assets if asset["name"] == path.name), None)
|
||||
if existing is not None:
|
||||
if client.download(existing["browser_download_url"]) != path.read_bytes():
|
||||
raise ValueError(f"Refusing to replace release asset with different content: {path.name}")
|
||||
else:
|
||||
client.upload(release["id"], path)
|
||||
# New releases remain drafts until both assets have uploaded successfully.
|
||||
if release.get("draft"):
|
||||
client.request("PATCH", f"/releases/{release['id']}", {"draft": False})
|
||||
print(f"Published {tag}: {archive.name}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=["plan", "validate", "publish"])
|
||||
parser.add_argument("--plugin", choices=catalog())
|
||||
parser.add_argument("--tag", required=True)
|
||||
args = parser.parse_args()
|
||||
metadata = plan(args.tag)
|
||||
if args.plugin is not None and metadata["plugin"] != args.plugin:
|
||||
raise ValueError("Selected plugin does not match the release tag")
|
||||
if args.action in ["plan", "validate"]:
|
||||
print(f"Validated {metadata['tag']}")
|
||||
output = os.environ.get("GITHUB_OUTPUT") or os.environ.get("GITEA_OUTPUT")
|
||||
if args.action == "plan" and output:
|
||||
with open(output, "a", encoding="utf-8") as stream:
|
||||
for name, value in metadata.items():
|
||||
stream.write(f"{name}={value}\n")
|
||||
return
|
||||
if os.environ.get("GITEA_REF") != "refs/tags/" + metadata["tag"]:
|
||||
raise ValueError("Publishing requires a matching Gitea tag workflow")
|
||||
client = Gitea(os.environ["GITEA_SERVER_URL"], os.environ["GITEA_REPOSITORY"], os.environ["GITEA_TOKEN"])
|
||||
publish(metadata["plugin"], metadata["tag"], client)
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (ValueError, RuntimeError, KeyError, OSError) as error:
|
||||
raise SystemExit(str(error)) from None
|
||||
@@ -0,0 +1,187 @@
|
||||
import contextlib
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from plugin_catalog import archive_name, tag_version
|
||||
import release
|
||||
|
||||
spec = importlib.util.spec_from_file_location("package_windows", Path(__file__).resolve().parents[1] / "package-windows.py")
|
||||
packaging = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(packaging)
|
||||
|
||||
class FakeGitea:
|
||||
def __init__(self):
|
||||
self.release = None
|
||||
self.bytes = {}
|
||||
self.calls = []
|
||||
self.tag_exists = True
|
||||
self.fail_upload = False
|
||||
|
||||
def request(self, method, path, data=None, missing=False):
|
||||
self.calls.append((method, path, data))
|
||||
if path.startswith("/tags/"):
|
||||
return {"name": path.split("/")[-1]} if self.tag_exists else None
|
||||
if method == "GET":
|
||||
return self.release
|
||||
if method == "POST" and path == "/releases":
|
||||
self.release = dict(data, id=7, assets=[])
|
||||
return self.release
|
||||
if method == "PATCH":
|
||||
self.release.update(data)
|
||||
return self.release
|
||||
raise AssertionError((method, path))
|
||||
|
||||
def upload(self, release_id, path):
|
||||
if self.fail_upload and path.name.endswith(".sha256"):
|
||||
raise RuntimeError("simulated interruption")
|
||||
self.calls.append(("UPLOAD", path.name, None))
|
||||
url = "https://gitea.invalid/download/" + path.name
|
||||
self.bytes[url] = path.read_bytes()
|
||||
asset = {"name": path.name, "browser_download_url": url}
|
||||
self.release["assets"].append(asset)
|
||||
return asset
|
||||
|
||||
def download(self, url):
|
||||
return self.bytes[url]
|
||||
|
||||
class ReleaseTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.folder = self.root / "dist/x86_64-pc-windows-gnu"
|
||||
self.folder.mkdir(parents=True)
|
||||
catalog = {}
|
||||
for plugin, name, version in [("rivet", "Rivet", "0.3.0"), ("patina", "Patina", "0.1.0")]:
|
||||
manifest = f"plugins/{plugin}/plugin/Cargo.toml"
|
||||
catalog[plugin] = {"name": name, "description": name + " processor", "manifest": manifest,
|
||||
"documentation": f"docs/{plugin}"}
|
||||
self.write(manifest, f'[package]\nname="{plugin}"\nversion="{version}"\n')
|
||||
self.write(f"docs/{plugin}/README.md", name + " guide")
|
||||
self.write(f"docs/{plugin}/CHANGELOG.md", f"# Changelog\n\n## {version}\n\nFirst {name} release.\n")
|
||||
self.write(f"dist/x86_64-pc-windows-gnu/docs/{plugin}/README.md", name + " guide")
|
||||
self.write(f"dist/x86_64-pc-windows-gnu/docs/{plugin}/CHANGELOG.md", f"# Changelog\n\n## {version}\n\nFirst {name} release.\n")
|
||||
binary = bytearray(160)
|
||||
binary[:2] = b"MZ"
|
||||
struct.pack_into("<I", binary, 0x3C, 64)
|
||||
binary[64:68] = b"PE\0\0"
|
||||
struct.pack_into("<H", binary, 68, 0x8664)
|
||||
binary[120:131] = b"clap_entry\0"
|
||||
(self.folder / f"{name}.clap").write_bytes(binary)
|
||||
self.write("plugins/catalog.json", json.dumps(catalog))
|
||||
for name in ["README.md", "LICENSE", "docs/development.md", "docs/releases.md"]:
|
||||
self.write("dist/x86_64-pc-windows-gnu/" + name, name)
|
||||
(self.folder / "Legacy.clap").write_bytes(b"unused")
|
||||
|
||||
def write(self, path, value):
|
||||
p = self.root / path
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(value)
|
||||
|
||||
def package(self, plugin):
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
return packaging.package(plugin=plugin, root=self.root)
|
||||
|
||||
def publish(self, client, plugin="patina"):
|
||||
tag = "patina-v0.1.0" if plugin == "patina" else "rivet-v0.3.0"
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
release.publish(plugin, tag, client, self.root)
|
||||
|
||||
def test_tag_and_version_are_independent(self):
|
||||
self.assertEqual(tag_version("rivet", "rivet-v0.3.0", self.root), "0.3.0")
|
||||
self.assertEqual(release.plan("refs/tags/patina-v0.1.0", self.root)["plugin"], "patina")
|
||||
for plugin, tag in [("rivet", "patina-v0.1.0"), ("patina", "patina-v0.3.0"), ("patina", "v0.1.0")]:
|
||||
with self.assertRaises(ValueError):
|
||||
tag_version(plugin, tag, self.root)
|
||||
|
||||
def test_individual_zip_excludes_other_plugin_and_is_reproducible(self):
|
||||
path = self.package("patina")
|
||||
first = path.read_bytes()
|
||||
self.package("patina")
|
||||
self.assertEqual(first, path.read_bytes())
|
||||
with zipfile.ZipFile(path) as z:
|
||||
self.assertIn("Patina.clap", z.namelist())
|
||||
self.assertNotIn("Rivet.clap", z.namelist())
|
||||
self.assertNotIn("Legacy.clap", z.namelist())
|
||||
self.assertFalse(any(n.startswith("docs/rivet/") for n in z.namelist()))
|
||||
for line in z.read("SHA256SUMS").decode().splitlines():
|
||||
digest, name = line.split(" ", 1)
|
||||
self.assertEqual(hashlib.sha256(z.read(name)).hexdigest(), digest)
|
||||
|
||||
def test_draft_publish_and_retry_preserve_notes(self):
|
||||
self.package("patina")
|
||||
client = FakeGitea()
|
||||
self.publish(client)
|
||||
self.assertFalse(client.release["draft"])
|
||||
self.assertEqual(len(client.release["assets"]), 2)
|
||||
client.release["body"] = "User edited notes"
|
||||
client.calls.clear()
|
||||
self.publish(client)
|
||||
self.assertEqual(client.release["body"], "User edited notes")
|
||||
self.assertTrue(all(call[0] == "GET" for call in client.calls))
|
||||
|
||||
def test_partial_upload_is_repaired_before_draft_is_published(self):
|
||||
self.package("patina")
|
||||
client = FakeGitea()
|
||||
client.fail_upload = True
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.publish(client)
|
||||
self.assertTrue(client.release["draft"])
|
||||
self.assertEqual(len(client.release["assets"]), 1)
|
||||
client.fail_upload = False
|
||||
self.publish(client)
|
||||
self.assertFalse(client.release["draft"])
|
||||
self.assertEqual(len(client.release["assets"]), 2)
|
||||
|
||||
def test_different_existing_asset_is_never_replaced(self):
|
||||
self.package("patina")
|
||||
client = FakeGitea()
|
||||
self.publish(client)
|
||||
client.bytes[client.release["assets"][0]["browser_download_url"]] = b"different"
|
||||
client.calls.clear()
|
||||
with self.assertRaises(ValueError):
|
||||
self.publish(client)
|
||||
self.assertTrue(all(call[0] == "GET" for call in client.calls))
|
||||
|
||||
def test_bad_checksum_or_missing_tag_cannot_publish(self):
|
||||
path = self.package("rivet")
|
||||
path.with_suffix(".zip.sha256").write_text("invalid")
|
||||
client = FakeGitea()
|
||||
with self.assertRaises(ValueError):
|
||||
self.publish(client, "rivet")
|
||||
self.assertEqual(client.calls, [])
|
||||
self.package("rivet")
|
||||
client.tag_exists = False
|
||||
with self.assertRaises(ValueError):
|
||||
self.publish(client, "rivet")
|
||||
self.assertFalse(any(call[0] != "GET" for call in client.calls))
|
||||
|
||||
def test_no_release_notes_fails_before_network(self):
|
||||
self.package("patina")
|
||||
self.write("docs/patina/CHANGELOG.md", "# No matching release")
|
||||
client = FakeGitea()
|
||||
with self.assertRaises(ValueError):
|
||||
self.publish(client)
|
||||
self.assertEqual(client.calls, [])
|
||||
|
||||
def test_foreign_plugin_in_archive_cannot_publish(self):
|
||||
path = self.package("patina")
|
||||
with zipfile.ZipFile(path, "a") as z:
|
||||
z.writestr("Rivet.clap", b"wrong plugin")
|
||||
path.with_suffix(".zip.sha256").write_text(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n")
|
||||
client = FakeGitea()
|
||||
with self.assertRaises(ValueError):
|
||||
self.publish(client)
|
||||
self.assertEqual(client.calls, [])
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user