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. diff --git a/.github/workflows/agents_desktop_build.yml b/.github/workflows/agents_desktop_build.yml new file mode 100644 index 0000000000..b7d7d80cf3 --- /dev/null +++ b/.github/workflows/agents_desktop_build.yml @@ -0,0 +1,728 @@ +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 }} + STABLE_LATEST_TAG: agents-desktop-latest + 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 + + 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: + 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 -r --filter @electric-ax/agents-desktop^... 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: + CHANNEL: ${{ inputs.channel }} + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + 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 + 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/packages/agents-desktop/electron-builder.yml b/packages/agents-desktop/electron-builder.yml index 18d8d0bbed..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 @@ -10,6 +10,7 @@ files: - dist/**/* - assets/**/* - package.json + - '!**/node_modules/@rollup/**' extraResources: - from: ../agents-server-ui/dist-desktop 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(`/`) +} 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..c0d283540e --- /dev/null +++ b/website/src/components/app-download/AppDownloadPage.vue @@ -0,0 +1,883 @@ + + + + +