Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 11 additions & 0 deletions .github/macos-release-identity.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"bundleId": "com.lycaonsolutions.t4code",
"teamId": "WJLM3D3DK6",
"certificateCommonName": "Developer ID Application: Michael Schoenberger (WJLM3D3DK6)",
"certificateSha256": "7f946ec45aabcb895a46df233f326d8a878d1e874b2d546bfa781d4bd7b081ef",
"certificateAuthority": "Developer ID Certification Authority",
"architecture": "arm64",
"firstSignedReleaseTag": "v0.1.24",
"notarizationRequired": true
}
34 changes: 32 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,44 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build unsigned macOS packages
run: pnpm package:mac:unsigned
- name: Restore notarization API key
shell: bash
env:
T4_APPLE_API_KEY_BASE64: ${{ secrets.T4_APPLE_API_KEY_BASE64 }}
run: |
set -euo pipefail
if [[ -z "${T4_APPLE_API_KEY_BASE64:-}" ]]; then
echo "required GitHub Actions secret T4_APPLE_API_KEY_BASE64 is not configured" >&2
exit 1
fi
key_path="$RUNNER_TEMP/AuthKey.p8"
printf '%s' "$T4_APPLE_API_KEY_BASE64" | openssl base64 -d -A -out "$key_path"
chmod 600 "$key_path"

- name: Build signed and notarized macOS packages
env:
CSC_LINK: ${{ secrets.T4_MACOS_CERTIFICATE_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.T4_MACOS_CERTIFICATE_PASSWORD }}
APPLE_API_KEY: ${{ runner.temp }}/AuthKey.p8
APPLE_API_KEY_ID: ${{ secrets.T4_APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.T4_APPLE_API_ISSUER_ID }}
APPLE_TEAM_ID: ${{ secrets.T4_APPLE_TEAM_ID }}
run: pnpm package:mac

