# Release and distribution

> Ship InkIt: tools/make-dmg.sh, tools/make-appcast.sh, tools/publish-release.sh, Sparkle SUFeedURL and UpdateManager custom pill flow, version bump via tools/bump-version.sh, and GitHub release assets (InkIt.dmg, appcast.xml).

- Repository: cartesia-ai/InkIt
- GitHub: https://github.com/cartesia-ai/InkIt
- Human docs: https://grok-wiki.com/public/docs/cartesia-ai-inkit-18975554254b
- Complete Markdown: https://grok-wiki.com/public/docs/cartesia-ai-inkit-18975554254b/llms-full.txt

## Source Files

- `tools/publish-release.sh`
- `tools/make-dmg.sh`
- `tools/make-appcast.sh`
- `tools/bump-version.sh`
- `InkIt/UpdateManager.swift`
- `project.yml`
- `Config/Sparkle.xcconfig`

---

---
title: "Release and distribution"
description: "Ship InkIt: tools/make-dmg.sh, tools/make-appcast.sh, tools/publish-release.sh, Sparkle SUFeedURL and UpdateManager custom pill flow, version bump via tools/bump-version.sh, and GitHub release assets (InkIt.dmg, appcast.xml)."
---

InkIt ships as a notarized `InkIt.dmg` on GitHub Releases at `cartesia-ai/InkIt`, with Sparkle auto-updates driven by `appcast.xml` hosted on the same release. Maintainers bump version in `project.yml`, build and staple the DMG with `tools/make-dmg.sh`, sign the appcast with `tools/make-appcast.sh`, and publish assets with `tools/publish-release.sh`. Installed apps poll `SUFeedURL` and surface update state through `UpdateManager` and the Home `UpdatePill` instead of Sparkle's default windows.

## Release pipeline

```text
project.yml (version) ──► bump-version.sh
        │
        ▼
make-dmg.sh ──► build/dist/InkIt.dmg (notarized, stapled)
        │
        ├──► make-appcast.sh ──► build/dist/appcast.xml
        │
        ▼
publish-release.sh ──► GitHub Release vX.Y.Z
        │                 ├── InkIt.dmg          (stable README download)
        │                 ├── InkIt_X.Y.Z_arm64.dmg
        │                 └── appcast.xml      (Sparkle feed)
        ▼
Installed InkIt ──► SUFeedURL ──► UpdateManager ──► UpdatePill
```

CI on `main` runs tests and a Release build but does not produce distributable artifacts. Release work is maintainer-local and requires a Developer ID certificate, notary credentials, and the `gh` CLI.

## Version numbers

`project.yml` is the source of truth for both marketing and build numbers. XcodeGen regenerates `InkIt/Info.plist` from the `properties` block on every `xcodegen generate`, so editing `Info.plist` alone does not survive the next build.

| Key | Role | Sparkle constraint |
|-----|------|-------------------|
| `CFBundleShortVersionString` | User-visible version (e.g. `0.1.2`) | Appears in update UI |
| `CFBundleVersion` | Monotonic build number (e.g. `4`) | Must strictly increase every release |

<ParamField body="level" type="patch | minor | major" default="patch">
Semantic bump level passed to `tools/bump-version.sh`.
</ParamField>

```bash
./tools/bump-version.sh          # patch: 0.1.2 → 0.1.3, build 4 → 5
./tools/bump-version.sh minor    # 0.1.2 → 0.2.0
./tools/bump-version.sh major    # 0.1.2 → 1.0.0
```

The script updates `project.yml` and mirrors values into the committed `InkIt/Info.plist`, prints the new marketing version on stdout, and refuses to run if git tag `v<new>` already exists. It does not commit — commit the version bump before building.

<Warning>
If `project.yml` lags behind published tags, `bump-version.sh` exits with an error. Set the version past the latest release tag manually, then bump again.
</Warning>

## One-time maintainer setup

<Steps>
<Step title="Configure Developer ID signing">

Copy `Config/Signing.local.xcconfig.example` to `Config/Signing.local.xcconfig` (gitignored) and set your Apple Team ID:

```bash
cp Config/Signing.local.xcconfig.example Config/Signing.local.xcconfig
```

```xcconfig
DEVELOPMENT_TEAM = YOUR_TEAM_ID
CODE_SIGN_STYLE = Manual
CODE_SIGN_IDENTITY = Developer ID Application
OTHER_CODE_SIGN_FLAGS = --timestamp --options=runtime
```

Without this file, Release builds fall back to ad-hoc signing in `Config/Signing.xcconfig` — fine for local dev, not for distribution.

</Step>

<Step title="Store notarization credentials">

```bash
xcrun notarytool store-credentials inkit-notary \
  --apple-id <your-apple-id-email> \
  --team-id <your-team-id> \
  --password <app-specific-password>
```

`tools/make-dmg.sh` expects the Keychain profile name `inkit-notary`.

</Step>

<Step title="Install build tools">

```bash
brew install xcodegen create-dmg gh
```

</Step>

<Step title="Configure Sparkle EdDSA keys">

Generate Sparkle's EdDSA keypair on the release maintainer's Mac (see [Sparkle documentation](https://sparkle-project.org/documentation/)). Paste the **public** key into `Config/Sparkle.xcconfig`:

```xcconfig
SPARKLE_PUBLIC_ED_KEY = <your-public-key>
```

The private key stays in the maintainer's login Keychain and is used only when `generate_appcast` signs appcast entries. `Config/Signing.xcconfig` includes `Sparkle.xcconfig`, so `SUPublicEDKey` resolves at build time via `$(SPARKLE_PUBLIC_ED_KEY)` in `project.yml`.

</Step>
</Steps>

## Build the DMG

`tools/make-dmg.sh` produces `build/dist/InkIt.dmg` — a notarized, stapled disk image ready to upload.

<Steps>
<Step title="Generate project and build Release">

The script runs `xcodegen generate`, then `xcodebuild` with `-configuration Release` and `-derivedDataPath build`. Output lands at `build/Build/Products/Release/InkIt.app`.

</Step>

<Step title="Verify code signature">

`codesign --verify --deep --strict` runs on the `.app`. The script also scans Sparkle nested binaries (`Updater.app`, `Autoupdate`, `*.xpc`) for ad-hoc signatures — a fast-fail before the ~5 minute notarization round-trip. Ad-hoc Sparkle helpers are re-signed by the **Deep-sign Sparkle helpers** post-build script in `project.yml` when a Developer ID identity is present.

</Step>

<Step title="Package and sign the DMG">

The app is staged, background art is regenerated via `tools/generate_dmg_background.swift`, and `create-dmg` lays out a 600×400 window with the app icon and Applications drop link. The resulting DMG is signed with `Developer ID Application`.

</Step>

<Step title="Notarize and staple">

```bash
xcrun notarytool submit build/dist/InkIt.dmg --keychain-profile inkit-notary --wait
xcrun stapler staple build/dist/InkIt.dmg
xcrun stapler validate build/dist/InkIt.dmg
```

On failure, the script fetches the notarization log by submission ID.

</Step>
</Steps>

<RequestExample>

```bash
./tools/make-dmg.sh
```

</RequestExample>

<ResponseExample>

```text
Notarized DMG ready: build/dist/InkIt.dmg
Verify on a clean machine: spctl -a -t open --context context:primary-signature build/dist/InkIt.dmg
```

</ResponseExample>

## Generate the Sparkle appcast

Run `tools/make-appcast.sh` after `make-dmg.sh`. It copies the DMG into `build/dist/appcast/`, invokes Sparkle's `generate_appcast` (resolved from `build/**/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_appcast`), and writes `build/dist/appcast.xml`.

<ParamField body="dmg_path" type="string" default="build/dist/InkIt.dmg">
Optional first argument — path to the DMG to include in the appcast.
</ParamField>

Critical configuration:

| Setting | Value | Why |
|---------|-------|-----|
| `--download-url-prefix` | `https://github.com/cartesia-ai/InkIt/releases/latest/download/` | **Trailing slash required.** Without it, RFC 3986 path joining collapses `.../latest/download` + `InkIt.dmg` into `.../latest/InkIt.dmg`, which 404s and silently breaks auto-update. |
| `--maximum-versions` | `1` | Feed carries only the latest release entry. |
| `SPARKLE_PUBLIC_ED_KEY` | from `Config/Sparkle.xcconfig` | Embedded in the built app's `SUPublicEDKey`; appcast signatures must match. |

The generated appcast enclosure URL must resolve to:

```text
https://github.com/cartesia-ai/InkIt/releases/latest/download/InkIt.dmg
```

`publish-release.sh` validates this before upload.

## Publish to GitHub Releases

`tools/publish-release.sh` creates the GitHub release from pre-built artifacts. It handles tags, asset upload, and preflight checks — not building.

<ParamField body="version" type="string">
Marketing version (e.g. `0.2.0`; leading `v` tolerated). Omit to read `CFBundleShortVersionString` from `InkIt/Info.plist` — the version the DMG was built with.
</ParamField>

<ParamField body="--draft" type="flag">
Stage as a draft release for review before publishing. Default: publish immediately.
</ParamField>

<Steps>
<Step title="Preflight">

- `gh` CLI installed and authenticated
- `build/dist/InkIt.dmg` exists
- Tag `v<version>` does not already exist
- DMG passes `xcrun stapler validate` and `spctl -a -t open`
- Git state is clean (or you confirm continuing with uncommitted changes)
- Local `main` matches `origin/main` (or you confirm continuing)

</Step>

<Step title="Assemble assets">

| Asset | Purpose |
|-------|---------|
| `InkIt_<version>_arm64.dmg` | Versioned artifact on the releases page |
| `InkIt.dmg` | Stable name — README download button and appcast enclosure |
| `appcast.xml` | Sparkle auto-update feed (optional but required for existing users to update) |

If `appcast.xml` is missing, the script prompts to confirm publishing without an auto-update feed.

</Step>

<Step title="Create release">

```bash
gh release create v<version> <assets...> \
  --repo cartesia-ai/InkIt \
  --target main \
  --title "InkIt <version>" \
  --generate-notes
```

</Step>

<Step title="Verify">

```bash
gh release view v<version> --repo cartesia-ai/InkIt --json assets -q '.assets[].name'
```

Published download URL for new installs:

```text
https://github.com/cartesia-ai/InkIt/releases/latest/download/InkIt.dmg
```

</Step>
</Steps>

<RequestExample>

```bash
./tools/bump-version.sh patch
git add project.yml InkIt/Info.plist && git commit -m "Bump version to 0.1.3"
git push origin main

./tools/make-dmg.sh
./tools/make-appcast.sh
./tools/publish-release.sh          # reads version from Info.plist
# or
./tools/publish-release.sh 0.1.3 --draft
```

</RequestExample>

<Note>
`publish-release.sh` warns if the requested version differs from `Info.plist` — the DMG contents reflect whatever version was baked in at build time, not the tag you pass at publish time.
</Note>

## Sparkle configuration in the app

Sparkle is wired through `Info.plist` properties generated from `project.yml`:

| Plist key | Value |
|-----------|-------|
| `SUFeedURL` | `https://github.com/cartesia-ai/InkIt/releases/latest/download/appcast.xml` |
| `SUPublicEDKey` | `$(SPARKLE_PUBLIC_ED_KEY)` from `Config/Sparkle.xcconfig` |
| `SUEnableAutomaticChecks` | `true` |

`UpdateManager` starts Sparkle only when both `SUFeedURL` and `SUPublicEDKey` resolve to non-empty strings without unresolved `$(...)` build-setting placeholders. `AppDelegate.applicationDidFinishLaunching` calls `UpdateManager.shared.start()`.

## Custom update pill flow

`UpdateManager` implements `SPUUserDriver` to route all update UI through InkIt's Home pill (`UpdatePill`) instead of Sparkle's standard windows. The menu item **Check for Updates…** uses the same driver; it adds `NSAlert` dialogs only for user-initiated "up to date" and error cases the pill cannot show.

```mermaid
stateDiagram-v2
    [*] --> idle
    idle --> available: background check finds update
    available --> updating: user taps "Update now"
    updating --> ready: download and extract complete
    ready --> idle: user taps "Restart now" → relaunch
    available --> idle: dismiss / error
    updating --> idle: error
    idle --> idle: check finds no update
```

| Phase | Pill label | Action |
|-------|-----------|--------|
| `.idle` | Hidden | No update pending |
| `.available` | "New app version available" | **Update now** → `installNow()` invokes Sparkle's `updateFoundReply(.install)` |
| `.updating` | "Updating…" + spinner | Silent download and extraction in progress |
| `.ready` | "Update ready" | **Restart now** → `restartNow()` invokes `installReply(.install)` |

`UpdatePill` sits bottom-center on Home as a floating, undismissable overlay. Sparkle's permission prompt is auto-accepted with automatic checks enabled and no system profile sent. Release notes windows are suppressed (`showUpdateReleaseNotes` is a no-op).

<Tip>
After publishing, verify auto-update end-to-end: install the previous release, launch InkIt, confirm the pill appears when a newer `appcast.xml` is live, and complete the download → restart cycle.
</Tip>

## Failure modes

| Symptom | Likely cause | Fix |
|---------|-------------|-----|
| `Config/Signing.local.xcconfig missing` | No Developer ID config | Copy and fill the example xcconfig |
| Ad-hoc nested binaries in Sparkle | Deep-sign post-build script skipped or failed | Rebuild Release with Developer ID; check `project.yml` post-build script |
| Notarization rejected | Unsigned nested code, entitlements, or hardened runtime issue | Read `notarytool log`; fix signing, rebuild DMG |
| `appcast enclosure URL is wrong` | Missing trailing slash on `--download-url-prefix` | Regenerate with `tools/make-appcast.sh` |
| Auto-update 404s silently | Appcast points to `.../latest/InkIt.dmg` instead of `.../latest/download/InkIt.dmg` | Same — regenerate appcast with correct prefix |
| `tag vX.Y.Z already exists` | Version not bumped | Run `./tools/bump-version.sh` |
| Pill never appears | Sparkle keys or feed URL unresolved in built app | Confirm `SPARKLE_PUBLIC_ED_KEY` in `Config/Sparkle.xcconfig` and rebuild |
| Gatekeeper rejects DMG | Not stapled or unsigned | Re-run `make-dmg.sh`; validate with `spctl` |

## GitHub release assets summary

:::files
build/dist/
├── InkIt.dmg              # notarized DMG (uploaded as stable + versioned copy)
├── appcast.xml            # signed Sparkle feed
└── appcast/
    ├── InkIt.dmg          # input copy for generate_appcast
    └── appcast.xml        # intermediate output
:::

On GitHub, three assets attach to each release:

- **`InkIt.dmg`** — stable download URL used by the README button and Sparkle enclosure
- **`InkIt_<version>_arm64.dmg`** — versioned archive on the releases page
- **`appcast.xml`** — polled by installed apps via `SUFeedURL`

## Related pages

<CardGroup>
<Card title="Build from source" href="/build-from-source">
Local Release builds, ad-hoc vs Developer ID signing, Sparkle deep-sign post-build script, and installing to `/Applications`.
</Card>
<Card title="Installation" href="/installation">
End-user install from the release DMG and runtime prerequisites.
</Card>
<Card title="Testing and CI" href="/testing-and-ci">
CI test and Release build steps — what automation covers vs maintainer-only release scripts.
</Card>
<Card title="Overview" href="/overview">
Product surface including Sparkle updates in the feature set.
</Card>
</CardGroup>
