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..8318411 100644 --- a/Scripts/release/README.md +++ b/Scripts/release/README.md @@ -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 - ``` -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-.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 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 @@ -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-.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`) | + +## 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 @@ -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 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