Skip to content

chore(release): bump to 2.10.3 #151

chore(release): bump to 2.10.3

chore(release): bump to 2.10.3 #151

Workflow file for this run

name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g. v0.5.6). Leave empty to use current package.json version.'
required: false
permissions:
contents: write
jobs:
build-sign-release:
runs-on: windows-latest
# SignPath Authenticode signing (issue #71). The signing steps run only when
# both secrets are configured, so releases keep working while SignPath OSS
# approval is pending — and turn on automatically once the secrets land.
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_ORGANIZATION_ID: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Fix Python distutils for node-gyp
run: pip install setuptools
- name: Install dependencies
run: npm ci
# ── Gate ───────────────────────────────────────────────
# A tag push used to go straight from `npm ci` to a published release with
# no test or typecheck run anywhere in between. 0.35.0 shipped a renderer
# that threw on first paint for every user (a TDZ read in a useMemo deps
# array, which `tsc` reports as TS2448 in under two seconds) because
# nothing here ever asked. Failing the release is the point: a tag that
# cannot pass its own tests must not become a download.
- name: Test
run: npm test
# ── Build ──────────────────────────────────────────────
- name: Build main + preload + CLI
run: npm run build:main
# resources/cli/*.js is a checked-in copy of this build's output, and
# nothing kept it in step — it was ~1700 diff lines behind its own source
# when #168 measured it. Nothing shipped reads it (extraResources maps
# dist/cli), but dev-mode Claude hook registration does, so a stale copy
# means dev behaves unlike production for no visible reason.
#
# Runs AFTER build:main because that is what makes dist/cli exist; the
# build-free half of the same invariant (shell-integration) is covered by
# npm test above, so it gates a release even if this step is ever moved.
- name: Verify checked-in resources match their sources
run: npm run verify:resources
- name: Build renderer
run: npx vite build
# ── Package (NSIS installer) ───────────────────────────
# Build the NSIS installer so electron-updater can actually install the
# update on Windows. The previous `--dir` build shipped a zip, which
# electron-updater's NsisUpdater can download and verify but has no way to
# install — quitAndInstall() no-ops and the app relaunches unchanged
# (issue #96). --publish never: we upload manually below so latest.yml is
# generated AFTER signing (see the note on that step).
- name: Package with electron-builder
run: npx electron-builder --win --publish never
- name: Extract version
id: version
shell: bash
run: |
VER=$(node -p "require('./package.json').version")
echo "version=$VER" >> "$GITHUB_OUTPUT"
if [ "${{ github.event_name }}" = "push" ]; then
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
elif [ -n "${{ github.event.inputs.tag }}" ]; then
echo "tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=v$VER" >> "$GITHUB_OUTPUT"
fi
# ── Icon + version metadata ────────────────────────────
# electron-builder embeds win.icon and the package.json version into the
# packaged exe during the NSIS build above, so the previous manual rcedit
# pass is no longer needed — it targeted release/win-unpacked/wmux.exe,
# which is already baked into the installer by the time a separate step
# could run, so it had no effect on the shipped artifact.
# ── Sign exe with SignPath (runs only when secrets are set, #71) ──
# continue-on-error: a rejected submission (e.g. SignPath OSS yearly
# quota exceeded) must not block the release — we ship unsigned, as
# before, and signing resumes automatically once quota is available.
# The verify step below stays fatal: if a SIGNED artifact comes back
# invalid, the release fails rather than shipping it.
- name: Submit exe to SignPath for Authenticode signing
id: sign-submit
if: env.SIGNPATH_API_TOKEN != '' && env.SIGNPATH_ORGANIZATION_ID != ''
continue-on-error: true
shell: bash
run: |
HTTP_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" \
-X POST \
-H "Authorization: Bearer $SIGNPATH_API_TOKEN" \
-F "projectSlug=wmux" \
-F "signingPolicySlug=wmux" \
-F "artifactConfigurationSlug=initial" \
-F "description=CI release v${{ steps.version.outputs.version }}" \
-F "artifact=@release/wmux-${{ steps.version.outputs.version }}-setup.exe" \
-D headers.txt \
"https://app.signpath.io/api/v1/$SIGNPATH_ORGANIZATION_ID/SigningRequests/SubmitWithArtifact")
HTTP_CODE=$(echo "$HTTP_RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2)
echo "HTTP status: $HTTP_CODE"
if [ "$HTTP_CODE" != "201" ]; then
echo "::warning::SignPath submission failed (HTTP $HTTP_CODE) — releasing unsigned"
echo "$HTTP_RESPONSE"
exit 1
fi
LOCATION=$(grep -i "^location:" headers.txt | tr -d '\r')
SIGNING_REQUEST_ID=$(echo "$LOCATION" | grep -oE '[0-9a-f-]{36}$')
echo "Signing request ID: $SIGNING_REQUEST_ID"
echo "signing_request_id=$SIGNING_REQUEST_ID" >> "$GITHUB_OUTPUT"
- name: Wait for signing to complete
if: steps.sign-submit.outcome == 'success' && steps.sign-submit.outputs.signing_request_id != ''
shell: bash
run: |
REQ_ID="${{ steps.sign-submit.outputs.signing_request_id }}"
API="https://app.signpath.io/api/v1/$SIGNPATH_ORGANIZATION_ID/SigningRequests/$REQ_ID"
for i in $(seq 1 60); do
RESPONSE=$(curl -s -H "Authorization: Bearer $SIGNPATH_API_TOKEN" "$API")
STATUS=$(echo "$RESPONSE" | jq -r '.status')
echo "[$i/60] Status: $STATUS"
if [ "$STATUS" = "Completed" ]; then
echo "Signing completed successfully!"
exit 0
elif [ "$STATUS" = "Failed" ] || [ "$STATUS" = "Denied" ] || [ "$STATUS" = "Canceled" ]; then
echo "::error::Signing request $STATUS"
echo "$RESPONSE" | jq .
exit 1
fi
sleep 10
done
echo "::error::Signing timed out after 10 minutes"
exit 1
- name: Download signed exe and verify signature
id: sign-verify
if: steps.sign-submit.outcome == 'success' && steps.sign-submit.outputs.signing_request_id != ''
shell: bash
run: |
curl -s \
-H "Authorization: Bearer $SIGNPATH_API_TOKEN" \
-o "release/wmux-${{ steps.version.outputs.version }}-setup.exe" \
"https://app.signpath.io/api/v1/$SIGNPATH_ORGANIZATION_ID/SigningRequests/${{ steps.sign-submit.outputs.signing_request_id }}/SignedArtifact"
echo "Signed installer downloaded, size: $(wc -c < release/wmux-${{ steps.version.outputs.version }}-setup.exe) bytes"
# trusted=true only for a chain-valid Authenticode signature. A
# SignPath project can also sign with a SELF-SIGNED certificate
# (e.g. while the OSS "SignPath Foundation" cert is unavailable,
# issue #71): Get-AuthenticodeSignature then reports UnknownError
# (untrusted root). That artifact is fine to ship for manual
# download, but it must NOT be advertised to the auto-updater —
# electron-updater requires a Valid signature on the user's machine,
# so a self-signed installer downloads and then fails to install,
# which is exactly the endless-update loop of issue #96.
powershell -NoProfile -Command "
\$sig = Get-AuthenticodeSignature 'release/wmux-${{ steps.version.outputs.version }}-setup.exe';
\$subject = if (\$sig.SignerCertificate) { \$sig.SignerCertificate.Subject } else { '(none)' };
Write-Host ('Status: ' + \$sig.Status + ' — Subject: ' + \$subject);
\$trusted = if (\$sig.Status -eq 'Valid') { 'true' } else { 'false' };
Add-Content -Path \$env:GITHUB_OUTPUT -Value ('trusted=' + \$trusted);
if (\$trusted -eq 'false') {
Write-Host '::warning::Installer signature is not chain-trusted (self-signed cert?). Shipping anyway — clients no longer pin publisherName, so auto-update still works (integrity via latest.yml sha512).'
}
"
# ── Portable zip ───────────────────────────────────────
# Keep shipping the portable zip alongside the NSIS installer: the docs
# and wmux.org tell users to grab wmux-X.Y.Z-win-x64.zip, and unsigned
# zips trip SmartScreen far less than unsigned installers. The NSIS build
# above still emits release/win-unpacked/ as an intermediate, so this is
# free. Auto-update (latest.yml) points at the installer, not this zip —
# a zip can be downloaded but never installed by NsisUpdater (issue #96).
- name: Create portable zip
shell: pwsh
run: |
Compress-Archive -Path "release/win-unpacked/*" `
-DestinationPath "wmux-${{ steps.version.outputs.version }}-win-x64.zip"
# ── Generate latest.yml for electron-updater ──────────
# ALWAYS published. The `win.publisherName` pin was removed from
# electron-builder.json: with no pin, NsisUpdater skips Authenticode
# verification, so unsigned/self-signed installers install fine —
# integrity comes from the sha512 below, and the client-side quarantine
# window + explicit install dialog (updater.ts) remain the safety net.
# History: while the pin existed, latest.yml was gated on a chain-trusted
# signature; since SignPath fell back to a self-signed cert, that gate
# silently stranded every install on its current version (no latest.yml
# → 404 → no update ever). If chain-trusted signing returns, re-add the
# pin AND re-gate this step together.
# Installs ≤0.31 still carry the pin in their local app-update.yml and
# will fail verification silently; the notify-only checker
# (update-checker.ts) tells those users to update manually once.
#
# MUST run last, AFTER signing: SignPath rewrites the installer PE, so a
# hash computed earlier would not match the shipped file. Points at the
# NSIS installer (a zip can be downloaded but never installed — issue
# #96). No blockmap is emitted, so electron-updater does a full
# (non-differential) download verified by the top-level sha512.
- name: Generate latest.yml
shell: bash
run: |
node -e "
const crypto = require('crypto');
const fs = require('fs');
const version = '${{ steps.version.outputs.version }}';
const installer = 'wmux-' + version + '-setup.exe';
const data = fs.readFileSync('release/' + installer);
const sha512 = crypto.createHash('sha512').update(data).digest('base64');
const size = data.length;
const date = new Date().toISOString();
const yaml = [
'version: ' + version,
'files:',
' - url: ' + installer,
' sha512: ' + sha512,
' size: ' + size,
'path: ' + installer,
'sha512: ' + sha512,
\"releaseDate: '\" + date + \"'\",
''
].join('\n');
fs.writeFileSync('latest.yml', yaml);
console.log('latest.yml generated for', installer, 'size=' + size, 'sha512=' + sha512.substring(0, 20) + '...');
"
- name: Upload release artifacts as workflow artifacts
uses: actions/upload-artifact@v4
with:
name: wmux-release
path: |
release/wmux-${{ steps.version.outputs.version }}-setup.exe
wmux-${{ steps.version.outputs.version }}-win-x64.zip
latest.yml
# ── GitHub Release ─────────────────────────────────────
- name: Create or update GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: wmux ${{ steps.version.outputs.tag }}
files: |
release/wmux-${{ steps.version.outputs.version }}-setup.exe
wmux-${{ steps.version.outputs.version }}-win-x64.zip
latest.yml
draft: false
generate_release_notes: true