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
23 changes: 23 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,28 @@ jobs:
tar czf "${binary}.tar.gz" "$binary"
fi
done

# Bundle archives: both binaries under their plain names
# (sysml, sysml-lsp), which is the layout a Homebrew formula or a
# PATH install expects. The per-binary archives above are kept
# unchanged so existing download links keep working.
for platform in linux-amd64 linux-arm64 darwin-amd64 darwin-arm64; do
stage="stage/${platform}"
mkdir -p "$stage"
cp "sysml-${platform}" "${stage}/sysml"
cp "sysml-lsp-${platform}" "${stage}/sysml-lsp"
tar czf "systemica-${platform}.tar.gz" -C "$stage" sysml sysml-lsp
done
mkdir -p stage/windows-amd64
cp sysml-windows-amd64.exe stage/windows-amd64/sysml.exe
cp sysml-lsp-windows-amd64.exe stage/windows-amd64/sysml-lsp.exe
(cd stage/windows-amd64 && zip -q ../../systemica-windows-amd64.zip sysml.exe sysml-lsp.exe)
rm -rf stage

# Checksums over every published archive, so a Homebrew formula
# (or a careful user) can verify a download.
sha256sum ./*.tar.gz ./*.zip | sed 's|\./||' > SHA256SUMS.txt
cat SHA256SUMS.txt

- store_artifacts:
path: dist/
Expand Down Expand Up @@ -196,6 +218,7 @@ jobs:
# Move only tarballs to a release directory
mkdir -p dist/release
mv dist/*.tar.gz dist/*.zip dist/release/ 2>/dev/null || true
mv dist/SHA256SUMS.txt dist/release/
echo "Release artifacts:"
ls -la dist/release/

Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ A complete, production-grade SysML v2 implementation in Go—providing language
wget https://github.com/Open-MBEE/Systemica/releases/latest/download/sysml-linux-amd64.tar.gz
tar xzf sysml-linux-amd64.tar.gz && sudo mv sysml-linux-amd64 /usr/local/bin/sysml

# macOS (see Quick Start for all platforms)
# macOS (Intel or Apple Silicon) — see the note below; requires the tap to exist
brew tap Open-MBEE/tap && brew install systemica
```

**With a Go toolchain (no download, never quarantined):**
```bash
go install github.com/Open-MBEE/Systemica/cmd/sysml@latest
go install github.com/Open-MBEE/Systemica/cmd/sysml-lsp@latest
```

**Or build from source:**
Expand All @@ -23,6 +30,19 @@ make build
./bin/sysml
```

> **macOS — use Homebrew.** The released binaries are not Developer ID signed or notarized,
> so a tarball downloaded *in a browser* carries `com.apple.quarantine` and Gatekeeper shows
> "cannot be opened because the developer cannot be verified". Homebrew downloads with
> `curl`, which never sets that attribute, so `brew install` avoids the prompt entirely.
> Fallback if you download the tarball directly (`curl -fL ... systemica-darwin-arm64.tar.gz`,
> then `xattr -d com.apple.quarantine`): see
> [Quick Start](docs/QUICKSTART.md#macos-gatekeeper). Signing/notarization is the eventual
> fix — [docs/MACOS_DISTRIBUTION.md](docs/MACOS_DISTRIBUTION.md).
>
> The tap is not published yet: `brew tap Open-MBEE/tap` works once the maintainer creates
> `Open-MBEE/homebrew-tap` ([how](packaging/homebrew/README.md)). Until then use `go install`
> or the direct download.

### Try it

**Interactive modeling:**
Expand Down Expand Up @@ -210,6 +230,12 @@ Pre-built binaries for Linux, macOS, and Windows are available on the [Releases
- Every commit: Build + test
- Tagged releases (`v*`): Multi-platform binaries published to GitHub Releases

**Release artifacts:** per-binary archives (`sysml-<os>-<arch>.tar.gz`,
`sysml-lsp-<os>-<arch>.tar.gz`), `systemica-<os>-<arch>.tar.gz` bundles containing both
binaries, and `SHA256SUMS.txt`. macOS binaries are not Developer ID signed or notarized and
Windows binaries are not Authenticode signed — see
[docs/MACOS_DISTRIBUTION.md](docs/MACOS_DISTRIBUTION.md).

## Building

```bash
Expand Down
215 changes: 215 additions & 0 deletions docs/MACOS_DISTRIBUTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
# macOS Distribution: Gatekeeper, Signing, and Notarization

Status: **decision record** — records what was measured, what was changed, and what a
maintainer must provide to remove the macOS security prompt entirely.
Last updated: 2026-08-06.

## 1. The symptom

A user who downloads `sysml-darwin-arm64.tar.gz` (or the Intel/`sysml-lsp` equivalents)
from GitHub Releases in a browser, extracts it, and runs it gets:

> "sysml-darwin-arm64" cannot be opened because the developer cannot be verified.

and has to go to System Settings → Privacy & Security → **Open Anyway**.

## 2. Root cause: quarantine, not a missing signature

There are two distinct macOS mechanisms that can block a downloaded binary. Only one of
them is biting us.

### 2.1 Executability (code signature) — already satisfied

macOS on Apple silicon refuses to execute arm64 code with no valid signature attached
([Apple Platform Security, "Rosetta 2 on a Mac with Apple silicon"](https://support.apple.com/guide/security/rosetta-2-on-a-mac-with-apple-silicon-secebb113be1/web)).
An *ad-hoc* signature is enough to satisfy this.

Go's linker emits that ad-hoc signature itself, including when cross-compiling from Linux
(see [golang/go#42684](https://github.com/golang/go/issues/42684) and
[`cmd/internal/codesign`](https://go.dev/src/cmd/internal/codesign/codesign.go)).

Measured on a Linux host with Go 1.25.0, cross-compiling this repository's `cmd/sysml`
exactly as the `build-release` CircleCI job does, then parsing the Mach-O load commands:

| Target | `LC_CODE_SIGNATURE` | CodeDirectory flags |
| --- | --- | --- |
| `GOOS=darwin GOARCH=arm64` | present (57 KB blob, `0xfade0cc0` superblob) | `0x20002` = `adhoc, linker-signed`, identifier `a.out` |
| `GOOS=darwin GOARCH=amd64` | absent | n/a (x86-64 does not require one) |

Those are the same flags `codesign -dv` reports as `flags=0x20002(adhoc,linker-signed)` in
the Go issue above. **Untested claim (no macOS host available here):** that the binary
therefore launches on Apple silicon once quarantine is cleared — this follows from the
Apple/Go documentation cited, but it was not executed on a Mac as part of this work.

Consequence: adding our own ad-hoc signing step (`codesign -s -`) would change nothing. It
does not, and cannot, satisfy Gatekeeper for a quarantined download — an ad-hoc signature
carries no Developer ID and no notarization ticket.

### 2.2 Gatekeeper / quarantine — this is the prompt users see

Programs that download files on behalf of a user (Safari, Chrome, Mail, and anything else
that opts into `LSFileQuarantineEnabled`) tag the downloaded file with the
`com.apple.quarantine` extended attribute. Archive utilities propagate that attribute to
the files they extract, so the extracted `sysml-darwin-arm64` is quarantined too. On first
launch, Gatekeeper evaluates a quarantined binary and blocks anything that is not signed
with a Developer ID *and* notarized — which is exactly the prompt in §1
(Apple: ["Safely open apps on your Mac"](https://support.apple.com/en-us/102445),
["Notarizing macOS software before distribution"](https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution)).

Two consequences worth knowing:

- The attribute is set by the *downloader*, not by the file. `curl`, `wget`, `git`, and
`go install` do not set it, so binaries obtained those ways never trigger the prompt.
This is why `brew install` is prompt-free (Homebrew applies quarantine only to *casks* —
hence the cask-only `--no-quarantine` flag — not to formulae).
- `xattr -d com.apple.quarantine <file>` removes it, which is why that workaround "works".

## 3. Options considered

| Option | Removes the prompt for a browser download? | Cost | Requires credentials we don't have |
| --- | --- | --- | --- |
| Developer ID signing + notarization (stapling only if we ship `.pkg`/`.dmg`, see §5.3) | **Yes** — the only option that does | $99/yr Apple Developer Program + macOS CI minutes | Yes |
| Homebrew tap/formula (**accepted stopgap**) | Yes, for users who install via `brew` (curl download, no quarantine) | Free (a tap repo) | No, but needs a new repo |
| `go install .../cmd/sysml@latest` | Yes (built locally, never quarantined) | Free; user needs a Go toolchain | No |
| Ad-hoc signing in CI (`codesign -s -`) | **No** — see §2.1; also already effectively done by the Go linker | Needs a macOS runner | No |
| Documented `xattr -d com.apple.quarantine` | Yes, but by having the user disable a security check | Free | No |

Notes on the last row: telling users to strip quarantine is a poor default because it
trains them to bypass Gatekeeper on any download, and it gives them no way to tell our
archive from a tampered one. It is documented in `docs/QUICKSTART.md` as an escape hatch
*next to* the checksum verification step, not as the recommended path.

## 4. What this change actually lands

1. **Docs** (`README.md`, `docs/QUICKSTART.md`): a macOS section that recommends
`brew tap Open-MBEE/tap && brew install systemica`, with `curl`/`go install` and the
quarantine-clearing + checksum-verification commands as fallbacks.
2. **Release artifacts** (`.circleci/config.yml`): in addition to the existing per-binary
archives (unchanged, so existing links keep working), the `build-release` job now
publishes `systemica-<os>-<arch>.tar.gz`/`.zip` bundles containing `sysml` and
`sysml-lsp` under their plain names, plus a `SHA256SUMS.txt` covering every archive.
Both are prerequisites for a Homebrew formula and for users who want to verify a
download.
3. **Homebrew formula** (`packaging/homebrew/Formula/systemica.rb`) plus
`scripts/render-homebrew-formula.sh`, which renders it for a tag from `SHA256SUMS.txt`.
Homebrew is the accepted macOS install path until notarization exists. **No tap
repository was created**: the maintainer creates `Open-MBEE/homebrew-tap` and pushes the
rendered formula — steps in `packaging/homebrew/README.md`.

## 5. Decision record: notarization

**Decision: not implemented in this change, because it cannot be done without credentials
the maintainer must obtain. Homebrew is the accepted stopgap in the meantime** (maintainer
decision, 2026-08-06). Nothing about the change above conflicts with adding it later; the
notarization step would run after the existing build step and re-tar the signed binaries.

### 5.1 What the maintainer must provide

| # | Item | How to obtain | Where it goes |
| --- | --- | --- | --- |
| 1 | Apple Developer Program membership | https://developer.apple.com/programs/ — $99/year, organization enrollment needs a D-U-N-S number and takes days-to-weeks | prerequisite for 2–4 |
| 2 | **Developer ID Application** certificate (not "Apple Development", not "Mac App Distribution") | Certificates, Identifiers & Profiles → create a CSR on a Mac → download `.cer` → export as `.p12` with a password | CI secret `MACOS_CERT_P12` (base64) + `MACOS_CERT_PASSWORD` |
| 3 | App Store Connect **API key** for `notarytool` (preferred over an Apple ID + app-specific password: no 2FA interaction, revocable) | App Store Connect → Users and Access → Integrations → Keys → download the `.p8` **once** | CI secrets `APPLE_API_KEY_P8` (base64), `APPLE_API_KEY_ID`, `APPLE_API_ISSUER_ID` |
| 4 | Team ID | Apple Developer account membership page | CI secret `APPLE_TEAM_ID` |
| 5 | A decision on where it runs (see §5.2) | — | — |

Without item 2 specifically, nothing works: only a Developer ID certificate produces a
signature Gatekeeper accepts for software distributed outside the App Store.

### 5.2 Which CI provider it would run on

`codesign`, `notarytool`, and `stapler` are Xcode command-line tools and only run on macOS.
The current release pipeline is CircleCI (`.circleci/config.yml`, `release` workflow on
`v*` tags), which cross-compiles on the Linux `cimg/go:1.25` executor; GitHub Actions is
used only for PR checks (`.github/workflows/pr.yml`), which explicitly does not mirror the
release workflow.

- **CircleCI macOS VM**: `macos: {xcode: ...}` with `resource_class: m4pro.medium` costs
**200 credits/minute**, and macOS VMs are not available on the Free plan; CircleCI's
400,000 free open-source credits/month apply to Linux, Arm, and Docker only, so macOS
jobs draw on the 30,000-credit free allowance
([CircleCI price list](https://circleci.com/pricing/price-list/),
[pricing](https://circleci.com/pricing/)).
- **GitHub Actions `macos-latest`**: a standard GitHub-hosted runner, and standard runners
are **free for public repositories**
([GitHub Actions billing](https://docs.github.com/en/billing/concepts/product-billing/github-actions)).
(`macos-*-large`/`-xlarge` are *larger* runners and are always billed — do not use them.)

**Recommendation:** add a tag-triggered GitHub Actions release workflow on `macos-latest`
for the darwin artifacts only, and keep CircleCI for everything else — or move the whole
release there. Doing it on CircleCI is functionally fine but costs credits and, on the Free
plan, is not available at all. This only stays free if the repository is public.

### 5.3 What the pipeline steps would look like

Sketch, not tested (no macOS host available here); step order and flags follow Apple's
["Customizing the notarization workflow"](https://developer.apple.com/documentation/security/customizing-the-notarization-workflow):

```yaml
# .github/workflows/release-macos.yml (sketch), on: push: tags: ['v*']
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version-file: go.mod }

# 1. Import the Developer ID certificate into a throwaway keychain.
- run: |
echo "$MACOS_CERT_P12" | base64 -d > cert.p12
security create-keychain -p "$KEYCHAIN_PW" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PW" build.keychain
security import cert.p12 -k build.keychain -P "$MACOS_CERT_PASSWORD" \
-T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PW" build.keychain
rm cert.p12

# 2. Build both darwin architectures.
- run: |
GOOS=darwin GOARCH=arm64 make build-sysml VERSION="$GITHUB_REF_NAME" ...
GOOS=darwin GOARCH=amd64 make build-sysml VERSION="$GITHUB_REF_NAME" ...
# ...and build-lsp for both

# 3. Sign with the hardened runtime (notarization requires --options runtime).
- run: |
codesign --force --timestamp --options runtime \
--sign "Developer ID Application: <NAME> ($APPLE_TEAM_ID)" \
dist/sysml-darwin-arm64 dist/sysml-darwin-amd64 \
dist/sysml-lsp-darwin-arm64 dist/sysml-lsp-darwin-amd64
codesign --verify --strict --verbose=2 dist/sysml-darwin-arm64

# 4. Notarize. notarytool accepts .zip/.pkg/.dmg, not .tar.gz, so zip for submission.
- run: |
echo "$APPLE_API_KEY_P8" | base64 -d > key.p8
ditto -c -k --keepParent dist/sysml-darwin-arm64 notarize.zip
xcrun notarytool submit notarize.zip --wait \
--key key.p8 --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER_ID"
rm key.p8

# 5. Tar the *signed* binaries and publish. Do not modify a binary after signing —
# any edit (including strip) invalidates the signature.
```

Two constraints that shape the above:

- **Stapling does not apply to bare executables.** Apple: "Although tickets are created for
standalone binaries, it's not currently possible to staple tickets to them"
(["Customizing the notarization workflow"](https://developer.apple.com/documentation/security/customizing-the-notarization-workflow)).
Gatekeeper then fetches the ticket online at first launch. Getting a *stapled* artifact
would mean shipping a `.pkg` or `.dmg` instead of a tarball — a larger packaging change,
worth doing only if offline first launch matters.
- **Signing must happen after the last modification** of the binary, and the tarball must
be built from the signed files.

### 5.4 Recurring cost

$99/year (Apple Developer Program) plus CI time: on GitHub Actions `macos-latest` for a
public repository, $0; on CircleCI, ~200 credits/minute of macOS VM time per release.

## 6. Windows

Out of scope for this change, and deliberately not fixed here: the Windows artifacts are
unsigned too, so SmartScreen shows "Windows protected your PC" for downloaded
`sysml-windows-amd64.exe`. The remedy (an Authenticode / EV code-signing certificate from a
commercial CA, typically a few hundred USD per year) is a separate decision with a separate
cost, and SmartScreen reputation also builds over download volume.
Loading
Loading