forked from 1b-agent/opencadstudio-upstream
Automate weekly releases and synchronize application versions
This commit is contained in:
1 parent
28d3aa5be2
commit
13afc1479f
24 files changed
+659
-202
No files matched your search
@@ -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 \
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user