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: 4 additions & 0 deletions .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jobs:
pnpm exec tsx --test
packages/desktop/test/ccbuddy-update-policy.test.mjs
packages/desktop/test/finalize-macos-release.test.mjs
packages/desktop/test/legacy-native-update.test.mjs
packages/desktop/test/prepare-main-release.test.mjs

macos-package:
Expand Down Expand Up @@ -79,3 +80,6 @@ jobs:
test -s packages/desktop/dist/latest-mac.yml
test "$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' packages/desktop/dist/mac-arm64/CCbuddy.app/Contents/Info.plist)" = "$version"
test "$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' packages/desktop/dist/mac-arm64/CCbuddy.app/Contents/Info.plist)" = app.ccbuddy.desktop
# The legacy native updater rejects any symlink that resolves outside the app.
node packages/desktop/scripts/legacy-native-update.mjs check-symlinks \
packages/desktop/dist/mac-arm64/CCbuddy.app
41 changes: 38 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,28 @@ jobs:
node packages/desktop/scripts/finalize-macos-release.mjs packages/desktop/dist
version="${{ needs.verify.outputs.version }}"
unzip -t "packages/desktop/dist/CCbuddy-${version}-mac-arm64.zip"
- name: Package, notarize, and sign the legacy native update bridge
shell: bash
env:
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
# An unencrypted Tauri key legitimately has an empty password.
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
umask 077
test -n "$APPLE_API_KEY_P8"
test -n "$TAURI_SIGNING_PRIVATE_KEY"
key="$RUNNER_TEMP/ccbuddy-legacy-notary.p8"
trap 'rm -f "$key"' EXIT
printf '%s' "$APPLE_API_KEY_P8" | openssl base64 -d -A -out "$key"
unset APPLE_API_KEY_P8
test -s "$key"
APPLE_API_KEY_PATH="$key" packages/desktop/scripts/package-legacy-native-bridge.sh \
packages/desktop/dist/mac-arm64/CCbuddy.app "${{ needs.verify.outputs.version }}" \
packages/desktop/dist "$GITHUB_REPOSITORY"
- name: Upload complete release inputs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
Expand All @@ -185,12 +207,16 @@ jobs:
packages/desktop/dist/CCbuddy-${{ needs.verify.outputs.version }}-mac-arm64.zip.blockmap
packages/desktop/dist/CCbuddy-${{ needs.verify.outputs.version }}-mac-arm64.dmg
packages/desktop/dist/latest-mac.yml
packages/desktop/dist/CCbuddy-${{ needs.verify.outputs.version }}-legacy-mac-arm64.app.tar.gz
packages/desktop/dist/CCbuddy-${{ needs.verify.outputs.version }}-legacy-mac-arm64.app.tar.gz.sig
packages/desktop/dist/latest.json
if-no-files-found: error
retention-days: 14
- name: Remove temporary signing keychain
if: always()
run: |
rm -f "$RUNNER_TEMP/ccbuddy-developer-id.p12" "$RUNNER_TEMP/ccbuddy-notary.p8"
rm -f "$RUNNER_TEMP/ccbuddy-developer-id.p12" "$RUNNER_TEMP/ccbuddy-notary.p8" \
"$RUNNER_TEMP/ccbuddy-legacy-notary.p8"
if [ -f "$RUNNER_TEMP/ccbuddy-signing.keychain-db" ]; then
security delete-keychain "$RUNNER_TEMP/ccbuddy-signing.keychain-db"
fi
Expand Down Expand Up @@ -249,7 +275,9 @@ jobs:
fi
zip="CCbuddy-${VERSION}-mac-arm64.zip"
dmg="CCbuddy-${VERSION}-mac-arm64.dmg"
expected="$(printf '%s\n' "$zip" "$zip.blockmap" "$dmg" latest-mac.yml | sort)"
legacy="CCbuddy-${VERSION}-legacy-mac-arm64.app.tar.gz"
expected="$(printf '%s\n' "$zip" "$zip.blockmap" "$dmg" latest-mac.yml \
"$legacy" "$legacy.sig" latest.json | sort)"
actual="$(find dist -maxdepth 1 -type f -printf '%f\n' | sort)"
test "$actual" = "$expected"
sha="$(openssl dgst -sha512 -binary "dist/$zip" | openssl base64 -A)"
Expand All @@ -258,6 +286,12 @@ jobs:
grep -Fx "path: $zip" dist/latest-mac.yml
grep -Fx "sha512: $sha" dist/latest-mac.yml
test "$(grep -c '^ - url:' dist/latest-mac.yml)" = 1
if grep -Fq legacy dist/latest-mac.yml; then
echo "latest-mac.yml must not reference the legacy bridge" >&2
exit 1
fi
# Old native/Tauri clients read latest.json; re-verify it against the downloaded bytes.
node packages/desktop/scripts/legacy-native-update.mjs verify dist "$VERSION" "$GITHUB_REPOSITORY"
- name: Create or reuse draft and upload all assets
shell: bash
run: |
Expand All @@ -277,6 +311,7 @@ jobs:
test "$actual" = "$expected"
mkdir "$RUNNER_TEMP/release-check"
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
--pattern latest-mac.yml --dir "$RUNNER_TEMP/release-check"
--pattern latest-mac.yml --pattern latest.json --dir "$RUNNER_TEMP/release-check"
cmp dist/latest-mac.yml "$RUNNER_TEMP/release-check/latest-mac.yml"
cmp dist/latest.json "$RUNNER_TEMP/release-check/latest.json"
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest=true
105 changes: 105 additions & 0 deletions docs/specs/legacy-native-update-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Legacy native update bridge