- name: Inspect macOS packages
run: |
pnpm inspect:package -- release/*.zip
pnpm inspect:dmg -- release/*.dmg

- name: Verify Developer ID identity and notarization
env:
VERSION: ${{ needs.verify.outputs.version }}
run: >-
node scripts/inspect-macos-release.mjs
"release/T4-Code-${VERSION}-mac-arm64.zip"
"release/T4-Code-${VERSION}-mac-arm64.dmg"
.github/macos-release-identity.json

- name: Stage macOS artifacts
shell: bash
run: |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ pnpm test # workspace tests
pnpm test:soak # headless 10k-history and 20-reconnect stress checks
pnpm package:linux # .deb + AppImage into release/
pnpm package:mac:unsigned # unsigned macOS build (on a Mac)
pnpm package:mac # maintainer-only signed and notarized macOS build
```

The soak command needs no phone, Android emulator, or macOS simulator. It checks the shared data
Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ We read every report and will reply to tell you what happens next. This is a sma
- T4 Code is a desktop client. The OMP runtime is a separate project; runtime vulnerabilities belong at <https://github.com/can1357/oh-my-pi>.
- Pairing credentials are encrypted with the OS keychain via Electron `safeStorage`. Reports about credential handling, the pairing flow, or the `t4-code://` deep-link handler are especially welcome.
- The macOS v0.1.23 build is unsigned and unnotarized; that is a known, disclosed limitation, not a vulnerability report. Removing `com.apple.quarantine` changes Gatekeeper handling but does not sign, notarize, or verify the app.
- Starting with v0.1.24, the release workflow requires the pinned Developer ID identity, hardened runtime, Apple notarization, a stapled ticket, and a successful Gatekeeper assessment before publishing macOS artifacts.
10 changes: 10 additions & 0 deletions apps/desktop/build/entitlements.mac.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>
76 changes: 76 additions & 0 deletions docs/MACOS_SIGNING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# macOS signing and notarization

T4 Code distributes its Mac build directly through GitHub Releases. It does not use the Mac App Store. Starting with v0.1.24, the protected release workflow must sign the application with Apple Developer ID and submit it to Apple's automated notarization service before publication.

```text
source tag
|
v
protected macOS runner
|
+-- imports the encrypted Developer ID certificate
+-- builds with hardened runtime
+-- signs every application component
+-- submits the app to Apple notarization
+-- staples Apple's ticket
+-- builds the DMG and ZIP
+-- reopens both artifacts and checks their identity
v
release artifacts
```

## Public identity contract

`.github/macos-release-identity.json` pins the bundle ID, Apple Team ID, Developer ID certificate, target architecture, and first signed release. The release inspector extracts the leaf certificate from the built application and compares its SHA-256 fingerprint with that contract.

Certificate rotation is a deliberate release change. Create the replacement certificate first, update the public identity contract, verify a dry-run build, and only then replace the GitHub secret. Do not silently accept any valid Developer ID certificate.

## GitHub Actions secrets

The repository uses these encrypted secrets:

| Secret | Purpose |
| --- | --- |
| `T4_MACOS_CERTIFICATE_BASE64` | Base64-encoded password-protected Developer ID `.p12` bundle |
| `T4_MACOS_CERTIFICATE_PASSWORD` | Password for the `.p12` bundle |
| `T4_APPLE_API_KEY_BASE64` | Base64-encoded App Store Connect `.p8` key used for notarization |
| `T4_APPLE_API_KEY_ID` | App Store Connect API key ID |
| `T4_APPLE_API_ISSUER_ID` | App Store Connect API issuer ID |
| `T4_APPLE_TEAM_ID` | Apple Developer team identifier |

Secrets are available only to the protected release job. Pull requests and ordinary CI keep using dependency-free checks and never receive Apple credentials.

## Local commands

Contributors can still build an unsigned artifact without Apple credentials:

```bash
pnpm package:mac:unsigned
```

Maintainers can produce the release form when the five electron-builder credential variables are present:

```bash
CSC_LINK=/path/to/certificate.p12 \
CSC_KEY_PASSWORD='...' \
APPLE_API_KEY=/path/to/AuthKey.p8 \
APPLE_API_KEY_ID='...' \
APPLE_API_ISSUER='...' \
pnpm package:mac
```

Do not put these values in `.env` files, shell history, source control, build logs, or issue reports.

## Verification boundary

`scripts/inspect-macos-release.mjs` reopens the ZIP and mounts the DMG read-only. For the application inside each artifact it verifies:

- the complete code signature;
- the exact bundle ID and Apple Team ID;
- the pinned leaf-certificate SHA-256 fingerprint;
- the Developer ID certificate chain;
- hardened runtime and a secure timestamp;
- a valid stapled notarization ticket; and
- acceptance by macOS Gatekeeper.

The first signed release remains a manual GitHub download. Automatic macOS updates are a separate follow-up because migration from the older unsigned build must be tested independently.
2 changes: 2 additions & 0 deletions docs/RELEASE_GATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Every release must pass the layers below. Destructive lifecycle checks use a dis
- Run lint, type checks, unit/integration suites, production builds, packaging/tooling checks, and Playwright.
- Exercise a complete inventory, a truncated inventory, reconnects, authoritative empty state, stale routes, and two clients observing the same session changes.
- Run the separate unsigned Android job with pinned Java 21, Android platform 36, and build tools 36.0.0. It must complete `testDebugUnitTest`, `assembleDebug`, and `lintDebug` without release-signing secrets.
- Build macOS release artifacts only in the protected release job. The ZIP and DMG must contain an app signed with the pinned Developer ID certificate and Team ID, hardened runtime, a secure timestamp, Apple notarization, and a stapled ticket. Gatekeeper must accept the app from both artifacts.
4. Touch layouts
- Use real CDP touch input at 320 pixels for model-list drag scrolling and selection. Check Send and session-management control reachability at 320, 360, and 390 pixels, including a short 390 x 500 viewport.
- Open and close the session rail, create a session, reach the Send control, drag-scroll the model list, and select its last available model.
Expand All @@ -31,6 +32,7 @@ Every release must pass the layers below. Destructive lifecycle checks use a dis
6. Open the actual Tailscale Serve HTTPS URL in a touch browser. Confirm connected state, shared history, model selection, prompt round-trip, reload recovery, and usable controls at the narrowest viewport.
7. Confirm the route is Tailscale Serve only. Funnel must be off.
8. Verify the exact seven-asset GitHub bundle: five installable packages, `latest-linux.yml`, and `SHA256SUMS.txt`. The checksum file must contain exactly the five package digests plus the Linux updater-metadata digest. Fetch `https://t4code.net/releases/latest.json` and match its schema, version, tag, release URL, five canonical package records, sizes, immutable URLs, and SHA-256 digests against that GitHub release.
9. On macOS, download the public DMG through a browser, copy T4 Code to Applications, and open it without a quarantine-removal command. Confirm Gatekeeper reports the pinned Developer ID publisher and launches the app normally.

Release sequencing is enforced by the workflows. The branch-required `verify` check is a fail-closed aggregate of the core, tooling, and unsigned Android jobs. Main-branch runs are keyed by commit and are not cancelled by a later push; pull requests still cancel stale runs. A release tag must match the package version at its immutable commit and resolve to a commit reachable from `main`; a manual rerun may therefore repair an older valid release after `main` advances. The workflow itself must still be dispatched from current `main`, so historical source cannot replace release-control logic. After that source check, the platform builds and the CI authority check run in parallel. The authority check accepts only a successful, completed `push` run of `.github/workflows/ci.yml` on `main` for the exact release SHA. Its wait budget exceeds the longest required CI leg, and publication waits for that run and all three platform builds.

Expand Down
15 changes: 12 additions & 3 deletions electron-builder.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const runtimeExternalDependencies = [
"node_modules/electron-updater/**/*",
"node_modules/ws/**/*",
];
const signedMacBuild = process.env.T4_MACOS_SIGNED_BUILD === "1";

