Merge pull request #135 from ApocDev/release-please--branches--main #148
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: Release | |
| # release-please watches conventional commits on `main` and maintains a "release PR" | |
| # that bumps the version (app/package.json + Cargo.toml + tauri.conf.json) and the | |
| # changelog. Merging that PR cuts the GitHub release + tag; the build matrix then | |
| # builds + signs each platform's bundles and the latest-json job aggregates a signed | |
| # latest.json onto the release. workflow_dispatch with a `tag` rebuilds an existing | |
| # release's assets (recovery / manual fill-in); without one it's a build-only smoke | |
| # test. | |
| on: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: "Existing release tag to build + attach assets to (e.g. v0.2.0). Empty = build-only smoke test." | |
| required: false | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| jobs: | |
| release-please: | |
| runs-on: ubuntu-latest | |
| # The one package is rooted at the repo (path "."), so the action emits | |
| # unprefixed outputs. `releases_created` is the top-level "anything released" flag. | |
| outputs: | |
| release_created: ${{ steps.rp.outputs.releases_created }} | |
| tag_name: ${{ steps.rp.outputs.tag_name }} | |
| steps: | |
| - uses: googleapis/release-please-action@v4 | |
| id: rp | |
| with: | |
| config-file: release-please-config.json | |
| manifest-file: .release-please-manifest.json | |
| # Rewrite the fresh release's body with an LLM: a short friendly summary on | |
| # top, the raw release-please changelog kept verbatim below a divider. | |
| # Sequenced here — in the same job, before the build matrix — so it always | |
| # finishes before latest-json copies the body into latest.json's `notes` | |
| # (the in-app update dialog renders that markdown); a separate | |
| # `release: published` workflow would race it. Fails open at every step: a | |
| # missing OPENROUTER_API_KEY secret, an API error, or an empty reply keeps | |
| # the raw notes and the workflow green. The model only writes the summary — | |
| # the script assembles summary + divider + original body itself, so the | |
| # changelog can't be dropped or altered, and CHANGELOG.md is never touched. | |
| - name: Polish release notes with AI | |
| if: ${{ steps.rp.outputs.releases_created == 'true' }} | |
| continue-on-error: true | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} | |
| MODEL: ${{ vars.RELEASE_NOTES_MODEL || 'anthropic/claude-3.5-haiku' }} | |
| TAG: ${{ steps.rp.outputs.tag_name }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "$OPENROUTER_API_KEY" ]; then | |
| echo "OPENROUTER_API_KEY secret not set; keeping the raw release notes" | |
| exit 0 | |
| fi | |
| BODY="$(gh release view "$TAG" --repo "$REPO" --json body -q .body)" | |
| if [ -z "$BODY" ]; then | |
| echo "release body is empty; nothing to polish" | |
| exit 0 | |
| fi | |
| SYSTEM='You write release notes for PyOps, a desktop factory planner and | |
| in-game assistant for Factorio (Pyanodons). You are given the auto-generated | |
| changelog for one release: grouped conventional-commit subjects with commit | |
| links. Rewrite it as a short, friendly release summary in Markdown: one or | |
| two plain sentences saying what this release is about, then a few themed | |
| highlight bullets (group related commits; lead with what users notice). | |
| Strict rules: only describe changes that appear in the given changelog — | |
| never invent, embellish, or speculate about features or fixes. Do not | |
| include a version heading, a date, the word "Changelog", commit hashes, or | |
| links. Skip pure chore/CI noise unless it is all there is. Output plain | |
| Markdown only — no code fence around the output, no preamble like "Here is | |
| the summary".' | |
| REQ="$(jq -n --arg model "$MODEL" --arg sys "$SYSTEM" --arg body "$BODY" \ | |
| '{model: $model, max_tokens: 1200, | |
| messages: [{role: "system", content: $sys}, {role: "user", content: $body}]}')" | |
| RESP="$(curl -sS --max-time 120 https://openrouter.ai/api/v1/chat/completions \ | |
| -H "Authorization: Bearer $OPENROUTER_API_KEY" \ | |
| -H "Content-Type: application/json" \ | |
| -d "$REQ")" || { echo "OpenRouter request failed; keeping the raw release notes"; exit 0; } | |
| SUMMARY="$(jq -r '.choices[0].message.content // empty' <<<"$RESP" 2>/dev/null || true)" | |
| if [ -z "$SUMMARY" ]; then | |
| echo "no summary in the OpenRouter response; keeping the raw release notes" | |
| jq -r '.error.message // empty' <<<"$RESP" 2>/dev/null || true | |
| exit 0 | |
| fi | |
| printf '%s\n\n---\n\n%s\n' "$SUMMARY" "$BODY" > polished-notes.md | |
| gh release edit "$TAG" --repo "$REPO" --notes-file polished-notes.md | |
| echo "release notes polished with $MODEL" | |
| build: | |
| needs: release-please | |
| if: ${{ needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch' }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| # `updater` is the glob for this platform's updater artifact — the one that | |
| # goes in latest.json (deb/dmg are install-only and also produce .sig files, | |
| # so pick explicitly rather than grab the first .sig). | |
| - platform: ubuntu-22.04 # Linux x64 (deb + AppImage) | |
| bundles: "deb,appimage" | |
| target: linux-x86_64 | |
| updater: "*.AppImage" | |
| - platform: macos-14 # macOS Apple Silicon (app.tar.gz = updater artifact) | |
| bundles: "app,dmg" | |
| target: darwin-aarch64 | |
| updater: "*.app.tar.gz" | |
| - platform: macos-14 # macOS Intel — cross-compiled on arm64, since the | |
| bundles: "app,dmg" # macos-13 (Intel) runners are deprecated and badly | |
| target: darwin-x86_64 # queue-starved. arm64 runners are plentiful. | |
| updater: "*.app.tar.gz" | |
| rust_target: x86_64-apple-darwin | |
| - platform: windows-latest # Windows x64 | |
| bundles: "nsis" | |
| target: windows-x86_64 | |
| updater: "*-setup.exe" | |
| runs-on: ${{ matrix.platform }} | |
| steps: | |
| # Always build the workflow's own ref (the release commit on push, main on a | |
| # manual dispatch) — never an old tag, whose tree predates later CI fixes like | |
| # vendor-node.sh's cross-compile support. main carries the just-released version | |
| # until the next release PR, so a tag rebuild still yields that version's bundles. | |
| - uses: actions/checkout@v4 | |
| # Tauri's webview + bundling deps (Linux only). The Ubuntu runner is Debian- | |
| # based, so unlike an Arch host its linuxdeploy/AppImage tooling works. | |
| - name: Install Linux build dependencies | |
| if: startsWith(matrix.platform, 'ubuntu') | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y \ | |
| libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev \ | |
| libayatana-appindicator3-dev patchelf file | |
| # Rust toolchain for the Tauri shell. | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: swatinem/rust-cache@v2 | |
| with: | |
| workspaces: app/src-tauri | |
| # Cross-compile target (Intel macOS on the arm64 runner); no-op otherwise. | |
| - name: Add the cross-compile Rust target | |
| if: ${{ matrix.rust_target != '' }} | |
| run: rustup target add ${{ matrix.rust_target }} | |
| # vite-plus toolchain + app deps (same as ci.yml). | |
| - uses: voidzero-dev/setup-vp@v1 | |
| with: | |
| node-version: "24" | |
| cache: true | |
| - name: Install app dependencies | |
| working-directory: app | |
| run: vp install | |
| # Vendor the node sidecar (gitignored, so it isn't in the checkout). For a | |
| # cross-compile it must match the *target* arch, not the runner's host arch. | |
| - name: Vendor the node sidecar | |
| working-directory: app/src-tauri | |
| shell: bash | |
| run: TARGET_TRIPLE="${{ matrix.rust_target }}" ./vendor-node.sh | |
| # The release tag this run targets: release-please's tag on a push, or the | |
| # dispatch input on a manual rebuild. Empty on a build-only smoke test. | |
| - name: Resolve release tag | |
| id: tag | |
| shell: bash | |
| run: echo "tag=${{ needs.release-please.outputs.tag_name || inputs.tag }}" >> "$GITHUB_OUTPUT" | |
| # Build the bundles and sign the updater artifacts. createUpdaterArtifacts (in | |
| # tauri.conf.json) emits the updater bundle + a .sig per platform. We run the | |
| # CLI directly rather than via tauri-action, which assumes pnpm — our toolchain | |
| # is vp (it runs as beforeBuildCommand). `shell: bash` resolves the .bin shim | |
| # on every OS, including Windows. | |
| - name: Build and sign | |
| working-directory: app | |
| shell: bash | |
| env: | |
| TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} | |
| TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} | |
| run: | | |
| target_arg="" | |
| [ -n "${{ matrix.rust_target }}" ] && target_arg="--target ${{ matrix.rust_target }}" | |
| ./node_modules/.bin/tauri build $target_arg --bundles ${{ matrix.bundles }} | |
| # Both macOS arches emit `PyOps.app.tar.gz` (no arch in the name), so they'd | |
| # clobber each other on upload. Arch-suffix it (and its .sig) so each is | |
| # distinct. The .app.tar.gz is updater-only (users download the .dmg), so this | |
| # rename is invisible to them. | |
| - name: Disambiguate the macOS updater artifact | |
| if: ${{ steps.tag.outputs.tag != '' && startsWith(matrix.target, 'darwin') }} | |
| shell: bash | |
| run: | | |
| f=$(find app/src-tauri/target -path '*/release/bundle/*' -name '*.app.tar.gz' -type f | head -1) | |
| if [ -z "$f" ]; then echo "::error::no .app.tar.gz produced"; exit 1; fi | |
| base="${f%.app.tar.gz}" | |
| mv "$f" "${base}_${{ matrix.target }}.app.tar.gz" | |
| mv "$f.sig" "${base}_${{ matrix.target }}.app.tar.gz.sig" | |
| # Attach the install bundles + updater artifacts (+ their .sig) to the release. | |
| # `while read` (not mapfile) so it works on the macOS runners' bash 3.2. | |
| - name: Upload bundles to the release | |
| if: ${{ steps.tag.outputs.tag != '' }} | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| files=() | |
| while IFS= read -r -d '' f; do files+=("$f"); done < <( | |
| find app/src-tauri/target -path '*/release/bundle/*' -type f \ | |
| \( -name '*.deb' -o -name '*.AppImage' -o -name '*.dmg' \ | |
| -o -name '*-setup.exe' -o -name '*.app.tar.gz' -o -name '*.sig' \) -print0) | |
| printf 'uploading: %s\n' "${files[@]}" | |
| gh release upload "${{ steps.tag.outputs.tag }}" "${files[@]}" --clobber | |
| # Emit this platform's latest.json fragment (target -> signature + asset URL); | |
| # the aggregate job merges all four into one latest.json. | |
| - name: Build updater fragment | |
| if: ${{ steps.tag.outputs.tag != '' }} | |
| shell: bash | |
| run: | | |
| art=$(find app/src-tauri/target -path '*/release/bundle/*' -name '${{ matrix.updater }}' -type f | head -1) | |
| if [ -z "$art" ]; then echo "::error::no updater artifact (${{ matrix.updater }}) for ${{ matrix.target }}"; exit 1; fi | |
| sig="${art}.sig" | |
| if [ ! -f "$sig" ]; then echo "::error::no .sig beside $art"; exit 1; fi | |
| name=$(basename "$art") | |
| url="https://github.com/${{ github.repository }}/releases/download/${{ steps.tag.outputs.tag }}/${name}" | |
| jq -n --arg t "${{ matrix.target }}" --arg sig "$(cat "$sig")" --arg url "$url" \ | |
| '{($t): {signature: $sig, url: $url}}' > "fragment-${{ matrix.target }}.json" | |
| cat "fragment-${{ matrix.target }}.json" | |
| - name: Upload fragment | |
| if: ${{ steps.tag.outputs.tag != '' }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: updater-${{ matrix.target }} | |
| path: fragment-${{ matrix.target }}.json | |
| # Merge the per-platform fragments into one latest.json and attach it. The app's | |
| # updater endpoint is releases/latest/download/latest.json. Runs only when the | |
| # builds targeted a real tag (skipped on a build-only smoke test). | |
| latest-json: | |
| needs: [release-please, build] | |
| if: ${{ (needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch') && (needs.release-please.outputs.tag_name != '' || inputs.tag != '') }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| pattern: updater-* | |
| merge-multiple: true | |
| path: fragments | |
| - name: Assemble and upload latest.json | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| TAG="${{ needs.release-please.outputs.tag_name || inputs.tag }}" | |
| shopt -s nullglob | |
| frags=(fragments/*.json) | |
| if [ ${#frags[@]} -eq 0 ]; then echo "::error::no updater fragments"; exit 1; fi | |
| NOTES="$(gh release view "$TAG" --repo "${{ github.repository }}" --json body -q .body)" | |
| PLATFORMS="$(jq -s 'add' "${frags[@]}")" | |
| jq -n --arg v "${TAG#v}" --arg n "$NOTES" \ | |
| --arg d "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --argjson p "$PLATFORMS" \ | |
| '{version: $v, notes: $n, pub_date: $d, platforms: $p}' > latest.json | |
| cat latest.json | |
| gh release upload "$TAG" latest.json --repo "${{ github.repository }}" --clobber |