release(v2.2.0): the reported defects reach users, and the release bo… #104
Workflow file for this run
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
| # 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 |