Skip to content

release(v2.2.0): the reported defects reach users, and the release bo… #104

release(v2.2.0): the reported defects reach users, and the release bo…

release(v2.2.0): the reported defects reach users, and the release bo… #104

Workflow file for this run

# Build the LucidAgentIDE desktop installers (macOS .zip+.pkg + Windows .exe + Linux AppImage/.deb/.rpm).
#
# electron-builder can't cross-build/sign a mac app from Windows, so this runs
# the real packaging on native runners: macOS for the .zip bundle, Windows for the
# NSIS installer + portable .exe, and Ubuntu for the AppImage. All bundle the repo
# into the app (extraResources) per desktop/package.json.
#
# (macOS ships .zip, not .dmg: the default DMG layout drives a Finder/AppleScript
# background pass that is fragile on headless Apple-Silicon runners. The .zip is a
# complete, auto-updatable app bundle — see desktop/README.md.)
#
# Triggers:
# - Manual: Actions -> "Build desktop installers" -> Run workflow.
# - On tag: push a tag like v0.1.0 and the installers are attached to the
# GitHub Release for that tag.
#
# Output: downloadable artifacts on every run; release assets on tag builds.
# Builds are UNSIGNED (no Apple/Windows code-signing certs needed) — see
# desktop/README.md for how to add signing/notarization later.
name: Build desktop installers
on:
workflow_dispatch:
inputs:
publish_latest:
description: 'Publish to the rolling "latest" release (pushes to EXISTING users via auto-update). Leave OFF for test builds.'
type: boolean
default: false
push:
tags: ["v*"]
permissions:
contents: write # needed to attach installers to the tag's Release
jobs:
build:
name: ${{ matrix.label }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
label: macOS (.zip, arm64+x64)
script: dist:mac
- os: windows-latest
label: Windows (NSIS + portable, x64)
script: dist:win
- os: ubuntu-latest
label: Linux (AppImage + deb + rpm, x64)
script: dist:linux
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# Root deps are bundled into the app (extraResources copies node_modules/**),
# so they must exist before electron-builder packages. Not --frozen-lockfile: keeps the
# build working after a Dependabot npm bump (which updates package.json, not bun.lock).
- name: Install repo dependencies
run: bun install
- name: Install desktop dependencies
run: bun install
working-directory: desktop
# The static bun + uv binaries the installed app needs (so it has no prerequisites) are now
# fetched + SHA-256-verified by desktop/build/fetch-runtimes.ts, which each `dist:<os>` script
# runs first (pinned versions, vendor-cross-checked hashes — fail-closed). The old inline
# `latest`-curl steps were unpinned/unverified AND shadowed that path, so they were removed.
# electron-updater compares VERSION numbers. A rolling "latest" release that always ships 0.1.0
# never looks newer than an installed 0.1.0, so the in-app updater is a permanent no-op (the new
# code is in the installer, but auto-update refuses to fetch it). Stamp a MONOTONIC version per
# build: the git tag for tagged releases, else 0.1.<run number>. Build-time only (not committed).
- name: Set build version (so auto-update sees a newer release)
shell: bash
working-directory: desktop
env:
GH_TOKEN: ${{ github.token }} # read-only: look up the newest PUBLISHED release for the test stamp
run: |
# Derive a CLEAN semver from the tag, tolerant of a mistyped `v` prefix. A malformed tag must never
# brick the release build: electron-builder rejects e.g. ".1.11.3" ("Invalid version"), which is
# exactly what the old `${GITHUB_REF#refs/tags/v}` produced from a `v.1.11.3` tag (it left the dot).
# Strip refs/tags/, then an OPTIONAL leading `v`, then an OPTIONAL leading `.`, so v1.2.3 / v.1.2.3 /
# 1.2.3 all yield 1.2.3. If the result still isn't a real semver, fall back to the version committed
# in package.json (the source of truth) rather than failing. Non-tag runs get a monotonic 0.1.<run#>.
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
V="${GITHUB_REF#refs/tags/}"; V="${V#v}"; V="${V#.}"
if [[ ! "$V" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
echo "::warning::tag '${GITHUB_REF#refs/tags/}' is not a clean semver — using package.json version"
V="$(node -p "require('./package.json').version")"
fi
elif [ "${{ inputs.publish_latest }}" = "true" ]; then
# PUBLISHING dispatch: this is a RELEASE, not a test. It refreshes the rolling `latest`
# release, whose artifacts the website's version-pinned .deb/.rpm links resolve through
# (/releases/latest/download/...) and whose latest*.yml the in-app updater reads. A
# -test.<run> stamp here does two bad things at once: it puts a test-versioned build in
# front of every existing user via auto-update, and it leaves the site's pinned filenames
# 404ing because they name the real version. Both happened on the first publish that ever
# completed (it shipped 2.1.1-test.102). So a publish carries the COMMITTED release version
# verbatim, which is the same string the matching tag build used.
V="$(node -p "require('./package.json').version")"
echo "publish stamp: refreshing the rolling latest at the committed release version -> $V"
else
# TEST BUILD (manual dispatch, publish OFF): not a release. The stamp must sort ABOVE every PUBLISHED
# release, or the installed test build auto-updates itself back to the live version and wipes
# out what you are testing. The old 0.1.<run#> failed that outright, and package.json can LAG
# the real releases (it said 1.11.10 while v1.11.11 was live), so the committed version alone
# is not a safe base. Take the HIGHER of package.json and the newest published release, then
# patch+1 with a -test.<run#> prerelease. Build-time only: package.json in git is untouched and
# nothing is published here, so no existing user is affected. A later real release of that
# patch still supersedes the test build (a prerelease sorts before its release).
BASE="$(node -p "require('./package.json').version")"
LATEST="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName 2>/dev/null | sed 's/^v//' || true)"
# Values go through the ENV, not argv: `node -e` consumes a positional itself, which silently
# shifted the indices and produced a bogus "42.undefined.1-test.undefined" that electron-builder
# rejects as an invalid version (it would fail all three builds).
V="$(BASE="$BASE" LATEST="$LATEST" RUN="$GITHUB_RUN_NUMBER" node -e 'const c=(a,b)=>{const A=a.split("-")[0].split(".").map(Number),B=b.split("-")[0].split(".").map(Number);for(let i=0;i<3;i++){const d=(A[i]||0)-(B[i]||0);if(d)return d}return 0};const base=process.env.BASE||"0.0.0",latest=process.env.LATEST||"0.0.0",run=process.env.RUN||"0";const hi=c(base,latest)>=0?base:latest;const p=hi.split("-")[0].split(".").map(Number);console.log((p[0]||0)+"."+(p[1]||0)+"."+((p[2]||0)+1)+"-test."+run)')"
echo "test stamp: package.json=$BASE published=${LATEST:-none} -> $V"
fi
node -e "const fs=require('fs'),p=require('./package.json');p.version=process.argv[1];fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" "$V"
echo "build version = $V"
# electron-builder's deb/rpm targets shell out to fpm; building an .rpm on the Ubuntu runner needs
# the `rpm` toolchain (rpmbuild), which isn't installed by default. (.deb needs only fpm + dpkg,
# already present.) No-op on macOS/Windows.
- name: Install rpm tooling (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y rpm
# macOS builds whisper.cpp from source (upstream publishes no prebuilt server binary, only an
# xcframework), so the runner needs cmake. GitHub's macOS image ships it; make it explicit + resilient
# so an image change can't silently drop the bundled offline-STT binary. Win/Linux fetch a pinned,
# SHA-256-verified prebuilt release instead (no toolchain needed) - see desktop/build/fetch-whisper.ts.
- name: Ensure cmake (macOS, for the whisper.cpp source build)
if: runner.os == 'macOS'
run: cmake --version || brew install cmake
# electron-builder's pkg target resolves the signing cert even for an unsigned (identity:null)
# build. An EMPTY CSC_LINK is NOT the same as unset: '' != null in JS, so electron-builder treats
# '' as a cert path, resolves it to the project dir, and dies "<projectDir> not a file" (only the
# pkg target trips this — the zip/app path short-circuits on identity:null). So export CSC_LINK /
# CSC_KEY_PASSWORD ONLY when a real cert secret exists; never as empty strings.
- name: Configure code-signing (only when a cert secret exists)
shell: bash
env:
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
run: |
if [[ "$RUNNER_OS" == "macOS" && -n "$MAC_CSC_LINK" ]]; then
{ echo "CSC_LINK=$MAC_CSC_LINK"; echo "CSC_KEY_PASSWORD=$MAC_CSC_KEY_PASSWORD"; } >> "$GITHUB_ENV"
elif [[ "$RUNNER_OS" == "Windows" && -n "$WIN_CSC_LINK" ]]; then
{ echo "CSC_LINK=$WIN_CSC_LINK"; echo "CSC_KEY_PASSWORD=$WIN_CSC_KEY_PASSWORD"; } >> "$GITHUB_ENV"
fi
- name: Build installer (${{ matrix.script }})
run: bun run ${{ matrix.script }}
working-directory: desktop
env:
# CSC_LINK / CSC_KEY_PASSWORD come from the "Configure code-signing" step above — set ONLY
# when a real cert secret exists, never as '' (an empty CSC_LINK crashes the pkg build).
# Only hunt the keychain for a mac identity when a mac cert is provided.
CSC_IDENTITY_AUTO_DISCOVERY: ${{ runner.os == 'macOS' && secrets.MAC_CSC_LINK != '' }}
# macOS notarization (electron-builder runs notarytool when these are set).
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# softprops attaches the release; don't let electron-builder also upload.
GH_TOKEN: ""
# Air-gap gate (ADR-0225): prove the PACKAGED app is self-contained — omp + the scanner's Python
# resolve and RUN from bundled resources, so an air-gapped host works cold with no `bun add -g` /
# `uv venv` / network. Runs on the same native runner against electron-builder's *-unpacked output;
# fails the build (before any artifact is uploaded) if a runtime is missing or lost its exec bit.
- name: Air-gap smoke test (bundled runtimes resolve offline)
run: bun run build/airgap-smoke.ts
working-directory: desktop
# Program Files boot gate (ADR-0261): the gap that shipped the v1.12.0 brick - nothing ever
# booted the app from an ACL-protected install tree (packaged_boot.test.ts boots from a writable
# temp dir). Stages the PACKAGED repo into C:\Program Files\..., denies the runner user write on
# the whole tree (runners are admins, so inherited Program Files ACLs alone would not constrain
# them the way they constrain a real standard user), proves the denial took, then requires the
# compiled bin/lucid-engine to answer /api/health + serve the prebuilt renderer from there.
# STRICT: a missing packaged engine or an unwritable Program Files fails the build - never a
# silent downgrade to a temp dir (that downgrade is how the gap survived). Windows-only: the
# brick class is a Windows ACL/loader behavior.
- name: Program Files boot smoke (engine boots from a protected install)
if: runner.os == 'Windows'
run: bun run build/pf-boot-smoke.ts
working-directory: desktop
env:
LUCID_PF_SMOKE_STRICT: "1"
# Release identity gate (ADR-0307): make flavor identity a machine-checked precondition of the
# upload instead of something a human confirms after the fact. A user reported that v1.14.1
# installed a different product entirely ("Tactical GenAI Trainer") rather than LUCID. The
# release turned out to be genuine, but proving that cost a full forensic session of
# hand-parsing xar/ar/rpm headers out of the published assets, because no gate had ever looked
# INSIDE an artifact - CI only ever checked that files with the right NAMES existed.
# So this reads each artifact's EMBEDDED identity: the .pkg's bundle id, payload .app path and
# version from Distribution/PackageInfo, the .deb package name from its ar control member, the
# .rpm name from the lead, and the filename stem for the formats that carry no readable identity
# (.zip, NSIS/portable .exe, .AppImage, latest*.yml). Fail-closed: any mismatch against the
# flavor being built, or any unrecognized file sitting in the release dir, fails the build.
# Placement is load-bearing - after the air-gap and Program Files gates, before every upload -
# so a mis-flavored or mis-versioned artifact can never reach an artifact bundle or a Release.
- name: Release identity gate (embedded identity matches the flavor)
run: bun run build/release-identity-gate.ts --flavor agent
working-directory: desktop
- name: Upload installers as artifacts
uses: actions/upload-artifact@v7
with:
name: LucidAgentIDE-${{ runner.os }}
if-no-files-found: warn
path: |
desktop/release/*.dmg
desktop/release/*.zip
desktop/release/*.pkg
desktop/release/*Setup*.exe
desktop/release/*portable*.exe
desktop/release/*.AppImage
desktop/release/*.deb
desktop/release/*.rpm
desktop/release/*.blockmap
desktop/release/latest*.yml
- name: Attach installers to the Release (tag builds only)
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v3
with:
# Installers + the electron-updater feed (latest*.yml + .blockmap) so
# in-app auto-update can find new versions.
files: |
desktop/release/*.dmg
desktop/release/*.zip
desktop/release/*.pkg
desktop/release/*Setup*.exe
desktop/release/*portable*.exe
desktop/release/*.AppImage
desktop/release/*.deb
desktop/release/*.rpm
desktop/release/*.blockmap
desktop/release/latest*.yml
# Homebrew ships from THIS repo's Casks/lucidagentide.rb (the repo doubles as the tap:
# `brew tap mlcyclops/lucid https://github.com/mlcyclops/lucidagentide`). The cask is PINNED
# per release (version + per-arch sha256) because the in-app updater cannot install updates
# on the unsigned mac build (ADR-0246), which makes `brew upgrade` the working macOS update
# channel - so the pin must move with every release, automatically. This job rewrites the
# pin from the tag's uploaded release assets and pushes it to master, so `brew update` sees
# the new version the moment the release is live. Fail-closed: a missing asset or checksum
# fails the job loudly instead of leaving the cask silently stale (the pre-fix failure mode:
# the cask tracked the rolling "latest" release, which tag builds never refresh, so
# `brew install` served a weeks-old build - see ADR-0258).
update-cask:
name: Pin the Homebrew cask to this release
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
ref: master
- name: Rewrite Casks/lucidagentide.rb from the release assets
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/}"
# Same tolerant semver derivation as the version-stamp step (ADR-0213).
V="${TAG#v}"; V="${V#.}"
if [[ ! "$V" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
echo "::error::tag '${TAG}' is not a clean semver; refusing to pin the cask"; exit 1
fi
# sha256 of a release asset: prefer the API's server-computed digest, else
# download the asset and hash it. Empty result = hard failure (fail-closed).
sha() {
local d
d=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
--jq ".assets[] | select(.name==\"$1\") | .digest" | sed 's/^sha256://')
if [ -z "$d" ]; then
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "$1" --output "/tmp/$1"
d=$(sha256sum "/tmp/$1" | cut -d' ' -f1)
fi
[ -n "$d" ] || { echo "::error::no sha256 for release asset $1"; exit 1; }
echo "$d"
}
ARM=$(sha "LucidAgent-mac-arm64.pkg")
X64=$(sha "LucidAgent-mac-x64.pkg")
sed -i -E \
-e "s|^ version \".*\"$| version \"${V}\"|" \
-e "s|^ sha256 arm: \".*\",$| sha256 arm: \"${ARM}\",|" \
-e "s|^ intel: \".*\"$| intel: \"${X64}\"|" \
Casks/lucidagentide.rb
if git diff --exit-code --quiet Casks/lucidagentide.rb; then
echo "cask already pinned to ${V}"; exit 0
fi
# The sed must have landed all three fields; a drifted cask format must fail
# loudly, never half-pin (version bumped but checksums stale).
grep -q "version \"${V}\"" Casks/lucidagentide.rb
grep -q "${ARM}" Casks/lucidagentide.rb
grep -q "${X64}" Casks/lucidagentide.rb
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Casks/lucidagentide.rb
git commit -m "chore(brew): pin cask to ${TAG} (auto, update-cask job)"
git pull --rebase origin master
git push origin master
# Rolling "latest" release — every successful build (both platforms) publishes the newest
# installers to a single `latest`-tagged release, so the README download buttons
# (releases/latest/download/...) always point at the most recent build. Tag pushes still
# get their own versioned release via the step above; this covers manual `workflow_dispatch`
# runs so you don't have to tag to refresh the downloads.
publish-latest:
name: Publish rolling "latest" release
needs: build
# OPT-IN ONLY. This job updates the rolling `latest` release AND the electron-updater feed
# (latest*.yml), i.e. it pushes the build at everyone who already has the app installed. A plain
# manual dispatch must therefore publish NOTHING - it just uploads the per-OS artifacts for testing.
# Double-gated: the toggle must be ON *and* the run must be from the default branch, so a test build
# off a feature branch can never reach existing users even if the toggle is flipped by mistake.
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_latest && github.ref == 'refs/heads/master' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all platform artifacts
uses: actions/download-artifact@v8 # must match upload-artifact's major (artifact format)
with:
path: dist
merge-multiple: true
- name: List artifacts
run: ls -la dist
# softprops/action-gh-release replaces same-named assets but never deletes ones whose
# names changed (e.g. an old *.dmg or a versioned *-mac.zip from a prior build). Clear
# ALL existing assets on the rolling "latest" release first so it only ever holds the
# current build's artifacts. No-op if the release doesn't exist yet.
- name: Clear stale assets from the "latest" release
env:
GH_TOKEN: ${{ github.token }}
run: |
ids=$(gh api "repos/${{ github.repository }}/releases/tags/latest" --jq '.assets[].id' 2>/dev/null || true)
for id in $ids; do
echo "deleting stale asset $id"
gh api -X DELETE "repos/${{ github.repository }}/releases/assets/$id" || true
done
- name: Publish / update the "latest" release
uses: softprops/action-gh-release@v3
with:
tag_name: latest
name: Latest build
prerelease: false
make_latest: "true"
body: |
Rolling build — auto-updated on every successful CI build (commit ${{ github.sha }}).
Always the newest Windows, macOS, and Linux installers. For a pinned version, see the tagged releases.
files: |
dist/*.dmg
dist/*Setup*.exe
dist/*portable*.exe
dist/*.AppImage
dist/*.deb
dist/*.rpm
dist/*.zip
dist/*.pkg
dist/*.blockmap
dist/latest*.yml