Skip to content

Desktop Nightly

Desktop Nightly #166

name: Desktop Nightly
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * *"
permissions:
contents: write
actions: write
pull-requests: read
concurrency:
group: desktop-nightly
cancel-in-progress: false
jobs:
build:
uses: ./.github/workflows/desktop-build.yml
with:
environment: nexu-test
sentry_env: "test"
cloud_url: "https://nexu.powerformer.net"
link_url: "https://nexu-link.powerformer.net"
update_feed_url: "https://desktop-releases.nexu.io/nightly"
build_source: "nightly"
release_tag: desktop-nightly
release_name: "Nexu Desktop Nightly"
channel: "nightly"
secrets: inherit
package-windows:
runs-on: windows-latest
env:
CHANNEL: nightly
UPDATE_FEED_ROOT: https://desktop-releases.nexu.io/nightly
LANGFUSE_PUBLIC_KEY: ${{ secrets.LANGFUSE_PUBLIC_KEY }}
LANGFUSE_SECRET_KEY: ${{ secrets.LANGFUSE_SECRET_KEY }}
LANGFUSE_BASE_URL: ${{ secrets.LANGFUSE_BASE_URL }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.26.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install packaging tools
shell: pwsh
run: |
choco install nsis awscli -y --no-progress
- name: Install dependencies
shell: pwsh
run: pnpm install --frozen-lockfile
- name: Resolve build metadata
id: meta
shell: pwsh
run: |
$baseVersion = (node apps/desktop/scripts/desktop-package-version.mjs get).Trim()
$buildDate = Get-Date -Format 'yyyyMMdd'
$shortSha = "${env:GITHUB_SHA}".Substring(0, 7)
$desktopVersion = "$baseVersion-$env:CHANNEL.$buildDate"
node apps/desktop/scripts/desktop-package-version.mjs set "$desktopVersion" | Out-Null
"desktop_version=$desktopVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
"build_date=$buildDate" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
"short_sha=$shortSha" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
- name: Build Windows installer
shell: pwsh
env:
NEXU_CLOUD_URL: https://nexu.powerformer.net
NEXU_LINK_URL: https://nexu-link.powerformer.net
NEXU_DESKTOP_BUILD_SOURCE: nightly
NEXU_DESKTOP_BUILD_BRANCH: ${{ github.ref_name }}
NEXU_DESKTOP_BUILD_COMMIT: ${{ github.sha }}
NEXU_UPDATE_FEED_URL: https://desktop-releases.nexu.io/nightly/win32/x64/latest-win.json
VITE_POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
run: |
$env:NEXU_DESKTOP_BUILD_TIME = (Get-Date).ToUniversalTime().ToString('o')
pnpm --filter @nexu/desktop dist:win
- name: Prepare Windows release artifacts
id: artifacts
shell: pwsh
env:
VERSION: ${{ steps.meta.outputs.desktop_version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
CHANNEL: nightly
BASE_URL: https://desktop-releases.nexu.io/nightly/win32/x64
run: |
$artifactVersion = "$env:VERSION.$env:SHORT_SHA"
$releaseDir = "apps/desktop/release"
$channelArtifacts = "apps/desktop/channel-artifacts-win"
New-Item -ItemType Directory -Force -Path $channelArtifacts | Out-Null
Get-ChildItem -Path $channelArtifacts -File -ErrorAction SilentlyContinue | Remove-Item -Force
$sourceInstaller = Join-Path $releaseDir "nexu-setup-$env:VERSION-x64.exe"
if (-not (Test-Path $sourceInstaller)) {
throw "Missing Windows installer: $sourceInstaller"
}
$versionedInstaller = "nexu-setup-$artifactVersion-win-x64.exe"
$latestInstaller = "nexu-latest-$env:CHANNEL-win-x64.exe"
$checksumFile = "desktop-win-x64-sha256.txt"
$manifestFile = "latest-win.json"
Copy-Item $sourceInstaller (Join-Path $channelArtifacts $versionedInstaller)
Copy-Item $sourceInstaller (Join-Path $channelArtifacts $latestInstaller)
$hash = (Get-FileHash -Algorithm SHA256 (Join-Path $channelArtifacts $versionedInstaller)).Hash.ToLowerInvariant()
"$hash $versionedInstaller" | Out-File -FilePath (Join-Path $channelArtifacts $checksumFile) -Encoding ascii
$env:INSTALLER_FILE = (Join-Path $channelArtifacts $latestInstaller)
$env:MANIFEST_OUTPUT = (Join-Path $channelArtifacts $manifestFile)
node apps/desktop/scripts/generate-win-update-manifest.mjs
"versioned_installer=$versionedInstaller" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
"latest_installer=$latestInstaller" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
"checksum_file=$checksumFile" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
"manifest_file=$manifestFile" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
- name: Upload Windows workflow artifact
uses: actions/upload-artifact@v4
with:
name: desktop-nightly-win-x64-${{ steps.meta.outputs.build_date }}-${{ steps.meta.outputs.short_sha }}
path: |
apps/desktop/channel-artifacts-win/${{ steps.artifacts.outputs.versioned_installer }}
apps/desktop/channel-artifacts-win/${{ steps.artifacts.outputs.latest_installer }}
apps/desktop/channel-artifacts-win/${{ steps.artifacts.outputs.checksum_file }}
apps/desktop/channel-artifacts-win/${{ steps.artifacts.outputs.manifest_file }}
retention-days: 7
if-no-files-found: error
- name: Publish Windows prerelease assets
uses: softprops/action-gh-release@v2
with:
tag_name: desktop-nightly
target_commitish: ${{ github.sha }}
name: Nexu Desktop Nightly
prerelease: true
draft: false
overwrite_files: true
fail_on_unmatched_files: true
files: |
apps/desktop/channel-artifacts-win/${{ steps.artifacts.outputs.versioned_installer }}
apps/desktop/channel-artifacts-win/${{ steps.artifacts.outputs.latest_installer }}
apps/desktop/channel-artifacts-win/${{ steps.artifacts.outputs.checksum_file }}
apps/desktop/channel-artifacts-win/${{ steps.artifacts.outputs.manifest_file }}
- name: Upload Windows artifacts to Cloudflare R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_ENDPOINT_URL: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
AWS_REGION: auto
shell: pwsh
run: |
$prefix = "nightly/win32/x64"
aws s3 cp "apps/desktop/channel-artifacts-win/${{ steps.artifacts.outputs.versioned_installer }}" "s3://nexu-desktop-releases/$prefix/${{ steps.artifacts.outputs.versioned_installer }}" --no-progress
aws s3 cp "apps/desktop/channel-artifacts-win/${{ steps.artifacts.outputs.latest_installer }}" "s3://nexu-desktop-releases/$prefix/${{ steps.artifacts.outputs.latest_installer }}" --no-progress
aws s3 cp "apps/desktop/channel-artifacts-win/${{ steps.artifacts.outputs.checksum_file }}" "s3://nexu-desktop-releases/$prefix/${{ steps.artifacts.outputs.checksum_file }}" --no-progress
aws s3 cp "apps/desktop/channel-artifacts-win/${{ steps.artifacts.outputs.manifest_file }}" "s3://nexu-desktop-releases/$prefix/${{ steps.artifacts.outputs.manifest_file }}" --no-progress
- name: Purge Windows latest CDN artifacts
env:
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_PURGE_API_TOKEN: ${{ secrets.CLOUDFLARE_PURGE_API_TOKEN }}
shell: pwsh
run: |
if ([string]::IsNullOrWhiteSpace($env:CLOUDFLARE_ZONE_ID) -or [string]::IsNullOrWhiteSpace($env:CLOUDFLARE_PURGE_API_TOKEN)) {
Write-Host "Skipping Cloudflare purge because required secrets are missing"
exit 0
}
$baseUrl = "https://desktop-releases.nexu.io/nightly/win32/x64"
$files = @(
"$baseUrl/${{ steps.artifacts.outputs.latest_installer }}",
"$baseUrl/${{ steps.artifacts.outputs.manifest_file }}"
) | ConvertTo-Json
$payload = @{ files = ($files | ConvertFrom-Json) } | ConvertTo-Json -Depth 5
Invoke-RestMethod -Method Post -Uri "https://api.cloudflare.com/client/v4/zones/$env:CLOUDFLARE_ZONE_ID/purge_cache" -Headers @{ Authorization = "Bearer $env:CLOUDFLARE_PURGE_API_TOKEN" } -ContentType "application/json" -Body $payload | Out-Null
- name: Publish Windows download links
shell: pwsh
run: |
$baseUrl = "https://desktop-releases.nexu.io/nightly/win32/x64"
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "## Windows Nightly Downloads"
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "- Installer: $baseUrl/${{ steps.artifacts.outputs.latest_installer }}"
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "- Manifest: $baseUrl/${{ steps.artifacts.outputs.manifest_file }}"
trigger-e2e:
needs: [build, package-windows]
runs-on: ubuntu-latest
steps:
- name: Trigger Desktop E2E (async)
uses: actions/github-script@v7
with:
script: |
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'desktop-e2e.yml',
ref: context.ref || 'main',
inputs: { mode: 'model', channel: 'nightly' },
});
notify:
needs: [build, package-windows]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Resolve build metadata
id: meta
shell: bash
run: |
set -euo pipefail
base_version=$(node -e 'process.stdout.write(require("./apps/desktop/package.json").version)')
build_date=$(date +"%Y%m%d")
short_sha="${GITHUB_SHA::7}"
version="${base_version}-nightly.${build_date}"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
echo "build_date=$build_date" >> "$GITHUB_OUTPUT"
- name: Generate changelog since last release
id: changelog
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
shell: bash
run: |
set -euo pipefail
repo_url="https://github.com/${REPO}"
# Find the latest stable release tag (vX.Y.Z, not desktop-nightly)
last_release=$(git tag -l 'v*' --sort=-creatordate | head -1)
if [ -z "$last_release" ]; then
echo "No release tag found, using first commit"
last_release=$(git rev-list --max-parents=0 HEAD)
fi
echo "Comparing: ${last_release}..HEAD"
echo "last_release=$last_release" >> "$GITHUB_OUTPUT"
# Extract merged PR numbers from both formats:
# Squash merge: "feat: add foo (#123)" → matches (#123)
# Merge commit: "Merge pull request #123 from ..." → matches #123
pr_numbers=$(git log "${last_release}..HEAD" --oneline \
| grep -oE '(\(#[0-9]+\)|Merge pull request #[0-9]+)' \
| grep -oE '[0-9]+' | sort -un || true)
# Output files: feishu (with feishu markdown links), github (GFM), plain (for LLM)
feishu_lines=""
github_lines=""
plain_lines=""
pr_count=0
for pr in $pr_numbers; do
pr_data=$(gh pr view "$pr" --json title,labels,closingIssuesReferences \
--jq '{
title: .title,
labels: [.labels[].name] | join(","),
issues: [.closingIssuesReferences[].number]
}' 2>/dev/null || echo "")
[ -z "$pr_data" ] && continue
title=$(echo "$pr_data" | jq -r '.title')
labels=$(echo "$pr_data" | jq -r '.labels')
# Extract area from conventional commit prefix or labels
area=""
if echo "$title" | grep -qE '^\w+\(([^)]+)\)'; then
area=$(echo "$title" | sed -E 's/^\w+\(([^)]+)\).*/\1/')
fi
display_area="$area"
for kw_label in desktop web controller skills channels models ci; do
if echo "$labels" | grep -qi "$kw_label"; then
display_area="$kw_label"
break
fi
done
tag=""; [ -n "$display_area" ] && tag="\`${display_area}\` "
# Clean title: remove redundant "scope(area): " prefix for display
clean_title=$(echo "$title" | sed -E 's/^\w+(\([^)]+\))?: ?//')
# Resolve closing issue titles (fetch individually to avoid null)
issue_nums=$(echo "$pr_data" | jq -r '.issues[]' 2>/dev/null || true)
issue_parts_feishu=""
issue_parts_github=""
issue_parts_plain=""
for inum in $issue_nums; do
ititle=$(gh issue view "$inum" --json title --jq '.title' 2>/dev/null || echo "")
[ -z "$ititle" ] && continue
issue_parts_feishu="${issue_parts_feishu} 📌 [#${inum}](${repo_url}/issues/${inum}) ${ititle}\n"
issue_parts_github="${issue_parts_github} - 📌 #${inum} ${ititle}\n"
issue_parts_plain="${issue_parts_plain} 关联: #${inum} ${ititle}\n"
done
# Build lines
feishu_lines="${feishu_lines}${tag}${clean_title} ([PR #${pr}](${repo_url}/pull/${pr}))\n${issue_parts_feishu}"
github_lines="${github_lines}- ${tag}${clean_title} (#${pr})\n${issue_parts_github}"
plain_lines="${plain_lines}${display_area:+[${display_area}] }${clean_title} #${pr}\n${issue_parts_plain}"
pr_count=$((pr_count + 1))
done
if [ "$pr_count" -eq 0 ]; then
feishu_lines="No merged PRs since ${last_release}"
github_lines="No merged PRs since ${last_release}"
plain_lines="No merged PRs since ${last_release}"
fi
echo "pr_count=$pr_count" >> "$GITHUB_OUTPUT"
printf '%b' "$feishu_lines" > /tmp/changelog_feishu.txt
printf '%b' "$github_lines" > /tmp/changelog_github.txt
printf '%b' "$plain_lines" > /tmp/changelog_plain.txt
echo "Generated changelog with $pr_count PRs"
- name: Write GitHub Step Summary
env:
VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
PR_COUNT: ${{ steps.changelog.outputs.pr_count }}
LAST_RELEASE: ${{ steps.changelog.outputs.last_release }}
shell: bash
run: |
{
echo "## Nightly Changelog"
echo ""
echo "| | |"
echo "|---|---|"
echo "| **Version** | \`${VERSION}\` (\`${SHORT_SHA}\`) |"
echo "| **Baseline** | \`${LAST_RELEASE}\` |"
echo "| **PRs merged** | ${PR_COUNT} |"
echo ""
echo "### Changes since ${LAST_RELEASE}"
echo ""
cat /tmp/changelog_github.txt
} >> "$GITHUB_STEP_SUMMARY"
- name: Generate LLM summary
id: llm
env:
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
shell: bash
run: |
set -euo pipefail
changelog=$(cat /tmp/changelog_plain.txt)
pr_count="${{ steps.changelog.outputs.pr_count }}"
if [ "$pr_count" -eq 0 ] || [ -z "$OPENAI_BASE_URL" ] || [ -z "$OPENAI_API_KEY" ]; then
echo "summary=无新增变更" >> "$GITHUB_OUTPUT"
exit 0
fi
# Ask LLM for a concise Chinese summary (same endpoint as nexu-pal)
prompt="你是一个桌面应用的发布助手。以下是自上次发布以来合并的 PR 列表(带关联 issue):\n\n${changelog}\n\n请用中文生成一段简洁的变更摘要(3-5 句话),重点说明用户可感知的改进和修复。不要列举 PR 编号,用自然语言概括。如果有 bug 修复请优先提及。"
response=$(curl -sf --max-time 30 "${OPENAI_BASE_URL}/chat/completions" \
-H "Authorization: Bearer ${OPENAI_API_KEY}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg prompt "$prompt" '{
model: "google/gemini-2.5-flash",
messages: [{role: "user", content: $prompt}],
max_tokens: 300,
temperature: 0.3
}')" 2>/dev/null || echo "")
if [ -n "$response" ]; then
summary=$(echo "$response" | jq -r '.choices[0].message.content // empty' 2>/dev/null || echo "")
fi
if [ -z "${summary:-}" ]; then
summary="本次 nightly 包含 ${pr_count} 个 PR 变更,详见下方列表。"
fi
# Escape for GitHub output
{
echo "summary<<EOFLLM"
echo "$summary"
echo "EOFLLM"
} >> "$GITHUB_OUTPUT"
- name: Send Feishu notification
env:
FEISHU_WEBHOOK: ${{ secrets.NIGHTLY_FEISHU_WEBHOOK }}
VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
BUILD_DATE: ${{ steps.meta.outputs.build_date }}
PR_COUNT: ${{ steps.changelog.outputs.pr_count }}
LAST_RELEASE: ${{ steps.changelog.outputs.last_release }}
LLM_SUMMARY: ${{ steps.llm.outputs.summary }}
shell: bash
run: |
set -euo pipefail
if [ -z "$FEISHU_WEBHOOK" ]; then
echo "No Feishu webhook configured, skipping notification"
exit 0
fi
changelog=$(cat /tmp/changelog_feishu.txt | head -40)
run_url="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
release_url="https://github.com/${{ github.repository }}/releases/tag/desktop-nightly"
e2e_url="https://github.com/${{ github.repository }}/actions/workflows/desktop-e2e.yml"
dmg_arm64="https://desktop-releases.nexu.io/nightly/arm64/nexu-latest-nightly-mac-arm64.dmg"
dmg_x64="https://desktop-releases.nexu.io/nightly/x64/nexu-latest-nightly-mac-x64.dmg"
# Build Feishu interactive card
card=$(jq -n \
--arg version "$VERSION" \
--arg sha "$SHORT_SHA" \
--arg date "$BUILD_DATE" \
--arg pr_count "$PR_COUNT" \
--arg last_release "$LAST_RELEASE" \
--arg summary "$LLM_SUMMARY" \
--arg changelog "$changelog" \
--arg run_url "$run_url" \
--arg release_url "$release_url" \
--arg e2e_url "$e2e_url" \
--arg dmg_arm64 "$dmg_arm64" \
--arg dmg_x64 "$dmg_x64" \
'{
msg_type: "interactive",
card: {
header: {
template: "turquoise",
title: {
tag: "plain_text",
content: ("🚀 Nexu Desktop Nightly " + $version)
}
},
elements: [
{
tag: "markdown",
content: ("**变更摘要**\n" + $summary)
},
{
tag: "markdown",
content: ("**版本信息**\n- 版本: `" + $version + "` (`" + $sha + "`)\n- 基线: `" + $last_release + "`\n- 合并 PR: **" + $pr_count + "** 个")
},
{
tag: "markdown",
content: ("**变更列表**\n" + $changelog)
},
{
tag: "hr"
},
{
tag: "action",
actions: [
{
tag: "button",
text: { tag: "plain_text", content: "⬇️ DMG (Apple Silicon)" },
type: "primary",
url: $dmg_arm64
},
{
tag: "button",
text: { tag: "plain_text", content: "⬇️ DMG (Intel)" },
type: "default",
url: $dmg_x64
}
]
},
{
tag: "action",
actions: [
{
tag: "button",
text: { tag: "plain_text", content: "📦 CI 构建" },
type: "default",
url: $run_url
},
{
tag: "button",
text: { tag: "plain_text", content: "🧪 E2E 测试" },
type: "default",
url: $e2e_url
},
{
tag: "button",
text: { tag: "plain_text", content: "📋 Release" },
type: "default",
url: $release_url
}
]
}
]
}
}')
curl -sf -X POST "$FEISHU_WEBHOOK" \
-H "Content-Type: application/json" \
-d "$card"
echo "Feishu notification sent"