.github/workflows/release.yml
name: Build installers and release
# Eligible pushes to main build the next patch, then tag and publish it after
# every installer job passes. Manual dispatch on an existing stable tag rebuilds
# and publishes that tag. Every job runs on GitHub-hosted runners; Windows and
# macOS are billed at 2x and 10x the Linux minute rate. Tests run once, on
# Linux; other platforms only build, startup-check and package.
on:
push:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
- "website/**"
- ".impeccable/**"
- ".github/ISSUE_TEMPLATE/**"
workflow_dispatch:
permissions:
contents: read
# A retry for the same ref must not publish concurrently with its original run.
# Never cancel a publish in progress; a newer push replaces any queued run.
concurrency:
group: release
cancel-in-progress: false
jobs:
checks:
name: Checks
if: >-
github.event_name == 'workflow_dispatch' ||
(!startsWith(github.event.head_commit.message, 'Release ') &&
!contains(github.event.head_commit.message, '[skip release]'))
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.sha }}
fetch-depth: 0
- name: Require an existing stable version tag
if: github.event_name == 'workflow_dispatch'
shell: bash
run: |
[[ "${GITHUB_REF}" =~ ^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
test "$(git rev-parse --verify "${GITHUB_REF}^{commit}")" = "${GITHUB_SHA}"
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
if: github.event_name == 'push'
with:
node-version-file: .nvmrc
- name: Choose the next patch version
id: version
if: github.event_name == 'push'
run: echo "version=$(node scripts/release-version.mjs)" >> "$GITHUB_OUTPUT"
- name: Run actionlint
shell: bash
run: |
directory="$(mktemp -d)"
trap 'rm -rf "$directory"' EXIT
curl --fail --silent --show-error --location \
https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz \
--output "$directory/actionlint.tar.gz"
echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 $directory/actionlint.tar.gz" | sha256sum --check --strict
tar -xzf "$directory/actionlint.tar.gz" -C "$directory" actionlint
"$directory/actionlint" -color
linux:
needs: checks
if: always() && needs.checks.result != 'skipped'
name: Test and package (linux-x64)
runs-on: ubuntu-latest
timeout-minutes: 30
defaults:
run:
shell: bash
env:
PLATFORM: linux-x64
BUILDER_ARGS: --linux AppImage --x64
NODE_ARCH: ""
ARTIFACT: |
release/*.AppImage
release/latest-linux.yml
CSC_IDENTITY_AUTO_DISCOVERY: "false"
steps: &platform-steps
- name: Require successful checks
if: needs.checks.result != 'success'
run: exit 1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.sha }}
fetch-depth: 0
- name: Install Rosetta for x64 builds
if: env.NODE_ARCH == 'x64' && runner.os == 'macOS'
run: arch -x86_64 /usr/bin/true 2>/dev/null || sudo softwareupdate --install-rosetta --agree-to-license
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
architecture: ${{ env.NODE_ARCH }}
cache: npm
- name: Confirm Node architecture
if: env.NODE_ARCH == 'x64'
run: node -e "if (process.arch !== 'x64') { console.error('Expected x64 Node, got ' + process.arch); process.exit(1) }"
- name: Cache Electron downloads
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cache/electron
~/.cache/electron-builder
~/Library/Caches/electron
~/Library/Caches/electron-builder
~/AppData/Local/electron/Cache
~/AppData/Local/electron-builder/Cache
key: electron-${{ runner.os }}-${{ env.NODE_ARCH || runner.arch }}-${{ hashFiles('package-lock.json') }}
- name: Set build version
env:
EMC2_BUILD_REF: ${{ github.event_name == 'push' && github.sha || github.ref }}
EMC2_RELEASE_VERSION: ${{ needs.checks.outputs.version }}
run: node scripts/set-build-version.mjs
- run: npm ci --no-audit --no-fund
- if: runner.os == 'Linux'
run: npm test
- run: npm run build
- name: Check desktop startup
if: runner.os != 'Linux'
timeout-minutes: 1
run: npm run check:desktop -- --user-data-dir="${{ runner.temp }}/emc2-check"
- name: Create installer
if: runner.os != 'macOS'
run: npx --no-install electron-builder ${{ env.BUILDER_ARGS }} --publish never
# hdiutil intermittently fails ("Resource busy" / create failed) on macOS runners.
# Retry the DMG build up to 3 times, force-detaching stale volumes between attempts.
- name: Create installer (macOS, with hdiutil retry)
if: runner.os == 'macOS'
run: |
shopt -s nullglob
for attempt in 1 2 3; do
if npx --no-install electron-builder ${{ env.BUILDER_ARGS }} --publish never; then
exit 0
fi
echo "::warning::electron-builder DMG attempt $attempt failed"
for volume in /Volumes/Emc*; do
hdiutil detach -force "$volume" || true
done
rm -f release/*.dmg release/*.blockmap
sleep $((attempt * 10))
done
echo "::error::electron-builder DMG failed after 3 attempts"
exit 1
- name: Verify updater metadata and installer integrity
if: runner.os != 'macOS'
run: node --import tsx scripts/check-release-assets.ts ${{ env.PLATFORM }}
- name: Upload installer
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: installer-${{ env.PLATFORM }}
path: ${{ env.ARTIFACT }}
if-no-files-found: error
retention-days: 1
compression-level: 0
overwrite: true
windows:
needs: checks
name: Test and package (windows-x64)
runs-on: windows-2025
timeout-minutes: 45
defaults:
run:
shell: pwsh
env:
PLATFORM: windows-x64
BUILDER_ARGS: --win nsis --x64
NODE_ARCH: ""
ARTIFACT: |
release/*.exe
release/latest.yml
CSC_IDENTITY_AUTO_DISCOVERY: "false"
steps: *platform-steps
macos-arm64:
needs: checks
name: Test and package (macos-arm64)
runs-on: macos-15
timeout-minutes: 45
defaults:
run:
shell: bash --noprofile --norc -eo pipefail {0}
env:
PLATFORM: macos-arm64
BUILDER_ARGS: --mac dmg --arm64
NODE_ARCH: ""
ARTIFACT: release/*.dmg
CSC_IDENTITY_AUTO_DISCOVERY: "false"
steps: *platform-steps
# macos-15-intel is GitHub's native Intel image; the x64 Node guard stays.
macos-x64:
needs: checks
name: Test and package (macos-x64)
runs-on: macos-15-intel
timeout-minutes: 45
defaults:
run:
shell: bash --noprofile --norc -eo pipefail {0}
env:
PLATFORM: macos-x64
BUILDER_ARGS: --mac dmg --x64
NODE_ARCH: x64
ARTIFACT: release/*.dmg
CSC_IDENTITY_AUTO_DISCOVERY: "false"
steps: *platform-steps
publish:
if: github.event_name == 'push' || startsWith(github.ref, 'refs/tags/v')
needs: [checks, linux, windows, macos-arm64, macos-x64]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: .nvmrc
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: installer-*
path: release
merge-multiple: true
digest-mismatch: error
- name: Validate release assets and create checksums
env:
RELEASE_VERSION: ${{ needs.checks.outputs.version }}
run: node scripts/prepare-release-assets.mjs release "${RELEASE_VERSION:-${GITHUB_REF_NAME#v}}"
- name: Create the release tag
if: github.event_name == 'push'
env:
GH_TOKEN: ${{ github.token }}
TAG: v${{ needs.checks.outputs.version }}
run: |
if sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq .object.sha 2>/dev/null)"; then
test "$sha" = "$GITHUB_SHA"
else
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$TAG" -f sha="$GITHUB_SHA" > /dev/null
fi
- name: Publish the release
working-directory: release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event_name == 'push' && format('v{0}', needs.checks.outputs.version) || github.ref_name }}
run: |
gh release create "${TAG}" \
./*.exe ./*.dmg ./*.AppImage latest.yml latest-linux.yml SHA256SUMS.txt \
--repo "${GITHUB_REPOSITORY}" --verify-tag \
--title "${TAG}" \
--generate-notes