diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index af20b0722..f12e508fe 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -13,6 +13,12 @@ jobs: name: Create GitHub Release runs-on: ubuntu-latest steps: + - uses: actions/create-github-app-token@v3 + id: app-token + with: + app-id: ${{ secrets.DEVSY_GITHUB_APP_ID }} + private-key: ${{ secrets.DEVSY_GITHUB_APP_PRIVATE_KEY }} + - uses: actions/checkout@v6 with: fetch-depth: 0 @@ -29,7 +35,7 @@ jobs: - name: Create release env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | gh release create "$GITHUB_REF_NAME" \ --generate-notes \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9332ff288..408d7e82a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,7 @@ jobs: path: dist/devsy-${{ steps.os.outputs.runner_os }}_*/devsy-${{ steps.os.outputs.runner_os }}-* build-desktop: - name: Build Desktop App on ${{ matrix.os }} + name: Build Desktop App (${{ matrix.name }}) needs: build-cli permissions: contents: write @@ -58,15 +58,23 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-22.04 + - name: linux-x64 + os: ubuntu-22.04 go-os: linux go-arch: amd64 builder-args: --linux - - os: macos-latest + - name: macos-arm64 + os: macos-latest go-os: darwin - go-arch: universal - builder-args: --mac - - os: windows-2022 + go-arch: arm64 + builder-args: --mac --arm64 + - name: macos-x64 + os: macos-latest + go-os: darwin + go-arch: amd64 + builder-args: --mac --x64 + - name: windows-x64 + os: windows-2022 go-os: windows go-arch: amd64 builder-args: --win @@ -114,15 +122,8 @@ jobs: run: | TEMP_DIR="${{ runner.temp }}" mkdir -p desktop/resources/bin - - if [ "${{ matrix.go-arch }}" = "universal" ]; then - # macOS: use both arch binaries for universal build - cp "$TEMP_DIR/devsy-bin/devsy-darwin-amd64" desktop/resources/bin/devsy - chmod +x desktop/resources/bin/devsy - else - cp "$TEMP_DIR/devsy-bin/devsy-${{ matrix.go-os }}-${{ matrix.go-arch }}" desktop/resources/bin/devsy - chmod +x desktop/resources/bin/devsy - fi + cp "$TEMP_DIR/devsy-bin/devsy-${{ matrix.go-os }}-${{ matrix.go-arch }}" desktop/resources/bin/devsy + chmod +x desktop/resources/bin/devsy # Also stage binaries for dist/ upload and provider generation mkdir -p dist/ @@ -136,7 +137,7 @@ jobs: Copy-Item "${{ runner.temp }}/devsy-bin/devsy-windows-amd64.exe" desktop/resources/bin/devsy.exe - name: generate pro provider - if: runner.os == 'Linux' + if: matrix.name == 'linux-x64' working-directory: ./hack/pro env: PARTIAL: "true" @@ -199,13 +200,15 @@ jobs: - name: upload update metadata artifact uses: actions/upload-artifact@v7 with: - name: update-metadata-${{ matrix.os }} - path: desktop/release/latest*.yml + name: update-metadata-${{ matrix.name }} + path: | + desktop/release/latest*.yml + desktop/release/beta*.yml if-no-files-found: ignore - name: upload cli assets uses: softprops/action-gh-release@v3 - if: runner.os == 'Linux' + if: matrix.name == 'linux-x64' with: tag_name: ${{ inputs.tag || github.ref_name }} draft: true @@ -213,7 +216,7 @@ jobs: ./dist/* - name: upload desktop linux artifact for flatpak - if: runner.os == 'Linux' + if: matrix.name == 'linux-x64' uses: actions/upload-artifact@v7 with: name: devsy-flatpak @@ -226,16 +229,34 @@ jobs: permissions: contents: read steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: download update metadata artifacts uses: actions/download-artifact@v8 with: pattern: update-metadata-* path: metadata/ - - name: arrange files into publish directory + - name: fetch existing metadata from live site run: | mkdir -p publish-dir/desktop - find metadata/ -name 'latest*.yml' -exec cp {} publish-dir/desktop/ \; + for file in latest-mac.yml latest-linux.yml latest.yml beta-mac.yml beta-linux.yml beta.yml; do + curl -sSf "https://dl.devsy.sh/desktop/$file" -o "publish-dir/desktop/$file" 2>/dev/null || true + done + + - name: merge macOS metadata from separate arch builds + run: | + pip install pyyaml + python3 hack/merge-mac-metadata.py metadata/ publish-dir/desktop/ + + - name: overlay non-mac metadata files + run: | + find metadata/ -name 'latest-linux.yml' -exec cp {} publish-dir/desktop/ \; + find metadata/ -name 'latest.yml' -exec cp {} publish-dir/desktop/ \; + find metadata/ -name 'beta-linux.yml' -exec cp {} publish-dir/desktop/ \; + find metadata/ -name 'beta.yml' -exec cp {} publish-dir/desktop/ \; - name: deploy to Netlify (dl.devsy.sh) uses: nwtgck/actions-netlify@v3 diff --git a/README.md b/README.md index afe1a170f..97d5235b8 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,13 @@ Take a look at the Devsy macOS -Universal (Apple Silicon + Intel) -macOS Universal +Apple Silicon (ARM64) +macOS ARM64 + + +macOS +Intel (x64) +macOS x64 Windows diff --git a/cmd/self_update.go b/cmd/self_update.go index 65a67d3ef..890d02f52 100644 --- a/cmd/self_update.go +++ b/cmd/self_update.go @@ -7,9 +7,15 @@ import ( "github.com/spf13/cobra" ) +const ( + channelStable = "stable" + channelBeta = "beta" +) + // SelfUpdateCmd is a struct that defines a command call for "self-update". type SelfUpdateCmd struct { Version string + Channel string DryRun bool } @@ -20,9 +26,27 @@ func NewSelfUpdateCmd() *cobra.Command { Use: "self-update", Short: "Update the Devsy CLI to the newest version", Args: cobra.NoArgs, + PreRunE: func(_ *cobra.Command, _ []string) error { + switch cmd.Channel { + case channelStable, channelBeta: + return nil + default: + return fmt.Errorf( + "invalid channel %q: must be %q or %q", + cmd.Channel, + channelStable, + channelBeta, + ) + } + }, RunE: func(cobraCmd *cobra.Command, args []string) error { ctx := cobraCmd.Context() - if err := selfupdate.Upgrade(ctx, cmd.Version, cmd.DryRun); err != nil { + opts := selfupdate.Options{ + Version: cmd.Version, + DryRun: cmd.DryRun, + IncludePrerelease: cmd.Channel == channelBeta, + } + if err := selfupdate.Upgrade(ctx, opts); err != nil { return fmt.Errorf("unable to update: %w", err) } return nil @@ -32,6 +56,9 @@ func NewSelfUpdateCmd() *cobra.Command { selfUpdateCmd.Flags(). StringVar(&cmd.Version, "version", "", "The version to update to. Defaults to the latest stable version available") + selfUpdateCmd.Flags(). + StringVar(&cmd.Channel, "channel", channelStable, + "Release channel: 'stable' for production releases, 'beta' for pre-release versions") selfUpdateCmd.Flags(). BoolVar(&cmd.DryRun, "dry-run", false, "Show which version would be downloaded without actually updating") return selfUpdateCmd diff --git a/cmd/self_update_test.go b/cmd/self_update_test.go index 093bef243..b130b107c 100644 --- a/cmd/self_update_test.go +++ b/cmd/self_update_test.go @@ -26,6 +26,13 @@ func TestNewSelfUpdateCmd_HasDryRunFlag(t *testing.T) { assert.Equal(t, "false", f.DefValue) } +func TestNewSelfUpdateCmd_HasChannelFlag(t *testing.T) { + cmd := NewSelfUpdateCmd() + f := cmd.Flags().Lookup("channel") + require.NotNil(t, f, "--channel flag must exist") + assert.Equal(t, channelStable, f.DefValue) +} + func TestNewSelfUpdateCmd_AcceptsNoPositionalArgs(t *testing.T) { cmd := NewSelfUpdateCmd() err := cmd.Args(cmd, []string{"unexpected"}) @@ -34,3 +41,21 @@ func TestNewSelfUpdateCmd_AcceptsNoPositionalArgs(t *testing.T) { err = cmd.Args(cmd, []string{}) assert.NoError(t, err, "should accept zero arguments") } + +func TestNewSelfUpdateCmd_RejectsInvalidChannel(t *testing.T) { + cmd := NewSelfUpdateCmd() + cmd.SetArgs([]string{"--channel", "nightly"}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid channel") +} + +func TestNewSelfUpdateCmd_AcceptsValidChannels(t *testing.T) { + for _, ch := range []string{channelStable, channelBeta} { + cmd := NewSelfUpdateCmd() + require.NoError(t, cmd.Flags().Set("channel", ch)) + preRunE := cmd.PreRunE + require.NotNil(t, preRunE) + assert.NoError(t, preRunE(cmd, nil)) + } +} diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index 4a07f3f1c..fca862913 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -19,15 +19,16 @@ extraResources: mac: category: public.app-category.developer-tools icon: resources/icon.icns - x64ArchFiles: "Contents/Resources/bin/devsy" artifactName: Devsy_${os}_${arch}.${ext} target: - target: dmg arch: - - universal + - x64 + - arm64 - target: zip arch: - - universal + - x64 + - arm64 hardenedRuntime: true gatekeeperAssess: false entitlements: build/entitlements.mac.plist diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index e79b01db5..4ef51a7a5 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -11,6 +11,14 @@ import type { CliRunner } from "./cli.js" import type { LogStore } from "./log-store.js" import type { PtyManager } from "./pty.js" import type { DaemonState } from "./state.js" +import { + type ReleaseChannel, + checkForUpdates, + checkForUpdatesWithChannel, + getReleaseChannel, + installUpdate, + setReleaseChannel, +} from "./updater.js" import { type ProviderEntry, parseProviderEntries } from "./watcher.js" const execFileAsync = promisify(execFile) @@ -656,6 +664,30 @@ export function registerIpcHandlers(deps: IpcDependencies): void { }, ) + // ── Release Channel ── + ipcMain.handle("get_release_channel", () => { + return getReleaseChannel() + }) + + ipcMain.handle( + "set_release_channel", + async (_event, args: { channel: string }) => { + if (args.channel !== "stable" && args.channel !== "beta") { + throw new Error(`Invalid release channel: ${args.channel}`) + } + setReleaseChannel(args.channel) + await checkForUpdatesWithChannel(args.channel) + }, + ) + + ipcMain.handle("check_for_updates", async () => { + await checkForUpdates() + }) + + ipcMain.handle("install_update", async () => { + installUpdate() + }) + // ── Analytics ── ipcMain.handle( "analytics_track", diff --git a/desktop/src/main/updater.ts b/desktop/src/main/updater.ts index cd2297cc4..3c0cf09be 100644 --- a/desktop/src/main/updater.ts +++ b/desktop/src/main/updater.ts @@ -1,24 +1,106 @@ -import { dialog, type BrowserWindow } from "electron" +import { readFileSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { app, dialog, type BrowserWindow } from "electron" import { trackEvent } from "./analytics.js" +export type ReleaseChannel = "stable" | "beta" + +export interface UpdateStatus { + state: "checking" | "available" | "not-available" | "downloading" | "downloaded" | "error" + version?: string + releaseNotes?: string + releaseName?: string + error?: string +} + +function settingsPath(): string { + return join(app.getPath("userData"), "update-settings.json") +} + +function loadChannel(): ReleaseChannel { + try { + const data = JSON.parse(readFileSync(settingsPath(), "utf-8")) + if (data.channel === "beta") return "beta" + } catch { + // File doesn't exist or is corrupt + } + return "stable" +} + +function saveChannel(channel: ReleaseChannel): void { + writeFileSync(settingsPath(), JSON.stringify({ channel })) +} + +let currentChannel: ReleaseChannel = "stable" +let getMainWindowFn: (() => BrowserWindow | null) | null = null + +function sendUpdateStatus(status: UpdateStatus): void { + const win = getMainWindowFn?.() + if (win && !win.isDestroyed()) { + win.webContents.send("update-status", status) + } +} + +function normalizeReleaseNotes( + notes: string | { note: string }[] | null | undefined, +): string | undefined { + if (!notes) return undefined + if (typeof notes === "string") return notes + if (Array.isArray(notes)) return notes.map((n) => n.note).join("\n") + return undefined +} + +export function setReleaseChannel(channel: ReleaseChannel): void { + currentChannel = channel + saveChannel(channel) +} + +export function getReleaseChannel(): ReleaseChannel { + return currentChannel +} + export async function initAutoUpdater( getMainWindow: () => BrowserWindow | null, ): Promise { + getMainWindowFn = getMainWindow + currentChannel = loadChannel() const { autoUpdater } = await import("electron-updater") autoUpdater.autoDownload = true autoUpdater.autoInstallOnAppQuit = true + autoUpdater.allowPrerelease = currentChannel === "beta" + autoUpdater.channel = currentChannel === "beta" ? "beta" : "latest" autoUpdater.on("checking-for-update", () => { trackEvent("update_check") + sendUpdateStatus({ state: "checking" }) }) autoUpdater.on("update-available", (info) => { trackEvent("update_available", { version: info.version }) + sendUpdateStatus({ + state: "available", + version: info.version, + releaseName: info.releaseName ?? undefined, + releaseNotes: normalizeReleaseNotes(info.releaseNotes), + }) + }) + + autoUpdater.on("update-not-available", (info) => { + sendUpdateStatus({ + state: "not-available", + version: info.version, + }) }) autoUpdater.on("update-downloaded", (info) => { trackEvent("update_downloaded", { version: info.version }) + sendUpdateStatus({ + state: "downloaded", + version: info.version, + releaseName: info.releaseName ?? undefined, + releaseNotes: normalizeReleaseNotes(info.releaseNotes), + }) const win = getMainWindow() if (!win) return @@ -42,13 +124,34 @@ export async function initAutoUpdater( autoUpdater.on("error", (err) => { trackEvent("update_error", { error_type: err.name }) + sendUpdateStatus({ state: "error", error: err.message }) console.error("Auto-update error:", err.message) }) - // Check for updates after a short delay to avoid slowing down app launch setTimeout(() => { autoUpdater.checkForUpdates().catch((err: Error) => { console.error("Update check failed:", err.message) }) }, 10_000) } + +export async function checkForUpdates(): Promise { + const { autoUpdater } = await import("electron-updater") + await autoUpdater.checkForUpdates() +} + +export async function checkForUpdatesWithChannel( + channel: ReleaseChannel, +): Promise { + const { autoUpdater } = await import("electron-updater") + currentChannel = channel + saveChannel(channel) + autoUpdater.allowPrerelease = channel === "beta" + autoUpdater.channel = channel === "beta" ? "beta" : "latest" + await autoUpdater.checkForUpdates() +} + +export async function installUpdate(): Promise { + const { autoUpdater } = await import("electron-updater") + autoUpdater.quitAndInstall() +} diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index e35ef2fe4..3f982d1cc 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -250,6 +250,25 @@ export async function sshKeyGenerate(params: { return invoke("ssh_key_generate", params) } +// Release channel +export type ReleaseChannel = "stable" | "beta" + +export async function getReleaseChannel(): Promise { + return invoke("get_release_channel") +} + +export async function setReleaseChannel(channel: ReleaseChannel): Promise { + return invoke("set_release_channel", { channel }) +} + +export async function checkForUpdates(): Promise { + return invoke("check_for_updates") +} + +export async function installUpdate(): Promise { + return invoke("install_update") +} + // Analytics export function analyticsTrack( name: string, diff --git a/desktop/src/renderer/src/lib/ipc/events.ts b/desktop/src/renderer/src/lib/ipc/events.ts index 30f304930..24d683efe 100644 --- a/desktop/src/renderer/src/lib/ipc/events.ts +++ b/desktop/src/renderer/src/lib/ipc/events.ts @@ -8,12 +8,21 @@ import type { import { listen } from "./bridge.js" import type { UnlistenFn } from "./types.js" +export interface UpdateStatus { + state: "checking" | "available" | "not-available" | "downloading" | "downloaded" | "error" + version?: string + releaseNotes?: string + releaseName?: string + error?: string +} + export const EVENT_NAMES = { WORKSPACES_CHANGED: "workspaces-changed", PROVIDERS_CHANGED: "providers-changed", MACHINES_CHANGED: "machines-changed", CONTEXTS_CHANGED: "contexts-changed", COMMAND_PROGRESS: "command-progress", + UPDATE_STATUS: "update-status", } as const interface WorkspacesPayload { @@ -69,3 +78,11 @@ export function onCommandProgress( callback(event.payload) }) } + +export function onUpdateStatus( + callback: (status: UpdateStatus) => void, +): Promise { + return listen(EVENT_NAMES.UPDATE_STATUS, (event) => { + callback(event.payload) + }) +} diff --git a/desktop/src/renderer/src/lib/ipc/mock.ts b/desktop/src/renderer/src/lib/ipc/mock.ts index f792af0b1..03591480d 100644 --- a/desktop/src/renderer/src/lib/ipc/mock.ts +++ b/desktop/src/renderer/src/lib/ipc/mock.ts @@ -262,6 +262,12 @@ const COMMANDS: Record = { terminal_close: () => undefined, terminal_list: () => [], + // Release channel + get_release_channel: () => "stable", + set_release_channel: () => undefined, + check_for_updates: () => undefined, + install_update: () => undefined, + // Analytics analytics_track: () => undefined, } diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte index e7bfac586..250089466 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.svelte +++ b/desktop/src/renderer/src/pages/SettingsPage.svelte @@ -1,6 +1,6 @@