Automate weekly releases and synchronize application versions

This commit is contained in:
Hakan Seven committed 2026-09-05 02:13:20 +03:00
1 parent 28d3aa5be2
commit 13afc1479f
24 files changed
+659 -202

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/sh
set -eu
cargo build --release --target wasm32-unknown-unknown --package ocs_web_worker
cargo build --locked --release --target wasm32-unknown-unknown --package ocs_web_worker
worker_out="${TRUNK_STAGING_DIR:?}/worker_pkg"
mkdir -p "$worker_out"
wasm-bindgen \
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Prepare weekly releases and share package versions across build targets."""
import argparse
from datetime import date, datetime, timezone
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
def run(*args):
return subprocess.check_output(args, text=True, encoding="utf-8").rstrip("\n")
def gh(*args):
return json.loads(run("gh", *args))
def versions(tag):
version = tag.removeprefix("v")
weekly = re.fullmatch(r"(20\d{2})\.(\d{2})", version)
if weekly:
year, week = map(int, weekly.groups())
date.fromisocalendar(year, week, 1)
cargo = f"{year}.{week}.0"
msi = f"{year - 2000}.{week}.0"
elif re.fullmatch(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)", version):
cargo = msi = version
major, minor, patch = map(int, version.split("."))
if major > 255 or minor > 255 or patch > 65535:
raise ValueError(f"Unsupported MSI version: {version}")
else:
raise ValueError(f"Invalid release version: {tag}")
return {"version": version, "cargo": cargo, "msi": msi, "tag": f"v{version}"}
def cargo_version():
return re.search(r'^version = "([^"]+)"', Path("Cargo.toml").read_text(encoding="utf-8"), re.M)[1]
def display_version(cargo):
year, week, patch = cargo.split(".")
if len(year) == 4 and year.startswith("20") and patch == "0":
return versions(f"{year}.{int(week):02}")["version"]
return cargo
def output(values):
lines = "".join(f"{key}={value}\n" for key, value in values.items())
print(lines, end="")
if path := os.environ.get("GITHUB_OUTPUT"):
with open(path, "a", encoding="utf-8") as stream:
stream.write(lines)
def release_notes(previous, tag, repo, head="HEAD"):
sections = {
"Modeling, drawing and annotation": [],
"Rendering, plotting and file fidelity": [],
"Interface, web and plugins": [],
"Maintenance": [],
}
entries = run("git", "log", "--first-parent", "--format=%s%x1f%b%x1e", f"{previous}..{head}")
for entry in entries.split("\x1e"):
if "\x1f" not in entry:
continue
subject, body = entry.strip("\n").split("\x1f", 1)
if subject.startswith("Merge pull request"):
subject = next((line for line in body.splitlines() if line.strip()), subject)
if subject.startswith(("Merge ", "Release v")):
continue
subject = re.sub(r"^(?:feat|fix|perf|chore|docs)(?:\([^)]*\))?:\s*", "", subject)
text = subject.lower()
if re.search(r"render|plot|print|gpu|buffer|save|file|serializ|vram", text):
section = "Rendering, plotting and file fidelity"
elif re.search(r"ui|web|plugin|ribbon|theme|translat|dropdown|mcp|control|cli", text):
section = "Interface, web and plugins"
elif re.search(r"kernel|curve|solid|surface|extrud|loft|sweep|revol|presspull|snap|hatch|grip|trim|offset|draw|dimension", text):
section = "Modeling, drawing and annotation"
else:
section = "Maintenance"
if subject not in sections[section]:
sections[section].append(subject)
lines = ["## Highlights", "", f"- Weekly {tag.removeprefix('v')} release for web and desktop from the same source revision.", ""]
for title, subjects in sections.items():
if subjects:
lines += [f"## {title}", ""]
# ponytail: six recent entries per section; full history is linked below.
lines += [f"- {subject}" for subject in subjects[:6]]
lines += [""]
lines += [f"**Full Changelog:** https://github.com/{repo}/compare/{previous}...{tag}", ""]
return "\n".join(lines)
def prepare(publish):
if run("git", "status", "--porcelain"):
raise ValueError("Release preparation requires a clean working tree")
repo = os.environ["GITHUB_REPOSITORY"]
current = datetime.now(timezone.utc)
tag = current.strftime("v%G.%V")
version = versions(tag)
latest = gh("release", "view", "--json", "tagName")["tagName"]
if publish and os.environ.get("GITHUB_REF") != "refs/heads/main":
raise ValueError("Weekly releases must run on main")
existing = run("git", "tag", "--list", tag)
if existing:
# A retry always uses the original tag, even when main has advanced.
sha = run("git", "rev-parse", f"{tag}^{{commit}}")
releases = gh("release", "list", "--limit", "100", "--json", "tagName,isDraft")
if any(release["tagName"] == tag for release in releases):
published = gh("release", "view", tag, "--json", "name,isDraft,body")
if published["isDraft"] or not published["body"].strip() or published["name"] != version["version"]:
raise ValueError("Existing weekly release metadata is invalid")
output({"ready": str(publish).lower(), "tag": tag, "commit": sha})
return
elif run("git", "rev-list", "--count", f"{latest}..HEAD") == "0":
output({"ready": "false"})
return
notes = release_notes(latest, tag, repo, tag if existing else "HEAD")
print(notes)
if not publish:
output({"ready": "false", **version, "commit": run("git", "rev-parse", "HEAD")})
return
if not existing:
old = cargo_version()
updates = {}
for path, prefix in ((Path("Cargo.toml"), ""), (Path("Cargo.lock"), 'name = "OpenCADStudio"\n')):
content = path.read_text(encoding="utf-8")
before = f'{prefix}version = "{old}"'
if before not in content:
raise ValueError(f"Cannot update version in {path}")
updates[path] = content.replace(before, f'{prefix}version = "{version["cargo"]}"', 1)
for path, content in updates.items():
path.write_text(content, encoding="utf-8")
run("git", "config", "user.name", "github-actions[bot]")
run("git", "config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com")
run("git", "add", "Cargo.toml", "Cargo.lock")
run("git", "commit", "-m", f"Release {tag}")
run("git", "tag", "-a", tag, "-m", f"Release {tag}")
run("git", "push", "--atomic", "origin", "HEAD:main", f"refs/tags/{tag}")
sha = run("git", "rev-parse", "HEAD")
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", encoding="utf-8") as notes_file:
notes_file.write(notes)
notes_file.flush()
run("gh", "release", "create", tag, "--verify-tag", "--title", version["version"], "--notes-file", notes_file.name, "--latest")
published = gh("release", "view", tag, "--json", "name,body,isDraft")
if published != {"name": version["version"], "body": notes, "isDraft": False}:
raise ValueError("Published release notes do not match the prepared release")
output({"ready": "true", "tag": tag, "commit": sha})
def verify_native(tag):
release = gh("release", "view", tag, "--json", "assets,body,isDraft")
expected = {
f"OpenCADStudio-{tag}-{suffix}"
for suffix in ("linux-x86_64.AppImage", "linux-x86_64.snap",
"windows-x86_64-portable.exe", "windows-x86_64-installer.msi", "macos-arm64.dmg")
}
uploaded = {asset["name"] for asset in release["assets"] if asset["size"] > 0 and asset["state"] == "uploaded"}
if release["isDraft"] or not release["body"].strip() or not expected <= uploaded:
raise ValueError(f"Incomplete native release: {sorted(expected - uploaded)}")
print(f"Verified native release {tag}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
prepare_parser = commands.add_parser("prepare")
prepare_parser.add_argument("--publish", action="store_true")
version_parser = commands.add_parser("versions")
version_parser.add_argument("--tag", default="")
verify_parser = commands.add_parser("verify-native")
verify_parser.add_argument("--tag", required=True)
args = parser.parse_args()
if args.command == "prepare":
prepare(args.publish)
elif args.command == "verify-native":
verify_native(args.tag)
else:
version = versions(args.tag or display_version(cargo_version()))
if args.tag and version["cargo"] != cargo_version():
raise ValueError("Release tag and Cargo version do not match")
output(version)
if __name__ == "__main__":
main()
+132
View File
@@ -0,0 +1,132 @@
"""Run with python3 scripts/test_release.py; uses only local temporary repositories."""
from contextlib import redirect_stdout
from datetime import datetime, timezone
import io
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
import release
class ReleaseTests(unittest.TestCase):
def test_build_metadata(self):
with tempfile.TemporaryDirectory() as temp:
binary = str(Path(temp) / "build-script")
subprocess.run(["rustc", str(Path(__file__).resolve().parents[1] / "build.rs"), "-o", binary], check=True)
for cargo in ("0.9.8", "2026.9.0", "2026.35.0", "2026.53.0", "2027.1.0"):
result = subprocess.check_output([binary], text=True, env={**os.environ, "CARGO_PKG_VERSION": cargo})
self.assertIn(f"cargo:rustc-env=OCS_APP_VERSION={release.display_version(cargo)}\n", result)
def test_calendar_versions(self):
self.assertEqual(release.versions("v2026.35"), {
"version": "2026.35", "cargo": "2026.35.0", "msi": "26.35.0", "tag": "v2026.35",
})
self.assertEqual(release.versions("2026.09")["cargo"], "2026.9.0")
self.assertEqual(release.display_version("2026.9.0"), "2026.09")
self.assertEqual(release.display_version("0.9.8"), "0.9.8")
self.assertEqual(datetime(2027, 1, 3, tzinfo=timezone.utc).strftime("v%G.%V"), "v2026.53")
for value in ("2026.00", "2026.54", "2027.53", "2026.9", "2026.35.0", "bad", "0.09.8"):
with self.assertRaises(ValueError, msg=value):
release.versions(value)
def test_release_commit_push_and_retry(self):
original_run = release.run
releases = {"v0.9.8": {"name": "v0.9.8", "body": "Previous notes", "isDraft": False}}
latest = "v0.9.8"
fail_create = False
def run(*args):
nonlocal latest, fail_create
if args[0] != "gh":
return original_run(*args)
if args[1:3] == ("release", "list"):
return json.dumps([{"tagName": tag, "isDraft": False} for tag in releases])
if args[1:3] == ("release", "create"):
if fail_create:
fail_create = False
raise RuntimeError("Temporary release API failure")
latest = args[3]
releases[latest] = {
"name": args[args.index("--title") + 1],
"body": Path(args[args.index("--notes-file") + 1]).read_text(encoding="utf-8"),
"isDraft": False,
}
return ""
self.assertEqual(args[1:3], ("release", "view"))
if args[3] == "--json":
return json.dumps({"tagName": latest})
return json.dumps(releases[args[3]])
with tempfile.TemporaryDirectory() as temp:
temp = Path(temp)
root = temp / "checkout"
root.mkdir()
previous_directory = Path.cwd()
os.chdir(root)
try:
original_run("git", "init", "-b", "main")
original_run("git", "config", "user.name", "Release test")
original_run("git", "config", "user.email", "[email protected]")
original_run("git", "init", "--bare", "-b", "main", str(temp / "origin.git"))
original_run("git", "remote", "add", "origin", str(temp / "origin.git"))
Path("Cargo.toml").write_text('[package]\nname = "OpenCADStudio"\nversion = "0.9.8"\n')
Path("Cargo.lock").write_text('version = 4\n[[package]]\nname = "OpenCADStudio"\nversion = "0.9.8"\n')
original_run("git", "add", ".")
original_run("git", "commit", "-m", "Initial release")
original_run("git", "tag", "v0.9.8")
original_run("git", "push", "origin", "main", "--tags")
with patch.object(release, "run", run), patch.object(release, "datetime") as clock, patch.dict(os.environ, {
"GITHUB_REPOSITORY": "owner/repo", "GITHUB_REF": "refs/heads/main", "GITHUB_OUTPUT": str(temp / "output"),
}):
clock.now.return_value = datetime(2026, 8, 30, 12, tzinfo=timezone.utc)
def prepare(publish):
result = io.StringIO()
with redirect_stdout(result):
release.prepare(publish)
return result.getvalue()
self.assertIn("ready=false", prepare(True))
Path("feature").write_text("web release source")
original_run("git", "add", "feature")
original_run("git", "commit", "-m", "Add web release synchronization")
preview = prepare(False)
self.assertIn("Add web release synchronization", preview)
self.assertEqual(release.cargo_version(), "0.9.8")
self.assertEqual(original_run("git", "tag", "--list", "v2026.35"), "")
self.assertIn("ready=true", prepare(True))
sha = original_run("git", "rev-parse", "HEAD")
self.assertEqual(release.cargo_version(), "2026.35.0")
self.assertIn('version = "2026.35.0"', Path("Cargo.lock").read_text())
self.assertEqual(original_run("git", "log", "-1", "--format=%s"), "Release v2026.35")
self.assertEqual(original_run("git", "rev-parse", "origin/main"), sha)
self.assertEqual(original_run("git", "rev-parse", "v2026.35^{commit}"), sha)
self.assertEqual(original_run("git", "status", "--porcelain"), "")
self.assertIn(f"commit={sha}", prepare(True))
clock.now.return_value = datetime(2026, 9, 6, 12, tzinfo=timezone.utc)
self.assertIn("ready=false", prepare(True))
Path("feature").write_text("next change")
original_run("git", "add", "feature")
original_run("git", "commit", "-m", "Fix release retry")
fail_create = True
with self.assertRaises(RuntimeError):
prepare(True)
sha = original_run("git", "rev-parse", "HEAD")
self.assertIn(f"commit={sha}", prepare(True))
self.assertEqual(original_run("git", "rev-parse", "HEAD"), sha)
self.assertEqual(releases["v2026.36"]["name"], "2026.36")
finally:
os.chdir(previous_directory)
if __name__ == "__main__":
unittest.main()