Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# MobileCode releases

MobileCode publishes unsigned Apple-silicon macOS builds from `release-macos.yml`. The workflow creates a GitHub release with the DMG, ZIP, and `latest-mac.yml`, and also retains those files as workflow-run artifacts.

## One-time GitHub setup

1. In **Settings → Actions → General**, enable GitHub Actions and allow actions created by GitHub and verified creators.
2. In the **Actions** tab, disable inherited OpenCode workflows that MobileCode does not use. Leave **Release macOS** enabled. Workflow enabled/disabled state is stored by GitHub, so this avoids changing upstream workflow files and keeps upstream merges straightforward.
3. In **Settings → Actions → General → Workflow permissions**, select **Read and write permissions**. The release workflow also declares its narrower `contents: write` permission.

No Apple secrets are required while builds are unsigned. macOS auto-update is intentionally disabled for unsigned builds because Squirrel.Mac requires code signing. Users download each release manually and must approve the app on first launch.

## Create a release

Open **Actions → Release macOS → Run workflow**, choose `dev`, and choose whether to publish immediately. The workflow reads the version from `packages/opencode/package.json` and creates the matching tag. For example, OpenCode version `1.18.29` produces `v1.18.29`. Reruns accept that tag only when it still points to the selected commit.

Pushing a `v*` tag is also supported and creates a draft release for review, but the tag version must match `packages/opencode/package.json`.

## Sync from OpenCode

Keep the `upstream` remote pointed at `https://github.com/anomalyco/opencode.git`, then merge upstream into MobileCode's `dev` branch:

```sh
git fetch upstream
git checkout dev
git merge upstream/dev
```

Keep upstream's internal `@opencode-ai/*` workspace package names, `OPENCODE_*` environment variables, server protocol, and CLI terminology. They are implementation contracts, not installed-app identity, and retaining them minimizes merge conflicts. MobileCode-specific identity is limited to packaging metadata, application data directories, URL protocol, assets, and release configuration.

After resolving any branded-file conflicts, verify from `packages/desktop`:

```sh
bun test electron-builder.config.test.ts
bun typecheck
```

For a local unsigned release build, run `bun run build:macos` from the repository root.
131 changes: 131 additions & 0 deletions .github/workflows/release-macos.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
name: Release macOS

# Builds the MobileCode desktop app for Apple silicon and attaches it to a GitHub release.
#
# Two ways to run it:
# - Push a tag matching packages/opencode/package.json.
# - Run it manually. The workflow reads that version and creates the matching tag.
#
# The release is created as a draft so it can be reviewed, then published from the GitHub UI
# (or pass publish=true). The zip and latest-mac.yml are what the in-app updater reads, so keep
# the asset names electron-builder chooses.
#
# Builds are intentionally unsigned for now. Users must right-click the app and choose Open
# the first time they launch it.

on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
publish:
description: "Publish the release immediately instead of leaving it as a draft"
required: false
type: boolean
default: false

permissions:
contents: write

concurrency: release-macos-${{ github.ref }}

jobs:
build:
if: github.repository == 'hsandhu/mobilecode'
runs-on: macos-15
timeout-minutes: 90
env:
OPENCODE_CHANNEL: prod
MOBILECODE_UPDATER_ENABLED: "false"
CSC_IDENTITY_AUTO_DISCOVERY: "false"
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0

- name: Resolve version
id: version
run: |
set -euo pipefail
PACKAGE_VERSION="$(node -p "require('./packages/opencode/package.json').version")"
if [ "${{ github.event_name }}" = "push" ]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="$PACKAGE_VERSION"
fi
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "Version must be a stable SemVer such as 1.2.3, got '$VERSION'"
exit 1
fi
if [ "$VERSION" != "$PACKAGE_VERSION" ]; then
echo "Release version $VERSION must match OpenCode version $PACKAGE_VERSION"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"

- name: Create or verify tag
if: github.event_name == 'workflow_dispatch'
run: |
set -euo pipefail
TAG="${{ steps.version.outputs.tag }}"
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
TAG_COMMIT="$(git rev-list -n 1 "$TAG")"
if [ "$TAG_COMMIT" != "$GITHUB_SHA" ]; then
echo "Tag $TAG already points to $TAG_COMMIT, not the selected commit $GITHUB_SHA"
exit 1
fi
else
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "MobileCode $TAG"
git push origin "$TAG"
fi
git checkout --detach "$TAG"

- uses: ./.github/actions/setup-bun

- name: Test desktop
working-directory: packages/desktop
run: bun test

- name: Typecheck desktop
working-directory: packages/desktop
run: bun typecheck

- name: Prepare
working-directory: packages/desktop
run: bun ./scripts/prepare.ts
env:
OPENCODE_VERSION: ${{ steps.version.outputs.version }}

