Skip to content

Latest commit

 

History

History
185 lines (150 loc) · 11.5 KB

File metadata and controls

185 lines (150 loc) · 11.5 KB

Releasing LevelCode (build → strip → sign → notarize → publish)

How to turn the source into a de-Microsoft'd, Developer-ID-signed, notarized, stapled .dmg that launches cleanly on any Mac with no Gatekeeper warning — in one command once the one-time setup is done.

Status (2026-07-01): Apple Developer Program enrolled; the Developer ID Application cert is installed (AJ27Y4Z2HS) and the levelcode-notary credential is stored. scripts/notarize.sh + scripts/levelcode.entitlements + the make-dmg.sh/build-macos.sh wiring are all in place. Without CODESIGN_IDENTITY set, make-dmg.sh still produces an ad-hoc dmg (local/tester use; other Macs need right-click→Open or xattr -dr com.apple.quarantine).

0. Where we're going

Ad-hoc (default) Developer ID + notarized (release)
Signature codesign --sign - Developer ID Application: … (TEAMID)
Hardened runtime no yes (--options runtime)
Entitlements none Electron/V8 JIT (scripts/levelcode.entitlements)
Notarized + stapled no yes (notarytool + stapler)
Proprietary MS/Copilot code stripped either way stripped (scripts/strip-proprietary.mjs)
Other Macs Gatekeeper blocks launches cleanly

Pipeline: build → de-Microsoft strip → sign inside-out (hardened runtime + entitlements) → build dmg → notarize → staple → verify → publish.

1. One-time setup (manual — only you can do this)

a. Developer ID Application certificate. ⚠️ Pick the exact type — outside-the-App-Store distribution needs "Developer ID Application" (NOT "Apple Distribution", NOT "Developer ID Installer" which is for .pkg). Easiest via Xcode → Settings → Accounts → Manage Certificates… → + → Developer ID Application (creates the private key in your login keychain). Confirm + note your Team ID:

security find-identity -v -p codesigning
# → "Developer ID Application: SERGII DEMIANCHUK (AJ27Y4Z2HS)"     ← the (…) is your TEAM ID

Back it up: Keychain Access → My Certificates → right-click → Export the .p12 somewhere safe (losing the private key means re-issuing).

b. Notary credential (stored once; no secret ever enters the repo):

# App-specific password: appleid.apple.com → Sign-In and Security → App-Specific Passwords → Generate
xcrun notarytool store-credentials "levelcode-notary" \
  --apple-id "you@example.com" --team-id "AJ27Y4Z2HS" --password "xxxx-xxxx-xxxx-xxxx"

(For CI, use an App Store Connect API key instead: --key AuthKey_XXXX.p8 --key-id KEYID --issuer ISSUER-UUID.)

c. Export for your shell (or your release env):

export CODESIGN_IDENTITY="Developer ID Application: SERGII DEMIANCHUK (AJ27Y4Z2HS)"
export NOTARY_PROFILE="levelcode-notary"

2. Cut a release

./scripts/bootstrap.sh        # first time / after an upstream bump (clone + brand + patch + npm ci)
./scripts/build-macos.sh      # → VSCode-darwin-<arch>/LevelCode.app, then auto-strips proprietary MS/Copilot code from the app
./scripts/make-dmg.sh         # de-Microsoft (defensive) → sign (Developer ID) → dmg → notarize → staple → verify

With CODESIGN_IDENTITY + NOTARY_PROFILE set, make-dmg.sh runs the whole signed+notarized pipeline; without them it's ad-hoc (unnotarized). The de-Microsoft strip runs on the built app, never the source vscode/ checkout — so dev mode (run-dev.sh → typecheck) still sees the real packages.

3. Verify it will pass Gatekeeper on a clean Mac

xcrun stapler validate LevelCode-arm64.dmg
spctl -a -t open --context context:primary-signature -v LevelCode-arm64.dmg     # → "source=Notarized Developer ID" / "accepted"

