Skip to content

Build and Release

Build and Release #97

Workflow file for this run

name: Build and Release
# Manual release flow (modeled on gruntwork-io/terragrunt's release.yml):
#
# 1. A maintainer creates a DRAFT GitHub release for the version, targeting a
# specific commit (not a branch). The draft holds the release notes.
# 2. They run this workflow via "Run workflow", passing that version.
# 3. `validate` confirms the draft exists, the tag is strict semver, and the
# draft targets a full commit SHA (so no new commits can slip in between
# draft creation and publishing).
# 4. The per-platform `build-*` jobs build and package from that exact commit.
# 5. `upload-assets` attaches the binaries + checksums to the draft release.
# 6. The maintainer publishes the draft from the Releases page when ready.
on:
workflow_dispatch:
inputs:
version:
description: "Version to build (must match an existing draft release, e.g. v1.0.2)"
required: true
type: string
permissions:
contents: write
jobs:
# Validate the release is an existing release with a valid semver tag,
# targeting an explicit commit SHA.
validate:
name: Validate Release
runs-on: ubuntu-latest
outputs:
version: ${{ steps.resolve.outputs.version }}
ref: ${{ steps.resolve.outputs.ref }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Resolve version and ref
id: resolve
env:
INPUT_VERSION: ${{ inputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: .github/scripts/release/resolve-version-ref.sh
- name: Validate semver
env:
VERSION: ${{ steps.resolve.outputs.version }}
run: .github/scripts/release/validate-semver.sh "$VERSION"
- name: Enforce commit SHA target
env:
REF: ${{ steps.resolve.outputs.ref }}
run: .github/scripts/release/enforce-commit-sha.sh "$REF"
build-macos:
name: Build macOS
needs: validate
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.validate.outputs.ref }}
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build Electron app
run: just build
# Build both arches in a single job, one arch at a time. This must NOT be
# a matrix and must NOT be a single `electron-builder --mac` that emits
# both arches at once, because:
# 1. `resources/bin/boilerplate` is a single, arch-specific binary
# (fetch-boilerplate deletes the alternate). Each arch must be
# packaged with its matching boilerplate, so we re-fetch between
# passes — a both-at-once build would bundle the wrong-arch CLI.
# 2. A matrix had each job build BOTH arches anyway (the package.json
# `mac.target` arch arrays override the `--arm64`/`--x64` flag), so
# every artifact was produced twice under two artifact bundles. The
# create-release `download-artifact ... merge-multiple: true` then
# extracted the colliding filenames concurrently into one dir, which
# tore the bytes of whichever copy lost the race and shipped a DMG
# that failed `hdiutil verify` ("disk not readable"). One job, one
# copy of each artifact, one upload bundle = no collision.
# The `arch` arrays were removed from package.json `mac.target` so the
# `--arm64`/`--x64` flag below actually restricts each pass to one arch.
- name: Package for macOS
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.MACOS_AC_LOGIN }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.MACOS_AC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.MACOS_AC_PROVIDER }}
run: |
set -euo pipefail
# Only enable code signing when a certificate is actually configured.
# An empty CSC_LINK is treated by electron-builder as a file path that
# resolves to the project root, failing with "<projectDir> not a file".
if [ -n "$MACOS_CERTIFICATE" ]; then
export CSC_LINK="$MACOS_CERTIFICATE"
export CSC_KEY_PASSWORD="$MACOS_CERTIFICATE_PASSWORD"
else
echo "No MACOS_CERTIFICATE secret set; building an unsigned macOS app."
export CSC_IDENTITY_AUTO_DISCOVERY=false
fi
# arm64 pass (boilerplate arch arm64). `--publish never`: the
# upload-assets job is the sole uploader; electron-builder must not
# also push to the release (its implicit tag-publish caused duplicate,
# racing uploads of identically named assets).
just fetch-boilerplate darwin arm64
mise x node -- npx electron-builder --mac --arm64 --publish never
# Preserve the arm64 auto-update manifest: the x64 pass below rewrites
# latest-mac.yml from scratch with only its own artifacts (electron-
# builder only merges manifests when publishing, and we use
# `--publish never`), so without this the arm64 entries are lost.
cp out/latest-mac.yml out/latest-mac-arm64.yml
# x64 pass (boilerplate arch amd64). Cross-built on the arm64 runner.
just fetch-boilerplate darwin amd64
mise x node -- npx electron-builder --mac --x64 --publish never
# Merge the arm64 entries back into the now-x64-only latest-mac.yml so
# the published manifest lists both arches. electron-updater picks the
# arch by scanning files[] for an "arm64" url; an arch-incomplete
# manifest serves arm64 Macs the x64 build (which then runs under
# Rosetta). arm64 first so the legacy top-level path/sha512 match a
# single both-arch build.
mise x node -- node .github/scripts/release/merge-mac-latest.mjs \
out/latest-mac.yml out/latest-mac-arm64.yml out/latest-mac.yml
rm -f out/latest-mac-arm64.yml
# Fail the build loudly if any artifact is corrupt, instead of shipping a
# bad file with a matching checksum (the original failure mode).
- name: Verify macOS artifacts
run: |
set -euo pipefail
for f in out/*.dmg; do
echo "Verifying $f"
hdiutil verify "$f"
done
for z in out/*.zip; do
echo "Testing $z"
unzip -t "$z" >/dev/null
done
- name: Upload macOS artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: macos
path: |
out/*.dmg
out/*.zip
out/latest-mac*.yml
retention-days: 7
build-linux:
name: Build Linux
needs: validate
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.validate.outputs.ref }}
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build Electron app
run: just build
- name: Fetch boilerplate
run: just fetch-boilerplate linux amd64
# `--publish never`: the upload-assets job is the sole uploader.
- name: Package for Linux
run: mise x node -- npx electron-builder --linux --publish never
- name: Upload Linux artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: linux-x64
path: |
out/*.AppImage
out/*.deb
out/latest-linux*.yml
retention-days: 7
build-windows:
name: Build Windows
needs: validate
runs-on: windows-latest
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.validate.outputs.ref }}
- uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build Electron app
run: just build
- name: Fetch boilerplate
shell: bash
run: just fetch-boilerplate windows amd64
# `--publish never`: the upload-assets job is the sole uploader.
- name: Package for Windows
run: mise x node -- npx electron-builder --win --publish never
- name: Upload Windows artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-x64
path: |
out/*.exe
out/latest*.yml
retention-days: 7
# Attach the built binaries + checksums to the existing draft release.
upload-assets:
name: Upload Release Assets
needs: [validate, build-macos, build-linux, build-windows]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.validate.outputs.ref }}
- name: Check if release exists
id: check_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.validate.outputs.version }}
run: .github/scripts/release/check-release-exists.sh
- name: Download all artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: artifacts/
merge-multiple: true
- name: Generate checksums
run: .github/scripts/release/generate-checksums.sh artifacts
- name: Upload assets to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.validate.outputs.version }}
CLOBBER: "true"
run: .github/scripts/release/upload-assets.sh artifacts
- name: Verify all assets uploaded
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.validate.outputs.version }}
CLOBBER: "true"
run: .github/scripts/release/verify-assets-uploaded.sh artifacts
- name: Upload summary
if: always()
env:
VERSION: ${{ needs.validate.outputs.version }}
RELEASE_ID: ${{ steps.check_release.outputs.release_id }}
IS_DRAFT: ${{ steps.check_release.outputs.is_draft }}
run: .github/scripts/release/generate-upload-summary.sh artifacts