Every edition declares group.dev.rzen.indie.Kanban and homes its
app-side state there from day one (12-editions.md ruling 2026-07-29):
- AppGroup namespace: container resolution with per-edition fallback
when unprovisioned, shared UserDefaults suite, edition identity, and
a unit-test-host redirect (the test host IS the app — its launch
sweep and recents refresh must not touch the real shared container).
- BoardRecord: bookmark/isOpenNow replaced by per-edition grants and
openNow keyed by bundle id; hand-written Codable keeps legacy keys
decoding (adopted in memory as the running edition's slots, upgraded
on first save); every other field stays common.
- RecentBoard gains needsReopen: no grant of ours but somebody's —
first click runs an open panel pre-anchored at the recorded path,
prompt "Grant"; recordOpen mints this edition's slot onto the
matched shared record (path fallback only after identity fails and
only against records holding no grant of ours, so re-granting never
forks the record).
- Cross-edition freshness: stat-cheap mtime+size stamp re-reads the
registry when the sibling edition wrote it, so one edition's save
never erases the other's records wholesale.
- restorables() filters on this edition's open-now flags; the board
popover gains BoardEditionPresence ("Also open in Lanework Pro"),
pid-liveness-checked so crash residue never lies.
- Clipboard staging store moves to the group container; the sweep
claims doomed trees by atomic rename into .sweeping/ then deletes,
so the sibling's concurrent sweep is a non-event.
- Template store re-homed to the group container per the 09-templates
re-ruling; scalars (quick-style recents, window size) move to the
shared suite.
- verify-editions.sh: 30 checks (each edition carries exactly the
family group). No pathfinder 1.x migrator: 1.x predates the
registry; state starts fresh in the group container.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
324 lines
13 KiB
Bash
Executable File
324 lines
13 KiB
Bash
Executable File
#!/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 <base.app> <pro.app>
|
|
# 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 '<key>[^<]*</key>' | 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 '<key>'; 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 `<array>` and
|
|
# everything after its close, leaving only its own `<string>`s to unwrap.
|
|
app_groups() {
|
|
entitlements_of "$1" |
|
|
sed -e 's/.*<key>com\.apple\.security\.application-groups<\/key><array>//' \
|
|
-e 's/<\/array>.*//' |
|
|
grep -o '<string>[^<]*</string>' |
|
|
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"
|