From 35fd7dd5367ec9c52037775d145bc800e6dce7a8 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Thu, 2 Jul 2026 20:52:26 +0200 Subject: [PATCH] Post-release changes (#543) - Update version to 0.8.0-dev - Add new release scripts - Update release docs --- Buildscripts/gh-download-artifacts.py | 135 ++++++++++++++++++++++++++ Buildscripts/gh-release-summary.py | 126 ++++++++++++++++++++++++ Buildscripts/release-summary.py | 84 ++++++++++++++++ Documentation/releasing.md | 28 +++--- version.txt | 2 +- 5 files changed, 359 insertions(+), 16 deletions(-) create mode 100755 Buildscripts/gh-download-artifacts.py create mode 100755 Buildscripts/gh-release-summary.py create mode 100755 Buildscripts/release-summary.py diff --git a/Buildscripts/gh-download-artifacts.py b/Buildscripts/gh-download-artifacts.py new file mode 100755 index 00000000..62cb1583 --- /dev/null +++ b/Buildscripts/gh-download-artifacts.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Download all artifacts from a GitHub Actions workflow run.""" + +import argparse +import os +import subprocess +import sys +import zipfile +from pathlib import Path + +import requests + +API_URL = "https://api.github.com" + + +def get_token(cli_token: str | None) -> str: + if cli_token: + return cli_token + env_token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if env_token: + return env_token + try: + result = subprocess.run( + ["gh", "auth", "token"], capture_output=True, text=True, check=True + ) + return result.stdout.strip() + except (FileNotFoundError, subprocess.CalledProcessError): + sys.exit( + "No token found. Pass --token, set GITHUB_TOKEN/GH_TOKEN, or run `gh auth login`." + ) + + +def get_repo(cli_repo: str | None) -> str: + if cli_repo: + return cli_repo + try: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + capture_output=True, + text=True, + check=True, + ) + url = result.stdout.strip() + except (FileNotFoundError, subprocess.CalledProcessError): + sys.exit("Could not detect repo from git remote. Pass --repo owner/name.") + + # Handle both SSH and HTTPS remote URLs. + if url.startswith("git@"): + path = url.split(":", 1)[1] + else: + path = url.split("github.com/", 1)[-1] + return path.removesuffix(".git") + + +def download_artifacts(repo: str, run_id: str, token: str, out_dir: Path): + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + out_dir.mkdir(parents=True, exist_ok=True) + + url = f"{API_URL}/repos/{repo}/actions/runs/{run_id}/artifacts" + artifacts = [] + while url: + resp = requests.get(url, headers=headers, params={"per_page": 100}) + resp.raise_for_status() + data = resp.json() + artifacts.extend(data["artifacts"]) + url = resp.links.get("next", {}).get("url") + + if not artifacts: + print(f"No artifacts found for run {run_id}.") + return + + for artifact in artifacts: + name = artifact["name"] + if name == "all-artifacts": + print(f"Skipping {name}") + continue + if artifact.get("expired"): + print(f"Skipping expired artifact: {name}") + continue + + print(f"Downloading {name} ({artifact['size_in_bytes']} bytes)...") + download_url = artifact["archive_download_url"] + resp = requests.get(download_url, headers=headers, stream=True) + resp.raise_for_status() + + zip_path = out_dir / f"{name}.zip" + with open(zip_path, "wb") as f: + for chunk in resp.iter_content(chunk_size=8192): + f.write(chunk) + + if name.endswith(".bin"): + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(out_dir) + zip_path.unlink() + print(f" Extracted to {out_dir}") + else: + print(f" Saved to {zip_path}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("run_id", help="GitHub Actions run ID") + parser.add_argument( + "--repo", + help="owner/repo (default: detected from git remote 'origin')", + ) + parser.add_argument( + "--token", + help="GitHub token (default: $GITHUB_TOKEN, $GH_TOKEN, or `gh auth token`)", + ) + parser.add_argument( + "--out", + default="artifacts", + help="Output directory (default: ./artifacts)", + ) + args = parser.parse_args() + + repo = get_repo(args.repo) + token = get_token(args.token) + out_dir = Path(args.out) + + print(f"Repo: {repo}") + print(f"Run ID: {args.run_id}") + print(f"Output: {out_dir.resolve()}") + + download_artifacts(repo, args.run_id, token, out_dir) + + +if __name__ == "__main__": + main() diff --git a/Buildscripts/gh-release-summary.py b/Buildscripts/gh-release-summary.py new file mode 100755 index 00000000..004f49fa --- /dev/null +++ b/Buildscripts/gh-release-summary.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Summarize commits between the last 2 pushed git tags into commits.md and pull-requests.md.""" + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +PR_REF_RE = re.compile(r"#(\d+)") + + +def get_repo(cli_repo: str | None) -> str: + if cli_repo: + return cli_repo + try: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + capture_output=True, + text=True, + check=True, + ) + url = result.stdout.strip() + except (FileNotFoundError, subprocess.CalledProcessError): + sys.exit("Could not detect repo from git remote. Pass --repo owner/name.") + + if url.startswith("git@"): + path = url.split(":", 1)[1] + else: + path = url.split("github.com/", 1)[-1] + return path.removesuffix(".git") + + +def get_last_two_tags() -> tuple[str, str]: + result = subprocess.run( + ["git", "tag", "--sort=-creatordate"], + capture_output=True, + text=True, + check=True, + ) + tags = [t for t in result.stdout.splitlines() if t] + if len(tags) < 2: + sys.exit(f"Need at least 2 tags, found {len(tags)}.") + return tags[1], tags[0] # previous, latest + + +def get_commits(prev_tag: str, latest_tag: str) -> list[tuple[str, str]]: + result = subprocess.run( + [ + "git", + "log", + "--pretty=format:%H\x1f%s", + f"{prev_tag}..{latest_tag}", + ], + capture_output=True, + text=True, + check=True, + ) + commits = [] + for line in result.stdout.splitlines(): + if not line: + continue + sha, subject = line.split("\x1f", 1) + commits.append((sha, subject)) + return commits + + +def write_commits_md(path: Path, repo: str, prev_tag: str, latest_tag: str, commits: list[tuple[str, str]]): + lines = [f"# Commits: {prev_tag}..{latest_tag}", ""] + for sha, subject in commits: + link = f"https://github.com/{repo}/commit/{sha}" + lines.append(f"- [{subject}]({link})") + path.write_text("\n".join(lines) + "\n") + + +def write_pull_requests_md(path: Path, repo: str, commits: list[tuple[str, str]]): + pr_numbers = [] + seen = set() + for _, subject in commits: + for match in PR_REF_RE.finditer(subject): + number = match.group(1) + if number not in seen: + seen.add(number) + pr_numbers.append(number) + + lines = ["# Pull Requests", ""] + for number in pr_numbers: + lines.append(f"- https://github.com/{repo}/pull/{number}") + path.write_text("\n".join(lines) + "\n") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repo", + help="owner/repo (default: detected from git remote 'origin')", + ) + parser.add_argument( + "--out", + default=".", + help="Output directory for commits.md and pull-requests.md (default: current directory)", + ) + args = parser.parse_args() + + repo = get_repo(args.repo) + prev_tag, latest_tag = get_last_two_tags() + commits = get_commits(prev_tag, latest_tag) + + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + + commits_path = out_dir / "release-commits.md" + prs_path = out_dir / "release-pull-requests.md" + + write_commits_md(commits_path, repo, prev_tag, latest_tag, commits) + write_pull_requests_md(prs_path, repo, commits) + + print(f"Repo: {repo}") + print(f"Tags: {prev_tag}..{latest_tag}") + print(f"Commits: {len(commits)}") + print(f"Wrote {commits_path}") + print(f"Wrote {prs_path}") + + +if __name__ == "__main__": + main() diff --git a/Buildscripts/release-summary.py b/Buildscripts/release-summary.py new file mode 100755 index 00000000..f3918dee --- /dev/null +++ b/Buildscripts/release-summary.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Generate a release summary markdown by combining gh-release-summary.py output with Claude.""" + +import argparse +import subprocess +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent + +SECTIONS = [ + "New Devices", + "New Features", + "New Apps", + "Improvements & Fixes", + "Other Changes", + "TactilitySDK", + "Known Issues", +] + + +def run_gh_release_summary(out_dir: Path, repo: str | None): + cmd = [sys.executable, str(SCRIPT_DIR / "gh-release-summary.py"), "--out", str(out_dir)] + if repo: + cmd += ["--repo", repo] + subprocess.run(cmd, check=True) + + +def build_prompt() -> str: + section_list = "\n".join(f"- {s}" for s in SECTIONS) + return ( + "You are given a list of commits (with GitHub commit links) and a list of pull " + "request links from a software release. Write a release summary in Markdown, " + "organizing changes into these sections (omit a section if it has no relevant " + "changes):\n\n" + f"{section_list}\n\n" + "Use concise bullet points. Base every bullet strictly on the given commits and " + "pull requests, referencing the pull request link where applicable. Output only " + "the Markdown, no commentary before or after it." + "If a pull request or commit has multiple changes add them as separate entries." + "Ignore irrelevant changes: documentation, minor fixes, non-descript fixes, scripts, github workflows." + "For 'New Devices': only mention the device name, don't like pull request." + "For 'Improvements & Fixes': Avoid non-descript entries, write specifics." + "For 'TactilitySDK': Avoid generic changes, be specific. Mention API changes." + ) + + +def run_claude(commits_md: str, prs_md: str) -> str: + prompt = build_prompt() + stdin_content = f"## Commits\n\n{commits_md}\n\n## Pull Requests\n\n{prs_md}" + result = subprocess.run( + ["claude", "-p", prompt], + input=stdin_content, + capture_output=True, + text=True, + check=True, + ) + return result.stdout + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", help="owner/repo (default: detected from git remote 'origin')") + parser.add_argument("--out", default=".", help="Output directory (default: current directory)") + args = parser.parse_args() + + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + + run_gh_release_summary(out_dir, args.repo) + + commits_md = (out_dir / "release-commits.md").read_text() + prs_md = (out_dir / "release-pull-requests.md").read_text() + + print("Generating release summary via claude...") + summary = run_claude(commits_md, prs_md) + + summary_path = out_dir / "release-summary.md" + summary_path.write_text(summary) + print(f"Wrote {summary_path}") + + +if __name__ == "__main__": + main() diff --git a/Documentation/releasing.md b/Documentation/releasing.md index 9eb34581..99a442b4 100644 --- a/Documentation/releasing.md +++ b/Documentation/releasing.md @@ -4,24 +4,22 @@ 2. Set the new version in the Tactility repository: 1. Update `version.txt` in the Tactility project and create a branch to start a build for it 2. Merge the branch and wait for a build. -3. Upload the new Tactility SDK to the CDN - 1. Upload it to the [CDN](https://dash.cloudflare.com) - 2. Update `sdk.json` from [TactilityTool](https://github.com/TactilityProject/TactilityTool) and upload it to [CDN](https://dash.cloudflare.com) -4. Update the [TactilityApps](https://github.com/TactilityProject/TactilityApps) with the new SDK and also release these to the CDN: - 1. Download the `cdn-files.zip` from the pipelines - 2. Upload them to the CDN at `apps/x.y.z/` - 3. Also upload them to the CDN for the upcoming version, because the upcoming a.b.c version will also need some basic apps to download -5. Test the latest unstable version of Tactility on several devices using the [web installer](https://install.tactilityproject.org). Pay special attention to: - 1. The version of the unstable channel (should be updated!) - 2. App Hub - 3. Wi-Fi -6. Test the firmwares on all devices with the local web installer -7. Push a tag e.g. `v1.2.3` - the `v` prefix is crucial for the pipelines -8. The pipelines should now kick off a build that releases the build to the stable channel of the Web Installer. Verify that. +3. Push a tag e.g. `v1.2.3` - the `v` prefix is crucial for the pipelines. +4. Wait for the SDK and firmwares to be published on the CDN. +5. Test the firmwares on all devices with the web installer +6. Run `Buildscripts/gh-download-artifacts.py [actionId] --token [token]` and let it run in the background. + Get a token from [here](https://github.com/settings/personal-access-tokens) +7. Update the [TactilityApps](https://github.com/TactilityProject/TactilityApps) with the new SDK version and bump the app versions. +8. After merging the apps, manually upload to CDN, then test them. 9. Make a new version of the docs available at [TactilityDocs](https://github.com/TactilityProject/TactilityDocs) + 1. Copy `docs/versions/main/` into new version folder + 2. Edit `index.html` to include the new version + 3. Push code directly to `main` branch 10. Make a new [GitHub release](https://github.com/TactilityProject/Tactility/releases/new) + Run `Buildscripts/release-summary.py` to generate a release, then review it 11. Double-check that all CDN/TactilityApps/Tactility repository changes are merged. -12. Ensure that the CDN is not in development mode anymore. +12. Add the `-dev` postfix from `version.txt` and bump the version. Merge it to `main`. +13. Ensure that the CDN is not in development mode anymore. ### Post-release diff --git a/version.txt b/version.txt index bcaffe19..ffca19c8 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.7.0 \ No newline at end of file +0.8.0-dev \ No newline at end of file