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
5 changes: 5 additions & 0 deletions .changeset/dmg-smappservice-rebind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"devctl": minor
---

Ship a DMG installer that places the app in Applications and registers the Login Items agent, keep resource locks across a daemon crash so paused servers resume, and rebind the helper after ad-hoc upgrades so the daemon returns in seconds instead of stalling on a codesign throttle.
110 changes: 110 additions & 0 deletions .github/workflows/release-dmg.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Notarized DMG artifact for GitHub Releases.
# Triggered when the Ubuntu Changesets job publishes a release (tag vX.Y.Z).
# Requires repository secrets:
# APPLE_DEVELOPER_ID_P12_BASE64, APPLE_DEVELOPER_ID_P12_PASSWORD
# APPLE_SIGN_IDENTITY (e.g. "Developer ID Application: Name (TEAMID)")
# APPLE_API_KEY_BASE64, APPLE_API_KEY_ID, APPLE_API_ISSUER
name: Release DMG

on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Tag to build (e.g. v1.2.0); defaults to latest release tag"
required: false

concurrency:
group: release-dmg-${{ github.event.release.tag_name || github.ref }}
cancel-in-progress: false

permissions:
contents: write

jobs:
dmg:
name: Build and notarize DMG
runs-on: macos-26
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || inputs.tag || github.ref }}

- name: Cache SwiftPM
uses: actions/cache@v4
with:
path: |
.build
~/Library/Caches/org.swift.swiftpm
key: spm-release-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved') }}
restore-keys: |
spm-release-${{ runner.os }}-${{ runner.arch }}-

- name: Import Developer ID certificate
env:
P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }}
P12_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }}
run: |
set -euo pipefail
if [[ -z "${P12_BASE64:-}" || -z "${P12_PASSWORD:-}" ]]; then
echo "Missing APPLE_DEVELOPER_ID_P12_BASE64 or APPLE_DEVELOPER_ID_P12_PASSWORD" >&2
exit 1
fi
# RUNNER_TEMP is job-private; mktemp only randomizes trailing Xs, so
# a ".XXXXXX.p12" template would be a fixed path.
CERT_PATH="$(mktemp "$RUNNER_TEMP/devctl-cert.XXXXXX")"
KEYCHAIN_PATH="$RUNNER_TEMP/devctl-signing.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -base64 24)"
echo "$P12_BASE64" | base64 -D > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$P12_PASSWORD" -A -t cert -f pkcs12 \
-k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH" $(security list-keychain -d user | tr -d '"')
security set-key-partition-list -S apple-tool:,apple:,codesign: -s \
-k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
rm -f "$CERT_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV"

- name: Build signed DMG
env:
# A released image must carry the double-click instructions, so a
# runner that cannot drive Finder fails here instead of shipping a
# bare icon view.
DEVCTL_DMG_REQUIRE_LAYOUT: "1"
SIGN_IDENTITY: ${{ secrets.APPLE_SIGN_IDENTITY }}
run: |
set -euo pipefail
: "${SIGN_IDENTITY:?set APPLE_SIGN_IDENTITY secret}"
make dmg

- name: Notarize and staple
env:
APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: scripts/notarize.sh

- name: Upload DMG to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: |
set -euo pipefail
DMG="$(ls -t dist/devctl-*.dmg | head -1)"
if [[ -z "${TAG:-}" ]]; then
TAG="$(git describe --tags --abbrev=0)"
fi
gh release upload "$TAG" "$DMG" --clobber

- name: Cleanup keychain
if: always()
run: |
if [[ -n "${KEYCHAIN_PATH:-}" ]]; then
security delete-keychain "$KEYCHAIN_PATH" || true
fi
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ node_modules/
.build/
.swiftpm/
devctl.app/
dist/
*.corrupt-*
.DS_Store
9 changes: 4 additions & 5 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@

Open work only; entries are removed by the change that resolves them.

- DMG distribution with one-click install (Evan, 2026-07-19): a simple dmg builder (scripts/make-dmg.sh from the existing bundle pipeline; hdiutil + a drag-to-Applications layout) where first launch of devctl.app completes setup itself: install the CLI/daemon binaries from the app bundle's Resources, run daemon install, and auto-detect installed agent harnesses (Claude Code via ~/.claude presence → offer/perform `hook install --harness claude`; Cursor via ~/.cursor presence → `hook install --harness cursor`), with a small first-run panel reporting what was set up. Requires Developer ID signing + notarization (already backlogged) to be download-safe; sequence the two together.
- Orphan re-adoption without the bounce: after a daemon crash, re-adopt live orphan servers (pid + start-time match, resume spool tailing) instead of group-kill + restart. Blocked on: exit codes are unknowable for non-children; needs a design for degraded forensics.
- Reverse proxy on :80/:443 routing by host signature, making ports disappear from `*.localhost` URLs (Valet/Herd territory).
- MenuBarExtraAccess (orchetect) if `.window` presentation quirks bite in practice.
- Developer ID signing + notarization + Homebrew tap for OSS release; SIGN_IDENTITY variable already exists in the bundle script.
- App Intents / Shortcuts wrappers over the existing `DeepLink` verbs (`open`, `ensure`, `stop`, `why`) for Siri / Gemini-Siri and Control Center. The `devctl://` URL table and `DeepLinkRunner` are the shared surface; intents should call the runner, not reimplement dispatch.
- Populate Apple Developer ID + App Store Connect API secrets for `.github/workflows/release-dmg.yml` (and a Homebrew tap) so OSS downloads are Gatekeeper-safe; scripts (`make dmg`, `scripts/notarize.sh`) and the workflow already exist. First stapled DMG may be attached to the existing `v1.2.0` GitHub release only after local dogfood and Evan's explicit sign-off (do not upload without it).
- App Intents / Shortcuts wrappers over the existing `DeepLink` verbs (`open`, `ensure`, `stop`, `why`) for Siri / Gemini-Siri and Control Center. The `devctl://` URL table and `DeepLinkRunner` are the shared surface; intents should call the runner, not reimplement dispatch. Also the next plausible path for Spotlight ranking (IndexedEntity) once Core Spotlight levers are exhausted.
- swift-subprocess 0.5 occasionally fatals in its kqueue AsyncIO cleanup at process exit ("Failed to close kqueue fds: Bad file descriptor"), seen once under parallel test load; harmless to the long-lived daemon but track against upstream releases (pinned revision in Package.swift).
- Field-level config editing in the dashboard to preserve devservers.json formatting instead of normalizing writes.
- Spotlight thumbnails: confirm config icons render in the real Spotlight UI. Ranking above filesystem / Cursor Top Hits is a hard Apple ceiling (tried; stripped Recent Documents / jump-file chase 2026-07-24); do not reopen without a new system API.
- Field-level config editing in the dashboard to preserve `devservers.json` formatting instead of normalizing writes.
- Spotlight thumbnails: confirm config icons render in the real Spotlight UI. Within-app ranking levers are maxed (lastUsed preserved across sync, incremental index updates, live/pinned rankingHint, alternateNames). Outranking filesystem / Cursor Top Hits remains an Apple ceiling; do not chase without a new system API.
- Automatic port deconflicting / pre-spawn allocation: today is refuse (`port-held`) + observe framework bumps (`observedPort`). True auto-pick needs env injection, URL/head rewrite, and ephemeral-vs-committed policy.
Loading
Loading