From db850dee5a513acf8264993c2c3f873cd9348625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Thu, 2 Jul 2026 16:19:26 +0300 Subject: [PATCH 1/2] feat(release): wire Sparkle auto-updates end-to-end via GitHub Pages Point SUFeedURL at the capsule-native.github.io appcast and bake in the real EdDSA public key. The release workflow signs the appcast and sets the enclosure download URLs to the tag's Release assets (--download-url-prefix); the Pages repo's sync workflow republishes appcast.xml at the feed URL. Adds a tag/version guard so a vX.Y.Z tag can't ship mismatched artifacts, and repoints the moved repo/org references. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JYNmieH7AYvXys56bV8B7p --- .github/ISSUE_TEMPLATE/config.yml | 4 +- .github/workflows/release.yml | 15 ++++++ App/Info.plist | 15 +++--- Scripts/release/README.md | 86 ++++++++++++++++++++++++------- Scripts/release/appcast.sh | 16 ++++-- 5 files changed, 105 insertions(+), 31 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 41e3935..7d4838a 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f472180..158d7e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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)" @@ -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) ────────────────────────────── diff --git a/App/Info.plist b/App/Info.plist index e400888..e407b51 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -40,9 +40,10 @@ Capsule uses the local network to communicate with container runtimes and services on this Mac. - + SUFeedURL - https://capsule-app.github.io/capsule/appcast.xml + https://capsule-native.github.io/appcast.xml SUEnableAutomaticChecks @@ -50,11 +51,11 @@ SUScheduledCheckInterval 86400 - + SUPublicEDKey - REPLACE_WITH_SPARKLE_EDDSA_PUBLIC_KEY + SaJ9avcN8A1w5xrK7XmVidxCXX6swF0N/wsRfMIadcc= diff --git a/Scripts/release/README.md b/Scripts/release/README.md index e38e9cf..1ee90e4 100644 --- a/Scripts/release/README.md +++ b/Scripts/release/README.md @@ -4,9 +4,27 @@ 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). +## How updates reach users + +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-.dmg`, `Capsule-.zip`, and + `appcast.xml`. The appcast's `` 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 + **** — 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 must stay **public** so Sparkle can download the update zip from the release +> assets without authentication. + ## One-time setup -1. **Developer ID** — a “Developer ID Application” certificate in your login keychain, and your +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 @@ -14,23 +32,46 @@ unsandboxed to drive the `container` CLI). --apple-id you@example.com --team-id ABCDE12345 \ --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. +3. **Sparkle signing key** — already generated under the keychain account `capsule-native`. Its + public key is baked into `App/Info.plist` (`SUPublicEDKey`) and its private key is the CI + secret `SPARKLE_ED_PRIVATE_KEY`. A base64 backup is kept off-repo by the maintainer. + + > **Gotcha:** `generate_appcast` signs an update **only when the `.app`'s embedded + > `SUPublicEDKey` matches the signing key's public key.** If the Info.plist public key and + > the CI private key aren't a pair, the appcast ships **unsigned with no error** and every + > client silently rejects the update. Keep them in lockstep when rotating. + +## GitHub Actions secrets (`capsule-native/capsule`) -## Environment +| Secret | For | +| --- | --- | +| `APPLE_TEAM_ID` | signing + notarization | +| `DEVELOPER_ID_CERT_P12_BASE64` | Developer ID Application cert (base64 `.p12`) | +| `DEVELOPER_ID_CERT_PASSWORD` | `.p12` password | +| `NOTARY_APPLE_ID` | Apple ID for `notarytool` | +| `NOTARY_PASSWORD` | app-specific password for `notarytool` | +| `SPARKLE_ED_PRIVATE_KEY` | base64 of the Sparkle EdDSA private key — signs the appcast (**already set**) | + +To rotate the Sparkle key: `generate_keys --account capsule-native` (paste the new public key +into `App/Info.plist`), then re-export and update the secret: +```sh +generate_keys --account capsule-native -x sparkle_priv +base64 < sparkle_priv | gh secret set SPARKLE_ED_PRIVATE_KEY --repo capsule-native/capsule +rm sparkle_priv +``` +When signing secrets are absent (e.g. on a fork), the workflow still builds + validates the +pipeline but skips notarization/publishing — it never fails for want of credentials. + +## Environment (local runs) | Var | Required | Meaning | | --- | --- | --- | | `TEAM_ID` | yes | Apple Developer Team ID | -| `NOTARY_PROFILE` | yes | notarytool keychain profile name (e.g. `capsule-notary`) | +| `NOTARY_PROFILE` | yes | `notarytool` keychain profile name (e.g. `capsule-notary`) | +| `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. | | `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 | +| `SPARKLE_ED_KEY_FILE` | optional | private-key file; else the login keychain (`capsule-native`) | | `DIST_DIR` | optional | output dir (default `dist/`) | ## Run it @@ -48,20 +89,27 @@ 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-.zip` (Sparkle) + `dist/Capsule-.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`) | + +## Cutting a release + +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. ## CI -`.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. +`.github/workflows/release.yml` runs the same pipeline on a `v*` tag, importing the Developer ID +cert + notary creds + Sparkle key from repository secrets, then publishes the artifacts and the +signed `appcast.xml` to the GitHub Release. ## 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 @@ -70,7 +118,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 Sparkle’s 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. diff --git a/Scripts/release/appcast.sh b/Scripts/release/appcast.sh index ad3d4d0..bb7b408 100755 --- a/Scripts/release/appcast.sh +++ b/Scripts/release/appcast.sh @@ -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 From 56b0538c6c108704231a62b68bffc2a7584af95f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Thu, 2 Jul 2026 17:14:04 +0300 Subject: [PATCH 2/2] docs(release): trim signing-secret setup from the public README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public repo — drop the maintainer-only credential how-to (Developer ID cert export, notarytool store-credentials, the secrets table). Keep the pipeline and development guides: update flow, run/steps, environment, cutting a release, the fork-fallback, and the generate_appcast SUPublicEDKey-match gotcha. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JYNmieH7AYvXys56bV8B7p --- Scripts/release/README.md | 83 ++++++++++++--------------------------- 1 file changed, 25 insertions(+), 58 deletions(-) diff --git a/Scripts/release/README.md b/Scripts/release/README.md index 1ee90e4..8318411 100644 --- a/Scripts/release/README.md +++ b/Scripts/release/README.md @@ -19,60 +19,22 @@ unsandboxed to drive the `container` CLI). schedule (~15 min) and on demand (Actions ▸ Sync appcast ▸ Run workflow) for an instant publish right after a release. -> The code repo must stay **public** so Sparkle can download the update zip from the release -> assets without authentication. - -## 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 - ``` -3. **Sparkle signing key** — already generated under the keychain account `capsule-native`. Its - public key is baked into `App/Info.plist` (`SUPublicEDKey`) and its private key is the CI - secret `SPARKLE_ED_PRIVATE_KEY`. A base64 backup is kept off-repo by the maintainer. - - > **Gotcha:** `generate_appcast` signs an update **only when the `.app`'s embedded - > `SUPublicEDKey` matches the signing key's public key.** If the Info.plist public key and - > the CI private key aren't a pair, the appcast ships **unsigned with no error** and every - > client silently rejects the update. Keep them in lockstep when rotating. - -## GitHub Actions secrets (`capsule-native/capsule`) - -| Secret | For | -| --- | --- | -| `APPLE_TEAM_ID` | signing + notarization | -| `DEVELOPER_ID_CERT_P12_BASE64` | Developer ID Application cert (base64 `.p12`) | -| `DEVELOPER_ID_CERT_PASSWORD` | `.p12` password | -| `NOTARY_APPLE_ID` | Apple ID for `notarytool` | -| `NOTARY_PASSWORD` | app-specific password for `notarytool` | -| `SPARKLE_ED_PRIVATE_KEY` | base64 of the Sparkle EdDSA private key — signs the appcast (**already set**) | - -To rotate the Sparkle key: `generate_keys --account capsule-native` (paste the new public key -into `App/Info.plist`), then re-export and update the secret: -```sh -generate_keys --account capsule-native -x sparkle_priv -base64 < sparkle_priv | gh secret set SPARKLE_ED_PRIVATE_KEY --repo capsule-native/capsule -rm sparkle_priv -``` -When signing secrets are absent (e.g. on a fork), the workflow still builds + validates the -pipeline but skips notarization/publishing — it never fails for want of credentials. +> The code repo stays **public** so Sparkle can download the update zip from the release assets +> without authentication. -## Environment (local runs) +## Signing -| Var | Required | Meaning | -| --- | --- | --- | -| `TEAM_ID` | yes | Apple Developer Team ID | -| `NOTARY_PROFILE` | yes | `notarytool` keychain profile name (e.g. `capsule-notary`) | -| `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. | -| `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; else the login keychain (`capsule-native`) | -| `DIST_DIR` | optional | output dir (default `dist/`) | +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 @@ -91,6 +53,17 @@ Individual steps (each accepts `--dry-run`): | 4 | `package.sh` | `make package` | `dist/Capsule-.zip` (Sparkle) + `dist/Capsule-.dmg` | | 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/`) | + ## Cutting a release 1. Bump `CFBundleShortVersionString` **and** `CFBundleVersion` in `App/Info.plist`; commit + push. @@ -100,12 +73,6 @@ Individual steps (each accepts `--dry-run`): 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. -## CI - -`.github/workflows/release.yml` runs the same pipeline on a `v*` tag, importing the Developer ID -cert + notary creds + Sparkle key from repository secrets, then publishes the artifacts and the -signed `appcast.xml` to the GitHub Release. - ## Notes - **Version comes from the app, not the tag.** Artifact names and the Sparkle appcast use