## Problem and product rule

CCbuddy 2.0.3–2.0.9 shipped as a native Swift macOS app, and Tauri 1.3.9 shipped
before that. Those installed apps cannot be changed; their updater reads only
`https://github.com/ccbud/ccbud/releases/latest/download/latest.json` (GitHub
redirects the old repository name to `ccbud/CCBuddy`). The Electron releases
since 2.0.12 publish only `latest-mac.yml`. So `latest.json` returned 404 and
2.0.5 could not update.

Every production Electron release therefore also publishes a **legacy bridge**:
the same Electron app, re-signed under the old native identity, plus the
`latest.json` contract the old updater understands. After the bridge is
installed, it updates through `latest-mac.yml` like any other Electron build and
becomes `app.ccbuddy.desktop` on its next update.

## Frozen legacy client contract (from the v2.0.5 sources)

The old clients cannot change, so the bridge must meet every check below.

| Check | Required value |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Manifest | JSON `{version, pub_date, platforms}`; `version` is semver and newer than the installed version |
| Platform keys | arm64: `darwin-aarch64-app`, then `darwin-aarch64`; Intel: `darwin-x86_64-app`, then `darwin-x86_64` |
| Artifact entry | `url` (HTTPS, GitHub hosts), `signature`, `sha256` (lowercase hex) |
| Signature | Tauri minisign format: Base64 of the four-line `.sig` text, prehashed Ed25519 (`ED`, BLAKE2b-512) by key id `FB130F2908B61575` |
| Archive | gzip tar with exactly one top-level `*.app`, no absolute paths or `..`, no symlink resolving outside the app, at most 512 MiB |
| Bundle | `CFBundleIdentifier = dev.ccbud.gateway`, `CFBundleShortVersionString` equals manifest `version` |
| Code signature | satisfies `anchor apple generic and certificate leaf[subject.OU] = "2CGR266XD2" and identifier "dev.ccbud.gateway"` with strict nested validation, and carries a stapled notarization ticket |

The Electron app itself uses `app.ccbuddy.desktop`, which fails the bundle and
code-signature checks. The bridge therefore needs its own identity.

## Bridge identity and handover to Electron updates

- The bridge is a copy of the signed, production `CCbuddy.app` whose outer
`CFBundleIdentifier` and signing identifier are `dev.ccbud.gateway`. The product
name, `userData` path, update feed URL, helpers, and nested signatures stay
unchanged, so a bridge install and a normal install share the same data
directory.
- Squirrel.Mac accepts an update only if it satisfies the running app's
**designated requirement**. The bridge's designated requirement is therefore
team-only: `anchor apple generic and certificate leaf[subject.OU] = "2CGR266XD2"`.
This lets the bridge accept the next normal `app.ccbuddy.desktop` ZIP. Normal
builds keep electron-builder's default designated requirement.
- The Developer ID team must be `2CGR266XD2`; a release signed by any other team
fails before publication, because no old client could install it.

## Architecture scope

Electron releases are arm64 only. `latest.json` lists only the two
`darwin-aarch64*` keys. On Intel, the native 2.x updater finds no key and shows
its manual-download state, which links to the release page. Tauri 1.3.9 on
Intel reports a failed check. An arm64-only app is never offered to an Intel
Mac. Adding an Intel or universal Electron build later adds the `darwin-x86_64*`
keys.

## Owners and order

The release workflow owns the bridge; the app has no runtime code for it.

