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 thelevelcode-notarycredential is stored.scripts/notarize.sh+scripts/levelcode.entitlements+ themake-dmg.sh/build-macos.shwiring are all in place. WithoutCODESIGN_IDENTITYset,make-dmg.shstill produces an ad-hoc dmg (local/tester use; other Macs need right-click→Open orxattr -dr com.apple.quarantine).
| 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.
a. Developer ID Application certificate. .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 IDBack 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"./scripts/bootstrap.sh # first time / after an upstream bump (clone + brand + patch + npm ci)
./scripts/build-macos.sh # → VSCode-darwin-<arch>/LevelCode.app; strips proprietary MS/Copilot code, then stamps the release version (§3)
./scripts/make-dmg.sh # de-Microsoft (defensive) → sign (Developer ID) → dmg → notarize → staple → verifyWith 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.
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 NOTHINGConfirm the app reports its own version rather than the Code-OSS base — this fails silently, so check it every release:
python3 -c "import json;d=json.load(open('VSCode-darwin-arm64/LevelCode.app/Contents/Resources/app/product.json'));print(d.get('levelcodeVersion'),'|',d.get('version'),'|',d.get('commit','')[:7])"
# → 0.8.1 | 1.126.0 | 07b7341
# ^ the release version ^ MUST stay 1.126.0 (extensions' engines.vscode is checked against it)A missing levelcodeVersion means build-macos.sh could not resolve a tag (git describe --tags
failed) and fell back — the update tooltip and About will then report 1.126.0, the upstream base, which
is exactly the confusion this stamp exists to prevent. The build log distinguishes the two:
| Log line | Meaning |
|---|---|
[build] Stamping the LevelCode release version (v0.8.1) … |
Good. |
[build] WARN: no reachable git tag — skipping the release-version stamp. |
No stamp — fix before publishing. |
An off-tag build is stamped 0.8.1-3-g1a2b3c4 on purpose (see scripts/stamp-levelcode-version.mjs): a
build that isn't a release must not impersonate one. A release build sits on the tag and gets a clean
0.8.1, so a suffix here means you are not building what you think you are.
Build both architectures on matching hardware (Apple silicon → arm64, Intel → x64) to ship both dmgs.
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.zipUpload 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.
⚠️ Auto-update is LIVE — publishing is deploying.LEVELCODE_UPDATE_FEED_SIGNED=1is set in production, so a published release is no longer just announced: the built-in Squirrel updater downloads and installs it on every existing install at that install's next check. There is no staged rollout and no percentage gate. The rollback lever (below) stops it propagating further but cannot un-update anyone who already took it. Finish §3 on a real Mac before--draft=false.This changed with v0.8.0 — the first release to carry signed
.app.zipassets. Everything published before that stayed notify-only regardless of the flag, because the feed refuses to hand Squirrel a release that has no installable asset for its arch.
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.
Confirm the feed picked the release up (it caches for 5 minutes):
curl -s -H "User-Agent: LevelCode Updater" \
https://levelcode.ai/api/update/darwin-arm64/stable/deadbeef | python3 -m json.tool
# url must end in LevelCode-arm64.app.zip (NOT a /releases/tag/ page), and productVersion must be the new oneTwo 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.
| Symptom | Cause / fix |
|---|---|
⚑ security find-identity → 0 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-credentials → HTTP 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). |
notarytool → Invalid |
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. |
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 onmacos-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 (setVSCODE_ARCH=x64/npm_config_arch=x64, rebuild native modules for x64) rather than build natively — ascripts/build-macos.sh+scripts/bootstrap.shchange, 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.mdNotes:
- 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 atlevelcode.ai/download/<arch>expects — don't rename them. releases/latestonly 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
.p12Developer 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 runmake-dmg.shwithCODESIGN_IDENTITYset. Standard (VSCodium does this) but puts your signing identity in the cloud — the hybrid flow above deliberately doesn't.