From 97941e40d3fd91c254361e56d059d2129a16ce8f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 17 Aug 2026 11:44:54 +0000 Subject: [PATCH] Add GitHub Release workflow for tagged versions Publish desktop installers and Python artifacts when a vX.Y.Z tag is pushed. Desktop builds stay reusable so a manual workflow_dispatch still produces installers without creating a release. Co-authored-by: Damon --- .github/workflows/desktop.yml | 11 ++- .github/workflows/release.yml | 155 ++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 23 +++++ README.md | 2 +- packaging/release_meta.py | 104 +++++++++++++++++++++ tests/unit/test_release_meta.py | 75 ++++++++++++++++ 6 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 packaging/release_meta.py create mode 100644 tests/unit/test_release_meta.py diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index c483fb8..65ad4e3 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -2,12 +2,19 @@ name: Desktop builds on: workflow_dispatch: + workflow_call: + inputs: + ref: + description: Git ref to check out (tag, branch, or SHA) + type: string + required: false + default: "" permissions: contents: read concurrency: - group: desktop-build-${{ github.ref }} + group: desktop-build-${{ inputs.ref || github.ref }} cancel-in-progress: true jobs: @@ -34,6 +41,8 @@ jobs: CSC_IDENTITY_AUTO_DISCOVERY: "false" steps: - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.sha }} - uses: actions/setup-python@v7 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7524da6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,155 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + tag: + description: Existing git tag to release (for example v0.1.0) + required: true + type: string + draft: + description: Create a draft release instead of publishing + required: false + type: boolean + default: true + +permissions: + contents: write + +concurrency: + group: release-${{ inputs.tag || github.ref }} + cancel-in-progress: false + +jobs: + meta: + name: Check version + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.meta.outputs.tag }} + version: ${{ steps.meta.outputs.version }} + ref: ${{ steps.meta.outputs.ref }} + prerelease: ${{ steps.meta.outputs.prerelease }} + draft: ${{ steps.draft.outputs.draft }} + steps: + - name: Resolve tag + id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + DISPATCH_TAG: ${{ inputs.tag }} + REF_NAME: ${{ github.ref_name }} + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + TAG="$DISPATCH_TAG" + else + TAG="$REF_NAME" + fi + TAG="${TAG#refs/tags/}" + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-].*)?$ ]]; then + echo "Tag must look like vX.Y.Z, got: $TAG" + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v7 + with: + ref: ${{ steps.resolve.outputs.tag }} + + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Check package versions + id: meta + env: + TAG: ${{ steps.resolve.outputs.tag }} + run: python packaging/release_meta.py --tag "$TAG" --github-output + + - name: Draft flag + id: draft + env: + EVENT_NAME: ${{ github.event_name }} + DRAFT_INPUT: ${{ inputs.draft }} + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$DRAFT_INPUT" = "true" ]; then + echo "draft=true" >> "$GITHUB_OUTPUT" + else + echo "draft=false" >> "$GITHUB_OUTPUT" + fi + + desktop: + needs: meta + uses: ./.github/workflows/desktop.yml + with: + ref: ${{ needs.meta.outputs.ref }} + + python: + name: Python package + needs: meta + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.meta.outputs.ref }} + + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Build wheel and sdist + run: | + python -m pip install --upgrade pip build + python -m build + + - uses: actions/upload-artifact@v7 + with: + name: loadpath-python + if-no-files-found: error + path: dist/* + + publish: + name: GitHub Release + needs: [meta, desktop, python] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v7 + with: + path: artifacts + + - name: Collect assets + run: | + mkdir -p release-assets + find artifacts -type f \( \ + -name '*.AppImage' -o \ + -name '*.deb' -o \ + -name '*.dmg' -o \ + -name '*.zip' -o \ + -name '*.exe' -o \ + -name '*.whl' -o \ + -name '*.tar.gz' \ + \) -exec cp -v {} release-assets/ \; + ls -la release-assets + test -n "$(ls -A release-assets)" + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.meta.outputs.tag }} + VERSION: ${{ needs.meta.outputs.version }} + DRAFT: ${{ needs.meta.outputs.draft }} + PRERELEASE: ${{ needs.meta.outputs.prerelease }} + run: | + ARGS=(--title "Loadpath ${VERSION}" --generate-notes --verify-tag) + if [ "$DRAFT" = "true" ]; then + ARGS+=(--draft) + fi + if [ "$PRERELEASE" = "true" ]; then + ARGS+=(--prerelease) + fi + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" release-assets/* --clobber + else + gh release create "$TAG" release-assets/* "${ARGS[@]}" + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5156ee..0ceae5a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,3 +64,26 @@ cd desktop && npm install && npm start ``` Requires Python 3.12+ on `PATH` (`python` on Windows, `python3` elsewhere), or set `LOADPATH_PYTHON`. + +## Releases + +Keep these versions in lockstep: + +- `pyproject.toml` +- `src/loadpath/__init__.py` +- `desktop/package.json` +- `ui/package.json` +- `editors/vscode/package.json` + +Bump them, merge to `main`, wait for CI, then tag the merge commit: + +```bash +git checkout main +git pull +git tag v0.1.0 +git push origin v0.1.0 +``` + +Pushing `vX.Y.Z` runs **Release**: desktop installers, a Python wheel/sdist, and a GitHub Release. A pre-release version (`0.1.0rc1`, `0.1.0-rc.1`) is marked as such. The workflow does not create tags. + +For a draft, Actions → **Release** → **Run workflow** with an existing tag (draft is the default). Tag from a green `main`; the tagged commit must already include `.github/workflows/release.yml`. diff --git a/README.md b/README.md index 0dab932..3d21896 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ cd desktop && npm install && npm start Requires Python 3.12+ on `PATH` (`python` on Windows, `python3` elsewhere), or `LOADPATH_PYTHON`. -Installers: GitHub → Actions → **Desktop builds** → **Run workflow** (Linux AppImage/`.deb`, Windows NSIS, macOS unsigned `.dmg`/`.zip`). Gatekeeper needs right-click → Open on macOS. +Installers: GitHub → [Releases](https://github.com/Modsofthenation/Loadpath/releases) (Linux AppImage/`.deb`, Windows NSIS, macOS unsigned `.dmg`/`.zip`). Push a `vX.Y.Z` tag to publish, or Actions → **Desktop builds** → **Run workflow** for a one-off build. Gatekeeper needs right-click → Open on macOS. ## Security diff --git a/packaging/release_meta.py b/packaging/release_meta.py new file mode 100644 index 0000000..2760710 --- /dev/null +++ b/packaging/release_meta.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Check that package versions agree, and optionally match a git tag.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VERSION_FILES = ( + "pyproject.toml", + "src/loadpath/__init__.py", + "desktop/package.json", + "ui/package.json", + "editors/vscode/package.json", +) +PRE_RELEASE_RE = re.compile(r"(?:a|b|rc|dev|-)", re.IGNORECASE) + + +def _pyproject_version(root: Path) -> str: + data = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + return str(data["project"]["version"]) + + +def _init_version(root: Path) -> str: + text = (root / "src/loadpath/__init__.py").read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*"([^"]+)"', text, re.MULTILINE) + if not match: + raise SystemExit("Could not find __version__ in src/loadpath/__init__.py") + return match.group(1) + + +def _json_version(root: Path, rel: str) -> str: + data = json.loads((root / rel).read_text(encoding="utf-8")) + return str(data["version"]) + + +def collect_versions(root: Path) -> dict[str, str]: + return { + "pyproject.toml": _pyproject_version(root), + "src/loadpath/__init__.py": _init_version(root), + "desktop/package.json": _json_version(root, "desktop/package.json"), + "ui/package.json": _json_version(root, "ui/package.json"), + "editors/vscode/package.json": _json_version(root, "editors/vscode/package.json"), + } + + +def is_prerelease(version: str) -> bool: + return bool(PRE_RELEASE_RE.search(version)) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", default="", help="Git tag that must equal v{version}") + parser.add_argument( + "--github-output", + action="store_true", + help="Append version, tag, ref, and prerelease to $GITHUB_OUTPUT", + ) + args = parser.parse_args(argv) + + versions = collect_versions(ROOT) + unique = set(versions.values()) + if len(unique) != 1: + print("Version mismatch across package files:", file=sys.stderr) + for loc in VERSION_FILES: + print(f" {loc}: {versions[loc]}", file=sys.stderr) + return 1 + + version = unique.pop() + tag = args.tag.strip() + if tag.startswith("refs/tags/"): + tag = tag.removeprefix("refs/tags/") + expected_tag = f"v{version}" + if tag and tag != expected_tag: + print(f"Tag {tag!r} does not match package version {expected_tag!r}", file=sys.stderr) + return 1 + + resolved_tag = tag or expected_tag + prerelease = "true" if is_prerelease(version) else "false" + print(f"version={version}") + print(f"tag={resolved_tag}") + print(f"prerelease={prerelease}") + + if args.github_output: + github_output = os.environ.get("GITHUB_OUTPUT") + if not github_output: + print("GITHUB_OUTPUT is not set", file=sys.stderr) + return 1 + with Path(github_output).open("a", encoding="utf-8") as handle: + handle.write(f"version={version}\n") + handle.write(f"tag={resolved_tag}\n") + handle.write(f"ref={resolved_tag}\n") + handle.write(f"prerelease={prerelease}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_release_meta.py b/tests/unit/test_release_meta.py new file mode 100644 index 0000000..7a0b9c7 --- /dev/null +++ b/tests/unit/test_release_meta.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import os +import re +import subprocess +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "packaging" / "release_meta.py" + + +def _package_version() -> str: + data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + return str(data["project"]["version"]) + + +def _run(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + merged = os.environ.copy() + if env: + merged.update(env) + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + cwd=ROOT, + capture_output=True, + text=True, + env=merged, + check=False, + ) + + +def _expected_prerelease(version: str) -> str: + return "true" if re.search(r"(?:a|b|rc|dev|-)", version, re.I) else "false" + + +def test_current_versions_are_consistent(): + result = _run() + assert result.returncode == 0, result.stderr + version = _package_version() + assert f"version={version}" in result.stdout + assert f"tag=v{version}" in result.stdout + assert f"prerelease={_expected_prerelease(version)}" in result.stdout + + +def test_matching_tag_succeeds(): + version = _package_version() + result = _run("--tag", f"v{version}") + assert result.returncode == 0, result.stderr + assert f"tag=v{version}" in result.stdout + + +def test_refs_tags_prefix_is_stripped(): + version = _package_version() + result = _run("--tag", f"refs/tags/v{version}") + assert result.returncode == 0, result.stderr + assert f"tag=v{version}" in result.stdout + + +def test_mismatched_tag_fails(): + result = _run("--tag", "v9.9.9") + assert result.returncode == 1 + assert "does not match package version" in result.stderr + + +def test_github_output_writes_actions_fields(tmp_path: Path): + version = _package_version() + output = tmp_path / "github_output" + result = _run("--tag", f"v{version}", "--github-output", env={"GITHUB_OUTPUT": str(output)}) + assert result.returncode == 0, result.stderr + text = output.read_text(encoding="utf-8") + assert f"version={version}" in text + assert f"tag=v{version}" in text + assert f"ref=v{version}" in text + assert f"prerelease={_expected_prerelease(version)}" in text