```text
build job (macOS, release environment)
electron-builder → CCbuddy.app (app.ccbuddy.desktop) → notarize ZIP/DMG
ditto copy → set CFBundleIdentifier → re-sign outer bundle (team-only DR)
→ verify legacy requirement + nested/strict + symlinks + normal app satisfies bridge DR
→ notarize bridge → staple → re-verify stapled
→ tar (single .app root) → Tauri signer (.sig) → latest.json (signature re-verified with the embedded public key)
publish job
verify latest-mac.yml and latest.json against the downloaded bytes
→ draft release → upload all → compare inventory → publish as latest
old native / Tauri client
GET releases/latest/download/latest.json → download bridge tar.gz → verify → install → relaunch
bridge (Electron, dev.ccbud.gateway)
GET releases/latest/download/latest-mac.yml → Squirrel update → app.ccbuddy.desktop
```

`latest.json` is uploaded only in the same draft as its archive and becomes
visible only when the complete release is published as latest, so an old client
never sees a manifest without its archive.

The workflow needs the `TAURI_SIGNING_PRIVATE_KEY` secret (and
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, empty for an unencrypted key) in the
`release` environment. The signer is `@tauri-apps/cli` 2.11.3, the version the
native releases used.

## Acceptance

1. `releases/latest/download/latest.json` resolves for every new release. Its
`version` equals the release version, it has exactly the two arm64 keys, and
both point at the same archive URL in that release with the archive's SHA-256.
2. The signature decodes to four minisign lines, uses key id
`FB130F2908B61575`, and verifies against the archive bytes with the public key
embedded in the old clients. A wrong key, tampered archive, or non-canonical
Base64 fails the build.
3. The archive has one `.app` root. The app inside is `dev.ccbud.gateway`, has the
release version, satisfies the legacy code requirement, and carries a stapled
ticket.
4. The normal `app.ccbuddy.desktop` build satisfies the bridge's designated
requirement, so the bridge's first Electron update succeeds.
5. The normal ZIP, DMG, and `latest-mac.yml` are unchanged. `latest-mac.yml`
never references a bridge asset.
6. Manual acceptance on a real Mac: 2.0.5 (arm64) installs the bridge and
relaunches it; a newer release then updates the bridge to `app.ccbuddy.desktop`.
17 changes: 14 additions & 3 deletions docs/specs/macos-release-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,24 +38,33 @@ public only after the complete asset inventory is verified. Existing public
releases are immutable; reruns cannot replace their assets or move `latest`
backwards to an older version.

Each release also carries the legacy native update bridge
(`docs/specs/legacy-native-update-bridge.md`): `latest.json`, a minisign-signed
`CCbuddy-<version>-legacy-mac-arm64.app.tar.gz` and its `.sig`. These let the
frozen native 2.0.3–2.0.9 and Tauri 1.3.9 updaters install the Electron app.
`latest-mac.yml` never references them.

## Flow and failure semantics

```text
PR → pnpm checks + unsigned arm64 package → review and merge
main push → allocate annotated tag (main commit or version snapshot)
annotated tag → version/ancestry check → signed arm64 build
→ notarize ZIP and DMG → staple DMG → finalize YAML and verify assets
→ re-sign, notarize, staple, and sign the legacy bridge → latest.json
→ draft GitHub Release → upload and compare inventory → publish as latest
```

The GitHub `release` environment owns access to the Developer ID certificate and
Apple notary API key. Its required secrets are `MAC_CSC_LINK`,
`MAC_CSC_KEY_PASSWORD`, `APPLE_SIGNING_IDENTITY`, `APPLE_TEAM_ID`,
`APPLE_API_KEY_P8`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`. Missing or invalid
`APPLE_API_KEY_P8`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`, and
`TAURI_SIGNING_PRIVATE_KEY` (with `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, empty for
an unencrypted key) for the legacy bridge signature. Missing or invalid
credentials fail before publication. A temporary keychain and API key file live
only on the release runner and are removed afterward. The workflow never
generates a signing identity, publishes an ad hoc signed package, or uses the
removed native/Tauri release path.
generates a signing identity, publishes an ad hoc signed package, or builds the
removed native/Tauri app; it only reuses the legacy `latest.json` update contract.

If any signing, notarization, manifest, archive, or upload check fails, no public
release is created. A draft can be retried for the same tag. Publication uses the
Expand All @@ -74,5 +83,7 @@ and no release credentials.
3. A valid tagged build embeds the CCbuddy latest manifest URL and emits signed,
notarized artifacts whose ZIP hash matches the published YAML. The manifest
contains no DMG or foreign update source.
The same release carries a `latest.json` that meets the legacy bridge
acceptance criteria.
4. Failed publication leaves only a draft. An existing public release and a
newer latest version are never overwritten by a rerun or older tag.
Loading
Loading