Split the project into Lanework and Lanework Pro targets

Two app targets from one source tree — no build flags, no #if in
shared code: an edition difference is a file one target compiles and
the other does not. Base keeps everything it had (dev.rzen.indie.Kanban,
minimal entitlements, AppIcon); KanbanPro compiles the same sources
plus the reserved KanbanPro/ root (Git/, Remote/, Auth/ land with
pro-m1 — libgit2 deliberately not added yet), adds network-client and
its keychain group, and hand-writes its Info.plist with the UTI block
verbatim — base exports the type, Pro imports it, one format either
app opens. The unit-test sources compile twice, once per host, with
Pro's module aliased so 56 test files keep @testable import Kanban
unchanged; scheme Kanban stays the muscle-memory command and
LaneworkPro joins it. InertGitTests pins the base posture with bytes
and mtimes — a full editing session over boards carrying realistic
.git trees at root and nested in a card leaves all twelve entries
untouched, and moves and copies carry them verbatim.
scripts/verify-editions.sh proves the rest: 26 checks over signatures,
symbols, entitlements, identity, and the shared UTI, discounting
Xcode's test-host exceptions by name rather than silently.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 13:15:06 -04:00
parent 06ee59e24b
commit f37892c9a9
20 changed files with 898 additions and 5 deletions
@@ -0,0 +1 @@
{"colors":[{"idiom":"universal"}],"info":{"version":1,"author":"xcode"}}
@@ -0,0 +1,68 @@
{
"images": [
{
"filename": "icon-mac-16.png",
"idiom": "mac",
"size": "16x16",
"scale": "1x"
},
{
"filename": "[email protected]",
"idiom": "mac",
"size": "16x16",
"scale": "2x"
},
{
"filename": "icon-mac-32.png",
"idiom": "mac",
"size": "32x32",
"scale": "1x"
},
{
"filename": "[email protected]",
"idiom": "mac",
"size": "32x32",
"scale": "2x"
},
{
"filename": "icon-mac-128.png",
"idiom": "mac",
"size": "128x128",
"scale": "1x"
},
{
"filename": "[email protected]",
"idiom": "mac",
"size": "128x128",
"scale": "2x"
},
{
"filename": "icon-mac-256.png",
"idiom": "mac",
"size": "256x256",
"scale": "1x"
},
{
"filename": "[email protected]",
"idiom": "mac",
"size": "256x256",
"scale": "2x"
},
{
"filename": "icon-mac-512.png",
"idiom": "mac",
"size": "512x512",
"scale": "1x"
},
{
"filename": "[email protected]",
"idiom": "mac",
"size": "512x512",
"scale": "2x"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

+1
View File
@@ -0,0 +1 @@
{"info":{"version":1,"author":"xcode"}}
+36
View File
@@ -0,0 +1,36 @@
import Foundation
/// **Pro's source root.** This directory and this file's presence in it *is* the edition
/// split (12-editions.md Targets): the `Kanban` target compiles `Kanban/` alone, the
/// `KanbanPro` target compiles `Kanban/` **plus** `KanbanPro/`. Nothing in the shared tree is
/// `#if`-ed on an edition, because "the editions differ at the binary level" (12) is only true if
/// the difference is a file one target builds and the other does not.
///
/// **What lands here, and when.** Nothing yet this is a reserved root, split out ahead of the
/// code so pro-m1 is a set of new files rather than a second pass over the project structure:
///
/// - `KanbanPro/Git/` the git `HistoryProviding` implementation (06-history-undo.md: undo as
/// forward restore commits over HEAD's first-parent ancestry), init/adoption, branches, commit
/// identity, repository hygiene. Brings the libgit2 dependency with it; the base target must
/// never gain one, which `scripts/verify-editions.sh` checks against the signed binary.
/// - `KanbanPro/Remote/` pull/push, push-on-commit (07-sync-collab.md). The
/// `com.apple.security.network.client` entitlement Pro already carries is for this, and base's
/// lack of it is the other half of what the verification script checks.
/// - `KanbanPro/Auth/` remote credentials over the declared keychain access group
/// (07 Auth: Keychain, SSH, TOFU).
///
/// **What does not land here**: anything the edition matrix marks for all three editions. The
/// board experience, agent attribution and accessibility are shared code and stay in `Kanban/`.
///
/// The type itself is a namespace, not state the composition root that picks a
/// `HistoryProviding` per edition arrives with the providers it chooses between (base's native
/// stack in m8-native-undo, Pro's git stack in pro-m1), and inventing a seam here before either
/// exists would be guessing at its shape.
enum ProEdition {
/// The edition's own bundle identifier (12 Targets, ruled 2026-07-27: base keeps
/// `dev.rzen.indie.Kanban`, Pro mints its own). Stated as a constant so the value has one
/// home in Pro-only code; the running app reads its identity from `Bundle.main`, which is
/// already how the App Support directory is per-edition without a line of edition code
/// (`BoardRegistry`, `ClipboardStore`).
static let bundleIdentifier = "dev.rzen.indie.KanbanPro"
}
+107
View File
@@ -0,0 +1,107 @@
<?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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Lanework Pro</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Lanework Pro</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026 rzen</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<!-- The board format is one format, owned by 01-storage-format.md, with no edition-specific
keys (12-editions.md ▸ Distribution). Base **exports** `dev.rzen.indie.kanban-board`;
Pro **imports** the identical declaration verbatim — same identifier, same conformance,
same tag spec — so a board opens in either app and neither edition's copy can drift into
claiming a different format. The drag and clipboard payload types ride along for the same
reason: the two editions run the same drag code, so they must carry the same types. -->
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>dev.rzen.indie.kanban-board</string>
<key>UTTypeConformsTo</key>
<array>
<string>com.apple.package</string>
<string>public.directory</string>
</array>
<key>UTTypeDescription</key>
<string>Lanework Board</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<string>kanban</string>
</dict>
</dict>
<!-- The drag session payload types (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
Same-app transfer is the only real consumer — both boards are open in this app — but a
system drag session is what crosses window boundaries, draws the copy badge and gives
the full-size replica, and a system session needs a declared type to carry. -->
<dict>
<key>UTTypeIdentifier</key>
<string>dev.rzen.indie.kanban.cards</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>Lanework Cards</string>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>dev.rzen.indie.kanban.lanes</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>Lanework Lanes</string>
</dict>
<!-- The clipboard manifest (04-interactions.md ▸ Clipboard). Its own type rather than either
drag type above: a clipboard payload carries cards or lanes under one identifier, plus
the staging copyID and the fallback index.md text a drag has no use for. -->
<dict>
<key>UTTypeIdentifier</key>
<string>dev.rzen.indie.kanban.clipboard</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>Lanework Clipboard</string>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Lanework Board</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSItemContentTypes</key>
<array>
<string>dev.rzen.indie.kanban-board</string>
</array>
<key>LSTypeIsPackage</key>
<true/>
</dict>
</array>
</dict>
</plist>
+27
View File
@@ -0,0 +1,27 @@
<?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>
<!-- Base's three, verbatim (Kanban/Kanban.entitlements) — the board experience is identical
across editions, so its sandbox posture is too. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
<!-- Pro only (12-editions.md ▸ Targets): remotes. Base ships without this key at all, which
is what makes "base cannot talk to a network" a checkable property of the signed binary
rather than a promise about the source (scripts/verify-editions.sh). -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Pro only: remote auth credentials (07-sync-collab.md ▸ Auth — Keychain, SSH, TOFU).
A sandboxed app reaches its own keychain items without this key; the group is declared
so the credential store has one stable, named home from pro-m2 onward instead of an
implicit per-signature one that moves when the signing identity does. -->
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)dev.rzen.indie.KanbanPro</string>
</array>
</dict>
</plist>
+261
View File
@@ -0,0 +1,261 @@
import Foundation
import Testing
@testable import Kanban
/// **Base's inert-`.git` posture, stated against real bytes on disk** (12-editions.md Base and
/// `.git`): "any `.git` is inert opening a board that has one works normally, but the app never
/// reads history, never commits, never touches `.git` in any way."
///
/// Two halves of that posture are already pinned elsewhere and are referenced, not repeated:
/// `FolderWatcherTests` ".git filtering" proves the watcher ignores churn under a `.git` at any
/// depth, and `BoardLoaderStrayTests.strayFilesAndHiddenEntriesAreIgnoredWithoutWarning` proves a
/// `.git` at the board root loads as an ordinary stray with no warning. Both are *input* claims:
/// what the app does with events and entries it is handed.
///
/// This file states the **output** claim, which no existing test covers, and which is the one the
/// edition split has to be able to demonstrate: a full session of ordinary base editing leaves
/// every byte and every mtime under `.git` exactly as it found them. It is asserted the only way
/// that is worth anything by snapshotting the whole `.git` subtree from the filesystem before
/// the edits and re-reading it afterwards, never through the app's own read path
/// (`WriterTestSupport.swift`'s standing rule).
///
/// Note what "untouched" is worth as an mtime assertion specifically: bytes alone would pass even
/// if the app rewrote a file with identical content, and re-writing git's index with identical
/// bytes is exactly the kind of thing an accidental git dependency would do. The mtimes are the
/// assertion that nothing *opened for writing* down there at all.
// MARK: - Subtree snapshots
/// One entry under `.git`: its path relative to the board root, its bytes (nil for directories),
/// and its on-disk modification date. Directories carry an mtime too a file created or removed
/// inside a directory moves *that directory's* mtime, so including them catches a write the
/// per-file comparison would miss because the file it added is not in the "before" set.
private struct SubtreeEntry: Equatable, CustomStringConvertible {
let relativePath: String
let data: Data?
let modified: Date
var description: String {
"\(relativePath) (\(data.map { "\($0.count) bytes" } ?? "directory"), modified \(modified))"
}
}
/// Every entry beneath `root/subtree`, hidden entries included, sorted by path. `.git` is itself
/// hidden and everything inside it is reached through it, so `.skipsHiddenFiles` is deliberately
/// *not* passed a snapshot that skipped hidden files would snapshot nothing at all.
private func snapshotSubtree(_ root: URL, _ subtree: String) throws -> [SubtreeEntry] {
let base = root.appendingPathComponent(subtree, isDirectory: true)
let manager = FileManager.default
guard let walker = manager.enumerator(atPath: base.path) else {
Issue.record("could not enumerate \(subtree)")
return []
}
var entries: [SubtreeEntry] = []
for case let relative as String in walker {
let url = base.appendingPathComponent(relative)
let attributes = try manager.attributesOfItem(atPath: url.path)
guard let modified = attributes[.modificationDate] as? Date else {
Issue.record("no modification date for \(subtree)/\(relative)")
continue
}
let isDirectory = (attributes[.type] as? FileAttributeType) == .typeDirectory
entries.append(SubtreeEntry(
relativePath: "\(subtree)/\(relative)",
data: isDirectory ? nil : try Data(contentsOf: url),
modified: modified
))
}
// The directory the subtree hangs from, which the enumerator above does not yield.
let rootAttributes = try manager.attributesOfItem(atPath: base.path)
if let modified = rootAttributes[.modificationDate] as? Date {
entries.append(SubtreeEntry(relativePath: subtree, data: nil, modified: modified))
}
return entries.sorted { $0.relativePath < $1.relativePath }
}
// MARK: - Fixture construction
/// A `.git` directory with the shape a real one has a ref file, a binary index, a loose object
/// two levels down, a packed-refs file, and an empty `objects/pack` written under `parent`.
/// The bytes are sentinels: recognizable, non-UTF-8 in the index's case, and nothing the app has
/// any reader for.
@discardableResult
private func makeGitDirectory(in fixture: WriterFixture, under parent: String) throws -> String {
let prefix = parent.isEmpty ? ".git" : "\(parent)/.git"
try fixture.file("\(prefix)/HEAD", Data("ref: refs/heads/main\n".utf8))
try fixture.file("\(prefix)/config", Data("[core]\n\trepositoryformatversion = 0\n".utf8))
// Deliberately not valid UTF-8 a real `.git/index` is binary, and a byte comparison that
// only ever sees text is not testing the thing that matters.
try fixture.file("\(prefix)/index", Data([0x44, 0x49, 0x52, 0x43, 0x00, 0x00, 0x00, 0x02, 0xFF, 0xFE, 0x00, 0x01]))
try fixture.file("\(prefix)/objects/ab/cdef0123456789", Data([0x78, 0x01, 0xCB, 0xC8, 0x4F, 0x00, 0x00]))
try fixture.file("\(prefix)/refs/heads/main", Data("0123456789abcdef0123456789abcdef01234567\n".utf8))
try fixture.file("\(prefix)/packed-refs", Data("# pack-refs with: peeled fully-peeled sorted\n".utf8))
try FileManager.default.createDirectory(
at: fixture.url("\(prefix)/objects/pack"),
withIntermediateDirectories: true
)
return prefix
}
/// The board every test here edits: a root, two lanes, three cards, a `.git` at the board root
/// **and** a second one nested inside a card folder (12: "any `.git` is inert", not just the
/// root's own a repo-nested board or a clone dropped inside a card is the same promise).
private struct InertGitBoard {
let fixture: WriterFixture
let laneA: String
let laneB: String
let card1: String
let card2: String
/// The card carrying the nested repo the one the move test drags across lanes.
let cardWithRepo: String
init() throws {
fixture = try WriterFixture()
laneA = Ident.lane1
laneB = Ident.lane2
card1 = Ident.card1
card2 = Ident.card2
cardWithRepo = Ident.card3
try fixture.item("", Item.board)
try fixture.item(laneA, Item.rich(order: "1024", title: "Doing"))
try fixture.item(laneB, Item.rich(order: "2048", title: "Done"))
try fixture.item("\(laneA)/\(card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(laneA)/\(card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item("\(laneA)/\(cardWithRepo)", Item.rich(order: "3072", title: "Has a clone"))
try makeGitDirectory(in: fixture, under: "")
try makeGitDirectory(in: fixture, under: "\(laneA)/\(cardWithRepo)")
}
var root: URL { fixture.root }
func url(_ relativePath: String) -> URL { fixture.url(relativePath) }
func tearDown() { fixture.tearDown() }
}
// MARK: - The posture
struct BaseInertGitTests {
@Test("A full session of ordinary edits leaves every byte and mtime under .git untouched")
func anEditingSessionNeverTouchesGit() throws {
let board = try InertGitBoard()
defer { board.tearDown() }
let before = try snapshotSubtree(board.root, ".git")
#expect(before.count == 12, "the fixture's own shape — files, directories and the root")
// A session's worth of every write the base app can make, in one go. `.git` is at the
// board root, so anything that walks or renumbers the root's children walks past it.
try BoardWriter.updateIndex(inItemFolder: board.url(""), operation: .rename(title: nil)) { document in
document.set(FrontmatterKeys.title, to: .string("Renamed board"))
}
let newLane = try BoardWriter.createLane(inBoard: board.root, title: "Later")
let newCard = try BoardWriter.createCard(inLane: board.url(newLane.rawValue), title: "Fresh")
try BoardWriter.writeBody(inItemFolder: board.url("\(newLane.rawValue)/\(newCard.rawValue)"), body: "Body text.\n")
try BoardWriter.updateIndex(
inItemFolder: board.url("\(board.laneA)/\(board.card1)"),
operation: .style(title: nil)
) { document in
document.set(FrontmatterKeys.title, to: .string("Retitled"))
}
_ = try BoardWriter.moveItem(
at: board.url("\(board.laneA)/\(board.card2)"),
toParent: board.url(board.laneB),
sourceBoardRoot: board.root,
destinationBoardRoot: board.root,
order: nil
)
_ = try BoardWriter.copyItem(
at: board.url("\(board.laneA)/\(board.card1)"),
toParent: board.url(board.laneB),
order: nil,
stamps: .fork
)
try BoardWriter.deleteItem(at: board.url("\(board.laneA)/\(board.card1)"))
try BoardWriter.restoreItem(at: board.url("\(board.laneA)/\(board.card1)"))
// The renumbers are the pointed ones: both walk a parent's whole directory listing, which
// is where a `.git` entry actually gets looked at.
try BoardWriter.renumberVisibleChildren(of: board.root)
try BoardWriter.renumberVisibleChildren(of: board.url(board.laneA))
#expect(try snapshotSubtree(board.root, ".git") == before)
}
@Test("The board loads and renders normally with a .git at its root, without a warning")
func aGitBearingBoardLoadsLikeAnyOther() throws {
let board = try InertGitBoard()
defer { board.tearDown() }
let before = try snapshotSubtree(board.root, ".git")
let result = try BoardLoader.load(boardRoot: board.root)
#expect(result.model.lanes.map(\.id.rawValue) == [board.laneA, board.laneB])
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == [board.card1, board.card2, board.cardWithRepo])
#expect(result.warnings.isEmpty, "a `.git` is a stray like any other — strays are silent")
// A load is a read, but a read that opened `.git` would still move its atimes and would
// still be the app "touching" history; the mtime equality is what is checkable, and a
// loader that decided to *repair* something down there would break it.
#expect(try snapshotSubtree(board.root, ".git") == before)
}
@Test("A nested .git rides along a card move byte- and mtime-verbatim")
func aNestedRepositorySurvivesACardMove() throws {
let board = try InertGitBoard()
defer { board.tearDown() }
let nested = "\(board.laneA)/\(board.cardWithRepo)/.git"
let before = try snapshotSubtree(board.root, nested)
let result = try BoardWriter.moveItem(
at: board.url("\(board.laneA)/\(board.cardWithRepo)"),
toParent: board.url(board.laneB),
sourceBoardRoot: board.root,
destinationBoardRoot: board.root,
order: nil
)
// Same identity, new parent the folder moved whole, `.git` inside it.
let moved = "\(board.laneB)/\(result.id.rawValue)/.git"
let after = try snapshotSubtree(board.root, moved)
#expect(after.map(\.data) == before.map(\.data))
#expect(after.map(\.modified) == before.map(\.modified))
#expect(
after.map { $0.relativePath.replacingOccurrences(of: moved, with: nested) } == before.map(\.relativePath)
)
}
@Test("A copy of a card carrying a .git reproduces it verbatim and leaves the original alone")
func aCopyCarriesTheNestedRepositoryWithoutTouchingTheOriginal() throws {
let board = try InertGitBoard()
defer { board.tearDown() }
let nested = "\(board.laneA)/\(board.cardWithRepo)/.git"
let before = try snapshotSubtree(board.root, nested)
let copyID = try BoardWriter.copyItem(
at: board.url("\(board.laneA)/\(board.cardWithRepo)"),
toParent: board.url(board.laneB),
order: nil,
stamps: .fork
)
#expect(try snapshotSubtree(board.root, nested) == before, "the source is never touched")
// The copy's `.git` is the same tree with the same bytes (mtimes are a copy's to set
// `FileManager.copyItem` preserves them, but the promise being made here is about content
// and shape, not about a copy having been a rename).
let copied = try snapshotSubtree(board.root, "\(board.laneB)/\(copyID.rawValue)/.git")
#expect(copied.map(\.data) == before.map(\.data))
#expect(
copied.map { $0.relativePath.replacingOccurrences(of: "\(board.laneB)/\(copyID.rawValue)/.git", with: nested) }
== before.map(\.relativePath)
)
}
}
+5
View File
@@ -52,6 +52,11 @@ The Xcode project is generated — `project.yml` is the source of truth, not the
xcodegen generate
xcodebuild build -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS'
xcodebuild test -project Kanban.xcodeproj -scheme Kanban -destination 'platform=macOS,arch=arm64'
xcodebuild build -project Kanban.xcodeproj -scheme LaneworkPro -destination 'platform=macOS'
xcodebuild test -project Kanban.xcodeproj -scheme LaneworkPro -destination 'platform=macOS,arch=arm64'
scripts/verify-editions.sh
```
macOS 26+, Swift 6 (strict concurrency), SwiftUI, sandboxed. Internal codename `Kanban` (target, scheme, bundle id `dev.rzen.indie.Kanban`); the app ships under the display name **Lanework**.
Two app targets are built from one source tree (DESIGN/12-editions.md): **Lanework** compiles `Kanban/` alone, **Lanework Pro** (target `KanbanPro`, scheme `LaneworkPro`, bundle id `dev.rzen.indie.KanbanPro`) compiles `Kanban/` plus the Pro-only source root `KanbanPro/`. There is no edition flag and no `#if` in shared code — an edition difference is a file one target builds and the other does not — and the difference is checkable on the signed products: base carries no libgit2 and no network-client entitlement, which `scripts/verify-editions.sh` asserts against the built bundles. The unit suite is edition-agnostic and runs twice, once hosted by each app (`KanbanTests`, `KanbanProTests` — the same sources, bound to the Pro module by `-module-alias`). Both editions declare the same `dev.rzen.indie.kanban-board` UTI, so any board opens in either app.
+108 -3
View File
@@ -25,17 +25,29 @@ settings:
DEVELOPMENT_TEAM: ${APPLE_TEAM_ID}
ENABLE_USER_SCRIPT_SANDBOXING: NO
# The edition split (12-editions.md ▸ Targets). Two app targets compiled from one source tree:
# base builds `Kanban/` alone, Pro builds `Kanban/` *plus* `KanbanPro/`. There is no build flag
# and no `#if` in shared code — "why a real split and not feature flags" (12) is answered by the
# file list, so an edition difference is always a file one target compiles and the other does not.
# libgit2 arrives on the Pro target with pro-m1; the target, its entitlements and its source root
# exist now so that milestone lands without re-splitting anything.
#
# YAML anchors (`&name` / `*name`) carry the genuinely identical parts across the two apps and the
# two unit-test bundles. They are resolved by the YAML parser before XcodeGen sees the file, so
# they cost nothing at generate time and cannot drift the way copy-paste does.
targets:
# MARK: - Lanework (base)
Kanban:
type: application
platform: macOS
sources:
- Kanban
dependencies:
dependencies: &appDependencies
- package: Yams
- package: swift-markdown
product: Markdown
postBuildScripts:
postBuildScripts: &updateBuildInfo
- script: '"${SRCROOT}/../indie-skills/skills/app-versioning/scripts/update_build_info.sh"'
name: Update Build Info
shell: /bin/sh
@@ -45,6 +57,8 @@ targets:
- $(DWARF_DSYM_FOLDER_PATH)/$(DWARF_DSYM_FILE_NAME)/Contents/Info.plist
settings:
base:
# Base keeps the current identifier — it is the app that ships first, so nothing
# re-wires (12 ▸ Targets, ruled 2026-07-27).
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.Kanban
MARKETING_VERSION: "2.0"
INFOPLIST_FILE: Kanban/Info.plist
@@ -53,10 +67,57 @@ targets:
SWIFT_STRICT_CONCURRENCY: complete
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# MARK: - Lanework Pro
KanbanPro:
type: application
platform: macOS
sources:
# The shared tree, minus the three files that are base's *identity* rather than its code.
# (XcodeGen already keeps `Info.plist` and `.entitlements` out of build phases; excluding
# them here keeps them out of the Pro target's file list too, so there is exactly one
# plist and one entitlements file visible per app.)
- path: Kanban
excludes:
- Info.plist
- Kanban.entitlements
- Assets.xcassets
- Assets.xcassets/**
# Pro's own source root — reserved now, filled by pro-m1. See KanbanPro/Edition/ProEdition.swift.
- path: KanbanPro
excludes:
- Info.plist
- KanbanPro.entitlements
dependencies: *appDependencies
postBuildScripts: *updateBuildInfo
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.KanbanPro
MARKETING_VERSION: "2.0"
INFOPLIST_FILE: KanbanPro/Info.plist
CODE_SIGN_ENTITLEMENTS: KanbanPro/KanbanPro.entitlements
GENERATE_INFOPLIST_FILE: false
SWIFT_STRICT_CONCURRENCY: complete
ASSETCATALOG_COMPILER_APPICON_NAME: AppIconPro
# Spelled out because the whole shared-test-sources arrangement below hangs on it: the two
# apps must have *different* Swift module names, since both emit `<module>.swiftmodule`
# into the same `Build/Products/<config>/` and equal names would have one app silently
# overwrite the other's testable interface.
PRODUCT_MODULE_NAME: KanbanPro
# MARK: - Unit tests
#
# One suite, run twice — once hosted by each app. Every test in `KanbanTests/` is
# edition-agnostic (it exercises the storage format, the live store and the UI model, none of
# which differ between editions), so duplicating the *sources* would be duplicating the thing
# that is identical; what is worth running twice is the suite against each *binary*, which is
# what these two targets do. The Pro bundle compiles the very same files — it is the same
# `sources` list, by anchor — against `KanbanPro`.
KanbanTests:
type: bundle.unit-test
platform: macOS
sources:
sources: &unitTestSources
- KanbanTests
- path: Fixtures
type: folder
@@ -69,6 +130,30 @@ targets:
GENERATE_INFOPLIST_FILE: true
SWIFT_STRICT_CONCURRENCY: complete
KanbanProTests:
type: bundle.unit-test
platform: macOS
sources: *unitTestSources
dependencies:
- target: KanbanPro
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.KanbanProTests
GENERATE_INFOPLIST_FILE: true
SWIFT_STRICT_CONCURRENCY: complete
# The shared sources say `@testable import Kanban`, and they should: they are testing the
# app, not an edition. `-module-alias` (Swift 5.7+) resolves that import to the Pro
# module for this bundle only — the supported way to bind one source name to a different
# module, and it keeps the 56 test files edition-blind instead of littering them with
# conditional imports.
OTHER_SWIFT_FLAGS: -module-alias Kanban=KanbanPro
# MARK: - UI tests
#
# Base-only, deliberately: the single test here is `testAppLaunches`, and a second copy would
# double the slowest, most environment-dependent part of the suite to re-assert something the
# Pro unit bundle already proves (it launches the Pro app as its test host on every run).
KanbanUITests:
type: bundle.ui-testing
platform: macOS
@@ -83,6 +168,8 @@ targets:
SWIFT_STRICT_CONCURRENCY: complete
schemes:
# `Kanban` is unchanged, on purpose: `xcodebuild … -scheme Kanban` is the established command
# for this repo and base is the app it has always meant.
Kanban:
build:
targets:
@@ -101,3 +188,21 @@ schemes:
config: Debug
archive:
config: Release
LaneworkPro:
build:
targets:
KanbanPro: all
run:
config: Debug
test:
config: Debug
gatherCoverageData: false
targets:
- KanbanProTests
profile:
config: Release
analyze:
config: Debug
archive:
config: Release
+282
View File
@@ -0,0 +1,282 @@
#!/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; 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)$'
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.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
# ------------------------------------------------------------------------------ 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"