Best real test: copy the dmg to a different Mac (or fresh user account), open it, drag to Applications, double-click — it should launch with no warning.

Confirm the app carries no proprietary code:

find VSCode-darwin-arm64/LevelCode.app \( -path "*@github/copilot*" -o -path "*mxc-sdk*" \) \
  \( -name "*.node" -o -name "*.dylib" -o -name "mxc-exec-mac" \) -print   # → prints NOTHING

Build both architectures on matching hardware (Apple silicon → arm64, Intel → x64) to ship both dmgs.

4. Publish

gh release create v0.1.0 --title "LevelCode v0.1.0" --notes "First public build. …" \
  LevelCode-arm64.dmg LevelCode-x64.dmg \
  LevelCode-arm64.app.zip LevelCode-x64.app.zip

Upload both kinds: the .dmg is what humans install; the .app.zip is what the built-in updater installs (Squirrel takes a zip, never a dmg). make-dmg.sh also writes a .app.zip.sha256 next to each zip — that one is not a release asset; it stays local. See §5. Stable URL: https://github.com/levelcodeai/levelcode/releases/latest/download/LevelCode-arm64.dmg — link it from levelcode.ai/download.

5. The update feed

Publishing the GitHub release is the announcement — Levelcode::EditorReleaseFeed (thin.ly) reads releases/latest and serves /api/update/{target}/{quality}/{commit}. There is no feed file to hand-maintain per release.

Two assets, not interchangeable:

Asset Consumer
LevelCode-<arch>.dmg Humans — fresh install, drag to Applications.
LevelCode-<arch>.app.zip The built-in Squirrel updater — it installs from a zip, never a dmg.

make-dmg.sh emits the .app.zip only on the Developer-ID path, because Squirrel refuses an update whose signing identity doesn't match the running app — an ad-hoc build must never be served as an update.

Only those two files get uploaded per arch. make-dmg.sh also writes LevelCode-<arch>.app.zip.sha256, but that is a local verification convenience, not a release asset: the feed reads sha256hash from GitHub's own API-computed asset digest ("sha256:<hex>"), so nothing ever fetches a sidecar file. Uploading one is harmless — EditorReleaseFeed matches assets by exact filename and ignores anything else — just unnecessary. Use it to confirm the zip you published is the zip you built:

Z=LevelCode-arm64.app.zip
[ "$(shasum -a 256 "$Z" | cut -d' ' -f1)" = "$(cat "$Z.sha256")" ] && echo "$Z OK" || echo "$Z MISMATCH"

LEVELCODE_UPDATE_FEED (env JSON on the server) overrides the GitHub lookup and is the rollback pin: point it at the previous commit to stop a bad release propagating. Note it can't un-update anyone who already took the release.

Full contract, rollout order, and risks: docs/AUTO-UPDATE.md.

6. Troubleshooting (the ones that actually bit us are marked ⚑)

Symptom Cause / fix
security find-identity0 valid identities, cert shows "not trusted" in Keychain Access The Apple intermediate is missing. Install the Developer ID G2 CA: curl -O https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer → double-click. Do NOT hand-set trust on the leaf — leave "Use System Defaults".
notarytool store-credentialsHTTP 401 Invalid credentials You used your Apple ID password, or never generated an app-specific one. appleid.apple.com → App-Specific Passwords → Generate → paste that (with the dashes).
⚑ Notarization stuck In Progress for hours Apple-side queue/incident (check system status). The submission is server-side — Ctrl-C the --wait, then xcrun notarytool info <id> --keychain-profile levelcode-notary later and stapler staple once Accepted. Don't resubmit (duplicates just queue behind it).
notarytoolInvalid xcrun notarytool log <id> --keychain-profile levelcode-notary names the exact file/entitlement. Usually: sign with the Developer ID identity + --options runtime.
App crashes on launch (SIGKILL / code 137) after signing Missing JIT entitlements on the main app or a helper — check scripts/levelcode.entitlements is applied to the helper .apps too (notarize.sh does this).
"signature does not include a secure timestamp" Add --timestamp (needs network) — notarize.sh always does.
Gatekeeper still warns after notarizing Forgot to staple, or stapled the app but not the dmg.
Chat won't open / shortcut dead in a build levelcode.ai.focus is Ctrl+Cmd+I (moved off the Cmd+Alt+I DevTools collision); the chat also auto-reveals until the first message is sent.

