Desktop Installers #211
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
| # 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 }} |