From 354fa586b407ac9452e52828a61b0c09b7eff2b4 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 12 May 2026 09:34:35 +0300 Subject: [PATCH 1/8] ci(agents): build and publish desktop app Restore the desktop build, PR artifact, and canary publishing workflows on top of the app packaging branch. Co-authored-by: Cursor --- .github/workflows/agents_desktop_build.yml | 691 ++++++++++++++++++++ .github/workflows/agents_desktop_canary.yml | 71 ++ .github/workflows/agents_desktop_pr.yml | 69 ++ .github/workflows/changesets_release.yml | 28 + scripts/ci/desktop-affected.mjs | 156 +++++ 5 files changed, 1015 insertions(+) create mode 100644 .github/workflows/agents_desktop_build.yml create mode 100644 .github/workflows/agents_desktop_canary.yml create mode 100644 .github/workflows/agents_desktop_pr.yml create mode 100644 scripts/ci/desktop-affected.mjs diff --git a/.github/workflows/agents_desktop_build.yml b/.github/workflows/agents_desktop_build.yml new file mode 100644 index 0000000000..111ef40e99 --- /dev/null +++ b/.github/workflows/agents_desktop_build.yml @@ -0,0 +1,691 @@ +name: Agents Desktop Build + +on: + workflow_call: + inputs: + channel: + required: true + type: string + version: + required: true + type: string + git_ref: + required: true + type: string + publish: + required: true + type: boolean + sign: + required: true + type: boolean + release_tag: + required: false + type: string + default: '' + release_name: + required: false + type: string + default: '' + +jobs: + prepare-release: + name: Prepare + runs-on: ubuntu-latest + steps: + - name: Checkout + if: ${{ inputs.publish || (inputs.channel == 'pr' && github.event_name == 'pull_request') }} + uses: actions/checkout@v5.0.0 + with: + fetch-depth: 0 + ref: ${{ inputs.git_ref }} + + - name: Create or update PR artifact comment + if: ${{ inputs.channel == 'pr' && github.event_name == 'pull_request' }} + uses: actions/github-script@v7 + continue-on-error: true + with: + script: | + const marker = '' + const platforms = [ + ['macos-arm64', 'macOS Apple Silicon'], + ['macos-x64', 'macOS Intel'], + ['windows-x64', 'Windows x64'], + ['linux-x64', 'Linux x64'], + ] + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + const sha = context.payload.pull_request.head.sha + const shortSha = sha.slice(0, 7) + const rows = platforms + .map(([, label]) => `| ${label} | Building | Pending |`) + .join('\n') + const body = [ + marker, + '## Electric Agents Desktop Builds', + '', + `Build artifacts for commit \`${shortSha}\` are in progress.`, + '', + '| Platform | Status | Artifact |', + '| --- | --- | --- |', + rows, + '', + `[Workflow run](${runUrl})`, + ].join('\n') + + const { owner, repo } = context.repo + const issue_number = context.payload.pull_request.number + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }) + const existing = comments.find((comment) => comment.body?.includes(marker)) + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }) + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }) + } + + - name: Create or update GitHub release + if: ${{ inputs.publish }} + shell: bash + env: + CHANNEL: ${{ inputs.channel }} + GH_TOKEN: ${{ github.token }} + RELEASE_NAME: ${{ inputs.release_name }} + RELEASE_TAG: ${{ inputs.release_tag }} + SIGN: ${{ inputs.sign }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + if [[ -z "$RELEASE_TAG" ]]; then + echo "release_tag is required when publish is true" >&2 + exit 1 + fi + + COMMIT_SHA="$(git rev-parse HEAD)" + RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + + if [[ "$CHANNEL" == "canary" ]]; then + git tag -f "$RELEASE_TAG" "$COMMIT_SHA" + git push origin "refs/tags/${RELEASE_TAG}" --force + + { + echo "Canary build for Electric Agents Desktop." + echo + echo "- Source commit: ${COMMIT_SHA}" + echo "- Workflow run: ${RUN_URL}" + echo "- Build timestamp: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo "- Desktop package version: ${VERSION}" + echo "- Signed/notarized: ${SIGN}" + echo + echo "Canary builds are pre-release quality and may be unsigned." + } > release-notes.md + + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release edit "$RELEASE_TAG" \ + --title "$RELEASE_NAME" \ + --notes-file release-notes.md \ + --prerelease + else + gh release create "$RELEASE_TAG" \ + --target "$COMMIT_SHA" \ + --title "$RELEASE_NAME" \ + --notes-file release-notes.md \ + --prerelease + fi + else + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release edit "$RELEASE_TAG" --title "$RELEASE_NAME" + else + gh release create "$RELEASE_TAG" \ + --target "$COMMIT_SHA" \ + --title "$RELEASE_NAME" \ + --notes "Desktop artifacts for Electric Agents ${VERSION}." + fi + fi + + build: + name: Build ${{ matrix.name }} + needs: prepare-release + runs-on: ${{ matrix.runs_on }} + strategy: + fail-fast: false + matrix: + include: + - name: macOS + id: macos + runs_on: macos-14 + builder_args: --mac --arm64 --x64 + - name: Windows x64 + id: windows-x64 + runs_on: windows-latest + builder_args: --win --x64 + - name: Linux x64 + id: linux-x64 + runs_on: ubuntu-latest + builder_args: --linux --x64 + + steps: + - name: Checkout + uses: actions/checkout@v5.0.0 + with: + fetch-depth: 0 + ref: ${{ inputs.git_ref }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.tool-versions' + cache: pnpm + + - name: Install dependencies + run: pnpm --filter @electric-ax/agents-desktop... install --frozen-lockfile + + - name: Build desktop type dependencies + run: pnpm --filter @electric-ax/agents... build + + - name: Typecheck desktop app + run: pnpm --filter @electric-ax/agents-desktop typecheck + + - name: Build desktop app + run: pnpm --filter @electric-ax/agents-desktop build + + - name: Build desktop icons + run: pnpm --filter @electric-ax/agents-desktop build:icons + + - name: Rebuild native modules + run: pnpm --filter @electric-ax/agents-desktop rebuild:native + + - name: Package macOS desktop app + if: ${{ matrix.id == 'macos' }} + shell: bash + env: + CSC_FOR_PULL_REQUEST: ${{ inputs.sign && 'false' || 'true' }} + CSC_IDENTITY_AUTO_DISCOVERY: ${{ inputs.sign && 'true' || 'false' }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + builder_args=(${{ matrix.builder_args }}) + if [[ "${{ inputs.sign }}" != "true" ]]; then + builder_args+=("-c.mac.identity=-") + fi + + pnpm --filter @electric-ax/agents-desktop exec electron-builder "${builder_args[@]}" --publish never + + - name: Package desktop app + if: ${{ matrix.id != 'macos' }} + env: + CSC_IDENTITY_AUTO_DISCOVERY: ${{ inputs.sign && 'true' || 'false' }} + GH_TOKEN: ${{ github.token }} + run: pnpm --filter @electric-ax/agents-desktop exec electron-builder ${{ matrix.builder_args }} --publish never + + - name: Verify macOS app signatures + if: ${{ matrix.id == 'macos' }} + shell: bash + run: | + set -euo pipefail + + verify_signature() { + local app_dir="$1" + + codesign -dv --verbose=4 "$app_dir" + codesign --verify --deep --verbose=4 "$app_dir" + } + + verify_signature "packages/agents-desktop/release/mac-arm64/Electric Agents.app" + verify_signature "packages/agents-desktop/release/mac/Electric Agents.app" + + - name: Verify macOS native module architectures + if: ${{ matrix.id == 'macos' }} + shell: bash + run: | + set -euo pipefail + + check_arch() { + local app_dir="$1" + local expected="$2" + local found=0 + + while IFS= read -r -d '' node_file; do + found=1 + info="$(file "$node_file")" + echo "$info" + if [[ "$info" != *"$expected"* ]]; then + echo "Expected $node_file to contain $expected" >&2 + exit 1 + fi + done < <(find "$app_dir" -name '*.node' -print0) + + if [[ "$found" -eq 0 ]]; then + echo "No native .node files found under $app_dir" >&2 + exit 1 + fi + } + + check_arch "packages/agents-desktop/release/mac-arm64" "arm64" + check_arch "packages/agents-desktop/release/mac" "x86_64" + + - name: Upload macOS Apple Silicon DMG + id: upload-macos-arm64-dmg + if: ${{ matrix.id == 'macos' }} + uses: actions/upload-artifact@v4 + with: + name: electric-agents-desktop-${{ inputs.channel }}-macos-arm64-dmg + retention-days: ${{ inputs.channel == 'pr' && 7 || 30 }} + if-no-files-found: error + path: packages/agents-desktop/release/*mac-arm64.dmg + + - name: Upload macOS Intel DMG + id: upload-macos-x64-dmg + if: ${{ matrix.id == 'macos' }} + uses: actions/upload-artifact@v4 + with: + name: electric-agents-desktop-${{ inputs.channel }}-macos-x64-dmg + retention-days: ${{ inputs.channel == 'pr' && 7 || 30 }} + if-no-files-found: error + path: packages/agents-desktop/release/*mac-x64.dmg + + - name: Upload Windows installer + id: upload-windows-x64-exe + if: ${{ matrix.id == 'windows-x64' }} + uses: actions/upload-artifact@v4 + with: + name: electric-agents-desktop-${{ inputs.channel }}-windows-x64-exe + retention-days: ${{ inputs.channel == 'pr' && 7 || 30 }} + if-no-files-found: error + path: packages/agents-desktop/release/*.exe + + - name: Upload Linux AppImage + id: upload-linux-x64-appimage + if: ${{ matrix.id == 'linux-x64' }} + uses: actions/upload-artifact@v4 + with: + name: electric-agents-desktop-${{ inputs.channel }}-linux-x64-appimage + retention-days: ${{ inputs.channel == 'pr' && 7 || 30 }} + if-no-files-found: error + path: packages/agents-desktop/release/*.AppImage + + - name: Upload Linux deb + id: upload-linux-x64-deb + if: ${{ matrix.id == 'linux-x64' }} + uses: actions/upload-artifact@v4 + with: + name: electric-agents-desktop-${{ inputs.channel }}-linux-x64-deb + retention-days: ${{ inputs.channel == 'pr' && 7 || 30 }} + if-no-files-found: error + path: packages/agents-desktop/release/*.deb + + - name: Update PR artifact comment for ${{ matrix.name }} + if: ${{ always() && inputs.channel == 'pr' && github.event_name == 'pull_request' }} + uses: actions/github-script@v7 + continue-on-error: true + env: + LINUX_APPIMAGE_URL: ${{ steps.upload-linux-x64-appimage.outputs.artifact-url }} + LINUX_DEB_URL: ${{ steps.upload-linux-x64-deb.outputs.artifact-url }} + MACOS_ARM64_DMG_URL: ${{ steps.upload-macos-arm64-dmg.outputs.artifact-url }} + MACOS_X64_DMG_URL: ${{ steps.upload-macos-x64-dmg.outputs.artifact-url }} + PLATFORM_ID: ${{ matrix.id }} + PLATFORM_LABEL: ${{ matrix.name }} + WINDOWS_EXE_URL: ${{ steps.upload-windows-x64-exe.outputs.artifact-url }} + with: + script: | + const marker = '' + const platforms = [ + ['macos-arm64', 'macOS Apple Silicon'], + ['macos-x64', 'macOS Intel'], + ['windows-x64', 'Windows x64'], + ['linux-x64', 'Linux x64'], + ] + const { owner, repo } = context.repo + const issue_number = context.payload.pull_request.number + const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}` + const sha = context.payload.pull_request.head.sha + const shortSha = sha.slice(0, 7) + const label = process.env.PLATFORM_LABEL + const platformId = process.env.PLATFORM_ID + const rowsByLabel = buildRows() + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }) + const existing = comments.find((comment) => comment.body?.includes(marker)) + let nextBody = existing?.body ?? renderInitialBody() + for (const [platformLabel, row] of rowsByLabel) { + nextBody = replacePlatformRow(nextBody, platformLabel, row) + } + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body: nextBody, + }) + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: nextBody, + }) + } + + function renderInitialBody() { + return [ + marker, + '## Electric Agents Desktop Builds', + '', + `Build artifacts for commit \`${shortSha}\` are in progress.`, + '', + '| Platform | Status | Artifact |', + '| --- | --- | --- |', + platforms + .map(([, platformLabel]) => `| ${platformLabel} | Building | Pending |`) + .join('\n'), + '', + `[Workflow run](${runUrl})`, + ].join('\n') + } + + function replacePlatformRow(body, platformLabel, row) { + const lines = body.split('\n') + const rowIndex = lines.findIndex((line) => + line.startsWith(`| ${platformLabel} |`) + ) + if (rowIndex === -1) { + return body + } + + lines[rowIndex] = row + return lines.join('\n') + } + + function buildRows() { + switch (platformId) { + case 'macos': + return [ + [ + 'macOS Apple Silicon', + buildRow( + 'macOS Apple Silicon', + process.env.MACOS_ARM64_DMG_URL + ? `[DMG](${process.env.MACOS_ARM64_DMG_URL})` + : null + ), + ], + [ + 'macOS Intel', + buildRow( + 'macOS Intel', + process.env.MACOS_X64_DMG_URL + ? `[DMG](${process.env.MACOS_X64_DMG_URL})` + : null + ), + ], + ] + case 'linux-x64': + return [ + [ + 'Linux x64', + buildRow( + 'Linux x64', + [ + process.env.LINUX_APPIMAGE_URL + ? `[AppImage](${process.env.LINUX_APPIMAGE_URL})` + : null, + process.env.LINUX_DEB_URL + ? `[deb](${process.env.LINUX_DEB_URL})` + : null, + ] + .filter(Boolean) + .join(' / ') || null + ), + ], + ] + case 'windows-x64': + return [ + [ + 'Windows x64', + buildRow( + 'Windows x64', + process.env.WINDOWS_EXE_URL + ? `[Installer](${process.env.WINDOWS_EXE_URL})` + : null + ), + ], + ] + default: + return [[label, buildRow(label, null)]] + } + } + + function buildRow(platformLabel, artifact) { + const status = artifact ? 'Passed' : 'Failed' + return `| ${platformLabel} | ${status} | ${artifact ?? 'Unavailable'} |` + } + + - name: Prepare release assets + if: ${{ inputs.publish }} + shell: bash + env: + CHANNEL: ${{ inputs.channel }} + MATRIX_ID: ${{ matrix.id }} + run: | + set -euo pipefail + shopt -s nullglob + + mkdir -p packages/agents-desktop/publish-assets + + copy_first() { + local destination="$1" + shift + + for file in "$@"; do + if [[ -f "$file" ]]; then + cp "$file" "packages/agents-desktop/publish-assets/${destination}" + return + fi + done + + echo "No asset found for ${destination}" >&2 + exit 1 + } + + if [[ "$CHANNEL" == "canary" ]]; then + case "$MATRIX_ID" in + macos) + copy_first "Electric-Agents-canary-mac-arm64.dmg" packages/agents-desktop/release/*mac-arm64.dmg packages/agents-desktop/release/*arm64.dmg + copy_first "Electric-Agents-canary-mac-arm64.zip" packages/agents-desktop/release/*mac-arm64.zip packages/agents-desktop/release/*arm64.zip + copy_first "Electric-Agents-canary-mac-x64.dmg" packages/agents-desktop/release/*mac-x64.dmg packages/agents-desktop/release/*x64.dmg + copy_first "Electric-Agents-canary-mac-x64.zip" packages/agents-desktop/release/*mac-x64.zip packages/agents-desktop/release/*x64.zip + ;; + windows-x64) + copy_first "Electric-Agents-canary-windows-x64.exe" packages/agents-desktop/release/*.exe + ;; + linux-x64) + copy_first "Electric-Agents-canary-linux-x64.AppImage" packages/agents-desktop/release/*.AppImage + copy_first "Electric-Agents-canary-linux-x64.deb" packages/agents-desktop/release/*.deb + ;; + esac + else + cp packages/agents-desktop/release/*.{dmg,zip,exe,AppImage,deb,blockmap,yml,yaml} \ + packages/agents-desktop/publish-assets/ 2>/dev/null || true + fi + + assets=(packages/agents-desktop/publish-assets/*) + if (( ${#assets[@]} == 0 )); then + echo "No publish assets were prepared" >&2 + exit 1 + fi + + - name: Upload assets to GitHub release + if: ${{ inputs.publish }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + run: gh release upload "$RELEASE_TAG" packages/agents-desktop/publish-assets/* --clobber + + update-pr-comment: + name: Update PR artifact comment + needs: build + if: ${{ always() && inputs.channel == 'pr' && github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + steps: + - name: Update PR artifact comment + uses: actions/github-script@v7 + continue-on-error: true + with: + script: | + const marker = '' + const platforms = [ + { + id: 'macos', + label: 'macOS Apple Silicon', + jobName: 'build / Build macOS', + artifacts: [ + ['DMG', 'electric-agents-desktop-pr-macos-arm64-dmg'], + ], + }, + { + id: 'macos', + label: 'macOS Intel', + jobName: 'build / Build macOS', + artifacts: [['DMG', 'electric-agents-desktop-pr-macos-x64-dmg']], + }, + { + id: 'windows-x64', + label: 'Windows x64', + jobName: 'build / Build Windows x64', + artifacts: [ + ['Installer', 'electric-agents-desktop-pr-windows-x64-exe'], + ], + }, + { + id: 'linux-x64', + label: 'Linux x64', + jobName: 'build / Build Linux x64', + artifacts: [ + ['AppImage', 'electric-agents-desktop-pr-linux-x64-appimage'], + ['deb', 'electric-agents-desktop-pr-linux-x64-deb'], + ], + }, + ] + + const { owner, repo } = context.repo + const issue_number = context.payload.pull_request.number + const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}` + const sha = context.payload.pull_request.head.sha + const shortSha = sha.slice(0, 7) + + const [jobs, artifacts] = await Promise.all([ + github.paginate(github.rest.actions.listJobsForWorkflowRun, { + owner, + repo, + run_id: context.runId, + per_page: 100, + }), + github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: context.runId, + per_page: 100, + }), + ]) + + const artifactByName = new Map( + artifacts.map((artifact) => [ + artifact.name, + `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}/artifacts/${artifact.id}`, + ]) + ) + + const rows = platforms + .map((platform) => { + const job = jobs.find((candidate) => candidate.name === platform.jobName) + const status = formatStatus(job) + const artifact = platform.artifacts + .map(([label, artifactName]) => { + const artifactUrl = artifactByName.get(artifactName) + return artifactUrl ? `[${label}](${artifactUrl})` : null + }) + .filter(Boolean) + .join(' / ') || 'Unavailable' + return `| ${platform.label} | ${status} | ${artifact} |` + }) + .join('\n') + + const body = [ + marker, + '## Electric Agents Desktop Builds', + '', + `Build artifacts for commit \`${shortSha}\`.`, + '', + '| Platform | Status | Artifact |', + '| --- | --- | --- |', + rows, + '', + `[Workflow run](${runUrl})`, + ].join('\n') + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }) + const existing = comments.find((comment) => comment.body?.includes(marker)) + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }) + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }) + } + + function formatStatus(job) { + if (!job) return 'Unknown' + if (job.status !== 'completed') return 'Building' + + switch (job.conclusion) { + case 'success': + return 'Passed' + case 'failure': + return 'Failed' + case 'cancelled': + return 'Cancelled' + case 'skipped': + return 'Skipped' + default: + return job.conclusion || 'Completed' + } + } diff --git a/.github/workflows/agents_desktop_canary.yml b/.github/workflows/agents_desktop_canary.yml new file mode 100644 index 0000000000..3bd84e61de --- /dev/null +++ b/.github/workflows/agents_desktop_canary.yml @@ -0,0 +1,71 @@ +name: Agents Desktop Canary + +on: + push: + branches: + - main + paths: + - 'packages/agents-desktop/**' + - 'packages/agents-server-ui/**' + - 'packages/agents/**' + - 'packages/agents-mcp/**' + - 'packages/agents-runtime/**' + - '.github/workflows/agents_desktop_*.yml' + - 'scripts/ci/desktop-affected.mjs' + - '.npmrc' + - '.tool-versions' + - 'package.json' + - 'patches/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'tsconfig.base.json' + - 'tsconfig.build.json' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + detect: + name: Detect desktop impact + runs-on: ubuntu-latest + outputs: + should_build: ${{ steps.detect.outputs.should_build }} + reason: ${{ steps.detect.outputs.reason }} + steps: + - name: Checkout + uses: actions/checkout@v5.0.0 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.tool-versions' + + - name: Detect desktop impact + id: detect + env: + BASE_SHA: ${{ github.event.before }} + run: node scripts/ci/desktop-affected.mjs + + build-and-publish: + needs: detect + if: ${{ needs.detect.outputs.should_build == 'true' }} + uses: ./.github/workflows/agents_desktop_build.yml + secrets: inherit + with: + channel: canary + version: canary-${{ github.run_number }}-${{ github.sha }} + git_ref: ${{ github.sha }} + publish: true + sign: false + release_tag: agents-desktop-canary + release_name: Electric Agents Desktop Canary diff --git a/.github/workflows/agents_desktop_pr.yml b/.github/workflows/agents_desktop_pr.yml new file mode 100644 index 0000000000..9df663ddd2 --- /dev/null +++ b/.github/workflows/agents_desktop_pr.yml @@ -0,0 +1,69 @@ +name: Agents Desktop PR + +on: + pull_request: + paths: + - 'packages/agents-desktop/**' + - 'packages/agents-server-ui/**' + - 'packages/agents/**' + - 'packages/agents-mcp/**' + - 'packages/agents-runtime/**' + - '.github/workflows/agents_desktop_*.yml' + - 'scripts/ci/desktop-affected.mjs' + - '.npmrc' + - '.tool-versions' + - 'package.json' + - 'patches/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'tsconfig.base.json' + - 'tsconfig.build.json' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.number || github.ref }} + cancel-in-progress: true + +permissions: + actions: read + contents: read + issues: write + pull-requests: write + +jobs: + detect: + name: Detect desktop impact + runs-on: ubuntu-latest + outputs: + should_build: ${{ steps.detect.outputs.should_build }} + reason: ${{ steps.detect.outputs.reason }} + steps: + - name: Checkout + uses: actions/checkout@v5.0.0 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.tool-versions' + + - name: Detect desktop impact + id: detect + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: node scripts/ci/desktop-affected.mjs + + build: + needs: detect + if: ${{ needs.detect.outputs.should_build == 'true' }} + uses: ./.github/workflows/agents_desktop_build.yml + with: + channel: pr + version: pr-${{ github.event.pull_request.number || github.run_number }}-${{ github.sha }} + git_ref: ${{ github.event.pull_request.head.sha || github.sha }} + publish: false + sign: false diff --git a/.github/workflows/changesets_release.yml b/.github/workflows/changesets_release.yml index 3a8269fefd..6912c75415 100644 --- a/.github/workflows/changesets_release.yml +++ b/.github/workflows/changesets_release.yml @@ -21,6 +21,8 @@ jobs: published: ${{ steps.changesets.outputs.published }} sync_service_release_tag: ${{ steps.sync_service_release_tag.outputs.tag }} agent_server_release_tag: ${{ steps.agent_server_release_tag.outputs.tag }} + desktop_release_version: ${{ steps.desktop_release.outputs.version }} + desktop_release_tag: ${{ steps.desktop_release.outputs.tag }} steps: - uses: actions/checkout@v4 with: @@ -63,6 +65,16 @@ jobs: PUBLISHED_PACKAGES='${{ steps.changesets.outputs.publishedPackages }}' TAGS=$(echo "$PUBLISHED_PACKAGES" | jq -r '.[] | select(.name == "@electric-ax/agents-server") | .name + "@" + .version') echo "tag=$TAGS" >> "$GITHUB_OUTPUT" + - name: Capture the new agents-desktop release as an output (if any) + id: desktop_release + if: steps.changesets.outputs.published == 'true' + run: | + PUBLISHED_PACKAGES='${{ steps.changesets.outputs.publishedPackages }}' + VERSION=$(echo "$PUBLISHED_PACKAGES" | jq -r '.[] | select(.name == "@electric-ax/agents-desktop") | .version' | head -n 1) + if [ -n "$VERSION" ]; then + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=@electric-ax/agents-desktop@$VERSION" >> "$GITHUB_OUTPUT" + fi - name: Add latest tag to published packages if: steps.changesets.outputs.published == 'true' run: node scripts/tag-latest.mjs @@ -115,6 +127,22 @@ jobs: "agents_server_version": "${{ steps.agents_server.outputs.version }}" } + publish-agents-desktop: + needs: changesets + if: ${{ needs.changesets.outputs.published == 'true' && needs.changesets.outputs.desktop_release_tag != '' }} + uses: ./.github/workflows/agents_desktop_build.yml + permissions: + contents: write + secrets: inherit + with: + channel: stable + version: ${{ needs.changesets.outputs.desktop_release_version }} + git_ref: ${{ needs.changesets.outputs.desktop_release_tag }} + publish: true + sign: false + release_tag: ${{ needs.changesets.outputs.desktop_release_tag }} + release_name: Electric Agents Desktop v${{ needs.changesets.outputs.desktop_release_version }} + update-cloud: name: Update Electric version used by Cloud runs-on: ubuntu-latest diff --git a/scripts/ci/desktop-affected.mjs b/scripts/ci/desktop-affected.mjs new file mode 100644 index 0000000000..151084ccc4 --- /dev/null +++ b/scripts/ci/desktop-affected.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { appendFileSync } from 'node:fs' +import { relative, sep } from 'node:path' + +const repoRoot = execFileSync(`git`, [`rev-parse`, `--show-toplevel`], { + encoding: `utf8`, +}).trim() +const baseRef = process.env.BASE_SHA || process.env.GITHUB_BASE_REF || `HEAD~1` +const desktopPackage = `@electric-ax/agents-desktop` + +const globalChangePatterns = [ + `.github/workflows/agents_desktop_*.yml`, + `.npmrc`, + `.tool-versions`, + `package.json`, + `patches/**`, + `pnpm-lock.yaml`, + `pnpm-workspace.yaml`, + `tsconfig.base.json`, + `tsconfig.build.json`, +] + +const changedFiles = getChangedFiles(baseRef) +const globalChange = shouldRunForGlobalChange(baseRef, changedFiles) +const desktopClosure = listWorkspaces([`${desktopPackage}...`]) +const changedClosure = globalChange ? [] : listWorkspaces([`...[${baseRef}]`]) +const desktopClosureNames = new Set( + desktopClosure.map((workspace) => workspace.name) +) +const affectedWorkspaces = globalChange + ? desktopClosure + : changedClosure.filter((workspace) => + desktopClosureNames.has(workspace.name) + ) +const shouldBuild = globalChange || affectedWorkspaces.length > 0 + +const outputs = { + should_build: String(shouldBuild), + affected_workspaces: JSON.stringify( + affectedWorkspaces.map((workspace) => workspace.relativePath).sort() + ), + reason: globalChange + ? `global desktop build input changed` + : shouldBuild + ? `desktop package or dependency changed` + : `no desktop package dependency changed`, +} + +writeOutputs(outputs) + +console.log( + JSON.stringify( + { + baseRef, + changedFiles, + desktopClosure: desktopClosure.map((workspace) => workspace.relativePath), + ...outputs, + }, + null, + 2 + ) +) + +function listWorkspaces(filters) { + const args = [`-r`, `list`, `--depth`, `-1`, `--json`] + for (const filter of filters) { + args.push(`--filter`, filter) + } + + try { + return parsePnpmList(run(`pnpm`, args)) + } catch (error) { + console.warn( + `Falling back to building desktop because pnpm filtering failed.` + ) + console.warn(error.message) + return [{ name: desktopPackage, relativePath: `packages/agents-desktop` }] + } +} + +function parsePnpmList(output) { + return JSON.parse(output).map((workspace) => ({ + ...workspace, + relativePath: toPosixPath(relative(repoRoot, workspace.path)), + })) +} + +function getChangedFiles(base) { + if (!base || /^0+$/.test(base)) { + return [] + } + + try { + run(`git`, [`rev-parse`, `--verify`, `${base}^{commit}`]) + return run(`git`, [`diff`, `--name-only`, base, `--`, `.`]) + .split(`\n`) + .map((file) => file.trim()) + .filter(Boolean) + } catch { + return [] + } +} + +function shouldRunForGlobalChange(base, files) { + if (!base || /^0+$/.test(base) || files.length === 0) { + return true + } + + return files.some((file) => + globalChangePatterns.some((pattern) => matchesPattern(file, pattern)) + ) +} + +function matchesPattern(file, pattern) { + if (pattern.endsWith(`/**`)) { + return file.startsWith(pattern.slice(0, -3)) + } + + if (pattern.includes(`*`)) { + const escaped = pattern + .split(`*`) + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`)) + .join(`.*`) + return new RegExp(`^${escaped}$`).test(file) + } + + return file === pattern +} + +function writeOutputs(outputs) { + const outputFile = process.env.GITHUB_OUTPUT + if (!outputFile) { + return + } + + appendFileSync( + outputFile, + Object.entries(outputs) + .map(([key, value]) => `${key}=${value}`) + .join(`\n`) + `\n` + ) +} + +function run(command, args) { + return execFileSync(command, args, { + cwd: repoRoot, + encoding: `utf8`, + stdio: [`ignore`, `pipe`, `pipe`], + }) +} + +function toPosixPath(value) { + return value.split(sep).join(`/`) +} From 23f28cb613a247f6f7c46b41c135546201265b5c Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 12 May 2026 10:07:10 +0300 Subject: [PATCH 2/8] chore: add desktop CI changeset Co-authored-by: Cursor --- .changeset/agents-desktop-ci-publishing.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/agents-desktop-ci-publishing.md diff --git a/.changeset/agents-desktop-ci-publishing.md b/.changeset/agents-desktop-ci-publishing.md new file mode 100644 index 0000000000..d4158aa0f8 --- /dev/null +++ b/.changeset/agents-desktop-ci-publishing.md @@ -0,0 +1,5 @@ +--- +'@electric-ax/agents-desktop': patch +--- + +Add CI workflows for desktop app build artifacts and canary publishing. From a966b45cd10ff6a1e05eedac4fffdfc12a7f3f58 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 19 May 2026 12:18:53 +0100 Subject: [PATCH 3/8] fix(ci): build desktop workspace dependencies Co-authored-by: Cursor --- .github/workflows/agents_desktop_build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agents_desktop_build.yml b/.github/workflows/agents_desktop_build.yml index 111ef40e99..81af3ed4de 100644 --- a/.github/workflows/agents_desktop_build.yml +++ b/.github/workflows/agents_desktop_build.yml @@ -198,7 +198,7 @@ jobs: run: pnpm --filter @electric-ax/agents-desktop... install --frozen-lockfile - name: Build desktop type dependencies - run: pnpm --filter @electric-ax/agents... build + run: pnpm -r --filter @electric-ax/agents-desktop^... build - name: Typecheck desktop app run: pnpm --filter @electric-ax/agents-desktop typecheck From 96c207c568bbdd05790a3d49e11ec6f9df3ee89b Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 19 May 2026 12:27:22 +0100 Subject: [PATCH 4/8] fix(ci): exclude rollup natives from desktop app Co-authored-by: Cursor --- packages/agents-desktop/electron-builder.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/agents-desktop/electron-builder.yml b/packages/agents-desktop/electron-builder.yml index 18d8d0bbed..c0e7b33b35 100644 --- a/packages/agents-desktop/electron-builder.yml +++ b/packages/agents-desktop/electron-builder.yml @@ -10,6 +10,7 @@ files: - dist/**/* - assets/**/* - package.json + - '!**/node_modules/@rollup/**' extraResources: - from: ../agents-server-ui/dist-desktop From 46393b3256e13eeb621ebca0b4375cf41494ae04 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 19 May 2026 14:21:29 +0100 Subject: [PATCH 5/8] feat(website): add hidden /app download page Adds a hidden landing page at /app for downloading the Electric Agents desktop app, with platform-detected primary CTA, per-platform cards (macOS Apple Silicon / Intel, Windows, Linux AppImage + DEB), coming-soon iOS/Android placeholders, and a compact canary builds list. Not linked from the main nav yet; a real screenshot/mockup will land in a follow-up. Mac arch detection uses the WebGL renderer string rather than the UA, since every browser still reports `Intel Mac OS X` on Apple Silicon for legacy compat; macOS defaults to Apple Silicon and only flips to Intel on a positive renderer signal. Co-authored-by: Cursor --- website/app.md | 18 + .../app-download/AppDownloadPage.vue | 848 ++++++++++++++++++ 2 files changed, 866 insertions(+) create mode 100644 website/app.md create mode 100644 website/src/components/app-download/AppDownloadPage.vue diff --git a/website/app.md b/website/app.md new file mode 100644 index 0000000000..bf55200ee7 --- /dev/null +++ b/website/app.md @@ -0,0 +1,18 @@ +--- +layout: page +title: Electric Agents App +titleTemplate: false +description: Download Electric Agents Desktop for macOS, Windows and Linux. +sidebar: false +pageClass: app-page +mdExport: + mode: parse-html +--- + + + + + + diff --git a/website/src/components/app-download/AppDownloadPage.vue b/website/src/components/app-download/AppDownloadPage.vue new file mode 100644 index 0000000000..c77ea56e72 --- /dev/null +++ b/website/src/components/app-download/AppDownloadPage.vue @@ -0,0 +1,848 @@ + + + + + From 7be340d978f016b0b40bb0ed359312235d5c871e Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 19 May 2026 14:47:45 +0100 Subject: [PATCH 6/8] chore(agents-desktop): use stable release asset names Use non-versioned desktop artifact names so the static /app page can link to GitHub's /releases/latest/download URLs without runtime metadata or a website redeploy per release. Co-authored-by: Cursor --- packages/agents-desktop/electron-builder.yml | 2 +- .../app-download/AppDownloadPage.vue | 26 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/agents-desktop/electron-builder.yml b/packages/agents-desktop/electron-builder.yml index c0e7b33b35..539bc3393d 100644 --- a/packages/agents-desktop/electron-builder.yml +++ b/packages/agents-desktop/electron-builder.yml @@ -1,6 +1,6 @@ appId: com.electric-sql.agents productName: Electric Agents -artifactName: Electric-Agents-${version}-${os}-${arch}.${ext} +artifactName: Electric-Agents-${os}-${arch}.${ext} directories: buildResources: build diff --git a/website/src/components/app-download/AppDownloadPage.vue b/website/src/components/app-download/AppDownloadPage.vue index c77ea56e72..c86b4d57c4 100644 --- a/website/src/components/app-download/AppDownloadPage.vue +++ b/website/src/components/app-download/AppDownloadPage.vue @@ -25,6 +25,7 @@ import Section from '../agents-home/Section.vue' import BottomCtaStrap from '../BottomCtaStrap.vue' const githubReleaseBase = `https://github.com/electric-sql/electric/releases` +const appReleaseNotesUrl = `${githubReleaseBase}?q=%22%40electric-ax%2Fagents-desktop%22&expanded=true` type DesktopPlatformId = | 'macos-arm64' @@ -45,8 +46,6 @@ type DesktopPlatform = { downloads: DownloadOption[] } -const stableVersion = `0.1.4` -const stableTag = `@electric-ax/agents-desktop@${stableVersion}` const canaryTag = `agents-desktop-canary` const desktopPlatforms: DesktopPlatform[] = [ @@ -58,7 +57,7 @@ const desktopPlatforms: DesktopPlatform[] = [ downloads: [ { label: `Download for Mac (Apple Silicon)`, - assetName: `Electric-Agents-${stableVersion}-mac-arm64.dmg`, + assetName: `Electric-Agents-mac-arm64.dmg`, }, ], }, @@ -70,7 +69,7 @@ const desktopPlatforms: DesktopPlatform[] = [ downloads: [ { label: `Download for Mac (Intel)`, - assetName: `Electric-Agents-${stableVersion}-mac-x64.dmg`, + assetName: `Electric-Agents-mac-x64.dmg`, }, ], }, @@ -82,7 +81,7 @@ const desktopPlatforms: DesktopPlatform[] = [ downloads: [ { label: `Download for Windows`, - assetName: `Electric-Agents-${stableVersion}-win-x64.exe`, + assetName: `Electric-Agents-win-x64.exe`, }, ], }, @@ -94,11 +93,11 @@ const desktopPlatforms: DesktopPlatform[] = [ downloads: [ { label: `Download AppImage`, - assetName: `Electric-Agents-${stableVersion}-linux-x64.AppImage`, + assetName: `Electric-Agents-linux-x64.AppImage`, }, { label: `Download DEB`, - assetName: `Electric-Agents-${stableVersion}-linux-x64.deb`, + assetName: `Electric-Agents-linux-x64.deb`, }, ], }, @@ -177,6 +176,10 @@ function releaseUrl(tag: string, assetName: string): string { return `${githubReleaseBase}/download/${encodeURIComponent(tag)}/${assetName}` } +function latestReleaseUrl(assetName: string): string { + return `${githubReleaseBase}/latest/download/${assetName}` +} + /* Detect the visitor's OS on mount; default to macOS Apple Silicon so SSR / first paint always renders a sensible primary. */ const detectedId = ref('macos-arm64') @@ -247,9 +250,7 @@ const primaryPlatform = computed( size="medium" theme="brand" :text="primaryPlatform.downloads[0].label" - :href=" - releaseUrl(stableTag, primaryPlatform.downloads[0].assetName) - " + :href="latestReleaseUrl(primaryPlatform.downloads[0].assetName)" />

- v{{ stableVersion }} · Release notes From 8f1fdb80f788152ef918dfcee4c1b68004b80d90 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 19 May 2026 14:54:03 +0100 Subject: [PATCH 7/8] fix(agents-desktop): publish stable latest release assets Maintain a fixed agents-desktop-latest release for stable desktop download assets so the static /app page can use stable GitHub release URLs without relying on the repo-wide latest release redirect. Co-authored-by: Cursor --- .github/workflows/agents_desktop_build.yml | 39 ++++++++++++++++++- .../app-download/AppDownloadPage.vue | 3 +- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.github/workflows/agents_desktop_build.yml b/.github/workflows/agents_desktop_build.yml index 81af3ed4de..b7d7d80cf3 100644 --- a/.github/workflows/agents_desktop_build.yml +++ b/.github/workflows/agents_desktop_build.yml @@ -106,6 +106,7 @@ jobs: RELEASE_NAME: ${{ inputs.release_name }} RELEASE_TAG: ${{ inputs.release_tag }} SIGN: ${{ inputs.sign }} + STABLE_LATEST_TAG: agents-desktop-latest VERSION: ${{ inputs.version }} run: | set -euo pipefail @@ -155,6 +156,33 @@ jobs: --title "$RELEASE_NAME" \ --notes "Desktop artifacts for Electric Agents ${VERSION}." fi + + if [[ "$CHANNEL" == "stable" ]]; then + git tag -f "$STABLE_LATEST_TAG" "$COMMIT_SHA" + git push origin "refs/tags/${STABLE_LATEST_TAG}" --force + + { + echo "Latest stable Electric Agents Desktop build." + echo + echo "- Source release: ${RELEASE_TAG}" + echo "- Source commit: ${COMMIT_SHA}" + echo "- Workflow run: ${RUN_URL}" + echo "- Desktop package version: ${VERSION}" + echo "- Signed/notarized: ${SIGN}" + } > latest-release-notes.md + + if gh release view "$STABLE_LATEST_TAG" >/dev/null 2>&1; then + gh release edit "$STABLE_LATEST_TAG" \ + --title "Electric Agents Desktop Latest" \ + --notes-file latest-release-notes.md + else + gh release create "$STABLE_LATEST_TAG" \ + --target "$COMMIT_SHA" \ + --title "Electric Agents Desktop Latest" \ + --notes-file latest-release-notes.md \ + --latest=false + fi + fi fi build: @@ -542,9 +570,18 @@ jobs: if: ${{ inputs.publish }} shell: bash env: + CHANNEL: ${{ inputs.channel }} GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.release_tag }} - run: gh release upload "$RELEASE_TAG" packages/agents-desktop/publish-assets/* --clobber + STABLE_LATEST_TAG: agents-desktop-latest + run: | + set -euo pipefail + + gh release upload "$RELEASE_TAG" packages/agents-desktop/publish-assets/* --clobber + + if [[ "$CHANNEL" == "stable" ]]; then + gh release upload "$STABLE_LATEST_TAG" packages/agents-desktop/publish-assets/* --clobber + fi update-pr-comment: name: Update PR artifact comment diff --git a/website/src/components/app-download/AppDownloadPage.vue b/website/src/components/app-download/AppDownloadPage.vue index c86b4d57c4..d3c27e7b54 100644 --- a/website/src/components/app-download/AppDownloadPage.vue +++ b/website/src/components/app-download/AppDownloadPage.vue @@ -46,6 +46,7 @@ type DesktopPlatform = { downloads: DownloadOption[] } +const stableTag = `agents-desktop-latest` const canaryTag = `agents-desktop-canary` const desktopPlatforms: DesktopPlatform[] = [ @@ -177,7 +178,7 @@ function releaseUrl(tag: string, assetName: string): string { } function latestReleaseUrl(assetName: string): string { - return `${githubReleaseBase}/latest/download/${assetName}` + return releaseUrl(stableTag, assetName) } /* Detect the visitor's OS on mount; default to macOS Apple Silicon From 2350b69ba8e3b4f86e83be1f39b21b7a7bde456d Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 19 May 2026 15:02:02 +0100 Subject: [PATCH 8/8] copy(website): clarify app preview warning Moves signing caveats out of the desktop download intro and into the site warning style with practical macOS and Windows first-run guidance. Co-authored-by: Cursor --- .../app-download/AppDownloadPage.vue | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/website/src/components/app-download/AppDownloadPage.vue b/website/src/components/app-download/AppDownloadPage.vue index d3c27e7b54..c0d283540e 100644 --- a/website/src/components/app-download/AppDownloadPage.vue +++ b/website/src/components/app-download/AppDownloadPage.vue @@ -283,9 +283,8 @@ const primaryPlatform = computed(

@@ -318,6 +317,25 @@ const primaryPlatform = computed(
+ + @@ -641,6 +659,22 @@ const primaryPlatform = computed( flex-wrap: wrap; } +.ad-signing-note { + margin-top: 24px; + padding-bottom: 18px; +} + +.ad-signing-note p { + max-width: 700px; +} + +.ad-signing-note ul { + margin: 8px 0 0; + padding-left: 20px; + display: grid; + gap: 6px; +} + /* ── §3 mobile ──────────────────────────────────────────────── */ .ad-mobile-grid {