Skip to content

Desktop Installers #211

Desktop Installers

Desktop Installers #211

Workflow file for this run

# Ship desktop installers for every v* tag, but only pay the ~30 min
# matrix-build tax when the Electron wrapper itself actually changed.
# If a tag only bumps the pixcode product version (the common case),
# we fetch the previous release's installers, rename them to the new
# version, and re-upload — zero build, zero wasted CI minutes.
#
# Detection heuristic: compare files that CAN affect the built binary
# between the previous v* tag and the current one. If any of them
# moved, we trigger a full matrix build; otherwise we reuse:
# - desktop/electron/** (tray / window / bootstrap code)
# - desktop/electron-builder.yml (packaging config)
# - desktop/build-resources/** (icons, native assets)
#
# Explicitly NOT tracked: desktop/package.json (version bump + pixcode
# dep bump don't need a new binary — the runtime dir bootstrap picks
# up the new pixcode via the in-app update path) and README.md.
#
# electron-updater implication: when we reuse, we also skip the
# `latest.yml` / `latest-mac.yml` / `latest-linux.yml` metadata files
# the first build emitted. Users' installed wrappers therefore won't
# be prompted to "re-download the same binary" — they only see a
# wrapper update dialog when we really shipped new wrapper code.
name: Desktop Installers
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Git tag to attach artifacts to (e.g. v1.31.8)'
required: true
type: string
force_rebuild:
description: 'Force a full rebuild even if the wrapper is unchanged'
required: false
type: boolean
default: false
permissions:
contents: write
jobs:
# ─────────────────────────────────────────────────────────────
# 1. Decide: full build or reuse previous installers?
# ─────────────────────────────────────────────────────────────
decide:
runs-on: ubuntu-latest
outputs:
mode: ${{ steps.check.outputs.mode }}
source_tag: ${{ steps.check.outputs.source_tag }}
current_tag: ${{ steps.check.outputs.current_tag }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
# Full history needed so we can `git diff` against the prior tag.
fetch-depth: 0
- name: Decide build vs. reuse
id: check
shell: bash
run: |
CURRENT_TAG="${{ inputs.tag || github.ref_name }}"
echo "current_tag=$CURRENT_TAG" >> "$GITHUB_OUTPUT"
echo "Current tag: $CURRENT_TAG"
if [ "${{ inputs.force_rebuild }}" = "true" ]; then
echo "Force rebuild requested."
echo "mode=build" >> "$GITHUB_OUTPUT"
exit 0
fi
# Find the most recent v* tag ordered by semver that isn't us.
# -vFx: fixed-string (treat dots/hyphens literally, not as regex)
# matched against the full line — otherwise "v1.31.7" would also
# exclude a hypothetical "v1X31Y7".
PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | grep -vFx "$CURRENT_TAG" | head -n1 || true)
echo "Previous tag: ${PREV_TAG:-<none>}"
echo "source_tag=$PREV_TAG" >> "$GITHUB_OUTPUT"
if [ -z "$PREV_TAG" ]; then
echo "No previous tag — full build required."
echo "mode=build" >> "$GITHUB_OUTPUT"
exit 0
fi
# Narrow the diff to files that actually change the desktop wrapper
# binary. App/server/package version bumps are delivered through the
# npm package and in-app update path, so they must not spend CI minutes
# rebuilding native installers on every release tag.
if git diff --quiet "$PREV_TAG" "$CURRENT_TAG" -- \
desktop/electron/ \
desktop/electron-builder.yml \
desktop/build-resources/; then
echo "Wrapper unchanged since $PREV_TAG — reusing its installers."
echo "mode=reuse" >> "$GITHUB_OUTPUT"
else
echo "Wrapper changed since $PREV_TAG — full matrix build."
echo "mode=build" >> "$GITHUB_OUTPUT"
fi
# Fail loudly if `mode` somehow didn't get set — otherwise the
# downstream `if:` conditions both evaluate false and the workflow
# exits green with ZERO installer work done, which looks like a
# success to the release page. Belt-and-braces.
- name: Verify mode was decided
shell: bash
run: |
MODE="${{ steps.check.outputs.mode }}"
if [ "$MODE" != "build" ] && [ "$MODE" != "reuse" ]; then
echo "BUG: decide.mode is '$MODE' (expected 'build' or 'reuse')"
exit 1
fi
# ─────────────────────────────────────────────────────────────
# 2a. REUSE path — fetch + rename + reupload previous installers.
# ─────────────────────────────────────────────────────────────
reuse:
needs: decide
if: needs.decide.outputs.mode == 'reuse'
runs-on: ubuntu-latest
steps:
- name: Download previous installers
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
SRC="${{ needs.decide.outputs.source_tag }}"
mkdir -p dist-installer
# Skip latest*.yml so electron-updater doesn't offer a useless
# wrapper update for a renamed-but-identical binary.
gh release download "$SRC" \
--repo "${{ github.repository }}" \
--dir dist-installer \
--pattern 'Pixcode*.exe' \
--pattern 'Pixcode*.dmg' \
--pattern 'Pixcode*.AppImage' \
--pattern 'Pixcode*.deb'
ls -la dist-installer
- name: Rename installers to the new version
run: |
set -euo pipefail
CUR="${{ needs.decide.outputs.current_tag }}"
SRC="${{ needs.decide.outputs.source_tag }}"
NEW_VER="${CUR#v}"
OLD_VER="${SRC#v}"
for file in dist-installer/*; do
base=$(basename "$file")
# Replace all occurrences of the old version in the filename
# with the new one (belt-and-braces: exe uses Setup-X.Y.Z,
# dmg / AppImage / deb bake the version elsewhere in the name).
renamed="${base//$OLD_VER/$NEW_VER}"
if [ "$base" != "$renamed" ]; then
mv "dist-installer/$base" "dist-installer/$renamed"
echo " $base → $renamed"
fi
done
echo '---'
ls -la dist-installer
- name: Attach renamed installers to the new release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.decide.outputs.current_tag }}
files: |
dist-installer/*.exe
dist-installer/*.dmg
dist-installer/*.AppImage
dist-installer/*.deb
fail_on_unmatched_files: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ─────────────────────────────────────────────────────────────
# 2b. BUILD path — full matrix build on Win/Mac/Linux.
# ─────────────────────────────────────────────────────────────
build:
needs: decide
if: needs.decide.outputs.mode == 'build'
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: desktop
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag || github.ref }}
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install Linux build deps
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libgtk-3-0 libnotify4 libnss3 libxtst6 xdg-utils \
libatspi2.0-0 libdrm2 libgbm1 libxcb-dri3-0 \
fuse libfuse2
working-directory: .
- name: Install Electron wrapper deps
run: |
if [ -f package-lock.json ]; then
npm ci
else
npm install
fi
shell: bash
# Make sure the DMG-embedded Gatekeeper helper is actually
# executable when it lands inside the .dmg. Git on Windows doesn't
# preserve the +x bit reliably, so we set it explicitly on the
# macOS runner before electron-builder copies the file into the
# DMG. No-op on Windows/Linux matrix branches.
- name: chmod +x macOS Gatekeeper helper
if: matrix.os == 'macos-latest'
run: chmod +x "build-resources/Fix Gatekeeper.command"
- name: Build installer
# `--publish never` stops electron-builder from attempting the
# upload step itself — we attach via action-gh-release below.
run: npx electron-builder --publish never
# Clean out electron-builder's diagnostic dumps before upload so
# they don't show up as clutter in the release "Assets" section.
# `builder-debug.yml` / `builder-effective-config.yaml` are written
# by electron-builder when certain warnings fire — useful for
# local debugging, noise for end-users.
- name: Strip debug artifacts
shell: bash
run: |
rm -f desktop/dist-installer/builder-debug.yml \
desktop/dist-installer/builder-effective-config.yaml
- name: Upload workflow artifacts
uses: actions/upload-artifact@v4
with:
name: pixcode-${{ matrix.os }}
# latest*.yml stays — electron-updater's auto-update mechanism
# reads them to decide whether the installed wrapper is out
# of date. Without them, users wouldn't get wrapper-update
# prompts at all. builder-debug is not needed by anyone.
path: |
desktop/dist-installer/*.exe
desktop/dist-installer/*.dmg
desktop/dist-installer/*.AppImage
desktop/dist-installer/*.deb
desktop/dist-installer/latest*.yml
if-no-files-found: error
- name: Attach to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
desktop/dist-installer/*.exe
desktop/dist-installer/*.dmg
desktop/dist-installer/*.AppImage
desktop/dist-installer/*.deb
desktop/dist-installer/latest*.yml
tag_name: ${{ inputs.tag || github.ref_name }}
fail_on_unmatched_files: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}