7. CI build — hybrid model (.github/workflows/release.yml)

CI builds both arches; you sign locally. Your Developer ID cert never touches GitHub. CI exists to solve the awkward part — building the Intel (x64) dmg, which you can't easily do on an Apple-silicon Mac — by building each arch on its native runner (macos-14 = arm64, macos-15-intel = x64). It produces unsigned .app bundles; you do the fast, sensitive sign + notarize + staple on your machine.

Intel runner note. macos-13 (the old x64 runner) was retired 2025-12-04, so we build x64 on macos-15-intel — GitHub's last native x86_64 image. It's a premium/large runner (bills ~2× minutes) and Intel support on Actions ends Fall 2027. After that, the x64 job must cross-compile on an arm64 runner (set VSCODE_ARCH=x64/npm_config_arch=x64, rebuild native modules for x64) rather than build natively — a scripts/build-macos.sh + scripts/bootstrap.sh change, not just a runner swap.

The whole release becomes:

# 1. Kick off CI (builds both arches, ~30–60 min/arch; free on public repos, 10× minutes while private)
git tag v0.1.0 && git push --tags
#    → workflow builds → creates a DRAFT release with UNSIGNED-LevelCode-<arch>.app.zip attached

# 2. Sign + notarize LOCALLY (needs the one-time setup from §1)
gh release download v0.1.0 --pattern 'UNSIGNED-*.app.zip'
for A in arm64 x64; do
  rm -rf "VSCode-darwin-$A" && ditto -x -k "UNSIGNED-LevelCode-$A.app.zip" "VSCode-darwin-$A"
  CODESIGN_IDENTITY="Developer ID Application: SERGII DEMIANCHUK (AJ27Y4Z2HS)" \
    NOTARY_PROFILE=levelcode-notary ./scripts/make-dmg.sh "$A"     # → LevelCode-$A.dmg + LevelCode-$A.app.zip
done

# 3. Verify (§3), then attach the dmgs AND the update zips, drop the unsigned zips, and publish.
#    The .app.zip.sha256 files stay local on purpose — the feed uses GitHub's own asset digest (§5).
gh release upload v0.1.0 LevelCode-arm64.dmg LevelCode-x64.dmg \
                         LevelCode-arm64.app.zip LevelCode-x64.app.zip
# `gh release delete-asset` takes ONE asset per call — drop each unsigned zip separately (`-y` skips the prompt).
gh release delete-asset v0.1.0 UNSIGNED-LevelCode-arm64.app.zip -y
gh release delete-asset v0.1.0 UNSIGNED-LevelCode-x64.app.zip -y
gh release edit v0.1.0 --draft=false --notes-file RELEASE-NOTES.md

Notes:

  • No secrets required — the workflow is credential-free by design (that's the whole point of hybrid).
  • The dmg names (LevelCode-arm64.dmg / LevelCode-x64.dmg) are exactly what the download funnel at levelcode.ai/download/<arch> expects — don't rename them.
  • releases/latest only resolves once this is a published, non-prerelease release with both dmgs.
  • Fully-automated alternative (signing in CI) if you ever want zero local steps: base64 the .p12 Developer ID export + store it and an App Store Connect API key as secrets behind a protected Environment, import into a temp keychain at job start, and run make-dmg.sh with CODESIGN_IDENTITY set. Standard (VSCodium does this) but puts your signing identity in the cloud — the hybrid flow above deliberately doesn't.