Project-specific guardrails. Read every section before touching this repo.
This app ships via Sparkle auto-update. Get any of this wrong and you brick auto-update for everyone who's already installed.
echo "X.Y.Z" > VERSION # 1. Bump
git commit -am "chore(release): bump VERSION to X.Y.Z" \
&& git tag vX.Y.Z # 2. Commit + tag
git push origin main vX.Y.Z # 3. Push (fires CI)The marketing landing site at ericjypark/codex-island-landing has its own
VERSION file (the hero chip + footer read it at build time). Bump it in
that repo too, in the same release sweep, or the public site keeps showing
the prior version even after brew install ships the new one.
That's it. CI does everything else in ~1.5 min:
- Builds the universal DMG
- Signs it with the EdDSA key from the
SPARKLE_ED_PRIVATE_KEYsecret - Generates
appcast.xmllisting the new version - Uploads DMG + appcast as release assets
- Mirrors the cask to
ericjypark/homebrew-tapwith the new version + SHA-256
Watch with gh run watch --exit-status if you want confirmation, or just trust it.
-
VERSIONmust be a single-monotonic version like0.0.X, NOT1or100or anything weird.build.shuses$VERSIONas bothCFBundleVersionandCFBundleShortVersionString. Sparkle comparesCFBundleVersionof the running app againstsparkle:versionin the appcast using Apple's component-wise comparator — so"1"parses as[1]and is larger than"0.0.99". Stay in semver. Always increase. -
The Sparkle public key in
build.sh(SU_PUBLIC_KEY="bz1g...") must NEVER be changed casually. Every existing install verifies updates against this exact key. Change it and every prior install rejects every future update silently. The matching private key lives in (a) the maintainer's macOS Keychain under servicehttps://sparkle-project.organd (b) theSPARKLE_ED_PRIVATE_KEYGitHub Actions secret. To rotate, see the migration note indocs/SPARKLE.md(TL;DR: don't). -
Don't manually edit
Casks/codexisland.rbfor a version bump. CI rewrites it on the homebrew-tap side at release time. Manual version/SHA edits are overwritten or drift. (Editing unrelated cask metadata — postflight, zap, livecheck — via a normal commit is fine; CI preserves those.) -
Never edit appcast XML files by hand. The appcast is a release asset built by
release.shfrom the signed DMG. Hand-edits invalidate the EdDSA signature. -
Never commit
Vendor/. It's gitignored. Thebin/sign_update,bin/generate_keys, etc. binaries live there for local use; CI re-vendors viascripts/setup-sparkle.sh.
These two GitHub Actions secrets exist on the codex-island repo:
SPARKLE_ED_PRIVATE_KEY— the EdDSA private key. Without it CI fails at the signing step.HOMEBREW_TAP_TOKEN— fine-grained PAT withcontents: writeonericjypark/homebrew-taponly. Without it the cask-sync step warns and skips, but the GitHub Release still ships.
If either is rotated, regenerate via the original instructions in docs/SPARKLE.md.
If you want to verify Sparkle's UI before tagging:
./release.sh # produces dist/CodexIsland-X.Y.Z.dmg + dist/appcast.xml
# (uses Keychain key — no env vars needed locally)The local release.sh is identical to CI except for asset upload. To force-trigger an update prompt without publishing: temporarily change SUFeedURL in build.sh to point at http://127.0.0.1:8765/appcast.xml, serve dist/appcast.xml from there with python3 -m http.server 8765, run with a lower local VERSION than the appcast advertises, hit Check Now.
To build with auto-update disabled (debug copies): SU_FEED_URL= ./build.sh.
History — read before re-stepping on these rakes:
| Problem | Symptom | Root cause | Fix |
|---|---|---|---|
CFBundleVersion = "1" hardcoded |
Sparkle never sees any update as newer | "1" > "0.0.X" in component-wise comparison |
build.sh now sets it to $VERSION |
SUPublicEDKey empty in CI builds |
Sparkle silently rejects every signed update | Public key was in gitignored Vendor/ only |
Public key hardcoded in build.sh |
xattr -d non-recursive in cask postflight |
"Updater failed to start" on first Check Now | Quarantine attr remained on Sparkle's nested Updater.app |
xattr -dr (recursive) |
--no-quarantine in install docs |
brew install fails with "switch is disabled" |
Homebrew removed the flag in late 2025 | Cask postflight strips the attr; flag removed from docs |
… after $VAR in shell scripts |
CI fails with unbound variable |
Non-UTF-8 locale on runners makes bash include trailing bytes in identifier | Use ${VAR}… braces, or stick to ASCII in echo strings |
| Old yonsei email in commits | Vercel rejected landing deploys | Local git config used unverified email | Set git config user.email to a GitHub-verified address before committing |
Landing tried to read ../VERSION |
Vercel build ENOENT'd at /vercel/VERSION |
Landing is its own repo; Vercel only checks out codex-island-landing, so .. escapes the build root |
Landing has its own VERSION file, read with path.join(process.cwd(), "VERSION") — sync it on every release |
Claude usage chip showing HTTP 403 |
Existing installs stop showing real numbers post-upgrade | Anthropic added user:profile to the required scope set on /api/oauth/usage (mid-2026) — pre-upgrade keychain tokens only carry user:inference |
User runs claude /login to re-mint with the new scope set; app surfaces "re-login: claude /login" instead of raw HTTP code |
Refresh URL pinned to console.anthropic.com |
Refresh path silently 404s on tokens minted by current CLI | OAuth issuer migrated to platform.claude.com/v1/oauth/token; old host is no longer the canonical issuer |
Refresh URL bumped to platform.claude.com |
Sources/Window/IslandWindowController.swift— borderless overlay window. Listens toNSApplication.didChangeScreenParametersNotificationto reposition on display changes; prefers the screen withsafeAreaInsets.top > 0(the notched display).Sources/Update/UpdaterController.swift— wraps Sparkle'sSPUStandardUpdaterController. ReadsSUFeedURL/SUPublicEDKeyfrom Info.plist (injected bybuild.sh). Auto-check state is stored by Sparkle itself inNSUserDefaultsunderSU*keys.Sources/Usage/UsageFetcher.swift— Codex (/wham/usage) and Claude (/api/oauth/usage) fetchers. Claude requires theclaude-code/X.Y.ZUser-Agent +oauth-2025-04-20beta header. Refresh-token rotation is wired throughwriteClaudeCreds— Anthropic rotates the refresh token on every call and the keychain MUST be updated, or downstream consumers (Claude Code, Claude Desktop) 401. Refresh URL ishttps://platform.claude.com/v1/oauth/token(migrated fromconsole.anthropic.com). The endpoint also requires theuser:profilescope as of mid-2026 — tokens from older logins return 403 and the only fix isclaude /login(refresh re-issues with the same scope set).Sources/Usage/AppUsage.swift—planfield carries Claude'ssubscriptionType(from keychain) or Codex'splan_type(from API top-level). Surfaced as the chip badge inSettingsView+UsageView.
build.sh— universal binary (arm64 + x86_64 vialipo), macOS 13+, ad-hoc codesign, embeds Sparkle.framework with@executable_path/../Frameworksrpath.- Unsigned by Apple — no $99 Developer ID. The ad-hoc sign is just to dodge "is damaged and can't be opened" Gatekeeper rejection on download. Sparkle's EdDSA signing handles update integrity independently.
scripts/setup-sparkle.shdownloads Sparkle 2.9.1 intoVendor/Sparkle/(idempotent). Runs automatically as part ofbuild.sh.
- The
5m / 15m / 30mpolling presets (Sources/Model/RefreshIntervalStore.swift) — Anthropic rate-limits aggressively. Anything below 5m burns the daily quota. - The
claude-code/X.Y.ZUser-Agent string — Anthropic gates/api/oauth/usageon it. Without it, requests 401 even with a valid token. - The bundle ID
dev.codexisland.CodexIsland— changing it orphans every existing user's preferences and Launch-at-Login registration. - The
SU_PUBLIC_KEYconstant inbuild.sh. See hard rule #2.
This repo is public open source. docs/ is for things a contributor or
curious user would read. notes/ is gitignored and is for maintainer-only
operational material. When in doubt, default to notes/ — it's
trivial to promote a file later, painful to scrub git history.
docs/ (committed, public):
- Build / release / signing process (e.g.
SPARKLE.md). - Architecture deep-dives, protocol notes, contributor onboarding.
- Anything that helps someone reading the source understand it or ship a PR.
notes/ (gitignored, maintainer-only) — examples of what belongs here:
- Launch / marketing playbooks (where to post, when to post, UTM schemas, channel-by-channel rules).
- Analytics & traffic ops (PostHog dashboards, GitHub traffic API workflows, install-funnel telemetry, dashboard URLs).
- Anything mentioning private infra: tokens, secret names, dashboard IDs, internal cron schedules — even if the secret value isn't there, the shape of the deployment is.
- Personal launch-strategy retrospectives, growth experiments, A/B copy drafts.
Heuristic: if removing the file from the public repo would embarrass
nothing and help nobody outside the maintainer, it belongs in notes/.
If it would actively help a contributor or a downstream packager, it
belongs in docs/.
When creating a new doc in this category, do not add it to docs/ and
later move it — moving leaves a deletion in history that still shows the
title and intent. Create it in notes/ from the start.
- Conventional Commits:
feat:,fix:,chore:,refactor:,test:,docs:. NoCo-Authored-Bylines. - Strict TypeScript / Swift — no
any, no force-unwraps without justification. - Default to no comments. Only add when the WHY is non-obvious (a constraint, a workaround for a specific bug, behavior that would surprise a reader).
- Match existing style in the file you're editing, even if you'd do it differently.