#!/bin/bash # # verify-editions.sh — the edition split, checked against the *built binaries*. # # 12-editions.md ▸ Distribution: "the editions differ at the binary level — base ships without # libgit2 and without the network-client entitlement". That is a claim about a signed app bundle, # so it cannot be a unit test: a test runs inside the app and can only report what the app's own # code believes. This script reads the products instead — the signature's entitlements, the Mach-O # load commands and symbol table, and the bundled Info.plist — and is the check the release # pipeline should run before either edition is submitted. # # Usage: # scripts/verify-editions.sh # resolves the Debug products via xcodebuild # scripts/verify-editions.sh # CONFIGURATION=Release scripts/verify-editions.sh # # Exits non-zero if any check fails. set -uo pipefail PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CONFIGURATION="${CONFIGURATION:-Debug}" failures=0 checks=0 pass() { checks=$((checks + 1)); printf ' ok %s\n' "$1"; } fail() { checks=$((checks + 1)); failures=$((failures + 1)); printf ' FAIL %s\n' "$1"; } note() { printf ' %s\n' "$1"; } head2() { printf '\n%s\n' "$1"; } # ---------------------------------------------------------------------------- locating the apps if [ "$#" -ge 2 ]; then BASE_APP="$1" PRO_APP="$2" else products_dir="$( xcodebuild -project "$PROJECT_DIR/Kanban.xcodeproj" -scheme Kanban \ -configuration "$CONFIGURATION" -destination 'platform=macOS' \ -showBuildSettings 2>/dev/null | awk -F' = ' '/ BUILT_PRODUCTS_DIR = /{print $2; exit}' )" if [ -z "${products_dir:-}" ]; then echo "could not resolve BUILT_PRODUCTS_DIR — build first, or pass the two .app paths" >&2 exit 2 fi BASE_APP="$products_dir/Kanban.app" PRO_APP="$products_dir/KanbanPro.app" fi for app in "$BASE_APP" "$PRO_APP"; do if [ ! -d "$app" ]; then echo "no app bundle at $app — build both schemes first (Kanban, LaneworkPro)" >&2 exit 2 fi done BASE_BIN="$BASE_APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$BASE_APP/Contents/Info.plist")" PRO_BIN="$PRO_APP/Contents/MacOS/$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$PRO_APP/Contents/Info.plist")" echo "Lanework edition verification ($CONFIGURATION)" note "base: $BASE_APP" note "pro: $PRO_APP" # ------------------------------------------------------------------------------------ helpers # The signature's entitlements as an XML plist. Reads the *signature*, not the source # `.entitlements` file — the whole point is that the shipped binary is what gets checked. entitlements_of() { codesign -d --entitlements - --xml "$1" 2>/dev/null } # The entitlement *keys* the signature actually carries, one per line. Extracted from the XML # rather than queried with PlistBuddy because PlistBuddy needs a seekable file, and writing the # signature out to a temp file only to read one key back is a step this does not need. entitlement_keys() { entitlements_of "$1" | grep -o '[^<]*' | sed 's/<[^>]*>//g' } has_entitlement() { entitlement_keys "$1" | grep -qxF "$2" } plist_value() { /usr/libexec/PlistBuddy -c "Print :$2" "$1/Contents/Info.plist" 2>/dev/null } # Every dylib the binary and its embedded frameworks link, plus every undefined symbol — the two # places a libgit2 dependency can hide (dynamically linked, or statically linked and visible only # as defined symbols). linked_libraries() { local app="$1" find "$app/Contents/MacOS" "$app/Contents/Frameworks" -type f -perm -u+x 2>/dev/null | while read -r file; do otool -L "$file" 2>/dev/null; done } git_symbols() { local bin="$1" # `git_` is libgit2's exported prefix (git_repository_open, git_commit_create, …). Anchored so # ordinary Swift symbols with "git" inside a longer name do not register. nm -a "$bin" 2>/dev/null | awk '{print $NF}' | grep -E '^_?git_[a-z_]+$' | sort -u } # ------------------------------------------------------- base: no libgit2, no network, no keychain head2 "Base — no git machinery" base_libs="$(linked_libraries "$BASE_APP")" if printf '%s' "$base_libs" | grep -qiE 'git2|libgit'; then fail "base links a libgit2 dylib" printf '%s' "$base_libs" | grep -iE 'git2|libgit' | sed 's/^/ /' else pass "base links no libgit2 dylib (otool -L over the executable and embedded frameworks)" fi base_git_syms="$(git_symbols "$BASE_BIN")" if [ -n "$base_git_syms" ]; then fail "base's symbol table contains libgit2 symbols" printf '%s\n' "$base_git_syms" | sed 's/^/ /' else pass "base's symbol table contains no libgit2 symbols (nm -a, /^_?git_[a-z_]+\$/)" fi head2 "Base — entitlements" if entitlements_of "$BASE_APP" | grep -q ''; then pass "base is signed with an entitlements dictionary" else fail "base has no readable entitlements — is it signed?" fi if has_entitlement "$BASE_APP" "com.apple.security.network.client"; then fail "base carries com.apple.security.network.client" else pass "base does not carry com.apple.security.network.client" fi if has_entitlement "$BASE_APP" "keychain-access-groups"; then fail "base carries keychain-access-groups" else pass "base does not carry keychain-access-groups" fi for key in com.apple.security.app-sandbox \ com.apple.security.files.user-selected.read-write \ com.apple.security.files.bookmarks.app-scope \ com.apple.security.application-groups; do if has_entitlement "$BASE_APP" "$key"; then pass "base carries $key" else fail "base is missing $key" fi done # Anything beyond the three above would be entitlement creep — base's posture is "current minimal # entitlements exactly" (12 ▸ Targets). Four families of key are the toolchain's, not the app's, # and are discounted rather than counted: # # get-task-allow Debug builds, so the debugger can attach. # application-identifier injected by signing when a profile is involved. # developer.team-identifier likewise. # temporary-exception.{files,mach} injected into a *test host* so the sandboxed app can load # XCTest. Present after `xcodebuild test`, absent from an # archive — which is why they are named out loud below rather # than quietly filtered. toolchain_injected='^(com\.apple\.security\.get-task-allow|com\.apple\.application-identifier|com\.apple\.developer\.team-identifier|com\.apple\.security\.temporary-exception\.[a-z.-]*)$' settled_minimum='^(com\.apple\.security\.app-sandbox|com\.apple\.security\.files\.user-selected\.read-write|com\.apple\.security\.files\.bookmarks\.app-scope|com\.apple\.security\.application-groups)$' base_extra="$(entitlement_keys "$BASE_APP" | grep -vE "$settled_minimum" | grep -vE "$toolchain_injected")" if [ -n "$base_extra" ]; then fail "base carries entitlements beyond the settled minimum" printf '%s\n' "$base_extra" | sed 's/^/ /' else pass "base carries no entitlements beyond the settled minimum" fi base_test_exceptions="$(entitlement_keys "$BASE_APP" | grep -E '^com\.apple\.security\.temporary-exception\.')" if [ -n "$base_test_exceptions" ]; then note "(built for testing — Xcode's own test-host exceptions are present and discounted:)" printf '%s\n' "$base_test_exceptions" | sed 's/^/ /' fi # ------------------------------------------------------------------------- pro: the added grants head2 "Pro — entitlements" for key in com.apple.security.app-sandbox \ com.apple.security.files.user-selected.read-write \ com.apple.security.files.bookmarks.app-scope \ com.apple.security.application-groups \ com.apple.security.network.client \ keychain-access-groups; do if has_entitlement "$PRO_APP" "$key"; then pass "pro carries $key" else fail "pro is missing $key" fi done # ------------------------------------------------------------------------------ the App Group # # 12-editions.md ▸ Distribution (ruled 2026-07-29): "every edition declares the family group — # `group.dev.rzen.indie.Kanban` — and the board registry with its Application Support peers homes in # the group container **from day one**". The upgrade story is exactly this string matching across the # two signatures: an edition declaring a group of its own would share nothing while looking as though # it did, and nothing in the app could detect the difference — the container would simply be empty. head2 "The App Group — one container, either app" # The group ids the signature declares, one per line. Read from the signature rather than the source # `.entitlements` for the reason every check here does: the shipped binary is the claim. # # `sed` over the whole stream rather than a line-oriented pass, because `codesign --xml` emits the # entire plist on **one line** — which is also why `entitlement_keys` above reaches for `grep -o` # instead of PlistBuddy. The two substitutions trim everything before this key's `` and # everything after its close, leaving only its own ``s to unwrap. app_groups() { entitlements_of "$1" | sed -e 's/.*com\.apple\.security\.application-groups<\/key>//' \ -e 's/<\/array>.*//' | grep -o '[^<]*' | sed 's/<[^>]*>//g' } expected_group="group.dev.rzen.indie.Kanban" base_groups="$(app_groups "$BASE_APP")" pro_groups="$(app_groups "$PRO_APP")" for app_label in "base:$base_groups" "pro:$pro_groups"; do label="${app_label%%:*}" groups="${app_label#*:}" if [ "$groups" = "$expected_group" ]; then pass "$label declares exactly $expected_group" else fail "$label declares '$groups', expected '$expected_group'" fi done # ------------------------------------------------------------------------------ bundle identity head2 "Bundle identity" check_value() { local label="$1" actual="$2" expected="$3" if [ "$actual" = "$expected" ]; then pass "$label = $expected" else fail "$label = '$actual', expected '$expected'" fi } check_value "base CFBundleIdentifier" "$(plist_value "$BASE_APP" CFBundleIdentifier)" "dev.rzen.indie.Kanban" check_value "pro CFBundleIdentifier" "$(plist_value "$PRO_APP" CFBundleIdentifier)" "dev.rzen.indie.KanbanPro" check_value "base CFBundleDisplayName" "$(plist_value "$BASE_APP" CFBundleDisplayName)" "Lanework" check_value "pro CFBundleDisplayName" "$(plist_value "$PRO_APP" CFBundleDisplayName)" "Lanework Pro" check_value "base CFBundleShortVersionString" "$(plist_value "$BASE_APP" CFBundleShortVersionString)" "2.0" check_value "pro CFBundleShortVersionString" "$(plist_value "$PRO_APP" CFBundleShortVersionString)" "2.0" base_icon="$(plist_value "$BASE_APP" CFBundleIconName)" pro_icon="$(plist_value "$PRO_APP" CFBundleIconName)" if [ -n "$base_icon" ] && [ -n "$pro_icon" ] && [ "$base_icon" != "$pro_icon" ]; then pass "distinct app icon assets ($base_icon / $pro_icon)" else fail "app icon assets are not distinct (base '$base_icon', pro '$pro_icon')" fi # ------------------------------------------------------------------------------ the shared UTI head2 "The board UTI — one format, either app" # Base exports `dev.rzen.indie.kanban-board`, Pro imports the identical declaration (12 ▸ # Distribution: "both editions declare the same .kanban package UTI verbatim … base remains the # exporter"). Compared as normalized plist fragments so a drifted conformance list or tag spec # fails here rather than in the field, where it would show up as a board that opens in one app and # not the other. uti_declaration() { local app="$1" key="$2" /usr/libexec/PlistBuddy -x -c "Print :$key" "$app/Contents/Info.plist" 2>/dev/null | tr -d ' \t' | grep -v '^$' } base_uti="$(uti_declaration "$BASE_APP" UTExportedTypeDeclarations)" pro_uti="$(uti_declaration "$PRO_APP" UTImportedTypeDeclarations)" if [ -n "$base_uti" ]; then pass "base exports its type declarations" else fail "base has no UTExportedTypeDeclarations" fi if [ -n "$pro_uti" ]; then pass "pro imports its type declarations" else fail "pro has no UTImportedTypeDeclarations" fi if [ "$base_uti" = "$pro_uti" ]; then pass "the two declarations are verbatim identical" else fail "the two declarations differ" diff <(printf '%s\n' "$base_uti") <(printf '%s\n' "$pro_uti") | sed 's/^/ /' fi for app_label in "base:$BASE_APP" "pro:$PRO_APP"; do label="${app_label%%:*}" app="${app_label#*:}" if /usr/libexec/PlistBuddy -c 'Print :CFBundleDocumentTypes:0:LSItemContentTypes:0' "$app/Contents/Info.plist" 2>/dev/null | grep -q '^dev\.rzen\.indie\.kanban-board$'; then pass "$label opens dev.rzen.indie.kanban-board" else fail "$label does not claim dev.rzen.indie.kanban-board as a document type" fi done # ------------------------------------------------------------------------------------- verdict head2 "$((checks - failures))/$checks checks passed" if [ "$failures" -gt 0 ]; then echo "EDITION VERIFICATION FAILED" exit 1 fi echo "EDITION VERIFICATION PASSED"