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

This commit is contained in:
nvrl committed 2026-11-05 23:58:00 +01:00
1 parent 3c5b05fdec
commit 15853e61ac
87 files changed
+2065 -1198

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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}")
+22
View File
@@ -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
View File
@@ -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()
+27
View File
@@ -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"
+151
View File
@@ -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
+187
View File
@@ -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()