export const linuxUpdatePublish = {
provider: "github",
Expand Down Expand Up @@ -46,9 +47,17 @@ const config = {
mac: {
category: "public.app-category.developer-tools",
icon: "apps/desktop/build/icon.png",
// The public macOS build is intentionally unsigned and unnotarized. Keep
// it on the explicit-download path until there is an honest signed update
// channel; do not emit latest-mac.yml for electron-updater.
identity: signedMacBuild ? undefined : null,
hardenedRuntime: signedMacBuild,
gatekeeperAssess: false,
entitlements: signedMacBuild ? "apps/desktop/build/entitlements.mac.plist" : undefined,
entitlementsInherit: signedMacBuild
? "apps/desktop/build/entitlements.mac.plist"
: undefined,
notarize: signedMacBuild,
// The first signed release remains an explicit GitHub download. Keep the
// updater feed disabled until signed-to-signed update migration has its
// own release proof.
publish: [],
target: [
{ target: "dmg", arch: ["arm64"] },
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@
"prepackage": "pnpm check && pnpm build:web && pnpm build:desktop && node scripts/package-preflight.mjs",
"package:linux": "pnpm prepackage && node scripts/run-electron-builder.mjs --linux --x64",
"package:mac:unsigned": "node scripts/package-mac-unsigned.mjs",
"package:mac": "node scripts/package-mac-signed.mjs",
"package:dir": "pnpm prepackage && node scripts/run-electron-builder.mjs --linux --x64 --dir",
"inspect:package": "node scripts/inspect-package.mjs",
"inspect:dmg": "node scripts/inspect-macos-dmg.mjs",
"test:packaging": "node --test scripts/packaging.test.mjs scripts/inspect-linux-update.test.mjs scripts/inspect-macos-dmg.test.mjs",
"test:packaging": "node --test scripts/packaging.test.mjs scripts/inspect-linux-update.test.mjs scripts/inspect-macos-dmg.test.mjs scripts/inspect-macos-release.test.mjs",
"test:tooling": "node --test scripts/benchmark-omp-codex-transport.test.mjs scripts/check-release-consistency.test.mjs scripts/check-release-publication.test.mjs scripts/check-provenance.test.mjs scripts/deploy-site.test.mjs scripts/dispatch-site-deployment.test.mjs scripts/generate-release-manifest.test.mjs scripts/perf/perf.test.mjs scripts/reconcile-release-assets.test.mjs scripts/t4-maintainer-contract.test.mjs scripts/t4-maintainer-integration.test.mjs scripts/t4-maintainer-omp-publish.test.mjs scripts/test-temporary-directory.test.mjs scripts/tailnet-gateway.test.mjs scripts/tailnet-service.test.mjs scripts/wait-for-exact-ci.test.mjs scripts/wait-for-release-assets.test.mjs",
"check:release": "node scripts/check-release-consistency.mjs",
"check:provenance": "node scripts/check-provenance.mjs",
Expand Down
59 changes: 57 additions & 2 deletions scripts/check-release-consistency.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";

export const RELEASE_CONTRACT_PATHS = [
".github/android-release-identity.json",
".github/macos-release-identity.json",
".github/ISSUE_TEMPLATE/bug_report.yml",
".github/workflows/ci.yml",
".github/workflows/deploy-site.yml",
Expand All @@ -19,6 +20,7 @@ export const RELEASE_CONTRACT_PATHS = [
"apps/web/src/platform/browser-shell-port.ts",
"compat/omp-app-matrix.json",
"docs/CURRENT_RELEASE_NOTES.md",
"docs/MACOS_SIGNING.md",
"docs/RELEASE_GATE.md",
"ops/t4-maintainer/README.md",
"packages/client/src/omp-client-frames.ts",
Expand All @@ -42,6 +44,15 @@ const SHA_PATTERN = /^[0-9a-f]{40}$/u;
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
const PATCH_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;

function compareStableVersions(left, right) {
const leftParts = left.split(".").map(Number);
const rightParts = right.split(".").map(Number);
for (let index = 0; index < 3; index += 1) {
if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index];
}
return 0;
}

export function expectedReleaseAssetNames(version) {
return [
`T4-Code-${version}-android.apk`,
Expand Down Expand Up @@ -284,6 +295,36 @@ export function collectReleaseConsistencyErrors(files, releaseTag) {
);
}

const macosIdentityPath = ".github/macos-release-identity.json";
const macosIdentity = parseJson(files, macosIdentityPath, errors);
if (macosIdentity?.schemaVersion !== 1) {
errors.push(`${macosIdentityPath} schemaVersion must be 1`);
}
if (macosIdentity?.bundleId !== "com.lycaonsolutions.t4code") {
errors.push(`${macosIdentityPath} bundleId must be com.lycaonsolutions.t4code`);
}
if (!/^[A-Z0-9]{10}$/u.test(macosIdentity?.teamId ?? "")) {
errors.push(`${macosIdentityPath} teamId must be 10 uppercase letters or digits`);
}
if (
typeof macosIdentity?.certificateSha256 !== "string" ||
!SHA256_PATTERN.test(macosIdentity.certificateSha256)
) {
errors.push(`${macosIdentityPath} certificate must be a lowercase SHA-256 digest`);
}
if (macosIdentity?.certificateAuthority !== "Developer ID Certification Authority") {
errors.push(`${macosIdentityPath} must pin the Developer ID Certification Authority`);
}
if (macosIdentity?.architecture !== "arm64") {
errors.push(`${macosIdentityPath} architecture must be arm64`);
}
if (!/^v\d+\.\d+\.\d+$/u.test(macosIdentity?.firstSignedReleaseTag ?? "")) {
errors.push(`${macosIdentityPath} firstSignedReleaseTag must be vX.Y.Z`);
}
if (macosIdentity?.notarizationRequired !== true) {
errors.push(`${macosIdentityPath} must require notarization`);
}

const packagePaths = [...files.keys()]
.filter(
(path) => path === "package.json" || /^(?:apps|packages)\/[^/]+\/package\.json$/u.test(path),
Expand Down Expand Up @@ -646,9 +687,22 @@ export function collectReleaseConsistencyErrors(files, releaseTag) {
requireText(releaseNotes, expected, "docs/CURRENT_RELEASE_NOTES.md", errors);
}

const securityPolicy = files.get("SECURITY.md") ?? "";
const firstSignedVersion = String(macosIdentity?.firstSignedReleaseTag ?? "").replace(/^v/u, "");
const signedRelease = VERSION_PATTERN.test(firstSignedVersion)
? compareStableVersions(version, firstSignedVersion) >= 0
: false;
requireText(
securityPolicy,
signedRelease
? `The macOS ${expectedTag} build is signed with Apple Developer ID and notarized by Apple`
: `The macOS ${expectedTag} build is unsigned and unnotarized`,
"SECURITY.md",
errors,
);
requireText(
files.get("SECURITY.md") ?? "",
`The macOS ${expectedTag} build is unsigned and unnotarized`,
securityPolicy,
`Starting with ${macosIdentity?.firstSignedReleaseTag ?? "the first signed release"}`,
"SECURITY.md",
errors,
);
Expand Down Expand Up @@ -958,6 +1012,7 @@ export function collectReleaseConsistencyErrors(files, releaseTag) {
const releaseGate = files.get("docs/RELEASE_GATE.md") ?? "";
for (const expected of [
"`testDebugUnitTest`, `assembleDebug`, and `lintDebug`",
"pinned Developer ID certificate",
"exact seven-asset GitHub bundle",
"defers only when the exact GitHub release lookup returns HTTP 404",
"writes `/releases/latest.json`",
Expand Down
Loading
Loading