Skip to content

Merge pull request #135 from ApocDev/release-please--branches--main #148

Merge pull request #135 from ApocDev/release-please--branches--main

Merge pull request #135 from ApocDev/release-please--branches--main #148

Workflow file for this run

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