# 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-clickOpen 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:
```bash
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):
```bash
# 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):
```bash
export CODESIGN_IDENTITY="Developer ID Application: SERGII DEMIANCHUK (AJ27Y4Z2HS)"
export NOTARY_PROFILE="levelcode-notary"
```
## 2. Cut a release
```bash
./scripts/bootstrap.sh # first time / after an upstream bump (clone + brand + patch + npm ci)
./scripts/build-macos.sh # VSCode-darwin-/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 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
```bash
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:
```bash
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
```
Confirm the app reports **its own** version rather than the Code-OSS base this **fails silently**, so
check it every release:
```bash
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.
## 4. Publish
```bash
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
> **Auto-update is LIVE publishing is deploying.** `LEVELCODE_UPDATE_FEED_SIGNED=1` is 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.zip` assets. 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):
```bash
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 one
```
Two assets, **not** interchangeable:
| Asset | Consumer |
| --- | --- |
| `LevelCode-.dmg` | Humans fresh install, drag to Applications. |
| `LevelCode-.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-.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:"`), 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:
```bash
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-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](https://developer.apple.com/system-status/)). The submission is server-side `Ctrl-C` the `--wait`, then `xcrun notarytool info --keychain-profile levelcode-notary` later and `stapler staple` once `Accepted`. Don't resubmit (duplicates just queue behind it). |
| `notarytool` **Invalid** | `xcrun notarytool log --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 `.app`s 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:
```sh
# 1. Kick off CI (builds both arches, ~3060 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-.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/` 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.