- name: Build
working-directory: packages/desktop
run: bun run build
env:
NODE_OPTIONS: --max-old-space-size=4096
OPENCODE_VERSION: ${{ steps.version.outputs.version }}

- name: Package and attach to release
working-directory: packages/desktop
run: bun x electron-builder --mac --arm64 --publish always --config electron-builder.config.ts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Keep the artifacts on the run too
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: mobilecode-desktop-mac-arm64
path: |
packages/desktop/dist/*.dmg
packages/desktop/dist/*.zip
packages/desktop/dist/latest-mac.yml
if-no-files-found: error

- name: Publish release
if: github.event_name == 'workflow_dispatch' && inputs.publish
run: gh release edit "${{ steps.version.outputs.tag }}" --draft=false
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4 changes: 2 additions & 2 deletions packages/desktop/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# OpenCode Desktop
# MobileCode Desktop

The OpenCode Desktop app, built with Electron.
The MobileCode desktop app, built with Electron and kept close to the upstream OpenCode desktop package.

## Development

Expand Down
67 changes: 41 additions & 26 deletions packages/desktop/electron-builder.config.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import { expect, test } from "bun:test"
import type { Configuration } from "electron-builder"

const legacyDesktopEntry = "resources/linux/opencode-desktop.desktop"

const channels = [
{ channel: "dev", appId: "ai.opencode.desktop.dev" },
{ channel: "beta", appId: "ai.opencode.desktop.beta" },
{ channel: "prod", appId: "ai.opencode.desktop" },
{ channel: "dev", appId: "dev.mobilecode.desktop.dev", productName: "MobileCode Dev", protocolName: "MobileCode" },
{
channel: "beta",
appId: "dev.mobilecode.desktop.beta",
productName: "MobileCode Beta",
protocolName: "MobileCode Beta",
},
{ channel: "prod", appId: "dev.mobilecode.desktop", productName: "MobileCode", protocolName: "MobileCode" },
] as const

test("tracks the OpenCode version", async () => {
const desktop = await Bun.file("package.json").json()
const opencode = await Bun.file("../opencode/package.json").json()
expect(desktop.version).toBe(opencode.version)
})

for (const channel of channels) {
test(`uses one Linux desktop identity for ${channel.channel}`, async () => {
const previous = process.env.OPENCODE_CHANNEL
Expand All @@ -21,40 +30,32 @@ for (const channel of channels) {
else process.env.OPENCODE_CHANNEL = previous

expect(config.appId).toBe(channel.appId)
expect(config.productName).toBe(channel.productName)
expect(config.artifactName).toBe("mobilecode-desktop-${os}-${arch}.${ext}")
expect(config.extraMetadata?.name).toBe("mobilecode-desktop")
expect(config.extraMetadata?.desktopName).toBe(`${channel.appId}.desktop`)
expect(config.linux?.executableName).toBe(channel.appId)
expect(config.linux?.desktop?.entry?.StartupWMClass).toBe(channel.appId)
expect(config.protocols).toEqual({
name: channel.protocolName,
schemes: ["mobilecode"],
})
expect(config.deb?.fpm).toContainEqual(expect.stringContaining(`/usr/share/metainfo/${channel.appId}.metainfo.xml`))
expect(config.rpm?.fpm).toContainEqual(expect.stringContaining(`/usr/share/metainfo/${channel.appId}.metainfo.xml`))
})
}

test("keeps a hidden prod launcher for old Linux pins", async () => {
test("publishes production updates only from the MobileCode repository", async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = "prod"

const module = await import("./electron-builder.config.ts?compat=prod")
const module = await import("./electron-builder.config.ts?publish=prod")
const config = module.default as Configuration

if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous

expect(
config.deb?.fpm?.some((entry) =>
entry.endsWith("opencode-desktop.desktop=/usr/share/applications/opencode-desktop.desktop"),
),
).toBe(true)
expect(
config.rpm?.fpm?.some((entry) =>
entry.endsWith("opencode-desktop.desktop=/usr/share/applications/opencode-desktop.desktop"),
),
).toBe(true)

const desktop = await Bun.file(legacyDesktopEntry).text()
expect(desktop).toContain("Exec=/opt/OpenCode/ai.opencode.desktop %U")
expect(desktop).toContain("Icon=ai.opencode.desktop")
expect(desktop).toContain("StartupWMClass=ai.opencode.desktop")
expect(desktop).toContain("NoDisplay=true")
expect(config.publish).toEqual({ provider: "github", owner: "hsandhu", repo: "mobilecode", channel: "latest" })
})

test("bundles the CLI outside the dev app archive", async () => {
Expand All @@ -65,11 +66,11 @@ test("bundles the CLI outside the dev app archive", async () => {
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous

expect(config.files).toContain("!resources/opencode-cli*")
expect(config.files).toContain("!resources/mobilecode-cli*")
expect(config.extraResources).toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
filter: ["mobilecode-cli*"],
})
})

Expand All @@ -85,7 +86,21 @@ for (const channel of ["beta", "prod"] as const) {
expect(config.extraResources).not.toContainEqual({
from: "resources/",
to: "",
filter: ["opencode-cli*"],
filter: ["mobilecode-cli*"],
})
})
}

test("supports unsigned macOS packages", async () => {
const previous = process.env.CSC_IDENTITY_AUTO_DISCOVERY
process.env.CSC_IDENTITY_AUTO_DISCOVERY = "false"

const module = await import("./electron-builder.config.ts?unsigned=macos")
const config = module.default as Configuration

if (previous === undefined) delete process.env.CSC_IDENTITY_AUTO_DISCOVERY
else process.env.CSC_IDENTITY_AUTO_DISCOVERY = previous

expect(config.mac?.notarize).toBe(false)
expect(config.dmg?.sign).toBe(false)
})
27 changes: 15 additions & 12 deletions packages/desktop/electron-builder.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"
import { promisify } from "node:util"

import type { Configuration } from "electron-builder"
import { DESKTOP_APP_IDS, DESKTOP_APP_NAMES } from "./product"

const execFileAsync = promisify(execFile)
const packageDir = path.dirname(fileURLToPath(import.meta.url))
Expand All @@ -24,18 +25,17 @@ async function signWindows(configuration: { path: string }) {
)
}

// A build without an Apple Developer identity still has to produce an app: skip signing and
// notarization when the workflow (or a local run) turns identity discovery off.
const signing = process.env.CSC_IDENTITY_AUTO_DISCOVERY !== "false"
const notarize = signing && Boolean(process.env.APPLE_API_KEY)

const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
return "dev"
})()

const APP_IDS = {
dev: "dev.mobilecode.desktop.dev",
beta: "dev.mobilecode.desktop.beta",
prod: "dev.mobilecode.desktop",
} as const

const getBase = (appId: string): Configuration => ({
artifactName: "mobilecode-desktop-${os}-${arch}.${ext}",
directories: {
Expand All @@ -48,6 +48,9 @@ const getBase = (appId: string): Configuration => ({
// https://developer.gnome.org/documentation/guidelines/maintainer/integrating.html
// https://www.electron.build/docs/linux/
extraMetadata: {
// electron-updater derives its cache directory from the package name. Keep downloads
// isolated from OpenCode even though workspace package names stay upstream-compatible.
name: "mobilecode-desktop",
desktopName: `${appId}.desktop`,
},
files: ["out/**/*", "resources/**/*", "!resources/mobilecode-cli*"],
Expand All @@ -74,11 +77,11 @@ const getBase = (appId: string): Configuration => ({
gatekeeperAssess: false,
entitlements: "resources/entitlements.plist",
entitlementsInherit: "resources/entitlements.plist",
notarize: true,
notarize,
target: ["dmg", "zip"],
},
dmg: {
sign: true,
sign: signing,
},
protocols: {
name: "MobileCode",
Expand Down Expand Up @@ -114,15 +117,15 @@ const getBase = (appId: string): Configuration => ({
})

function getConfig() {
const appId = APP_IDS[channel]
const appId = DESKTOP_APP_IDS[channel]
const base = getBase(appId)

switch (channel) {
case "dev": {
return {
...base,
appId,
productName: "MobileCode Dev",
productName: DESKTOP_APP_NAMES.dev,
deb: { fpm: [metainfoFpm(appId)] },
rpm: { packageName: "mobilecode-dev", fpm: [metainfoFpm(appId)] },
}
Expand All @@ -131,7 +134,7 @@ function getConfig() {
return {
...base,
appId,
productName: "MobileCode Beta",
productName: DESKTOP_APP_NAMES.beta,
protocols: { name: "MobileCode Beta", schemes: ["mobilecode"] },
publish: { provider: "github", owner: "hsandhu", repo: "mobilecode-beta", channel: "latest" },
deb: { fpm: [metainfoFpm(appId)] },
Expand All @@ -142,7 +145,7 @@ function getConfig() {
return {
...base,
appId,
productName: "MobileCode",
productName: DESKTOP_APP_NAMES.prod,
protocols: { name: "MobileCode", schemes: ["mobilecode"] },
publish: { provider: "github", owner: "hsandhu", repo: "mobilecode", channel: "latest" },
deb: { fpm: [metainfoFpm(appId)] },
Expand Down
3 changes: 3 additions & 0 deletions packages/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export default defineConfig({
main: {
define: {
"import.meta.env.OPENCODE_CHANNEL": JSON.stringify(channel),
"import.meta.env.MOBILECODE_UPDATER_ENABLED": JSON.stringify(
process.env.MOBILECODE_UPDATER_ENABLED === "true" ? "true" : "false",
),
},
build: {
rollupOptions: {
Expand Down
Loading
Loading