release: prepare v0.5.0 #30
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build Multi-Platform | |
| on: | |
| push: | |
| branches: [main, develop] | |
| pull_request: | |
| branches: [main] | |
| workflow_dispatch: | |
| permissions: | |
| contents: write | |
| pull-requests: read | |
| jobs: | |
| detect-release: | |
| name: Detect Release Version Change | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| should_release: ${{ steps.detect.outputs.should_release }} | |
| version: ${{ steps.detect.outputs.version }} | |
| tag_name: ${{ steps.detect.outputs.tag_name }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Detect version change | |
| id: detect | |
| run: | | |
| python - <<'PY' | |
| import os | |
| import pathlib | |
| import subprocess | |
| import tomllib | |
| def set_output(name: str, value: str) -> None: | |
| with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: | |
| fh.write(f"{name}={value}\n") | |
| before = os.environ.get("GITHUB_EVENT_BEFORE", "") | |
| current = tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] | |
| previous = None | |
| if before and before != "0000000000000000000000000000000000000000": | |
| try: | |
| previous_text = subprocess.check_output( | |
| ["git", "show", f"{before}:pyproject.toml"], | |
| text=True, | |
| stderr=subprocess.DEVNULL, | |
| ) | |
| previous = tomllib.loads(previous_text)["project"]["version"] | |
| except Exception: | |
| previous = None | |
| should_release = previous != current | |
| tag_name = f"v{current}" | |
| print(f"previous={previous}") | |
| print(f"current={current}") | |
| print(f"should_release={str(should_release).lower()}") | |
| set_output("should_release", str(should_release).lower()) | |
| set_output("version", current) | |
| set_output("tag_name", tag_name) | |
| PY | |
| env: | |
| GITHUB_EVENT_BEFORE: ${{ github.event.before }} | |
| build-windows: | |
| name: Build Windows EXE & Installer | |
| runs-on: windows-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| cache: 'pip' | |
| cache-dependency-path: | | |
| requirements-windows.txt | |
| pyproject.toml | |
| - name: Validate version consistency | |
| run: | | |
| $code = @' | |
| import pathlib | |
| import re | |
| import sys | |
| import tomllib | |
| py = tomllib.loads(pathlib.Path("pyproject.toml").read_text(encoding="utf-8")) | |
| v1 = py["project"]["version"] | |
| txt = pathlib.Path("src/__init__.py").read_text(encoding="utf-8") | |
| m = re.search(r"__version__\s*=\s*\"([^\"]+)\"", txt) | |
| v2 = m.group(1) if m else None | |
| print(f"pyproject={v1}, src={v2}") | |
| sys.exit(0 if (v1 and v1 == v2) else 1) | |
| '@ | |
| python -c $code | |
| shell: pwsh | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install "numpy>=1.24.0,<2.0.0" --only-binary=:all: | |
| pip install -r requirements-windows.txt --verbose | |
| shell: pwsh | |
| - name: Build Single-File EXE | |
| run: | | |
| New-Item -ItemType Directory -Force -Path build-logs | Out-Null | |
| python setup.py --build *>&1 | Tee-Object -FilePath build-logs/pyinstaller-single.log | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Error "PyInstaller single-file build failed" | |
| exit $LASTEXITCODE | |
| } | |
| shell: pwsh | |
| - name: Build Directory Mode for Installer | |
| run: | | |
| python setup.py --build-dir *>&1 | Tee-Object -FilePath build-logs/pyinstaller-dir.log | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Error "PyInstaller directory build failed" | |
| exit $LASTEXITCODE | |
| } | |
| shell: pwsh | |
| - name: Install NSIS | |
| run: | | |
| winget install --id NSIS.NSIS --exact --accept-source-agreements --accept-package-agreements -e --silent | |
| Start-Sleep -Seconds 10 | |
| shell: pwsh | |
| - name: Build NSIS Installer | |
| run: | | |
| $nsisPaths = @( | |
| "C:\Program Files (x86)\NSIS\makensis.exe", | |
| "C:\Program Files\NSIS\makensis.exe", | |
| "C:\NSIS\makensis.exe" | |
| ) | |
| $makensis = $null | |
| foreach ($path in $nsisPaths) { | |
| if (Test-Path $path) { | |
| $makensis = $path | |
| break | |
| } | |
| } | |
| if (-not $makensis) { | |
| Write-Error "NSIS makensis.exe not found" | |
| exit 1 | |
| } | |
| & $makensis "installer\installer.nsi" *>&1 | Tee-Object -FilePath build-logs/nsis.log | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Error "Installer build failed!" | |
| exit 1 | |
| } | |
| shell: pwsh | |
| - name: Upload Windows build logs on failure | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: windows-build-logs | |
| path: | | |
| build-logs/** | |
| build/** | |
| retention-days: 14 | |
| if-no-files-found: warn | |
| - name: Upload Windows EXE | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ha-assistant-windows-exe | |
| path: dist/HomeAssistantWindows.exe | |
| retention-days: 30 | |
| - name: Upload Windows Installer | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ha-assistant-windows-installer | |
| path: dist/HomeAssistantWindows_Setup.exe | |
| retention-days: 30 | |
| build-macos: | |
| name: Build macOS App | |
| runs-on: macos-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Set up Python 3.12 | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| cache: 'pip' | |
| cache-dependency-path: | | |
| requirements-macos.txt | |
| pyproject.toml | |
| - name: Validate version consistency | |
| run: | | |
| python - <<'PY' | |
| import pathlib | |
| import re | |
| import sys | |
| import tomllib | |
| py = tomllib.loads(pathlib.Path('pyproject.toml').read_text(encoding='utf-8')) | |
| v1 = py['project']['version'] | |
| txt = pathlib.Path('src/__init__.py').read_text(encoding='utf-8') | |
| m = re.search(r'__version__\s*=\s*"([^"]+)"', txt) | |
| v2 = m.group(1) if m else None | |
| print(f'pyproject={v1}, src={v2}') | |
| raise SystemExit(0 if (v1 and v1 == v2) else 1) | |
| PY | |
| - name: Install dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -r requirements-macos.txt | |
| - name: Build macOS App | |
| run: | | |
| mkdir -p build-logs | |
| set -o pipefail | |
| python setup.py --build 2>&1 | tee build-logs/pyinstaller-macos.log | |
| - name: Create DMG | |
| run: | | |
| set -o pipefail | |
| hdiutil create -volname "Home Assistant" -srcfolder dist/HomeAssistant.app -ov -format UDZO dist/HomeAssistant-macOS.dmg 2>&1 | tee build-logs/dmg.log | |
| - name: Upload macOS build logs on failure | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: macos-build-logs | |
| path: | | |
| build-logs/** | |
| build/** | |
| retention-days: 14 | |
| if-no-files-found: warn | |
| - name: Upload macOS DMG | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ha-assistant-macos | |
| path: dist/HomeAssistant-macOS.dmg | |
| retention-days: 30 | |
| release: | |
| name: Create Release | |
| needs: [detect-release, build-windows, build-macos] | |
| if: needs.detect-release.outputs.should_release == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Download Windows EXE | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: ha-assistant-windows-exe | |
| path: dist/ | |
| - name: Download Windows Installer | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: ha-assistant-windows-installer | |
| path: dist/ | |
| - name: Download macOS DMG | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: ha-assistant-macos | |
| path: dist/ | |
| - name: Generate latest.json | |
| run: | | |
| version="${{ needs.detect-release.outputs.version }}" | |
| echo "{\"version\": \"$version\"}" > dist/latest.json | |
| shell: bash | |
| - name: Generate release notes | |
| run: | | |
| python - <<'PY' | |
| import json | |
| import os | |
| import textwrap | |
| import urllib.error | |
| import urllib.parse | |
| import urllib.request | |
| repo = os.environ["GITHUB_REPOSITORY"] | |
| token = os.environ["GITHUB_TOKEN"] | |
| current_tag = os.environ["CURRENT_TAG"] | |
| current_sha = os.environ["CURRENT_SHA"] | |
| def api(path: str): | |
| url = f"https://api.github.com{path}" | |
| request = urllib.request.Request( | |
| url, | |
| headers={ | |
| "Accept": "application/vnd.github+json", | |
| "Authorization": f"Bearer {token}", | |
| "X-GitHub-Api-Version": "2022-11-28", | |
| }, | |
| ) | |
| with urllib.request.urlopen(request) as response: | |
| return json.load(response) | |
| def get_latest_release_tag() -> str | None: | |
| try: | |
| release = api(f"/repos/{repo}/releases/latest") | |
| except urllib.error.HTTPError as exc: | |
| if exc.code == 404: | |
| return None | |
| raise | |
| return release.get("tag_name") | |
| def summarize_body(body: str | None) -> str | None: | |
| if not body: | |
| return None | |
| for raw_line in body.splitlines(): | |
| line = raw_line.strip() | |
| if not line: | |
| continue | |
| if line.startswith("#"): | |
| continue | |
| if line.startswith("-") or line.startswith("*"): | |
| line = line[1:].strip() | |
| if line: | |
| return textwrap.shorten(line, width=160, placeholder="...") | |
| return None | |
| previous_tag = get_latest_release_tag() | |
| pr_map: dict[int, dict] = {} | |
| if previous_tag: | |
| compare = api(f"/repos/{repo}/compare/{urllib.parse.quote(previous_tag, safe='')}...{current_sha}") | |
| for commit in compare.get("commits", []): | |
| commit_sha = commit["sha"] | |
| pulls = api(f"/repos/{repo}/commits/{commit_sha}/pulls") | |
| for pull in pulls: | |
| pr_map[pull["number"]] = { | |
| "number": pull["number"], | |
| "title": pull["title"].strip(), | |
| "body": summarize_body(pull.get("body")), | |
| "url": pull["html_url"], | |
| "merged_at": pull.get("merged_at") or "", | |
| } | |
| prs = sorted(pr_map.values(), key=lambda pr: (pr["merged_at"], pr["number"])) | |
| lines = [f"## What's Changed", ""] | |
| if previous_tag: | |
| lines.append(f"- Compare: https://github.com/{repo}/compare/{previous_tag}...{current_tag}") | |
| else: | |
| lines.append("- Initial release") | |
| lines.append("") | |
| lines.append("## Pull Requests") | |
| lines.append("") | |
| if prs: | |
| for pr in prs: | |
| lines.append(f"- #{pr['number']} {pr['title']} ({pr['url']})") | |
| if pr["body"]: | |
| lines.append(f" - {pr['body']}") | |
| else: | |
| lines.append("- No pull requests found between the previous release and this release.") | |
| with open("dist/release_notes.md", "w", encoding="utf-8") as fh: | |
| fh.write("\n".join(lines) + "\n") | |
| PY | |
| shell: bash | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| CURRENT_TAG: ${{ needs.detect-release.outputs.tag_name }} | |
| CURRENT_SHA: ${{ github.sha }} | |
| - name: Create Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ needs.detect-release.outputs.tag_name }} | |
| target_commitish: ${{ github.sha }} | |
| name: ${{ needs.detect-release.outputs.tag_name }} | |
| files: | | |
| dist/HomeAssistantWindows.exe | |
| dist/HomeAssistantWindows_Setup.exe | |
| dist/HomeAssistant-macOS.dmg | |
| dist/latest.json | |
| body_path: dist/release_notes.md | |
| draft: false | |
| prerelease: false | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |