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
4 changes: 2 additions & 2 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
blank_issues_enabled: false
contact_links:
- name: Question or discussion
url: https://github.com/capsule-app/capsule/discussions
url: https://github.com/capsule-native/capsule/discussions
about: For usage questions and open-ended discussion, please use Discussions.
- name: Security vulnerability
url: https://github.com/capsule-app/capsule/security/advisories/new
url: https://github.com/capsule-native/capsule/security/advisories/new
about: Report security issues privately via a GitHub Security Advisory — do not open a public issue.
15 changes: 15 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Verify tag matches app version
if: startsWith(github.ref, 'refs/tags/')
run: |
tag="${GITHUB_REF_NAME#v}"
appver="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' App/Info.plist)"
if [ "$tag" != "$appver" ]; then
echo "::error::Tag v$tag does not match App/Info.plist CFBundleShortVersionString ($appver). Bump Info.plist and re-tag so the packaged artifacts carry the right version."
exit 1
fi
echo "Tag v$tag matches app version $appver."

- name: Select newest available Xcode
run: |
latest="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort -V | tail -1)"
Expand Down Expand Up @@ -72,6 +83,10 @@ jobs:

- name: Build, sign, notarize, staple, package, appcast
if: steps.signing.outputs.enabled == 'true'
env:
# Point the appcast's enclosure URLs at this tag's Release assets. Empty on non-tag
# (manual) runs, which makes generate_appcast fall back to relative filenames.
RELEASE_DOWNLOAD_URL_PREFIX: ${{ startsWith(github.ref, 'refs/tags/') && format('https://github.com/{0}/releases/download/{1}/', github.repository, github.ref_name) || '' }}
run: make release

# ── Unsigned path (forks / missing secrets) ──────────────────────────────
Expand Down
15 changes: 8 additions & 7 deletions App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,22 @@
<string>Capsule uses the local network to communicate with container runtimes and services on this Mac.</string>

<!-- Sparkle auto-updates (unsandboxed, Developer-ID build). See Scripts/release/README.md. -->
<!-- The appcast the updater polls. RELEASE TODO: host this at your real URL. -->
<!-- The appcast the updater polls. Served from the capsule-native.github.io Pages repo;
the release workflow publishes appcast.xml there. -->
<key>SUFeedURL</key>
<string>https://capsule-app.github.io/capsule/appcast.xml</string>
<string>https://capsule-native.github.io/appcast.xml</string>
<!-- Check for updates in the background by default; the user can turn this off in
Settings ▸ Updates. -->
<key>SUEnableAutomaticChecks</key>
<true/>
<!-- Once per day (seconds). -->
<key>SUScheduledCheckInterval</key>
<integer>86400</integer>
<!-- EdDSA public key that verifies every downloaded update. RELEASE TODO: replace this
placeholder with the real public key printed by Sparkle's `generate_keys` (the private
key stays in the release runner's keychain and NEVER enters the repo). A placeholder is
safe: Sparkle rejects every feed until a matching key pair signs it. -->
<!-- EdDSA public key that verifies every downloaded update. The matching private key was
generated by Sparkle's `generate_keys --account capsule-native`; it lives in the
maintainer's login keychain and in the CI secret SPARKLE_ED_PRIVATE_KEY, and NEVER
enters the repo. Sparkle rejects any update not signed by this key pair. -->
<key>SUPublicEDKey</key>
<string>REPLACE_WITH_SPARKLE_EDDSA_PUBLIC_KEY</string>
<string>SaJ9avcN8A1w5xrK7XmVidxCXX6swF0N/wsRfMIadcc=</string>
</dict>
</plist>
87 changes: 51 additions & 36 deletions Scripts/release/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,37 @@ Capsule ships as a **Developer ID–signed, notarized, stapled** `.app`, distrib
for humans and a zip for Sparkle auto-updates. The Mac App Store is out of scope (the app is
unsandboxed to drive the `container` CLI).

## One-time setup

1. **Developer ID** — a “Developer ID Application” certificate in your login keychain, and your
Team ID.
2. **Notary credentials** — store them once as a keychain profile:
```sh
xcrun notarytool store-credentials capsule-notary \
--apple-id you@example.com --team-id ABCDE12345 \
--password <app-specific-password>
```
3. **Sparkle signing keys** — generate the EdDSA key pair once:
```sh
# generate_keys ships with Sparkle (find it under .build/artifacts or set $SPARKLE_BIN)
./generate_keys
```
Paste the printed **public** key into `App/Info.plist` → `SUPublicEDKey`. The **private** key
stays in your keychain and never enters the repo.

## Environment
## How updates reach users

| Var | Required | Meaning |
| --- | --- | --- |
| `TEAM_ID` | yes | Apple Developer Team ID |
| `NOTARY_PROFILE` | yes | notarytool keychain profile name (e.g. `capsule-notary`) |
| `DEVELOPER_ID_APP` | optional | explicit signing identity string |
| `SPARKLE_BIN` | optional | dir containing `generate_appcast` / `generate_keys` |
| `SPARKLE_ED_KEY_FILE` | optional | private-key file, if not using the keychain |
| `DIST_DIR` | optional | output dir (default `dist/`) |
1. Tagging `vX.Y.Z` runs [`.github/workflows/release.yml`](../../.github/workflows/release.yml)
on `capsule-native/capsule`: build → Developer-ID sign → notarize → staple → package →
**sign the appcast**.
2. The workflow publishes a **GitHub Release** with `Capsule-<v>.dmg`, `Capsule-<v>.zip`, and
`appcast.xml`. The appcast's `<enclosure>` URLs point at that release's own asset download
URLs (via `--download-url-prefix`, set from the tag).
3. A **Sync appcast** workflow in the Pages repo
[`capsule-native/capsule-native.github.io`](https://github.com/capsule-native/capsule-native.github.io)
pulls the latest release's `appcast.xml` and commits it, republishing the feed at
**<https://capsule-native.github.io/appcast.xml>** — the app's `SUFeedURL`. It runs on a
schedule (~15 min) and on demand (Actions ▸ Sync appcast ▸ Run workflow) for an instant
publish right after a release.

> The code repo stays **public** so Sparkle can download the update zip from the release assets
> without authentication.

## Signing

Signed, notarized releases require Developer-ID + notarization credentials held by the
maintainer, plus a Sparkle EdDSA key whose public half is baked into `App/Info.plist`
(`SUPublicEDKey`). These are configured as repository secrets and their setup is intentionally
**not** documented in this public repo. When the signing secrets are absent (a fork, or a PR from
one), the workflow still **builds and validates** the whole pipeline but skips
notarization/publishing — it never fails for want of credentials, so contributors can run it.

> Pipeline behaviour worth knowing: `generate_appcast` writes `sparkle:edSignature` **only when
> the app's embedded `SUPublicEDKey` matches the signing key's public key** — otherwise it emits
> an *unsigned* enclosure with no error, and every client silently rejects the update. Keep the
> Info.plist public key and the signing key in lockstep.

## Run it

Expand All @@ -48,20 +51,32 @@ Individual steps (each accepts `--dry-run`):
| 2 | `export.sh` | `make export` | `xcodebuild -exportArchive` (Developer ID) → signed `dist/Capsule.app` (inside-out, incl. Sparkle; Hardened Runtime) |
| 3 | `notarize.sh` | `make notarize` | zip + `notarytool submit --wait` + `stapler staple` |
| 4 | `package.sh` | `make package` | `dist/Capsule-<v>.zip` (Sparkle) + `dist/Capsule-<v>.dmg` |
| 5 | `appcast.sh` | `make appcast` | Sparkle `generate_appcast` signs the zip + writes `appcast.xml` |
| 5 | `appcast.sh` | `make appcast` | Sparkle `generate_appcast` signs the zip + writes `appcast.xml` (with `RELEASE_DOWNLOAD_URL_PREFIX`) |

## Environment (local runs)

| Var | Required | Meaning |
| --- | --- | --- |
| `TEAM_ID` | signed runs | Apple Developer Team ID |
| `NOTARY_PROFILE` | signed runs | `notarytool` keychain profile name |
| `RELEASE_DOWNLOAD_URL_PREFIX` | CI-set | prepended to the appcast's enclosure filenames — the tag's release-download URL. Empty → filenames stay relative to the appcast. |
| `SPARKLE_BIN` | optional | dir containing `generate_appcast` / `generate_keys` |
| `SPARKLE_ED_KEY_FILE` | optional | private-key file; else the login keychain |
| `DIST_DIR` | optional | output dir (default `dist/`) |

## CI
## Cutting a release

`.github/workflows/release.yml` runs the same pipeline on a `v*` tag, importing the Developer
ID cert + notary creds from repository secrets, then publishes the artifacts and `appcast.xml`.
The signing/notarization steps are skipped automatically when the secrets are absent (e.g. on a
fork), so the workflow never fails for want of credentials.
1. Bump `CFBundleShortVersionString` **and** `CFBundleVersion` in `App/Info.plist`; commit + push.
2. `git tag vX.Y.Z && git push origin vX.Y.Z`. The tag `vX.Y.Z` **must** match the Info.plist
version — the workflow's *Verify tag matches app version* step fails the build otherwise (no
more `v1.2.3` tags that package `Capsule-0.1.0.zip`).
3. The workflow builds, signs, notarizes, and publishes the Release. The Pages repo picks up the
new appcast automatically within ~15 min, or immediately if you run **Sync appcast** there.

## Notes

- **Version comes from the app, not the tag.** Artifact names and the Sparkle appcast use
`CFBundleShortVersionString` from `App/Info.plist`. Bump it (and `CFBundleVersion`) and commit
**before** tagging `vX.Y.Z`, or a `v1.2.3` tag will still package `Capsule-0.1.0.zip`.
`CFBundleShortVersionString` from `App/Info.plist`; the tag guard just enforces they agree.
- **Notary password on argv.** `notarize.sh` / `release.yml` pass the app-specific password to
`notarytool store-credentials --password` (the API has no stdin form). It's masked and never
echoed; on the ephemeral, single-tenant `macos-26` runner nothing else can read the process
Expand All @@ -70,7 +85,7 @@ fork), so the workflow never fails for want of credentials.

## Why archive→export instead of `codesign --deep`

`xcodebuild -exportArchive` signs embedded code inside-out — including Sparkles nested
`xcodebuild -exportArchive` signs embedded code inside-out — including Sparkle's nested
`Autoupdate`, `Updater.app`, and XPC services — with the Hardened Runtime and a secure
timestamp, which is what the notary service demands. `codesign --deep` is discouraged by Apple
and easy to get subtly wrong for a framework with nested helpers.
16 changes: 13 additions & 3 deletions Scripts/release/appcast.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,25 @@ if [ -z "$GEN" ]; then
fi
fi

# Optional: prefix the bare enclosure filenames with the public download URL (e.g. the GitHub
# Release asset base for this tag). Without it, generate_appcast writes filenames relative to
# the appcast's own location — wrong here, since the appcast is hosted on the Pages repo but the
# zips live on the code repo's Release. The release workflow sets this to the tag's download URL.
PREFIX_ARGS=()
if [ -n "${RELEASE_DOWNLOAD_URL_PREFIX:-}" ]; then
PREFIX_ARGS=(--download-url-prefix "$RELEASE_DOWNLOAD_URL_PREFIX")
log "Enclosure URL prefix: $RELEASE_DOWNLOAD_URL_PREFIX"
fi

log "Signing artifacts + generating appcast with: $GEN"
if [ -n "${SPARKLE_ED_KEY_FILE:-}" ]; then
run "$GEN" --ed-key-file "$SPARKLE_ED_KEY_FILE" "$DIST_DIR"
run "$GEN" --ed-key-file "$SPARKLE_ED_KEY_FILE" ${PREFIX_ARGS[@]+"${PREFIX_ARGS[@]}"} "$DIST_DIR"
else
# No key file → generate_appcast reads the private key from the login keychain.
run "$GEN" "$DIST_DIR"
run "$GEN" ${PREFIX_ARGS[@]+"${PREFIX_ARGS[@]}"} "$DIST_DIR"
fi

log "appcast written → $DIST_DIR/appcast.xml"
if [ "$DRY_RUN" != "1" ] && [ -f "$DIST_DIR/appcast.xml" ]; then
log "Host this file at the URL in App/Info.plist's SUFeedURL."
log "Attached to the GitHub Release; the Pages repo's sync workflow publishes it at SUFeedURL."
fi
Loading