diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87701d6..cd8d902 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,27 @@ jobs: - name: Run tests run: python3 -m pytest -v + # Event 179 — the app shell gets a build gate (E176 shipped it CI-blind; + # release-relevance ends that). Build only — launching a GUI on a runner + # proves nothing the compile doesn't. + app-build: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cargo cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + app/target + key: cargo-${{ runner.os }}-${{ hashFiles('app/Cargo.lock') }} + + - name: Build app shell + run: cd app && cargo build --release + # Event 177 — the distribution acceptance bar as CI: a clean macOS runner # with nothing but Python must go wheel → install → doctor clean → sync → # resolvable hooks → a hook actually firing, with ZERO repo checkout in diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml new file mode 100644 index 0000000..dcb7ac7 --- /dev/null +++ b/.github/workflows/release-assets.yml @@ -0,0 +1,62 @@ +# Release assets (E179): build the wheel and the .pkg and attach both to a +# GitHub release. +# +# TRIGGER REALITY (the E163 deadlock class, documented not hidden): +# release-please cuts releases with GITHUB_TOKEN, and GitHub deliberately +# does not chain workflows off bot-token events — so `release: published` +# only fires once the operator gives release-please a PAT. Until then, +# `workflow_dispatch` is the working path: pick the tag, run, assets land. +# +# Signing: runners have no Developer ID cert; artifacts built here are the +# UNSIGNED tier. Once the operator enrolls (Apple Developer, operator-only +# gate), signing moves into scripts/build_app_pkg.sh via its env vars and a +# certificate-import step here. + +name: release-assets + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag to attach assets to (e.g. episteme-v1.10.0)" + required: true + release: + types: [published] + +permissions: + contents: write + +jobs: + build-and-attach: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build wheel + run: python3 -m pip wheel . --no-deps -w dist_wheel + + - name: Privacy gate (same contract as CI clean-install) + run: | + LISTING="$(python3 -m zipfile -l dist_wheel/*.whl)" + echo "$LISTING" | grep -q "_assets/core/hooks/workflow_guard.py" || { echo "assets missing"; exit 1; } + if echo "$LISTING" | grep -E "memory/global/(runtime_digest|operator_profile|agent_feedback|workflow_policy|cognitive_profile|overview|python_runtime_policy)\.md|skills/private|memory/(evolution|substrates)|\.jsonl|\.pem|\.key|\.p12"; then + echo "PRIVACY VIOLATION in release wheel"; exit 1; fi + + - name: Build app + pkg + run: scripts/build_app_pkg.sh + + - name: Attach assets + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} + run: | + gh release upload "$TAG" dist_wheel/*.whl dist/Episteme.pkg --clobber + echo "attached: $(ls dist_wheel/*.whl) dist/Episteme.pkg → $TAG" diff --git a/app/README.md b/app/README.md index 0b8982d..565b5ce 100644 --- a/app/README.md +++ b/app/README.md @@ -29,7 +29,25 @@ scripts/build_app_pkg.sh Requires Rust ≥ 1.85 (`edition = "2024"` in `app/Cargo.toml`); `app/` is not built in CI — the build host's toolchain is the only gate. -Produces `dist/Episteme.app` and an **unsigned** `dist/Episteme.pkg` -(installs to /Applications). Unsigned is a conscious tier: it installs -cleanly on the machine that built it; distribution-grade signing + -notarization is a separate operator-gated decision recorded in the ledger. +Produces `dist/Episteme.app` and `dist/Episteme.pkg` (installs to +/Applications), with the icon regenerated on demand: +`swift app/icon/make_icns.swift app/icon` (the committed `episteme.icns` is +a reproducible artifact of that script — no design blob without a source). + +**Signing tiers** (E179 — the script is credential-ready and inert without +certs; each tier upgrades by exporting env vars before the build): + +| Tier | Requires | Gets you | +|---|---|---| +| unsigned (default) | nothing | installs on the building machine; right-click → Open elsewhere | +| signed | `EPISTEME_SIGN_APP_ID`, `EPISTEME_SIGN_INSTALLER_ID` (Developer ID certs — operator-only Apple enrollment) | Gatekeeper-recognized identity | +| notarized | + `EPISTEME_NOTARY_PROFILE` (notarytool keychain profile) | clean first-run for any downloader | + +Local builds carry `com.apple.provenance` AppleDouble entries in the pkg +payload — SIP-protected, re-stamped on write, unremovable by construction; +harmless 11-byte metadata (release artifacts are CI-built via +`release-assets.yml`). First check after enrolling: hardened runtime +(`--options runtime`) vs the wry webview — if the window comes up blank, add a JIT entitlement (noted in +`scripts/build_app_pkg.sh`). Release assets: `.github/workflows/release-assets.yml` +attaches the wheel + pkg to a GitHub release (`workflow_dispatch` today; +`release: published` activates once release-please gets the operator's PAT). diff --git a/app/icon/episteme.icns b/app/icon/episteme.icns new file mode 100644 index 0000000..db6ba16 Binary files /dev/null and b/app/icon/episteme.icns differ diff --git a/app/icon/episteme.iconset/icon_128x128.png b/app/icon/episteme.iconset/icon_128x128.png new file mode 100644 index 0000000..647b500 Binary files /dev/null and b/app/icon/episteme.iconset/icon_128x128.png differ diff --git a/app/icon/episteme.iconset/icon_128x128@2x.png b/app/icon/episteme.iconset/icon_128x128@2x.png new file mode 100644 index 0000000..da0d47e Binary files /dev/null and b/app/icon/episteme.iconset/icon_128x128@2x.png differ diff --git a/app/icon/episteme.iconset/icon_16x16.png b/app/icon/episteme.iconset/icon_16x16.png new file mode 100644 index 0000000..fa900c5 Binary files /dev/null and b/app/icon/episteme.iconset/icon_16x16.png differ diff --git a/app/icon/episteme.iconset/icon_16x16@2x.png b/app/icon/episteme.iconset/icon_16x16@2x.png new file mode 100644 index 0000000..9b203aa Binary files /dev/null and b/app/icon/episteme.iconset/icon_16x16@2x.png differ diff --git a/app/icon/episteme.iconset/icon_256x256.png b/app/icon/episteme.iconset/icon_256x256.png new file mode 100644 index 0000000..da0d47e Binary files /dev/null and b/app/icon/episteme.iconset/icon_256x256.png differ diff --git a/app/icon/episteme.iconset/icon_256x256@2x.png b/app/icon/episteme.iconset/icon_256x256@2x.png new file mode 100644 index 0000000..86140f2 Binary files /dev/null and b/app/icon/episteme.iconset/icon_256x256@2x.png differ diff --git a/app/icon/episteme.iconset/icon_32x32.png b/app/icon/episteme.iconset/icon_32x32.png new file mode 100644 index 0000000..9b203aa Binary files /dev/null and b/app/icon/episteme.iconset/icon_32x32.png differ diff --git a/app/icon/episteme.iconset/icon_32x32@2x.png b/app/icon/episteme.iconset/icon_32x32@2x.png new file mode 100644 index 0000000..d66fa48 Binary files /dev/null and b/app/icon/episteme.iconset/icon_32x32@2x.png differ diff --git a/app/icon/episteme.iconset/icon_512x512.png b/app/icon/episteme.iconset/icon_512x512.png new file mode 100644 index 0000000..86140f2 Binary files /dev/null and b/app/icon/episteme.iconset/icon_512x512.png differ diff --git a/app/icon/episteme.iconset/icon_512x512@2x.png b/app/icon/episteme.iconset/icon_512x512@2x.png new file mode 100644 index 0000000..4afb732 Binary files /dev/null and b/app/icon/episteme.iconset/icon_512x512@2x.png differ diff --git a/app/icon/make_icns.swift b/app/icon/make_icns.swift new file mode 100644 index 0000000..f81985a --- /dev/null +++ b/app/icon/make_icns.swift @@ -0,0 +1,112 @@ +// Episteme.app icon generator (E179). +// +// The icns is a REPRODUCIBLE artifact: this script draws the mark with +// CoreGraphics (no design binary without a source), writes the full iconset, +// and runs iconutil. Regenerate with: +// +// swift app/icon/make_icns.swift app/icon +// +// Mark: the Gate — two accent brackets facing each other with the operator +// (a filled dot) between them, on the console's dark panel color. Geometry +// is proportional to size, with stroke widths tuned so the gate survives +// 16 px. + +import AppKit +import CoreGraphics +import Foundation + +let bg = CGColor(red: 0x0B / 255.0, green: 0x0E / 255.0, blue: 0x14 / 255.0, alpha: 1) +let edge = CGColor(red: 0x1E / 255.0, green: 0x25 / 255.0, blue: 0x30 / 255.0, alpha: 1) +let accent = CGColor(red: 0x5E / 255.0, green: 0xC2 / 255.0, blue: 0xA6 / 255.0, alpha: 1) + +func drawIcon(size: Int) -> CGImage { + let s = CGFloat(size) + let ctx = CGContext( + data: nil, width: size, height: size, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + + // Background: rounded square (macOS icon-grid inset), subtle edge. + let inset = s * 0.06 + let rect = CGRect(x: inset, y: inset, width: s - 2 * inset, height: s - 2 * inset) + let radius = rect.width * 0.225 + let path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) + ctx.addPath(path) + ctx.setFillColor(bg) + ctx.fillPath() + if size >= 64 { + ctx.addPath(path) + ctx.setStrokeColor(edge) + ctx.setLineWidth(max(1, s * 0.008)) + ctx.strokePath() + } + + // The Gate: two brackets [ ] + the operator dot between them. + let stroke = max(1.5, s * 0.075) + let armLen = s * 0.14 + let bracketH = s * 0.46 + let leftX = s * 0.30 + let rightX = s * 0.70 + let midY = s * 0.5 + + ctx.setStrokeColor(accent) + ctx.setLineWidth(stroke) + ctx.setLineCap(.round) + ctx.setLineJoin(.round) + + // Left bracket [ + ctx.beginPath() + ctx.move(to: CGPoint(x: leftX + armLen, y: midY + bracketH / 2)) + ctx.addLine(to: CGPoint(x: leftX, y: midY + bracketH / 2)) + ctx.addLine(to: CGPoint(x: leftX, y: midY - bracketH / 2)) + ctx.addLine(to: CGPoint(x: leftX + armLen, y: midY - bracketH / 2)) + ctx.strokePath() + + // Right bracket ] + ctx.beginPath() + ctx.move(to: CGPoint(x: rightX - armLen, y: midY + bracketH / 2)) + ctx.addLine(to: CGPoint(x: rightX, y: midY + bracketH / 2)) + ctx.addLine(to: CGPoint(x: rightX, y: midY - bracketH / 2)) + ctx.addLine(to: CGPoint(x: rightX - armLen, y: midY - bracketH / 2)) + ctx.strokePath() + + // The operator: a filled dot at the gate's center. + let dotR = s * 0.065 + ctx.setFillColor(accent) + ctx.fillEllipse(in: CGRect(x: s / 2 - dotR, y: midY - dotR, width: dotR * 2, height: dotR * 2)) + + return ctx.makeImage()! +} + +func writePNG(_ image: CGImage, to url: URL) { + let rep = NSBitmapImageRep(cgImage: image) + let data = rep.representation(using: .png, properties: [:])! + try! data.write(to: url) +} + +let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "app/icon" +let iconset = URL(fileURLWithPath: outDir).appendingPathComponent("episteme.iconset") +try? FileManager.default.createDirectory(at: iconset, withIntermediateDirectories: true) + +// (filename base size, pixel size) pairs iconutil expects. +let entries: [(String, Int)] = [ + ("icon_16x16", 16), ("icon_16x16@2x", 32), + ("icon_32x32", 32), ("icon_32x32@2x", 64), + ("icon_128x128", 128), ("icon_128x128@2x", 256), + ("icon_256x256", 256), ("icon_256x256@2x", 512), + ("icon_512x512", 512), ("icon_512x512@2x", 1024), +] +for (name, px) in entries { + writePNG(drawIcon(size: px), to: iconset.appendingPathComponent("\(name).png")) +} + +let task = Process() +task.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil") +task.arguments = [ + "-c", "icns", iconset.path, + "-o", URL(fileURLWithPath: outDir).appendingPathComponent("episteme.icns").path, +] +try! task.run() +task.waitUntilExit() +print("wrote \(outDir)/episteme.icns (exit \(task.terminationStatus))") diff --git a/scripts/build_app_pkg.sh b/scripts/build_app_pkg.sh index 1a708a0..1c457cf 100755 --- a/scripts/build_app_pkg.sh +++ b/scripts/build_app_pkg.sh @@ -22,8 +22,11 @@ echo "==> cargo build --release (app shell v$VERSION)" echo "==> assembling $APP" rm -rf "$APP" -mkdir -p "$APP/Contents/MacOS" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$APP_DIR/target/release/episteme-app" "$APP/Contents/MacOS/episteme-app" +# Icon (E179): committed reproducible artifact; regenerate with +# swift app/icon/make_icns.swift app/icon +cp "$APP_DIR/icon/episteme.icns" "$APP/Contents/Resources/episteme.icns" cat > "$APP/Contents/Info.plist" << PLIST "$APP/Contents/Info.plist" << PLIST CFBundleVersion$VERSION CFBundleShortVersionString$VERSION CFBundleExecutableepisteme-app + CFBundleIconFileepisteme CFBundlePackageTypeAPPL LSMinimumSystemVersion12.0 NSHighResolutionCapable @@ -43,14 +47,63 @@ cat > "$APP/Contents/Info.plist" << PLIST PLIST -echo "==> pkgbuild (unsigned)" -pkgbuild \ - --component "$APP" \ +# AppleDouble hygiene (E176 review → E179 measured reality): xattr -cr + +# ditto --noextattr remove the REMOVABLE class (quarantine, FinderInfo, +# resource forks). com.apple.provenance is SIP-protected AND re-stamped by +# the OS on every local write, so local builds ALWAYS carry its 11-byte +# AppleDouble entries in the payload — unavoidable by construction, harmless +# metadata. Release artifacts come from CI (release-assets.yml) where this +# does not gate anything; do not chase the residue further. +xattr -cr "$APP" 2>/dev/null || true +STAGED="$DIST/.staging/Episteme.app" +rm -rf "$DIST/.staging" +mkdir -p "$DIST/.staging" +ditto --noextattr --norsrc --noacl "$APP" "$STAGED" + +# Signing tiers (E179): credential-ready, inert without the operator's cert. +# EPISTEME_SIGN_APP_ID "Developer ID Application: Name (TEAMID)" +# EPISTEME_SIGN_INSTALLER_ID "Developer ID Installer: Name (TEAMID)" +# EPISTEME_NOTARY_PROFILE notarytool keychain profile name +# FIRST CHECK once a real cert exists: hardened runtime vs the wry webview — +# if the window comes up blank under --options runtime, add an entitlements +# plist (com.apple.security.cs.allow-jit) here. +TIER="unsigned (personal use)" +if [ -n "${EPISTEME_SIGN_APP_ID:-}" ]; then + echo "==> codesign (hardened runtime, on the STAGED copy — signing must + follow attribute-stripping, never precede it)" + codesign --force --deep --options runtime --timestamp \ + --sign "$EPISTEME_SIGN_APP_ID" "$STAGED" + TIER="signed" +fi + +echo "==> pkgbuild" +RAW_PKG="$DIST/Episteme-unsigned.pkg" +COPYFILE_DISABLE=1 pkgbuild \ + --component "$STAGED" \ --install-location /Applications \ --identifier com.episteme.app \ --version "$VERSION" \ - "$DIST/Episteme.pkg" + "$RAW_PKG" + +if [ -n "${EPISTEME_SIGN_INSTALLER_ID:-}" ]; then + echo "==> productsign" + productsign --sign "$EPISTEME_SIGN_INSTALLER_ID" "$RAW_PKG" "$DIST/Episteme.pkg" + rm -f "$RAW_PKG" + TIER="signed installer" +else + mv "$RAW_PKG" "$DIST/Episteme.pkg" +fi + +if [ -n "${EPISTEME_NOTARY_PROFILE:-}" ]; then + echo "==> notarytool submit (waits for Apple)" + xcrun notarytool submit "$DIST/Episteme.pkg" \ + --keychain-profile "$EPISTEME_NOTARY_PROFILE" --wait + xcrun stapler staple "$DIST/Episteme.pkg" + TIER="notarized" +fi -echo "done:" +echo "done: tier=$TIER" ls -lh "$DIST/Episteme.pkg" -echo "install: open dist/Episteme.pkg (unsigned — right-click → Open on first run)" +if [ "$TIER" = "unsigned (personal use)" ]; then + echo "install: open dist/Episteme.pkg (unsigned — right-click → Open on first run)" +fi